Merge pull request #775 from iflytek/fix/cli-namespace-sync-server-route

fix(cli): add namespace sync manifest endpoint
This commit is contained in:
XiaoSeS 2026-08-29 15:16:27 +08:00 • committed by GitHub
commit 337d3d973d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 457 additions and 15 deletions

View file

@ -0,0 +1,69 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* CLI namespace-scoped read endpoints.
*/
@RestController
@RequestMapping("/api/cli/v1/namespaces")
public class CliNamespaceController extends BaseApiController {
private static final int DEFAULT_LIMIT = 100;
private static final int MAX_LIMIT = 100;
private final CliSkillAppService cliSkillAppService;
public CliNamespaceController(CliSkillAppService cliSkillAppService, ApiResponseFactory responseFactory) {
super(responseFactory);
this.cliSkillAppService = cliSkillAppService;
}
@GetMapping("/{namespace}/skills")
@RateLimit(category = "skills", authenticated = 60, anonymous = 0)
public ApiResponse<CliNamespaceSyncResponse> listSkills(
@PathVariable String namespace,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "100") int limit,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
int page = parseCursor(cursor);
int normalizedLimit = Math.min(Math.max(limit, 1), MAX_LIMIT);
return ok("response.success.read", cliSkillAppService.listNamespaceSkills(
namespace,
page,
normalizedLimit,
userId,
userNsRoles
));
}
private int parseCursor(String cursor) {
if (cursor == null || cursor.isBlank()) {
return 0;
}
try {
int page = Integer.parseInt(cursor);
if (page < 0) {
throw new NumberFormatException("negative cursor");
}
return page;
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("cursor must be a non-negative page number", ex);
}
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto.cli;
import java.time.Instant;
/**
* Installable skill metadata used by the CLI namespace workspace synchronizer.
*/
public record CliNamespaceSyncItemResponse(
String namespace,
String slug,
String version,
Long versionId,
String fingerprint,
Instant updatedAt,
String visibility,
String downloadUrl
) {}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto.cli;
import java.util.List;
/**
* Cursor-shaped response consumed by the CLI namespace workspace synchronizer.
*/
public record CliNamespaceSyncResponse(
List<CliNamespaceSyncItemResponse> items,
String nextCursor
) {}

View file

@ -59,7 +59,8 @@ public class AuthContextFilter extends OncePerRequestFilter {
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(requestPath(request))) {
if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(
RouteSecurityPolicyRegistry.requestPath(request))) {
filterChain.doFilter(request, response);
return;
}
@ -88,14 +89,6 @@ public class AuthContextFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private String requestPath(HttpServletRequest request) {
String servletPath = request.getServletPath();
if (servletPath != null && !servletPath.isBlank()) {
return servletPath;
}
return request.getRequestURI();
}
private boolean isInactiveUser(String userId) {
if (!enforceActiveUserCheck) {
return false;

View file

@ -1,14 +1,16 @@
package com.iflytek.skillhub.service.cli;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.dto.cli.CliDeleteResponse;
import com.iflytek.skillhub.dto.cli.CliDryRunResponse;
import com.iflytek.skillhub.dto.cli.CliNamespaceSyncItemResponse;
import com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse;
import com.iflytek.skillhub.dto.cli.CliPublishResponse;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
@ -16,6 +18,8 @@ import com.iflytek.skillhub.service.SkillDeleteAppService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@ -82,6 +86,42 @@ public class CliSkillAppService {
);
}
public CliNamespaceSyncResponse listNamespaceSkills(
String namespace,
int page,
int limit,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
Page<Skill> skills = skillQueryService.listInstallableSkillsByNamespace(
namespace, userId, roles, PageRequest.of(page, limit));
List<CliNamespaceSyncItemResponse> items = skills.getContent().stream()
.map(skill -> toNamespaceSyncItem(skill, namespace, userId, roles))
.toList();
String nextCursor = skills.hasNext() ? String.valueOf(page + 1) : null;
return new CliNamespaceSyncResponse(items, nextCursor);
}
private CliNamespaceSyncItemResponse toNamespaceSyncItem(
Skill skill,
String namespace,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
namespace, skill.getSlug(), null, null, null, userId, userNsRoles);
return new CliNamespaceSyncItemResponse(
namespace,
skill.getSlug(),
resolved.version(),
resolved.versionId(),
resolved.fingerprint(),
skill.getUpdatedAt(),
skill.getVisibility().name(),
resolved.downloadUrl()
);
}
public ResponseEntity<InputStreamResource> downloadLatest(String namespace, String slug, HttpServletRequest request) {
String userId = (String) request.getAttribute("userId");
@SuppressWarnings("unchecked")

View file

@ -132,6 +132,38 @@ class CliSkillControllerTest {
verify(apiTokenService).touchLastUsed(token);
}
@Test
void namespaceSyncReturnsCursorResponseForValidBearer() throws Exception {
ApiToken token = new ApiToken("sync-user", "cli", "sk_test", "hash", "[]");
UserAccount user = new UserAccount("sync-user", "Sync User", "sync@example.com", "");
var response = new com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse(
List.of(new com.iflytek.skillhub.dto.cli.CliNamespaceSyncItemResponse(
"team-a", "demo", "1.0.0", 42L, "sha256:fingerprint",
java.time.Instant.parse("2026-08-20T00:00:00Z"), "NAMESPACE_ONLY",
"/api/v1/skills/team-a/demo/versions/1.0.0/download"
)),
"2"
);
given(apiTokenService.validateToken("sync-token")).willReturn(Optional.of(token));
given(userAccountRepository.findById("sync-user")).willReturn(Optional.of(user));
given(userRoleBindingRepository.findByUserId("sync-user")).willReturn(List.of());
given(cliSkillAppService.listNamespaceSkills("team-a", 1, 25, "sync-user", Map.of()))
.willReturn(response);
mockMvc.perform(get("/api/cli/v1/namespaces/team-a/skills")
.param("cursor", "1")
.param("limit", "25")
.header(HttpHeaders.AUTHORIZATION, "Bearer sync-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].namespace").value("team-a"))
.andExpect(jsonPath("$.data.items[0].slug").value("demo"))
.andExpect(jsonPath("$.data.items[0].version").value("1.0.0"))
.andExpect(jsonPath("$.data.nextCursor").value("2"));
verify(cliSkillAppService).listNamespaceSkills("team-a", 1, 25, "sync-user", Map.of());
}
@Test
void resolveReturnsCliResolveResponse() throws Exception {
given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn(

View file

@ -32,6 +32,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import java.math.BigDecimal;
import java.time.Instant;
@ -222,6 +224,30 @@ class CliSkillAppServiceTest {
assertEquals("abc123", response.fingerprint());
}
@Test
void listNamespaceSkills_mapsInstallableVersionsToCliManifest() {
Skill skill = new Skill(7L, "demo", "user-1", SkillVisibility.NAMESPACE_ONLY);
setField(skill, "id", 10L);
skill.setLatestVersionId(42L);
skill.setUpdatedBy("user-1");
given(skillQueryService.listInstallableSkillsByNamespace(
eq("team-a"), eq("user-1"), eq(Map.of()), eq(PageRequest.of(0, 100))))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 100), 1));
given(skillQueryService.resolveVersion("team-a", "demo", null, null, null, "user-1", Map.of()))
.willReturn(new SkillQueryService.ResolvedVersionDTO(
10L, "team-a", "demo", "1.0.0", 42L, "sha256:fingerprint", null,
"/api/v1/skills/team-a/demo/versions/1.0.0/download"));
var result = service.listNamespaceSkills("team-a", 0, 100, "user-1", Map.of());
assertEquals(1, result.items().size());
assertEquals("demo", result.items().getFirst().slug());
assertEquals("1.0.0", result.items().getFirst().version());
assertEquals("sha256:fingerprint", result.items().getFirst().fingerprint());
assertNull(result.nextCursor());
}
@Test
void deleteRemote_delegatesToDeleteAppService() {
var auditContext = new AuditRequestContext("127.0.0.1", "CLI/1.0");

View file

@ -103,7 +103,7 @@ public class SecurityConfig {
var csrfHandler = new CsrfTokenRequestAttributeHandler();
csrfHandler.setCsrfRequestAttributeName(null);
RequestMatcher csrfIgnoreMatcher = request -> {
String path = request.getRequestURI();
String path = RouteSecurityPolicyRegistry.requestPath(request);
String authorization = request.getHeader("Authorization");
return routeSecurityPolicyRegistry.shouldIgnoreCsrf(request.getMethod(), path, authorization, hasSessionCookie(request));
};

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.policy;
import java.util.List;
import java.util.Set;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpMethod;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
@ -83,6 +84,7 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"),
RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/cli/v1/auth/whoami"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/cli/v1/namespaces/*/skills"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/search"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/resolve"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),
@ -133,6 +135,7 @@ public class RouteSecurityPolicyRegistry {
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/auth/whoami"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/namespaces/*/skills"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/search"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/resolve"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),
@ -182,6 +185,22 @@ public class RouteSecurityPolicyRegistry {
return (method == null ? "ANY" : method.name()) + " " + pattern;
}
/**
* Returns the application-relative request path used by security policies.
*
* <p>When a reverse proxy supplies {@code X-Forwarded-Prefix}, Spring exposes
* that external prefix through {@code getRequestURI()} while keeping the
* application route in {@code getServletPath()}. Security filters must match
* the latter or bearer authentication is skipped for sub-path deployments.</p>
*/
public static String requestPath(HttpServletRequest request) {
String servletPath = request.getServletPath();
if (servletPath != null && !servletPath.isBlank()) {
return servletPath;
}
return request.getRequestURI();
}
public ApiTokenAuthorizationDecision authorizeApiToken(String method, String path, Set<String> tokenScopes) {
if (!isApiPath(path)) {
return ApiTokenAuthorizationDecision.allow();

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.auth.token;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
@ -114,7 +115,7 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
String path = RouteSecurityPolicyRegistry.requestPath(request);
return !(path.startsWith("/api/v1/")
|| path.startsWith("/api/web/")
|| path.startsWith("/api/cli/"));

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.auth.token;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@ -47,9 +48,10 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
.map(authority -> authority.substring("SCOPE_".length()))
.collect(Collectors.toSet());
String requestPath = RouteSecurityPolicyRegistry.requestPath(request);
ApiTokenScopeService.AuthorizationDecision decision = apiTokenScopeService.authorize(
request.getMethod(),
request.getRequestURI(),
requestPath,
tokenScopes
);
@ -60,13 +62,13 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
ApiTokenAccessDeniedException exception = decision.requiredScope() != null
? ApiTokenAccessDeniedException.missingScope(decision.requiredScope())
: ApiTokenAccessDeniedException.unsupportedEndpoint(request.getRequestURI());
: ApiTokenAccessDeniedException.unsupportedEndpoint(requestPath);
accessDeniedHandler.handle(request, response, exception);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
String path = RouteSecurityPolicyRegistry.requestPath(request);
return path == null || (!path.startsWith("/api/v1/")
&& !path.startsWith("/api/web/")
&& !path.startsWith("/api/cli/"));

View file

@ -9,6 +9,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
class RouteSecurityPolicyRegistryTest {
@ -98,6 +99,7 @@ class RouteSecurityPolicyRegistryTest {
@Test
void apiTokenPolicySupportsNativeCliRoutes() {
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/auth/whoami", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/namespaces/team-a/skills", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/search", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/global/demo/resolve", Set.of()).allowed());
assertFalse(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish", Set.of()).allowed());
@ -107,6 +109,16 @@ class RouteSecurityPolicyRegistryTest {
assertTrue(registry.authorizeApiToken("DELETE", "/api/cli/v1/skills/global/demo", Set.of("skill:delete")).allowed());
}
@Test
void routeAuthorizationRequiresAuthenticationForNativeCliNamespaceSync() {
boolean matched = registry.authorizationPolicies().stream()
.anyMatch(policy -> policy.method() == HttpMethod.GET
&& "/api/cli/v1/namespaces/*/skills".equals(policy.pattern())
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
assertTrue(matched);
}
@Test
void routeAuthorizationProtectsNativeCliRemoteDeleteByAuthenticationNotSuperAdminRole() {
boolean matched = registry.authorizationPolicies().stream()
@ -136,6 +148,18 @@ class RouteSecurityPolicyRegistryTest {
assertFalse(registry.shouldProjectRequestContext("/assets/index.css"));
}
@Test
void requestPathUsesApplicationRouteBehindForwardedPrefix() {
MockHttpServletRequest request = new MockHttpServletRequest(
"GET",
"/skillhub/api/cli/v1/auth/whoami"
);
request.setContextPath("/skillhub");
request.setServletPath("/api/cli/v1/auth/whoami");
assertEquals("/api/cli/v1/auth/whoami", RouteSecurityPolicyRegistry.requestPath(request));
}
@Test
void authorizeApiToken_allowsPublicLabelCatalogue() {
assertTrue(registry.authorizeApiToken("GET", "/api/v1/labels", Set.of()).allowed());

View file

@ -227,6 +227,26 @@ class ApiTokenAuthenticationFilterTest {
verify(apiTokenService).touchLastUsed(token);
}
@Test
void shouldAuthenticateBearerTokensBehindForwardedPrefix() throws Exception {
ApiToken token = new ApiToken("user-4", "cli", "sk_test", "hash", "[\"skill:read\"]");
UserAccount user = new UserAccount("user-4", "Dana", "dana@example.com", "");
when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token));
when(userAccountRepository.findById("user-4")).thenReturn(Optional.of(user));
when(roleBindingRepository.findByUserId("user-4")).thenReturn(List.of());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/skillhub/api/cli/v1/auth/whoami");
request.setContextPath("/skillhub");
request.setServletPath("/api/cli/v1/auth/whoami");
request.addHeader("Authorization", "Bearer raw-token");
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
assertNotNull(SecurityContextHolder.getContext().getAuthentication());
verify(apiTokenService).touchLastUsed(token);
}
private static List<String> cliReadRoutes() {
return Stream.of(
"/api/cli/v1/skills/search",

View file

@ -180,4 +180,42 @@ class ApiTokenScopeFilterTest {
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
verify(chain, never()).doFilter(request, response);
}
@Test
void shouldAuthorizeApplicationPathBehindForwardedPrefix() throws Exception {
AccessDeniedHandler handler = mock(AccessDeniedHandler.class);
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
PlatformPrincipal principal = new PlatformPrincipal(
"user-5",
"Erin",
"erin@example.com",
"",
"api_token",
Set.of("USER")
);
var authentication = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("SCOPE_skill:read")
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
MockHttpServletRequest request = new MockHttpServletRequest(
"GET",
"/skillhub/api/cli/v1/namespaces/global/skills"
);
request.setContextPath("/skillhub");
request.setServletPath("/api/cli/v1/namespaces/global/skills");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
verify(chain).doFilter(request, response);
verify(handler, never()).handle(eq(request), eq(response), any());
}
}

View file

@ -37,6 +37,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
@ -286,6 +287,47 @@ public class SkillQueryService {
return new PageImpl<>(pageContent, pageable, accessibleSkills.size());
}
/**
* Lists only active, visible skills whose latest version can be installed.
*
* <p>This is intentionally separate from {@link #listSkillsByNamespace}:
* the portal discovery method also exposes skills without a published
* version, while the CLI sync manifest must contain concrete downloadable
* versions. Filtering happens before pagination so cursors remain stable.
*/
public Page<Skill> listInstallableSkillsByNamespace(
String namespaceSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
Namespace namespace = findNamespace(namespaceSlug);
List<Skill> accessibleSkills = skillRepository
.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE)
.stream()
.filter(skill -> visibilityChecker.canAccess(skill, currentUserId, userNsRoles))
.toList();
Map<Long, SkillVersion> latestVersions = skillVersionRepository.findByIdIn(
accessibleSkills.stream()
.map(Skill::getLatestVersionId)
.filter(Objects::nonNull)
.distinct()
.toList())
.stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Skill> installableSkills = accessibleSkills.stream()
.filter(skill -> SkillInstallability.isInstallableVersion(latestVersions.get(skill.getLatestVersionId())))
.sorted(Comparator.comparing(Skill::getSlug)
.thenComparing(Skill::getId, Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
int start = Math.min((int) pageable.getOffset(), installableSkills.size());
int end = Math.min(start + pageable.getPageSize(), installableSkills.size());
return new PageImpl<>(installableSkills.subList(start, end), pageable, installableSkills.size());
}
/**
* Returns metadata for a visible version, including the stored manifest and
* parsed metadata payload.

View file

@ -239,6 +239,46 @@ class SkillQueryServiceTest {
verify(skillRepository).findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE);
}
@Test
void installableNamespaceDiscoveryFiltersBeforePaginationAndSortsBySlug() throws Exception {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
setId(namespace, 1L);
Skill ready = new Skill(1L, "ready", "owner-1", SkillVisibility.NAMESPACE_ONLY);
setId(ready, 10L);
ready.setLatestVersionId(100L);
Skill draft = new Skill(1L, "draft", "owner-1", SkillVisibility.NAMESPACE_ONLY);
setId(draft, 11L);
draft.setLatestVersionId(101L);
Skill publicReady = new Skill(1L, "public-ready", "owner-2", SkillVisibility.PUBLIC);
setId(publicReady, 12L);
publicReady.setLatestVersionId(102L);
SkillVersion readyVersion = new SkillVersion(10L, "1.0.0", "owner-1");
setId(readyVersion, 100L);
readyVersion.setStatus(SkillVersionStatus.PUBLISHED);
readyVersion.setDownloadReady(true);
SkillVersion draftVersion = new SkillVersion(11L, "1.0.0", "owner-1");
setId(draftVersion, 101L);
draftVersion.setStatus(SkillVersionStatus.DRAFT);
SkillVersion publicReadyVersion = new SkillVersion(12L, "2.0.0", "owner-2");
setId(publicReadyVersion, 102L);
publicReadyVersion.setStatus(SkillVersionStatus.PUBLISHED);
publicReadyVersion.setDownloadReady(true);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(ready, draft, publicReady));
when(skillVersionRepository.findByIdIn(List.of(100L, 101L, 102L)))
.thenReturn(List.of(readyVersion, draftVersion, publicReadyVersion));
Page<Skill> result = service.listInstallableSkillsByNamespace(
"team-a", "viewer", Map.of(1L, NamespaceRole.MEMBER), PageRequest.of(0, 1));
assertEquals(2, result.getTotalElements());
assertEquals("public-ready", result.getContent().getFirst().getSlug());
assertTrue(result.hasNext());
}
@Test
void versionContentRejectsPublicArchivedSkillForNonManager() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner-1");

View file

@ -3348,6 +3348,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cli/v1/namespaces/{namespace}/skills": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listSkills_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cli/v1/auth/whoami": {
parameters: {
query?: never;
@ -4347,6 +4363,8 @@ export interface components {
namespace?: string;
/** Format: date-time */
updatedAt?: string;
ownerId?: string;
ownerDisplayName?: string;
canSubmitPromotion?: boolean;
headlineVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
@ -5330,6 +5348,31 @@ export interface components {
/** Format: int32 */
limit?: number;
};
ApiResponseCliNamespaceSyncResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["CliNamespaceSyncResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
CliNamespaceSyncItemResponse: {
namespace?: string;
slug?: string;
version?: string;
/** Format: int64 */
versionId?: number;
fingerprint?: string;
/** Format: date-time */
updatedAt?: string;
visibility?: string;
downloadUrl?: string;
};
CliNamespaceSyncResponse: {
items?: components["schemas"]["CliNamespaceSyncItemResponse"][];
nextCursor?: string;
};
ApiResponseCliWhoAmIResponse: {
/** Format: int32 */
code?: number;
@ -11357,6 +11400,31 @@ export interface operations {
};
};
};
listSkills_1: {
parameters: {
query?: {
cursor?: string;
limit?: number;
};
header?: never;
path: {
namespace: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseCliNamespaceSyncResponse"];
};
};
};
};
whoami_1: {
parameters: {
query?: never;