Skip to content

[FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them - #472

Open
justin13888 wants to merge 2 commits into
feat/server-album-membership-405from
feat/server-federation-406
Open

[FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them#472
justin13888 wants to merge 2 commits into
feat/server-album-membership-405from
feat/server-federation-406

Conversation

@justin13888

Copy link
Copy Markdown
Collaborator

Description

capsule-server::federation (slices S-E2, S-E5, S-C49): the capability token a peer server presents on the existing GET /v1/sync?album_id= and GET /v1/blob/{hash} reads, the store it is issued from and revoked into (which is now the revocation list /.well-known/capsule/revoked-jti serves), the peers this server pins and blocks, and the lifecycle around it. Stacked on #458 (feat/server-album-membership-405); the PR targets that branch.

Status: in progress. Delivered in ordered commits, each leaving the tree coherent; this body is updated as slices land. No human has approved the plan below — it was executed under the run's unattended declaration and is published here for review.

Related Issues

Refs #406

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

Summary

Landed so far:

  1. Codec, ports, doubles, contextfederation/{mod,capability,store,peers,memory,conformance}.rs. EdDSA-JWT capability with the design's claim set verbatim (RFC 3339 instants, 24 h ceiling, whole-second instants so a stored grant re-signs byte-for-byte for idempotent refresh), signed by the same Ed25519 key as session tokens; CapabilityStore (implements RevocationList — the store is the list) and PeerStore (pinned keys, blocklist column) with in-memory adapters and one conformance suite; FederationContext wired into App/Modules/boot/the fixture; Config.federation_url (FEDERATION_URL) published on server-info; InMemoryRevocations retired (its cases moved into the conformance suite); four error.federation.* keys added; capsule-server::federation removed from planned-modules.txt.

Validation

See the per-commit list below; every gate is recorded with its exact command and observed outcome. Failures are classified caused / pre-existing / flaky / unavailable / unclassified.

  • cargo nextest run -p capsule-server -E 'test(federation) | test(discovery) | binary(well_known) | binary(conformance) | test(boot::) | test(config::)' — 82 passed (commit 1)
  • cargo clippy -p capsule-server -- $CLIPPY_FLAGS — clean (commit 1)
  • cargo fmt --check -p capsule-server — clean (commit 1)
  • cargo run -q -p capsule-server -- gen-openapi --check — document up to date (commit 1; no route change yet)
  • mise run i18n-check, mise run i18n-guard — clean (commit 1)
  • mise run check-docs-truth — 476 links, 101 citations, 119 paths all resolve (commit 1)
  • mise run check-rust, mise run test-rust, container-gated postgres_conformance suite — to run at the end of the series; not yet observed.

Risks and rollout

  • discovery::revocation::InMemoryRevocations is gone; boot and the test fixture hand one InMemoryCapabilities to both discovery and federation. The published list's behaviour (prune on read, order by expiry, refuse past the ceiling, idempotent revoke) is pinned by federation::conformance.
  • FEDERATION_URL unset (the default) changes nothing observable: no federation_url on server-info, and the lifecycle writes refuse 403 error.federation.not_configured once they land.
  • No new crate. No migration yet (ordinal 6 lands with the Postgres slice).

Decisions taken

The plan's record, verbatim, followed by the decisions this run took while delivering it.

Issue 406 - server: federation — capabilities, the pull path, and the sync capability gate (S-E2, S-E5, S-C49)
Plan:     v1 (this document)
Branch:   feat/server-federation-406
Base:     feat/server-album-membership-405 (head of the W-MEMBER PR #458), stacked; the PR targets that branch
Cause:    -
Touches:  capsule-server/src/federation/**, routes/{federation,sync,blob,roster}.rs, serve/{mod,authority}.rs,
          sync/cursor.rs, counter/{mod,budgets}.rs, moderation/mod.rs, discovery/revocation.rs,
          {app,boot,config,lib,cli}.rs, .env.example, migration/src/{lib,m20260902_000006_federation}.rs,
          postgres/mod.rs, openapi.json, tests/{federation,sync,blob,moderation,well_known,conformance,sdk_client}.rs,
          tests/support/mod.rs, locales/en.json + generated i18n, capsule-sdk/src/{federation,sync,fetch,lib}.rs,
          capsule-docs/planned-modules.txt, design/{federation,moderation,api-surfaces,threat-model/validation,
          import/download-sync,module-map}.md, SLICES.md
Will not: add a pull route (pull IS sync+blob, federation.md:19-25); bytes/CPU budgets, error budget, circuit
          breaker, probation (federation.md:135-138); rejected-hash table / inbound stale-revival (:140-155);
          network peer-key fetch or perspective check (:188-190); blocklist exchange (moderation.md:37);
          admin wire surface or admin auth; per-user blocks; capsule-e2e (W-E2E); negotiation.rs; ServerInfo
          fields beyond reading federation_url; capsule-core::federation (S-E4, already landed)
Lane:     serialised behind #405; forecast collisions: W-OIDC #459 on lib.rs, openapi.json, locales/en.json +
          generated i18n, config.rs, boot.rs, .env.example, app.rs, tests/conformance.rs, tests/support/mod.rs,
          SLICES.md, api-surfaces.md; W-E2E #463 on planned-modules.txt, SLICES.md, module-map.md;
          W-MEMBER's own tail on routes/sync.rs, sync/cursor.rs, serve/authority.rs, tests/sync.rs
Settled:  federation.md:19-25,83 (no new protocol; pull-only); :93-107 (claim set, Ed25519 operational key);
          :111-115 (lifecycle, (peer_id,jti) idempotency, 24h + 15-min list); :100 (scope by blob role);
          :117 (transport control); validation.md:64-68,74,147,161-162; api-surfaces.md:168-169,174-187;
          moderation.md:27-29,35-38,58,61,66; download-sync.md:18,49,51,132; module-map.md:134-135;
          SLICES.md S-E2 (4512-4525), S-E5 (4556-4580), S-C49 (3815-3840)

Decisions taken.

1. Pull is the existing sync and blob surfaces under a capability bearer
   Taken:    GET /v1/sync?album_id= and GET /v1/blob/{hash} gain a capability arm; the only new operations
             are mint, revoke, refresh and report intake.
   Rejected: a dedicated /v1/federation/pull route (federation.md:19-25 forbids a new data protocol).
   Reverses: the charter's "pull route" forecast (routes/federation.rs carries lifecycle, not data).

2. Claim set is federation.md:93-105 verbatim; the epoch binding lives in the store record
   Taken:    CapabilityRecord.granted_epoch (from Membership::Member) is checked at presentation; token has no
             epoch claim.
   Rejected: an extra epoch claim (the token format is normative and parsed by every peer, federation.md:89).
   Reverses: the charter's "bound to ... epoch range" as a token field.

3. One bearer component key, two principals
   Taken:    federation::scheme::ReadBearer with NAME "bearer" and a describe() asserted equal to
             AccessToken's; Principal::{Session, Peer}; authenticator delegates to AuthContext first.
   Rejected: widening AccessToken's credential (touches ~40 handlers); MaybeAuth pairs (present-but-invalid
             refuses before the second scheme runs).
   Fallback: if Kynos refuses the duplicate key, NAME "capability" and the SDK registers both keys.
   Reverses: -

4. Coded refusals are rendered by routes from an admitted credential, never by the authenticator
   Taken:    revoked/audience/scope/budget are route-side 403/429 with error.federation.* codes; structural
             and expiry failures stay the framework's uncoded 401 (scheme.rs:87-94).
   Rejected: coding the 401 (no seam in AuthRejection).
   Reverses: leaves error.federation.capability_expired/capability_invalid unused server-side (as today).

5. Blob boundary for a peer: wrong album or never-member is 404, revoked is 403, originals under
   read-derivative-only are 403 scope_insufficient
   Taken:    ReadAuthority takes ReadPrincipal; BlobReadAccess::ScopeInsufficient added; S-C40's 409 is
             account-only (download-sync.md:132).
   Rejected: a separate peer authority trait (the facts are the same store's).
   Reverses: -

6. Revocation writes: roster removal, peer block, explicit revoke, refresh; not epoch-bump-alone, not takedown
   Taken:    routes/roster.rs Applied → federation::on_roster_applied; PeerStore::block cascades; takedown stays
             a serving-hold 410 (moderation.md:58,66).
   Rejected: revoking on every epoch bump (members keep their keys; nothing to cut).
   Reverses: the charter's "takedown" and "epoch bump" as revocation triggers.

7. The capability store is the revocation list
   Taken:    CapabilityStore adapters implement RevocationList; Postgres keeps federation_revoked_jti beside
             federation_capabilities and writes both in one transaction; InMemoryRevocations retired in slice 4.
   Rejected: two sources of truth for "is this jti revoked".
   Reverses: -

8. Budgets through CounterStore only: events/hour per peer, reports/hour per (server,user)
   Taken:    CounterKey::PeerRequests, CounterKey::FederatedReports; budgets in counter/budgets.rs.
   Rejected: bytes/CPU budgets, breaker, probation (need a weighted counter → post-v1, circuit_open key idle).
   Reverses: -

9. Peer identity is operator-pinned; blocklist is a column on the peer row
   Taken:    PeerStore {pin, block, unblock, read}; no outbound HTTP; operator subcommands in a droppable slice.
   Rejected: TOFU fetch of .well-known/capsule/server-info at intake (server has no HTTP client).
   Reverses: the charter's "how the granting server knows the peer (server-info keys)" — the granting server
             needs no peer key to mint; the pinned key serves signed reports.

10. Report intake is a signed, unauthenticated write; the queue is the port
   Taken:    POST /v1/federation/reports; ModerationStore::file_report/pending_reports; no admin surface.
   Rejected: an admin read route (no admin auth exists; SLICES.md:3830-3834).
   Reverses: -

11. Cursor scope gets a third byte for peers
   Taken:    CursorScope::peer(peer_id, album), MAC input byte 2.
   Rejected: reusing CursorScope::album with the peer id as caller (structural separation is one byte).
   Reverses: -

12. SDK orchestration over generated calls only
   Taken:    federation::FederationPull; SyncConsumer::pull_album; CapabilityBlobSource over rest get_blob;
             revoked-jti poll with the 15-min fail-closed rule.
   Rejected: reusing HttpBlobSource (raw reqwest over a Session).
   Reverses: -

13. Federation is opt-in by FEDERATION_URL
   Taken:    config.federation_url → ServerInfo::with_federation; writes refuse 403 error.federation.not_configured
             when unset (mirrors #459's oidc_not_configured).
   Rejected: conditional route mounting (router type is static, lib.rs:257-261).
   Reverses: -
14. Existing moderation catalog keys are reused for the peer-facing refusals
    Taken:    error.moderation.server_blocked (blocked peer at mint / presentation / refresh / intake),
              error.moderation.report_unsigned (bad report signature), error.moderation.report_rate_limited
              (per-(server,user) report budget). New keys: error.federation.not_configured, peer_unknown,
              member_not_on_roster, unavailable.
    Rejected: the plan's error.federation.peer_blocked / report_signature_invalid — duplicates of keys the
              catalog already carries for exactly these refusals (locales/en.json, slice S-C8).
    Reverses: the plan's D8 key list; a client switching on the moderation codes already localizes them.

15. InMemoryRevocations retired with slice 1, not slice 4
    Taken:    boot and the fixture hand one InMemoryCapabilities to discovery and federation from the
              first commit; the four adapter cases moved into federation::conformance unchanged in
              substance; check_revocation and its cases stay in discovery.
    Rejected: two revocation objects for three slices (the fixture would have needed a second rewire).
    Reverses: nothing observable; /.well-known/capsule/revoked-jti tests pass unchanged.

16. The codec is stricter than the plan's claim list
    Taken:    jti must be a UUIDv7, the aud suffix a UUID, min_protocol_version a YYYY-MM-DD date, no
              unknown claims (deny_unknown_fields); a non-positive TTL is clamped to one second rather
              than signed unreadable.
    Rejected: presence-only checks (a token this server never minted would verify under its own key).
    Reverses: nothing a conforming peer sees; federation.md's table names these types already.

17. The store refuses what the route would otherwise have to
    Taken:    issue/refresh reject a record past the 24 h ceiling and a successor naming another peer,
              album or member; a list-side revoke of an issued jti publishes under the record's own
              expiry and never shortens an entry.
    Rejected: trusting every caller of the port to have come through the codec.
    Reverses: -

Unresolved review notes

  • nbf is honoured with zero leeway on the issuer's own clock; the verifier in v1 is always the issuer, so the skew tolerance the table gives nbf is nil here. Recorded, not changed.

…e module context

Land `capsule-server::federation` (S-E2, S-E5, S-C49): the EdDSA-JWT capability
a peer presents on the existing sync and blob reads, the codec that mints and
reads it under the same Ed25519 key session tokens are signed with, and the two
ports behind it.

`CapabilityStore` records every capability this server issued and implements
`RevocationList`: the store is the list `/.well-known/capsule/revoked-jti`
serves, so "is this jti revoked" has one answer. `PeerStore` holds the peers an
operator has pinned and the server-level blocklist as a column on the same row.
Both have in-memory adapters and one conformance suite, which now also carries
the four cases the retired `discovery::revocation::InMemoryRevocations` had.

The claim set is design/federation.md's table verbatim, with RFC 3339 instants
checked against the injected clock, the 24 h ceiling enforced at mint and at
verify, and every instant at whole seconds so a stored grant re-signs
byte-for-byte for an idempotent refresh. A session token is unreadable to the
capability codec and a capability to the session verifier, by construction.

`FederationContext` joins `App`/`Modules`, `boot` and the test fixture; `boot`
builds the codec from the same DER as the session signer and asserts the
published key is the one capabilities verify under. `FEDERATION_URL` opts a
deployment in and is published as `server-info.federation_url`. Four
`error.federation.*` catalog keys are added for the lifecycle routes that
follow, and `capsule-server::federation` leaves `planned-modules.txt`.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 6, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0b7fe30
Status: ✅  Deploy successful!
Preview URL: https://21b0ee6a.capsule-22k.pages.dev
Branch Preview URL: https://feat-server-federation-406.capsule-22k.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant