atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Add account takedown status support

zzstoatzz (Jun 24, 2026, 5:45 PM -0500) cb168582 a7dcf9cb

+952 -90
+4 -3
README.md
··· 138 138 credential IDs, public keys, counters, names, and last-use timestamps. 139 139 - `/security` is the local account-security page for passkey and app-password 140 140 management. 141 - - `com.atproto.space.*` permissioned-data routes are experimental and operator 142 - gated with `ZDS_PERMISSIONED_DATA`. ZDS implements the storage and credential 143 - substrate, while reader/group semantics are left to applications. 141 + - `com.atproto.space.*` permissioned-data routes are an experimental prototype 142 + and operator gated with `ZDS_PERMISSIONED_DATA`. ZDS implements enough storage 143 + and credential substrate for local experiments, but the upstream proposal is 144 + still moving and this surface is not a stable compatibility contract. 144 145 145 146 ## references 146 147
+300
docs/account-takedown-runbook.md
··· 1 + # account takedown runbook 2 + 3 + This runbook is for operator action against accounts hosted on this ZDS 4 + instance. It covers test-account cleanup, emergency suspension, and long-term 5 + host takedown. It is not a global identity takedown mechanism. 6 + 7 + ## authority boundary 8 + 9 + A PDS operator controls local hosting for accounts on that PDS: 10 + 11 + - whether this PDS reports a hosted account as active 12 + - whether this PDS serves the account's repo, records, and blobs 13 + - whether this PDS accepts authenticated writes for the account 14 + - whether this PDS emits account-status events on its local firehose 15 + - whether this PDS retains or purges local account data according to policy 16 + 17 + A PDS operator does not, by this action alone, erase all downstream copies, 18 + control every relay or appview, or tombstone a PLC identity. Downstream services 19 + are expected to respect account-status changes, but propagation is hop-by-hop 20 + and can lag or diverge. 21 + 22 + References: 23 + 24 + - Account lifecycle guide: 25 + <https://atproto.com/guides/account-lifecycle> 26 + - Account hosting status spec: 27 + <https://atproto.com/specs/account> 28 + - Sync and `#account` events: 29 + <https://atproto.com/specs/sync> 30 + 31 + ## status vocabulary 32 + 33 + Atproto distinguishes several inactive hosting states: 34 + 35 + - `deactivated`: user or host temporarily pauses the account. Content should not 36 + be displayed or redistributed, but infrastructure does not need to delete 37 + local copies. 38 + - `suspended`: host temporarily pauses the account. 39 + - `takendown`: host or service removes the repository long-term for policy or 40 + terms reasons. 41 + - `deleted`: account deletion. Downstream services should stop serving content 42 + and may delete data according to policy. 43 + 44 + ZDS stores account hosting state in `accounts.account_status`. The currently 45 + implemented operator states are: 46 + 47 + - `active` 48 + - `deactivated` 49 + - `takendown` 50 + 51 + `suspended` and `deleted` are reserved vocabulary from the protocol, but ZDS 52 + does not yet expose operator workflows for them. 53 + 54 + Current behavior: 55 + 56 + - `com.atproto.server.deactivateAccount` sets `account_status: 57 + "deactivated"`. 58 + - `com.atproto.server.activateAccount` returns the account to `active` only 59 + from `deactivated`. 60 + - `com.atproto.admin.updateSubjectStatus` can apply or clear `takendown` for a 61 + repo subject. 62 + - applying `takendown` revokes local session tokens and OAuth token families. 63 + - ZDS emits a `#account` event with `active: false` and the precise inactive 64 + status. 65 + - `com.atproto.sync.getRepoStatus` reports `active: false` and the precise 66 + inactive status, and omits `rev` while inactive. 67 + - public repo/blob/read XRPCs and authenticated repo writes are denied while the 68 + account is inactive. 69 + 70 + ## response levels 71 + 72 + ### test account cleanup 73 + 74 + Use this when the operator owns the test account and wants to remove clutter. 75 + 76 + Default action: 77 + 78 + 1. Verify the account DID and handle. 79 + 2. Export or snapshot enough metadata to undo mistakes. 80 + 3. Deactivate first. 81 + 4. Leave physical purge as a separate, reviewed step. 82 + 83 + ### temporary safety pause 84 + 85 + Use this when an account may be compromised, abusive, or causing operational 86 + harm, but the final policy decision is not ready. 87 + 88 + Default action: 89 + 90 + 1. Capture evidence and timestamps. 91 + 2. Deactivate the account. 92 + 3. Revoke active local sessions and OAuth grants if the compromise involves 93 + credentials. 94 + 4. Verify `getRepoStatus` reports inactive. 95 + 5. Watch relay/appview behavior and logs. 96 + 6. Decide later whether to reactivate, keep suspended, or perform long-term 97 + takedown. 98 + 99 + ### long-term host takedown 100 + 101 + Use this when the PDS will no longer host the account. 102 + 103 + ZDS has a first-class `takendown` state for hosted repo subjects. It stops local 104 + hosting without deleting rows or blob bytes. Treat takedown as the normal 105 + long-term operator action; treat physical purge as a separate retention-policy 106 + decision. 107 + 108 + Behavior: 109 + 110 + - durable status distinct from user deactivation 111 + - `#account` event with `active: false` and `status: "takendown"` 112 + - `getRepoStatus` returns `active: false` and `status: "takendown"` 113 + - writes, repo exports, record reads, and blob reads are denied 114 + - sessions and OAuth grants are revoked 115 + 116 + Remaining gap: 117 + 118 + - ZDS should add a richer admin audit row for operator status changes. Until 119 + then, preserve external notes with actor, reason, timestamps, and affected 120 + DID. 121 + 122 + ## investigation checklist 123 + 124 + Before changing account state: 125 + 126 + ```sh 127 + fly ssh console -a zds-pds -C "sqlite3 /data/zds.sqlite3 \ 128 + \"select handle,did,account_status,datetime(created_at,'unixepoch'),\ 129 + datetime(activated_at,'unixepoch'),email \ 130 + from accounts order by created_at;\"" 131 + ``` 132 + 133 + For a candidate DID, inspect footprint before deciding: 134 + 135 + ```sh 136 + did='did:plc:example' 137 + fly ssh console -a zds-pds -C "sqlite3 /data/zds.sqlite3 \" 138 + select 'records', count(*) from records where did='$did' 139 + union all select 'repo_blocks', count(*) from repo_blocks where did='$did' 140 + union all select 'commits', count(*) from commits where did='$did' 141 + union all select 'blobs', count(*) from blobs where did='$did' 142 + union all select 'spaces_owned', count(*) from permissioned_spaces where owner_did='$did' 143 + union all select 'space_actor_state', count(*) from permissioned_space_actor_state where actor_did='$did' 144 + union all select 'space_records', count(*) from permissioned_space_records where repo_did='$did' 145 + union all select 'space_repos', count(*) from permissioned_space_repos where repo_did='$did' 146 + union all select 'session_tokens', count(*) from session_tokens where did='$did' 147 + union all select 'oauth_tokens', count(*) from oauth_tokens where did='$did'; 148 + \"" 149 + ``` 150 + 151 + Record: 152 + 153 + - handle and DID 154 + - who requested action 155 + - why action is justified 156 + - whether the account is operator-owned test data or another user's account 157 + - current record/blob/space footprint 158 + - whether a backup is needed before action 159 + - planned status and retention policy 160 + 161 + ## current ZDS actions 162 + 163 + For user-initiated deactivation, use the existing XRPC with the user's own 164 + bearer token. 165 + 166 + For operator-driven takedown, use the admin XRPC with `ZDS_ADMIN_TOKEN`: 167 + 168 + ```sh 169 + curl -fsS -X POST "https://pds.zat.dev/xrpc/com.atproto.admin.updateSubjectStatus" \ 170 + -H "authorization: Bearer $ZDS_ADMIN_TOKEN" \ 171 + -H "content-type: application/json" \ 172 + --data-binary @- <<JSON | jq . 173 + { 174 + "subject": { 175 + "$type": "com.atproto.admin.defs#repoRef", 176 + "did": "$did" 177 + }, 178 + "takedown": { 179 + "applied": true, 180 + "ref": "operator-note-or-ticket" 181 + } 182 + } 183 + JSON 184 + ``` 185 + 186 + To clear an operator takedown: 187 + 188 + ```sh 189 + curl -fsS -X POST "https://pds.zat.dev/xrpc/com.atproto.admin.updateSubjectStatus" \ 190 + -H "authorization: Bearer $ZDS_ADMIN_TOKEN" \ 191 + -H "content-type: application/json" \ 192 + --data-binary @- <<JSON | jq . 193 + { 194 + "subject": { 195 + "$type": "com.atproto.admin.defs#repoRef", 196 + "did": "$did" 197 + }, 198 + "takedown": { 199 + "applied": false 200 + } 201 + } 202 + JSON 203 + ``` 204 + 205 + If direct SQL is unavoidable in an emergency, the minimum semantic behavior is: 206 + 207 + 1. Set `account_status`. 208 + 2. Insert a `#account` event with `active: false` and the precise status. 209 + 3. Notify crawlers. 210 + 4. Revoke tokens if credential compromise is part of the incident. 211 + 212 + Do not perform only step 1 and call it done; downstream services learn about 213 + status through repo-status reads and firehose account events. 214 + 215 + ## verification 216 + 217 + After deactivation or takedown: 218 + 219 + ```sh 220 + curl -fsS "https://pds.zat.dev/xrpc/com.atproto.sync.getRepoStatus?did=$did" | jq . 221 + ``` 222 + 223 + Expected for ZDS takedown: 224 + 225 + ```json 226 + { 227 + "did": "did:plc:...", 228 + "active": false, 229 + "status": "takendown" 230 + } 231 + ``` 232 + 233 + Also verify: 234 + 235 + - the account does not appear as active on the landing page 236 + - repo export, record reads, and blob reads do not continue to make unavailable 237 + content public 238 + - the firehose emitted a recent `#account` event 239 + - relays/appviews eventually stop serving or redistributing the account's 240 + content 241 + 242 + ## physical purge 243 + 244 + Physical purge is different from account status. 245 + 246 + Before purging rows or blob bytes: 247 + 248 + - confirm this is operator-owned test data, or that policy/terms require purge 249 + - take a database backup or Fly volume snapshot if rollback matters 250 + - understand foreign-key cascades for the affected tables 251 + - decide whether global blob/block tables may contain shared data 252 + - record what was deleted 253 + 254 + For non-test accounts, prefer a two-step process: 255 + 256 + 1. deactivate or takedown now 257 + 2. purge after a retention window 258 + 259 + This keeps the emergency action reversible while still stopping local hosting. 260 + 261 + ## current known test account 262 + 263 + As of 2026-06-24, the likely disposable private-media test account on 264 + `pds.zat.dev` is: 265 + 266 + - handle: `plyr-priv-test.pds.zat.dev` 267 + - DID: `did:plc:dm3jejwohil763nmotc6cu2m` 268 + - email: `plyr-priv-test@example.com` 269 + 270 + Recent read-only footprint: 271 + 272 + ```text 273 + records: 0 274 + repo_blocks: 0 275 + commits: 0 276 + blobs: 3 277 + spaces_owned: 1 278 + space_actor_state: 1 279 + space_records: 1 280 + space_repos: 1 281 + session_tokens: 6 282 + oauth_tokens: 0 283 + ``` 284 + 285 + Treat this as a candidate for testing the runbook. Do not use it as precedent 286 + for deleting a real user's account without a first-class admin action and audit 287 + trail. 288 + 289 + ## implementation backlog 290 + 291 + ZDS should eventually add: 292 + 293 + - explicit admin account-status endpoint or CLI 294 + - distinct statuses: `deactivated`, `suspended`, `takendown`, `deleted` 295 + - admin audit log entries for status changes 296 + - token revocation tied to operator actions 297 + - dry-run account footprint command 298 + - reviewed purge tool with table-by-table output 299 + - tests for `#account` status events and `getRepoStatus` behavior for each 300 + status
+11 -6
docs/operations.md
··· 4 4 configuration, containers, invite-code administration, account migration notes, 5 5 and release procedure. 6 6 7 + For account suspension, takedown, and test-account cleanup, see the 8 + [account takedown runbook](account-takedown-runbook.md). ZDS supports temporary 9 + account deactivation and operator takedown for hosted repo subjects; `suspended` 10 + and `deleted` remain protocol vocabulary without local operator workflows. 11 + 7 12 ## local checks 8 13 9 14 Run these before committing: ··· 106 111 creation. `describeServer` reflects this value. 107 112 - `ZDS_PERMISSIONED_DATA`: set to `true` to opt into experimental 108 113 `com.atproto.space.*` permissioned-data routes. Default: disabled. The 109 - current implementation is intended to track the upstream experimental 110 - permissioned-data work while following the newer direction that removes 111 - protocol member lists. ZDS provides space lifecycle, private records, grants, 112 - credentials, writer repo state/oplogs, ranged blob reads, and notify routes. 113 - Reader/group semantics are application policy. Use `just smoke-permissioned` 114 - for the experimental smoke lane. 114 + current implementation is a prototype, not a stable protocol contract. It 115 + provides space lifecycle, private records, grants, credentials, writer repo 116 + state/oplogs, ranged blob reads, and notify routes for local experiments. 117 + Reader/group semantics remain application policy, while the newer proposal's 118 + `com.atproto.simplespace.*` baseline is tracked as future alignment work. 119 + Use `just smoke-permissioned` for the experimental smoke lane. 115 120 - Passkeys do not require a separate deployment secret. WebAuthn RP ID is 116 121 derived from the public URL host. 117 122
+210
docs/permissioned-data-proposal-94.md
··· 1 + # permissioned data proposal 94 notes 2 + 3 + These notes track Bluesky proposal PR #94 from the perspective of ZDS. They are 4 + not implementation instructions and should not be treated as a stable protocol 5 + contract. The proposal is a draft; use this page to reduce wrong-way risk while 6 + keeping ZDS's current permissioned-data work clearly experimental. 7 + 8 + PR: <https://github.com/bluesky-social/proposals/pull/94> 9 + 10 + ## working posture 11 + 12 + ZDS is a sandcastle for this surface. It is acceptable to make subtractive 13 + changes while there are no external production users, but avoid deepening older 14 + experimental shapes when the proposal has already moved away from them. 15 + 16 + Prefer small alignment work that preserves optionality: 17 + 18 + - keep the API reference and operator docs explicit that this is experimental 19 + - isolate prototype code under the existing `space` and permissioned-data 20 + modules 21 + - add tests around durable semantics that are likely to survive 22 + - avoid adding new member-list or grant behavior in the old vocabulary 23 + 24 + ZDS's immediate goal is modest: get ahead of the likely direction enough to 25 + store private data on the user's PDS, while keeping the implementation easy to 26 + reshape. Do not write code or docs as if the draft is settled. 27 + 28 + ## recent design pressure 29 + 30 + On 2026-06-24, Richard Barnes pushed back publicly on the proposal's broad 31 + scope. His useful objection is that "permissioned data" appears to combine 32 + several product shapes that can have different needs: 33 + 34 + - personal data: bookmarks, mutes, drafts 35 + - gated content: paid newsletters, subscriber-only posts 36 + - socially shared data: private posts and stories 37 + - groups: private forums, communities, group chats 38 + 39 + He also questioned the cost of creating a parallel data universe and called out 40 + URI-scheme complexity around `ats://`. This is worth tracking even if the 41 + thread did not fully engage Daniel's earlier long-form diaries. Richard has 42 + done foundational protocol/security work, so treat the critique as meaningful 43 + design pressure rather than as implementation guidance. 44 + 45 + Eli Mallon pointed back to the diary motivation: for permissioned data, 46 + "rebroadcastability" is an antipattern. That is the core distinction that makes 47 + this more than ordinary public repo records with app-level filtering. If a 48 + reader can freely rebroadcast the same bytes into the public sync graph, the 49 + privacy boundary has already failed. 50 + 51 + Daniel's reply clarified the current bet: the motivating use case is groups, 52 + and the proposal tries not to be dogmatic about one protocol per product 53 + category. If the group-shaped primitive is expressive enough for the simpler 54 + cases without becoming overwrought, that is a reasonable design win. He also 55 + said he is still wrestling with URI scheme choice and is leaning back toward 56 + keeping `at`. 57 + 58 + ZDS implications: 59 + 60 + - keep our docs careful about `ats://`; do not invest in naming churn until the 61 + proposal lands 62 + - keep private personal data as the motivating local use case, not a claim that 63 + ZDS has solved groups 64 + - do not encode a universal group model into ZDS while the proposal is still 65 + deliberately pushing group semantics up to applications/space hosts 66 + - preserve the ability to store private records and blobs now, but keep the 67 + protocol surface clearly experimental 68 + 69 + ## broad matches 70 + 71 + The current ZDS implementation broadly matches these proposal instincts: 72 + 73 + - permissioned data is separate from public repo data 74 + - permissioned records use an `ats://` address that names space authority, 75 + space type, space key, writer repo, collection, and record key 76 + - one writer has one permissioned repo per space 77 + - record sets are represented with LtHash-style set commitment state 78 + - general reader/group semantics should not be encoded as a universal protocol 79 + member list 80 + - apps that receive access to a space should expect whole-space read semantics, 81 + not just access to the authorizing user's own writer repo 82 + 83 + ## proposal deltas to track 84 + 85 + These are the main known gaps between ZDS's current prototype and PR #94: 86 + 87 + - Space declarations are lexicon definitions, not records. That appears to make 88 + a space type behave like a collection/schema authority: one NSID-shaped path 89 + declares a reusable space type that many space repos, accounts, spaces, and 90 + apps can share. This trades away the direct repo-authority feel of records in 91 + favor of namespace authority, fallback behavior, and a single path segment. 92 + App designers or project maintainers may publish the initial declaration 93 + using DNS and lexicon-publishing authority, but the declaration becomes a 94 + reusable commons resource after that. Lexicon unpublishing also should not 95 + break existing spaces that depend on the declaration, which makes lexicon 96 + resolution override behavior appropriate in a way that would be surprising 97 + for normal AT records. 98 + - `com.atproto.space.getMemberGrant` is older vocabulary. The proposal uses 99 + `com.atproto.space.getDelegationToken` as a PDS-issued OAuth query and then 100 + exchanges that with the space host through `getSpaceCredential`. 101 + - `getRepoOplog` is older vocabulary. The proposal uses `listRepoOps`. 102 + - The proposal adds `com.atproto.space.listRepos` so a space host can list known 103 + writer repos in a space without enumerating readers. 104 + - The proposal adds `registerNotify` as an explicit syncer registration method. 105 + ZDS currently records credential recipients and supports write/deletion 106 + notifications, but does not expose this method shape. 107 + - Space credentials and delegation tokens have more specific JWT `typ`, `sub`, 108 + `aud`, `client_id`, lifetime, and verification expectations than ZDS's 109 + prototype helper names imply. 110 + - The proposal introduces optional client attestation for app-bound space 111 + credentials. 112 + - Space-authority DID documents are expected to publish `#atproto_space` and 113 + `#atproto_space_host` material. ZDS currently does not model this separately 114 + from ordinary account/PDS signing material. 115 + - Commit state is more than an LtHash value. The draft includes random commit 116 + `ikm`, a signature over commit context, and a MAC over the record-set hash. 117 + This is a deliberate deniability design: do not accidentally make a 118 + permissioned commit into a durable public proof of private content. 119 + - OAuth `space:` scopes distinguish `read` from `read_self` and separate 120 + lifecycle/admin capability with `manage=`. 121 + - Permission-set records use proposal vocabulary such as `spaceType`; older ZDS 122 + permission-set handling should be audited before relying on it. 123 + - Space deletion semantics need review. The draft distinguishes space-authority 124 + deletion from member repo ownership and does not imply arbitrary erasure of 125 + every writer's local data. 126 + 127 + ## implementation traps 128 + 129 + These are low-level details to keep visible before attempting parity work: 130 + 131 + - LtHash live-record elements must stay unique. The draft shape uses 132 + `{collection}/{rkey}/{record_cid}` so the same live element is not added more 133 + than once. 134 + - Byte order matters. LtHash lanes are little-endian, while the 135 + TLS-style length-prefixed context used around signed commit material is 136 + big-endian. 137 + - The operation log is a sync shortcut, not permanent history. A host may 138 + compact or drop it; syncers must be able to compare set hashes and recover by 139 + listing records. 140 + - Permissioned sync is relay-less and pull-based. Real-time behavior comes from 141 + write notifications through the space authority, but correctness cannot rely 142 + on every notification arriving. 143 + - Space types should be particular. Avoid designing one universal community 144 + space that gives every app access to every modality; apps can group many 145 + narrower spaces under a community authority at the application layer. 146 + 147 + ## export and backup pressure 148 + 149 + The draft does not currently look like it wants a `getSpaceRepo` CAR equivalent 150 + for permissioned repos. That may be intentional: a permissioned repo is not 151 + necessarily the public MST repo shape, and backfill can be expressed through 152 + space enumeration plus `listRecords`. 153 + 154 + For ZDS, avoid inventing a `getSpaceRepo` endpoint unless the proposal moves 155 + that way. The safer design pressure is exportability: 156 + 157 + - residents should be able to enumerate their own permissioned spaces 158 + - residents should be able to enumerate their own writer repos and records 159 + inside those spaces 160 + - backup/export can be a higher-level artifact, such as a manifest plus records 161 + grouped by space 162 + - blobs should probably be referenced or streamed separately from record export 163 + instead of assuming one giant blob archive is always practical 164 + - any future export path should be resumable or easy to retry for large media 165 + sets 166 + 167 + This is separate from sync. Sync methods need protocol parity; backup/export can 168 + be an operator or resident affordance built from stable lower-level primitives. 169 + 170 + ## simplespace surprise 171 + 172 + PR #94 removes the protocol-level member list as a universal authorization 173 + substrate, but it also sketches a required PDS baseline called 174 + `com.atproto.simplespace.*`. 175 + 176 + That baseline currently includes: 177 + 178 + - `createSpace` 179 + - `updateSpace` 180 + - `deleteSpace` 181 + - `addMember` 182 + - `removeMember` 183 + - `listMembers` 184 + 185 + It also introduces simple policies such as `public`, `member-list`, and 186 + `managing-app`, plus a managing-app access check hook. 187 + 188 + This is not the same as reviving the older generic member-list experiment. If 189 + the proposal keeps `simplespace`, ZDS should treat it as a small baseline space 190 + management API while still leaving richer application/group semantics to apps. 191 + 192 + Some commentary describes the boring access model as a list of DIDs. In ZDS 193 + terms, do not read that as permission to restore the old protocol member-list 194 + sync surface. Treat it as space-management policy, especially for 195 + `com.atproto.simplespace.*` and managing-app credential minting. 196 + 197 + ## useful tests before more surface 198 + 199 + Before adding more endpoints, prefer tests that exercise durable semantics: 200 + 201 + - per-space and per-writer repo isolation 202 + - `ats://` parsing and formatting 203 + - OAuth scope gating for owner reads, self reads, writes, and management 204 + - ranged blob reads through permissioned-data auth 205 + - write oplog ordering and cursor behavior 206 + - record-set commitment changes across create, update, and delete 207 + - deletion behavior for owner spaces versus writer repos 208 + 209 + These tests should stay separate from public-repo conformance tests so the 210 + experimental surface can move without muddying stable PDS behavior.
+40 -17
docs/permissioned-data.md
··· 1 1 # permissioned data 2 2 3 - Permissioned data support is experimental and operator gated. ZDS documents the 4 - `com.atproto.space.*` surface as experimental, and only enables the handlers 5 - when explicitly configured: 3 + Permissioned data support is experimental and operator gated. Treat the current 4 + ZDS surface as a prototype for local experiments, not as a claim of parity with 5 + the evolving upstream permissioned-data proposal. ZDS documents the 6 + `com.atproto.space.*` routes as experimental, and only enables the handlers when 7 + explicitly configured: 6 8 7 9 ```sh 8 10 ZDS_PERMISSIONED_DATA=true ··· 24 26 <https://github.com/bluesky-social/atproto/tree/permissioned-data/lexicons/com/atproto/space> 25 27 - Reference implementation path: 26 28 `packages/pds/src/actor-store/space` on the permissioned-data branch 29 + - Bluesky proposal PR: 30 + <https://github.com/bluesky-social/proposals/pull/94> 31 + - Local alignment notes: 32 + [permissioned-data proposal 94](permissioned-data-proposal-94.md) 27 33 28 34 The upstream design is still moving. The May branch included protocol-level 29 - member lists. The later discussion is moving toward making space credentials 30 - the protocol substrate and leaving reader/group semantics to applications or 31 - space-host policy. ZDS follows that newer direction because this deployment has 32 - no external production users, so subtractive experimental changes are cheaper 33 - than carrying compatibility for a shape we no longer want. 35 + member lists. The later discussion moved toward making space credentials the 36 + protocol substrate and leaving reader/group semantics to applications or 37 + space-host policy. Proposal PR #94 keeps that broad direction, but also sketches 38 + a baseline `com.atproto.simplespace.*` management surface for PDS-managed 39 + spaces. ZDS has not implemented that newer proposal shape yet. 34 40 35 41 Feedback should respect the research behind the sketch and avoid overfitting to 36 42 ZDS or plyr.fm. Name concrete implementation pressure, but frame it as input for 37 43 the broader protocol shape. 38 44 39 - ## current surface 45 + The current local motivation is private data storage on a resident's PDS. That 46 + is narrower than proving every group/community use case. Recent proposal 47 + discussion reinforces the same posture: groups are the hard case that may 48 + justify the primitive, but ZDS should not bake group semantics into the PDS just 49 + to get private records and blobs working early. 50 + 51 + ## current prototype surface 40 52 41 - ZDS currently keeps the permissioned-data substrate: 53 + ZDS currently keeps a pre-proposal-94 permissioned-data substrate: 42 54 43 55 - space lifecycle: `createSpace`, `getSpace`, `listSpaces`, 44 56 `updateSpaceConfig`, `deleteSpace` ··· 48 60 - credentials and deletion fanout: `getMemberGrant`, `getSpaceCredential`, 49 61 `notifySpaceDeleted` 50 62 51 - ZDS deliberately does not expose protocol member-list routes: 63 + The proposal draft has since moved some names and responsibilities. In 64 + particular, `getMemberGrant` maps only loosely to the newer 65 + `getDelegationToken`, and `getRepoOplog` maps only loosely to `listRepoOps`. 66 + Keep that vocabulary difference visible when changing this area. 67 + 68 + ZDS deliberately does not expose the older protocol member-list routes: 52 69 53 70 - `addMember` 54 71 - `removeMember` ··· 64 81 ZDS treats permissioned data as a space credential and writer-repo substrate, 65 82 not as a universal group-membership system. 66 83 67 - Applications own reader/group semantics: label rosters, supporter access, 84 + Applications own rich reader/group semantics: label rosters, supporter access, 68 85 personal private libraries, community roles, follower-only access, and similar 69 - policy all belong above the PDS substrate. If an application needs portable 70 - access state, it should model that state as application records in a 71 - permissioned space rather than relying on a PDS-wide member list. 86 + policy all belong above the low-level PDS substrate. If an application needs 87 + portable access state, it should model that state as application records in a 88 + permissioned space rather than relying on a universal PDS-wide member list. 89 + 90 + The newer proposal draft may still require a small PDS-managed 91 + `com.atproto.simplespace.*` baseline for simple spaces. That is a compatibility 92 + target to evaluate, not permission to rebuild the older generic member-list 93 + experiment. 72 94 73 95 For private spaces, ZDS currently mints space credentials only to the space 74 96 owner DID unless the space is public. That is the conservative default until ··· 93 115 - `permissioned_space_repos`: writer repo state and current record-set hash 94 116 - `permissioned_space_record_oplog`: incremental record changes by 95 117 `(space, repo_did, rev, idx)` 96 - - `permissioned_space_credentials`: short-lived grants and credentials 118 + - `permissioned_space_credentials`: short-lived prototype grants and 119 + credentials 97 120 - `permissioned_space_credential_recipients`: services to notify for writes and 98 121 space deletion 99 122 ··· 117 140 118 141 - `SpaceUri` parsing/formatting 119 142 - LtHash and set commitment primitives 120 - - member-grant and space-credential JWT helpers 143 + - proposal-shaped delegation-token and space-credential JWT helpers 121 144 122 145 Do not edit or patch the sibling `zat` repo from ZDS without stopping and 123 146 making the proposed Zat change explicit.
+3
docs/references.md
··· 185 185 state, not merely JWT formatting concerns. 186 186 - Delegation-ready code should keep subject, actor, and controller identities 187 187 distinct even before user-facing account delegation exists. 188 + - Account-status actions are local hosting decisions. For operator action, 189 + distinguish temporary deactivation from host takedown, deletion, and physical 190 + purge; see [account takedown runbook](account-takedown-runbook.md). 188 191 189 192 ## active comparison work 190 193
+38
src/atproto/repo.zig
··· 18 18 19 19 const auth_ctx = requireAccount(request, allocator) catch return; 20 20 const account = auth_ctx.account; 21 + try requireActiveAccount(request, account.did); 21 22 const body = http_api.readBodyAlloc(request, allocator, max_repo_write_body_len) catch |err| switch (err) { 22 23 error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "record write body is too large"), 23 24 else => return err, ··· 58 59 59 60 const auth_ctx = requireAccount(request, allocator) catch return; 60 61 const account = auth_ctx.account; 62 + try requireActiveAccount(request, account.did); 61 63 const body = http_api.readBodyAlloc(request, allocator, max_repo_write_body_len) catch |err| switch (err) { 62 64 error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "record write body is too large"), 63 65 else => return err, ··· 107 109 const account = store.resolveRepo(repo) orelse { 108 110 return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 109 111 }; 112 + try requirePublicRepoAvailable(request, account.did); 110 113 const collections = try store.listCollectionsJson(allocator, account.did); 111 114 const body = try std.fmt.allocPrint( 112 115 allocator, ··· 135 138 const account = store.resolveRepo(repo) orelse { 136 139 return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 137 140 }; 141 + try requirePublicRepoAvailable(request, account.did); 138 142 var collection_buf: [256]u8 = undefined; 139 143 const collection = http_api.queryParam(request.url.raw, "collection", &collection_buf) orelse { 140 144 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); ··· 169 173 const account = store.resolveRepo(repo) orelse { 170 174 return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 171 175 }; 176 + try requirePublicRepoAvailable(request, account.did); 172 177 var collection_buf: [256]u8 = undefined; 173 178 const collection = http_api.queryParam(request.url.raw, "collection", &collection_buf) orelse { 174 179 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); ··· 187 192 188 193 const auth_ctx = requireAccount(request, allocator) catch return; 189 194 const account = auth_ctx.account; 195 + try requireActiveAccount(request, account.did); 190 196 const body = http_api.readBodyAlloc(request, allocator, max_repo_write_body_len) catch |err| switch (err) { 191 197 error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "record write body is too large"), 192 198 else => return err, ··· 223 229 224 230 const auth_ctx = requireAccount(request, allocator) catch return; 225 231 const account = auth_ctx.account; 232 + try requireActiveAccount(request, account.did); 226 233 const body = http_api.readBodyAlloc(request, allocator, max_apply_writes_body_len) catch |err| switch (err) { 227 234 error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "applyWrites body is too large"), 228 235 else => return err, ··· 340 347 341 348 const auth_ctx = requireAccount(request, allocator) catch return; 342 349 const account = auth_ctx.account; 350 + try requireActiveAccount(request, account.did); 343 351 const body = http_api.readBodyAlloc(request, allocator, 128 * 1024 * 1024) catch |err| switch (err) { 344 352 error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "repo CAR is too large"), 345 353 else => return err, ··· 414 422 415 423 const auth_ctx = requireBlobUploadAuth(request, allocator) catch return; 416 424 const account = auth_ctx.account; 425 + try requireActiveAccount(request, account.did); 417 426 const mime_type = try allocator.dupe(u8, http_api.headerValue(request, "content-type") orelse "application/octet-stream"); 418 427 if (auth_ctx.oauth_scope) |scope| { 419 428 try requireBlobScope(request, scope, mime_type); ··· 553 562 554 563 const auth_ctx = requireAccount(request, allocator) catch return; 555 564 const account = auth_ctx.account; 565 + try requireActiveAccount(request, account.did); 556 566 var cursor_buf: [256]u8 = undefined; 557 567 const body = try store.writeMissingBlobsJson( 558 568 allocator, ··· 570 580 error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 571 581 } 572 582 return error.HandledResponse; 583 + }; 584 + } 585 + 586 + fn requireActiveAccount(request: *http_api.Request, did: []const u8) !void { 587 + const status = store.accountStatus(did) catch |err| switch (err) { 588 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 589 + else => return err, 590 + }; 591 + if (status.isActive()) return; 592 + return accountStatusError(request, status); 593 + } 594 + 595 + fn requirePublicRepoAvailable(request: *http_api.Request, did: []const u8) !void { 596 + const status = store.accountStatus(did) catch |err| switch (err) { 597 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 598 + else => return err, 599 + }; 600 + if (status.isActive()) return; 601 + return accountStatusError(request, status); 602 + } 603 + 604 + fn accountStatusError(request: *http_api.Request, status: store.AccountStatus) !void { 605 + return switch (status) { 606 + .active => {}, 607 + .takendown => http_api.xrpcError(request, .bad_request, "RepoTakendown", "Repo has been taken down"), 608 + .suspended => http_api.xrpcError(request, .bad_request, "RepoSuspended", "Repo is suspended"), 609 + .deactivated => http_api.xrpcError(request, .bad_request, "RepoDeactivated", "Repo is deactivated"), 610 + .deleted => http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 573 611 }; 574 612 } 575 613
+117 -14
src/atproto/server.zig
··· 753 753 log.debug("xrpc createSession rejected account_not_found identifier={s}\n", .{identifier}); 754 754 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); 755 755 }; 756 + const account_status = try store.accountStatus(account.did); 757 + switch (account_status) { 758 + .active, .deactivated => {}, 759 + .takendown => return http_api.xrpcError(request, .unauthorized, "AccountTakedown", "Account has been taken down"), 760 + .suspended => return http_api.xrpcError(request, .unauthorized, "AccountSuspended", "Account is suspended"), 761 + .deleted => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"), 762 + } 756 763 const primary_password_matches = auth.passwordMatches(account, password); 757 764 const app_password = if (primary_password_matches) 758 765 null ··· 772 779 const info = store.getEmailInfo(arena.allocator(), account.did) orelse { 773 780 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 774 781 }; 775 - const active = store.isAccountActive(account.did); 776 - 777 - const body_out = try sessionJsonWithInfo(arena.allocator(), account, info.email, info.email_confirmed, active, issued.access.token, issued.refresh.token); 782 + const status = try store.accountStatus(account.did); 783 + const body_out = try sessionJsonWithInfo(arena.allocator(), account, info.email, info.email_confirmed, status, issued.access.token, issued.refresh.token); 778 784 try http_api.json(request, .ok, body_out); 779 785 } 780 786 ··· 804 810 const info = store.getEmailInfo(allocator, account.did) orelse { 805 811 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 806 812 }; 807 - const active = store.isAccountActive(account.did); 808 - const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, active, issued.access.token, issued.refresh.token); 813 + const status = try store.accountStatus(account.did); 814 + const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, status, issued.access.token, issued.refresh.token); 809 815 try http_api.json(request, .ok, body_out); 810 816 } 811 817 ··· 822 828 const info = store.getEmailInfo(allocator, account.did) orelse { 823 829 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 824 830 }; 825 - const active = store.isAccountActive(account.did); 826 - const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, active, null, null); 831 + const status = try store.accountStatus(account.did); 832 + const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, status, null, null); 827 833 return http_api.json(request, .ok, body_out); 828 834 } 829 835 ··· 834 840 refresh: []const u8, 835 841 ) ![]const u8 { 836 842 const info = store.getEmailInfo(allocator, account.did) orelse return error.AccountNotFound; 837 - return sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, store.isAccountActive(account.did), access, refresh); 843 + return sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, try store.accountStatus(account.did), access, refresh); 838 844 } 839 845 840 846 fn issueSessionTokens(allocator: std.mem.Allocator, account: auth.Account, auth_method: []const u8, app_password_name: ?[]const u8) !auth.SessionPair { ··· 872 878 account: auth.Account, 873 879 email: []const u8, 874 880 email_confirmed: bool, 875 - active: bool, 881 + status: store.AccountStatus, 876 882 access: ?[]const u8, 877 883 refresh: ?[]const u8, 878 884 ) ![]const u8 { ··· 898 904 .handle = account.handle, 899 905 .email = email, 900 906 .emailConfirmed = email_confirmed, 901 - .active = active, 902 - .status = if (active) null else "deactivated", 907 + .active = status.isActive(), 908 + .status = if (status.isActive()) null else status.asString(), 903 909 }, .{ .emit_null_optional_fields = false }); 904 910 } 905 911 ··· 925 931 defer arena.deinit(); 926 932 const allocator = arena.allocator(); 927 933 const account = requireAccount(request, allocator) catch return; 928 - try store.setAccountActive(account.did, true); 929 - try store.sequenceAccountEvent(allocator, account.did, true); 934 + store.setAccountActive(account.did, true) catch |err| switch (err) { 935 + error.InvalidAccountStatus => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account cannot be activated while takendown, suspended, or deleted"), 936 + else => return err, 937 + }; 938 + try store.sequenceAccountEvent(allocator, account.did, .active); 930 939 try store.sequenceIdentityEvent(allocator, account.did, account.handle); 931 940 try store.sequenceSyncEvent(allocator, account.did); 932 941 sync.notifyCrawlers(true); ··· 939 948 const allocator = arena.allocator(); 940 949 const account = requireAccount(request, allocator) catch return; 941 950 try store.setAccountActive(account.did, false); 942 - try store.sequenceAccountEvent(allocator, account.did, false); 951 + try store.sequenceAccountEvent(allocator, account.did, .deactivated); 943 952 sync.notifyCrawlers(true); 944 953 return http_api.json(request, .ok, "{}"); 945 954 } 946 955 956 + pub fn updateSubjectStatus(request: *http_api.Request) !void { 957 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 958 + defer arena.deinit(); 959 + const allocator = arena.allocator(); 960 + 961 + requireAdminToken(request) catch return; 962 + var body_buf: [8192]u8 = undefined; 963 + const body = try http_api.readBody(request, &body_buf); 964 + const parsed = try http_api.parseJsonBody(request, allocator, body); 965 + const subject = switch (parsed.value) { 966 + .object => |object| object.get("subject") orelse { 967 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing subject"); 968 + }, 969 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), 970 + }; 971 + const did = switch (subject) { 972 + .object => |object| switch (object.get("did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing subject did")) { 973 + .string => |value| value, 974 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid subject did"), 975 + }, 976 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid subject"), 977 + }; 978 + if (zat.Did.parse(did) == null) { 979 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid subject did"); 980 + } 981 + 982 + const takedown = objectValue(parsed.value, "takedown"); 983 + const deactivated = objectValue(parsed.value, "deactivated"); 984 + const takedown_applied = if (takedown) |value| valueBool(value, "applied") orelse false else false; 985 + const deactivated_applied = if (deactivated) |value| valueBool(value, "applied") else null; 986 + if (takedown_applied and deactivated_applied != null and deactivated_applied.? == false) { 987 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot activate and takedown an account at the same time"); 988 + } 989 + 990 + if (takedown) |value| { 991 + const applied = valueBool(value, "applied") orelse { 992 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing takedown.applied"); 993 + }; 994 + if (!applied) { 995 + try store.setAccountTakendown(did, false, null); 996 + } 997 + } 998 + if (!takedown_applied) { 999 + if (deactivated) |value| { 1000 + const applied = valueBool(value, "applied") orelse { 1001 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing deactivated.applied"); 1002 + }; 1003 + store.setAccountActive(did, !applied) catch |err| switch (err) { 1004 + error.InvalidAccountStatus => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account cannot be activated while takendown, suspended, or deleted"), 1005 + else => return err, 1006 + }; 1007 + } 1008 + } 1009 + if (takedown) |value| { 1010 + const applied = valueBool(value, "applied") orelse { 1011 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing takedown.applied"); 1012 + }; 1013 + if (applied) { 1014 + const status_ref = zat.json.getString(value, "ref"); 1015 + try store.setAccountTakendown(did, true, status_ref); 1016 + } 1017 + } 1018 + if (takedown == null and deactivated == null) { 1019 + _ = try store.accountStatus(did); 1020 + } 1021 + 1022 + const status = try store.accountStatus(did); 1023 + try store.sequenceAccountEvent(allocator, did, status); 1024 + sync.notifyCrawlers(true); 1025 + const body_out = try std.fmt.allocPrint( 1026 + allocator, 1027 + "{{\"subject\":{{\"$type\":\"com.atproto.admin.defs#repoRef\",\"did\":{f}}},\"active\":{},\"status\":{f}}}", 1028 + .{ std.json.fmt(did, .{}), status.isActive(), std.json.fmt(status.asString(), .{}) }, 1029 + ); 1030 + return http_api.json(request, .ok, body_out); 1031 + } 1032 + 947 1033 pub fn getServiceAuth(request: *http_api.Request) !void { 948 1034 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 949 1035 defer arena.deinit(); ··· 1225 1311 try http_api.xrpcError(request, .forbidden, "AdminRequired", "Admin token required"); 1226 1312 return error.HandledResponse; 1227 1313 } 1314 + } 1315 + 1316 + fn objectValue(value: std.json.Value, key: []const u8) ?std.json.Value { 1317 + return switch (value) { 1318 + .object => |object| object.get(key), 1319 + else => null, 1320 + }; 1321 + } 1322 + 1323 + fn valueBool(value: std.json.Value, key: []const u8) ?bool { 1324 + return switch (value) { 1325 + .object => |object| switch (object.get(key) orelse return null) { 1326 + .bool => |boolean| boolean, 1327 + else => null, 1328 + }, 1329 + else => null, 1330 + }; 1228 1331 } 1229 1332 1230 1333 fn expiresInTenMinutes() i64 {
+18
src/atproto/sync.zig
··· 177 177 const cid = http_api.queryParam(request.url.raw, "cid", &cid_buf) orelse { 178 178 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing cid"); 179 179 }; 180 + try requirePublicRepoAvailable(request, did); 180 181 const blob = store.getPublicBlob(allocator, did, cid) orelse { 181 182 return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found"); 182 183 }; ··· 205 206 }; 206 207 var since_buf: [256]u8 = undefined; 207 208 const since = http_api.queryParam(request.url.raw, "since", &since_buf); 209 + try requirePublicRepoAvailable(request, did); 208 210 const body = store.writeRepoCarSince(allocator, did, since) catch { 209 211 return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"); 210 212 }; ··· 228 230 }; 229 231 var since_buf: [256]u8 = undefined; 230 232 var cursor_buf: [256]u8 = undefined; 233 + try requirePublicRepoAvailable(request, did); 231 234 const body = try store.writeBlobListJson( 232 235 allocator, 233 236 did, ··· 247 250 const did = http_api.queryParam(request.url.raw, "did", &did_buf) orelse { 248 251 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 249 252 }; 253 + try requirePublicRepoAvailable(request, did); 250 254 const body = store.writeLatestCommitJson(allocator, did) catch |err| switch (err) { 251 255 error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 252 256 else => return err, ··· 314 318 315 319 pub fn requestCrawl(request: *http_api.Request) !void { 316 320 return http_api.json(request, .ok, "{}"); 321 + } 322 + 323 + fn requirePublicRepoAvailable(request: *http_api.Request, did: []const u8) !void { 324 + const status = store.accountStatus(did) catch |err| switch (err) { 325 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 326 + else => return err, 327 + }; 328 + switch (status) { 329 + .active => return, 330 + .takendown => return http_api.xrpcError(request, .bad_request, "RepoTakendown", "Repo has been taken down"), 331 + .suspended => return http_api.xrpcError(request, .bad_request, "RepoSuspended", "Repo is suspended"), 332 + .deactivated => return http_api.xrpcError(request, .bad_request, "RepoDeactivated", "Repo is deactivated"), 333 + .deleted => return http_api.xrpcError(request, .not_found, "RepoNotFound", "Repo not found"), 334 + } 317 335 } 318 336 319 337 pub fn notifyCrawlers(force: bool) void {
+23 -18
src/http/router.zig
··· 35 35 create_invite_code, 36 36 create_invite_codes, 37 37 get_account_invite_codes, 38 + admin_update_subject_status, 38 39 list_app_passwords, 39 40 create_app_password, 40 41 revoke_app_password, ··· 103 104 return endpoint.path[prefix.len..]; 104 105 } 105 106 }; 107 + 108 + const permissioned_data_note = "Experimental prototype gated by ZDS_PERMISSIONED_DATA; shape may change with upstream permissioned-data drafts."; 106 109 107 110 pub const endpoints = [_]Endpoint{ 108 111 .{ .route = .api_docs, .method = "GET", .path = "/api", .group = "zds", .auth = "public", .summary = "Interactive endpoint inventory for this ZDS instance." }, ··· 146 149 .{ .route = .create_invite_code, .method = "POST", .path = "/xrpc/com.atproto.server.createInviteCode", .group = "server", .auth = "admin", .summary = "Create one invite code.", .body = &.{ "useCount", "forAccount" } }, 147 150 .{ .route = .create_invite_codes, .method = "POST", .path = "/xrpc/com.atproto.server.createInviteCodes", .group = "server", .auth = "admin", .summary = "Create invite codes in bulk.", .body = &.{ "codeCount", "useCount", "forAccounts" } }, 148 151 .{ .route = .get_account_invite_codes, .method = "GET", .path = "/xrpc/com.atproto.server.getAccountInviteCodes", .group = "server", .auth = "bearer", .summary = "List invite codes associated with the signed-in account." }, 152 + .{ .route = .admin_update_subject_status, .method = "POST", .path = "/xrpc/com.atproto.admin.updateSubjectStatus", .group = "admin", .auth = "admin", .summary = "Update account takedown or deactivation status.", .body = &.{ "subject", "takedown", "deactivated" } }, 149 153 .{ .route = .list_app_passwords, .method = "GET", .path = "/xrpc/com.atproto.server.listAppPasswords", .group = "server", .auth = "bearer", .summary = "List app passwords for the signed-in account." }, 150 154 .{ .route = .create_app_password, .method = "POST", .path = "/xrpc/com.atproto.server.createAppPassword", .group = "server", .auth = "bearer", .summary = "Create an app password.", .body = &.{ "name", "privileged" } }, 151 155 .{ .route = .revoke_app_password, .method = "POST", .path = "/xrpc/com.atproto.server.revokeAppPassword", .group = "server", .auth = "bearer", .summary = "Revoke an app password.", .body = &.{"name"} }, ··· 201 205 .{ .route = .identity_submit_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.submitPlcOperation", .group = "identity", .auth = "bearer", .summary = "Submit a PLC operation." }, 202 206 .{ .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"} }, 203 207 204 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.createSpace", .group = "space", .auth = "experimental bearer", .summary = "Create a permissioned data space.", .body = &.{ "did", "type", "skey", "managingApp", "isPublic", "appAccessMode", "appExceptions" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 205 - .{ .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 = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 206 - .{ .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 = &.{ "did", "type", "limit", "cursor" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 207 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.updateSpaceConfig", .group = "space", .auth = "experimental bearer", .summary = "Update permissioned data space configuration.", .body = &.{ "space", "managingApp", "isPublic", "appAccessMode", "appExceptions" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 208 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.deleteSpace", .group = "space", .auth = "experimental bearer", .summary = "Tombstone a permissioned data space.", .body = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 208 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.createSpace", .group = "space", .auth = "experimental bearer", .summary = "Create a permissioned data space.", .body = &.{ "did", "type", "skey", "managingApp", "isPublic", "appAccessMode", "appExceptions" }, .notes = permissioned_data_note }, 209 + .{ .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 }, 210 + .{ .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 = &.{ "did", "type", "limit", "cursor" }, .notes = permissioned_data_note }, 211 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.updateSpaceConfig", .group = "space", .auth = "experimental bearer", .summary = "Update permissioned data space configuration.", .body = &.{ "space", "managingApp", "isPublic", "appAccessMode", "appExceptions" }, .notes = permissioned_data_note }, 212 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.deleteSpace", .group = "space", .auth = "experimental bearer", .summary = "Tombstone a permissioned data space.", .body = &.{"space"}, .notes = permissioned_data_note }, 209 213 210 - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getMemberGrant", .group = "space", .auth = "experimental OAuth", .summary = "Create a member grant for exchange with a space owner.", .params = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 214 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getMemberGrant", .group = "space", .auth = "experimental OAuth", .summary = "Create a member grant for exchange with a space owner.", .params = &.{"space"}, .notes = permissioned_data_note }, 211 215 212 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.createRecord", .group = "space", .auth = "experimental bearer", .summary = "Create a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey", "validate", "record" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 213 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.putRecord", .group = "space", .auth = "experimental bearer", .summary = "Create or update a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey", "validate", "record" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 214 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.deleteRecord", .group = "space", .auth = "experimental bearer", .summary = "Delete a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 215 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.applyWrites", .group = "space", .auth = "experimental bearer", .summary = "Apply a batch of writes inside a permissioned data space.", .body = &.{ "space", "repo", "validate", "writes" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 216 - .{ .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 = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 217 - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRecords", .group = "space", .auth = "experimental bearer or space credential", .summary = "List record keys and CIDs in a permissioned data space.", .params = &.{ "space", "repo", "collection", "limit", "cursor", "reverse" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 218 - .{ .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 = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 219 - .{ .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 = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 220 - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRepoOplog", .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 = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 221 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifyWrite", .group = "space", .auth = "experimental service", .summary = "Notify a space owner or syncing service of a permissioned data write.", .body = &.{ "space", "repo", "rev" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 216 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.createRecord", .group = "space", .auth = "experimental bearer", .summary = "Create a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey", "validate", "record" }, .notes = permissioned_data_note }, 217 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.putRecord", .group = "space", .auth = "experimental bearer", .summary = "Create or update a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey", "validate", "record" }, .notes = permissioned_data_note }, 218 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.deleteRecord", .group = "space", .auth = "experimental bearer", .summary = "Delete a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey" }, .notes = permissioned_data_note }, 219 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.applyWrites", .group = "space", .auth = "experimental bearer", .summary = "Apply a batch of writes inside a permissioned data space.", .body = &.{ "space", "repo", "validate", "writes" }, .notes = permissioned_data_note }, 220 + .{ .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 }, 221 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRecords", .group = "space", .auth = "experimental bearer or space credential", .summary = "List record keys and CIDs in a permissioned data space.", .params = &.{ "space", "repo", "collection", "limit", "cursor", "reverse" }, .notes = permissioned_data_note }, 222 + .{ .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 }, 223 + .{ .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 }, 224 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRepoOplog", .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 }, 225 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifyWrite", .group = "space", .auth = "experimental service", .summary = "Notify a space owner or syncing service of a permissioned data write.", .body = &.{ "space", "repo", "rev" }, .notes = permissioned_data_note }, 222 226 223 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental member grant", .summary = "Exchange a member grant for a space credential.", .body = &.{ "space", "notifyEndpoint" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 224 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifySpaceDeleted", .group = "space", .auth = "experimental service", .summary = "Notify a member PDS or syncing service that a space was deleted.", .body = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 227 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental member grant", .summary = "Exchange a member grant for a space credential.", .body = &.{ "space", "notifyEndpoint" }, .notes = permissioned_data_note }, 228 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifySpaceDeleted", .group = "space", .auth = "experimental service", .summary = "Notify a member PDS or syncing service that a space was deleted.", .body = &.{"space"}, .notes = permissioned_data_note }, 225 229 }; 226 230 227 231 pub fn route(method: httpz.Method, target: []const u8) Route { ··· 301 305 try std.testing.expectEqual(Route.refresh_session, route(.POST, "/xrpc/com.atproto.server.refreshSession")); 302 306 try std.testing.expectEqual(Route.get_session, route(.GET, "/xrpc/com.atproto.server.getSession")); 303 307 try std.testing.expectEqual(Route.get_service_auth, route(.GET, "/xrpc/com.atproto.server.getServiceAuth?aud=did%3Aplc%3Aservice")); 308 + try std.testing.expectEqual(Route.admin_update_subject_status, route(.POST, "/xrpc/com.atproto.admin.updateSubjectStatus")); 304 309 try std.testing.expectEqual(Route.app_preferences_get, route(.GET, "/xrpc/app.bsky.actor.getPreferences")); 305 310 try std.testing.expectEqual(Route.app_preferences_put, route(.POST, "/xrpc/app.bsky.actor.putPreferences")); 306 311 try std.testing.expectEqual(Route.repo_list_records, route(.GET, "/xrpc/com.atproto.repo.listRecords?repo=alice.test&collection=app.bsky.feed.post"));
+1
src/http/server.zig
··· 104 104 .create_invite_code => try atproto_server.createInviteCode(request), 105 105 .create_invite_codes => try atproto_server.createInviteCodes(request), 106 106 .get_account_invite_codes => try atproto_server.getAccountInviteCodes(request), 107 + .admin_update_subject_status => try atproto_server.updateSubjectStatus(request), 107 108 .list_app_passwords => try atproto_server.listAppPasswords(request), 108 109 .create_app_password => try atproto_server.createAppPassword(request), 109 110 .revoke_app_password => try atproto_server.revokeAppPassword(request),
+187 -32
src/storage/store.zig
··· 25 25 InvalidRepoPath, 26 26 InvalidDagCbor, 27 27 InvalidRefreshSession, 28 + InvalidAccountStatus, 28 29 StoreNotInitialized, 30 + }; 31 + 32 + pub const AccountStatus = enum { 33 + active, 34 + takendown, 35 + suspended, 36 + deactivated, 37 + deleted, 38 + 39 + pub fn asString(self: AccountStatus) []const u8 { 40 + return switch (self) { 41 + .active => "active", 42 + .takendown => "takendown", 43 + .suspended => "suspended", 44 + .deactivated => "deactivated", 45 + .deleted => "deleted", 46 + }; 47 + } 48 + 49 + pub fn parse(raw: []const u8) AccountStatus { 50 + if (std.mem.eql(u8, raw, "takendown")) return .takendown; 51 + if (std.mem.eql(u8, raw, "suspended")) return .suspended; 52 + if (std.mem.eql(u8, raw, "deactivated")) return .deactivated; 53 + if (std.mem.eql(u8, raw, "deleted")) return .deleted; 54 + return .active; 55 + } 56 + 57 + pub fn isActive(self: AccountStatus) bool { 58 + return self == .active; 59 + } 29 60 }; 30 61 31 62 pub const Record = struct { ··· 529 560 530 561 const actual_limit = if (limit == 0) 24 else @min(limit, 100); 531 562 var rows = try conn.rows( 532 - \\SELECT a.handle, a.did, a.activated_at, a.deactivated_at, COALESCE(c.rev, ''), COUNT(r.uri) 563 + \\SELECT a.handle, a.did, a.account_status, COALESCE(c.rev, ''), COUNT(r.uri) 533 564 \\FROM accounts a 534 565 \\LEFT JOIN ( 535 566 \\ SELECT c1.did, c1.rev ··· 553 584 try residents.append(allocator, .{ 554 585 .handle = try allocator.dupe(u8, row.text(0)), 555 586 .did = try allocator.dupe(u8, row.text(1)), 556 - .active = row.nullableInt(2) != null and row.nullableInt(3) == null, 557 - .rev = try allocator.dupe(u8, row.text(4)), 558 - .record_count = @intCast(row.int(5)), 587 + .active = AccountStatus.parse(row.text(2)).isActive(), 588 + .rev = try allocator.dupe(u8, row.text(3)), 589 + .record_count = @intCast(row.int(4)), 559 590 }); 560 591 } 561 592 if (rows.err) |err| return err; ··· 774 805 try conn.execNoArgs("BEGIN IMMEDIATE"); 775 806 errdefer conn.execNoArgs("ROLLBACK") catch {}; 776 807 try conn.exec( 777 - \\INSERT INTO accounts (did, handle, email, password_hash, activated_at, signing_key_type, signing_key) 778 - \\VALUES (?, ?, ?, ?, CASE WHEN ? THEN unixepoch() ELSE NULL END, 'secp256k1', ?) 779 - , .{ did, handle, email, password_hash, activated, zqlite.blob(&signing_key) }); 808 + \\INSERT INTO accounts (did, handle, email, password_hash, activated_at, account_status, signing_key_type, signing_key) 809 + \\VALUES (?, ?, ?, ?, CASE WHEN ? THEN unixepoch() ELSE NULL END, CASE WHEN ? THEN 'active' ELSE 'deactivated' END, 'secp256k1', ?) 810 + , .{ did, handle, email, password_hash, activated, activated, zqlite.blob(&signing_key) }); 780 811 if (invite_code) |code| { 781 812 try recordInviteUseLocked(code, did); 782 813 } ··· 934 965 defer db_mutex.unlock(store_io); 935 966 try requireInitialized(); 936 967 if (active) { 968 + const status = try accountStatusLocked(did); 969 + if (status != .active and status != .deactivated) return Error.InvalidAccountStatus; 937 970 try conn.exec( 938 971 \\UPDATE accounts 939 - \\SET activated_at = COALESCE(activated_at, unixepoch()), 940 - \\ deactivated_at = NULL 972 + \\SET account_status = 'active', 973 + \\ activated_at = COALESCE(activated_at, unixepoch()) 941 974 \\WHERE did = ? 942 975 , .{did}); 943 976 } else { 944 977 try conn.exec( 945 978 \\UPDATE accounts 946 - \\SET deactivated_at = unixepoch() 979 + \\SET account_status = 'deactivated', 980 + \\ activated_at = COALESCE(activated_at, unixepoch()) 947 981 \\WHERE did = ? 948 982 , .{did}); 949 983 } 950 984 } 951 985 952 - pub fn sequenceAccountEvent(allocator: std.mem.Allocator, did: []const u8, active: bool) !void { 986 + pub fn setAccountTakendown(did: []const u8, applied: bool, status_ref: ?[]const u8) !void { 987 + db_mutex.lockUncancelable(store_io); 988 + defer db_mutex.unlock(store_io); 989 + try requireInitialized(); 990 + if (applied) { 991 + try conn.execNoArgs("BEGIN IMMEDIATE"); 992 + errdefer conn.execNoArgs("ROLLBACK") catch {}; 993 + try conn.exec( 994 + \\UPDATE accounts 995 + \\SET account_status = 'takendown', 996 + \\ account_status_ref = ? 997 + \\WHERE did = ? 998 + , .{ status_ref, did }); 999 + try revokeAccountTokensLocked(did); 1000 + try conn.execNoArgs("COMMIT"); 1001 + } else { 1002 + try conn.exec( 1003 + \\UPDATE accounts 1004 + \\SET account_status = 'active', 1005 + \\ account_status_ref = NULL, 1006 + \\ activated_at = COALESCE(activated_at, unixepoch()) 1007 + \\WHERE did = ? AND account_status = 'takendown' 1008 + , .{did}); 1009 + } 1010 + } 1011 + 1012 + pub fn accountStatus(did: []const u8) !AccountStatus { 1013 + db_mutex.lockUncancelable(store_io); 1014 + defer db_mutex.unlock(store_io); 1015 + try requireInitialized(); 1016 + return accountStatusLocked(did); 1017 + } 1018 + 1019 + pub fn sequenceAccountEvent(allocator: std.mem.Allocator, did: []const u8, status: AccountStatus) !void { 953 1020 db_mutex.lockUncancelable(store_io); 954 1021 defer db_mutex.unlock(store_io); 955 1022 try requireInitialized(); 956 1023 const seq = try nextSeqLocked(); 957 - const frame = try accountEventFrame(allocator, seq, did, active); 1024 + const frame = try accountEventFrame(allocator, seq, did, status); 958 1025 try insertSeqEventLocked(seq, did, "", frame); 959 1026 eventlog.publish(seq); 960 1027 } ··· 2527 2594 "SELECT COUNT(*) FROM repo_blocks WHERE did = ?", 2528 2595 did, 2529 2596 ); 2530 - const active = try accountActiveLocked(did); 2597 + const status = try accountStatusLocked(did); 2598 + const active = status.isActive(); 2531 2599 const root = try latestRootLocked(allocator, did); 2532 2600 2533 2601 return std.fmt.allocPrint( 2534 2602 allocator, 2535 - "{{\"activated\":{},\"validDid\":{},\"repoCommit\":{f},\"repoRev\":{f},\"repoBlocks\":{d},\"indexedRecords\":{d},\"privateStateValues\":0,\"expectedBlobs\":{d},\"importedBlobs\":{d}}}", 2603 + "{{\"activated\":{},\"validDid\":{},\"status\":{f},\"repoCommit\":{f},\"repoRev\":{f},\"repoBlocks\":{d},\"indexedRecords\":{d},\"privateStateValues\":0,\"expectedBlobs\":{d},\"importedBlobs\":{d}}}", 2536 2604 .{ 2537 2605 active, 2538 2606 active, 2607 + std.json.fmt(status.asString(), .{}), 2539 2608 std.json.fmt(root.cid, .{}), 2540 2609 std.json.fmt(root.rev, .{}), 2541 2610 block_count, ··· 2666 2735 const parsed_cursor = try parseRepoListCursor(cursor); 2667 2736 var rows = if (parsed_cursor) |after| 2668 2737 try conn.rows( 2669 - \\SELECT a.did, c.cid, c.rev, a.activated_at, a.deactivated_at, a.created_at 2738 + \\SELECT a.did, c.cid, c.rev, a.account_status, a.created_at 2670 2739 \\FROM accounts a 2671 2740 \\JOIN ( 2672 2741 \\ SELECT did, MAX(seq) AS seq ··· 2680 2749 , .{ after.created_at, after.created_at, after.did, @as(i64, @intCast(actual_limit)) }) 2681 2750 else 2682 2751 try conn.rows( 2683 - \\SELECT a.did, c.cid, c.rev, a.activated_at, a.deactivated_at, a.created_at 2752 + \\SELECT a.did, c.cid, c.rev, a.account_status, a.created_at 2684 2753 \\FROM accounts a 2685 2754 \\JOIN ( 2686 2755 \\ SELECT did, MAX(seq) AS seq ··· 2704 2773 while (rows.next()) |row| { 2705 2774 const did = row.text(0); 2706 2775 last_did = try allocator.dupe(u8, did); 2707 - last_created_at = row.int(5); 2708 - const active = row.nullableInt(3) != null and row.nullableInt(4) == null; 2776 + last_created_at = row.int(4); 2777 + const status = AccountStatus.parse(row.text(3)); 2778 + const active = status.isActive(); 2709 2779 try json.beginObject(); 2710 2780 try json.objectField("did"); 2711 2781 try json.write(did); ··· 2717 2787 try json.write(active); 2718 2788 if (!active) { 2719 2789 try json.objectField("status"); 2720 - try json.write("deactivated"); 2790 + try json.write(status.asString()); 2721 2791 } 2722 2792 try json.endObject(); 2723 2793 } ··· 2799 2869 db_mutex.lockUncancelable(store_io); 2800 2870 defer db_mutex.unlock(store_io); 2801 2871 try requireInitialized(); 2802 - const root = try latestRootLocked(allocator, did); 2803 - const active = try accountActiveLocked(did); 2872 + const status = try accountStatusLocked(did); 2873 + const active = status.isActive(); 2874 + const root = if (active) try latestRootLocked(allocator, did) else null; 2804 2875 var out: std.Io.Writer.Allocating = .init(allocator); 2805 2876 defer out.deinit(); 2806 2877 var json: std.json.Stringify = .{ .writer = &out.writer }; ··· 2811 2882 try json.write(active); 2812 2883 if (!active) { 2813 2884 try json.objectField("status"); 2814 - try json.write("deactivated"); 2885 + try json.write(status.asString()); 2886 + } else { 2887 + try json.objectField("rev"); 2888 + try json.write(root.?.rev); 2815 2889 } 2816 - try json.objectField("rev"); 2817 - try json.write(root.rev); 2818 2890 try json.endObject(); 2819 2891 return out.toOwnedSlice(); 2820 2892 } ··· 3618 3690 fn migrate() !void { 3619 3691 inline for (schema_statements) |sql| try conn.execNoArgs(sql); 3620 3692 try migratePermissionedDataTables(); 3693 + const had_deactivated_at = try accountsColumnExistsLocked("deactivated_at"); 3621 3694 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email TEXT") catch {}; 3622 3695 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email_confirmed_at INTEGER") catch {}; 3623 3696 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code TEXT") catch {}; ··· 3625 3698 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN pending_email TEXT") catch {}; 3626 3699 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN invites_disabled INTEGER NOT NULL DEFAULT 0") catch {}; 3627 3700 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN activated_at INTEGER") catch {}; 3628 - conn.execNoArgs("ALTER TABLE accounts ADD COLUMN deactivated_at INTEGER") catch {}; 3701 + conn.execNoArgs("ALTER TABLE accounts ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'") catch {}; 3702 + conn.execNoArgs("ALTER TABLE accounts ADD COLUMN account_status_ref TEXT") catch {}; 3703 + conn.execNoArgs("UPDATE accounts SET account_status = 'deactivated' WHERE activated_at IS NULL AND account_status = 'active'") catch {}; 3704 + if (had_deactivated_at) { 3705 + conn.execNoArgs("UPDATE accounts SET account_status = 'deactivated' WHERE deactivated_at IS NOT NULL AND account_status = 'active'") catch {}; 3706 + } 3629 3707 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key_type TEXT") catch {}; 3630 3708 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key BLOB") catch {}; 3631 3709 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN login_hint TEXT") catch {}; ··· 3669 3747 \\ applied_at INTEGER NOT NULL DEFAULT (unixepoch()) 3670 3748 \\) 3671 3749 ); 3750 + } 3751 + 3752 + fn accountsColumnExistsLocked(column: []const u8) !bool { 3753 + var rows = try conn.rows("PRAGMA table_info(accounts)", .{}); 3754 + defer rows.deinit(); 3755 + while (rows.next()) |row| { 3756 + if (std.mem.eql(u8, row.text(1), column)) return true; 3757 + } 3758 + if (rows.err) |err| return err; 3759 + return false; 3672 3760 } 3673 3761 3674 3762 fn migrationApplied(name: []const u8) !bool { ··· 4918 5006 return commitEventFrameFromCar(allocator, seq, did, commit_cid, rev, null, null, car_bytes, &.{}); 4919 5007 } 4920 5008 4921 - fn accountEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, active: bool) ![]const u8 { 5009 + fn accountEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, status: AccountStatus) ![]const u8 { 4922 5010 const header = try eventHeader(allocator, "#account"); 4923 - const body = if (active) 5011 + const body = if (status.isActive()) 4924 5012 try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ 4925 5013 .{ .key = "seq", .value = .{ .unsigned = seq } }, 4926 5014 .{ .key = "did", .value = .{ .text = did } }, ··· 4932 5020 .{ .key = "seq", .value = .{ .unsigned = seq } }, 4933 5021 .{ .key = "did", .value = .{ .text = did } }, 4934 5022 .{ .key = "active", .value = .{ .boolean = false } }, 4935 - .{ .key = "status", .value = .{ .text = "deactivated" } }, 5023 + .{ .key = "status", .value = .{ .text = status.asString() } }, 4936 5024 .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, 4937 5025 } }); 4938 5026 return joinFrame(allocator, header, body); ··· 5064 5152 } 5065 5153 5066 5154 fn accountActiveLocked(did: []const u8) !bool { 5155 + return (try accountStatusLocked(did)).isActive(); 5156 + } 5157 + 5158 + fn accountStatusLocked(did: []const u8) !AccountStatus { 5067 5159 const row = try conn.row( 5068 - \\SELECT activated_at, deactivated_at 5160 + \\SELECT account_status 5069 5161 \\FROM accounts 5070 5162 \\WHERE did = ? 5071 5163 , .{did}); 5072 - if (row == null) return false; 5164 + if (row == null) return Error.RepoNotFound; 5073 5165 defer row.?.deinit(); 5074 - return row.?.nullableInt(0) != null and row.?.nullableInt(1) == null; 5166 + return AccountStatus.parse(row.?.text(0)); 5167 + } 5168 + 5169 + fn revokeAccountTokensLocked(did: []const u8) !void { 5170 + try conn.exec( 5171 + \\UPDATE session_tokens 5172 + \\SET revoked_at = unixepoch(), updated_at = unixepoch() 5173 + \\WHERE did = ? AND revoked_at IS NULL 5174 + , .{did}); 5175 + try conn.exec( 5176 + \\UPDATE oauth_tokens 5177 + \\SET revoked_at = unixepoch() 5178 + \\WHERE did = ? AND revoked_at IS NULL 5179 + , .{did}); 5075 5180 } 5076 5181 5077 5182 fn cidForJson(allocator: std.mem.Allocator, json: []const u8) ![]const u8 { ··· 5185 5290 \\ pending_email TEXT, 5186 5291 \\ invites_disabled INTEGER NOT NULL DEFAULT 0, 5187 5292 \\ activated_at INTEGER, 5188 - \\ deactivated_at INTEGER, 5293 + \\ account_status TEXT NOT NULL DEFAULT 'active', 5294 + \\ account_status_ref TEXT, 5189 5295 \\ signing_key_type TEXT, 5190 5296 \\ signing_key BLOB, 5191 5297 \\ password_hash TEXT NOT NULL, ··· 6345 6451 const inactive_sessions = try listSessionsForAccount(allocator, account.did, false, 50); 6346 6452 try std.testing.expectEqual(@as(usize, 1), inactive_sessions.len); 6347 6453 try std.testing.expect(!inactive_sessions[0].active); 6454 + } 6455 + 6456 + test "account takedown sets precise sync status and revokes tokens" { 6457 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 6458 + defer arena.deinit(); 6459 + const allocator = arena.allocator(); 6460 + 6461 + try init(std.Options.debug_io, ":memory:"); 6462 + defer close(); 6463 + 6464 + const account = try createAccount( 6465 + allocator, 6466 + "status.test", 6467 + "status@test.com", 6468 + "password", 6469 + "did:plc:status", 6470 + true, 6471 + ); 6472 + var record_json = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"hello\"}", .{}); 6473 + defer record_json.deinit(); 6474 + _ = try create(allocator, account, "app.bsky.feed.post", "3jtest", record_json.value); 6475 + 6476 + _ = try createSessionTokenRow( 6477 + allocator, 6478 + account.did, 6479 + "status-access", 6480 + "status-refresh", 6481 + 4102444800, 6482 + 4102444800, 6483 + "password", 6484 + null, 6485 + null, 6486 + ); 6487 + try std.testing.expect(try sessionTokenIsActive(account.did, "status-access", "com.atproto.access")); 6488 + 6489 + try setAccountTakendown(account.did, true, "mod-ref-1"); 6490 + try std.testing.expectEqual(AccountStatus.takendown, try accountStatus(account.did)); 6491 + try std.testing.expect(!try sessionTokenIsActive(account.did, "status-access", "com.atproto.access")); 6492 + 6493 + const status_json = try writeRepoStatusJson(allocator, account.did); 6494 + try std.testing.expect(std.mem.indexOf(u8, status_json, "\"active\":false") != null); 6495 + try std.testing.expect(std.mem.indexOf(u8, status_json, "\"status\":\"takendown\"") != null); 6496 + try std.testing.expect(std.mem.indexOf(u8, status_json, "\"rev\"") == null); 6497 + 6498 + const frame = try accountEventFrame(allocator, 1, account.did, .takendown); 6499 + try std.testing.expect(std.mem.indexOf(u8, frame, "takendown") != null); 6500 + 6501 + try setAccountTakendown(account.did, false, null); 6502 + try std.testing.expectEqual(AccountStatus.active, try accountStatus(account.did)); 6348 6503 } 6349 6504 6350 6505 test "app passwords are durable matchable and revoke their sessions" {