~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
766 lines · 42.8 KB Raw History
// @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 {};