skillhub/docs/superpowers/plans/2026-07-28-revoked-token-validation.md
dongmucat 5012b31af2 test(auth): cover CLI session fallback (#605)
Signed-off-by: dongmucat <1127093059@qq.com>
2026-07-28 15:22:11 +08:00

52 KiB
Raw Blame History

Revoked API Token Validation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Lock the CLI API's fail-closed Bearer behavior and Web Session fallback with persisted lifecycle and mixed-credential tests, prove 401/403 semantics on every affected read endpoint, publish the authentication OpenAPI contract, and reconcile source behavior with the actual runtime artifact.

Architecture: Keep ApiTokenAuthenticationFilter as the sole Bearer authentication entry point while preserving Spring Security's existing Web Session identity. Valid Bearer replaces Session; invalid Bearer fails closed without Session fallback; absent or non-Bearer Authorization preserves Session and otherwise leaves public reads anonymous. Use one Spring Boot/MockMvc class with real token and user persistence plus deterministic controller-service stubs for the credential-state matrix, and a second Spring Boot/MockMvc class with real query/download authorization plus persisted PRIVATE and matching PUBLIC skills for authorization checks. Production authentication code remains unchanged unless the unmodified-source matrix reproduces a failure; any such failure stops this plan for systematic root-cause analysis before a minimal fix is planned.

Tech Stack: Java 21, Spring Boot 3.2, Spring Security, Spring Data JPA/H2, MockMvc, JUnit 5 parameterized tests, Mockito, OpenAPI 3.0 YAML, Docker/OCI image inspection.


File Map

  • Create server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java: persisted valid/revoked/expired/unknown/empty/malformed credential matrix for each CLI endpoint.
  • Create server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java: real PRIVATE-skill search omission and read authorization through resolve, latest download, and versioned download.
  • Modify docs/03-authentication-design.md: current CLI route table, Web Session/Bearer priority, and explicit anonymous/401/403 rules.
  • Create docs/api/authentication.openapi.yaml: OpenAPI 3.0 contract for whoami, search, resolve, latest download, and versioned download.
  • Do not modify server/skillhub-auth/src/main/** unless Task 5 records a failing unmodified-source assertion and a separate systematic-debugging plan amendment identifies the root cause.

Task 1: Persisted credential fixture and whoami/search/resolve matrix

Files:

  • Create: server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java

  • Step 1: Create the integration test fixture and endpoint tests

Create the class with real ApiTokenService, ApiTokenRepository, and UserAccountRepository; mock only CliSkillAppService so successful public reads are deterministic. Add independent anonymous, valid, and parameterized invalid-state methods for whoami, search, and resolve:

package com.iflytek.skillhub.controller.cli;

import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import java.io.ByteArrayInputStream;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliTokenLifecycleSecurityIntegrationTest {

    private enum InvalidCredentialState {
        REVOKED,
        EXPIRED,
        UNKNOWN,
        EMPTY,
        MALFORMED
    }

    @Autowired MockMvc mockMvc;
    @Autowired ApiTokenService apiTokenService;
    @Autowired ApiTokenRepository apiTokenRepository;
    @Autowired UserAccountRepository userAccountRepository;
    @Autowired Clock clock;
    @MockBean CliSkillAppService cliSkillAppService;

    private String userId;

    @BeforeEach
    void setUp() {
        userId = "token-matrix-" + UUID.randomUUID();
        userAccountRepository.save(new UserAccount(
                userId, "Token Matrix", userId + "@example.com", ""));
        given(cliSkillAppService.search(any(), anyInt(), any(), any()))
                .willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20));
        given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any()))
                .willReturn(new CliResolveResponse(
                        "global", "demo", "1.0.0", 1L, "sha256:empty",
                        "/api/v1/skills/global/demo/versions/1.0.0/download"));
        given(cliSkillAppService.downloadLatest(anyString(), anyString(), any()))
                .willAnswer(ignored -> downloadResponse());
        given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any()))
                .willAnswer(ignored -> downloadResponse());
    }

    @Test
    void whoamiWithoutAuthorizationReturns401() throws Exception {
        mockMvc.perform(get("/api/cli/v1/auth/whoami"))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.code").value(401));
    }

    @Test
    void whoamiWithValidPersistedTokenReturns200() throws Exception {
        String token = createActiveToken();
        mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.handle").value(userId));
    }

    @ParameterizedTest(name = "whoami rejects {0}")
    @EnumSource(InvalidCredentialState.class)
    void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
        clearInvocations(cliSkillAppService);
        mockMvc.perform(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.code").value(401));
        verifyNoInteractions(cliSkillAppService);
    }

    @Test
    void searchWithoutAuthorizationReturns200() throws Exception {
        mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"))
                .andExpect(status().isOk());
    }

    @Test
    void searchWithValidPersistedTokenReturns200() throws Exception {
        String token = createActiveToken();
        mockMvc.perform(withBearer(
                        get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token))
                .andExpect(status().isOk());
    }

    @ParameterizedTest(name = "search rejects {0}")
    @EnumSource(InvalidCredentialState.class)
    void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
        clearInvocations(cliSkillAppService);
        mockMvc.perform(withInvalidBearer(
                        get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.code").value(401));
        verifyNoInteractions(cliSkillAppService);
    }

    @Test
    void resolveWithoutAuthorizationReturns200() throws Exception {
        mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve"))
                .andExpect(status().isOk());
    }

    @Test
    void resolveWithValidPersistedTokenReturns200() throws Exception {
        String token = createActiveToken();
        mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token))
                .andExpect(status().isOk());
    }

    @ParameterizedTest(name = "resolve rejects {0}")
    @EnumSource(InvalidCredentialState.class)
    void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
        clearInvocations(cliSkillAppService);
        mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/resolve"), state))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.code").value(401));
        verifyNoInteractions(cliSkillAppService);
    }

    private MockHttpServletRequestBuilder withInvalidBearer(
            MockHttpServletRequestBuilder request,
            InvalidCredentialState state) {
        return request
                .header(HttpHeaders.AUTHORIZATION, authorizationHeader(state))
                .with(authentication(sessionAuthentication()));
    }

    private MockHttpServletRequestBuilder withBearer(
            MockHttpServletRequestBuilder request,
            String rawToken) {
        return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken);
    }

    private String authorizationHeader(InvalidCredentialState state) {
        return switch (state) {
            case REVOKED -> {
                ApiTokenService.TokenCreateResult result = createToken();
                apiTokenService.revokeToken(result.entity().getId(), userId);
                yield "Bearer " + result.rawToken();
            }
            case EXPIRED -> {
                ApiTokenService.TokenCreateResult result = createToken();
                ApiToken token = result.entity();
                token.setExpiresAt(Instant.now(clock).minusSeconds(1));
                apiTokenRepository.saveAndFlush(token);
                yield "Bearer " + result.rawToken();
            }
            case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID();
            case EMPTY -> "Bearer ";
            case MALFORMED -> "Bearer";
        };
    }

    private String createActiveToken() {
        return createToken().rawToken();
    }

    private ApiTokenService.TokenCreateResult createToken() {
        return apiTokenService.createToken(
                userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]");
    }

    private UsernamePasswordAuthenticationToken sessionAuthentication() {
        PlatformPrincipal principal = new PlatformPrincipal(
                userId, "Session User", userId + "@example.com", "", "session", Set.of("USER"));
        return new UsernamePasswordAuthenticationToken(principal, null, List.of());
    }

    private ResponseEntity<InputStreamResource> downloadResponse() {
        return ResponseEntity.ok(new InputStreamResource(
                new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8))));
    }
}
  • Step 2: Apply a reversible fail-open mutation before the first test run

Temporarily change both rejection branches in ApiTokenAuthenticationFilter.doFilterInternal so malformed and invalid credentials continue down the chain. Do not stage or commit this mutation:

if (rawToken == null) {
    filterChain.doFilter(request, response);
    return;
}

var token = apiTokenService.validateToken(rawToken);
if (token.isEmpty()) {
    filterChain.doFilter(request, response);
    return;
}
  • Step 3: Run whoami RED verification

Run:

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliTokenLifecycleSecurityIntegrationTest#whoamiRejectsInvalidBearer \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected: FAIL for the invalid-state invocations because the pre-authenticated session reaches whoami and returns 200 instead of 401.

  • Step 4: Run search RED verification

Run the same Maven command with #searchRejectsInvalidBearer.

Expected: FAIL with expected 401 but actual 200 for revoked, expired, unknown, empty, and malformed Bearer credentials.

  • Step 5: Run resolve RED verification

Run the same Maven command with #resolveRejectsInvalidBearer.

Expected: FAIL with expected 401 but actual 200 for all invalid credential states.

  • Step 6: Restore the two original reject branches

Restore exactly:

if (rawToken == null) {
    rejectBearer(request, response);
    return;
}

var token = apiTokenService.validateToken(rawToken);
if (token.isEmpty()) {
    rejectBearer(request, response);
    return;
}

Confirm git diff -- server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java is empty.

  • Step 7: Run whoami/search/resolve GREEN commands independently

Run three Maven commands, one for each of:

CliTokenLifecycleSecurityIntegrationTest#whoamiRejectsInvalidBearer
CliTokenLifecycleSecurityIntegrationTest#searchRejectsInvalidBearer
CliTokenLifecycleSecurityIntegrationTest#resolveRejectsInvalidBearer

Expected: each command reports all parameterized invocations PASS, with no production authentication diff.

Task 2: Latest download matrix

Files:

  • Modify: server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java

  • Step 1: Add independent latest-download methods

Insert before the helper methods:

@Test
void latestDownloadWithoutAuthorizationReturns200() throws Exception {
    mockMvc.perform(get("/api/cli/v1/skills/global/demo/download"))
            .andExpect(status().isOk());
}

@Test
void latestDownloadWithValidPersistedTokenReturns200() throws Exception {
    String token = createActiveToken();
    mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token))
            .andExpect(status().isOk());
}

@ParameterizedTest(name = "latest download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
    clearInvocations(cliSkillAppService);
    mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/download"), state))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.code").value(401));
    verifyNoInteractions(cliSkillAppService);
}
  • Step 2: Reapply the reversible fail-open mutation and run latest-download RED

Run:

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliTokenLifecycleSecurityIntegrationTest#latestDownloadRejectsInvalidBearer \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected: FAIL with expected 401 but actual 200 for every invalid state.

  • Step 3: Restore the original reject branches and run latest-download GREEN

Run the same command after restoring the filter.

Expected: all five invalid-state invocations PASS. Then run independent anonymous and valid methods with #latestDownloadWithoutAuthorizationReturns200 and #latestDownloadWithValidPersistedTokenReturns200; both PASS.

Task 3: Versioned download matrix

Files:

  • Modify: server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java

  • Step 1: Add independent versioned-download methods

Insert before the helper methods:

@Test
void versionedDownloadWithoutAuthorizationReturns200() throws Exception {
    mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"))
            .andExpect(status().isOk());
}

@Test
void versionedDownloadWithValidPersistedTokenReturns200() throws Exception {
    String token = createActiveToken();
    mockMvc.perform(withBearer(
                    get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token))
            .andExpect(status().isOk());
}

@ParameterizedTest(name = "versioned download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
    clearInvocations(cliSkillAppService);
    mockMvc.perform(withInvalidBearer(
                    get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.code").value(401));
    verifyNoInteractions(cliSkillAppService);
}
  • Step 2: Reapply the reversible fail-open mutation and run versioned-download RED

Run the focused method command for #versionedDownloadRejectsInvalidBearer.

Expected: FAIL with expected 401 but actual 200 for every invalid state.

  • Step 3: Restore the filter and run versioned-download GREEN independently

Run focused commands for the invalid, anonymous, and valid versioned-download methods.

Expected: all commands PASS and the filter source has no diff.

  • Step 4: Add and prove the same-token valid-to-revoked replay

Create one token through ApiTokenService, retain its raw value, and use that same value successfully against whoami, search, resolve, latest download, and versioned download. Revoke the persisted token through ApiTokenService.revokeToken, clear prior business-service invocations, then replay the exact same raw value against all five endpoints. Each replay must return 401 and the mocked business service must receive no post-revocation interaction.

For the three valid JSON responses and all five revoked error responses, assert that the outer JSON object contains exactly code, msg, data, timestamp, and requestId; successful downloads remain binary-stream exceptions.

Apply the reversible invalid-token fail-open mutation and run:

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliTokenLifecycleSecurityIntegrationTest#sameRawTokenIsRejectedByAllEndpointsAfterValidUseAndRevocation \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected RED: at least one public read replay returns 200 instead of 401. Restore the filter, confirm its production diff is empty, and rerun the same command. Expected GREEN: one test passes with all five valid calls and all five revoked replays exercised.

  • Step 5: Run the complete persisted credential matrix

Run:

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliTokenLifecycleSecurityIntegrationTest \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected: PASS for all five endpoints and all absent, valid, revoked, expired, unknown, empty, and malformed credential cases, plus the same-token lifecycle replay.

  • Step 6: Commit the credential matrix
git add server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java
git commit -s -m "test(auth): cover persisted CLI token states (#605)"

Task 4: Real restricted-read 403 boundary

Files:

  • Create: server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java

  • Step 1: Create a persisted PRIVATE skill fixture and real authorization tests

package com.iflytek.skillhub.controller.cli;

import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliRestrictedReadAuthorizationIntegrationTest {

    @Autowired MockMvc mockMvc;
    @Autowired ApiTokenService apiTokenService;
    @Autowired UserAccountRepository userAccountRepository;
    @Autowired NamespaceRepository namespaceRepository;
    @Autowired SkillRepository skillRepository;
    @Autowired SkillVersionRepository skillVersionRepository;

    private String namespaceSlug;
    private String skillSlug;
    private String version;
    private String ownerToken;
    private String outsiderToken;

    @BeforeEach
    void setUp() {
        String suffix = UUID.randomUUID().toString().replace("-", "");
        String ownerId = "private-owner-" + suffix;
        String outsiderId = "private-outsider-" + suffix;
        namespaceSlug = "private-ns-" + suffix;
        skillSlug = "private-skill-" + suffix;
        version = "1.0.0";

        userAccountRepository.save(new UserAccount(ownerId, "Owner", ownerId + "@example.com", ""));
        userAccountRepository.save(new UserAccount(
                outsiderId, "Outsider", outsiderId + "@example.com", ""));
        ownerToken = apiTokenService.createToken(
                ownerId, "owner-token-" + suffix, "[\"skill:read\"]").rawToken();
        outsiderToken = apiTokenService.createToken(
                outsiderId, "outsider-token-" + suffix, "[\"skill:read\"]").rawToken();

        Namespace namespace = namespaceRepository.save(new Namespace(namespaceSlug, "Private NS", ownerId));
        Skill skill = skillRepository.save(new Skill(
                namespace.getId(), skillSlug, ownerId, SkillVisibility.PRIVATE));
        SkillVersion published = new SkillVersion(skill.getId(), version, ownerId);
        published.setStatus(SkillVersionStatus.PUBLISHED);
        published.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z"));
        published.setDownloadReady(true);
        published = skillVersionRepository.save(published);
        skill.setLatestVersionId(published.getId());
        skillRepository.save(skill);
        skillRepository.flush();
        skillVersionRepository.flush();
    }

    @Test
    void outsiderCannotResolvePrivateSkill() throws Exception {
        mockMvc.perform(withBearer(
                        get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
                        outsiderToken))
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.code").value(403));
    }

    @Test
    void outsiderCannotDownloadLatestPrivateSkill() throws Exception {
        mockMvc.perform(withBearer(
                        get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug),
                        outsiderToken))
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.code").value(403));
    }

    @Test
    void outsiderCannotDownloadVersionedPrivateSkill() throws Exception {
        mockMvc.perform(withBearer(
                        get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download",
                                namespaceSlug, skillSlug, version),
                        outsiderToken))
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.code").value(403));
    }

    @Test
    void ownerCanResolvePrivateSkill() throws Exception {
        mockMvc.perform(withBearer(
                        get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
                        ownerToken))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.slug").value(skillSlug));
    }

    private MockHttpServletRequestBuilder withBearer(
            MockHttpServletRequestBuilder request,
            String rawToken) {
        return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken);
    }
}
  • Step 2: Apply a reversible authorization mutation before the first run

Temporarily change only the PRIVATE arm in VisibilityChecker.canAccess:

case PRIVATE -> true;

Do not stage or commit this mutation.

  • Step 3: Run three independent restricted-read RED commands

Run the focused Maven command separately for:

CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotResolvePrivateSkill
CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadLatestPrivateSkill
CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadVersionedPrivateSkill

Expected: each command FAILS because the outsider no longer receives 403. Resolve reaches 200; downloads proceed past authorization and return a non-403 response.

  • Step 4: Restore PRIVATE authorization and run GREEN commands

Restore:

case PRIVATE -> isOwner(skill, currentUserId) || isAdminOrAbove(roles.get(skill.getNamespaceId()));

Confirm git diff -- server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java is empty. Run all four test methods independently.

Expected: outsider resolve/latest/versioned each PASS with 403; owner resolve PASS with 200.

  • Step 5: Persist and verify the PRIVATE search-visibility boundary

Persist a SkillSearchDocumentEntity for the same PRIVATE fixture, call the CLI search endpoint with the valid outsider token through the real CliSkillAppService and SearchQueryService, and assert HTTP 200 with the fixture slug omitted. Run it independently:

cd server
./mvnw -pl skillhub-app -am \
  -Dtest='CliRestrictedReadAuthorizationIntegrationTest#outsiderSearchOmitsPersistedPrivateSkill' \
  -Dsurefire.failIfNoSpecifiedTests=false test

Before the GREEN run, temporarily include PRIVATE documents in the search adapter's visibility predicate and confirm the test fails because the fixture slug appears. Restore the production predicate and confirm the command passes. The search omission is not a substitute for the real resolve/download 403 assertions above.

  • Step 6: Commit the restricted-read tests
git add server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java
git commit -s -m "test(auth): cover restricted CLI read authorization (#605)"

Task 5: Production-code decision gate

Files:

  • Inspect only: server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java

  • Inspect only: server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java

  • Step 1: Confirm unmodified-source results and production diff

Run both new classes without any mutation, then run:

git diff --exit-code origin/main -- \
  server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java \
  server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java

Expected: both classes PASS and the production authentication diff is empty. Record the outcome as “current source matrix passes; no production authentication change justified.”

  • Step 2: Stop for systematic debugging if the expected result is false

If any unmodified-source assertion fails, stop execution before editing production code. Preserve the failing command and output, invoke superpowers:systematic-debugging, trace the request through token persistence, security chains, filters, and endpoint service boundaries, then amend this plan with the confirmed minimal change. Do not continue to documentation with a speculative fix.

Task 6: Authentication and OpenAPI documentation

Files:

  • Modify: docs/03-authentication-design.md

  • Create: docs/api/authentication.openapi.yaml

  • Step 1: Replace the CLI API table with current paths and semantics

Use this content in section 10.3:

### 10.3 CLI API

| 接口 | 凭证规则 | 授权与错误语义 |
|------|---------|---------------|
| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 |
| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |

Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 覆盖 Session;坏 Bearer 清除当前身份并立即返回 401,不回退 Session 或匿名。没有 Authorization 或使用 Basic/其他非 Bearer scheme 时保留 Session;如果 Session 也不存在,公共读匿名而 `whoami` 返回 401。身份已验证但 token scope 或资源权限不足时返回 403。`whoami.email` 字段始终存在,没有邮箱时为 `null`。
  • Step 2: Create the complete OpenAPI 3.0 document

Create docs/api/authentication.openapi.yaml with openapi: 3.0.3, a bearerAuth HTTP bearer security scheme, all five paths, and these exact contract rules:

openapi: 3.0.3
info:
  title: SkillHub CLI Authentication API
  version: 1.0.0
  description: >-
    Authentication contract for CLI identity and public skill reads. Valid
    Bearer overrides Web Session. Invalid Bearer returns HTTP 401 without
    Session fallback. An absent Authorization header or unsupported scheme such
    as Basic preserves Session; without Session, public reads are anonymous and
    whoami returns HTTP 401.
servers:
  - url: /
tags:
  - name: CLI Authentication
  - name: CLI Skills
paths:
  /api/cli/v1/auth/whoami:
    get:
      tags: [CLI Authentication]
      summary: Return the current CLI identity
      operationId: cliWhoAmI
      description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session.
      security:
        - bearerAuth: []
        - sessionAuth: []
      responses:
        '200':
          description: Authenticated CLI identity
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CliWhoAmIEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/cli/v1/skills/search:
    get:
      tags: [CLI Skills]
      summary: Search CLI-installable skills
      operationId: cliSearchSkills
      description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
      security:
        - {}
        - sessionAuth: []
        - bearerAuth: []
      parameters:
        - name: q
          in: query
          required: false
          schema: {type: string}
          example: pdf
          description: Optional search text.
        - name: limit
          in: query
          required: false
          schema: {type: integer, format: int32, default: 20}
          example: 20
          description: Maximum number of results.
      responses:
        '200':
          description: Search result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CliSearchEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/cli/v1/skills/{namespace}/{slug}/resolve:
    get:
      tags: [CLI Skills]
      summary: Resolve a skill version
      operationId: cliResolveSkill
      description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
      security:
        - {}
        - sessionAuth: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Namespace'
        - $ref: '#/components/parameters/Slug'
        - name: version
          in: query
          required: false
          schema: {type: string}
          example: 1.0.0
          description: Optional exact version; omitted resolves latest.
      responses:
        '200':
          description: Resolved version
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CliResolveEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/cli/v1/skills/{namespace}/{slug}/download:
    get:
      tags: [CLI Skills]
      summary: Download the latest installable skill version
      operationId: cliDownloadLatestSkill
      description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
      security:
        - {}
        - sessionAuth: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Namespace'
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          $ref: '#/components/responses/Download'
        '302':
          $ref: '#/components/responses/DownloadRedirect'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          $ref: '#/components/responses/StorageUnavailable'
  /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download:
    get:
      tags: [CLI Skills]
      summary: Download an exact installable skill version
      operationId: cliDownloadSkillVersion
      description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
      security:
        - {}
        - sessionAuth: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Namespace'
        - $ref: '#/components/parameters/Slug'
        - $ref: '#/components/parameters/Version'
      responses:
        '200':
          $ref: '#/components/responses/Download'
        '302':
          $ref: '#/components/responses/DownloadRedirect'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          $ref: '#/components/responses/StorageUnavailable'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: SkillHub API token
      description: API token issued by SkillHub. Valid Bearer overrides Session; invalid lifecycle states return the same 401 response without Session fallback.
    sessionAuth:
      type: apiKey
      in: cookie
      name: SESSION
      description: Spring Session browser identity, preserved when Authorization is absent or uses a non-Bearer scheme.
  parameters:
    Namespace:
      name: namespace
      in: path
      required: true
      schema: {type: string}
      example: global
      description: Namespace slug.
    Slug:
      name: slug
      in: path
      required: true
      schema: {type: string}
      example: pdf-parser
      description: Skill slug.
    Version:
      name: version
      in: path
      required: true
      schema: {type: string}
      example: 1.0.0
      description: Exact semantic version.
  responses:
    Download:
      description: ZIP package stream
      headers:
        Content-Disposition:
          schema: {type: string}
          description: Attachment filename.
      content:
        application/zip:
          schema: {type: string, format: binary}
    DownloadRedirect:
      description: Redirect to a presigned object-storage URL
      headers:
        Location:
          schema: {type: string, format: uri}
    BadRequest:
      description: Namespace, skill, or version cannot be resolved.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    Unauthorized:
      description: No valid supported identity is present where required, or the Bearer credential is invalid. Invalid Bearer never falls back to Web Session.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
          example:
            code: 401
            msg: Authentication required
            data: null
            timestamp: '2026-07-28T00:00:00Z'
            requestId: req-123
    Forbidden:
      description: Credential is valid but token scope or resource permission is insufficient.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
          example:
            code: 403
            msg: Forbidden
            data: null
            timestamp: '2026-07-28T00:00:00Z'
            requestId: req-123
    StorageUnavailable:
      description: Object storage is unavailable.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
  schemas:
    Envelope:
      type: object
      required: [code, msg, data, timestamp, requestId]
      properties:
        code: {type: integer, format: int32}
        msg: {type: string}
        data: {type: object, nullable: true}
        timestamp: {type: string, format: date-time}
        requestId: {type: string, example: req-123}
    ErrorEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {type: object, nullable: true, example: null}
    CliWhoAmIEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data:
              $ref: '#/components/schemas/CliWhoAmI'
    CliWhoAmI:
      type: object
      required: [handle, displayName, email]
      properties:
        handle: {type: string, example: user-123}
        displayName: {type: string, example: CLI User}
        email: {type: string, format: email, nullable: true, example: cli@example.com}
    CliSearchEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data:
              $ref: '#/components/schemas/CliSearchResult'
    CliSearchResult:
      type: object
      required: [items, total, limit]
      properties:
        items:
          type: array
          items: {$ref: '#/components/schemas/CliSearchItem'}
        total: {type: integer, format: int64, example: 1}
        limit: {type: integer, format: int32, example: 20}
    CliSearchItem:
      type: object
      required: [namespace, slug, latestVersion]
      properties:
        namespace: {type: string, example: global}
        slug: {type: string, example: pdf-parser}
        latestVersion: {type: string, example: 1.2.0}
        summary: {type: string, nullable: true, example: Parse PDF files}
    CliResolveEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data:
              $ref: '#/components/schemas/CliResolveResult'
    CliResolveResult:
      type: object
      required: [namespace, slug, version, versionId, fingerprint, downloadUrl]
      properties:
        namespace: {type: string, example: global}
        slug: {type: string, example: pdf-parser}
        version: {type: string, example: 1.2.0}
        versionId: {type: integer, format: int64, example: 42}
        fingerprint: {type: string, example: 'sha256:abc123'}
        downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download}
  • Step 3: Validate documentation formatting and contract paths

Run:

ruby -e 'require "yaml"; YAML.load_file("docs/api/authentication.openapi.yaml"); puts "OpenAPI YAML OK"'
rg -n '/api/cli/v1/(auth/whoami|skills)' docs/03-authentication-design.md docs/api/authentication.openapi.yaml
git diff --check

Expected: YAML parser prints OpenAPI YAML OK, all five current paths are found, and git diff --check exits 0.

  • Step 4: Commit authentication documentation
git add docs/03-authentication-design.md docs/api/authentication.openapi.yaml
git commit -s -m "docs(auth): document CLI token failure semantics (#605)"

Task 7: Release artifact and runtime identity evidence

Files:

  • No repository file changes; evidence belongs in the active issue comment because runtime URLs, replica identities, and operational details may not be suitable for the public repository.

  • Step 1: Resolve the published v0.2.14 server digest and revision

Run:

docker buildx imagetools inspect ghcr.io/iflytek/skillhub-server:v0.2.14
docker buildx imagetools inspect ghcr.io/iflytek/skillhub-server:sha-982258d

Expected: record the immutable manifest digest and confirm whether the release tag and SHA tag resolve to the same manifest. If registry access is denied, capture the denial and escalate access to the human owner.

  • Step 2: Inspect every affected runtime replica when access is provided

On the runtime host, from the release compose directory, run:

docker compose -f compose.release.yml config --images
SERVER_CONTAINER_IDS="$(docker compose -f compose.release.yml ps -q server)"
docker inspect --format '{{.Name}} {{.Config.Image}} {{.Image}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}' ${SERVER_CONTAINER_IDS}
for container_id in ${SERVER_CONTAINER_IDS}; do
  image_id="$(docker inspect --format '{{.Image}}' "${container_id}")"
  docker image inspect --format '{{json .RepoDigests}}' "${image_id}"
done

Expected: record configured version, resolved image reference, image ID, OCI revision/version, and immutable RepoDigest for every replica. A mutable tag alone is not a pass.

  • Step 3: Replay one token lifecycle against the identified runtime

Using an authorized dedicated test account, create one token through the normal product flow, verify all five endpoint results while valid, revoke the same token, verify its database revoked_at through an authorized operational read, then repeat all five requests with the same raw token. Record HTTP status, response requestId, timestamp, and serving replica separately for whoami, search, resolve, latest download, and versioned download. Never paste the raw token into comments or logs.

Expected after revocation: 401 on every endpoint. If behavior differs, preserve the exact digest/replica/request evidence and continue systematic root-cause investigation; do not claim the defect is fixed or closable.

  • Step 4: Escalate missing runtime authority explicitly

If no affected runtime URL, host/replica access, or authorization to create/revoke a test token is available, explicitly escalate to the human owner in the active issue. Name the missing authority and request the exact evidence still required: deployed version, immutable server digest or build SHA, all replica identities, and same-token valid-to-revoked replay. State that repository tests do not close the field contradiction and therefore cannot justify closing the defect.

Task 8: Preserve Web Session fallback and harden the reviewed contracts

Files:

  • Modify: server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java

  • Modify: server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java

  • Modify: docs/03-authentication-design.md

  • Modify: docs/api/authentication.openapi.yaml

  • Modify: docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md

  • Step 1: Add the five-endpoint Web Session and mixed-credential matrix

Add independent arguments for whoami, search, resolve, latest download, and versioned download. For each endpoint exercise Session-only, Session + Basic, Basic-only, and Session + valid Bearer. Persist distinct Session and token users, assert Session identity is retained when Bearer is absent or the scheme is Basic, assert public reads are anonymous for Basic-only, and assert valid Bearer identity replaces Session identity. Existing revoked, expired, unknown, empty, and malformed Bearer cases must attach a real mock HTTP Session and continue to return the fixed five-field 401 envelope before controller service logic runs.

Run a reversible filter mutation that prevents valid Bearer replacement of an existing Session principal, then run:

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliTokenLifecycleSecurityIntegrationTest#sessionAndAuthorizationSchemeMatrix \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected RED: Session + valid Bearer exposes the Session user instead of the token user. Restore production source immediately and rerun the same command. Expected GREEN: all 20 endpoint/credential arguments pass without a production source diff.

  • Step 2: Lock the nullable whoami email contract

Persist an active user whose email is null, issue its token through ApiTokenService, call GET /api/cli/v1/auth/whoami, and assert the email key is present with a JSON null value inside the standard five-field envelope.

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliTokenLifecycleSecurityIntegrationTest#whoamiReturnsNullEmailForPersistedUserWithoutEmail \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected: PASS against existing production behavior; this is a response-shape characterization test. Update CliWhoAmI.email in OpenAPI to remain required while becoming nullable: true.

  • Step 3: Make PRIVATE search omission a positive and negative proof

Use a unique numeric skillSlug as q, persist an installable PUBLIC skill whose search document contains the same keyword, and keep the existing installable PRIVATE skill. Assert the PUBLIC slug is returned and the PRIVATE slug is omitted for the outsider token.

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected RED before the PUBLIC fixture is persisted: the expected PUBLIC slug is absent. Expected GREEN after the fixture is added: the same non-empty result contains PUBLIC and omits PRIVATE.

  • Step 4: Assert the fixed five-field 403 envelope on every restricted read

Replace status/code-only assertions for restricted resolve, latest download, and versioned download with a shared assertion for exactly code, msg, data, timestamp, and requestId; require code=403, data=null, and string timestamps/request IDs. Keep the three routes as separate test methods.

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotResolvePrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadLatestPrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadVersionedPrivateSkill \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected: all three pass through the real access-denied path.

  • Step 5: Align authentication design and OpenAPI priority rules

Document these exact rules: valid Bearer overrides Web Session; any Bearer attempt that is empty, malformed, unknown, expired, revoked, or tied to an unavailable user returns 401 without Session fallback; no Authorization header or a non-Bearer scheme preserves a valid Session; without a Session, public reads use anonymous visibility and whoami returns 401. Add cookie sessionAuth to OpenAPI and list it as an alternative on all five operations. OpenAPI descriptions must state the precedence because security alternatives cannot encode it alone.

  • Step 6: Confirm the review correction did not change production auth
git diff --name-only origin/main...HEAD
git diff --exit-code origin/main...HEAD -- server/skillhub-auth/src/main server/skillhub-app/src/main

Expected: only tests and documentation changed; the production-code diff command exits 0.

Task 9: Quality gates and implementation review handoff

Files:

  • Verify all changed files; do not create a PR in this stage.

  • Step 1: Run both focused integration classes

cd server && ./mvnw -pl skillhub-app -am \
  -Dtest=CliTokenLifecycleSecurityIntegrationTest,CliRestrictedReadAuthorizationIntegrationTest \
  -Dsurefire.failIfNoSpecifiedTests=false test

Expected: PASS, with latest and versioned download reported as distinct methods.

  • Step 2: Run the complete backend gate
make test-backend-app

Expected: BUILD SUCCESS, zero failures, zero errors.

  • Step 3: Run repository web gates required before delivery
make typecheck-web
make lint-web

Expected: zero TypeScript errors and zero ESLint errors/warnings.

  • Step 4: Run containerized staging regression
make staging

Expected: backend/frontend images build, services become healthy, and smoke tests pass. Tear down with make staging-down after collecting evidence.

  • Step 5: Verify scope, formatting, and commit hygiene
git diff --check origin/main...HEAD
git diff --name-only origin/main...HEAD
git status --short --branch
git log --format='%h %s%n%b' origin/main..HEAD

Expected: only the approved spec/plan, two test classes, authentication design, and OpenAPI document are changed; no production authentication source is changed when the matrix passes; all commits are signed off and reference GitHub issue #605 without any Multica identifier.

  • Step 6: Route to tester and reviewer quality gates

Provide the branch, focused commands, complete matrix result, 403 fixture result, docs path, runtime identity/replay evidence or explicit external blocker, and full gate output to the project tester. After tester passes, request structured reviewer/security review. Address any findings on the same branch and rerun affected gates.

  • Step 7: Update the existing single PR and report completion

Commit and push to the existing fix/auth-revoked-token-validation branch so PR #609 updates in place. Post the implementation result to the active issue thread. Include commit SHAs, endpoint-by-state matrix, RED mutation evidence, GREEN results, quality gates, OpenAPI path, production-code decision, and runtime identity/replay status. Do not create a second PR, do not change issue status, and do not merge main during this stage.