chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/core/ports - Port interfaces for hexagonal architecture
/**
* @typedef {Object} RecordResult
* @property {string} uri
* @property {string} cid
* @property {Uint8Array|ArrayBuffer} value
*/
/**
* @typedef {Object} EventResult
* @property {number} seq
* @property {string} commit_cid
* @property {Uint8Array} evt
*/
/**
* @typedef {Object} ActorRecord
* @property {string} did
* @property {string|null} handle
* @property {number} createdAt
*/
/**
* OAuth request data stored during authorization flow.
* Shape varies depending on flow stage (PAR, authorization, code exchange).
* @typedef {Object} OAuthRequestData
* @property {string} [clientId] - OAuth client ID
* @property {string|null} [redirectUri] - Redirect URI
* @property {string|null} [scope] - OAuth scope
* @property {string|null} [codeChallenge] - PKCE code challenge
* @property {string|null} [codeChallengeMethod] - PKCE method (S256)
* @property {string|null} [dpopJkt] - DPoP key thumbprint
* @property {string|null} [state] - OAuth state parameter
* @property {string|null} [responseMode] - Response mode (query/fragment)
* @property {string|null} [loginHint] - Login hint
* @property {string} [code] - Authorization code (after approval)
* @property {string} [did] - User DID (after approval)
* @property {string} [requestUri] - For code mapping lookup
* @property {Object} [clientMetadata] - Client metadata
*/
/**
* OAuth token data for refresh tokens.
* @typedef {Object} OAuthTokenData
* @property {string} did - User DID
* @property {string} [clientId] - OAuth client ID
* @property {string} [scope] - OAuth scope
* @property {string} [dpopJkt] - DPoP key thumbprint
* @property {number} [createdAt] - Token creation timestamp
* @property {number} [updatedAt] - Last time the session was touched: its
* authorization or its most recent refresh. Absent on tokens written before
* this field existed, where createdAt stands in.
* @property {string|null} [userAgent] - Device that opened the session
* @property {string} [tokenId] - Set by listOAuthTokensByDid only: the storage
* key, so a listed session can be revoked without a second lookup. This is
* the refresh token itself — never put it in a response.
* @property {string} [rotatedTo] - On a refresh token just used: the one that
* replaced it, which it answers with until that one is used in turn. A
* record with this is spent, not a session of its own.
* @property {string} [replaced] - The spent token this one replaced, dropped
* at the next refresh so a session keeps one of them at a time.
*/
/**
* The account's email address and its verification state. A pending change is
* held here until the code sent to the new address comes back, so an address
* only becomes the account's once it has been proven reachable.
* @typedef {Object} EmailState
* @property {string|null} address - The confirmed address, if any
* @property {boolean} verified - Whether `address` completed a code exchange
* @property {EmailPending|null} [pending] - An address awaiting its code
*/
/**
* @typedef {Object} EmailPending
* @property {string} address - The address a code was sent to
* @property {string} codeHash - HMAC of the code; the code itself is not stored
* @property {number} expiresAt - Epoch ms after which the code is dead
* @property {number} [sentAt] - Epoch ms the code was last sent, for resend cooldown
*/
/**
* An account password the user set through the account pages, stored as a
* hash. The seed hash names the configured password (PDS_PASSWORD) this
* change was made under: a configured password that no longer matches the
* seed is an operator reset and wins over the stored hash.
* @typedef {Object} AccountPasswordState
* @property {string} hash - PBKDF2 hash of the password, keyed on the DID
* @property {string|null} seedHash - Hash of the configured password at change time, null when none was set
* @property {string} changedAt - ISO 8601 datetime
*/
/**
* A confirmation code mailed before a PLC operation is signed. Only its HMAC
* is kept, so the stored value cannot authorize anything by itself.
* @typedef {Object} PlcToken
* @property {string} codeHash - HMAC of the code
* @property {number} expiresAt - Epoch ms after which the code is dead
*/
/**
* Sends mail on the server's behalf. Optional: with no emailer configured a
* PDS still stores an address, it just cannot prove anyone can read it.
*
* @typedef {Object} EmailPort
* @property {(message: {to: string, subject: string, text: string, html?: string}) => Promise<void>} send
*/
/**
* A stored app password. The password itself is never returned — only its
* hash is kept, so listing them cannot leak credentials.
* @typedef {Object} AppPasswordRecord
* @property {string} name - User-supplied label, unique per account
* @property {boolean} privileged - Granted access to sensitive account state
* @property {string} createdAt - ISO 8601 datetime
*/
/**
* @typedef {Object} SharedStoragePort
*
* // Actor/handle registry
* @property {(did: string) => Promise<ActorRecord|null>} getActor
* @property {(handle: string) => Promise<string|null>} resolveHandle - Returns DID or null
* @property {(did: string, handle: string|null, createdAt: number) => Promise<void>} putActor
* @property {(did: string) => Promise<void>} deleteActor
*
* // OAuth requests
* @property {(requestUri: string) => Promise<OAuthRequestData|null>} getOAuthRequest
* @property {(requestUri: string, data: OAuthRequestData, expiresAt: number) => Promise<void>} putOAuthRequest
* @property {(requestUri: string) => Promise<void>} deleteOAuthRequest
*
* // OAuth tokens
* @property {(tokenId: string) => Promise<OAuthTokenData|null>} getOAuthToken
* @property {(tokenId: string, data: OAuthTokenData) => Promise<void>} putOAuthToken
* @property {(tokenId: string) => Promise<void>} deleteOAuthToken
* @property {(did: string) => Promise<OAuthTokenData[]>} listOAuthTokensByDid
*
* // DPoP jti tracking
* @property {(jti: string, expiresAt: number) => Promise<boolean>} checkAndStoreDpopJti
* @property {() => Promise<void>} cleanupExpiredDpopJtis
*
* // App passwords
* @property {(did: string, name: string, hash: string, privileged: boolean, createdAt: string) => Promise<boolean>} putAppPassword - False if the name is already taken
* @property {(did: string, hash: string) => Promise<AppPasswordRecord|null>} getAppPasswordByHash
* @property {(did: string) => Promise<AppPasswordRecord[]>} listAppPasswords
* @property {(did: string, name: string) => Promise<boolean>} deleteAppPassword - False if no such name
*
* // Sessions opened by password login, keyed by the refresh token's jti
* @property {(session: SessionRecord) => Promise<void>} putSession
* @property {(jti: string) => Promise<SessionRecord|null>} getSession
* @property {(did: string) => Promise<SessionRecord[]>} listSessions
* @property {(jti: string) => Promise<boolean>} deleteSession - False if no such session
* @property {(jti: string, expiresAt: number, nextJti: string) => Promise<boolean>} [spendSession] -
* Mark a session's token spent by a rotation, shortening its expiry and
* naming its successor — but only if no other rotation has already named a
* different one. False means this caller lost that race and must defer to
* the successor already recorded. A store that cannot do this atomically may
* leave it out, at the cost of two simultaneous refreshes forking a session.
*
* // Passkeys
* @property {(record: PasskeyRecord) => Promise<boolean>} putPasskey - False if the credential is already registered
* @property {(did: string) => Promise<PasskeyRecord[]>} listPasskeys
* @property {(credentialId: string) => Promise<PasskeyRecord|null>} getPasskey - Looked up without a DID: a sign-in knows only the credential
* @property {(did: string, credentialId: string) => Promise<boolean>} deletePasskey - False if no such credential
* @property {(credentialId: string, signCount: number, lastUsedAt: string) => Promise<void>} touchPasskey
*/
/**
* One login opened with the account password or an app password. The tokens
* themselves are stateless JWTs; this row is what makes a session listable
* and revocable.
* @typedef {Object} SessionRecord
* @property {string} jti - The current refresh token's id
* @property {string} did
* @property {string} label - What opened it: the account password, or an app password's name
* @property {string} scope - The session's access level
* @property {string|null} userAgent - Client's User-Agent at sign-in, truncated
* @property {string} createdAt - ISO 8601 datetime
* @property {string|null} refreshedAt - ISO 8601 datetime of the last rotation
* @property {number} [expiresAt] - The refresh token's expiry (epoch seconds).
* A store that can expire rows should honor it; absent on legacy rows.
* @property {string|null} [nextJti] - Set once this row's token has been spent
* by a rotation: the id its replacement was minted under. The row is kept,
* with its expiry shortened to the grace period, so that a client replaying
* the spent token is handed the same replacement rather than signed out. A
* row carrying this is no longer a login in its own right.
*/
/**
* A registered passkey. The public key is stored as base64url SPKI, the form
* WebCrypto imports directly.
* @typedef {Object} PasskeyRecord
* @property {string} credentialId - base64url of the credential's raw id
* @property {string} did - The account the credential signs in as
* @property {string} name - User-supplied label
* @property {string} publicKey - base64url SPKI
* @property {number} algorithm - COSE algorithm identifier
* @property {number} signCount - Last counter the authenticator reported
* @property {string} createdAt - ISO 8601 datetime
* @property {string|null} lastUsedAt - ISO 8601 datetime, or null if unused
*/
/**
* Preferences the account app keeps server-side, so they follow the account
* across browsers and devices.
* @typedef {Object} AccountUiState
* @property {Record<string, string>} appAliases - Apps rows folded together:
* each key domain's records display under the value domain's row
* @property {string[]} dismissedAppPairs - Combine offers turned down, each
* as "from>to", so the Apps screen stops suggesting that pair
* @property {'system'|'light'|'dark'} theme - The console's color palette:
* follow the device's preference, or pin light or dark
* @property {'stable'|'canary'} releaseChannel - Which published releases the
* update check follows: tagged releases, or canary builds of the main branch
*/
/**
* @typedef {Object} BlobDetails
* @property {string} cid
* @property {string} mimeType
* @property {number} size
* @property {number} createdAt - Epoch milliseconds the blob was stored
* @property {number} time - Epoch ms the content entered the account: the
* earliest referencing record's stored `createdAt`, else that record's TID
* time, else `createdAt`. On a migrated repo this is the original post
* time, not the migration date.
* @property {boolean} timeFromRecord - Whether `time` came from a referencing
* record; false means it is only the blob's arrival time here.
* @property {string[]} records - AT-URIs of the records that reference it
*/
/**
* The broad media kind a mime type falls under: its type prefix, or `other`
* for anything outside the named four.
* @typedef {'image'|'video'|'audio'|'text'|'other'} BlobKind
*/
/**
* How a blob listing is narrowed and ordered. Filters compose (AND).
* @typedef {Object} BlobListOptions
* @property {BlobKind} [kind] - Only blobs of this media kind
* @property {string} [appAuthority] - Only blobs referenced by a record whose
* collection NSID starts with this authority (e.g. `app.bsky`)
* @property {boolean} [unused] - Only blobs no record references
* @property {'newest'|'largest'} [sort] - Order; `newest` when omitted
*/
/**
* @typedef {Object} BlobBucket
* @property {number} count
* @property {number} bytes
*/
/**
* Aggregate counts over all blobs, for filter chips and the storage bar.
* @typedef {Object} BlobFacets
* @property {BlobBucket} total
* @property {BlobBucket} unused - Blobs no record references
* @property {(BlobBucket & {kind: BlobKind})[]} kinds - Only kinds present
* @property {(BlobBucket & {authority: string})[]} apps - Per collection-NSID
* authority (e.g. `app.bsky`); a blob used by two apps counts once in each
*/
/**
* @typedef {Object} ActorStoragePort
* @property {(uri: string) => Promise<{cid: string, value: Uint8Array}|null>} getRecord
* @property {(collection: string, cursor: string|null, limit: number, reverse?: boolean) => Promise<{records: RecordResult[], cursor: string|null}>} listRecords - `reverse` lists newest rkey first
* @property {() => Promise<Array<{key: string, cid: string}>>} listAllRecords - List all records for MST building
* @property {(uri: string, cid: string, collection: string, rkey: string, value: Uint8Array) => Promise<void>} putRecord
* @property {(uri: string) => Promise<void>} deleteRecord
* @property {(cid: string) => Promise<Uint8Array|null>} getBlock
* @property {(cid: string, data: Uint8Array) => Promise<void>} putBlock
* @property {(cids: string[]) => Promise<void>} [deleteBlocks]
* Optional, with the three below: a store that has them drops the blocks
* each commit leaves unreachable, and getRepo reads its block table rather
* than walking the tree. Absent, every block is kept.
* @property {(cursor: string|null, limit: number) => Promise<{blocks: Array<{cid: string, data: Uint8Array}>, cursor: string|null}>} [listBlocks]
* Every stored block, in CID order, `limit` at a time after `cursor`.
* @property {(cids: string[]) => Promise<string[]>} [recordCidsAmong]
* Those of `cids` some record still has as its CID.
* @property {() => Promise<boolean>} [getBlocksCollected]
* Whether the block table holds only what the current commit reaches:
* false until the first sweep, and again after the tree is rebuilt or a
* repo imported.
* @property {(collected: boolean) => Promise<void>} [setBlocksCollected]
* @property {() => Promise<{seq: number, cid: string, rev: string}|null>} getLatestCommit
* @property {(seq: number, cid: string, rev: string, prev: string|null) => Promise<void>} putCommit
* @property {(cursor: number|null, limit: number) => Promise<{events: EventResult[], cursor: number|null}>} getEvents
* @property {(before: number, limit: number) => Promise<{seq: number, evt: Uint8Array}[]>} [getEventsBefore]
* @property {(seq: number, did: string, commitCid: string, evt: Uint8Array) => Promise<void>} putEvent
* @property {() => Promise<number>} getLatestSeq
* @property {(cid: string) => Promise<{mimeType: string, size: number, createdAt: number}|null>} getBlob
* @property {(cid: string, mimeType: string, size: number) => Promise<void>} putBlob
* @property {(cid: string) => Promise<void>} deleteBlob
* @property {(cursor: string|null, limit: number) => Promise<{cids: string[], cursor: string|null}>} listBlobs
* @property {(cursor: string|null, limit: number, options?: BlobListOptions) => Promise<{blobs: BlobDetails[], cursor: string|null}>} [listBlobDetails]
* Optional: blobs with their metadata and referencing records, filtered and
* ordered per the options. Absent, the account console has no Files page.
* @property {() => Promise<BlobFacets>} [getBlobFacets]
* Optional: aggregate blob counts by kind, app, and usage.
* @property {(olderThan: number) => Promise<string[]>} getOrphanedBlobs
* @property {(blobCid: string, recordUri: string, recordTime?: number|null) => Promise<void>} linkBlobToRecord
* `recordTime` is the referencing record's own timestamp (epoch ms) when it
* carries one; it dates the blob by the record's clock rather than arrival.
* @property {(recordUri: string) => Promise<void>} unlinkBlobsFromRecord
* @property {(blobCid: string) => Promise<string[]>} listBlobRecordUris
* Every record URI the link table holds for a blob. The public blob
* endpoints read these to refuse a blob only space records reference.
* @property {() => Promise<string|null>} getDid
* @property {(did: string) => Promise<void>} setDid
* @property {() => Promise<Uint8Array|null>} getPrivateKey
* @property {(key: Uint8Array) => Promise<void>} setPrivateKey
* @property {() => Promise<Uint8Array|null>} getKemKey - The account's `#atproto_kem` key, a 32-byte P-256 scalar
* @property {(key: Uint8Array) => Promise<void>} setKemKey
* @property {() => Promise<string|null>} getHandle
* @property {() => Promise<EmailState|null>} getEmail
* @property {(state: EmailState|null) => Promise<void>} setEmail
* @property {() => Promise<AccountPasswordState|null>} [getAccountPassword] - The stored account password. A store without these two methods pins the password to the deployment's configuration.
* @property {(state: AccountPasswordState|null) => Promise<void>} [setAccountPassword]
* @property {() => Promise<PlcToken|null>} getPlcToken
* @property {(token: PlcToken|null) => Promise<void>} setPlcToken
* @property {() => Promise<string|null>} getAccountStatus
* @property {(status: string) => Promise<void>} setAccountStatus
* @property {(handle: string) => Promise<void>} setHandle
* @property {() => Promise<Object[]>} getPreferences
* @property {(preferences: Object[]) => Promise<void>} setPreferences
* @property {() => Promise<import('./backup.js').BackupState|null>} [getBackupState]
* Optional: backup settings and run history, one JSON document. Absent, the
* backup endpoints report backups as unavailable.
* @property {(state: import('./backup.js').BackupState) => Promise<void>} [setBackupState]
* @property {() => Promise<AccountUiState|null>} [getAccountUiState]
* Optional: the account app's own preferences, one JSON document. Absent,
* the Apps screen never offers to combine rows.
* @property {(state: AccountUiState) => Promise<void>} [setAccountUiState]
*/
/**
* One streamed blob write. The CID arrives only at commit, after the last
* chunk: content addressing needs the whole hash. An adapter therefore stages
* bytes somewhere temporary and files them under the CID at commit. After
* commit or abort the writer takes no further calls.
*
* @typedef {Object} BlobWriter
* @property {(chunk: Uint8Array) => Promise<void>} write
* @property {(cid: string, mimeType: string) => Promise<void>} commit
* @property {() => Promise<void>} abort
*/
/**
* @typedef {Object} BlobPort
* @property {(did: string, cid: string) => Promise<{data: Uint8Array, mimeType: string}|null>} get
* @property {(did: string, cid: string, data: Uint8Array, mimeType: string) => Promise<void>} put
* @property {(did: string, cid: string) => Promise<void>} delete
* @property {(did: string) => Promise<BlobWriter>} [createWriter]
* Optional: write one blob without holding it in memory. Absent, core
* buffers the upload and calls `put`, bounded by the upload limit.
* @property {(did: string, cid: string, range?: {start: number, end: number}) => Promise<{body: ReadableStream<Uint8Array>, mimeType: string}|null>} [getStream]
* Optional: read one blob, or an inclusive byte range of it, without
* holding it in memory. The caller clamps the range to the blob's size.
* Absent, core buffers via `get` and slices.
*/
/**
* An incremental hash over one byte stream. `digest` is final: further
* updates throw.
*
* @typedef {Object} Hasher
* @property {(chunk: Uint8Array) => void|Promise<void>} update
* @property {() => Uint8Array|Promise<Uint8Array>} digest
*/
/**
* Makes one SHA-256 Hasher per call. Platform packages inject a native
* implementation (node:crypto, the Workers DigestStream); the core default is
* plain JavaScript and slower.
*
* @typedef {() => Hasher} HasherFactory
*/
/**
* Where backups land: any flat key/value object store — a directory, an S3
* bucket, an R2 bucket. Keys are `/`-separated paths chosen by core
* (`snapshots/<stamp>/repo.car`, `blobs/<cid>`); adapters treat them as
* opaque strings. `list` returns every key under a prefix — a single-account
* PDS holds at most a few thousand, so no cursor.
*
* @typedef {Object} BackupTargetPort
* @property {(key: string, data: Uint8Array, contentType?: string) => Promise<void>} put
* @property {(key: string, body: ReadableStream<Uint8Array>, contentType?: string) => Promise<void>} [putStream]
* Optional: write one object without holding it in memory, and without a
* size limit the target sets on a single request. Absent, core reads the
* blob whole and calls `put`.
* @property {(key: string) => Promise<Uint8Array|null>} get
* @property {(prefix: string) => Promise<string[]>} list
* @property {(key: string) => Promise<void>} delete
*/
/**
* @typedef {Object} WebSocketSender
* @property {(data: Uint8Array) => void} send - Send binary data to client
* @property {() => void} close - Close the connection
*/
/**
* @typedef {Object} WebSocketPort
* @property {(request: Request) => boolean} isUpgrade - Check if request is WebSocket upgrade
* @property {(request: Request, onConnect: (sender: WebSocketSender) => Promise<void>) => Response} upgrade - Upgrade connection and call onConnect with sender
* @property {(data: Uint8Array) => void} [broadcast] - Broadcast data to all connected clients
*/
/**
* @typedef {'valid' | 'unknown'} ValidationStatus
*/
/**
* Lexicon resolver port for validating records against schemas.
* Implementations may resolve schemas dynamically (DNS + fetch) or use static schemas.
*
* @typedef {Object} LexiconResolverPort
* @property {(collection: string, record: object, opts?: {strict?: boolean}) => Promise<ValidationStatus>} assertValid
* Resolves schema if needed, validates record.
* Returns 'valid' on success, 'unknown' if the record could not be held to a
* schema this PDS knows. Throws Error with descriptive message if the record
* is invalid against a known schema.
* `strict` reflects the caller's `validate: true`: require validation to
* happen, and enforce schemas resolved from the network too.
* @property {(nsid: string) => Promise<boolean>} [isPublished]
* Optional. Whether the NSID's lexicon is published (resolvable) via the protocol.
* @property {(nsid: string) => Promise<object|null>} [getLexicon]
* Optional. The resolved lexicon document for an NSID, or null if unresolvable.
*/
/**
* Verifies signatures against did:key identifiers. P-256 is always supported;
* secp256k1 support depends on an injected implementation.
*
* @typedef {Object} SignatureVerifierPort
* @property {(didKey: string, data: Uint8Array, sig: Uint8Array) => Promise<boolean>} verify
*/
/**
* Signs with secp256k1 account keys — the curve the reference PDS gives every
* account, and the only one some third-party services (Go feed generators,
* labelers) can verify. WebCrypto cannot sign secp256k1, so the
* implementation is injected like the verifier above; without one, this PDS
* signs with P-256 only. `sign` must return a low-S compact 64-byte (r || s)
* signature over the SHA-256 of `data`.
*
* @typedef {Object} Secp256k1SignerPort
* @property {() => Uint8Array} generatePrivateKey - New random 32-byte scalar
* @property {(privateKey: Uint8Array, compressed?: boolean) => Uint8Array} getPublicKey
* Public key for a scalar: 33-byte compressed (default) or 65-byte uncompressed.
* @property {(privateKey: Uint8Array, data: Uint8Array) => Promise<Uint8Array>} sign
*/
/**
* Resolves a DID to its DID document, or null when it cannot. Injected rather
* than imported so that @pdsjs/spaces stays dependency-free.
*
* `forceRefresh` asks an implementation that holds its answers to read past
* the one it holds. A caller verifying a signature passes it after the
* signature fails: a held document can name a key the issuer rotated away
* from. An implementation that never holds an answer ignores it.
*
* @typedef {(did: string, opts?: {forceRefresh?: boolean}) => Promise<Object|null>} DidResolverPort
*/
/**
* Read-only view of this account's spaces, for the account page.
*
* Injected the same way the space route table is: core never imports
* @pdsjs/spaces, so a deployment with permissioned data off has no spaces
* section and core has no idea the package exists.
*
* @typedef {Object} SpaceBrowserPort
* @property {() => Promise<SpaceRow[]>} listSpaces
* @property {(uri: string) => Promise<Array<{collection: string, count: number, lastRecordAt: number|null}>>} countRecords
* @property {(uri: string) => Promise<number>} countMembers
* @property {(uri: string) => Promise<SpaceMemberRow[]>} listMembers
* @property {(uri: string, collection: string, limit: number) => Promise<{records: Array<{rkey: string, cid: string, indexedAt: string, value: unknown}>, truncated: boolean}>} listRecords
* @property {() => Promise<Array<{space: string, collection: string, rkey: string, value: unknown}>>} listAllRecords
*/
/**
* A veto over record writes in the named collections, consulted before the
* write lands. Built by a feature package (branch protection comes from
* @pdsjs/git) and injected by the platform; core knows only the shape. A
* check that throws refuses the write with its message, and the error's
* `code` names the XRPC error.
* @typedef {Object} RecordWriteGuardPort
* @property {string[]} collections
* @property {(write: {space: string|null, collection: string, rkey: string, prevValue: unknown|null, nextValue: unknown|null}) => Promise<void>} check
*/
/**
* Space management for the account page, built by a platform package from
* @pdsjs/spaces; core never imports that package. The account page runs
* behind the owner's session, so these carry no scope checks of their own.
* @typedef {Object} SpaceAdminPort
* @property {(opts: {type: string, skey?: string}) => Promise<{uri: string}>} createSpace
* @property {(space: string, did: string, access: SpaceMemberAccess) => Promise<void>} putMember
* @property {(space: string, did: string) => Promise<void>} removeMember
* @property {(space: string, collection: string, rkey: string, record: Object) => Promise<void>} createRecord
* Write a record into an owned space, refusing an rkey that exists. The
* account page uses this to create a repository before its first push.
* @property {(space: string, collection: string, rkey: string, record: Object) => Promise<void>} putRecord
* Write a record into an owned space, creating or replacing. The account
* page uses this for repository settings.
* @property {(space: string, collection: string, rkey: string) => Promise<Object|null>} getRecord
* A record from an owned space, decoded raw rather than display-formatted,
* so a caller can rewrite it without corrupting blob references.
* @property {(space: string, collection: string, rkey: string) => Promise<void>} deleteRecord
* Remove a record from an owned space, refusing an rkey that is not there.
* The blobs it held are freed by the orphan sweep, which reads the space
* records for what they reference.
*/
/**
* One applied commit, as handed to the commit tap.
*
* @typedef {Object} CommitTapEvent
* @property {string} did
* @property {number} seq
* @property {string} rev
* @property {Array<{action: string, uri: string, cid: string|null}>} ops
*/
/**
* Called after each successful commit. Feature packages maintain derived
* state from this tap (materialized queries; eventually rules). Core logs
* and discards any error, so a subscriber can never fail a write.
*
* @typedef {(evt: CommitTapEvent) => Promise<void>|void} CommitTapPort
*/
/**
* What core hands an extension factory. Every function late-binds into the
* server, so a factory may run before the server's handlers exist and an
* extension may call back into the server at request time.
*
* @typedef {Object} ExtensionHost
* @property {ActorStoragePort} actorStorage - The account's storage behind the extension's collection scope: a record read or write naming a collection outside `PdsExtension.collections` is refused. Blocks, blobs, the event log and the account's own fields pass through.
* @property {BlobPort} blobs - Blob storage, unscoped: a blob belongs to the account, not to a collection
* @property {SpaceStoragePort} [spaceStorage] - Permissioned space storage behind the same scope, present when spaces are on. An extension whose records may be held in a space reads them here.
* @property {SpaceBrowserPort} [spaceBrowser] - Read-only view of the account's spaces behind the same scope, built by core over spaceStorage when spaces are on
* @property {DidResolverPort} [didResolver] - Resolves a DID to its document, when the platform injected one
* @property {SignatureVerifierPort} [verifier] - Verifies a signature against a did:key, when the platform injected one
* @property {() => Promise<{curve: 'p256'|'secp256k1', sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} getSigner - Signs with the account's own signing key, for an extension that signs commits of its own. Throws when the server has no key.
* @property {() => Promise<{privateKey: Uint8Array, didKey: string}|null>} getKemKey - The account's `#atproto_kem` key: its 32-byte P-256 scalar and did:key. Null while the account has none.
* @property {RecordWriteGuardPort} writeGuard - The server's composed write guard, the platform's and every extension's, read at write time. An extension that applies record writes of its own consults it.
* @property {string} [hostname]
* @property {string} plcUrl - The PLC directory this server resolves DIDs against
* @property {number} blobUploadLimit - Largest blob this server accepts, in bytes. An extension that splits a file across blobs sizes its parts to it.
* @property {boolean} readOnly
* @property {HasherFactory} [hasher] - The platform's SHA-256 factory; absent, an extension hashes in plain JavaScript
* @property {() => Promise<string|null>} getDid
* @property {(request: Request) => Promise<Response>} xrpc - The server's own fetch, for self-addressed XRPC calls. The call is anonymous: any Authorization, Cookie or DPoP header on the request is removed first.
* @property {(did: string, olderThan: number) => Promise<void>} reapOrphanedBlobs - Delete blobs no record references, created before `olderThan` (epoch ms). An extension that deletes a record calls it to free the bytes at once rather than at the hourly sweep.
* @property {(did: string, ops: Array<{action: string, uri: string, cid?: string}>) => Promise<{cid: string, rev: string}>} createCommit - Commits record ops to the public repo. Every op must name a collection in `PdsExtension.collections`; one that does not rejects the whole commit before it starts.
* @property {(request: Request, url: URL, handler: (did: string, body: Record<string, unknown>) => Promise<unknown>, options?: {write?: boolean, raw?: boolean}) => Promise<Response>} accountApi
* Answers a JSON request behind the owner's account session, the way the
* /account/api routes do: 401 without a session, 403 for a write on a
* read-only server, a thrown error as a 400.
* @property {(request: Request) => Promise<string|null>} readAccountSession - The DID behind the request's account-page session cookie, or null. For a route that answers in its own shape and takes the owner's session alongside a bearer token.
* @property {(request: Request) => Promise<unknown|Response>} readJsonBounded - The request's JSON body, or a 413 Response when the body exceeds the server's JSON limit.
* @property {(at: number) => Promise<void>} scheduleWakeup - Asks the platform to run the extensions' `tick` no later than `at` (epoch ms). On a platform that runs ticks on a short interval this does nothing.
* @property {(work: Promise<unknown>) => void} background - Runs `work` after the response is sent. The platform keeps the request alive until `work` settles. An error from `work` is logged.
*/
/**
* @typedef {Object} ExtensionRoute
* @property {string} [method] - HTTP method (defaults to GET)
* @property {'required'|'optional'} [auth] - Authentication requirement
* @property {boolean} [serviceAuth] - Also accepts a service auth token the account minted for this server, bound to the route's lexicon method
* @property {(request: Request, url: URL, auth: {did: string, scope: string}|null) => Promise<Response>|Response} handler
* Called unbound: an extension reaches the server through its host, not
* through `this`.
*/
/**
* A feature package mounted on the server. Core never imports a feature
* package; a platform package passes its factory in and core builds the
* extension against an ExtensionHost.
*
* @typedef {Object} PdsExtension
* @property {string} name - Names the extension in errors
* @property {string[]} [collections] - The record collections the extension reads and writes through its host, `['*']` for every collection. A call naming any other collection is refused. Absent, the extension may read and write no records at all.
* @property {Record<string, ExtensionRoute>} [routes] - Route table fragment, merged into the server's table. A path core already serves is refused at construction.
* @property {string[]} [prefixes] - Path prefixes `handle` answers under, `/npm` say. Required with `handle`. A prefix core serves under (`/xrpc`, `/oauth`, `/account`, `/.well-known`, or any route in its table) or that overlaps another extension's is refused at construction.
* @property {(request: Request, url: URL) => Promise<Response|null>} [handle] - Answers requests under `prefixes` before core dispatch, for surfaces outside XRPC (a registry, say). The extension owns the whole response: no CORS decoration, no DPoP nonce. A null return passes the request on.
* @property {(request: Request, url: URL) => Promise<Response|null>} [handleHost] - Answers requests addressed to a hostname other than the server's (a static site, say), after prefix handlers and before core dispatch. The extension owns the whole response. A null return passes the request on.
* @property {RecordWriteGuardPort} [writeGuard] - A veto over writes into the public repo, consulted alongside the platform's guard.
* @property {CommitTapPort} [onCommit] - Called after each successful commit. An error is logged and never fails the write.
* @property {Record<string, boolean>} [features] - Merged into the account overview's `features`, which the account application reads to show or hide its sections.
* @property {(count: (collection: string) => number) => Record<string, number>} [stats] - Merged into the account overview's `stats`, the counts the account application shows beside its sections. `count` answers how many records a collection holds across the public repo and every live space.
* @property {() => Promise<number|null>} [tick] - Background work, run by the platform on a short interval or at the times passed to `scheduleWakeup`. Answers the next time (epoch ms) it has work due, or null.
*/
/**
* @typedef {(host: ExtensionHost) => PdsExtension} ExtensionFactory
*/
/**
* @typedef {Object} SpaceRow
* @property {string} uri - space AT-URI
* @property {string} spaceDid - the space authority's DID, as carried in `uri`
* @property {string} spaceType - the space type NSID, as carried in `uri`
* @property {boolean} isOwner - whether this actor is the space authority
* @property {SpacePolicy|null} policy - the policy governing the space, with
* its defaults filled in. Null when this actor is not the authority.
* @property {string} createdAt - ISO timestamp
* @property {string|null} deletedAt - ISO timestamp, set by soft delete
*/
/**
* A space policy, as `com.atproto.space.createSpace` takes it: an open union
* whose `$type` names the policy (`com.atproto.personalspace.policy`,
* `com.atproto.memberspace.policy` or `com.atproto.managedspace.policy`) and
* whose other fields configure it.
*
* @typedef {{$type: string} & Record<string, unknown>} SpacePolicy
*/
/**
* What a member of a space may do. Both flags are host-internal state the
* authority consults when the memberspace policy governs the space.
*
* @typedef {Object} SpaceMemberAccess
* @property {boolean} read - whether the member may hold a space credential
* @property {boolean} write - whether the authority tracks the member's writes
*/
/**
* @typedef {SpaceMemberAccess & {did: string}} SpaceMemberRow
*/
/**
* @typedef {Object} SpaceRecordRow
* @property {string} collection
* @property {string} rkey
* @property {string} cid
* @property {Uint8Array} value - DAG-CBOR encoded record
* @property {string} repoRev - rev of the commit that wrote it
* @property {string} indexedAt - ISO timestamp
*/
/**
* A listed record. `value` is absent when the caller asked for metadata only.
* @typedef {Object} SpaceRecordListRow
* @property {string} collection
* @property {string} rkey
* @property {string} cid
* @property {Uint8Array} [value] - DAG-CBOR encoded record
* @property {string} repoRev
* @property {string} indexedAt
*/
/**
* @typedef {Object} SpaceOpRow
* @property {string} rev
* @property {number} idx - position within the rev, from 0
* @property {'create'|'update'|'delete'} action
* @property {string} collection
* @property {string} rkey
* @property {string|null} cid - null for deletes
* @property {string|null} prev - null for creates
*/
/**
* @typedef {Object} SpaceWriterRow
* @property {string} did
* @property {string} repoRev - the writer's repo rev, as last reported
* @property {Uint8Array} data - the writer's commit data, as last reported
* @property {string} spaceRev - where the authority sequenced this update
*/
/**
* A write notice a repo host has not yet delivered to the space's authority.
* @typedef {Object} SpaceNotifyRow
* @property {string} space
* @property {string} repoRev
* @property {Uint8Array} data
* @property {number} attempts - failed deliveries of this repoRev so far
* @property {string} nextAttemptAt - ISO timestamp
* @property {string} deadlineAt - ISO timestamp after which delivery stops
*/
/**
* A notice an authority has not yet delivered to a syncer registered for a
* space it deleted.
* @typedef {Object} SpaceDeletedNotifyRow
* @property {string} space
* @property {string} service - the syncer's service identifier
* @property {string} serviceEndpoint
* @property {number} attempts - failed deliveries so far
* @property {string} nextAttemptAt - ISO timestamp
* @property {string} deadlineAt - ISO timestamp: the registration's expiry
*/
/**
* @typedef {Object} SpaceRecipientRow
* @property {string} serviceDid
* @property {string} serviceEndpoint
* @property {string} expiresAt
*/
/**
* A single write within a commit.
* @typedef {Object} SpaceWrite
* @property {'create'|'update'|'delete'} action
* @property {string} collection
* @property {string} rkey
* @property {string|null} cid - null for deletes
* @property {Uint8Array|null} value - null for deletes
* @property {string|null} prev - null for creates
*/
/**
* Per-actor storage for permissioned spaces (proposal 0016).
*
* Separate from ActorStoragePort so that a deployment without spaces carries
* no space schema. Writes go through the single commit-shaped
* `commitSpaceWrite` rather than per-table methods, so transaction control
* stays inside the adapter instead of leaking through the port.
*
* @typedef {Object} SpaceStoragePort
* @property {(uri: string) => Promise<SpaceRow|null>} getSpace
* @property {(space: SpaceRow) => Promise<void>} putSpace
* @property {(uri: string, deletedAt: string) => Promise<void>} deleteSpace
* @property {(opts: {type?: string|null, did?: string|null, cursor?: string|null, limit?: number, excludeDeleted?: boolean}) => Promise<{spaces: SpaceRow[], cursor: string|null}>} listSpaces
* @property {(space: string, did: string, access: SpaceMemberAccess) => Promise<void>} putMember
* @property {(space: string, did: string) => Promise<void>} removeMember
* @property {(space: string, cursor: string|null, limit: number) => Promise<{members: SpaceMemberRow[], cursor: string|null}>} listMembers
* @property {(space: string, did: string) => Promise<SpaceMemberAccess|null>} getMember
* @property {(space: string) => Promise<void>} removeAllMembers
* @property {(space: string, collection: string, rkey: string) => Promise<SpaceRecordRow|null>} getSpaceRecord
* @property {(space: string, collection: string, rkey: string) => Promise<string|null>} getSpaceRecordCid
* @property {(space: string, opts: {collection?: string|null, cursor?: string|null, limit: number, reverse?: boolean, excludeValues?: boolean}) => Promise<{records: SpaceRecordListRow[], cursor: string|null}>} listSpaceRecords
* @property {(space: string) => Promise<Array<{collection: string, rkey: string, cid: string}>>} listAllSpaceRecords
* @property {(space: string, commit: {writes: SpaceWrite[], setHash: Uint8Array, rev: string, indexedAt: string}) => Promise<void>} commitSpaceWrite
* @property {(space: string) => Promise<{setHash: Uint8Array|null, rev: string|null}|null>} getSpaceRepo
* @property {(space: string, since: string|null, limit: number) => Promise<SpaceOpRow[]>} listSpaceOps
* @property {(space: string, did: string) => Promise<SpaceWriterRow|null>} getSpaceWriter
* @property {(space: string) => Promise<string|null>} getSpaceRevHead - the latest spaceRev allocated in the space
* @property {(space: string, did: string, repoRev: string, data: Uint8Array, spaceRev: string) => Promise<void>} putSpaceWriter
* @property {(space: string, did: string) => Promise<void>} removeSpaceWriter
* @property {(space: string, cursor: string|null, limit: number) => Promise<{writers: SpaceWriterRow[], cursor: string|null}>} listSpaceWriters - ascending spaceRev after `cursor`, exclusive; `cursor` is the last entry's spaceRev, null on an empty page
* @property {(space: string, jtis: string[], expiresAt: string) => Promise<void>} putCredentialRevocations - idempotent; also drops expired revocations
* @property {(space: string, jti: string) => Promise<boolean>} isCredentialRevoked
* @property {(row: Omit<SpaceNotifyRow, 'attempts'>) => Promise<void>} enqueueSpaceNotify - replaces any row for the space, with attempts reset
* @property {(now: string, limit: number) => Promise<SpaceNotifyRow[]>} listDueSpaceNotify
* @property {(space: string, repoRev: string, attempts: number, nextAttemptAt: string) => Promise<void>} rescheduleSpaceNotify - only while the row still holds repoRev
* @property {(space: string, repoRev: string) => Promise<void>} deleteSpaceNotify - only while the row still holds repoRev
* @property {(row: SpaceDeletedNotifyRow) => Promise<void>} enqueueSpaceDeletedNotify - replaces any row for the space and service
* @property {(now: string, limit: number) => Promise<SpaceDeletedNotifyRow[]>} listDueSpaceDeletedNotify
* @property {(space: string, service: string, attempts: number, nextAttemptAt: string) => Promise<void>} rescheduleSpaceDeletedNotify
* @property {(space: string, service: string) => Promise<void>} deleteSpaceDeletedNotify
* @property {() => Promise<string|null>} nextSpaceNotifyAt - the earliest nextAttemptAt of either kind of notice, or null
* @property {(space: string, serviceDid: string, serviceEndpoint: string, expiresAt: string) => Promise<void>} putCredentialRecipient
* @property {(space: string, serviceDid: string) => Promise<void>} deleteCredentialRecipient
* @property {(space: string) => Promise<SpaceRecipientRow[]>} listCredentialRecipients
*/
// Export empty object - types are defined via JSDoc
export {};