From 93cff73caa41ab27c463280d39b426ecc5f3f447 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 20 Jul 2026 11:03:10 -0500 Subject: [PATCH] Align permissioned data with proposal semantics --- bench/README.md | 1 + bench/main.zig | 30 +- docs/benchmarking.md | 5 +- docs/permissioned-data-proposal-94.md | 78 +-- docs/permissioned-data.md | 101 +++- docs/session-handoff-2026-07-16.md | 379 +++++++++++++ src/atproto/oauth.zig | 31 +- src/atproto/oauth/permission_sets.zig | 274 ++++----- src/atproto/space.zig | 589 ++++++++++++++----- src/http/router.zig | 19 +- src/internal/cli.zig | 8 +- src/internal/client_attestation.zig | 130 +++++ src/internal/email_tokens.zig | 3 + src/internal/permissioned_data.zig | 186 ++++-- src/internal/scopes.zig | 65 ++- src/internal/space_uri.zig | 115 ++++ src/main.zig | 2 +- src/root.zig | 45 +- src/storage/blobstore.zig | 3 +- src/storage/store.zig | 785 +++++++++++++++++++------- tools/smoke-permissioned.sh | 45 +- 21 files changed, 2262 insertions(+), 632 deletions(-) create mode 100644 docs/session-handoff-2026-07-16.md create mode 100644 src/internal/client_attestation.zig create mode 100644 src/internal/space_uri.zig diff --git a/bench/README.md b/bench/README.md index aaa92a2..7795124 100644 --- a/bench/README.md +++ b/bench/README.md @@ -85,6 +85,7 @@ blob, then measures these paths as distinct units of work: - `createRecord` into a space writer repo - `getRecord` by `(space, repo, collection, rkey)` - `listRecords`, limit 50, with values included by default +- full-state `getRepo` CAR construction, including signed commit and record index - `getBlob` storage readback - `listRepoOps` catch-up reads diff --git a/bench/main.zig b/bench/main.zig index fa2f804..0bded7e 100644 --- a/bench/main.zig +++ b/bench/main.zig @@ -629,6 +629,7 @@ fn benchSpace(allocator: std.mem.Allocator, options: Options) !void { std.debug.print("\n=== zds permissioned-space benchmarks ===\n", .{}); (try benchSpaceListSpaces(allocator, state.account, space.uri)).print(); (try benchSimpleSpaceListMembers(allocator, space.uri)).print(); + (try benchSpaceRepoCar(allocator, state.account, space.uri)).print(); (try benchSpaceWrite(allocator, state.account, space.uri, records)).print(); (try benchSpaceGetRecord(allocator, state.account, space.uri, records)).print(); (try benchSpaceListRecords(allocator, state.account, space.uri, records)).print(); @@ -637,6 +638,31 @@ fn benchSpace(allocator: std.mem.Allocator, options: Options) !void { (try benchSpaceBlob(allocator, state.account, cid)).print(); } +fn benchSpaceRepoCar(allocator: std.mem.Allocator, account: zds.auth.tokens.Account, space: []const u8) !BenchResult { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const iterations: usize = 20; + var total_bytes: usize = 0; + const start = nowNs(); + for (0..iterations) |_| { + _ = arena.reset(.retain_capacity); + const a = arena.allocator(); + const state = try zds.storage.store.getSpaceRepoState(a, space, account.did); + const set_hash = state.set_hash orelse return error.MissingRecords; + const rev = state.rev orelse return error.MissingRecords; + var keypair = try zds.storage.store.signingKeypair(account.did); + const commit = try zds.internal.permissioned_data.createCommit(a, std.Options.debug_io, set_hash, .{ + .space = space, + .author = account.did, + .rev = rev, + }, &keypair); + const records = try zds.storage.store.loadSpaceRepoBlocks(a, space, account.did); + const car = try zds.internal.permissioned_data.serializeRepoCar(a, commit, records); + total_bytes += car.len; + } + return .{ .name = "space getRepo", .ops = iterations, .bytes = total_bytes, .elapsed_ns = nowNs() - start }; +} + fn seedSpaceRecords( allocator: std.mem.Allocator, account: zds.auth.tokens.Account, @@ -751,7 +777,7 @@ fn benchSpaceListRepos(allocator: std.mem.Allocator, space: []const u8) !BenchRe const start = nowNs(); for (0..iterations) |_| { _ = arena.reset(.retain_capacity); - const repos = try zds.storage.store.listSpaceRepos(arena.allocator(), space, null, 50); + const repos = try zds.storage.store.listSpaceWriters(arena.allocator(), space, null, 50); if (repos.len == 0) return error.MissingRecords; } return .{ .name = "space listRepos", .ops = iterations, .elapsed_ns = nowNs() - start }; @@ -764,7 +790,7 @@ fn benchSpaceOplog(allocator: std.mem.Allocator, account: zds.auth.tokens.Accoun const start = nowNs(); for (0..iterations) |_| { _ = arena.reset(.retain_capacity); - const ops = try zds.storage.store.listSpaceRecordOplog(arena.allocator(), space, account.did, null, 100); + const ops = try zds.storage.store.listSpaceRecordOplog(arena.allocator(), space, account.did, null, 100, true); if (ops.len == 0) return error.MissingRecords; } return .{ .name = "space listRepoOps", .ops = iterations, .elapsed_ns = nowNs() - start }; diff --git a/docs/benchmarking.md b/docs/benchmarking.md index f8d70b9..d53b693 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -111,8 +111,9 @@ repo tables would hide the actual access pattern. The first benchmark slice should be small and diagnostic rather than exhaustive: - seed one self-owned private space and one writer repo. -- measure `createSpace`, record write, record read, `listRecords`, ranged - `getBlob`, and writer repo oplog catch-up separately. +- measure `createSpace`, record write, record read, `listRecords`, full-state + `getRepo` CAR construction, ranged `getBlob`, and writer repo oplog catch-up + separately. - report p50/p95/p99/max for the concurrent paths, but keep write, read, blob, and oplog rows separate. - compare to Daniel's permissioned-data branch only after we can run that PDS diff --git a/docs/permissioned-data-proposal-94.md b/docs/permissioned-data-proposal-94.md index 7dd6b25..11ea06f 100644 --- a/docs/permissioned-data-proposal-94.md +++ b/docs/permissioned-data-proposal-94.md @@ -37,7 +37,7 @@ several product shapes that can have different needs: - groups: private forums, communities, group chats He also questioned the cost of creating a parallel data universe and called out -URI-scheme complexity around `ats://`. This is worth tracking even if the +URI-scheme complexity. This is worth tracking even if the thread did not fully engage Daniel's earlier long-form diaries. Richard has done foundational protocol/security work, so treat the critique as meaningful design pressure rather than as implementation guidance. @@ -52,13 +52,14 @@ Daniel's reply clarified the current bet: the motivating use case is groups, and the proposal tries not to be dogmatic about one protocol per product category. If the group-shaped primitive is expressive enough for the simpler cases without becoming overwrought, that is a reasonable design win. He also -said he is still wrestling with URI scheme choice and is leaning back toward -keeping `at`. +said he was leaning back toward keeping `at`. Proposal 0016 now uses canonical +`at://` URIs with a literal `space` path marker, and ZDS follows that shape. ZDS implications: -- keep our docs careful about `ats://`; do not invest in naming churn until the - proposal lands +- treat the `at://{authority}/space/{type}/{skey}` shape as the current + experimental foundation, without maintaining the superseded scheme as an + alias - keep private personal data as the motivating local use case, not a claim that ZDS has solved groups - do not encode a universal group model into ZDS while the proposal is still @@ -71,7 +72,7 @@ ZDS implications: The current ZDS implementation broadly matches these proposal instincts: - permissioned data is separate from public repo data -- permissioned records use an `ats://` address that names space authority, +- permissioned records use an `at://` address with a `space` marker that names space authority, space type, space key, writer repo, collection, and record key - one writer has one permissioned repo per space - record sets are represented with LtHash-style set commitment state @@ -98,28 +99,29 @@ These are the main known gaps between ZDS's current prototype and PR #94: - ZDS should use the proposal names directly: `getDelegationToken` for the PDS-issued OAuth query, and `listRepoOps` for incremental permissioned-repo sync. -- The proposal adds `com.atproto.space.listRepos` so a space host can list known - writer repos in a space without enumerating readers. -- The proposal adds `registerNotify` as an explicit syncer registration method. - ZDS currently records credential recipients and supports write/deletion - notifications, but does not expose this method shape. -- Space credentials and delegation tokens have specific JWT `typ`, `sub`, - `aud`, `client_id`, lifetime, and verification expectations. Keep ZDS helper - names and token shapes aligned with those names instead of inventing a local - credential vocabulary. -- The proposal introduces optional client attestation for app-bound space - credentials. -- Space-authority DID documents are expected to publish `#atproto_space` and - `#atproto_space_host` material. ZDS currently does not model this separately - from ordinary account/PDS signing material. -- Commit state is more than an LtHash value. The draft includes random commit - `ikm`, a signature over commit context, and a MAC over the record-set hash. - This is a deliberate deniability design: do not accidentally make a - permissioned commit into a durable public proof of private content. -- OAuth `space:` scopes distinguish `read` from `read_self` and separate - lifecycle/admin capability with `manage=`. -- Permission-set records use proposal vocabulary such as `spaceType`; older ZDS - permission-set handling should be audited before relying on it. +- `com.atproto.space.listRepos` is implemented as a space-credential-only view + of the authority's writer registry. `notifyWrite` advances that registry with + the writer's revision and SHA-256 digest of its LtHash state; raw LtHash state + remains private to the writer's repo host. +- `registerNotify` stores expiring whole-space or repo-scoped syncer + registrations. ZDS follows the branch's 24-hour registration window. +- Delegation tokens identify a requesting user and contain no app identity. + Space credentials identify the authority and space and likewise contain no + app identity. Optional app identity arrives separately as a verified client + attestation. +- ZDS currently uses the proposal's permitted `#atproto` signing-key fallback. + For remote authorities it resolves `#atproto_space_host` when present and + falls back to `#atproto_pds`; ZDS does not yet publish dedicated space key or + host entries for its own resident authorities. +- Commit state uses versioned deniable commits: random `ikm`, a signature over + `(space, author, rev, ikm)`, and a context-derived MAC over the record-set + hash. Wire bytes use AT JSON `$bytes` values. +- OAuth `space:` scopes distinguish `read` from collection-constrained + `read_self`, use `authority=`, and separate lifecycle/admin capability with + repeated `manage=` parameters. Permission sets use `spaceType`, may name + cross-namespace collections, and resolve `authority=self` at grant issuance. +- Delegation tokens and verified client attestations are short-lived, + single-use inputs consumed atomically when minting a space credential. - Space deletion semantics need review. The draft distinguishes space-authority deletion from member repo ownership and does not imply arbitrary erasure of every writer's local data. @@ -135,8 +137,8 @@ These are low-level details to keep visible before attempting parity work: TLS-style length-prefixed context used around signed commit material is big-endian. - The operation log is a sync shortcut, not permanent history. A host may - compact or drop it; syncers must be able to compare set hashes and recover by - listing records. + compact or drop it; syncers compare set hashes and recover from the full + two-root CAR returned by `getRepo`. - Permissioned sync is relay-less and pull-based. Real-time behavior comes from write notifications through the space authority, but correctness cannot rely on every notification arriving. @@ -146,10 +148,13 @@ These are low-level details to keep visible before attempting parity work: ## export and backup pressure -The draft does not currently look like it wants a `getSpaceRepo` CAR equivalent -for permissioned repos. That may be intentional: a permissioned repo is not -necessarily the public MST repo shape, and backfill can be expressed through -space enumeration plus `listRecords`. +The current proposal defines `com.atproto.space.getRepo` for full-state +recovery. This is not the public MST repo shape: the CAR roots are a deniable +signed commit and a DAG-CBOR map from `collection/rkey` to record CID. Record +blocks follow in the same lexicographic order, while blobs are fetched +separately. Account-wide backup still has to enumerate the resident's spaces +and export each writer repo; the proposal does not define one aggregate backup +archive. For ZDS, avoid inventing a `getSpaceRepo` endpoint unless the proposal moves that way. The safer design pressure is exportability: @@ -192,7 +197,8 @@ richer application/group semantics to apps. Some commentary describes the boring access model as a list of DIDs. In ZDS terms, do not read that as permission to restore the old protocol member-list sync surface. Treat it as space-management policy, especially for -`com.atproto.simplespace.*` and managing-app credential minting. +`com.atproto.simplespace.*`, including the service-authenticated +`checkUserAccess` policy hook used during managing-app credential minting. Implementation note: ZDS exposes the proposal namespace directly and does not carry the old `com.atproto.space.createSpace` / `updateSpaceConfig` / @@ -204,7 +210,7 @@ own older namespace; ZDS intentionally does not. Before adding more endpoints, prefer tests that exercise durable semantics: - per-space and per-writer repo isolation -- `ats://` parsing and formatting +- canonical permissioned `at://` parsing, formatting, and stored-row migration - OAuth scope gating for authority reads, self reads, writes, and management - ranged blob reads through permissioned-data auth - write oplog ordering and cursor behavior diff --git a/docs/permissioned-data.md b/docs/permissioned-data.md index 2cfd5eb..f36649a 100644 --- a/docs/permissioned-data.md +++ b/docs/permissioned-data.md @@ -18,7 +18,11 @@ even when an operator has not enabled it. ## references -- Discourse: +- Proposal: +- Diary 7, signed commits and sync: + +- Current discussion: +- Earlier lexicon discussion: - Member-list direction: - Branch: @@ -53,10 +57,12 @@ to get private records and blobs working early. ZDS splits protocol data routes from baseline PDS management routes: - `com.atproto.space.*`: `getSpace`, `listSpaces`, `listRepos`, - `getDelegationToken`, `getSpaceCredential`, records, blobs, writer state, - write notifications, and deletion notifications + `getDelegationToken`, `getSpaceCredential`, records, blobs, signed commits, + full and incremental repo sync, notification registration, write + notifications, and deletion notifications - `com.atproto.simplespace.*`: `createSpace`, `updateSpace`, `deleteSpace`, - `addMember`, `removeMember`, and `listMembers` + `addMember`, `removeMember`, `listMembers`, and the managing-app + `checkUserAccess` hook ZDS uses the proposal names for the credential and sync-read surface: `getDelegationToken` and `listRepoOps`. @@ -88,17 +94,35 @@ permissioned space rather than relying on a universal PDS-wide member list. `simplespace` membership is stored only as baseline space-management policy state. It is not exposed as a protocol sync surface. For a `member-list` space, ZDS mints credentials and allows bearer reads/writes only for listed DIDs. For -`public`, any requester can be authorized. `managing-app` is represented in -config, but ZDS currently fails closed until the `checkUserAccess` service-auth -hook is implemented. - -`appAccess` follows the proposal shape: `{"type":"open"}` or -`{"type":"allowList","allowed":[...]}`. ZDS can enforce the literal client ID -it sees in a delegation token; client attestation remains an explicit gap. +`public`, any requester can be authorized. For `managing-app`, the authority +calls the configured service's `com.atproto.simplespace.checkUserAccess` method +with service auth and fails closed on resolution, transport, or response errors. +The generic PDS implementation of that method returns `authorized: false`; +policy services replace it with their own application-layer decision. + +`appAccess` follows the proposal's lexicon-union wire shape: +`{"$type":"com.atproto.simplespace.defs#open"}` or +`{"$type":"com.atproto.simplespace.defs#allowList","allowed":[...]}`. +Delegation tokens identify the user, not the app. For allow-list spaces, ZDS +verifies the separately supplied `clientAttestation` against the client's +published metadata and JWKS before using its `client_id`. Writer notifications are keyed by writer repo. `notifyWrite` verifies service -auth from the writer repo to the space DID, then fans out to registered -credential recipients. +auth from the writer repo to the space DID, records the writer's latest revision +and SHA-256 digest of its LtHash state at the authority, then fans the same +summary out to registered syncers. `listRepos` is space-credential-only and +returns this authority-maintained writer set; it never exposes the raw LtHash +state or a reader/member list. + +`registerNotify` stores a 24-hour subscription. Omitting `repo` registers a +syncer with the space host for the whole space; naming `repo` registers it with +that repo's host only. Notifications remain best effort, so syncers compare +revisions and commit hashes and recover through direct pulls. + +When notifying a remote authority, ZDS resolves its dedicated +`#atproto_space_host` service when present and falls back to `#atproto_pds`. +This preserves the proposal's separation between an authority's identity, +space host, and account PDS. ## storage shape @@ -111,15 +135,43 @@ explicit space-scoped tables instead of the public repo tables: - `simplespace_members`: baseline PDS-managed member-list policy state keyed by `(space, member_did)` - `permissioned_space_records`: current records keyed by - `(space, repo_did, collection, rkey)` with CID, DAG-CBOR value, repo revision, - and indexed timestamp -- `permissioned_space_repos`: writer repo state and current record-set hash + `(space, repo_did, collection, rkey)` with CID, AT JSON value, repo revision, + and indexed timestamp; canonical DAG-CBOR is reconstructed and CID-checked + for full-state export +- `permissioned_space_repos`: repo-host state, including the full LtHash state + needed to update and sign that writer's local permissioned repo +- `permissioned_space_writers`: authority-host writer registry, containing only + each known writer's latest revision and 32-byte commit digest - `permissioned_space_record_oplog`: incremental record changes by `(space, repo_did, rev, idx)` -- `permissioned_space_credentials`: short-lived prototype credentials and - credentials -- `permissioned_space_credential_recipients`: services to notify for writes and - space deletion +- `permissioned_space_notify_registrations`: expiring whole-space and + repo-scoped write-notification subscriptions +- `permissioned_space_used_delegations`: consumed one-use delegation-token IDs +- `permissioned_space_used_client_attestations`: consumed one-use client + attestation IDs + +Space roots use the canonical proposal URI: + +```text +at://{authorityDid}/space/{spaceType}/{skey} +``` + +Permissioned record URIs append `{authorDid}/{collection}/{rkey}`. ZDS migrates +the earlier experimental URI scheme in storage and does not accept it at the +HTTP boundary. + +`listSpaces` enumerates actor-local space rows: spaces the resident owns or has +materialized a permissioned writer repo in. Being named in a simplespace member +list does not make a space appear there. + +OAuth space grants use `authority=`, repeated `action=` record operations, and +separate repeated `manage=` space-management operations. `authority=self` is +resolved to the granting account's DID when the token is issued. + +OAuth reads are limited to the authenticated account's own permissioned repo. +Whole-space writer enumeration and cross-repo reads require a space credential. +Delegation tokens and client attestations are short-lived and consumed together +in one transaction when the authority mints that credential. Permissioned records and blobs are not public repo records. They must not be squeezed into `records`, `repo_blocks`, `commits`, or `seq_events`. @@ -129,6 +181,15 @@ line with the permissioned-data proposal. Consumers that only need the collection, rkey, and CID listing can pass `excludeValues=true` to avoid materializing record JSON. +`com.atproto.space.listRepoOps` follows the same value rule. Create and update +operations inline a value only while that operation's CID is still the current +record CID; deletes and superseded operations remain metadata-only. + +`com.atproto.space.getRepo` is the full-state recovery path. Its CAR declares +the freshly generated deniable signed commit and a DAG-CBOR record index as its +two roots, then carries record blocks in lexicographic `collection/rkey` order. +Blobs are deliberately excluded and remain available through `getBlob`. + ## Zat first Before implementing local primitives, check Zat's current public API. ZDS uses diff --git a/docs/session-handoff-2026-07-16.md b/docs/session-handoff-2026-07-16.md new file mode 100644 index 0000000..abd7194 --- /dev/null +++ b/docs/session-handoff-2026-07-16.md @@ -0,0 +1,379 @@ +# session handoff: zds exploration through 2026-07-16 + +This handoff summarizes the long ZDS work session that moved the project from a +small experimental PDS into a more serious, operator-run sandcastle with OAuth, +account management, permissioned-data experiments, better sync behavior, and +benchmark coverage. + +It is intentionally descriptive. Treat it as context for the next engineer, not +as a command queue. Re-read the current protocol docs, ZDS source, Tranquil, +reference PDS, and `zat` before changing semantics. + +## current state + +- Branch: `main` +- Remote status at handoff time: clean against `origin/main` +- Current head: `fd0c1af Align space listRecords value shape` +- Latest deployed target during the session: `pds.zat.dev` / Fly app `zds-pds` +- Latest known deployed Fly machine after `fd0c1af`: version `226`, region `ord` +- Permissioned data is operator gated with `ZDS_PERMISSIONED_DATA=true`. +- Invite-code admin assumes `ZDS_ADMIN_TOKEN` is in the repo `.env`; use: + +```sh +set -a; . ./.env; set +a; just invite +``` + +Before committing code changes, run: + +```sh +just test +just smoke +git diff --check +zig zen +``` + +Also run `just smoke-permissioned` when touching `com.atproto.space.*`, +permissioned-data storage, or `/account/spaces`. + +## posture learned the hard way + +- Start from protocol text, then compare against reference PDS, Tranquil, + Pegasus, and local notes. Do not invent PDS behavior from vibes. +- ZDS owns ZDS. Do not edit sibling `zat` from this repo without explicit + approval. If a primitive clearly belongs in `zat`, write down the desired API + and let the `zat` owner decide. +- Do not vendor or patch dependency internals as a convenience. Install pinned + dependencies normally. +- Avoid compatibility hedging for old ZDS-only experimental shapes. There are + no broad external users to preserve accidental legacy for. If an experimental + protocol shape moves, remove the old shape rather than keeping aliases unless + the user explicitly asks for a transition window. +- Browser-visible failures need request/response evidence, not intuition. +- Benchmarks should compare equivalent work only. Keep adjacent probes adjacent, + not mixed into the same table. + +## major shipped areas + +### Operator configuration and docs + +- Added/cleaned operator docs under `docs/operations.md`. +- Documented invite-code behavior in `docs/invite-codes.md`. +- Added environment-variable support for port and DB path. +- Adopted SemVer tags; patch releases were cut for operator-visible fixes. +- README references now include `haileyok/cocoon`. + +### Email delivery + +- Added pluggable email-provider structure. +- Implemented Comail as the default provider. +- Documented Comail in `docs/comail.md`. +- Important deployment details: + - `ZDS_MAIL_PROVIDER=comail` + - `ZDS_COMAIL_API_KEY` + - `ZDS_COMAIL_DID` + - `ZDS_EMAIL_FROM` must be a bare email address. +- Verified account email flow against Bluesky UI after configuring Fly secrets. + +### Account, sessions, and resident UX + +- Added `/account` as the resident-facing hub. +- Folded security/session/app-password/passkey/account status surfaces into the + account hub. +- Improved the resident sessions UI after several UX passes. +- Added operator/admin account-session visibility. +- Added account status/takedown support and runbook docs: + `docs/account-takedown-runbook.md`. +- Important semantic point: ZDS now models more than active/deactivated, but do + not claim full support for protocol statuses unless storage, eventing, and + API responses all represent the distinction. + +### OAuth and auth hardening + +- Implemented and hardened ATProto OAuth/DPoP behavior. +- Added stateless DPoP nonce behavior using a shared secret + (`ZDS_DPOP_SECRET`, falling back to `ZDS_JWT_SECRET`). +- Hardened token families and refresh behavior. +- Added discriminating OAuth logging for invalid grant/code paths. +- Fixed several OAuth migration-order and token-row bugs. +- Verified behavior against atproto.com expectations and compared with + reference PDS/Tranquil while doing the DPoP work. + +### Repo and sync correctness + +- Fixed large `applyWrites` request bodies by moving off the small fixed buffer + path and mapping oversize bodies correctly. +- Fixed firehose `#commit.since` to use previous rev semantics rather than the + previous commit CID. Mia reported this; it was vetted against the sync spec + and Daniel Holmgren's synchronization draft before fixing. +- Fixed sync event migrations and bounded sync event rebuild memory. +- Fixed repo block migration order and import/export reachability. +- Added stricter full-CAR import behavior; incomplete repo CARs are rejected. +- Fixed `getRepo` behavior: + - full repo export reachability + - `HEAD` response path + - notes moved to `docs/getrepo-notes.md` +- Adopted `zat.signCommit` for repo commit signing after `zat` released the + helper. + +### Dependency graph and release posture + +- Moved ZDS onto the first-party canonical `http.zig` / `websocket.zig` / + `zat` dependency graph. +- Avoid resurrecting old Karl/httpz/websocket forks. +- The relevant dependency lesson: if `zat`, `httpz`, and ZDS each see different + websocket packages, Zig module identity breaks. Keep the graph on one + canonical websocket release. +- The local rule is documented in README and development docs: ZDS is an + application and should consume `zat`/`httpz`; broadly useful protocol + primitives can move upstream only after being made explicit. + +### Performance and benchmarks + +- Added benchmark coverage in `bench/`, with docs in `bench/README.md`. +- Added focused `getRepo` export benchmark. +- Added public repo read/write, sync, blob, HTTP route, and permissioned-data + benchmark slices. +- Fixed obvious repo-write performance problems by adopting lazy MST loading. +- Added apples-to-apples notes comparing ZDS, Tranquil, and official PDS where + equivalent measurements exist. +- Do not put unlike operations in the same comparison table. Example: + Tranquil index-only list rows are not the same unit as ZDS full + materialization. + +### Stats and health UI + +- Added a `/stats` health/latency page. +- Iterated on it after user feedback: + - do not count the stats page's own refreshes as user-facing API traffic + - avoid noisy not-found rows + - separate appview/proxy latency from local PDS behavior + - make “needs attention” more legible +- Open desire: persistent/historical stats that survive deploys. Current stats + remain in-process. + +## permissioned data work + +This was the biggest exploratory arc. Current docs: + +- `docs/permissioned-data.md` +- `docs/permissioned-data-proposal-94.md` +- `bench/README.md#permissioned-data` + +### Overall direction + +- Permissioned data is explicitly experimental and behind + `ZDS_PERMISSIONED_DATA`. +- The project intentionally tracks the current proposal direction instead of + preserving old local shapes. +- Protocol routes live under `com.atproto.space.*`. +- Baseline PDS-managed space management lives under + `com.atproto.simplespace.*`. +- Old protocol member-list routes were removed. The later proposal/discussion + direction pushes rich reader/group semantics into applications or space-host + policy instead of making a universal protocol member list. + +### Current implemented surface + +Protocol/data surface: + +- `com.atproto.space.getSpace` +- `com.atproto.space.listSpaces` +- `com.atproto.space.listRepos` +- `com.atproto.space.getDelegationToken` +- `com.atproto.space.getSpaceCredential` +- `com.atproto.space.createRecord` +- `com.atproto.space.putRecord` +- `com.atproto.space.deleteRecord` +- `com.atproto.space.applyWrites` +- `com.atproto.space.getRecord` +- `com.atproto.space.listRecords` +- `com.atproto.space.getBlob` +- `com.atproto.space.getLatestCommit` +- `com.atproto.space.listRepoOps` +- `com.atproto.space.notifyWrite` +- `com.atproto.space.notifySpaceDeleted` + +Baseline management surface: + +- `com.atproto.simplespace.createSpace` +- `com.atproto.simplespace.updateSpace` +- `com.atproto.simplespace.deleteSpace` +- `com.atproto.simplespace.addMember` +- `com.atproto.simplespace.removeMember` +- `com.atproto.simplespace.listMembers` + +### Access model + +- ZDS treats permissioned data as private writer repos plus space credentials. +- Application-level access semantics stay above the PDS. Examples: supporter + access, label rosters, private subscriptions, follower-only spaces, group + roles. +- `simplespace` membership is only baseline policy state for PDS-managed + spaces, not a protocol sync surface. +- `managing-app` policy calls the configured `checkUserAccess` service with + authority service auth and fails closed on resolution or response errors. +- `appAccess` supports `open` and `allowList`; allow-list decisions use a + separately verified client attestation rather than a delegation-token claim. +- Space roots now use the canonical + `at://{authorityDid}/space/{spaceType}/{skey}` syntax. The old `ats://` + experiment is migrated in storage and rejected at the HTTP boundary. +- OAuth grants use proposal-shaped `authority`, `action`, and `manage` + parameters; `authority=self` is resolved to the resident DID at issuance. +- `listRepos` reads a distinct authority-owned writer registry populated from + `notifyWrite` revision and commit-digest summaries. It does not expose the + repo host's raw LtHash state. +- Deniable commits now use the proposal context and AT JSON byte encoding. +- Delegation tokens and cryptographically verified client attestations are + short-lived, one-use inputs consumed atomically during credential exchange. + +### Blob handling + +- Permissioned record blob refs are tracked separately from public record blob + refs. +- Blob bytes are still stored once in the author's PDS blobstore. +- A blob referenced only by a permissioned record must not become public via + `com.atproto.sync.getBlob`. +- Permissioned blob reads go through `com.atproto.space.getBlob(space, repo, + cid)` because the space is the auth context. +- This matches Daniel's explanation: author uploads blob to author PDS, creates + a permissioned record referencing it, and authorized syncers fetch the blob + from the author's PDS through the space-authenticated path. + +### `listRecords` value shape + +- Latest commit `fd0c1af` aligned `com.atproto.space.listRecords` with the + proposal: records include `value` by default. +- `excludeValues=true` returns metadata-only rows with collection/rkey/CID. +- This was motivated by the sibling Racine project + (`~/tangled.org/zzstoatzz.io/racine`), which was forced into an N+1 + hydration pattern: `listRecords` then many `getRecord` calls. +- Live production probe after deploy confirmed: + - default `listRecords` includes `value` + - `excludeValues=true` omits `value` + - cursor shape is unchanged + +### Permissioned-data follow-up audit + +These are not necessarily bugs; they are places where the evolving proposal may +expect more than current ZDS provides. + +- `listRepoOps` now inlines current values by default and supports + `excludeValues=true`, matching the proposal's sync shape. +- ZDS implements the proposal's full-state permissioned `getRepo` CAR: deniable + signed-commit root, DAG-CBOR index root, then lexicographically ordered record + blocks. Daniel's branch exposes the lexicon but its handler is still a stub. +- `registerNotify` supports expiring whole-space and repo-scoped subscriptions, + matching the current lexicon and branch's 24-hour lifetime. +- Space credential/delegation-token details should keep tracking proposal + changes, especially `typ`, `aud`, `sub`, and dedicated space DID material. +- The generic PDS `checkUserAccess` endpoint deliberately denies; a configured + managing app supplies the application-specific authorization decision. + +## sibling projects touched or used as forcing functions + +### plyr.fm + +Plyr.fm was the first permissioned-media adopter and drove much of the +permissioned-data work: + +- private media should upload blobs to the user's PDS with normal + `com.atproto.repo.uploadBlob` +- records should live in a permissioned space +- playback should fetch through `com.atproto.space.getBlob` +- OAuth scope expansion and permission-set handling exposed several ZDS auth + bugs +- token lifetime/refresh behavior was hardened because plyr sessions died after + roughly an hour + +There is an issue in `zzstoatzz/plyr.fm` tracking the minimum viable private +media shape. Its body/comments were updated during the session. Re-read it +before changing ZDS behavior that plyr depends on. + +### racine + +Racine is a small family-tree app at +`~/tangled.org/zzstoatzz.io/racine`. It reads private records from a +permissioned space on `waow.tech`: + +- space type: `tech.waow.tree` +- skey: `self` +- collections: `tech.waow.tree.person`, `tech.waow.tree.union`, + `tech.waow.tree.edge` + +Racine made the `listRecords` value-shape problem obvious. It should be able to +drop the N+1 `getRecord` hydration path now that ZDS returns values by default. + +### zlay, atproto-bench, Tranquil, reference PDS + +- `zlay` and `atproto-bench` were used as context for commit signing, + dependency graph, and benchmarking. +- Tranquil and reference PDS are semantic references, not authorities to copy + blindly. Use them to understand mature behavior and then reason against the + protocol. + +## issue/bug arcs handled + +- Invalid email tokens: ZDS generated tokens containing digits the Bluesky app + rejected. Fixed and released as a patch. +- Missing env vars: added env support/docs for port and DB path. +- Email not sending: implemented Comail provider and deployed secrets. +- Migration/account deactivated in Bluesky: root cause involved relay/appview + state, not a ZDS code patch. Important diagnostic lesson: Blacksky working + means the repo/PDS can be fine while Bluesky appview state is stale. +- Slow feeds/appview proxy: added stats and diagnosed slow proxied XRPCs rather + than assuming the user was mistaken. +- PDS debugger relay statuses: noted surprising offline statuses; still worth + following up, likely via crawler/request-crawl/vsky behavior. +- ApplyWrites large body: fixed request-size limit and error mapping. +- Firehose `#commit.since`: fixed previous-rev semantics and responded to Mia. +- Dependency graph breakage: resolved by moving onto canonical first-party + websocket/httpz/zat graph. + +## deployment and verification habits + +Do not deploy because a build completed. Deploy after: + +1. reading the relevant protocol/reference behavior +2. implementing narrowly +3. running the required checks +4. running the relevant smoke/bench lane +5. probing production behavior when possible + +Useful production probes: + +- `fly status` +- `fly logs` +- `/xrpc/_health` +- `/api/openapi.json` +- authenticated `com.atproto.space.listRecords` for a known resident account +- PDS debugger and pdsls firehose when sync behavior changed + +When probing with local `.env` files, do not print secrets or access tokens. + +## immediate next useful work + +Prioritize these only after checking current source and protocol state: + +1. Align `com.atproto.space.listRepoOps` with the proposal's values-by-default + plus `excludeValues` shape. +2. Let Racine remove its N+1 hydration fallback and verify cold-load latency + drops from seconds to one paged call per collection. +3. Re-check HappyView and proposal PR #94 for any further permissioned-data + drift, especially `getRepo`, `getLatestCommit`, notification registration, + and credential JWT details. +4. Add persistent historical stats, but design it as operator/resident + diagnostics rather than process-internals telemetry. +5. Follow up on relay/PDS debugger statuses and whether `vsky`/crawler config + should be represented better. +6. Keep benchmark tables honest: rerun comparable ZDS/Tranquil/reference PDS + probes before claiming performance wins or regressions. +7. Continue reviewing account-status behavior against protocol docs before + exposing stronger operator workflows. + +## final caution + +This session included several false starts caused by treating local guesses as +protocol knowledge. The durable lesson is simple: read the protocol, read the +reference implementations, then make the smallest semantically correct ZDS +change. If something feels like it should live in `zat`, stop and write the +case down instead of editing `zat` from this repo. diff --git a/src/atproto/oauth.zig b/src/atproto/oauth.zig index 44cda88..a0e4227 100644 --- a/src/atproto/oauth.zig +++ b/src/atproto/oauth.zig @@ -541,7 +541,7 @@ fn issueTokenResponse( const refresh = try auth.createSessionJwt(allocator, "refresh", account); const access_expires_at = now() + access_token_expires_in; const refresh_expires_at = now() + refresh_token_expires_in; - const token_scope = permission_sets.expandScopes(allocator, scope) catch |err| { + const token_scope = permission_sets.expandScopes(allocator, scope, account.did) catch |err| { log.err("oauth token scope_expand failed client={s} did={s} scope={s} err={s}\n", .{ client_id, account.did, scope, @errorName(err) }); return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); }; @@ -1144,8 +1144,13 @@ fn scopeRegistered(client_scope_text: []const u8, requested_scope: []const u8) b fn sameScopeResource(client_scope: []const u8, requested_scope: []const u8) bool { const client_base = scopeBase(client_scope); - if (std.mem.indexOfScalar(u8, client_base, '*') == null) return false; - return std.mem.eql(u8, scopeResourceType(client_base), scopeResourceType(scopeBase(requested_scope))); + const requested_base = scopeBase(requested_scope); + const resource = scopeResourceType(client_base); + if (!std.mem.eql(u8, resource, scopeResourceType(requested_base))) return false; + if (std.mem.eql(u8, resource, "blob")) { + return blobResourceCovers(scopeResourceName(client_base), scopeResourceName(requested_base)); + } + return wildcardResourceCovers(scopeResourceName(client_base), scopeResourceName(requested_base)); } fn scopeBase(scope: []const u8) []const u8 { @@ -1156,6 +1161,26 @@ fn scopeResourceType(scope: []const u8) []const u8 { return if (std.mem.indexOfScalar(u8, scope, ':')) |idx| scope[0..idx] else scope; } +fn scopeResourceName(scope: []const u8) []const u8 { + return if (std.mem.indexOfScalar(u8, scope, ':')) |idx| scope[idx + 1 ..] else ""; +} + +fn wildcardResourceCovers(allowed: []const u8, requested: []const u8) bool { + if (std.mem.eql(u8, allowed, "*")) return true; + if (std.mem.eql(u8, allowed, requested)) return true; + if (!std.mem.endsWith(u8, allowed, ".*")) return false; + const prefix = allowed[0 .. allowed.len - 2]; + return std.mem.startsWith(u8, requested, prefix) and requested.len > prefix.len and requested[prefix.len] == '.'; +} + +fn blobResourceCovers(allowed: []const u8, requested: []const u8) bool { + if (std.mem.eql(u8, allowed, "*/*")) return true; + if (std.mem.eql(u8, allowed, requested)) return true; + if (!std.mem.endsWith(u8, allowed, "/*")) return false; + const prefix = allowed[0 .. allowed.len - 2]; + return std.mem.startsWith(u8, requested, prefix) and requested.len > prefix.len and requested[prefix.len] == '/'; +} + fn clientAssertionTimestampIsFresh(current_time: i64, exp: ?i64, iat: ?i64) bool { if (exp) |expires_at| return expires_at >= current_time; const issued_at = iat orelse return false; diff --git a/src/atproto/oauth/permission_sets.zig b/src/atproto/oauth/permission_sets.zig index 1c6f707..52a472f 100644 --- a/src/atproto/oauth/permission_sets.zig +++ b/src/atproto/oauth/permission_sets.zig @@ -13,7 +13,7 @@ pub const PermissionSet = struct { expanded_scope: []const u8, }; -pub fn expandScopes(allocator: std.mem.Allocator, scope_text: []const u8) ![]const u8 { +pub fn expandScopes(allocator: std.mem.Allocator, scope_text: []const u8, user_did: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); var first = true; var scopes = std.mem.splitScalar(u8, scope_text, ' '); @@ -29,12 +29,76 @@ pub fn expandScopes(allocator: std.mem.Allocator, scope_text: []const u8) ![]con if (part.len == 0) continue; if (!first) try out.writer.writeByte(' '); first = false; - try out.writer.writeAll(part); + try writeResolvedScope(allocator, &out.writer, part, user_did); } } return out.toOwnedSlice(); } +fn writeResolvedScope(allocator: std.mem.Allocator, writer: *std.Io.Writer, scope: []const u8, user_did: []const u8) !void { + if (!std.mem.startsWith(u8, scope, "space:")) return writer.writeAll(scope); + const query_start = std.mem.indexOfScalar(u8, scope, '?'); + const base = if (query_start) |idx| scope[0..idx] else scope; + const space_type = base["space:".len..]; + var authority = user_did; + var has_collection = false; + if (query_start) |idx| { + var params = std.mem.splitScalar(u8, scope[idx + 1 ..], '&'); + while (params.next()) |param| { + if (std.mem.startsWith(u8, param, "collection=")) has_collection = true; + if (!std.mem.startsWith(u8, param, "authority=")) continue; + const value = param["authority=".len..]; + if (!std.mem.eql(u8, value, "self")) authority = value; + } + } + try writer.writeAll(base); + try writer.print("?authority={s}", .{authority}); + if (!has_collection and !std.mem.eql(u8, space_type, "*")) { + const collections = try resolveSpaceTypeCollections(allocator, space_type); + for (collections) |collection| try writer.print("&collection={s}", .{collection}); + } + if (query_start) |idx| { + var params = std.mem.splitScalar(u8, scope[idx + 1 ..], '&'); + while (params.next()) |param| { + if (std.mem.startsWith(u8, param, "authority=")) continue; + try writer.writeByte('&'); + try writer.writeAll(param); + } + } +} + +fn resolveSpaceTypeCollections(allocator: std.mem.Allocator, nsid: []const u8) ![]const []const u8 { + const lexicon = try fetchPermissionSetLexicon(allocator, nsid); + defer lexicon.deinit(); + const root = switch (lexicon.value) { + .object => |object| object, + else => return error.InvalidSpaceTypeDeclaration, + }; + const value = switch (root.get("value") orelse return error.InvalidSpaceTypeDeclaration) { + .object => |object| object, + else => return error.InvalidSpaceTypeDeclaration, + }; + const defs = switch (value.get("defs") orelse return error.InvalidSpaceTypeDeclaration) { + .object => |object| object, + else => return error.InvalidSpaceTypeDeclaration, + }; + const main = switch (defs.get("main") orelse return error.InvalidSpaceTypeDeclaration) { + .object => |object| object, + else => return error.InvalidSpaceTypeDeclaration, + }; + if (!std.mem.eql(u8, jsonString(main, "type") orelse return error.InvalidSpaceTypeDeclaration, "space")) return error.InvalidSpaceTypeDeclaration; + const values = switch (main.get("collections") orelse return error.InvalidSpaceTypeDeclaration) { + .array => |array| array, + else => return error.InvalidSpaceTypeDeclaration, + }; + var collections: std.ArrayList([]const u8) = .empty; + for (values.items) |item| { + if (item != .string or zat.Nsid.parse(item.string) == null) return error.InvalidSpaceTypeDeclaration; + try collections.append(allocator, try allocator.dupe(u8, item.string)); + } + return collections.toOwnedSlice(allocator); +} + pub fn resolvePermissionSet(allocator: std.mem.Allocator, include_scope: []const u8) !PermissionSet { const parsed = try parseIncludeScope(include_scope); const lexicon = try fetchPermissionSetLexicon(allocator, parsed.nsid); @@ -254,6 +318,8 @@ fn appendRpcScopes( if (explicit_aud != null and inherit_aud) return; const aud = explicit_aud orelse if (inherit_aud) include_aud else null; if (aud == null) return; + const escaped_aud = try percentEncode(allocator, aud.?); + defer allocator.free(escaped_aud); for (lxms.items) |lxm_value| { const lxm = switch (lxm_value) { @@ -268,7 +334,6 @@ fn appendRpcScopes( .string => |string| string, else => unreachable, }; - const escaped_aud = try percentEncode(allocator, aud.?); if (!first.*) try writer.writeByte(' '); first.* = false; try writer.print("rpc:{s}?aud={s}", .{ lxm, escaped_aud }); @@ -281,100 +346,57 @@ fn appendSpaceScopes( include_nsid: []const u8, permission: std.json.ObjectMap, ) !void { - const actions = switch (permission.get("action") orelse return) { - .array => |array| array, - else => return, - }; - if (!validSpaceActions(actions.items)) return; - - var space_type_buf: [64][]const u8 = undefined; - var type_buf: [64][]const u8 = undefined; - var did_buf: [64][]const u8 = undefined; - var skey_buf: [64][]const u8 = undefined; - var collection_buf: [64][]const u8 = undefined; - const maybe_space = try optionalStringArray(permission, "space", &space_type_buf); - const maybe_space_types = maybe_space orelse try optionalStringArray(permission, "type", &type_buf); - const maybe_dids = try optionalStringArray(permission, "did", &did_buf); - const maybe_skeys = try optionalStringArray(permission, "skey", &skey_buf); - const maybe_collections = try optionalStringArray(permission, "collection", &collection_buf); - - if (maybe_space_types) |space_types| { - for (space_types) |space_type| { - if (!validPermissionSetNsid(include_nsid, space_type)) return; - } - } else if (zat.Nsid.parse(include_nsid) == null) { - return; - } - - if (maybe_dids) |dids| { - for (dids) |did| { - if (!std.mem.eql(u8, did, "*") and zat.Did.parse(did) == null) return; - } + const space_type = jsonString(permission, "spaceType") orelse return; + if (!validPermissionSetNsid(include_nsid, space_type) or std.mem.eql(u8, space_type, "*")) return; + const authority = jsonString(permission, "authority"); + if (authority) |value| { + if (!std.mem.eql(u8, value, "*") and !std.mem.eql(u8, value, "self") and zat.Did.parse(value) == null) return; } - if (maybe_skeys) |skeys| { - for (skeys) |skey| { - if (!std.mem.eql(u8, skey, "*") and zat.Rkey.parse(skey) == null) return; - } + const skey = jsonString(permission, "skey"); + if (skey) |value| { + if (!std.mem.eql(u8, value, "*") and zat.Rkey.parse(value) == null) return; } + var collection_buf: [64][]const u8 = undefined; + const maybe_collections = try optionalStringArray(permission, "collection", &collection_buf); if (maybe_collections) |collections| { for (collections) |collection| { - if (!validPermissionSetNsid(include_nsid, collection)) return; + if (!std.mem.eql(u8, collection, "*") and zat.Nsid.parse(collection) == null) return; } } + const actions = switch (permission.get("action") orelse .null) { + .array => |array| array.items, + .null => null, + else => return, + }; + if (actions) |values| if (!validSpaceActions(values)) return; + const manages = switch (permission.get("manage") orelse .null) { + .array => |array| array.items, + .null => null, + else => return, + }; + if (manages) |values| if (!validSpaceManage(values)) return; - if (maybe_space_types) |space_types| { - for (space_types) |space_type| { - try appendSpaceScopesForType(writer, first, space_type, actions.items, maybe_dids, maybe_skeys, maybe_collections); - } - } else { - try appendSpaceScopesForType(writer, first, include_nsid, actions.items, maybe_dids, maybe_skeys, maybe_collections); + if (!first.*) try writer.writeByte(' '); + first.* = false; + try writer.print("space:{s}", .{space_type}); + var has_query = false; + if (authority) |value| try appendSpaceParam(writer, &has_query, "authority", value); + if (skey) |value| try appendSpaceParam(writer, &has_query, "skey", value); + if (maybe_collections) |collections| { + for (collections) |collection| try appendSpaceParam(writer, &has_query, "collection", collection); } -} - -fn appendSpaceScopesForType( - writer: *std.Io.Writer, - first: *bool, - space_type: []const u8, - actions: []const std.json.Value, - maybe_dids: ?[]const []const u8, - maybe_skeys: ?[]const []const u8, - maybe_collections: ?[]const []const u8, -) !void { - const dids = maybe_dids orelse &[_][]const u8{"*"}; - const skeys = maybe_skeys orelse &[_][]const u8{"*"}; - for (actions) |action_value| { - const action = action_value.string; - for (dids) |did| { - for (skeys) |skey| { - if (spaceActionUsesCollection(action)) { - if (maybe_collections) |collections| { - for (collections) |collection| { - try appendSpaceScope(writer, first, space_type, action, did, skey, collection); - } - } else { - try appendSpaceScope(writer, first, space_type, action, did, skey, null); - } - } else { - try appendSpaceScope(writer, first, space_type, action, did, skey, null); - } - } - } + if (actions) |values| { + for (values) |value| try appendSpaceParam(writer, &has_query, "action", value.string); + } + if (manages) |values| { + for (values) |value| try appendSpaceParam(writer, &has_query, "manage", value.string); } } -fn appendSpaceScope( - writer: *std.Io.Writer, - first: *bool, - space_type: []const u8, - action: []const u8, - did: []const u8, - skey: []const u8, - collection: ?[]const u8, -) !void { - if (!first.*) try writer.writeByte(' '); - first.* = false; - try writer.print("space:{s}?action={s}&did={s}&skey={s}", .{ space_type, action, did, skey }); - if (collection) |value| try writer.print("&collection={s}", .{value}); +fn appendSpaceParam(writer: *std.Io.Writer, has_query: *bool, name: []const u8, value: []const u8) !void { + try writer.writeByte(if (has_query.*) '&' else '?'); + has_query.* = true; + try writer.print("{s}={s}", .{ name, value }); } fn isParentAuthorityOf(include_nsid: []const u8, other: []const u8) bool { @@ -404,19 +426,24 @@ fn validSpaceActions(actions: []const std.json.Value) bool { if (actions.len == 0) return false; for (actions) |action| { if (action != .string) return false; - if (!(std.mem.eql(u8, action.string, "read") or + if (!(std.mem.eql(u8, action.string, "read_self") or + std.mem.eql(u8, action.string, "read") or std.mem.eql(u8, action.string, "create") or std.mem.eql(u8, action.string, "update") or - std.mem.eql(u8, action.string, "delete") or - std.mem.eql(u8, action.string, "manage"))) return false; + std.mem.eql(u8, action.string, "delete"))) return false; } return true; } -fn spaceActionUsesCollection(action: []const u8) bool { - return std.mem.eql(u8, action, "create") or - std.mem.eql(u8, action, "update") or - std.mem.eql(u8, action, "delete"); +fn validSpaceManage(actions: []const std.json.Value) bool { + if (actions.len == 0) return false; + for (actions) |action| { + if (action != .string) return false; + if (!(std.mem.eql(u8, action.string, "create") or + std.mem.eql(u8, action.string, "update") or + std.mem.eql(u8, action.string, "delete"))) return false; + } + return true; } fn isDefaultRepoActions(actions: []const std.json.Value) bool { @@ -554,6 +581,19 @@ test "permission set expansion rejects mixed-authority repo permission" { } test "rpc permissions inherit include audience" { + const allocator = std.testing.allocator; + const text = + \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"rpc","inheritAud":true,"lxm":["fm.plyr.getThing","fm.plyr.getOther"]}]}}}} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); + defer parsed.deinit(); + const main = try mainPermissionSet(parsed); + const expanded = try buildExpandedScope(allocator, "fm.plyr.authFullApp", "did:web:api.plyr.fm#svc", main); + defer allocator.free(expanded); + try std.testing.expectEqualStrings("rpc:fm.plyr.getThing?aud=did%3Aweb%3Aapi.plyr.fm%23svc rpc:fm.plyr.getOther?aud=did%3Aweb%3Aapi.plyr.fm%23svc", expanded); +} + +test "rpc permission entry is rejected when any method is outside the include authority" { const allocator = std.testing.allocator; const text = \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"rpc","inheritAud":true,"lxm":["fm.plyr.getThing","com.atproto.repo.getRecord"]}]}}}} @@ -563,7 +603,7 @@ test "rpc permissions inherit include audience" { const main = try mainPermissionSet(parsed); const expanded = try buildExpandedScope(allocator, "fm.plyr.authFullApp", "did:web:api.plyr.fm#svc", main); defer allocator.free(expanded); - try std.testing.expectEqualStrings("rpc:fm.plyr.getThing?aud=did%3Aweb%3Aapi.plyr.fm%23svc", expanded); + try std.testing.expectEqualStrings("", expanded); } test "rpc permissions reject specific explicit audiences" { @@ -582,7 +622,7 @@ test "rpc permissions reject specific explicit audiences" { test "space permissions expand to scoped permissioned data grants" { const allocator = std.testing.allocator; const text = - \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"space","action":["read","manage","create","update","delete"],"space":["fm.plyr.privateMedia"],"did":["*"],"skey":["self"],"collection":["fm.plyr.track"]}]}}}} + \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"space","spaceType":"fm.plyr.privateMedia","authority":"*","skey":"self","collection":["fm.plyr.track"],"action":["read","create","update","delete"],"manage":["update","delete"]}]}}}} ; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); defer parsed.deinit(); @@ -590,33 +630,20 @@ test "space permissions expand to scoped permissioned data grants" { const expanded = try buildExpandedScope(allocator, "fm.plyr.privateMedia", null, main); defer allocator.free(expanded); try std.testing.expectEqualStrings( - "space:fm.plyr.privateMedia?action=read&did=*&skey=self space:fm.plyr.privateMedia?action=manage&did=*&skey=self space:fm.plyr.privateMedia?action=create&did=*&skey=self&collection=fm.plyr.track space:fm.plyr.privateMedia?action=update&did=*&skey=self&collection=fm.plyr.track space:fm.plyr.privateMedia?action=delete&did=*&skey=self&collection=fm.plyr.track", + "space:fm.plyr.privateMedia?authority=*&skey=self&collection=fm.plyr.track&action=read&action=create&action=update&action=delete&manage=update&manage=delete", expanded, ); try std.testing.expect(scope_rules.spaceAllows(expanded, .read, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); - try std.testing.expect(scope_rules.spaceAllows(expanded, .manage, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); + try std.testing.expect(scope_rules.spaceAllows(expanded, .manage_update, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); try std.testing.expect(scope_rules.spaceAllows(expanded, .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); try std.testing.expect(!scope_rules.spaceAllows(expanded, .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.comment")); try std.testing.expect(!scope_rules.spaceAllows(expanded, .create, "fm.plyr.privateMedia", "did:plc:alice", "other", "fm.plyr.track")); } -test "space permissions default space type to include nsid" { - const allocator = std.testing.allocator; - const text = - \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"space","action":["read"],"did":["*"],"skey":["*"]}]}}}} - ; - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); - defer parsed.deinit(); - const main = try mainPermissionSet(parsed); - const expanded = try buildExpandedScope(allocator, "fm.plyr.privateMedia", null, main); - defer allocator.free(expanded); - try std.testing.expectEqualStrings("space:fm.plyr.privateMedia?action=read&did=*&skey=*", expanded); -} - -test "space permissions reject mixed-authority space or collection" { +test "space permissions require canonical spaceType" { const allocator = std.testing.allocator; const text = - \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"space","action":["create"],"space":["fm.plyr.privateMedia"],"did":["*"],"skey":["self"],"collection":["app.bsky.feed.post"]}]}}}} + \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"space","action":["read"],"did":["*"],"space":["fm.plyr.privateMedia"]}]}}}} ; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); defer parsed.deinit(); @@ -626,34 +653,25 @@ test "space permissions reject mixed-authority space or collection" { try std.testing.expectEqualStrings("", expanded); } -test "space permission accepts lexicon permission type string when space field is present" { +test "space permissions allow cross-namespace collections" { const allocator = std.testing.allocator; const text = - \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"space","action":["read","create"],"space":["fm.plyr.stg.privateMedia"],"skey":["self"],"did":["*"],"collection":["fm.plyr.stg.track"]}]}}}} + \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"space","spaceType":"fm.plyr.privateMedia","action":["create"],"collection":["app.bsky.feed.post"]}]}}}} ; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); defer parsed.deinit(); const main = try mainPermissionSet(parsed); - const expanded = try buildExpandedScope(allocator, "fm.plyr.stg.privateMedia", null, main); + const expanded = try buildExpandedScope(allocator, "fm.plyr.privateMedia", null, main); defer allocator.free(expanded); - try std.testing.expectEqualStrings( - "space:fm.plyr.stg.privateMedia?action=read&did=*&skey=self space:fm.plyr.stg.privateMedia?action=create&did=*&skey=self&collection=fm.plyr.stg.track", - expanded, - ); + try std.testing.expectEqualStrings("space:fm.plyr.privateMedia?collection=app.bsky.feed.post&action=create", expanded); } -test "plyr staging private media permission set expands all space actions" { +test "space permission resolves self authority at token issuance" { const allocator = std.testing.allocator; - const text = - \\{"value":{"defs":{"main":{"type":"permission-set","title":"plyr.fm Private Media","detail":"Access to your private audio - a permissioned space on your PDS that only you (and apps you grant) can read.","permissions":[{"type":"permission","resource":"space","action":["read","create","update","delete","manage"],"space":["fm.plyr.stg.privateMedia"],"skey":["self"],"did":["*"],"collection":["fm.plyr.stg.track"]}]}}}} - ; - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); - defer parsed.deinit(); - const main = try mainPermissionSet(parsed); - const expanded = try buildExpandedScope(allocator, "fm.plyr.stg.privateMedia", null, main); + const expanded = try expandScopes(allocator, "space:fm.plyr.privateMedia?skey=self&collection=fm.plyr.track&action=read_self", "did:plc:alice"); defer allocator.free(expanded); try std.testing.expectEqualStrings( - "space:fm.plyr.stg.privateMedia?action=read&did=*&skey=self space:fm.plyr.stg.privateMedia?action=create&did=*&skey=self&collection=fm.plyr.stg.track space:fm.plyr.stg.privateMedia?action=update&did=*&skey=self&collection=fm.plyr.stg.track space:fm.plyr.stg.privateMedia?action=delete&did=*&skey=self&collection=fm.plyr.stg.track space:fm.plyr.stg.privateMedia?action=manage&did=*&skey=self", + "space:fm.plyr.privateMedia?authority=did:plc:alice&skey=self&collection=fm.plyr.track&action=read_self", expanded, ); } diff --git a/src/atproto/space.zig b/src/atproto/space.zig index efbd9b6..fb2222b 100644 --- a/src/atproto/space.zig +++ b/src/atproto/space.zig @@ -10,7 +10,9 @@ const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const http_api = @import("../http/api.zig"); const permissioned = @import("../internal/permissioned_data.zig"); +const client_attestation = @import("../internal/client_attestation.zig"); const scopes = @import("../internal/scopes.zig"); +const space_uris = @import("../internal/space_uri.zig"); const store = @import("../storage/store.zig"); const zat = @import("zat"); @@ -41,8 +43,10 @@ pub fn dispatch(request: *http_api.Request) !void { if (std.mem.eql(u8, method, "com.atproto.space.getRecord")) return getRecord(request); if (std.mem.eql(u8, method, "com.atproto.space.listRecords")) return listRecords(request); if (std.mem.eql(u8, method, "com.atproto.space.getBlob")) return getBlob(request); - if (std.mem.eql(u8, method, "com.atproto.space.getRepoState")) return getRepoState(request); + if (std.mem.eql(u8, method, "com.atproto.space.getLatestCommit")) return getLatestCommit(request); + if (std.mem.eql(u8, method, "com.atproto.space.getRepo")) return getRepo(request); if (std.mem.eql(u8, method, "com.atproto.space.listRepoOps")) return listRepoOps(request); + if (std.mem.eql(u8, method, "com.atproto.space.registerNotify")) return registerNotify(request); if (std.mem.eql(u8, method, "com.atproto.space.getSpaceCredential")) return getSpaceCredential(request); if (std.mem.eql(u8, method, "com.atproto.space.notifyWrite")) return notifyWrite(request); if (std.mem.eql(u8, method, "com.atproto.space.notifySpaceDeleted")) return notifySpaceDeleted(request); @@ -52,6 +56,7 @@ pub fn dispatch(request: *http_api.Request) !void { if (std.mem.eql(u8, method, "com.atproto.simplespace.addMember")) return addSimpleSpaceMember(request); if (std.mem.eql(u8, method, "com.atproto.simplespace.removeMember")) return removeSimpleSpaceMember(request); if (std.mem.eql(u8, method, "com.atproto.simplespace.listMembers")) return listSimpleSpaceMembers(request); + if (std.mem.eql(u8, method, "com.atproto.simplespace.checkUserAccess")) return checkSimpleSpaceUserAccess(request); return http_api.xrpcError( request, @@ -72,16 +77,26 @@ fn createSimpleSpace(request: *http_api.Request) !void { else => return err, }; const input = parsed.value; - const authority = auth_ctx.account.did; + const authority = zat.json.getString(input, "did") orelse { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); + }; const space_type = zat.json.getString(input, "type") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing type"); }; const skey = zat.json.getString(input, "skey") orelse try store.generateRkey(allocator); - try requireSpaceScope(request, auth_ctx.oauth_scope, space_type, authority, skey, .manage, null); - const managing_app = zat.json.getString(input, "managingApp"); - const policy = zat.json.getString(input, "policy") orelse "member-list"; + try requireSpaceScope(request, auth_ctx.oauth_scope, space_type, authority, skey, .manage_create, null); + const config_value = switch (input) { + .object => |object| object.get("config") orelse std.json.Value.null, + else => std.json.Value.null, + }; + switch (config_value) { + .null, .object => {}, + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid config"), + } + const managing_app = zat.json.getString(config_value, "managingApp"); + const policy = zat.json.getString(config_value, "policy") orelse "member-list"; if (!validPolicy(policy)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid policy"); - const app_access_json = appAccessJson(allocator, input) catch |err| switch (err) { + const app_access_json = appAccessJson(allocator, config_value) catch |err| switch (err) { error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccess"), else => return err, }; @@ -91,7 +106,7 @@ fn createSimpleSpace(request: *http_api.Request) !void { .authority_did = authority, .space_type = space_type, .skey = skey, - .is_authority = true, + .is_authority = std.mem.eql(u8, authority, auth_ctx.account.did), .managing_app = managing_app, .policy = policy, .app_access_json = app_access_json, @@ -104,7 +119,7 @@ fn createSimpleSpace(request: *http_api.Request) !void { else => return err, }; - const body = try std.fmt.allocPrint(allocator, "{{\"uri\":{f},\"config\":{s}}}", .{ std.json.fmt(space.uri, .{}), try spaceConfigJson(allocator, space) }); + const body = try std.fmt.allocPrint(allocator, "{{\"uri\":{f}}}", .{std.json.fmt(space.uri, .{})}); return http_api.json(request, .ok, body); } @@ -121,14 +136,21 @@ fn getSpace(request: *http_api.Request) !void { const parsed = parseSpaceUri(space_uri) orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); }; - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .read, null); + if (!std.mem.eql(u8, parsed.authority_did, auth_ctx.account.did)) { + return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found on this authority host"); + } const space = (try store.getSpace(allocator, auth_ctx.account.did, space_uri)) orelse { return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); }; if (space.deleted_at != null) { return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); } - return http_api.json(request, .ok, try spaceConfigJson(allocator, space)); + const body = try std.fmt.allocPrint( + allocator, + "{{\"uri\":{f},\"config\":{s}}}", + .{ std.json.fmt(space.uri, .{}), try simpleSpaceConfigJson(allocator, space) }, + ); + return http_api.json(request, .ok, body); } fn listSpaces(request: *http_api.Request) !void { @@ -137,28 +159,31 @@ fn listSpaces(request: *http_api.Request) !void { const allocator = arena.allocator(); const auth_ctx = requireAccount(request, allocator) catch return; - var authority_buf: [256]u8 = undefined; - const maybe_authority = http_api.queryParam(request.url.raw, "authority", &authority_buf); + var did_buf: [256]u8 = undefined; + const maybe_did = http_api.queryParam(request.url.raw, "did", &did_buf); + if (maybe_did) |did| { + if (zat.Did.parse(did) == null) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid did"); + } var type_buf: [320]u8 = undefined; const maybe_type = http_api.queryParam(request.url.raw, "type", &type_buf); if (maybe_type) |space_type| { if (zat.Nsid.parse(space_type) == null) return http_api.xrpcError(request, .bad_request, "InvalidType", "Invalid space type"); } if (auth_ctx.oauth_scope) |scope_text| { - if (!scopes.spaceAllows(scope_text, .read, maybe_type orelse "*", maybe_authority orelse "*", "*", null)) { + if (!scopes.spaceAllows(scope_text, .read, maybe_type orelse "*", maybe_did orelse "*", "*", null)) { return http_api.xrpcError(request, .forbidden, "InsufficientScope", "OAuth token does not grant the requested space operation"); } } var cursor_buf: [1024]u8 = undefined; const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); - const spaces = try store.listSpaces(allocator, auth_ctx.account.did, maybe_authority, maybe_type, maybe_cursor, http_api.queryLimit(request.url.raw, 50)); + const spaces = try store.listSpaces(allocator, auth_ctx.account.did, maybe_did, maybe_type, maybe_cursor, http_api.queryLimit(request.url.raw, 50)); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"spaces\":["); for (spaces, 0..) |space, idx| { if (idx != 0) try out.writer.writeByte(','); - try out.writer.print("{{\"uri\":{f},\"isAuthority\":{}}}", .{ std.json.fmt(space.uri, .{}), space.is_authority }); + try out.writer.print("{{\"uri\":{f},\"isOwner\":{}}}", .{ std.json.fmt(space.uri, .{}), space.is_authority }); } try out.writer.writeByte(']'); if (spaces.len > 0) { @@ -176,19 +201,21 @@ fn listRepos(request: *http_api.Request) !void { const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); }; - _ = requireReadAccess(request, allocator, space, null) catch return; + _ = requireSpaceCredential(request, allocator, space) catch return; var cursor_buf: [256]u8 = undefined; const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); - const repos = try store.listSpaceRepos(allocator, space, maybe_cursor, http_api.queryLimit(request.url.raw, 50)); + const repos = store.listSpaceWriters(allocator, space, maybe_cursor, http_api.queryLimit(request.url.raw, 50)) catch |err| switch (err) { + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), + else => return err, + }; var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"repos\":["); for (repos, 0..) |repo, idx| { if (idx != 0) try out.writer.writeByte(','); try out.writer.print("{{\"did\":{f},\"rev\":", .{std.json.fmt(repo.repo_did, .{})}); - if (repo.rev) |rev| try out.writer.print("{f}", .{std.json.fmt(rev, .{})}) else try out.writer.writeAll("null"); - try out.writer.writeAll(",\"hash\":"); - if (repo.set_hash) |hash| try out.writer.print("{f}", .{std.json.fmt(try hexLower(allocator, hash), .{})}) else try out.writer.writeAll("null"); + try out.writer.print("{f},\"hash\":", .{std.json.fmt(repo.rev, .{})}); + try writeAtJsonBytes(&out.writer, allocator, repo.hash); try out.writer.writeByte('}'); } try out.writer.writeByte(']'); @@ -256,6 +283,26 @@ fn listSimpleSpaceMembers(request: *http_api.Request) !void { return http_api.json(request, .ok, out.written()); } +fn checkSimpleSpaceUserAccess(request: *http_api.Request) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var space_buf: [1024]u8 = undefined; + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); + var user_buf: [256]u8 = undefined; + const user = http_api.queryParam(request.url.raw, "user", &user_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing user"); + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); + if (zat.Did.parse(user) == null) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid user DID"); + const service = requireServiceAuth(request, allocator, "com.atproto.simplespace.checkUserAccess") catch return; + if (!std.mem.eql(u8, service.issuer_did, parsed.authority_did)) { + return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the space authority DID"); + } + if (!serviceAudienceMatchesPds(service.audience)) { + return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience does not identify this service"); + } + return http_api.json(request, .ok, "{\"authorized\":false}"); +} + fn updateSimpleSpace(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -268,10 +315,10 @@ fn updateSimpleSpace(request: *http_api.Request) !void { const input = parsed_body.value; const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); - if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); + if (!std.mem.eql(u8, parsed.authority_did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, .manage_update, null); const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); - if (!existing.is_authority) return http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); + if (!existing.is_authority) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); if (existing.deleted_at != null) return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); const managing_app = zat.json.getString(input, "managingApp"); const clear_managing_app = switch (input) { @@ -313,13 +360,13 @@ fn deleteSimpleSpace(request: *http_api.Request) !void { }; const space = zat.json.getString(parsed_body.value, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); - if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); + if (!std.mem.eql(u8, parsed.authority_did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, .manage_delete, null); const existing = try store.getSpace(allocator, auth_ctx.account.did, space); if (existing == null) return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); - if (!existing.?.is_authority) return http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); + if (!existing.?.is_authority) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); if (existing.?.deleted_at != null) return http_api.json(request, .ok, "{}"); - const recipients = try store.listCredentialRecipients(allocator, space); + const recipients = try store.listNotificationRecipients(allocator, space, null, true); try store.markSpaceDeleted(auth_ctx.account.did, space); try store.purgeAuthoritySpaceData(space); fireNotifySpaceDeleted(allocator, auth_ctx.account, space, recipients) catch {}; @@ -331,13 +378,12 @@ fn getDelegationToken(request: *http_api.Request) !void { defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccount(request, allocator) catch return; - const client_id = auth_ctx.oauth_client_id orelse "unknown"; var space_buf: [1024]u8 = undefined; const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .read, null); + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, .read, null); var keypair = store.signingKeypair(auth_ctx.account.did) catch return http_api.xrpcError(request, .not_found, "RepoNotFound", "Signing key not found"); - const token = try permissioned.createDelegationToken(allocator, store.currentIo(), auth_ctx.account.did, parsed.did, space, client_id, &keypair); + const token = try permissioned.createDelegationToken(allocator, store.currentIo(), auth_ctx.account.did, parsed.authority_did, space, &keypair); return http_api.json(request, .ok, try std.fmt.allocPrint(allocator, "{{\"token\":{f}}}", .{std.json.fmt(token, .{})})); } @@ -431,7 +477,7 @@ fn applyWrites(request: *http_api.Request) !void { else => return err, }; const state = try store.getSpaceRepoState(allocator, space, repo); - if (state.rev) |rev| fireNotifyWrite(allocator, auth_ctx.account, space, repo, rev) catch {}; + fireNotifyWriteForState(allocator, auth_ctx.account, space, repo, state) catch {}; var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"results\":["); @@ -500,7 +546,8 @@ fn writeSpaceRecord(request: *http_api.Request, mode: WriteMode) !void { error.MissingRecord => return http_api.xrpcError(request, .bad_request, "RecordNotFound", "Record not found"), else => return err, }; - fireNotifyWrite(allocator, auth_ctx.account, space, repo, stored.repo_rev) catch {}; + const state = try store.getSpaceRepoState(allocator, space, repo); + fireNotifyWriteForState(allocator, auth_ctx.account, space, repo, state) catch {}; const uri = try stored.uri(allocator); const body = try std.fmt.allocPrint( allocator, @@ -519,7 +566,7 @@ fn getRecord(request: *http_api.Request) !void { error.HandledResponse => return, else => return err, }; - _ = requireReadAccess(request, allocator, params.space, params.collection) catch return; + _ = requireReadAccess(request, allocator, params.space, params.repo, params.collection) catch return; const record = (try store.getSpaceRecord(allocator, params.space, params.repo, params.collection, params.rkey)) orelse { return http_api.xrpcError(request, .not_found, "RecordNotFound", "Record not found"); }; @@ -547,7 +594,7 @@ fn listRecords(request: *http_api.Request) !void { }; var collection_buf: [320]u8 = undefined; const maybe_collection = http_api.queryParam(request.url.raw, "collection", &collection_buf); - _ = requireReadAccess(request, allocator, space, maybe_collection) catch return; + _ = requireReadAccess(request, allocator, space, repo, maybe_collection) catch return; var cursor_buf: [1024]u8 = undefined; const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); var reverse_buf: [8]u8 = undefined; @@ -611,14 +658,27 @@ fn getBlob(request: *http_api.Request) !void { const cid = http_api.queryParam(request.url.raw, "cid", &cid_buf) orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing cid"); }; - _ = requireReadAccess(request, allocator, space, null) catch return; + _ = requireReadAccess(request, allocator, space, repo, null) catch return; const blob = store.getBlob(allocator, repo, cid) orelse { return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found"); }; return writeBlobResponse(request, allocator, cid, blob); } -fn getRepoState(request: *http_api.Request) !void { +fn getLatestCommit(request: *http_api.Request) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var space_buf: [1024]u8 = undefined; + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); + var repo_buf: [256]u8 = undefined; + const repo = http_api.queryParam(request.url.raw, "repo", &repo_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); + _ = requireReadAccess(request, allocator, space, repo, null) catch return; + const state = try store.getSpaceRepoState(allocator, space, repo); + return writeSignedState(request, allocator, space, repo, repo, state); +} + +fn getRepo(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); @@ -626,9 +686,36 @@ fn getRepoState(request: *http_api.Request) !void { const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); var repo_buf: [256]u8 = undefined; const repo = http_api.queryParam(request.url.raw, "repo", &repo_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); - _ = requireReadAccess(request, allocator, space, null) catch return; + _ = requireReadAccess(request, allocator, space, repo, null) catch return; + + if ((try store.findAccount(allocator, repo)) == null) { + return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); + } + switch (try store.accountStatus(repo)) { + .active => {}, + .takendown => return http_api.xrpcError(request, .forbidden, "RepoTakendown", "Repo has been taken down"), + .suspended => return http_api.xrpcError(request, .forbidden, "RepoSuspended", "Repo is suspended"), + .deactivated => return http_api.xrpcError(request, .forbidden, "RepoDeactivated", "Repo is deactivated"), + .deleted => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), + } const state = try store.getSpaceRepoState(allocator, space, repo); - return writeSignedState(request, allocator, space, repo, repo, .records, state); + const set_hash = state.set_hash orelse return http_api.xrpcError(request, .not_found, "RepoNotFound", "Permissioned repo not found"); + const rev = state.rev orelse return http_api.xrpcError(request, .not_found, "RepoNotFound", "Permissioned repo not found"); + var keypair = try store.signingKeypair(repo); + const commit = try permissioned.createCommit(allocator, store.currentIo(), set_hash, .{ + .space = space, + .author = repo, + .rev = rev, + }, &keypair); + const records = try store.loadSpaceRepoBlocks(allocator, space, repo); + const body = try permissioned.serializeRepoCar(allocator, commit, records); + const headers = [_]http.Header{ + .{ .name = "content-type", .value = "application/vnd.ipld.car" }, + .{ .name = "access-control-allow-origin", .value = "*" }, + .{ .name = "access-control-allow-private-network", .value = "true" }, + .{ .name = "connection", .value = "close" }, + }; + return http_api.respond(request, .ok, body, &headers); } fn listRepoOps(request: *http_api.Request) !void { @@ -639,11 +726,16 @@ fn listRepoOps(request: *http_api.Request) !void { const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); var repo_buf: [256]u8 = undefined; const repo = http_api.queryParam(request.url.raw, "repo", &repo_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); - _ = requireReadAccess(request, allocator, space, null) catch return; + _ = requireReadAccess(request, allocator, space, repo, null) catch return; var since_buf: [128]u8 = undefined; const since = http_api.queryParam(request.url.raw, "since", &since_buf); + var exclude_values_buf: [8]u8 = undefined; + const exclude_values = if (http_api.queryParam(request.url.raw, "excludeValues", &exclude_values_buf)) |value| + std.mem.eql(u8, value, "true") + else + false; const limit = http_api.queryLimit(request.url.raw, 100); - const ops = try store.listSpaceRecordOplog(allocator, space, repo, since, limit); + const ops = try store.listSpaceRecordOplog(allocator, space, repo, since, limit, !exclude_values); const state = try store.getSpaceRepoState(allocator, space, repo); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); @@ -651,17 +743,18 @@ fn listRepoOps(request: *http_api.Request) !void { for (ops, 0..) |op, idx| { if (idx != 0) try out.writer.writeByte(','); try out.writer.print( - "{{\"rev\":{f},\"action\":{f},\"collection\":{f},\"rkey\":{f},\"cid\":", - .{ std.json.fmt(op.rev, .{}), std.json.fmt(op.action, .{}), std.json.fmt(op.collection, .{}), std.json.fmt(op.rkey, .{}) }, + "{{\"rev\":{f},\"collection\":{f},\"rkey\":{f},\"cid\":", + .{ std.json.fmt(op.rev, .{}), std.json.fmt(op.collection, .{}), std.json.fmt(op.rkey, .{}) }, ); if (op.cid) |cid_value| try out.writer.print("{f}", .{std.json.fmt(cid_value, .{})}) else try out.writer.writeAll("null"); try out.writer.writeAll(",\"prev\":"); if (op.prev) |prev_value| try out.writer.print("{f}", .{std.json.fmt(prev_value, .{})}) else try out.writer.writeAll("null"); + if (op.value_json) |value_json| try out.writer.print(",\"value\":{s}", .{value_json}); try out.writer.writeByte('}'); } try out.writer.writeByte(']'); if (ops.len < @min(if (limit == 0) 100 else limit, 1000)) { - if (try signedCommitJson(allocator, space, repo, repo, .records, state)) |commit_json| { + if (try signedCommitJson(allocator, space, repo, repo, state)) |commit_json| { try out.writer.print(",\"commit\":{s}", .{commit_json}); } } @@ -669,6 +762,49 @@ fn listRepoOps(request: *http_api.Request) !void { return http_api.json(request, .ok, out.written()); } +fn registerNotify(request: *http_api.Request) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { + error.HandledResponse => return, + else => return err, + }; + const input = parsed_body.value; + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); + const endpoint = zat.json.getString(input, "endpoint") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing endpoint"); + const repo = zat.json.getString(input, "repo"); + _ = std.Uri.parse(endpoint) catch return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid notification endpoint"); + if (!std.mem.startsWith(u8, endpoint, "https://") and !std.mem.startsWith(u8, endpoint, "http://")) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Notification endpoint must be an HTTP URL"); + } + _ = requireSpaceCredential(request, allocator, space) catch return; + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); + + if (repo) |repo_did| { + if (zat.Did.parse(repo_did) == null or (try store.findAccount(allocator, repo_did)) == null) { + return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); + } + const state = try store.getSpaceRepoState(allocator, space, repo_did); + if (state.rev == null) return http_api.xrpcError(request, .not_found, "RepoNotFound", "Permissioned repo not found"); + } else { + if ((try store.findAccount(allocator, parsed.authority_did)) == null) { + return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); + } + const existing = try store.getSpace(allocator, parsed.authority_did, space); + if (existing == null or !existing.?.is_authority or existing.?.deleted_at != null) { + return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); + } + } + const expires_at = unixNow() + 24 * 60 * 60; + try store.registerSpaceNotification(space, repo, endpoint, expires_at); + return http_api.json(request, .ok, try std.fmt.allocPrint( + allocator, + "{{\"expiresAt\":{f}}}", + .{std.json.fmt(try datetimeFromUnix(allocator, expires_at), .{})}, + )); +} + fn getSpaceCredential(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -691,22 +827,33 @@ fn getSpaceCredential(request: *http_api.Request) !void { const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); if (!std.mem.eql(u8, delegation.space, space)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Delegation token space mismatch"); const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); - if (!std.mem.eql(u8, delegation.authority_did, parsed.did)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Delegation token authority mismatch"); - const config_row = (try store.getSpace(allocator, parsed.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); + if (!std.mem.eql(u8, delegation.authority_did, parsed.authority_did)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Delegation token authority mismatch"); + const config_row = (try store.getSpace(allocator, parsed.authority_did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); if (config_row.deleted_at != null) { return http_api.xrpcError(request, .bad_request, "SpaceDeleted", "Space has been deleted"); } - if (!try spacePolicyAllowsRequester(allocator, config_row, delegation.requester_did, delegation.client_id)) { - return http_api.xrpcError(request, .forbidden, "NotPermitted", "The space host did not grant access to this requester"); + const attestation_claims: ?client_attestation.Claims = if (zat.json.getString(input, "clientAttestation")) |attestation| blk: { + const audience = try std.fmt.allocPrint(allocator, "{s}#atproto_space_host", .{parsed.authority_did}); + break :blk client_attestation.verify(allocator, store.currentIo(), attestation, audience) catch { + return http_api.xrpcError(request, .unauthorized, "InvalidClientAttestation", "Client attestation verification failed"); + }; + } else null; + const attested_client_id = if (attestation_claims) |claims| claims.client_id else null; + if (!try spacePolicyAllowsRequester(allocator, config_row, delegation.requester_did, attested_client_id)) { + return http_api.xrpcError(request, .forbidden, "UserNotAuthorized", "The space host did not grant access to this requester"); } - if (!spaceAllowsClient(allocator, config_row, delegation.client_id)) { - return http_api.xrpcError(request, .forbidden, "AppNotPermitted", "OAuth client is not allowed for this space"); + if (!spaceAllowsClient(allocator, config_row, attested_client_id)) { + return http_api.xrpcError(request, .forbidden, "AppNotAuthorized", "This space requires a verified client attestation"); } - var keypair = store.signingKeypair(parsed.did) catch return http_api.xrpcError(request, .not_found, "RepoNotFound", "Authority signing key not found"); - const credential = try permissioned.createSpaceCredential(allocator, store.currentIo(), parsed.did, space, delegation.client_id, &keypair); - if (zat.json.getString(input, "notifyEndpoint")) |endpoint| { - try store.recordCredentialRecipient(space, delegation.requester_did, endpoint); + const attestation_replay: ?store.SpaceReplayToken = if (attestation_claims) |claims| .{ .jti = claims.jti, .expires_at = claims.exp } else null; + if (!try store.consumeSpaceCredentialExchange( + .{ .jti = delegation.jti, .expires_at = delegation.exp }, + attestation_replay, + )) { + return http_api.xrpcError(request, .unauthorized, "InvalidDelegationToken", "Credential exchange token has already been used"); } + var keypair = store.signingKeypair(parsed.authority_did) catch return http_api.xrpcError(request, .not_found, "RepoNotFound", "Authority signing key not found"); + const credential = try permissioned.createSpaceCredential(allocator, store.currentIo(), parsed.authority_did, space, &keypair); return http_api.json(request, .ok, try std.fmt.allocPrint(allocator, "{{\"credential\":{f}}}", .{std.json.fmt(credential, .{})})); } @@ -722,12 +869,23 @@ fn notifyWrite(request: *http_api.Request) !void { const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); const repo = zat.json.getString(input, "repo") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); const rev = zat.json.getString(input, "rev") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rev"); + const hash = parseAtJsonBytes(allocator, switch (input) { + .object => |object| object.get("hash") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing hash"), + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid body"), + }) catch return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid hash"); + if (hash.len != permissioned.commit_hash_bytes) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid hash"); const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); const service = requireServiceAuth(request, allocator, "com.atproto.space.notifyWrite") catch return; if (!std.mem.eql(u8, service.issuer_did, repo)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the writer repo DID"); - if (!std.mem.eql(u8, service.audience, parsed.did)) return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience must be the space DID"); - const authority = (store.findAccount(allocator, parsed.did) catch null) orelse return http_api.json(request, .ok, "{}"); - try fanoutNotifyWriteToRecipients(allocator, authority, space, repo, rev); + if (!std.mem.eql(u8, service.audience, parsed.authority_did)) return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience must be the space DID"); + const authority = (store.findAccount(allocator, parsed.authority_did) catch null) orelse return http_api.json(request, .ok, "{}"); + const config_row = (try store.getSpace(allocator, parsed.authority_did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); + if (config_row.deleted_at != null) return http_api.xrpcError(request, .bad_request, "SpaceDeleted", "Space has been deleted"); + if (!try spacePolicyAllowsRequester(allocator, config_row, repo, null)) { + return http_api.xrpcError(request, .forbidden, "UserNotAuthorized", "Writer is not authorized for this space"); + } + try store.recordSpaceWriter(space, repo, rev, hash); + try fanoutNotifyWriteToRecipients(allocator, authority, space, repo, rev, hash); return http_api.json(request, .ok, "{}"); } @@ -742,7 +900,7 @@ fn notifySpaceDeleted(request: *http_api.Request) !void { const space = zat.json.getString(parsed_body.value, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); const service = requireServiceAuth(request, allocator, "com.atproto.space.notifySpaceDeleted") catch return; - if (!std.mem.eql(u8, service.issuer_did, parsed.did)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the space DID"); + if (!std.mem.eql(u8, service.issuer_did, parsed.authority_did)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the space DID"); if (zat.Did.parse(service.audience) == null and zat.Handle.parse(service.audience) == null) return http_api.json(request, .ok, "{}"); if ((store.findAccount(allocator, service.audience) catch null) == null) return http_api.json(request, .ok, "{}"); if ((try store.getSpace(allocator, service.audience, space)) == null) return http_api.json(request, .ok, "{}"); @@ -899,14 +1057,91 @@ fn serviceAudienceMatchesPds(aud: []const u8) bool { return std.mem.eql(u8, aud[config.serverDid().len..], "#atproto_pds"); } -fn pdsEndpointForDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { +fn spaceHostEndpointForDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { const parsed = zat.Did.parse(did) orelse return error.InvalidDid; var resolver = zat.DidResolver.init(store.currentIo(), allocator); defer resolver.deinit(); var doc = try resolver.resolve(parsed); defer doc.deinit(); - const endpoint = doc.pdsEndpoint() orelse return error.MissingPdsEndpoint; - return allocator.dupe(u8, endpoint); + for (doc.services) |service| { + if (std.mem.endsWith(u8, service.id, "#atproto_space_host")) { + return allocator.dupe(u8, service.service_endpoint); + } + } + return allocator.dupe(u8, doc.pdsEndpoint() orelse return error.MissingSpaceHostEndpoint); +} + +fn serviceEndpointForId(allocator: std.mem.Allocator, service_id: []const u8) ![]const u8 { + const fragment_index = std.mem.indexOfScalar(u8, service_id, '#') orelse return error.InvalidServiceId; + const did_text = service_id[0..fragment_index]; + const fragment = service_id[fragment_index..]; + const did = zat.Did.parse(did_text) orelse return error.InvalidServiceId; + var resolver = zat.DidResolver.init(store.currentIo(), allocator); + defer resolver.deinit(); + var doc = try resolver.resolve(did); + defer doc.deinit(); + for (doc.services) |service| { + if (std.mem.eql(u8, service.id, service_id) or std.mem.eql(u8, service.id, fragment)) { + return allocator.dupe(u8, service.service_endpoint); + } + } + return error.MissingServiceEndpoint; +} + +fn managingAppAllowsRequester( + allocator: std.mem.Allocator, + space: store.SpaceConfig, + requester_did: []const u8, + client_id: ?[]const u8, +) !bool { + const managing_app = space.managing_app orelse return false; + const endpoint = serviceEndpointForId(allocator, managing_app) catch return false; + const parsed = parseSpaceUri(space.uri) orelse return false; + const authority = (try store.findAccount(allocator, parsed.authority_did)) orelse return false; + var keypair = try store.signingKeypair(authority.did); + const token = try auth.createServiceJwtWithKeypair( + allocator, + authority, + managing_app, + "com.atproto.simplespace.checkUserAccess", + null, + &keypair, + ); + const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); + const encoded_space = try percentEncode(allocator, space.uri); + const encoded_user = try percentEncode(allocator, requester_did); + const url = if (client_id) |id| + try std.fmt.allocPrint( + allocator, + "{s}/xrpc/com.atproto.simplespace.checkUserAccess?space={s}&user={s}&clientId={s}", + .{ endpoint, encoded_space, encoded_user, try percentEncode(allocator, id) }, + ) + else + try std.fmt.allocPrint( + allocator, + "{s}/xrpc/com.atproto.simplespace.checkUserAccess?space={s}&user={s}", + .{ endpoint, encoded_space, encoded_user }, + ); + var transport = zat.HttpTransport.init(store.currentIo(), allocator); + defer transport.deinit(); + var result = transport.fetch(.{ + .url = url, + .authorization = authorization, + .accept = "application/json", + .max_response_size = 16 * 1024, + .redirect_behavior = .not_allowed, + }) catch return false; + defer result.deinit(allocator); + if (result.status != .ok) return false; + const response = std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}) catch return false; + defer response.deinit(); + return switch (response.value) { + .object => |object| switch (object.get("authorized") orelse return false) { + .bool => |authorized| authorized, + else => false, + }, + else => false, + }; } fn postSignedXrpc( @@ -934,41 +1169,65 @@ fn postSignedXrpc( if (@intFromEnum(result.status) >= 500) return error.RemoteServerError; } -fn fireNotifyWrite(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, repo: []const u8, rev: []const u8) !void { +fn fireNotifyWriteForState(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, repo: []const u8, state: store.SpaceState) !void { + const rev = state.rev orelse return; + const state_bytes = state.set_hash orelse return; + const set_hash = try permissioned.LtHash.fromBytes(state_bytes); + const hash = set_hash.digest(); const parsed = parseSpaceUri(space) orelse return error.InvalidSpaceUri; - if (!std.mem.eql(u8, repo, parsed.did)) { - const endpoint = try pdsEndpointForDid(allocator, parsed.did); + if (!std.mem.eql(u8, repo, parsed.authority_did)) { + const endpoint = try spaceHostEndpointForDid(allocator, parsed.authority_did); const body = try std.fmt.allocPrint( allocator, - "{{\"space\":{f},\"repo\":{f},\"rev\":{f}}}", - .{ std.json.fmt(space, .{}), std.json.fmt(repo, .{}), std.json.fmt(rev, .{}) }, + "{{\"space\":{f},\"repo\":{f},\"rev\":{f},\"hash\":{s}}}", + .{ std.json.fmt(space, .{}), std.json.fmt(repo, .{}), std.json.fmt(rev, .{}), try atJsonBytes(allocator, &hash) }, ); - try postSignedXrpc(allocator, account, endpoint, parsed.did, "com.atproto.space.notifyWrite", body); + try postSignedXrpc(allocator, account, endpoint, parsed.authority_did, "com.atproto.space.notifyWrite", body); } - if (std.mem.eql(u8, account.did, parsed.did)) { - try fanoutNotifyWriteToRecipients(allocator, account, space, repo, rev); + if (std.mem.eql(u8, account.did, parsed.authority_did)) { + try fanoutNotifyWriteToRecipients(allocator, account, space, repo, rev, &hash); } } -fn fanoutNotifyWriteToRecipients(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, repo: []const u8, rev: []const u8) !void { - const recipients = try store.listCredentialRecipients(allocator, space); +fn fanoutNotifyWriteToRecipients(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, repo: []const u8, rev: []const u8, hash: []const u8) !void { + const recipients = try store.listNotificationRecipients(allocator, space, repo, true); for (recipients) |recipient| { const body = try std.fmt.allocPrint( allocator, - "{{\"space\":{f},\"repo\":{f},\"rev\":{f}}}", - .{ std.json.fmt(space, .{}), std.json.fmt(repo, .{}), std.json.fmt(rev, .{}) }, + "{{\"space\":{f},\"repo\":{f},\"rev\":{f},\"hash\":{s}}}", + .{ std.json.fmt(space, .{}), std.json.fmt(repo, .{}), std.json.fmt(rev, .{}), try atJsonBytes(allocator, hash) }, ); - postSignedXrpc(allocator, account, recipient.service_endpoint, recipient.service_did, "com.atproto.space.notifyWrite", body) catch {}; + postSignedXrpc(allocator, account, recipient.service_endpoint, recipient.service_endpoint, "com.atproto.space.notifyWrite", body) catch {}; } } fn fireNotifySpaceDeleted(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, recipients: []const store.CredentialRecipient) !void { const body = try std.fmt.allocPrint(allocator, "{{\"space\":{f}}}", .{std.json.fmt(space, .{})}); for (recipients) |recipient| { - postSignedXrpc(allocator, account, recipient.service_endpoint, recipient.service_did, "com.atproto.space.notifySpaceDeleted", body) catch {}; + postSignedXrpc(allocator, account, recipient.service_endpoint, recipient.service_endpoint, "com.atproto.space.notifySpaceDeleted", body) catch {}; } } +fn unixNow() i64 { + var ts: std.posix.timespec = undefined; + return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { + .SUCCESS => ts.sec, + else => 0, + }; +} + +fn datetimeFromUnix(allocator: std.mem.Allocator, seconds: i64) ![]const u8 { + const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(seconds) }; + const day_seconds = epoch_seconds.getDaySeconds(); + const year_day = epoch_seconds.getEpochDay().calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + return std.fmt.allocPrint( + allocator, + "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.000Z", + .{ year_day.year, month_day.month.numeric(), month_day.day_index + 1, day_seconds.getHoursIntoDay(), day_seconds.getMinutesIntoHour(), day_seconds.getSecondsIntoMinute() }, + ); +} + fn requireAccount(request: *http_api.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { return http_api.requireBearerAccess(request, allocator) catch |err| { switch (err) { @@ -1002,7 +1261,7 @@ fn requireSpaceAccess( collection: ?[]const u8, ) !void { const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, action, collection); + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, action, collection); const visible = try store.getSpace(allocator, auth_ctx.account.did, space); if (visible == null or visible.?.deleted_at != null) { try http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"); @@ -1020,11 +1279,11 @@ fn requireSimpleSpaceAuthority( try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); return error.HandledResponse; }; - if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) { - try http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); + if (!std.mem.eql(u8, parsed.authority_did, auth_ctx.account.did)) { + try http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); return error.HandledResponse; } - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, .manage_update, null); const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse { try http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); return error.HandledResponse; @@ -1040,13 +1299,21 @@ const ReadAuth = union(enum) { space_credential: permissioned.SpaceCredential, }; -fn requireReadAccess(request: *http_api.Request, allocator: std.mem.Allocator, space: []const u8, collection: ?[]const u8) !ReadAuth { +fn requireReadAccess(request: *http_api.Request, allocator: std.mem.Allocator, space: []const u8, repo: []const u8, collection: ?[]const u8) !ReadAuth { + if (zat.Did.parse(repo) == null) { + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid repo DID"); + return error.HandledResponse; + } const parsed = parseSpaceUri(space) orelse { try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); return error.HandledResponse; }; if (http_api.requireBearerAccess(request, allocator)) |auth_ctx| { - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .read, collection); + if (!std.mem.eql(u8, repo, auth_ctx.account.did)) { + try http_api.xrpcError(request, .forbidden, "NotPermitted", "OAuth credentials can only read the holder's own permissioned repo"); + return error.HandledResponse; + } + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, .read_self, collection); const visible = try store.getSpace(allocator, auth_ctx.account.did, space); if (visible == null or visible.?.deleted_at != null) { try http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to read this space"); @@ -1055,6 +1322,14 @@ fn requireReadAccess(request: *http_api.Request, allocator: std.mem.Allocator, s return .{ .bearer = auth_ctx }; } else |_| {} + return .{ .space_credential = try requireSpaceCredential(request, allocator, space) }; +} + +fn requireSpaceCredential(request: *http_api.Request, allocator: std.mem.Allocator, space: []const u8) !permissioned.SpaceCredential { + const parsed = parseSpaceUri(space) orelse { + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); + return error.HandledResponse; + }; const token = bearerToken(request) orelse { try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); return error.HandledResponse; @@ -1063,7 +1338,7 @@ fn requireReadAccess(request: *http_api.Request, allocator: std.mem.Allocator, s try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); return error.HandledResponse; }; - if (!std.mem.eql(u8, issuer, parsed.did)) { + if (!std.mem.eql(u8, issuer, parsed.authority_did)) { try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Space credential issuer must be the space authority"); return error.HandledResponse; } @@ -1079,7 +1354,7 @@ fn requireReadAccess(request: *http_api.Request, allocator: std.mem.Allocator, s try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Credential space mismatch"); return error.HandledResponse; } - return .{ .space_credential = credential }; + return credential; } fn prepareSpaceRecord( @@ -1116,10 +1391,9 @@ fn writeSignedState( space: []const u8, signer_did: []const u8, user_did: []const u8, - scope: permissioned.SpaceContext.Scope, state: store.SpaceState, ) !void { - if (try signedCommitJson(allocator, space, signer_did, user_did, scope, state)) |commit_json| { + if (try signedCommitJson(allocator, space, signer_did, user_did, state)) |commit_json| { return http_api.json(request, .ok, try std.fmt.allocPrint(allocator, "{{\"commit\":{s}}}", .{commit_json})); } return http_api.json(request, .ok, "{}"); @@ -1130,20 +1404,16 @@ fn signedCommitJson( space: []const u8, signer_did: []const u8, user_did: []const u8, - scope: permissioned.SpaceContext.Scope, state: store.SpaceState, ) !?[]const u8 { const set_hash = state.set_hash orelse return null; const rev = state.rev orelse return null; - const parsed = parseSpaceUri(space) orelse return error.InvalidSpaceUri; + _ = parseSpaceUri(space) orelse return error.InvalidSpaceUri; var keypair = try store.signingKeypair(signer_did); const commit = try permissioned.createCommit(allocator, store.currentIo(), set_hash, .{ - .space_did = parsed.did, - .space_type = parsed.space_type, - .space_key = parsed.skey, - .user_did = user_did, + .space = space, + .author = user_did, .rev = rev, - .scope = scope, }, &keypair); return try permissioned.signedCommitJson(allocator, commit); } @@ -1176,7 +1446,7 @@ fn valueBool(value: std.json.Value, key: []const u8) ?bool { }; } -fn spaceAllowsClient(allocator: std.mem.Allocator, space: store.SpaceConfig, client_id: []const u8) bool { +fn spaceAllowsClient(allocator: std.mem.Allocator, space: store.SpaceConfig, client_id: ?[]const u8) bool { const parsed = std.json.parseFromSlice(std.json.Value, allocator, space.app_access_json, .{}) catch return false; defer parsed.deinit(); const object = switch (parsed.value) { @@ -1189,10 +1459,11 @@ fn spaceAllowsClient(allocator: std.mem.Allocator, space: store.SpaceConfig, cli }; if (std.mem.eql(u8, kind, "open")) return true; if (std.mem.eql(u8, kind, "allowList")) { + const actual_client_id = client_id orelse return false; const allowed = object.get("allowed") orelse return false; if (allowed != .array) return false; for (allowed.array.items) |item| { - if (item == .string and std.mem.eql(u8, item.string, client_id)) return true; + if (item == .string and std.mem.eql(u8, item.string, actual_client_id)) return true; } } return false; @@ -1202,32 +1473,47 @@ fn spacePolicyAllowsRequester( allocator: std.mem.Allocator, space: store.SpaceConfig, requester_did: []const u8, - client_id: []const u8, + client_id: ?[]const u8, ) !bool { - _ = allocator; if (std.mem.eql(u8, space.policy, "public")) return true; if (std.mem.eql(u8, space.policy, "member-list")) return store.simpleSpaceHasMember(space.uri, requester_did); - if (std.mem.eql(u8, space.policy, "managing-app")) { - _ = client_id; - return false; - } + if (std.mem.eql(u8, space.policy, "managing-app")) return managingAppAllowsRequester(allocator, space, requester_did, client_id); return false; } +fn percentEncode(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + for (value) |c| { + if (std.ascii.isAlphanumeric(c) or c == '-' or c == '_' or c == '.' or c == '~') { + try out.writer.writeByte(c); + } else { + try out.writer.print("%{X:0>2}", .{c}); + } + } + return out.toOwnedSlice(); +} + fn validPolicy(policy: []const u8) bool { return std.mem.eql(u8, policy, "member-list") or std.mem.eql(u8, policy, "public") or std.mem.eql(u8, policy, "managing-app"); } -fn hexLower(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 { - const alphabet = "0123456789abcdef"; - const out = try allocator.alloc(u8, bytes.len * 2); - for (bytes, 0..) |byte, idx| { - out[idx * 2] = alphabet[byte >> 4]; - out[idx * 2 + 1] = alphabet[byte & 0x0f]; - } - return out; +fn atJsonBytes(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 { + const encoded = try allocator.alloc(u8, std.base64.standard.Encoder.calcSize(bytes.len)); + _ = std.base64.standard.Encoder.encode(encoded, bytes); + return std.fmt.allocPrint(allocator, "{{\"$bytes\":{f}}}", .{std.json.fmt(encoded, .{})}); +} + +fn writeAtJsonBytes(writer: *std.Io.Writer, allocator: std.mem.Allocator, bytes: []const u8) !void { + try writer.writeAll(try atJsonBytes(allocator, bytes)); +} + +fn parseAtJsonBytes(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { + const encoded = zat.json.getString(value, "$bytes") orelse return error.InvalidBytes; + const bytes = try allocator.alloc(u8, try std.base64.standard.Decoder.calcSizeForSlice(encoded)); + try std.base64.standard.Decoder.decode(bytes, encoded); + return bytes; } fn appAccessJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { @@ -1239,63 +1525,68 @@ fn appAccessJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u .object => |object| object, else => return error.InvalidRecordType, }; - const kind = switch (object.get("type") orelse return error.InvalidRecordType) { + const kind = switch (object.get("$type") orelse return error.InvalidRecordType) { .string => |text| text, else => return error.InvalidRecordType, }; - if (std.mem.eql(u8, kind, "open")) return try std.fmt.allocPrint(allocator, "{{\"type\":\"open\"}}", .{}); - if (!std.mem.eql(u8, kind, "allowList")) return error.InvalidRecordType; + if (std.mem.eql(u8, kind, "com.atproto.simplespace.defs#open")) return try allocator.dupe(u8, "{\"type\":\"open\"}"); + if (!std.mem.eql(u8, kind, "com.atproto.simplespace.defs#allowList")) return error.InvalidRecordType; const allowed = object.get("allowed") orelse return error.InvalidRecordType; if (allowed != .array) return error.InvalidRecordType; for (allowed.array.items) |item| { if (item != .string) return error.InvalidRecordType; } - return try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(app_access, .{})}); + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + try out.writer.writeAll("{\"type\":\"allowList\",\"allowed\":["); + for (allowed.array.items, 0..) |item, idx| { + if (idx != 0) try out.writer.writeByte(','); + try out.writer.print("{f}", .{std.json.fmt(item.string, .{})}); + } + try out.writer.writeAll("]}"); + return out.toOwnedSlice(); } -fn spaceConfigJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { +fn lexAppAccessJson(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, raw, .{}); + const kind = zat.json.getString(parsed.value, "type") orelse return error.InvalidRecordType; + if (std.mem.eql(u8, kind, "open")) return allocator.dupe(u8, "{\"$type\":\"com.atproto.simplespace.defs#open\"}"); + if (!std.mem.eql(u8, kind, "allowList")) return error.InvalidRecordType; + const allowed = switch (parsed.value) { + .object => |object| object.get("allowed") orelse return error.InvalidRecordType, + else => return error.InvalidRecordType, + }; + if (allowed != .array) return error.InvalidRecordType; + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + try out.writer.writeAll("{\"$type\":\"com.atproto.simplespace.defs#allowList\",\"allowed\":["); + for (allowed.array.items, 0..) |item, idx| { + if (item != .string) return error.InvalidRecordType; + if (idx != 0) try out.writer.writeByte(','); + try out.writer.print("{f}", .{std.json.fmt(item.string, .{})}); + } + try out.writer.writeAll("]}"); + return out.toOwnedSlice(); +} + +fn simpleSpaceConfigJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.print( - "{{\"uri\":{f},\"authority\":{f},\"type\":{f},\"skey\":{f},\"isAuthority\":{},\"config\":{{\"$type\":\"com.atproto.simplespace.defs#spaceConfig\",\"policy\":{f},\"appAccess\":{s}", + "{{\"$type\":\"com.atproto.simplespace.defs#spaceConfig\",\"policy\":{f},\"appAccess\":{s}", .{ - std.json.fmt(space.uri, .{}), - std.json.fmt(space.authority_did, .{}), - std.json.fmt(space.space_type, .{}), - std.json.fmt(space.skey, .{}), - space.is_authority, std.json.fmt(space.policy, .{}), - space.app_access_json, + try lexAppAccessJson(allocator, space.app_access_json), }, ); if (space.managing_app) |managing_app| { try out.writer.print(",\"managingApp\":{f}", .{std.json.fmt(managing_app, .{})}); } - try out.writer.writeAll("}}}"); + try out.writer.writeByte('}'); return out.toOwnedSlice(); } -const SpaceUriParts = struct { - did: []const u8, - space_type: []const u8, - skey: []const u8, -}; - -fn parseSpaceUri(uri: []const u8) ?SpaceUriParts { - const prefix = "ats://"; - if (!std.mem.startsWith(u8, uri, prefix)) return null; - const rest = uri[prefix.len..]; - const first = std.mem.indexOfScalar(u8, rest, '/') orelse return null; - const did = rest[0..first]; - if (zat.Did.parse(did) == null) return null; - const after_did = rest[first + 1 ..]; - const second = std.mem.indexOfScalar(u8, after_did, '/') orelse return null; - const space_type = after_did[0..second]; - if (zat.Nsid.parse(space_type) == null) return null; - const skey = after_did[second + 1 ..]; - if (zat.Rkey.parse(skey) == null) return null; - return .{ .did = did, .space_type = space_type, .skey = skey }; -} +const parseSpaceUri = space_uris.SpaceUri.parse; fn xrpcMethod(target: []const u8) ?[]const u8 { const prefix = "/xrpc/"; @@ -1306,8 +1597,20 @@ fn xrpcMethod(target: []const u8) ?[]const u8 { } test "parses permissioned space uris" { - const parsed = parseSpaceUri("ats://did:plc:abc/fm.plyr.privateMedia/self").?; - try std.testing.expectEqualStrings("did:plc:abc", parsed.did); + const parsed = parseSpaceUri("at://did:plc:abc/space/fm.plyr.privateMedia/self").?; + try std.testing.expectEqualStrings("did:plc:abc", parsed.authority_did); try std.testing.expectEqualStrings("fm.plyr.privateMedia", parsed.space_type); try std.testing.expectEqualStrings("self", parsed.skey); } + +test "permissioned wire hashes use AT JSON bytes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + const expected = [_]u8{ 0, 1, 2, 253, 254, 255 }; + const encoded = try atJsonBytes(allocator, &expected); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, encoded, .{}); + defer parsed.deinit(); + const actual = try parseAtJsonBytes(allocator, parsed.value); + try std.testing.expectEqualSlices(u8, &expected, actual); +} diff --git a/src/http/router.zig b/src/http/router.zig index a86ffd6..0ccda23 100644 --- a/src/http/router.zig +++ b/src/http/router.zig @@ -213,9 +213,9 @@ pub const endpoints = [_]Endpoint{ .{ .route = .identity_submit_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.submitPlcOperation", .group = "identity", .auth = "bearer", .summary = "Submit a PLC operation." }, .{ .route = .identity_resolve_handle, .method = "GET", .path = "/xrpc/com.atproto.identity.resolveHandle", .group = "identity", .auth = "public", .summary = "Resolve a handle to a DID.", .params = &.{"handle"} }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getSpace", .group = "space", .auth = "experimental bearer", .summary = "Read permissioned data space configuration.", .params = &.{"space"}, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listSpaces", .group = "space", .auth = "experimental bearer", .summary = "List spaces the authenticated user participates in.", .params = &.{ "authority", "type", "limit", "cursor" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRepos", .group = "space", .auth = "experimental bearer or space credential", .summary = "List known writer repos in a permissioned data space.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getSpace", .group = "space", .auth = "experimental bearer", .summary = "Read space configuration from its authority host.", .params = &.{"space"}, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listSpaces", .group = "space", .auth = "experimental bearer", .summary = "List permissioned repos held by the authenticated user, grouped by space.", .params = &.{ "did", "type", "limit", "cursor" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRepos", .group = "space", .auth = "experimental space credential", .summary = "List the authority's known writer repos and commit digests.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getDelegationToken", .group = "space", .auth = "experimental OAuth", .summary = "Create a delegation token for exchange with a space authority.", .params = &.{"space"}, .notes = permissioned_data_note }, @@ -226,19 +226,22 @@ pub const endpoints = [_]Endpoint{ .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRecord", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read a record from a permissioned data space.", .params = &.{ "space", "repo", "collection", "rkey" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRecords", .group = "space", .auth = "experimental bearer or space credential", .summary = "List records in a permissioned data space. Values are included unless excludeValues=true.", .params = &.{ "space", "repo", "collection", "limit", "cursor", "reverse", "excludeValues" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getBlob", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read a blob referenced from a permissioned data record.", .params = &.{ "space", "repo", "cid" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRepoState", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read current record-set commitment state for a writer repo in a space.", .params = &.{ "space", "repo" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRepoOps", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read incremental record operations for a writer repo in a space.", .params = &.{ "space", "repo", "since", "limit" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifyWrite", .group = "space", .auth = "experimental service", .summary = "Notify a space authority or syncing service of a permissioned data write.", .body = &.{ "space", "repo", "rev" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getLatestCommit", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read the current signed commit for a writer repo in a space.", .params = &.{ "space", "repo" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRepo", .group = "space", .auth = "experimental bearer or space credential", .summary = "Download a full permissioned repo CAR for recovery.", .params = &.{ "space", "repo" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRepoOps", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read incremental record operations. Values are included unless excludeValues=true.", .params = &.{ "space", "repo", "since", "limit", "excludeValues" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.registerNotify", .group = "space", .auth = "experimental space credential", .summary = "Register an expiring write-notification endpoint for a space or repo.", .body = &.{ "space", "repo", "endpoint" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifyWrite", .group = "space", .auth = "experimental service", .summary = "Notify a space authority or syncing service of a permissioned data write.", .body = &.{ "space", "repo", "rev", "hash" }, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental delegation token", .summary = "Exchange a delegation token for a space credential.", .body = &.{ "space", "notifyEndpoint" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental delegation token", .summary = "Exchange a delegation token for a space credential.", .body = &.{ "space", "clientAttestation" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifySpaceDeleted", .group = "space", .auth = "experimental service", .summary = "Notify a repo host or syncing service that a space was deleted.", .body = &.{"space"}, .notes = permissioned_data_note }, - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.createSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Create a baseline PDS-managed permissioned data space.", .body = &.{ "type", "skey", "managingApp", "policy", "appAccess" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.createSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Create or materialize a baseline PDS-managed permissioned data space.", .body = &.{ "did", "type", "skey", "config" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.updateSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Update baseline PDS-managed space configuration.", .body = &.{ "space", "managingApp", "policy", "appAccess" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.deleteSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Delete a baseline PDS-managed space.", .body = &.{"space"}, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.addMember", .group = "simplespace", .auth = "experimental bearer", .summary = "Add a DID to a simplespace member-list policy.", .body = &.{ "space", "did" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.removeMember", .group = "simplespace", .auth = "experimental bearer", .summary = "Remove a DID from a simplespace member-list policy.", .body = &.{ "space", "did" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.listMembers", .group = "simplespace", .auth = "experimental bearer", .summary = "List DIDs in a simplespace member-list policy.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.checkUserAccess", .group = "simplespace", .auth = "experimental service", .summary = "Ask a managing app whether a user may access a space. Generic PDS handling denies by default.", .params = &.{ "space", "user", "clientId" }, .notes = permissioned_data_note }, }; pub fn route(method: httpz.Method, target: []const u8) Route { diff --git a/src/internal/cli.zig b/src/internal/cli.zig index f1686ca..7167c45 100644 --- a/src/internal/cli.zig +++ b/src/internal/cli.zig @@ -61,7 +61,7 @@ pub const ParseError = error{ UnknownArgument, } || std.fmt.ParseIntError; -pub fn parse(init: std.process.Init) ParseError!Options { +pub fn parse(init: std.process.Init.Minimal) ParseError!Options { var options = Options{ .host = env("ZDS_HOST") orelse "127.0.0.1", .port = try envU16("ZDS_PORT") orelse 2583, @@ -93,7 +93,7 @@ pub fn parse(init: std.process.Init) ParseError!Options { }; if (envBool("ZDS_DEBUG")) options.log_level = "debug"; - var args = std.process.Args.Iterator.init(init.minimal.args); + var args = std.process.Args.Iterator.init(init.args); _ = args.next(); while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) return error.Help; @@ -331,7 +331,7 @@ test "parse split and joined arguments" { "ZDS ", "--debug", }; - const init = std.process.Init{ .minimal = .{ .args = &argv } }; + const init = std.process.Init.Minimal{ .environ = .empty, .args = .{ .vector = &argv } }; const options = try parse(init); @@ -352,7 +352,7 @@ test "split arguments require a value" { "zds", "--db", }; - const init = std.process.Init{ .minimal = .{ .args = &argv } }; + const init = std.process.Init.Minimal{ .environ = .empty, .args = .{ .vector = &argv } }; try std.testing.expectError(error.MissingDatabasePath, parse(init)); } diff --git a/src/internal/client_attestation.zig b/src/internal/client_attestation.zig new file mode 100644 index 0000000..3a25c71 --- /dev/null +++ b/src/internal/client_attestation.zig @@ -0,0 +1,130 @@ +//! Verification for permissioned-space client attestations. + +const std = @import("std"); +const zat = @import("zat"); + +const max_json_bytes = 256 * 1024; + +pub const Claims = struct { + client_id: []const u8, + jti: []const u8, + exp: i64, +}; + +pub fn verify( + allocator: std.mem.Allocator, + io: std.Io, + token: []const u8, + expected_audience: []const u8, +) !Claims { + var parts = std.mem.splitScalar(u8, token, '.'); + const header_part = parts.next() orelse return error.InvalidJwt; + const payload_part = parts.next() orelse return error.InvalidJwt; + const signature_part = parts.next() orelse return error.InvalidJwt; + if (parts.next() != null) return error.InvalidJwt; + const signing_input_len = header_part.len + 1 + payload_part.len; + if (token.len < signing_input_len or token[header_part.len] != '.') return error.InvalidJwt; + + const header_json = try zat.jwt.base64UrlDecode(allocator, header_part); + defer allocator.free(header_json); + const header = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}); + defer header.deinit(); + if (!std.mem.eql(u8, zat.json.getString(header.value, "typ") orelse return error.InvalidJwt, "atproto-client-attestation+jwt")) return error.InvalidJwt; + const alg = zat.json.getString(header.value, "alg") orelse return error.InvalidJwt; + const jwt_alg = zat.jwt.Algorithm.fromString(alg) orelse return error.InvalidJwt; + const kid = zat.json.getString(header.value, "kid") orelse return error.InvalidJwt; + + const payload_json = try zat.jwt.base64UrlDecode(allocator, payload_part); + defer allocator.free(payload_json); + const payload = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); + defer payload.deinit(); + const issuer = zat.json.getString(payload.value, "iss") orelse return error.InvalidJwt; + const subject = zat.json.getString(payload.value, "sub") orelse return error.InvalidJwt; + const audience = zat.json.getString(payload.value, "aud") orelse return error.InvalidJwt; + const issued_at = zat.json.getInt(payload.value, "iat") orelse return error.InvalidJwt; + const expires_at = zat.json.getInt(payload.value, "exp") orelse return error.InvalidJwt; + const jti = zat.json.getString(payload.value, "jti") orelse return error.InvalidJwt; + if (!std.mem.eql(u8, issuer, subject)) return error.InvalidJwt; + if (!std.mem.eql(u8, audience, expected_audience)) return error.AudienceMismatch; + if (!fresh(now(), issued_at, expires_at)) return error.ExpiredJwt; + if (!std.mem.startsWith(u8, issuer, "https://")) return error.InvalidClientId; + + const metadata = try fetchJson(allocator, io, issuer); + defer metadata.deinit(); + if (!std.mem.eql(u8, zat.json.getString(metadata.value, "client_id") orelse issuer, issuer)) return error.InvalidClientMetadata; + var fetched_jwks: ?std.json.Parsed(std.json.Value) = null; + defer if (fetched_jwks) |parsed| parsed.deinit(); + const jwks = if (metadata.value == .object and metadata.value.object.get("jwks") != null) + metadata.value.object.get("jwks").? + else blk: { + const uri = zat.json.getString(metadata.value, "jwks_uri") orelse return error.InvalidClientMetadata; + fetched_jwks = try fetchJson(allocator, io, uri); + break :blk fetched_jwks.?.value; + }; + const public_key = try publicKeyFromJwks(allocator, jwks, kid, alg); + defer allocator.free(public_key); + const signature = try zat.jwt.base64UrlDecode(allocator, signature_part); + defer allocator.free(signature); + try zat.jwt.verifyJose(jwt_alg, token[0..signing_input_len], signature, public_key); + return .{ + .client_id = try allocator.dupe(u8, issuer), + .jti = try allocator.dupe(u8, jti), + .exp = expires_at, + }; +} + +fn fetchJson(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !std.json.Parsed(std.json.Value) { + var transport = zat.HttpTransport.init(io, allocator); + defer transport.deinit(); + const result = try transport.fetch(.{ + .url = url, + .method = .GET, + .accept = "application/json", + .max_response_size = max_json_bytes, + .redirect_behavior = .not_allowed, + }); + const status = @intFromEnum(result.status); + if (status < 200 or status >= 300) return error.HttpStatus; + return std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); +} + +fn publicKeyFromJwks(allocator: std.mem.Allocator, jwks: std.json.Value, kid: []const u8, alg: []const u8) ![]u8 { + const keys = zat.json.getArray(jwks, "keys") orelse return error.InvalidJwks; + for (keys) |key| { + if (!std.mem.eql(u8, zat.json.getString(key, "kid") orelse continue, kid)) continue; + if (!std.mem.eql(u8, zat.json.getString(key, "kty") orelse "", "EC")) continue; + const curve = zat.json.getString(key, "crv") orelse continue; + if (std.mem.eql(u8, alg, "ES256") and !std.mem.eql(u8, curve, "P-256")) continue; + if (std.mem.eql(u8, alg, "ES256K") and !std.mem.eql(u8, curve, "secp256k1")) continue; + const x = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "x") orelse return error.InvalidJwks); + defer allocator.free(x); + const y = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "y") orelse return error.InvalidJwks); + defer allocator.free(y); + if (x.len != 32 or y.len != 32) return error.InvalidJwks; + const public_key = try allocator.alloc(u8, 33); + public_key[0] = if ((y[31] & 1) == 1) 0x03 else 0x02; + @memcpy(public_key[1..], x); + return public_key; + } + return error.KeyNotFound; +} + +fn fresh(current: i64, issued_at: i64, expires_at: i64) bool { + if (issued_at > current + 60 or expires_at < current) return false; + return expires_at >= issued_at and expires_at - issued_at <= 300; +} + +fn now() i64 { + var ts: std.posix.timespec = undefined; + return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { + .SUCCESS => ts.sec, + else => 0, + }; +} + +test "client attestation freshness is short lived" { + try std.testing.expect(fresh(1000, 940, 1060)); + try std.testing.expect(!fresh(1000, 600, 1100)); + try std.testing.expect(!fresh(1000, 1061, 1100)); + try std.testing.expect(!fresh(1000, 900, 999)); +} diff --git a/src/internal/email_tokens.zig b/src/internal/email_tokens.zig index afe9efa..4f6b076 100644 --- a/src/internal/email_tokens.zig +++ b/src/internal/email_tokens.zig @@ -14,6 +14,9 @@ pub fn makeCode(out: *[11]u8) []const u8 { } test "email codes use social-app compatible base32 shape" { + try store.init(std.Options.debug_io, ":memory:"); + defer store.close(); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; var out: [11]u8 = undefined; const code = makeCode(&out); diff --git a/src/internal/permissioned_data.zig b/src/internal/permissioned_data.zig index f5e7234..0a3f62a 100644 --- a/src/internal/permissioned_data.zig +++ b/src/internal/permissioned_data.zig @@ -63,49 +63,42 @@ pub const LtHash = struct { }; pub const SpaceContext = struct { - space_did: []const u8, - space_type: []const u8, - space_key: []const u8, - user_did: []const u8, + space: []const u8, + author: []const u8, rev: []const u8, - scope: Scope, - - pub const Scope = enum { - records, - - pub fn text(self: Scope) []const u8 { - return switch (self) { - .records => "records", - }; - } - }; }; pub const SignedCommit = struct { + ver: u8 = 1, hash: [32]u8, - hmac: [32]u8, + mac: [32]u8, ikm: [32]u8, sig: [64]u8, rev: []const u8, }; +pub const RepoRecordBlock = struct { + path: []const u8, + cid: zat.cbor.Cid, + data: []const u8, +}; + pub const DelegationToken = struct { requester_did: []const u8, authority_did: []const u8, space: []const u8, - client_id: []const u8, + jti: []const u8, exp: i64, }; pub const SpaceCredential = struct { authority_did: []const u8, space: []const u8, - client_id: []const u8, exp: i64, }; pub fn recordElement(allocator: std.mem.Allocator, collection: []const u8, rkey: []const u8, cid: []const u8) ![]const u8 { - return std.fmt.allocPrint(allocator, "{s}/{s}:{s}", .{ collection, rkey, cid }); + return std.fmt.allocPrint(allocator, "{s}/{s}/{s}", .{ collection, rkey, cid }); } pub fn createCommit( @@ -118,11 +111,13 @@ pub fn createCommit( const lthash = try LtHash.fromBytes(state); var ikm: [32]u8 = undefined; io.random(&ikm); - const hmac = try commitHmac(allocator, &ikm, <hash.digest(), ctx); - const sig = try keypair.sign(&ikm); + const context = try commitContext(allocator, ctx, &ikm); + defer allocator.free(context); + const mac = commitMac(&ikm, context, <hash.digest()); + const sig = try keypair.sign(context); return .{ .hash = lthash.digest(), - .hmac = hmac, + .mac = mac, .ikm = ikm, .sig = sig.bytes, .rev = ctx.rev, @@ -130,28 +125,73 @@ pub fn createCommit( } pub fn signedCommitJson(allocator: std.mem.Allocator, commit: SignedCommit) ![]const u8 { - const hash = try zat.jwt.base64UrlEncode(allocator, &commit.hash); + const hash = try base64Bytes(allocator, &commit.hash); defer allocator.free(hash); - const hmac = try zat.jwt.base64UrlEncode(allocator, &commit.hmac); - defer allocator.free(hmac); - const ikm = try zat.jwt.base64UrlEncode(allocator, &commit.ikm); + const mac = try base64Bytes(allocator, &commit.mac); + defer allocator.free(mac); + const ikm = try base64Bytes(allocator, &commit.ikm); defer allocator.free(ikm); - const sig = try zat.jwt.base64UrlEncode(allocator, &commit.sig); + const sig = try base64Bytes(allocator, &commit.sig); defer allocator.free(sig); return std.fmt.allocPrint( allocator, - "{{\"hash\":{f},\"hmac\":{f},\"ikm\":{f},\"sig\":{f},\"rev\":{f}}}", - .{ std.json.fmt(hash, .{}), std.json.fmt(hmac, .{}), std.json.fmt(ikm, .{}), std.json.fmt(sig, .{}), std.json.fmt(commit.rev, .{}) }, + "{{\"ver\":{d},\"hash\":{{\"$bytes\":{f}}},\"mac\":{{\"$bytes\":{f}}},\"ikm\":{{\"$bytes\":{f}}},\"sig\":{{\"$bytes\":{f}}},\"rev\":{f}}}", + .{ commit.ver, std.json.fmt(hash, .{}), std.json.fmt(mac, .{}), std.json.fmt(ikm, .{}), std.json.fmt(sig, .{}), std.json.fmt(commit.rev, .{}) }, ); } +pub fn serializeRepoCar( + allocator: std.mem.Allocator, + commit: SignedCommit, + records: []const RepoRecordBlock, +) ![]u8 { + const commit_value: zat.cbor.Value = .{ .map = &.{ + .{ .key = "ver", .value = .{ .unsigned = commit.ver } }, + .{ .key = "hash", .value = .{ .bytes = &commit.hash } }, + .{ .key = "mac", .value = .{ .bytes = &commit.mac } }, + .{ .key = "ikm", .value = .{ .bytes = &commit.ikm } }, + .{ .key = "sig", .value = .{ .bytes = &commit.sig } }, + .{ .key = "rev", .value = .{ .text = commit.rev } }, + } }; + const commit_bytes = try zat.cbor.encodeAlloc(allocator, commit_value); + const commit_cid = try zat.cbor.Cid.forDagCbor(allocator, commit_bytes); + + const index_entries = try allocator.alloc(zat.cbor.Value.MapEntry, records.len); + for (records, 0..) |record, idx| { + if (idx > 0 and std.mem.order(u8, records[idx - 1].path, record.path) != .lt) { + return error.RecordsNotSorted; + } + const computed = try zat.cbor.Cid.forDagCbor(allocator, record.data); + if (!std.mem.eql(u8, computed.raw, record.cid.raw)) return error.RecordCidMismatch; + index_entries[idx] = .{ .key = record.path, .value = .{ .cid = record.cid } }; + } + const index_bytes = try zat.cbor.encodeAlloc(allocator, .{ .map = index_entries }); + const index_cid = try zat.cbor.Cid.forDagCbor(allocator, index_bytes); + + const blocks = try allocator.alloc(zat.car.Block, records.len + 2); + blocks[0] = .{ .cid_raw = commit_cid.raw, .data = commit_bytes }; + blocks[1] = .{ .cid_raw = index_cid.raw, .data = index_bytes }; + for (records, 0..) |record, idx| { + blocks[idx + 2] = .{ .cid_raw = record.cid.raw, .data = record.data }; + } + return zat.car.writeAlloc(allocator, .{ + .roots = &.{ commit_cid, index_cid }, + .blocks = blocks, + }); +} + +fn base64Bytes(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 { + const encoded = try allocator.alloc(u8, std.base64.standard.Encoder.calcSize(bytes.len)); + _ = std.base64.standard.Encoder.encode(encoded, bytes); + return encoded; +} + pub fn createDelegationToken( allocator: std.mem.Allocator, io: std.Io, requester_did: []const u8, authority_did: []const u8, space: []const u8, - client_id: []const u8, keypair: *const zat.Keypair, ) ![]const u8 { const iat = unixNow(); @@ -164,8 +204,8 @@ pub fn createDelegationToken( defer allocator.free(audience); const payload = try std.fmt.allocPrint( allocator, - "{{\"iss\":{f},\"aud\":{f},\"sub\":{f},\"client_id\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", - .{ std.json.fmt(requester_did, .{}), std.json.fmt(audience, .{}), std.json.fmt(space, .{}), std.json.fmt(client_id, .{}), iat, exp, std.json.fmt(jti, .{}) }, + "{{\"iss\":{f},\"aud\":{f},\"sub\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", + .{ std.json.fmt(requester_did, .{}), std.json.fmt(audience, .{}), std.json.fmt(space, .{}), iat, exp, std.json.fmt(jti, .{}) }, ); defer allocator.free(payload); return zat.oauth.createJwt(allocator, header, payload, keypair); @@ -176,19 +216,18 @@ pub fn createSpaceCredential( io: std.Io, authority_did: []const u8, space: []const u8, - client_id: []const u8, keypair: *const zat.Keypair, ) ![]const u8 { const iat = unixNow(); const exp = iat + 7200; const jti = try randomTokenId(allocator, io); defer allocator.free(jti); - const header = try std.fmt.allocPrint(allocator, "{{\"typ\":\"atproto-space-credential+jwt\",\"alg\":\"{s}\",\"kid\":\"#atproto_space\"}}", .{@tagName(keypair.algorithm())}); + const header = try std.fmt.allocPrint(allocator, "{{\"typ\":\"atproto-space-credential+jwt\",\"alg\":\"{s}\",\"kid\":\"#atproto\"}}", .{@tagName(keypair.algorithm())}); defer allocator.free(header); const payload = try std.fmt.allocPrint( allocator, - "{{\"iss\":{f},\"sub\":{f},\"client_id\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", - .{ std.json.fmt(authority_did, .{}), std.json.fmt(space, .{}), std.json.fmt(client_id, .{}), iat, exp, std.json.fmt(jti, .{}) }, + "{{\"iss\":{f},\"sub\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", + .{ std.json.fmt(authority_did, .{}), std.json.fmt(space, .{}), iat, exp, std.json.fmt(jti, .{}) }, ); defer allocator.free(payload); return zat.oauth.createJwt(allocator, header, payload, keypair); @@ -205,7 +244,7 @@ pub fn verifyDelegationToken(allocator: std.mem.Allocator, token: []const u8, pu .requester_did = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "iss") orelse return error.InvalidJwt), .authority_did = try allocator.dupe(u8, authority_did), .space = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "sub") orelse return error.InvalidJwt), - .client_id = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "client_id") orelse return error.InvalidJwt), + .jti = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "jti") orelse return error.InvalidJwt), .exp = exp, }; } @@ -218,7 +257,6 @@ pub fn verifySpaceCredential(allocator: std.mem.Allocator, token: []const u8, pu return .{ .authority_did = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "iss") orelse return error.InvalidJwt), .space = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "sub") orelse return error.InvalidJwt), - .client_id = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "client_id") orelse return error.InvalidJwt), .exp = exp, }; } @@ -323,26 +361,22 @@ fn unixNow() i64 { }; } -fn commitHmac(allocator: std.mem.Allocator, ikm: *const [32]u8, hash: *const [32]u8, ctx: SpaceContext) ![32]u8 { - const info = try commitInfo(allocator, ctx); - defer allocator.free(info); +fn commitMac(ikm: *const [32]u8, context: []const u8, hash: *const [32]u8) [32]u8 { const prk = HkdfSha256.extract("", ikm); var derived: [32]u8 = undefined; - HkdfSha256.expand(&derived, info, prk); + HkdfSha256.expand(&derived, context, prk); var out: [32]u8 = undefined; HmacSha256.create(&out, hash, &derived); return out; } -fn commitInfo(allocator: std.mem.Allocator, ctx: SpaceContext) ![]const u8 { +fn commitContext(allocator: std.mem.Allocator, ctx: SpaceContext, ikm: *const [32]u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); try out.writer.writeAll("atproto-space-v1"); - try writeInfoField(&out.writer, ctx.space_did); - try writeInfoField(&out.writer, ctx.space_type); - try writeInfoField(&out.writer, ctx.space_key); - try writeInfoField(&out.writer, ctx.user_did); + try writeInfoField(&out.writer, ctx.space); + try writeInfoField(&out.writer, ctx.author); try writeInfoField(&out.writer, ctx.rev); - try writeInfoField(&out.writer, ctx.scope.text()); + try writeInfoField(&out.writer, ikm); return out.toOwnedSlice(); } @@ -376,6 +410,54 @@ test "LtHash add remove and ordering" { try std.testing.expect(first.equals(&empty)); } +test "permissioned repo CAR has commit and index roots followed by sorted records" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const first_data = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ + .{ .key = "$type", .value = .{ .text = "fm.example.note" } }, + .{ .key = "text", .value = .{ .text = "first" } }, + } }); + const second_data = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ + .{ .key = "$type", .value = .{ .text = "fm.example.note" } }, + .{ .key = "text", .value = .{ .text = "second" } }, + } }); + const first_cid = try zat.cbor.Cid.forDagCbor(allocator, first_data); + const second_cid = try zat.cbor.Cid.forDagCbor(allocator, second_data); + const first_cid_text = try zat.multibase.base32lower.encode(allocator, first_cid.raw); + const second_cid_text = try zat.multibase.base32lower.encode(allocator, second_cid.raw); + + var state: LtHash = .{}; + state.add(try recordElement(allocator, "fm.example.note", "one", first_cid_text)); + state.add(try recordElement(allocator, "fm.example.note", "two", second_cid_text)); + var keypair = try zat.Keypair.fromSecretKey(.p256, .{0x21} ** 32); + const commit = try createCommit(allocator, std.Options.debug_io, &state.bytes, .{ + .space = "at://did:plc:alice/space/fm.example.private/self", + .author = "did:plc:alice", + .rev = "3mrepoexporttest", + }, &keypair); + const records = [_]RepoRecordBlock{ + .{ .path = "fm.example.note/one", .cid = first_cid, .data = first_data }, + .{ .path = "fm.example.note/two", .cid = second_cid, .data = second_data }, + }; + const bytes = try serializeRepoCar(allocator, commit, &records); + const car = try zat.car.read(allocator, bytes); + try std.testing.expectEqual(@as(usize, 2), car.roots.len); + try std.testing.expectEqual(@as(usize, 4), car.blocks.len); + try std.testing.expectEqualSlices(u8, car.roots[0].raw, car.blocks[0].cid_raw); + try std.testing.expectEqualSlices(u8, car.roots[1].raw, car.blocks[1].cid_raw); + try std.testing.expectEqualSlices(u8, first_cid.raw, car.blocks[2].cid_raw); + try std.testing.expectEqualSlices(u8, second_cid.raw, car.blocks[3].cid_raw); + + const decoded_commit = try zat.cbor.decodeAll(allocator, car.blocks[0].data); + try std.testing.expectEqualSlices(u8, &state.digest(), decoded_commit.getBytes("hash").?); + try std.testing.expectEqualStrings("3mrepoexporttest", decoded_commit.getString("rev").?); + const index = try zat.cbor.decodeAll(allocator, car.blocks[1].data); + try std.testing.expectEqualSlices(u8, first_cid.raw, index.get("fm.example.note/one").?.cid.raw); + try std.testing.expectEqualSlices(u8, second_cid.raw, index.get("fm.example.note/two").?.cid.raw); +} + test "LtHash snapshot vector" { var hash: LtHash = .{}; hash.add("atproto"); @@ -404,28 +486,24 @@ test "delegation token and space credential round trip" { std.Options.debug_io, "did:plc:requester", "did:plc:spaceauthority", - "ats://did:plc:spaceauthority/fm.plyr.privateMedia/self", - "https://plyr.fm/oauth-client.json", + "at://did:plc:spaceauthority/space/fm.plyr.privateMedia/self", &keypair, ); const delegation = try verifyDelegationToken(allocator, delegation_token, public_key_multibase); try std.testing.expectEqualStrings("did:plc:requester", delegation.requester_did); try std.testing.expectEqualStrings("did:plc:spaceauthority", delegation.authority_did); - try std.testing.expectEqualStrings("ats://did:plc:spaceauthority/fm.plyr.privateMedia/self", delegation.space); - try std.testing.expectEqualStrings("https://plyr.fm/oauth-client.json", delegation.client_id); + try std.testing.expectEqualStrings("at://did:plc:spaceauthority/space/fm.plyr.privateMedia/self", delegation.space); const credential_token = try createSpaceCredential( allocator, std.Options.debug_io, "did:plc:spaceauthority", delegation.space, - delegation.client_id, &keypair, ); const credential = try verifySpaceCredential(allocator, credential_token, public_key_multibase); try std.testing.expectEqualStrings("did:plc:spaceauthority", credential.authority_did); try std.testing.expectEqualStrings(delegation.space, credential.space); - try std.testing.expectEqualStrings(delegation.client_id, credential.client_id); try std.testing.expectError(error.InvalidJwt, verifySpaceCredential(allocator, delegation_token, public_key_multibase)); try std.testing.expectError(error.InvalidJwt, verifyDelegationToken(allocator, credential_token, public_key_multibase)); diff --git a/src/internal/scopes.zig b/src/internal/scopes.zig index d88a13c..839eb32 100644 --- a/src/internal/scopes.zig +++ b/src/internal/scopes.zig @@ -1,7 +1,7 @@ const std = @import("std"); pub const RepoAction = enum { create, update, delete }; -pub const SpaceAction = enum { read, create, update, delete, manage }; +pub const SpaceAction = enum { read_self, read, create, update, delete, manage_create, manage_update, manage_delete }; pub const AccountAction = enum { read, manage }; pub const AccountAttr = enum { email, repo, status, wildcard }; pub const IdentityAttr = enum { handle, wildcard }; @@ -251,34 +251,57 @@ fn spaceScopeMatches( scope: []const u8, action: SpaceAction, space_type: []const u8, - did: []const u8, + authority: []const u8, skey: []const u8, collection: ?[]const u8, ) bool { const query_start = std.mem.indexOfScalar(u8, scope, '?'); const base = if (query_start) |idx| scope[0..idx] else scope; if (!spaceTypeMatches(base, space_type)) return false; - if (query_start == null) return true; - + if (query_start == null) return action == .read; + var saw_action = false; + var action_matches = false; + var saw_manage = false; + var manage_matches = false; var saw_collection = false; + var collection_matches = false; var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); while (params.next()) |param| { if (std.mem.startsWith(u8, param, "action=")) { - if (!std.mem.eql(u8, param["action=".len..], spaceActionName(action))) return false; - } else if (std.mem.startsWith(u8, param, "did=")) { - const allowed = param["did=".len..]; - if (!std.mem.eql(u8, allowed, "*") and !std.mem.eql(u8, allowed, did)) return false; + saw_action = true; + const allowed = param["action=".len..]; + action_matches = action_matches or switch (action) { + .read_self => std.mem.eql(u8, allowed, "read_self") or std.mem.eql(u8, allowed, "read"), + .read, .create, .update, .delete => std.mem.eql(u8, allowed, spaceActionName(action)), + else => false, + }; + } else if (std.mem.startsWith(u8, param, "manage=")) { + saw_manage = true; + manage_matches = manage_matches or std.mem.eql(u8, param["manage=".len..], spaceManageName(action)); + } else if (std.mem.startsWith(u8, param, "authority=")) { + const allowed = param["authority=".len..]; + if (std.mem.eql(u8, allowed, "self")) return false; + if (!std.mem.eql(u8, allowed, "*") and !std.mem.eql(u8, allowed, authority)) return false; } else if (std.mem.startsWith(u8, param, "skey=")) { const allowed = param["skey=".len..]; if (!std.mem.eql(u8, allowed, "*") and !std.mem.eql(u8, allowed, skey)) return false; } else if (std.mem.startsWith(u8, param, "collection=")) { + if (action == .read or switch (action) { + .manage_create, .manage_update, .manage_delete => true, + else => false, + }) continue; saw_collection = true; const requested = collection orelse return false; const allowed = param["collection=".len..]; - if (!collectionPatternMatches(allowed, requested)) return false; + collection_matches = collection_matches or collectionPatternMatches(allowed, requested); } } - return !saw_collection or collection != null; + return switch (action) { + .manage_create, .manage_update, .manage_delete => saw_manage and manage_matches, + .read => if (saw_action) action_matches else true, + .read_self => (if (saw_action) action_matches else true) and saw_collection and collection_matches, + .create, .update, .delete => (if (saw_action) action_matches else true) and saw_collection and collection_matches, + }; } fn spaceTypeMatches(base: []const u8, space_type: []const u8) bool { @@ -299,11 +322,21 @@ fn collectionPatternMatches(pattern: []const u8, value: []const u8) bool { fn spaceActionName(action: SpaceAction) []const u8 { return switch (action) { + .read_self => "read_self", .read => "read", .create => "create", .update => "update", .delete => "delete", - .manage => "manage", + .manage_create, .manage_update, .manage_delete => "", + }; +} + +fn spaceManageName(action: SpaceAction) []const u8 { + return switch (action) { + .manage_create => "create", + .manage_update => "update", + .manage_delete => "delete", + else => "", }; } @@ -329,9 +362,13 @@ test "rpc scopes constrain method and audience" { } test "space scopes constrain type identity key action and collection" { - try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?action=create&did=did:plc:alice&skey=self&collection=fm.plyr.track", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); - try std.testing.expect(!spaceAllows("space:fm.plyr.privateMedia?action=read&did=did:plc:alice&skey=self", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); - try std.testing.expect(spaceAllows("space:fm.plyr.*?action=read&did=*&skey=*", .read, "fm.plyr.privateMedia", "did:plc:bob", "records", null)); + try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&skey=self&collection=fm.plyr.track&action=create", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); + try std.testing.expect(!spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&skey=self&action=read", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); + try std.testing.expect(spaceAllows("space:fm.plyr.*?authority=*&skey=*&action=read", .read, "fm.plyr.privateMedia", "did:plc:bob", "records", null)); + try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&collection=fm.plyr.track&action=read_self", .read_self, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); + try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&collection=fm.plyr.track&action=read", .read, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); + try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&manage=update", .manage_update, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); + try std.testing.expect(!spaceAllows("space:fm.plyr.privateMedia?authority=self&manage=update", .manage_update, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); try std.testing.expect(!spaceAllows("repo:*", .read, "fm.plyr.privateMedia", "did:plc:bob", "records", null)); } diff --git a/src/internal/space_uri.zig b/src/internal/space_uri.zig new file mode 100644 index 0000000..b6d4207 --- /dev/null +++ b/src/internal/space_uri.zig @@ -0,0 +1,115 @@ +const std = @import("std"); +const zat = @import("zat"); + +pub const SpaceUri = struct { + authority_did: []const u8, + space_type: []const u8, + skey: []const u8, + author_did: ?[]const u8 = null, + collection: ?[]const u8 = null, + rkey: ?[]const u8 = null, + + pub fn parse(uri: []const u8) ?SpaceUri { + const prefix = "at://"; + if (!std.mem.startsWith(u8, uri, prefix)) return null; + const rest = uri[prefix.len..]; + + const authority_end = std.mem.indexOfScalar(u8, rest, '/') orelse return null; + const authority_did = rest[0..authority_end]; + if (zat.Did.parse(authority_did) == null) return null; + + const path = rest[authority_end + 1 ..]; + const marker_end = std.mem.indexOfScalar(u8, path, '/') orelse return null; + if (!std.mem.eql(u8, path[0..marker_end], "space")) return null; + + const after_marker = path[marker_end + 1 ..]; + const type_end = std.mem.indexOfScalar(u8, after_marker, '/') orelse return null; + const space_type = after_marker[0..type_end]; + if (zat.Nsid.parse(space_type) == null) return null; + + const after_type = after_marker[type_end + 1 ..]; + const skey_end = std.mem.indexOfScalar(u8, after_type, '/') orelse after_type.len; + const skey = after_type[0..skey_end]; + if (zat.Rkey.parse(skey) == null) return null; + + if (skey_end == after_type.len) return .{ + .authority_did = authority_did, + .space_type = space_type, + .skey = skey, + }; + + const record_path = after_type[skey_end + 1 ..]; + const author_end = std.mem.indexOfScalar(u8, record_path, '/') orelse return null; + const author_did = record_path[0..author_end]; + if (zat.Did.parse(author_did) == null) return null; + const after_author = record_path[author_end + 1 ..]; + const collection_end = std.mem.indexOfScalar(u8, after_author, '/') orelse return null; + const collection = after_author[0..collection_end]; + if (zat.Nsid.parse(collection) == null) return null; + const rkey = after_author[collection_end + 1 ..]; + if (std.mem.indexOfScalar(u8, rkey, '/') != null or zat.Rkey.parse(rkey) == null) return null; + + return .{ + .authority_did = authority_did, + .space_type = space_type, + .skey = skey, + .author_did = author_did, + .collection = collection, + .rkey = rkey, + }; + } + + pub fn format( + allocator: std.mem.Allocator, + authority_did: []const u8, + space_type: []const u8, + skey: []const u8, + ) ![]const u8 { + if (zat.Did.parse(authority_did) == null) return error.InvalidDid; + if (zat.Nsid.parse(space_type) == null) return error.InvalidNsid; + if (zat.Rkey.parse(skey) == null) return error.InvalidRecordKey; + return std.fmt.allocPrint(allocator, "at://{s}/space/{s}/{s}", .{ authority_did, space_type, skey }); + } + + pub fn formatRecord( + allocator: std.mem.Allocator, + space: []const u8, + author_did: []const u8, + collection: []const u8, + rkey: []const u8, + ) ![]const u8 { + const parsed = parse(space) orelse return error.InvalidSpaceUri; + if (parsed.author_did != null) return error.InvalidSpaceUri; + if (zat.Did.parse(author_did) == null) return error.InvalidDid; + if (zat.Nsid.parse(collection) == null) return error.InvalidNsid; + if (zat.Rkey.parse(rkey) == null) return error.InvalidRecordKey; + return std.fmt.allocPrint(allocator, "{s}/{s}/{s}/{s}", .{ space, author_did, collection, rkey }); + } +}; + +test "parses canonical permissioned space uri" { + const parsed = SpaceUri.parse("at://did:plc:abc/space/fm.plyr.privateMedia/self").?; + try std.testing.expectEqualStrings("did:plc:abc", parsed.authority_did); + try std.testing.expectEqualStrings("fm.plyr.privateMedia", parsed.space_type); + try std.testing.expectEqualStrings("self", parsed.skey); +} + +test "parses canonical permissioned record uri" { + const parsed = SpaceUri.parse("at://did:plc:abc/space/fm.plyr.privateMedia/self/did:plc:author/fm.plyr.track/key").?; + try std.testing.expectEqualStrings("did:plc:author", parsed.author_did.?); + try std.testing.expectEqualStrings("fm.plyr.track", parsed.collection.?); + try std.testing.expectEqualStrings("key", parsed.rkey.?); +} + +test "formats canonical permissioned record uri" { + const uri = try SpaceUri.formatRecord(std.testing.allocator, "at://did:plc:abc/space/fm.plyr.privateMedia/self", "did:plc:author", "fm.plyr.track", "key"); + defer std.testing.allocator.free(uri); + try std.testing.expectEqualStrings("at://did:plc:abc/space/fm.plyr.privateMedia/self/did:plc:author/fm.plyr.track/key", uri); +} + +test "rejects legacy and malformed space uris" { + try std.testing.expect(SpaceUri.parse("ats://did:plc:abc/fm.plyr.privateMedia/self") == null); + try std.testing.expect(SpaceUri.parse("at://did:plc:abc/fm.plyr.privateMedia/self") == null); + try std.testing.expect(SpaceUri.parse("at://did:plc:abc/space/fm.plyr.privateMedia/self/did:plc:author") == null); + try std.testing.expect(SpaceUri.parse("at://did:plc:abc/space/fm.plyr.privateMedia/self/did:plc:author/fm.plyr.track/key/extra") == null); +} diff --git a/src/main.zig b/src/main.zig index 7da352b..d0c2e98 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,7 +14,7 @@ pub fn main(init: std.process.Init) !void { }); const io = app_threaded_io.io(); - const options = zds.internal.cli.parse(init) catch |err| switch (err) { + const options = zds.internal.cli.parse(init.minimal) catch |err| switch (err) { error.Help => { zds.internal.cli.usage(); return; diff --git a/src/root.zig b/src/root.zig index 31237d4..958e9c2 100644 --- a/src/root.zig +++ b/src/root.zig @@ -34,12 +34,14 @@ pub const http = struct { pub const internal = struct { pub const api_reference = @import("internal/api_reference/root.zig"); pub const cli = @import("internal/cli.zig"); + pub const client_attestation = @import("internal/client_attestation.zig"); pub const email_tokens = @import("internal/email_tokens.zig"); pub const dpop = @import("internal/dpop.zig"); pub const passkeys = @import("internal/passkeys.zig"); pub const permissioned_data = @import("internal/permissioned_data.zig"); pub const scopes = @import("internal/scopes.zig"); pub const sharded_locks = @import("internal/sharded_locks.zig"); + pub const space_uri = @import("internal/space_uri.zig"); }; pub const storage = struct { @@ -51,10 +53,41 @@ pub const storage = struct { pub const zat = @import("zat"); test { - _ = atproto; - _ = auth; - _ = core; - _ = http; - _ = internal; - _ = storage; + inline for (.{ + atproto.identity, + atproto.plc, + atproto.preferences, + atproto.repo, + atproto.proxy, + atproto.server, + atproto.space, + atproto.sync, + auth.tokens, + core.atid, + core.config, + core.log, + core.mail, + core.repo, + core.syntax, + core.xrpc, + http.api, + http.docs, + http.landing, + http.router, + http.server, + internal.api_reference, + internal.cli, + internal.client_attestation, + internal.email_tokens, + internal.dpop, + internal.passkeys, + internal.permissioned_data, + internal.scopes, + internal.sharded_locks, + internal.space_uri, + storage.blobstore, + storage.eventlog, + storage.store, + }) |module| std.testing.refAllDecls(module); } +const std = @import("std"); diff --git a/src/storage/blobstore.zig b/src/storage/blobstore.zig index 878edac..68db696 100644 --- a/src/storage/blobstore.zig +++ b/src/storage/blobstore.zig @@ -81,7 +81,8 @@ test "disk blobstore writes and reads account blob bytes" { defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp.dir.realpath(".", &path_buf); + const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); + const path = path_buf[0..path_len]; init(std.Options.debug_io, path); var arena = std.heap.ArenaAllocator.init(allocator); diff --git a/src/storage/store.zig b/src/storage/store.zig index ce055b2..e9c8106 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -6,6 +6,7 @@ const cbor_json = @import("../internal/cbor_json.zig"); const eventlog = @import("eventlog.zig"); const permissioned = @import("../internal/permissioned_data.zig"); const sharded_locks = @import("../internal/sharded_locks.zig"); +const space_uri = @import("../internal/space_uri.zig"); const zat = @import("zat"); const zqlite = @import("zqlite"); const Io = std.Io; @@ -325,7 +326,7 @@ pub const SpaceRecord = struct { updated_at: i64, pub fn uri(self: SpaceRecord, allocator: std.mem.Allocator) ![]const u8 { - return std.fmt.allocPrint(allocator, "{s}/{s}/{s}/{s}", .{ self.space, self.repo_did, self.collection, self.rkey }); + return space_uri.SpaceUri.formatRecord(allocator, self.space, self.repo_did, self.collection, self.rkey); } }; @@ -345,6 +346,7 @@ pub const SpaceRecordOplogEntry = struct { rkey: []const u8, cid: ?[]const u8, prev: ?[]const u8, + value_json: ?[]const u8, }; pub const SpaceState = struct { @@ -352,16 +354,16 @@ pub const SpaceState = struct { rev: ?[]const u8, }; -pub const SpaceRepoState = struct { +pub const SpaceWriterState = struct { repo_did: []const u8, - set_hash: ?[]const u8, - rev: ?[]const u8, + hash: []const u8, + rev: []const u8, }; pub const CredentialRecipient = struct { - service_did: []const u8, service_endpoint: []const u8, - last_issued_at: i64, + repo_did: ?[]const u8, + expires_at: i64, }; pub const PreparedRecord = struct { @@ -3077,7 +3079,7 @@ pub fn createSpace(allocator: std.mem.Allocator, input: CreateSpaceInput) !Space if (!validSimpleSpacePolicy(input.policy)) return Error.InvalidRecordType; try validateAppAccessJson(input.app_access_json); - const uri = try std.fmt.allocPrint(allocator, "ats://{s}/{s}/{s}", .{ input.authority_did, input.space_type, input.skey }); + const uri = try space_uri.SpaceUri.format(allocator, input.authority_did, input.space_type, input.skey); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); @@ -3156,96 +3158,45 @@ pub fn listSpaces( try requireInitialized(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); + var query: std.Io.Writer.Allocating = .init(allocator); + defer query.deinit(); + try query.writer.writeAll( + \\SELECT s.uri, a.is_authority + \\FROM permissioned_spaces s + \\JOIN permissioned_space_actor_state a ON a.space = s.uri + \\WHERE a.actor_did = ? AND a.deleted_at IS NULL AND s.deleted_at IS NULL + ); + if (maybe_did != null) try query.writer.writeAll(" AND s.authority_did = ?"); + if (maybe_type != null) try query.writer.writeAll(" AND s.space_type = ?"); + if (maybe_cursor != null) try query.writer.writeAll(" AND s.uri > ?"); + try query.writer.writeAll(" ORDER BY s.uri ASC LIMIT ?"); + var rows = if (maybe_did) |did| if (maybe_type) |space_type| if (maybe_cursor) |cursor| - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.authority_did = ? AND s.space_type = ? AND s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, did, space_type, cursor, capped_limit }) + try conn.rows(query.written(), .{ actor_did, did, space_type, cursor, capped_limit }) else - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.authority_did = ? AND s.space_type = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, did, space_type, capped_limit }) + try conn.rows(query.written(), .{ actor_did, did, space_type, capped_limit }) else if (maybe_cursor) |cursor| - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.authority_did = ? AND s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, did, cursor, capped_limit }) + try conn.rows(query.written(), .{ actor_did, did, cursor, capped_limit }) else - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.authority_did = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, did, capped_limit }) + try conn.rows(query.written(), .{ actor_did, did, capped_limit }) else if (maybe_type) |space_type| if (maybe_cursor) |cursor| - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.space_type = ? AND s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, space_type, cursor, capped_limit }) + try conn.rows(query.written(), .{ actor_did, space_type, cursor, capped_limit }) else - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.space_type = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, space_type, capped_limit }) + try conn.rows(query.written(), .{ actor_did, space_type, capped_limit }) else if (maybe_cursor) |cursor| - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, cursor, capped_limit }) + try conn.rows(query.written(), .{ actor_did, cursor, capped_limit }) else - try conn.rows( - \\SELECT s.uri - \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - \\ORDER BY s.uri ASC - \\LIMIT ? - , .{ actor_did, actor_did, capped_limit }); + try conn.rows(query.written(), .{ actor_did, capped_limit }); defer rows.deinit(); var spaces: std.ArrayList(SpaceConfig) = .empty; while (rows.next()) |row| { - if (try getSpaceLocked(allocator, actor_did, row.text(0))) |space| { - try spaces.append(allocator, space); - } + var space = (try getSpaceConfigLocked(allocator, row.text(0))) orelse continue; + space.is_authority = row.int(1) != 0; + try spaces.append(allocator, space); } if (rows.err) |err| return err; return spaces.toOwnedSlice(allocator); @@ -3352,41 +3303,64 @@ pub fn simpleSpaceHasMember(space: []const u8, did: []const u8) !bool { return true; } -pub fn listSpaceRepos(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]SpaceRepoState { +pub fn listSpaceWriters(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]SpaceWriterState { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); - _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; + const authority = try conn.row( + \\SELECT 1 + \\FROM permissioned_spaces s + \\JOIN permissioned_space_actor_state a + \\ ON a.space = s.uri AND a.actor_did = s.authority_did + \\WHERE s.uri = ? AND s.deleted_at IS NULL AND a.deleted_at IS NULL AND a.is_authority = 1 + , .{space}); + if (authority == null) return Error.RepoNotFound; + authority.?.deinit(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); var rows = if (maybe_cursor) |cursor| try conn.rows( - \\SELECT repo_did, set_hash, rev - \\FROM permissioned_space_repos + \\SELECT repo_did, hash, rev + \\FROM permissioned_space_writers \\WHERE space = ? AND repo_did > ? \\ORDER BY repo_did ASC \\LIMIT ? , .{ space, cursor, capped_limit }) else try conn.rows( - \\SELECT repo_did, set_hash, rev - \\FROM permissioned_space_repos + \\SELECT repo_did, hash, rev + \\FROM permissioned_space_writers \\WHERE space = ? \\ORDER BY repo_did ASC \\LIMIT ? , .{ space, capped_limit }); defer rows.deinit(); - var repos: std.ArrayList(SpaceRepoState) = .empty; + var repos: std.ArrayList(SpaceWriterState) = .empty; while (rows.next()) |row| { try repos.append(allocator, .{ .repo_did = try allocator.dupe(u8, row.text(0)), - .set_hash = if (row.nullableBlob(1)) |bytes| try allocator.dupe(u8, bytes) else null, - .rev = if (row.nullableText(2)) |rev| try allocator.dupe(u8, rev) else null, + .hash = try allocator.dupe(u8, row.blob(1)), + .rev = try allocator.dupe(u8, row.text(2)), }); } if (rows.err) |err| return err; return repos.toOwnedSlice(allocator); } +pub fn recordSpaceWriter(space: []const u8, repo_did: []const u8, rev: []const u8, hash: []const u8) !void { + if (hash.len != permissioned.commit_hash_bytes) return Error.InvalidRecordType; + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + try conn.exec( + \\INSERT INTO permissioned_space_writers (space, repo_did, rev, hash) + \\VALUES (?, ?, ?, ?) + \\ON CONFLICT(space, repo_did) DO UPDATE SET + \\ rev = excluded.rev, + \\ hash = excluded.hash, + \\ updated_at = unixepoch() + , .{ space, repo_did, rev, zqlite.blob(hash) }); +} + pub fn markSpaceDeleted(actor_did: []const u8, space: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); @@ -3401,46 +3375,141 @@ pub fn purgeAuthoritySpaceData(space: []const u8) !void { try requireInitialized(); try conn.exclusiveTransaction(); errdefer conn.rollback(); - try conn.exec("DELETE FROM permissioned_space_credential_recipients WHERE space = ?", .{space}); + try conn.exec("DELETE FROM permissioned_space_notify_registrations WHERE space = ?", .{space}); try conn.commit(); } -pub fn recordCredentialRecipient(space: []const u8, service_did: []const u8, service_endpoint: []const u8) !void { +pub fn registerSpaceNotification(space: []const u8, repo_did: ?[]const u8, service_endpoint: []const u8, expires_at: i64) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( - \\INSERT INTO permissioned_space_credential_recipients (space, service_did, service_endpoint, last_issued_at) - \\VALUES (?, ?, ?, unixepoch()) - \\ON CONFLICT(space, service_did) DO UPDATE SET - \\ service_endpoint = excluded.service_endpoint, - \\ last_issued_at = excluded.last_issued_at - , .{ space, service_did, service_endpoint }); + \\INSERT INTO permissioned_space_notify_registrations (space, repo_did, service_endpoint, expires_at) + \\VALUES (?, ?, ?, ?) + \\ON CONFLICT(space, repo_did, service_endpoint) DO UPDATE SET + \\ expires_at = excluded.expires_at + , .{ space, repo_did orelse "", service_endpoint, expires_at }); } -pub fn listCredentialRecipients(allocator: std.mem.Allocator, space: []const u8) ![]CredentialRecipient { +pub fn listNotificationRecipients(allocator: std.mem.Allocator, space: []const u8, repo_did: ?[]const u8, include_space_wide: bool) ![]CredentialRecipient { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); - var rows = try conn.rows( - \\SELECT service_did, service_endpoint, last_issued_at - \\FROM permissioned_space_credential_recipients - \\WHERE space = ? - \\ORDER BY service_did ASC - , .{space}); + try conn.execNoArgs("DELETE FROM permissioned_space_notify_registrations WHERE expires_at <= unixepoch()"); + var rows = if (repo_did) |repo| + if (include_space_wide) + try conn.rows( + \\SELECT repo_did, service_endpoint, expires_at + \\FROM permissioned_space_notify_registrations + \\WHERE space = ? AND (repo_did = '' OR repo_did = ?) + \\ORDER BY repo_did ASC, service_endpoint ASC + , .{ space, repo }) + else + try conn.rows( + \\SELECT repo_did, service_endpoint, expires_at + \\FROM permissioned_space_notify_registrations + \\WHERE space = ? AND repo_did = ? + \\ORDER BY service_endpoint ASC + , .{ space, repo }) + else if (include_space_wide) + try conn.rows( + \\SELECT '', service_endpoint, max(expires_at) + \\FROM permissioned_space_notify_registrations + \\WHERE space = ? + \\GROUP BY service_endpoint + \\ORDER BY service_endpoint ASC + , .{space}) + else + try conn.rows( + \\SELECT repo_did, service_endpoint, expires_at + \\FROM permissioned_space_notify_registrations + \\WHERE space = ? AND repo_did = '' + \\ORDER BY service_endpoint ASC + , .{space}); defer rows.deinit(); var out: std.ArrayList(CredentialRecipient) = .empty; while (rows.next()) |row| { try out.append(allocator, .{ - .service_did = try allocator.dupe(u8, row.text(0)), .service_endpoint = try allocator.dupe(u8, row.text(1)), - .last_issued_at = row.int(2), + .repo_did = if (row.text(0).len == 0) null else try allocator.dupe(u8, row.text(0)), + .expires_at = row.int(2), }); } if (rows.err) |err| return err; return out.toOwnedSlice(allocator); } +pub fn loadSpaceRepoBlocks(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) ![]permissioned.RepoRecordBlock { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + var rows = try conn.rows( + \\SELECT collection, rkey, cid, value_json + \\FROM permissioned_space_records + \\WHERE space = ? AND repo_did = ? + \\ORDER BY collection ASC, rkey ASC + , .{ space, repo_did }); + defer rows.deinit(); + var blocks: std.ArrayList(permissioned.RepoRecordBlock) = .empty; + var scratch = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer scratch.deinit(); + while (rows.next()) |row| { + _ = scratch.reset(.retain_capacity); + const scratch_allocator = scratch.allocator(); + const parsed = try std.json.parseFromSlice(std.json.Value, scratch_allocator, row.blob(3), .{}); + const value = try jsonToDagCbor(scratch_allocator, parsed.value); + const data = try zat.cbor.encodeAlloc(allocator, value); + const cid_text = row.text(2); + if (cid_text.len == 0 or cid_text[0] != 'b') return Error.InvalidDagCbor; + try blocks.append(allocator, .{ + .path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ row.text(0), row.text(1) }), + .cid = .{ .raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]) }, + .data = data, + }); + } + if (rows.err) |err| return err; + return blocks.toOwnedSlice(allocator); +} + +pub const SpaceReplayToken = struct { + jti: []const u8, + expires_at: i64, +}; + +pub fn consumeSpaceCredentialExchange(delegation: SpaceReplayToken, attestation: ?SpaceReplayToken) !bool { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + try conn.exclusiveTransaction(); + errdefer conn.rollback(); + try conn.execNoArgs("DELETE FROM permissioned_space_used_delegations WHERE expires_at < unixepoch()"); + try conn.execNoArgs("DELETE FROM permissioned_space_used_client_attestations WHERE expires_at < unixepoch()"); + conn.exec( + \\INSERT INTO permissioned_space_used_delegations (jti, expires_at) + \\VALUES (?, ?) + , .{ delegation.jti, delegation.expires_at }) catch |err| switch (err) { + error.Constraint => { + conn.rollback(); + return false; + }, + else => return err, + }; + if (attestation) |token| { + conn.exec( + \\INSERT INTO permissioned_space_used_client_attestations (jti, expires_at) + \\VALUES (?, ?) + , .{ token.jti, token.expires_at }) catch |err| switch (err) { + error.Constraint => { + conn.rollback(); + return false; + }, + else => return err, + }; + } + try conn.commit(); + return true; +} + pub fn putSpaceRecord( allocator: std.mem.Allocator, space: []const u8, @@ -3583,7 +3652,7 @@ pub fn applySpaceWrites( db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); - _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; + const space_config = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; try conn.exclusiveTransaction(); errdefer conn.rollback(); @@ -3645,6 +3714,22 @@ pub fn applySpaceWrites( \\ set_hash = excluded.set_hash, \\ rev = excluded.rev , .{ space, repo_did, zqlite.blob(&set_hash.bytes), rev }); + try conn.exec( + \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_authority) + \\VALUES (?, ?, 0) + \\ON CONFLICT(space, actor_did) DO NOTHING + , .{ space, repo_did }); + if (space_config.is_authority) { + const digest = set_hash.digest(); + try conn.exec( + \\INSERT INTO permissioned_space_writers (space, repo_did, rev, hash) + \\VALUES (?, ?, ?, ?) + \\ON CONFLICT(space, repo_did) DO UPDATE SET + \\ rev = excluded.rev, + \\ hash = excluded.hash, + \\ updated_at = unixepoch() + , .{ space, repo_did, rev, zqlite.blob(&digest) }); + } try conn.commit(); return results.toOwnedSlice(allocator); } @@ -3656,27 +3741,35 @@ pub fn getSpaceRepoState(allocator: std.mem.Allocator, space: []const u8, repo_d return getRepoStateLocked(allocator, space, repo_did); } -pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, limit: usize) ![]SpaceRecordOplogEntry { +pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, limit: usize, include_values: bool) ![]SpaceRecordOplogEntry { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); var rows = if (since) |rev| try conn.rows( - \\SELECT rev, idx, action, repo_did, collection, rkey, cid, prev - \\FROM permissioned_space_record_oplog - \\WHERE space = ? AND repo_did = ? AND rev > ? - \\ORDER BY rev ASC, idx ASC + \\SELECT o.rev, o.idx, o.action, o.repo_did, o.collection, o.rkey, o.cid, o.prev, + \\ CASE WHEN ? AND r.cid = o.cid THEN r.value_json END + \\FROM permissioned_space_record_oplog o + \\LEFT JOIN permissioned_space_records r + \\ ON r.space = o.space AND r.repo_did = o.repo_did + \\ AND r.collection = o.collection AND r.rkey = o.rkey + \\WHERE o.space = ? AND o.repo_did = ? AND o.rev > ? + \\ORDER BY o.rev ASC, o.idx ASC \\LIMIT ? - , .{ space, repo_did, rev, capped_limit }) + , .{ include_values, space, repo_did, rev, capped_limit }) else try conn.rows( - \\SELECT rev, idx, action, repo_did, collection, rkey, cid, prev - \\FROM permissioned_space_record_oplog - \\WHERE space = ? AND repo_did = ? - \\ORDER BY rev ASC, idx ASC + \\SELECT o.rev, o.idx, o.action, o.repo_did, o.collection, o.rkey, o.cid, o.prev, + \\ CASE WHEN ? AND r.cid = o.cid THEN r.value_json END + \\FROM permissioned_space_record_oplog o + \\LEFT JOIN permissioned_space_records r + \\ ON r.space = o.space AND r.repo_did = o.repo_did + \\ AND r.collection = o.collection AND r.rkey = o.rkey + \\WHERE o.space = ? AND o.repo_did = ? + \\ORDER BY o.rev ASC, o.idx ASC \\LIMIT ? - , .{ space, repo_did, capped_limit }); + , .{ include_values, space, repo_did, capped_limit }); defer rows.deinit(); var out: std.ArrayList(SpaceRecordOplogEntry) = .empty; while (rows.next()) |row| { @@ -3689,6 +3782,7 @@ pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, rep .rkey = try allocator.dupe(u8, row.text(5)), .cid = if (row.nullableText(6)) |cid| try allocator.dupe(u8, cid) else null, .prev = if (row.nullableText(7)) |prev| try allocator.dupe(u8, prev) else null, + .value_json = if (row.nullableBlob(8)) |value| try allocator.dupe(u8, value) else null, }); } if (rows.err) |err| return err; @@ -3788,9 +3882,14 @@ fn insertRecordOplogLocked( fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceConfig { const row = try conn.row( - \\SELECT uri, authority_did, space_type, skey, managing_app, policy, app_access_json - \\FROM permissioned_spaces - \\WHERE uri = ? + \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.managing_app, s.policy, s.app_access_json, + \\ EXISTS ( + \\ SELECT 1 FROM permissioned_space_actor_state a + \\ WHERE a.space = s.uri AND a.actor_did = s.authority_did + \\ AND a.is_authority = 1 AND a.deleted_at IS NULL + \\ ) + \\FROM permissioned_spaces s + \\WHERE s.uri = ? AND s.deleted_at IS NULL , .{uri}); if (row == null) return null; defer row.?.deinit(); @@ -3803,7 +3902,7 @@ fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceCo .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, .policy = try allocator.dupe(u8, row.?.text(5)), .app_access_json = try allocator.dupe(u8, row.?.text(6)), - .is_authority = false, + .is_authority = row.?.int(7) != 0, .deleted_at = null, }; } @@ -3811,12 +3910,11 @@ fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceCo fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { const row = try conn.row( \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.managing_app, s.policy, s.app_access_json, - \\ a.is_authority, COALESCE(a.deleted_at, s.deleted_at) + \\ a.is_authority, a.deleted_at \\FROM permissioned_spaces s - \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? - \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? - \\WHERE s.uri = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') - , .{ actor_did, actor_did, uri }); + \\JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? + \\WHERE s.uri = ? AND s.deleted_at IS NULL AND a.deleted_at IS NULL + , .{ actor_did, uri }); if (row == null) return null; defer row.?.deinit(); @@ -3828,7 +3926,7 @@ fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []co .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, .policy = try allocator.dupe(u8, row.?.text(5)), .app_access_json = try allocator.dupe(u8, row.?.text(6)), - .is_authority = (row.?.nullableInt(7) orelse 0) != 0, + .is_authority = row.?.int(7) != 0, .deleted_at = if (row.?.nullableInt(8)) |value| value else null, }; } @@ -3866,28 +3964,6 @@ fn parseSpaceRecordCursor(cursor: []const u8) struct { collection: []const u8, r return .{ .collection = cursor[0..slash], .rkey = cursor[slash + 1 ..] }; } -const SpaceParts = struct { - authority_did: []const u8, - space_type: []const u8, - skey: []const u8, -}; - -fn parseSpaceParts(uri: []const u8) ?SpaceParts { - const prefix = "ats://"; - if (!std.mem.startsWith(u8, uri, prefix)) return null; - const rest = uri[prefix.len..]; - const first = std.mem.indexOfScalar(u8, rest, '/') orelse return null; - const authority_did = rest[0..first]; - if (zat.Did.parse(authority_did) == null) return null; - const after_did = rest[first + 1 ..]; - const second = std.mem.indexOfScalar(u8, after_did, '/') orelse return null; - const space_type = after_did[0..second]; - if (zat.Nsid.parse(space_type) == null) return null; - const skey = after_did[second + 1 ..]; - if (zat.Rkey.parse(skey) == null) return null; - return .{ .authority_did = authority_did, .space_type = space_type, .skey = skey }; -} - fn validSimpleSpacePolicy(policy: []const u8) bool { return std.mem.eql(u8, policy, "member-list") or std.mem.eql(u8, policy, "public") or @@ -3965,6 +4041,158 @@ fn migratePermissionedDataTables() !void { try migratePermissionedSpaceAuthorityFlag(); try migrateSimpleSpaceConfig(); try migratePermissionedRecordOplogRepo(); + try migratePermissionedSpaceUris(); + try migratePermissionedSpaceRepoHashes(); + try migratePermissionedSpaceWriters(); + try migratePermissionedSpaceNotifyRegistrations(); +} + +fn migratePermissionedSpaceNotifyRegistrations() !void { + const name = "permissioned-space-notify-registration-v1"; + if (try migrationApplied(name)) return; + // The prior table was never populated through a protocol endpoint and + // cannot represent repo-scoped or expiring registrations. + try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_credential_recipients"); + try conn.execNoArgs( + \\CREATE TABLE IF NOT EXISTS permissioned_space_notify_registrations ( + \\ space TEXT NOT NULL, + \\ repo_did TEXT NOT NULL DEFAULT '', + \\ service_endpoint TEXT NOT NULL, + \\ expires_at INTEGER NOT NULL, + \\ PRIMARY KEY (space, repo_did, service_endpoint) + \\) + ); + try markMigrationApplied(name); +} + +fn migratePermissionedSpaceUris() !void { + const name = "permissioned-space-at-uri"; + if (try migrationApplied(name)) return; + + try conn.execNoArgs("PRAGMA foreign_keys = OFF"); + errdefer conn.execNoArgs("PRAGMA foreign_keys = ON") catch {}; + try conn.exclusiveTransaction(); + errdefer conn.rollback(); + + const tables = [_][]const u8{ + "permissioned_space_actor_state", + "simplespace_members", + "permissioned_space_records", + "permissioned_space_record_blobs", + "permissioned_space_repos", + "permissioned_space_writers", + "permissioned_space_record_oplog", + "permissioned_space_notify_registrations", + }; + inline for (tables) |table| { + try conn.execNoArgs( + "UPDATE " ++ table ++ " SET space = (" ++ + "SELECT 'at://' || authority_did || '/space/' || space_type || '/' || skey " ++ + "FROM permissioned_spaces WHERE uri = " ++ table ++ ".space" ++ + ") WHERE space LIKE 'ats://%'", + ); + } + try conn.execNoArgs( + \\UPDATE permissioned_spaces + \\SET uri = 'at://' || authority_did || '/space/' || space_type || '/' || skey + \\WHERE uri LIKE 'ats://%' + ); + try markMigrationApplied(name); + try conn.commit(); + try conn.execNoArgs("PRAGMA foreign_keys = ON"); +} + +fn migratePermissionedSpaceRepoHashes() !void { + const name = "permissioned-space-record-element-v1"; + if (try migrationApplied(name)) return; + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + const RepoIdentity = struct { space: []const u8, did: []const u8, has_rev: bool }; + var identities: std.ArrayList(RepoIdentity) = .empty; + var repos = try conn.rows("SELECT space, repo_did, rev FROM permissioned_space_repos", .{}); + while (repos.next()) |repo| { + try identities.append(allocator, .{ + .space = try allocator.dupe(u8, repo.text(0)), + .did = try allocator.dupe(u8, repo.text(1)), + .has_rev = repo.nullableText(2) != null, + }); + } + if (repos.err) |err| return err; + repos.deinit(); + + for (identities.items) |identity| { + var hash: permissioned.LtHash = .{}; + var record_count: usize = 0; + var records = try conn.rows( + \\SELECT collection, rkey, cid + \\FROM permissioned_space_records + \\WHERE space = ? AND repo_did = ? + , .{ identity.space, identity.did }); + defer records.deinit(); + while (records.next()) |record| { + try addRecordElement(allocator, &hash, record.text(0), record.text(1), record.text(2)); + record_count += 1; + } + if (records.err) |err| return err; + if (!identity.has_rev and record_count == 0) continue; + try conn.exec( + \\UPDATE permissioned_space_repos + \\SET set_hash = ? + \\WHERE space = ? AND repo_did = ? + , .{ zqlite.blob(&hash.bytes), identity.space, identity.did }); + } + try markMigrationApplied(name); +} + +fn migratePermissionedSpaceWriters() !void { + const name = "permissioned-space-writer-set-v1"; + if (try migrationApplied(name)) return; + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + const ExistingRepo = struct { + space: []const u8, + did: []const u8, + rev: []const u8, + state: []const u8, + }; + var existing: std.ArrayList(ExistingRepo) = .empty; + var rows = try conn.rows( + \\SELECT r.space, r.repo_did, r.rev, r.set_hash + \\FROM permissioned_space_repos r + \\JOIN permissioned_spaces s ON s.uri = r.space + \\JOIN permissioned_space_actor_state a + \\ ON a.space = s.uri AND a.actor_did = s.authority_did + \\WHERE r.rev IS NOT NULL AND r.set_hash IS NOT NULL + \\ AND s.deleted_at IS NULL AND a.deleted_at IS NULL AND a.is_authority = 1 + , .{}); + while (rows.next()) |row| { + try existing.append(allocator, .{ + .space = try allocator.dupe(u8, row.text(0)), + .did = try allocator.dupe(u8, row.text(1)), + .rev = try allocator.dupe(u8, row.text(2)), + .state = try allocator.dupe(u8, row.blob(3)), + }); + } + if (rows.err) |err| return err; + rows.deinit(); + + for (existing.items) |repo| { + const state = try permissioned.LtHash.fromBytes(repo.state); + const digest = state.digest(); + try conn.exec( + \\INSERT INTO permissioned_space_writers (space, repo_did, rev, hash) + \\VALUES (?, ?, ?, ?) + \\ON CONFLICT(space, repo_did) DO UPDATE SET + \\ rev = excluded.rev, + \\ hash = excluded.hash, + \\ updated_at = unixepoch() + , .{ repo.space, repo.did, repo.rev, zqlite.blob(&digest) }); + } + try markMigrationApplied(name); } fn migrateSimpleSpaceConfig() !void { @@ -5867,6 +6095,15 @@ const schema_statements = [_][*:0]const u8{ \\ PRIMARY KEY (space, repo_did) \\) , + \\CREATE TABLE IF NOT EXISTS permissioned_space_writers ( + \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, + \\ repo_did TEXT NOT NULL, + \\ rev TEXT NOT NULL, + \\ hash BLOB NOT NULL, + \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + \\ PRIMARY KEY (space, repo_did) + \\) + , \\CREATE TABLE IF NOT EXISTS permissioned_space_record_oplog ( \\ space TEXT NOT NULL, \\ repo_did TEXT NOT NULL, @@ -5880,12 +6117,12 @@ const schema_statements = [_][*:0]const u8{ \\ PRIMARY KEY (space, repo_did, rev, idx) \\) , - \\CREATE TABLE IF NOT EXISTS permissioned_space_credential_recipients ( + \\CREATE TABLE IF NOT EXISTS permissioned_space_notify_registrations ( \\ space TEXT NOT NULL, - \\ service_did TEXT NOT NULL, + \\ repo_did TEXT NOT NULL DEFAULT '', \\ service_endpoint TEXT NOT NULL, - \\ last_issued_at INTEGER NOT NULL, - \\ PRIMARY KEY (space, service_did) + \\ expires_at INTEGER NOT NULL, + \\ PRIMARY KEY (space, repo_did, service_endpoint) \\) , }; @@ -5906,6 +6143,18 @@ const post_schema_statements = [_][*:0]const u8{ \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , + \\CREATE TABLE IF NOT EXISTS permissioned_space_used_delegations ( + \\ jti TEXT PRIMARY KEY, + \\ expires_at INTEGER NOT NULL, + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) + \\) + , + \\CREATE TABLE IF NOT EXISTS permissioned_space_used_client_attestations ( + \\ jti TEXT PRIMARY KEY, + \\ expires_at INTEGER NOT NULL, + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) + \\) + , \\CREATE TABLE IF NOT EXISTS repo_blocks ( \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ cid TEXT NOT NULL, @@ -5933,11 +6182,12 @@ test "persists records in sqlite" { "did:plc:cmadossymmii3izkabdbp5en", true, ); - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"hello\"}", .{}); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"hello\"}", .{}); defer parsed.deinit(); - const record = try create(allocator, account, "app.bsky.feed.post", "3ztest", parsed.value); - try std.testing.expectEqualStrings("3ztest", record.rkey); + const rkey = "3jzfcijpj2z2a"; + const record = try create(allocator, account, "app.bsky.feed.post", rkey, parsed.value); + try std.testing.expectEqualStrings(rkey, record.rkey); try std.testing.expect(!try recordsTableHasValueJsonColumn()); const block_row = try conn.row( @@ -5948,7 +6198,7 @@ test "persists records in sqlite" { defer block_row.?.deinit(); try std.testing.expectEqual(@as(i64, 1), block_row.?.int(0)); - const fetched = get(account.did, "app.bsky.feed.post", "3ztest").?; + const fetched = get(account.did, "app.bsky.feed.post", rkey).?; try std.testing.expectEqualStrings(record.cid, fetched.cid); try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "hello") != null); @@ -5962,14 +6212,14 @@ test "persists records in sqlite" { try std.testing.expectEqualStrings((try latestRootLocked(allocator, account.did)).cid, try cidText(allocator, loaded.commit_cid)); const tree = try zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid); - const found = tree.get("app.bsky.feed.post/3ztest") orelse return error.MissingRecord; + const found = tree.get("app.bsky.feed.post/3jzfcijpj2z2a") orelse return error.MissingRecord; try std.testing.expectEqualStrings(record.cid, try cidText(allocator, found.raw)); try conn.exec( "DELETE FROM repo_blocks WHERE did = ? AND cid = ?", .{ account.did, record.cid }, ); - try std.testing.expect(get(account.did, "app.bsky.feed.post", "3ztest") == null); + try std.testing.expect(get(account.did, "app.bsky.feed.post", rkey) == null); } test "repo writes enforce swap and explicit validation preconditions" { @@ -5991,9 +6241,10 @@ test "repo writes enforce swap and explicit validation preconditions" { const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"before\"}", .{}); defer first.deinit(); + const rkey = "3jzfcijpj2z2b"; const created_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", - .rkey = "3zswap", + .rkey = rkey, .value = first.value, } }}, .{}); const first_cid = created_result.records[0].cid; @@ -6002,19 +6253,19 @@ test "repo writes enforce swap and explicit validation preconditions" { defer second.deinit(); try std.testing.expectError(Error.InvalidSwap, applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", - .rkey = "3zswap", + .rkey = rkey, .value = second.value, } }}, .{ .swap_commit = "bafkreiwrongcommit" })); try std.testing.expectError(Error.InvalidSwap, applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", - .rkey = "3zswap", + .rkey = rkey, .value = second.value, .swap = .{ .cid = "bafkreiwrongrecord" }, } }}, .{})); const updated_result = try applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", - .rkey = "3zswap", + .rkey = rkey, .value = second.value, .swap = .{ .cid = first_cid }, } }}, .{ .swap_commit = created_result.commit.cid }); @@ -6055,6 +6306,7 @@ test "repo writes lazily load existing MST blocks for mutation" { var create_ops: [160]WriteOp = undefined; var create_parsed: [160]std.json.Parsed(std.json.Value) = undefined; + var create_rkeys: [160][atid.encoded_len]u8 = undefined; for (&create_ops, &create_parsed, 0..) |*op, *parsed, i| { parsed.* = try std.json.parseFromSlice( std.json.Value, @@ -6062,9 +6314,10 @@ test "repo writes lazily load existing MST blocks for mutation" { try std.fmt.allocPrint(allocator, "{{\"$type\":\"app.bsky.feed.post\",\"text\":\"record {d}\"}}", .{i}), .{}, ); + create_rkeys[i] = try atid.encode(1_700_000_000_000_000 + i, 0); op.* = .{ .create = .{ .collection = "app.bsky.feed.post", - .rkey = try std.fmt.allocPrint(allocator, "3zlazy{d:0>4}", .{i}), + .rkey = &create_rkeys[i], .value = parsed.value, } }; } @@ -6074,26 +6327,32 @@ test "repo writes lazily load existing MST blocks for mutation" { const update = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"updated\"}", .{}); defer update.deinit(); + const update_rkey = create_rkeys[77]; const update_result = try applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", - .rkey = "3zlazy0077", + .rkey = &update_rkey, .value = update.value, } }}, .{}); + const delete_rkey = create_rkeys[12]; _ = try applyWritesWithOptions(allocator, account, &.{.{ .delete = .{ .collection = "app.bsky.feed.post", - .rkey = "3zlazy0012", + .rkey = &delete_rkey, } }}, .{}); const repo_car = try writeRepoCar(allocator, account.did); const loaded = try zat.loadCommitFromCAR(allocator, repo_car); var tree = try zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid); - const updated = tree.get("app.bsky.feed.post/3zlazy0077") orelse return error.MissingRecord; + const updated_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{update_rkey}); + const updated = tree.get(updated_path) orelse return error.MissingRecord; try std.testing.expectEqualStrings(update_result.records[0].cid, try cidText(allocator, updated.raw)); - try std.testing.expect(tree.get("app.bsky.feed.post/3zlazy0012") == null); - const retained = tree.get("app.bsky.feed.post/3zlazy0159") orelse return error.MissingRecord; - try std.testing.expectEqualStrings((get(account.did, "app.bsky.feed.post", "3zlazy0159") orelse return error.MissingRecord).cid, try cidText(allocator, retained.raw)); + const deleted_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{delete_rkey}); + try std.testing.expect(tree.get(deleted_path) == null); + const retained_rkey = create_rkeys[159]; + const retained_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{retained_rkey}); + const retained = tree.get(retained_path) orelse return error.MissingRecord; + try std.testing.expectEqualStrings((get(account.did, "app.bsky.feed.post", &retained_rkey) orelse return error.MissingRecord).cid, try cidText(allocator, retained.raw)); } test "commit firehose since uses previous repo rev" { @@ -6115,17 +6374,19 @@ test "commit firehose since uses previous repo rev" { const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"first\"}", .{}); defer first.deinit(); + const first_rkey = "3jzfcijpj2z2c"; const first_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", - .rkey = "3zsinceone", + .rkey = first_rkey, .value = first.value, } }}, .{}); const second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"second\"}", .{}); defer second.deinit(); + const second_rkey = "3jzfcijpj2z2d"; const second_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", - .rkey = "3zsincetwo", + .rkey = second_rkey, .value = second.value, } }}, .{}); @@ -6170,17 +6431,19 @@ test "getRepo since filters repo blocks by revision" { const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"first\"}", .{}); defer first.deinit(); + const first_rkey = "3jzfcijpj2z2e"; const first_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", - .rkey = "3zsincea", + .rkey = first_rkey, .value = first.value, } }}, .{}); const second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"second\"}", .{}); defer second.deinit(); + const second_rkey = "3jzfcijpj2z2f"; const second_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", - .rkey = "3zsinceb", + .rkey = second_rkey, .value = second.value, } }}, .{}); @@ -6220,9 +6483,10 @@ test "full getRepo exports only current reachable record blocks" { const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"first\"}", .{}); defer first.deinit(); + const rkey = "3jzfcijpj2z2g"; const first_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", - .rkey = "3zreach", + .rkey = rkey, .value = first.value, } }}, .{}); const first_cid = first_result.records[0].cid; @@ -6231,7 +6495,7 @@ test "full getRepo exports only current reachable record blocks" { defer second.deinit(); const second_result = try applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", - .rkey = "3zreach", + .rkey = rkey, .value = second.value, } }}, .{}); const second_cid = second_result.records[0].cid; @@ -6242,7 +6506,7 @@ test "full getRepo exports only current reachable record blocks" { _ = try applyWritesWithOptions(allocator, account, &.{.{ .delete = .{ .collection = "app.bsky.feed.post", - .rkey = "3zreach", + .rkey = rkey, } }}, .{}); const deleted_car = try zat.car.read(allocator, try writeRepoCar(allocator, account.did)); @@ -6277,7 +6541,7 @@ test "permissioned spaces store self-owned records outside public repo" { .policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); - try std.testing.expectEqualStrings("ats://did:plc:spaceauthorityalice/fm.plyr.privateMedia/self", space.uri); + try std.testing.expectEqualStrings("at://did:plc:spaceauthorityalice/space/fm.plyr.privateMedia/self", space.uri); try std.testing.expect(space.is_authority); try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ @@ -6338,6 +6602,120 @@ test "permissioned spaces store self-owned records outside public repo" { try expectSpaceBlobRefCount(space.uri, account.did, "fm.plyr.track", "track-one", "bafkreibm6jgipb22ignhvbmf6avdkvuevprpyx6y6722fsxk7wxiofq4wu", 0); } +test "permissioned space foundation migration preserves rows and rebuilds hashes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + try init(std.Options.debug_io, ":memory:"); + defer close(); + const did = "did:plc:spacemigrationalice"; + const canonical = "at://did:plc:spacemigrationalice/space/fm.example.private/self"; + const legacy = "ats://did:plc:spacemigrationalice/fm.example.private/self"; + const space = try createSpace(allocator, .{ + .actor_did = did, + .authority_did = did, + .space_type = "fm.example.private", + .skey = "self", + .is_authority = true, + .managing_app = null, + .policy = "member-list", + .app_access_json = "{\"type\":\"open\"}", + }); + try std.testing.expectEqualStrings(canonical, space.uri); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"fm.example.note\",\"text\":\"private\"}", .{}); + defer parsed.deinit(); + const prepared = try prepareRecordValue(allocator, "fm.example.note", "one", parsed.value); + const record = try putSpaceRecord(allocator, canonical, did, "fm.example.note", "one", prepared); + try conn.exec( + \\INSERT INTO permissioned_space_record_blobs (space, repo_did, collection, rkey, blob_cid) + \\VALUES (?, ?, ?, ?, ?) + , .{ canonical, did, "fm.example.note", "one", "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }); + try conn.exec( + \\INSERT INTO permissioned_space_notify_registrations (space, repo_did, service_endpoint, expires_at) + \\VALUES (?, '', ?, 4102444800) + , .{ canonical, "https://sync.example" }); + + try conn.execNoArgs("PRAGMA foreign_keys = OFF"); + inline for (.{ + "permissioned_space_actor_state", + "simplespace_members", + "permissioned_space_records", + "permissioned_space_record_blobs", + "permissioned_space_repos", + "permissioned_space_writers", + "permissioned_space_record_oplog", + "permissioned_space_notify_registrations", + }) |table| { + try conn.exec("UPDATE " ++ table ++ " SET space = ? WHERE space = ?", .{ legacy, canonical }); + } + try conn.exec("UPDATE permissioned_spaces SET uri = ? WHERE uri = ?", .{ legacy, canonical }); + try conn.exec("UPDATE permissioned_space_repos SET set_hash = zeroblob(?)", .{permissioned.lthash_state_bytes}); + try conn.exec("DELETE FROM zds_migrations WHERE name IN (?, ?, ?)", .{ "permissioned-space-at-uri", "permissioned-space-record-element-v1", "permissioned-space-writer-set-v1" }); + try conn.execNoArgs("PRAGMA foreign_keys = ON"); + + try migratePermissionedDataTables(); + inline for (.{ + "permissioned_spaces", + "permissioned_space_actor_state", + "simplespace_members", + "permissioned_space_records", + "permissioned_space_record_blobs", + "permissioned_space_repos", + "permissioned_space_writers", + "permissioned_space_record_oplog", + "permissioned_space_notify_registrations", + }) |table| { + const column = comptime if (std.mem.eql(u8, table, "permissioned_spaces")) "uri" else "space"; + const row = (try conn.row("SELECT count(*) FROM " ++ table ++ " WHERE " ++ column ++ " = ?", .{canonical})).?; + defer row.deinit(); + try std.testing.expect(row.int(0) > 0); + } + const state = try getSpaceRepoState(allocator, canonical, did); + var expected: permissioned.LtHash = .{}; + try addRecordElement(allocator, &expected, record.collection, record.rkey, record.cid); + try std.testing.expectEqualSlices(u8, &expected.bytes, state.set_hash.?); + const writers = try listSpaceWriters(allocator, canonical, null, 50); + try std.testing.expectEqual(@as(usize, 1), writers.len); + try std.testing.expectEqualStrings(did, writers[0].repo_did); + try std.testing.expectEqualSlices(u8, &expected.digest(), writers[0].hash); +} + +test "permissioned notification registrations are scoped and expire" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + try init(std.Options.debug_io, ":memory:"); + defer close(); + + const space = "at://did:plc:authority/space/fm.example.private/self"; + try registerSpaceNotification(space, null, "https://whole.example", 4102444800); + try registerSpaceNotification(space, "did:plc:writer", "https://repo.example", 4102444800); + try registerSpaceNotification(space, null, "https://expired.example", 1); + + const whole = try listNotificationRecipients(allocator, space, null, false); + try std.testing.expectEqual(@as(usize, 1), whole.len); + try std.testing.expectEqualStrings("https://whole.example", whole[0].service_endpoint); + const repo = try listNotificationRecipients(allocator, space, "did:plc:writer", true); + try std.testing.expectEqual(@as(usize, 2), repo.len); + try std.testing.expectEqualStrings("https://whole.example", repo[0].service_endpoint); + try std.testing.expectEqualStrings("https://repo.example", repo[1].service_endpoint); +} + +test "permissioned credential exchange tokens are one use" { + try init(std.Options.debug_io, ":memory:"); + defer close(); + + const first = SpaceReplayToken{ .jti = "delegation-one", .expires_at = 4_000_000_000 }; + const attestation = SpaceReplayToken{ .jti = "attestation-one", .expires_at = 4_000_000_000 }; + try std.testing.expect(try consumeSpaceCredentialExchange(first, attestation)); + try std.testing.expect(!try consumeSpaceCredentialExchange(first, null)); + + const second = SpaceReplayToken{ .jti = "delegation-two", .expires_at = 4_000_000_000 }; + try std.testing.expect(!try consumeSpaceCredentialExchange(second, attestation)); + try std.testing.expect(try consumeSpaceCredentialExchange(second, null)); +} + fn expectSpaceBlobRefCount( space: []const u8, repo_did: []const u8, @@ -6391,8 +6769,7 @@ test "permissioned spaces keep authority-local state per space URI" { try std.testing.expect(owner_spaces[0].is_authority); try markSpaceDeleted(owner.did, created.uri); - const deleted_owner_view = (try getSpace(allocator, owner.did, created.uri)).?; - try std.testing.expect(deleted_owner_view.deleted_at != null); + try std.testing.expect((try getSpace(allocator, owner.did, created.uri)) == null); } test "permissioned space create is duplicate-checked per actor" { @@ -6405,6 +6782,7 @@ test "permissioned space create is duplicate-checked per actor" { const owner = try createAccount(allocator, "authority-first.test", "authority-first@test.com", "password", "did:plc:ownerfirst", true); const viewer = try createAccount(allocator, "viewer-first.test", "viewer-first@test.com", "password", "did:plc:viewerfirst", true); + const member = try createAccount(allocator, "member-only.test", "member-only@test.com", "password", "did:plc:memberonly", true); const viewer_row = try createSpace(allocator, .{ .actor_did = viewer.did, @@ -6432,6 +6810,14 @@ test "permissioned space create is duplicate-checked per actor" { try std.testing.expectEqualStrings("public", owner_row.policy); try std.testing.expectEqualStrings("did:web:plyr.fm", owner_row.managing_app.?); + try addSimpleSpaceMember(owner_row.uri, member.did); + const member_spaces = try listSpaces(allocator, member.did, owner.did, "fm.plyr.privateMedia", null, 50); + try std.testing.expectEqual(@as(usize, 0), member_spaces.len); + + const viewer_spaces = try listSpaces(allocator, viewer.did, owner.did, "fm.plyr.privateMedia", null, 50); + try std.testing.expectEqual(@as(usize, 1), viewer_spaces.len); + try std.testing.expect(!viewer_spaces[0].is_authority); + try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ .actor_did = viewer.did, .authority_did = owner.did, @@ -6460,10 +6846,10 @@ test "inactive repo import does not publish firehose event" { "did:plc:importinactive", true, ); - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"hello\"}", .{}); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"hello\"}", .{}); defer parsed.deinit(); - const record = try create(allocator, account, "app.bsky.feed.post", "3ztest", parsed.value); + const record = try create(allocator, account, "app.bsky.feed.post", "3jzfcijpj2z2h", parsed.value); const repo_car = try writeRepoCar(allocator, account.did); const loaded = try zat.loadCommitFromCAR(allocator, repo_car); @@ -6513,15 +6899,16 @@ test "put updates record index while content comes from repo blocks" { true, ); - var first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"before\"}", .{}); + var first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"before\"}", .{}); defer first.deinit(); - _ = try create(allocator, account, "app.bsky.feed.post", "3zput", first.value); + const rkey = "3jzfcijpj2z2i"; + _ = try create(allocator, account, "app.bsky.feed.post", rkey, first.value); - var second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"after\"}", .{}); + var second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"after\"}", .{}); defer second.deinit(); - const updated = try put(allocator, account, "app.bsky.feed.post", "3zput", second.value); + const updated = try put(allocator, account, "app.bsky.feed.post", rkey, second.value); - const fetched = get(account.did, "app.bsky.feed.post", "3zput").?; + const fetched = get(account.did, "app.bsky.feed.post", rkey).?; try std.testing.expectEqualStrings(updated.cid, fetched.cid); try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "after") != null); try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "before") == null); @@ -6543,7 +6930,8 @@ test "stores blob metadata in sqlite and bytes in disk blobstore" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const blob_root = try tmp.dir.realpath(".", &path_buf); + const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); + const blob_root = path_buf[0..path_len]; blobstore.init(std.Options.debug_io, blob_root); try init(std.Options.debug_io, ":memory:"); @@ -6577,7 +6965,8 @@ test "stores larger blob metadata in sqlite and bytes in disk blobstore" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const blob_root = try tmp.dir.realpath(".", &path_buf); + const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); + const blob_root = path_buf[0..path_len]; blobstore.init(std.Options.debug_io, blob_root); try init(std.Options.debug_io, ":memory:"); @@ -6875,9 +7264,9 @@ test "account takedown sets precise sync status and revokes tokens" { "did:plc:status", true, ); - var record_json = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"hello\"}", .{}); + var record_json = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"hello\"}", .{}); defer record_json.deinit(); - _ = try create(allocator, account, "app.bsky.feed.post", "3jtest", record_json.value); + _ = try create(allocator, account, "app.bsky.feed.post", "3jzfcijpj2z2j", record_json.value); _ = try createSessionTokenRow( allocator, @@ -7000,15 +7389,15 @@ test "account audit log stores subject actor and controller fields" { } test "commit event encoding uses Zat firehose builder" { - const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); const cid = "bafyreifmpxapiafzeml4ns5nedutwbsnjw5obdganycdy6mhmb2mxth5aq"; const rev = try atid.encode(1_700_000_123_456_789, 1); const since = try atid.encode(1_700_000_123_456_000, 1); const prev_data_raw = try cidRawFromText(allocator, cid); - defer allocator.free(prev_data_raw); const ops = try allocator.alloc(zat.firehose.CommitEventOp, 3); - defer allocator.free(ops); ops[0] = try firehoseOp(allocator, .create, "sh.tangled.string", "zds-create", cid, null); ops[1] = try firehoseOp(allocator, .update, "sh.tangled.string", "zds-update", cid, cid); ops[2] = try firehoseOp(allocator, .delete, "sh.tangled.string", "zds-delete", null, cid); @@ -7024,8 +7413,6 @@ test "commit event encoding uses Zat firehose builder" { "not-a-real-car-for-this-encoding-test", ops, ); - defer allocator.free(frame); - const decoded = try zat.firehose.decodeFrame(allocator, frame); try std.testing.expectEqual(@as(i64, 1), decoded.commit.seq); try std.testing.expectEqualStrings("did:plc:b64lsctzqnzpv6vd4ry3qktw", decoded.commit.repo); diff --git a/tools/smoke-permissioned.sh b/tools/smoke-permissioned.sh index c85cdac..c506855 100755 --- a/tools/smoke-permissioned.sh +++ b/tools/smoke-permissioned.sh @@ -7,7 +7,7 @@ db="${TMPDIR:-/tmp}/zds-permissioned-smoke.sqlite3" blob_root="${TMPDIR:-/tmp}/zds-permissioned-smoke-blobs" log="${TMPDIR:-/tmp}/zds-permissioned-smoke.log" base="http://127.0.0.1:${port}" -space_uri="ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self" +space_uri="at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self" encoded_space=$(printf '%s' "$space_uri" | jq -sRr @uri) cleanup() { @@ -48,6 +48,11 @@ done curl -fsS "$base/xrpc/_health" >/dev/null sqlite3 "$db" "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:permissionsmoke', 'permissioned-smoke.test', 'permissioned-smoke@test.com', 'password', unixepoch(), unixepoch())" +policy_status=$(curl -sS -o /tmp/zds-space-policy.json -w '%{http_code}' \ + "$base/xrpc/com.atproto.simplespace.checkUserAccess?space=$encoded_space&user=did:plc:permissionsmoke") +test "$policy_status" = "401" +grep -q '"error":"AuthenticationRequired"' /tmp/zds-space-policy.json + session=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createSession" \ -H 'content-type: application/json' \ --data '{"identifier":"permissioned-smoke.test","password":"password"}') @@ -69,27 +74,27 @@ test "$public_blob_status" = "404" space_create=$(curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"self","managingApp":"did:web:plyr.fm","policy":"member-list","appAccess":{"type":"open"}}') -printf '%s' "$space_create" | grep -q '"uri":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' -printf '%s' "$space_create" | grep -q '"config":' + --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self","config":{"managingApp":"did:web:plyr.fm","policy":"member-list","appAccess":{"$type":"com.atproto.simplespace.defs#open"}}}') +printf '%s' "$space_create" | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"' +! printf '%s' "$space_create" | grep -q '"config":' space_duplicate_status=$(curl -sS -o /tmp/zds-space-duplicate.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"type":"fm.plyr.privateMedia","skey":"self","policy":"member-list"}') + --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self"}') test "$space_duplicate_status" = "400" grep -q '"error":"SpaceAlreadyExists"' /tmp/zds-space-duplicate.json space_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getSpace?space=$encoded_space") -printf '%s' "$space_get" | grep -q '"isAuthority":true' +printf '%s' "$space_get" | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"' printf '%s' "$space_get" | grep -q '"managingApp":"did:web:plyr.fm"' printf '%s' "$space_get" | grep -q '"policy":"member-list"' ! printf '%s' "$space_get" | grep -q '"members":' -space_list=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listSpaces?authority=did:plc:permissionsmoke&type=fm.plyr.privateMedia") -printf '%s' "$space_list" | grep -q '"uri":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' -printf '%s' "$space_list" | grep -q '"isAuthority":true' -printf '%s' "$space_list" | grep -q '"cursor":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' +space_list=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listSpaces?did=did:plc:permissionsmoke&type=fm.plyr.privateMedia") +printf '%s' "$space_list" | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"' +printf '%s' "$space_list" | grep -q '"isOwner":true' +printf '%s' "$space_list" | grep -q '"cursor":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"' ! printf '%s' "$space_list" | grep -q '"isMember":' space_members=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.simplespace.listMembers?space=$encoded_space") @@ -99,7 +104,7 @@ space_record=$(curl -fsS -X POST "$base/xrpc/com.atproto.space.createRecord" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ --data "$(jq -nc --arg space "$space_uri" --arg cid "$blob_cid" '{space:$space,repo:"did:plc:permissionsmoke",collection:"fm.plyr.track",rkey:"track-one",record:{"$type":"fm.plyr.track",title:"private smoke",audioBlob:{"$type":"blob",ref:{"$link":$cid},mimeType:"image/jpeg",size:12}}}')") -printf '%s' "$space_record" | grep -q '"uri":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self/did:plc:permissionsmoke/fm.plyr.track/track-one"' +printf '%s' "$space_record" | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self/did:plc:permissionsmoke/fm.plyr.track/track-one"' printf '%s' "$space_record" | grep -q '"cid":"' ! printf '%s' "$space_record" | grep -q '"validationStatus":' @@ -119,6 +124,24 @@ space_record_refs=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com. printf '%s' "$space_record_refs" | grep -q '"collection":"fm.plyr.track"' ! printf '%s' "$space_record_refs" | grep -q '"value":' +space_ops=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listRepoOps?space=$encoded_space&repo=did:plc:permissionsmoke") +printf '%s' "$space_ops" | grep -q '"collection":"fm.plyr.track"' +printf '%s' "$space_ops" | grep -q '"value":' +printf '%s' "$space_ops" | grep -q '"commit":' +! printf '%s' "$space_ops" | grep -q '"action":' + +space_op_refs=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listRepoOps?space=$encoded_space&repo=did:plc:permissionsmoke&excludeValues=true") +printf '%s' "$space_op_refs" | grep -q '"collection":"fm.plyr.track"' +! printf '%s' "$space_op_refs" | grep -q '"value":' + +space_repo_headers="${TMPDIR:-/tmp}/zds-space-repo.headers" +space_repo_car="${TMPDIR:-/tmp}/zds-space-repo.car" +curl -fsS -D "$space_repo_headers" -o "$space_repo_car" \ + -H "authorization: Bearer $token" \ + "$base/xrpc/com.atproto.space.getRepo?space=$encoded_space&repo=did:plc:permissionsmoke" +grep -qi '^content-type: application/vnd.ipld.car' "$space_repo_headers" +test "$(wc -c < "$space_repo_car" | tr -d ' ')" -gt 100 + space_blob_status=$(curl -sS -o /tmp/zds-space-blob-range.bin -w '%{http_code}' \ -H "authorization: Bearer $token" \ -H 'range: bytes=0-2' \ -- 2.51.2