feat: Spaces credential revocation #221

Merged
Grandiras merged 2 commits from issue-214-spaces-revocation into main 2026-10-05 14:17:01 +00:00
Owner

Closes #214.

Spaces credential revocation, as in the 2026-10-01 alpha (upstream #5567, com.atproto.space.notifyCredentialRevoked).

Repo host

  • NotifyCredentialRevokedEndpoint (registered by AddSpaceRepoHost) accepts {space, credentials: jti[1..100]}, authenticated with service auth:
    • lxm must be this method;
    • iss must be the space's authority (403 otherwise);
    • aud must be an account this host serves, checked after the signature so it can't be probed (403 otherwise);
    • the token is single-use (replayed jti refused).
    • Body limits are checked before auth, as for notifyWrite.
  • Revoked (space, jti) pairs are kept for at least 3610 s (credential max lifetime + 2× clock skew). SpaceCredentialVerifier checks them on every presentation, cached credentials included, and answers 401 CredentialRevoked.
  • New ISpaceCredentialRevocationStore, with in-memory and EF Core implementations (AtProtoSpaceCredentialRevocations table, AddAtProtoEfCoreSpaceCredentialRevocationStore<TContext>()).
    • The sweep only deletes expired entries, so cleanup can't revive a credential.
    • Both stores run the same contract tests; EF Core runs them on SQLite.

Authority

SpaceCredentialRevoker.RevokeAsync(space, jtis) (registered by AddSpaceAuthority):

  • records the revocation locally, so the authority's own endpoints refuse the credential at once;
  • notifies each writer's host in batches of 100, signed as the authority with aud set to the writer. Upstream has no reference fan-out, so this part is our design: best effort, one attempt.

Client

SpaceClient.NotifyCredentialRevokedAsync(space, jtis).

Breaking

ISpaceRepoHost gains HostsAccountAsync. The reference requires aud to be a hosted account, and the interface had no way to ask. The EF schema gains a table. Both are covered in docs/migrating-to-0.8.md.

A startup warning flags the in-process default revocation store, as it does for the replay store.

Build has 0 warnings; 5143 unit tests pass.

🤖 Generated with Claude Code

Closes #214. Spaces credential revocation, as in the 2026-10-01 alpha (upstream #5567, `com.atproto.space.notifyCredentialRevoked`). ## Repo host - `NotifyCredentialRevokedEndpoint` (registered by `AddSpaceRepoHost`) accepts `{space, credentials: jti[1..100]}`, authenticated with service auth: - `lxm` must be this method; - `iss` must be the space's authority (403 otherwise); - `aud` must be an account this host serves, checked after the signature so it can't be probed (403 otherwise); - the token is single-use (replayed `jti` refused). - Body limits are checked before auth, as for `notifyWrite`. - Revoked `(space, jti)` pairs are kept for at least 3610 s (credential max lifetime + 2× clock skew). `SpaceCredentialVerifier` checks them on every presentation, cached credentials included, and answers 401 `CredentialRevoked`. - New `ISpaceCredentialRevocationStore`, with in-memory and EF Core implementations (`AtProtoSpaceCredentialRevocations` table, `AddAtProtoEfCoreSpaceCredentialRevocationStore<TContext>()`). - The sweep only deletes expired entries, so cleanup can't revive a credential. - Both stores run the same contract tests; EF Core runs them on SQLite. ## Authority `SpaceCredentialRevoker.RevokeAsync(space, jtis)` (registered by `AddSpaceAuthority`): - records the revocation locally, so the authority's own endpoints refuse the credential at once; - notifies each writer's host in batches of 100, signed as the authority with `aud` set to the writer. Upstream has no reference fan-out, so this part is our design: best effort, one attempt. ## Client `SpaceClient.NotifyCredentialRevokedAsync(space, jtis)`. ## Breaking `ISpaceRepoHost` gains `HostsAccountAsync`. The reference requires `aud` to be a hosted account, and the interface had no way to ask. The EF schema gains a table. Both are covered in `docs/migrating-to-0.8.md`. A startup warning flags the in-process default revocation store, as it does for the replay store. Build has 0 warnings; 5143 unit tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: Spaces credential revocation (closes #214)
All checks were successful
CI / pds-integration (pull_request) Successful in 28s
CI / build-and-test (pull_request) Successful in 56s
3bb545f673
Repo hosts serve com.atproto.space.notifyCredentialRevoked, authenticated with service auth from the space's authority and addressed to a hosted repo DID. Revoked (space, jti) pairs are kept for 3610 s in an ISpaceCredentialRevocationStore (in-memory and EF Core) and SpaceCredentialVerifier refuses them with CredentialRevoked. SpaceCredentialRevoker lets an authority revoke locally and notify the writers' hosts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-2026-10-05T14-01-45-078Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/749); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-749.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-10-05 14:02:14 +00:00
Dismissed
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

The change matches the description and I found nothing that should block merging. I read the diff only and did not build or run the tests.

Endpoint auth order. notifyCredentialRevoked checks body limits first, then the service-auth signature, lxm, expiry and single use. After that it checks iss against the space's authority. The hosted-account check on aud comes last, after the signature, so an unauthenticated caller can't probe which accounts the host holds. A fragment audience such as did#atproto_pds is rejected with 401.

Stores. The in-memory and EF Core stores share one contract test suite. Retention is raised but never lowered, and the sweep deletes only entries whose retention has passed. The EF retry after a concurrent insert looks safe.

Verifier, docs, API files. The verifier checks revocation on every presentation, cached credentials included, and does so last, so only credentials that otherwise verify cost a store read. The CHANGELOG entries, migration notes, docs and public API files are updated.

Minor, non-blocking. SpaceCredentialRevoker.RevokeAsync skips the "this service is the authority" check when ServiceDid is not configured. A caller could then record local revocations for another authority's space, and the notifications would go out signed as the wrong DID. Failing fast when ServiceDid is unset would be tidier.


🧠 claude-sonnet-5-5 · ⏱ 0m 16s · 2 turns · tokens: 54.8k in / 2.0k out (+12.2k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. The change matches the description and I found nothing that should block merging. I read the diff only and did not build or run the tests. **Endpoint auth order.** `notifyCredentialRevoked` checks body limits first, then the service-auth signature, `lxm`, expiry and single use. After that it checks `iss` against the space's authority. The hosted-account check on `aud` comes last, after the signature, so an unauthenticated caller can't probe which accounts the host holds. A fragment audience such as `did#atproto_pds` is rejected with 401. **Stores.** The in-memory and EF Core stores share one contract test suite. Retention is raised but never lowered, and the sweep deletes only entries whose retention has passed. The EF retry after a concurrent insert looks safe. **Verifier, docs, API files.** The verifier checks revocation on every presentation, cached credentials included, and does so last, so only credentials that otherwise verify cost a store read. The CHANGELOG entries, migration notes, docs and public API files are updated. **Minor, non-blocking.** `SpaceCredentialRevoker.RevokeAsync` skips the "this service is the authority" check when `ServiceDid` is not configured. A caller could then record local revocations for another authority's space, and the notifications would go out signed as the wrong DID. Failing fast when `ServiceDid` is unset would be tidier. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 16s · 2 turns · tokens: 54.8k in / 2.0k out (+12.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-749.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:3bb545f673df89f4dd143ab361e272b19aa8cbc6 -->
Author
Owner

Independent adversarial review (read-only Sonnet reviewer): approve. Unit suite: 5143 passed.

  • Verified: only the space's own authority can revoke (iss from the space URI, the store keyed by that space). lxm and aud are enforced, with the hosted-account lookup only after the signature. Replay is covered. Every credential presentation, cached or not, consults the store, keyed by the verified credential's jti. The sweep can't delete a live entry. Fan-out sends only jtis, through the SSRF-guarded resolver.
  • Fixed in the latest commit: retention used ClockSkew, but credentials are verified with the fixed 5 s DefaultClockSkew. With ClockSkew = 0 a revocation lapsed up to 10 s before its credential expired. Retention now uses the larger of the two, as ReplayRetention does.
  • Hardened in the same commit: identifiers that fail Jwt.IsUsableTokenId (empty, control characters, over 255) are refused with InvalidRequest, since no credential can carry them and they would only take up store space.
Independent adversarial review (read-only Sonnet reviewer): **approve**. Unit suite: 5143 passed. - **Verified:** only the space's own authority can revoke (`iss` from the space URI, the store keyed by that space). `lxm` and `aud` are enforced, with the hosted-account lookup only after the signature. Replay is covered. Every credential presentation, cached or not, consults the store, keyed by the verified credential's `jti`. The sweep can't delete a live entry. Fan-out sends only `jti`s, through the SSRF-guarded resolver. - **Fixed in the latest commit:** retention used `ClockSkew`, but credentials are verified with the fixed 5 s `DefaultClockSkew`. With `ClockSkew = 0` a revocation lapsed up to 10 s before its credential expired. Retention now uses the larger of the two, as `ReplayRetention` does. - **Hardened in the same commit:** identifiers that fail `Jwt.IsUsableTokenId` (empty, control characters, over 255) are refused with `InvalidRequest`, since no credential can carry them and they would only take up store space.
fix: keep revocations for the credential skew, and refuse identifiers no credential could carry
All checks were successful
CI / pds-integration (pull_request) Successful in 27s
CI / build-and-test (pull_request) Successful in 56s
de2354901a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/750); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-750.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-10-05 14:06:41 +00:00
Dismissed
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

The new commit is fine, and I found nothing that blocks merging. I read the diff and checked Jwt.IsUsableTokenId and the SpaceTokens constants. I did not build or run the tests.

  • Retention: SpaceCredentialRevocation.RetainUntil now uses the larger of options.ClockSkew and SpaceTokens.DefaultClockSkew. That matches the skew the credential checks use, and a test covers a ClockSkew of zero.
  • Identifiers: The endpoint now refuses identifiers no credential could carry (blank, control characters, over-long) with 400. It does this before auth, like the other body checks.

Last pass's minor note is still open and still non-blocking: SpaceCredentialRevoker.RevokeAsync skips the "this service is the authority" check when ServiceDid is unset.


🧠 claude-sonnet-5-5 · ⏱ 0m 7s · 3 turns · tokens: 57.3k in / 599 out (+80.5k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. The new commit is fine, and I found nothing that blocks merging. I read the diff and checked `Jwt.IsUsableTokenId` and the `SpaceTokens` constants. I did not build or run the tests. - **Retention:** `SpaceCredentialRevocation.RetainUntil` now uses the larger of `options.ClockSkew` and `SpaceTokens.DefaultClockSkew`. That matches the skew the credential checks use, and a test covers a `ClockSkew` of zero. - **Identifiers:** The endpoint now refuses identifiers no credential could carry (blank, control characters, over-long) with 400. It does this before auth, like the other body checks. Last pass's minor note is still open and still non-blocking: `SpaceCredentialRevoker.RevokeAsync` skips the "this service is the authority" check when `ServiceDid` is unset. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 7s · 3 turns · tokens: 57.3k in / 599 out (+80.5k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-750.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:de2354901a6f876eeb684e53499dc81ff5523fc0 -->
opencode-reviewer approved these changes 2026-10-05 14:07:52 +00:00
Dismissed
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

This is a well-built feature. The security-critical parts hold up:

  • Endpoint auth order is right. Body shape is refused before auth (matching notifyWrite), then the service auth signature is verified for lxm/audience/single-use, and only then is iss compared against the space's authority and HostsAccountAsync consulted — so an unauthenticated caller can't probe which accounts the host serves, and a non-authority can't lock readers out of a space. Accepting unknown jtis (revocation racing credential arrival) and idempotency are both correct.
  • Retention is sound. MaxCredentialLifetime + 2×ClockSkew from receipt covers a credential issued up to one skew in the past plus the skew at expiry; both stores only delete entries whose retention has passed, so sweeping can never revive a credential, and the conditional update (RetainUntil < until) never shortens an entry.
  • Revocation is checked on every presentation, cached credentials included, with a comment explaining why — easy to get wrong and it isn't.
  • Fan-out design (writers only, aud = writer DID, batches of 100, one best-effort attempt) is a reasonable choice given upstream has no reference, and it's documented as such. The Audience() mapping reuses the authority-host special case correctly.
  • The ISpaceRepoHost.HostsAccountAsync and EF-schema breaks are justified by the reference's hosted-account requirement and are documented in the migration guide; the startup warning for the in-process default mirrors the replay store, and the tests cover it.

The in-memory/EF contract tests plus endpoint tests (idempotency, cross-space isolation, non-authority refusal, audience probing, replay, batch bounds, retention window) give good coverage; the SQLite cross-instance test exercises what a shared store actually buys.

Two non-blocking notes: the endpoint accepts whitespace-only identifiers (IsNullOrEmpty) where SpaceClient and SpaceCredentialRevoker reject them (IsNullOrWhiteSpace) — harmless since such a jti can never match a minted credential, but the two validations could agree. And the fragment-form audience (did:plc:…#atproto_pds) is refused with 401 "not a repo DID" rather than the 403 path; since the reference requires aud to be a hosted account, that's consistent, just worth knowing if a test harness ever sends the fragment form.


⏱ 6m 7s · 7 steps · $0.0185 · tokens: 65.7k in / 1.1k out (+169.8k cache-read) · Go: 5h 4% · wk 2% · mo 92%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. This is a well-built feature. The security-critical parts hold up: - **Endpoint auth order is right.** Body shape is refused before auth (matching `notifyWrite`), then the service auth signature is verified for `lxm`/audience/single-use, and only then is `iss` compared against the space's authority and `HostsAccountAsync` consulted — so an unauthenticated caller can't probe which accounts the host serves, and a non-authority can't lock readers out of a space. Accepting unknown `jtis` (revocation racing credential arrival) and idempotency are both correct. - **Retention is sound.** `MaxCredentialLifetime + 2×ClockSkew` from receipt covers a credential issued up to one skew in the past plus the skew at expiry; both stores only delete entries whose retention has passed, so sweeping can never revive a credential, and the conditional update (`RetainUntil < until`) never shortens an entry. - **Revocation is checked on every presentation**, cached credentials included, with a comment explaining why — easy to get wrong and it isn't. - **Fan-out design** (writers only, `aud` = writer DID, batches of 100, one best-effort attempt) is a reasonable choice given upstream has no reference, and it's documented as such. The `Audience()` mapping reuses the authority-host special case correctly. - The `ISpaceRepoHost.HostsAccountAsync` and EF-schema breaks are justified by the reference's hosted-account requirement and are documented in the migration guide; the startup warning for the in-process default mirrors the replay store, and the tests cover it. The in-memory/EF contract tests plus endpoint tests (idempotency, cross-space isolation, non-authority refusal, audience probing, replay, batch bounds, retention window) give good coverage; the SQLite cross-instance test exercises what a shared store actually buys. Two non-blocking notes: the endpoint accepts whitespace-only identifiers (`IsNullOrEmpty`) where `SpaceClient` and `SpaceCredentialRevoker` reject them (`IsNullOrWhiteSpace`) — harmless since such a `jti` can never match a minted credential, but the two validations could agree. And the fragment-form audience (`did:plc:…#atproto_pds`) is refused with 401 "not a repo DID" rather than the 403 path; since the reference requires `aud` to be a hosted account, that's consistent, just worth knowing if a test harness ever sends the fragment form. --- ⏱ 6m 7s · 7 steps · $0.0185 · tokens: 65.7k in / 1.1k out (+169.8k cache-read) · Go: 5h 4% · wk 2% · mo 92% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-2026-10-05T14-01-45-078Z.md) <!-- opencode-reviewed-head:3bb545f673df89f4dd143ab361e272b19aa8cbc6 --> <!-- opencode-verdict:approve -->
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-2026-10-05T14-08-22-347Z.md) — updating every ~5 minutes while the review runs.
Grandiras force-pushed issue-214-spaces-revocation from de2354901a
All checks were successful
CI / pds-integration (pull_request) Successful in 27s
CI / build-and-test (pull_request) Successful in 56s
to 4d5970fda1
All checks were successful
CI / pds-integration (pull_request) Successful in 40s
CI / build-and-test (pull_request) Successful in 1m22s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 32s
2026-10-05 14:08:31 +00:00
Compare
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/751); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-751.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

I found nothing that blocks merging. I read the diff only and did not build or run the tests.

My earlier non-blocking note is still open. SpaceCredentialRevoker.RevokeAsync skips the "this service is the authority" check when ServiceDid is unset. A caller could then record local revocations for another authority's space, and the notifications would go out signed as the wrong DID. Throwing when ServiceDid is null would be tidier.

The endpoint checks the body limits and identifier validity first, then the service-auth signature, iss, and the hosted account on aud. The retention window uses the larger of ClockSkew and the default skew. The verifier checks revocation on every presentation, and both stores run the same contract tests. The CHANGELOG entries, migration notes and public API files are updated.


🧠 claude-sonnet-5-5 · ⏱ 0m 6s · 2 turns · tokens: 56.4k in / 548 out (+12.2k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. I found nothing that blocks merging. I read the diff only and did not build or run the tests. My earlier non-blocking note is still open. `SpaceCredentialRevoker.RevokeAsync` skips the "this service is the authority" check when `ServiceDid` is unset. A caller could then record local revocations for another authority's space, and the notifications would go out signed as the wrong DID. Throwing when `ServiceDid` is null would be tidier. The endpoint checks the body limits and identifier validity first, then the service-auth signature, `iss`, and the hosted account on `aud`. The retention window uses the larger of `ClockSkew` and the default skew. The verifier checks revocation on every presentation, and both stores run the same contract tests. The CHANGELOG entries, migration notes and public API files are updated. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 6s · 2 turns · tokens: 56.4k in / 548 out (+12.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-751.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:4d5970fda1739fed371ba61c2950a0e06b5a9be1 -->
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-review: both non-blocking notes from my last pass are resolved. The endpoint now validates each credential identifier with Jwt.IsUsableTokenId (not-only-whitespace, no control characters, ≤ 255 chars, 400 otherwise), bringing it in line with SpaceClient and SpaceCredentialRevoker; tests cover blank, tab, and over-long identifiers. The fragment-form audience behavior is unchanged and as designed.

The security-critical parts remain sound: body shape is refused before auth (matching notifyWrite); the service-auth signature, lxm, aud and single-use jti are verified before the iss-is-the-authority and HostsAccountAsync checks, so an unauthenticated caller cannot probe which accounts the host serves and a non-authority cannot lock readers out; unknown jtis are recorded (revocation may race credential arrival) and the call is idempotent. Retention is MaxCredentialLifetime + 2×max(options.ClockSkew, SpaceTokens.DefaultClockSkew) from receipt, matching the skew the credential checks actually use; both stores never shorten an entry and sweep only rows past retention, so cleanup cannot revive a credential. The verifier checks revocation on every presentation, cached credentials included, and last, so only credentials that otherwise verify cost a store read.

Breaking changes (ISpaceRepoHost.HostsAccountAsync, the AtProtoSpaceCredentialRevocations EF table) are justified and documented in docs/migrating-to-0.8.md; the startup warning for the in-process default store mirrors the replay store and its tests were updated. The in-memory and EF Core stores share one contract test suite, plus SQLite-specific concurrency and endpoint tests. No remaining issues; good to merge.


⏱ 3m 27s · 4 steps · $0.0112 · tokens: 59.1k in / 835 out (+57.3k cache-read) · Go: 5h 4% · wk 2% · mo 92%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-review: both non-blocking notes from my last pass are resolved. The endpoint now validates each credential identifier with `Jwt.IsUsableTokenId` (not-only-whitespace, no control characters, ≤ 255 chars, 400 otherwise), bringing it in line with `SpaceClient` and `SpaceCredentialRevoker`; tests cover blank, tab, and over-long identifiers. The fragment-form audience behavior is unchanged and as designed. The security-critical parts remain sound: body shape is refused before auth (matching `notifyWrite`); the service-auth signature, `lxm`, `aud` and single-use `jti` are verified before the `iss`-is-the-authority and `HostsAccountAsync` checks, so an unauthenticated caller cannot probe which accounts the host serves and a non-authority cannot lock readers out; unknown `jtis` are recorded (revocation may race credential arrival) and the call is idempotent. Retention is `MaxCredentialLifetime + 2×max(options.ClockSkew, SpaceTokens.DefaultClockSkew)` from receipt, matching the skew the credential checks actually use; both stores never shorten an entry and sweep only rows past retention, so cleanup cannot revive a credential. The verifier checks revocation on every presentation, cached credentials included, and last, so only credentials that otherwise verify cost a store read. Breaking changes (`ISpaceRepoHost.HostsAccountAsync`, the `AtProtoSpaceCredentialRevocations` EF table) are justified and documented in `docs/migrating-to-0.8.md`; the startup warning for the in-process default store mirrors the replay store and its tests were updated. The in-memory and EF Core stores share one contract test suite, plus SQLite-specific concurrency and endpoint tests. No remaining issues; good to merge. --- ⏱ 3m 27s · 4 steps · $0.0112 · tokens: 59.1k in / 835 out (+57.3k cache-read) · Go: 5h 4% · wk 2% · mo 92% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-2026-10-05T14-08-22-347Z.md) <!-- opencode-reviewed-head:de2354901a6f876eeb684e53499dc81ff5523fc0 --> <!-- opencode-verdict:approve -->
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-2026-10-05T14-12-33-467Z.md) — updating every ~5 minutes while the review runs.
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-review: both non-blocking notes from my previous pass remain resolved — the endpoint validates each identifier with Jwt.IsUsableTokenId (blank/control-character/over-long → 400), and the retention window uses the larger of options.ClockSkew and SpaceTokens.DefaultClockSkew, with a test covering ClockSkew = 0.

This pass I additionally checked the new fan-out code (SpaceWriteNotifier.NotifyCredentialRevokedAsync) against the existing delivery paths. It reuses DeliverAsync correctly: failures (including DID resolution) are logged and counted as not-delivered, exceptions are absorbed per attempt, and only successful deliveries increment the counter. Each request is addressed (aud) to the writer's bare DID as the endpoint requires, signed via SpaceAccountSigning.ChooseAsync with the same issuer-selection fallback the other notifications use, and batches of 100 respect the request limit. The endpoint path remains sound: body shape first, then the service-auth signature with lxm/aud/single-use, then iss == the space authority, then HostsAccountAsync — so the hosted-account list cannot be probed by unauthenticated callers and a non-authority cannot lock readers out. The verifier still checks revocation on every presentation (cached credentials included) and last, so only otherwise-valid credentials cost a store read; retention is MaxCredentialLifetime + 2×max(skews) from receipt and neither store ever shortens an entry or sweeps rows still within retention.

One non-blocking note stands open (explained on the code): SpaceCredentialRevoker.RevokeAsync does not require an explicitly configured ServiceDid before recording a local revocation, so with ServiceDid unset it could record a revocation for a space this service does not author; the receiving host would still refuse it, so it is tidy-up rather than a blocker.

Good to merge.


⏱ 1m 6s · 6 steps · $0.0115 · tokens: 39.6k in / 1.0k out (+151.6k cache-read) · Go: 5h 4% · wk 2% · mo 92%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-review: both non-blocking notes from my previous pass remain resolved — the endpoint validates each identifier with `Jwt.IsUsableTokenId` (blank/control-character/over-long → 400), and the retention window uses the larger of `options.ClockSkew` and `SpaceTokens.DefaultClockSkew`, with a test covering `ClockSkew = 0`. This pass I additionally checked the new fan-out code (`SpaceWriteNotifier.NotifyCredentialRevokedAsync`) against the existing delivery paths. It reuses `DeliverAsync` correctly: failures (including DID resolution) are logged and counted as not-delivered, exceptions are absorbed per attempt, and only successful deliveries increment the counter. Each request is addressed (`aud`) to the writer's bare DID as the endpoint requires, signed via `SpaceAccountSigning.ChooseAsync` with the same issuer-selection fallback the other notifications use, and batches of 100 respect the request limit. The endpoint path remains sound: body shape first, then the service-auth signature with `lxm`/aud/single-use, then `iss` == the space authority, then `HostsAccountAsync` — so the hosted-account list cannot be probed by unauthenticated callers and a non-authority cannot lock readers out. The verifier still checks revocation on every presentation (cached credentials included) and last, so only otherwise-valid credentials cost a store read; retention is `MaxCredentialLifetime + 2×max(skews)` from receipt and neither store ever shortens an entry or sweeps rows still within retention. One non-blocking note stands open (explained on the code): `SpaceCredentialRevoker.RevokeAsync` does not require an explicitly configured `ServiceDid` before recording a local revocation, so with `ServiceDid` unset it could record a revocation for a space this service does not author; the receiving host would still refuse it, so it is tidy-up rather than a blocker. Good to merge. --- ⏱ 1m 6s · 6 steps · $0.0115 · tokens: 39.6k in / 1.0k out (+151.6k cache-read) · Go: 5h 4% · wk 2% · mo 92% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-221/run-2026-10-05T14-12-33-467Z.md) <!-- opencode-reviewed-head:4d5970fda1739fed371ba61c2950a0e06b5a9be1 --> <!-- opencode-verdict:approve -->
@ -0,0 +55,4 @@
if (credentialIds.Any(string.IsNullOrWhiteSpace))
throw new ArgumentException("A credential is revoked by its non-blank jti.", nameof(credentialIds));
if (_options.ServiceDid is { } serviceDid && space.Authority != serviceDid)
throw new ArgumentException($"This service is not the authority for {space}.", nameof(space));
Collaborator

Non-blocking, carried over from earlier passes: when SpaceServerOptions.ServiceDid is unset, the "this service is the authority for the space" check is skipped, so a caller could record local revocations for another authority's space and the notifications would go out signed as this service (which receivers then refuse on iss). Failing fast when ServiceDid is null would be tidier, but it is not a blocker: a receiver only honors revocations signed by the space's authority, and the default used in production flows sets ServiceDid.

Non-blocking, carried over from earlier passes: when `SpaceServerOptions.ServiceDid` is unset, the "this service is the authority for the space" check is skipped, so a caller could record local revocations for another authority's space and the notifications would go out signed as this service (which receivers then refuse on `iss`). Failing fast when `ServiceDid` is null would be tidier, but it is not a blocker: a receiver only honors revocations signed by the space's authority, and the default used in production flows sets `ServiceDid`.
Collaborator

🔧 Let opencode-bot fix this — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do.

  • @opencode-bot Address the findings of the latest review on this pull request.
🔧 **Let opencode-bot fix this** — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do. - [ ] @opencode-bot Address the findings of the latest review on this pull request. <!-- opencode-fix-button:221 -->
Grandiras deleted branch issue-214-spaces-revocation 2026-10-05 14:17:01 +00:00
Sign in to join this conversation.
No description provided.