open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents atprotocol pds cloudflare
0

Configure Feed

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

rookery / src / directory.ts
19 kB 590 lines
1// SPDX-License-Identifier: MIT 2// Copyright (c) 2026 sol pbc 3 4import type { Env } from "./types"; 5 6const SCHEMA = ` 7CREATE TABLE IF NOT EXISTS accounts ( 8 did TEXT PRIMARY KEY, 9 handle TEXT NOT NULL UNIQUE, 10 do_id TEXT NOT NULL, 11 jwk_thumbprint TEXT, 12 active INTEGER NOT NULL DEFAULT 1, 13 created_at TEXT NOT NULL DEFAULT (datetime('now')) 14); 15`; 16 17const INDEX = "CREATE INDEX IF NOT EXISTS idx_accounts_handle ON accounts(handle);"; 18const THUMBPRINT_INDEX = "CREATE INDEX IF NOT EXISTS idx_accounts_thumbprint ON accounts(jwk_thumbprint);"; 19const INVITE_QUOTAS_SCHEMA = ` 20CREATE TABLE IF NOT EXISTS invite_quotas ( 21 did TEXT PRIMARY KEY, 22 quota INTEGER NOT NULL CHECK (quota >= 0) 23); 24`; 25const INVITES_SCHEMA = ` 26CREATE TABLE IF NOT EXISTS invites ( 27 token TEXT PRIMARY KEY, 28 minted_by TEXT, 29 minted_at TEXT NOT NULL DEFAULT (datetime('now')), 30 spent_by_did TEXT, 31 spent_at TEXT, 32 idempotency_key TEXT, 33 keeper_label TEXT, 34 CHECK ( 35 (spent_by_did IS NULL AND spent_at IS NULL) 36 OR (spent_by_did IS NOT NULL AND spent_at IS NOT NULL) 37 ) 38); 39`; 40const KEEPERS_SCHEMA = ` 41CREATE TABLE IF NOT EXISTS keepers ( 42 did TEXT PRIMARY KEY, 43 handle TEXT NOT NULL, 44 keeper_source TEXT NOT NULL DEFAULT 'operator', 45 verified INTEGER NOT NULL DEFAULT 0, 46 updated_at TEXT NOT NULL DEFAULT (datetime('now')) 47); 48`; 49const INVITES_MINTED_BY_INDEX = "CREATE INDEX IF NOT EXISTS idx_invites_minted_by ON invites(minted_by);"; 50const INVITES_MINTED_AT_INDEX = "CREATE INDEX IF NOT EXISTS idx_invites_minted_at ON invites(minted_at, token);"; 51// Partial unique index: only keyed (idempotent) mints are constrained; legacy 52// rows and un-keyed mints keep idempotency_key NULL and are excluded, so this is 53// backward-compatible with already-populated invites tables. 54const INVITES_IDEMPOTENCY_INDEX = 55 "CREATE UNIQUE INDEX IF NOT EXISTS idx_invites_idempotency_key ON invites(idempotency_key) WHERE idempotency_key IS NOT NULL;"; 56const CONFIG_SCHEMA = ` 57CREATE TABLE IF NOT EXISTS config ( 58 key TEXT PRIMARY KEY, 59 value TEXT NOT NULL, 60 updated_at TEXT NOT NULL DEFAULT (datetime('now')) 61); 62`; 63const CONFIG_SEED = "INSERT OR IGNORE INTO config (key, value) VALUES ('invite_quota_default', '3');"; 64const TAKEDOWNS_SCHEMA = ` 65CREATE TABLE IF NOT EXISTS takedowns ( 66 did TEXT NOT NULL, 67 handle TEXT NOT NULL, 68 actor TEXT NOT NULL, 69 records_deleted INTEGER NOT NULL, 70 blobs_deleted INTEGER NOT NULL, 71 collections TEXT NOT NULL, 72 taken_down_at TEXT NOT NULL DEFAULT (datetime('now')) 73); 74`; 75 76const INVITE_TOKEN_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; 77const INVITE_TOKEN_LENGTH = 10; 78 79export interface InviteRecord { 80 token: string; 81 minted_by: string | null; 82 minted_at: string; 83 spent_by_did: string | null; 84 spent_at: string | null; 85 keeper_label: string | null; 86} 87 88export interface InviteListCursor { 89 mintedAt: string; 90 token: string; 91} 92 93export interface KeeperRecord { 94 did: string; 95 handle: string; 96 keeper_source: string; 97 verified: number; 98 updated_at: string; 99} 100 101export interface KeeperListCursor { 102 updatedAt: string; 103 did: string; 104} 105 106export interface TakedownAccount { 107 did: string; 108 handle: string; 109 doId: string; 110} 111 112export interface TakedownAuditInput { 113 did: string; 114 handle: string; 115 actor: string; 116 recordsDeleted: number; 117 blobsDeleted: number; 118 collections: string[]; 119} 120 121/** 122 * Thrown when directory schema initialization cannot be completed even after 123 * retries. The Worker's global error handler maps this to a structured, 124 * retryable 503 instead of leaking a bare 500 on cold-start init races. 125 */ 126export class DirectoryInitError extends Error { 127 constructor(message: string, options?: { cause?: unknown }) { 128 super(message); 129 this.name = "DirectoryInitError"; 130 if (options && "cause" in options) { 131 this.cause = options.cause; 132 } 133 } 134} 135 136const DIRECTORY_INIT_MAX_ATTEMPTS = 3; 137const DIRECTORY_INIT_RETRY_BASE_MS = 25; 138 139function sleep(ms: number): Promise<void> { 140 return new Promise((resolve) => setTimeout(resolve, ms)); 141} 142 143// One-time-per-isolate guard for both additive invite columns and the idempotency 144// index. Adding columns is a one-way, never-reverted migration, so memoizing 145// avoids a PRAGMA on every request while staying correct across the process lifetime. 146let inviteIdempotencyEnsured = false; 147 148export function __resetInviteMigrationCache(): void { 149 inviteIdempotencyEnsured = false; 150} 151 152async function ensureInviteIdempotency(db: D1Database): Promise<void> { 153 if (inviteIdempotencyEnsured) { 154 return; 155 } 156 // CREATE TABLE IF NOT EXISTS is a no-op on already-provisioned directories, so 157 // an existing invites table won't gain columns from the batch above. Add them 158 // idempotently here, then build the partial unique index that backs idempotent 159 // mints. New deployments already have both columns from INVITES_SCHEMA. 160 const columns = await db.prepare("PRAGMA table_info(invites)").all<{ name: string }>(); 161 const columnNames = new Set(columns.results.map((column) => column.name)); 162 if (!columnNames.has("idempotency_key")) { 163 await db.prepare("ALTER TABLE invites ADD COLUMN idempotency_key TEXT").run(); 164 } 165 if (!columnNames.has("keeper_label")) { 166 await db.prepare("ALTER TABLE invites ADD COLUMN keeper_label TEXT").run(); 167 } 168 await db.prepare(INVITES_IDEMPOTENCY_INDEX).run(); 169 inviteIdempotencyEnsured = true; 170} 171 172export async function initDirectory(db: D1Database): Promise<void> { 173 // Create the account, invite, keeper, quota, config, and takedown schemas and seed 174 // the default configuration. 175 // 176 // Cold-start guard: the first directory op after idle can lose a DO+D1 init 177 // race and throw. An immediate retry succeeds once D1 is warm, so bound a few 178 // attempts here rather than letting the caller surface a bare 500. 179 let lastError: unknown; 180 for (let attempt = 1; attempt <= DIRECTORY_INIT_MAX_ATTEMPTS; attempt++) { 181 try { 182 await db.batch([ 183 db.prepare(SCHEMA), 184 db.prepare(INDEX), 185 db.prepare(THUMBPRINT_INDEX), 186 db.prepare(INVITE_QUOTAS_SCHEMA), 187 db.prepare(INVITES_SCHEMA), 188 db.prepare(KEEPERS_SCHEMA), 189 db.prepare(INVITES_MINTED_BY_INDEX), 190 db.prepare(INVITES_MINTED_AT_INDEX), 191 db.prepare(CONFIG_SCHEMA), 192 db.prepare(CONFIG_SEED), 193 db.prepare(TAKEDOWNS_SCHEMA), 194 ]); 195 await ensureInviteIdempotency(db); 196 return; 197 } catch (err) { 198 lastError = err; 199 if (attempt < DIRECTORY_INIT_MAX_ATTEMPTS) { 200 await sleep(DIRECTORY_INIT_RETRY_BASE_MS * attempt); 201 } 202 } 203 } 204 throw new DirectoryInitError( 205 `Directory initialization failed after ${DIRECTORY_INIT_MAX_ATTEMPTS} attempts: ${ 206 lastError instanceof Error ? lastError.message : String(lastError) 207 }`, 208 { cause: lastError }, 209 ); 210} 211 212export class RepoNotFoundError extends Error { 213 constructor(message: string) { 214 super(message); 215 this.name = "RepoNotFoundError"; 216 } 217} 218 219export async function resolveRepo( 220 repo: string, 221 env: Env, 222): Promise<{ did: string; doId: string }> { 223 const row = repo.includes(":") 224 ? await env.DIRECTORY.prepare( 225 "SELECT did, do_id FROM accounts WHERE did = ? AND active = 1", 226 ).bind(repo).first<{ did: string; do_id: string }>() 227 : await env.DIRECTORY.prepare( 228 "SELECT did, do_id FROM accounts WHERE handle = ? AND active = 1", 229 ).bind(repo).first<{ did: string; do_id: string }>(); 230 231 if (!row) { 232 throw new RepoNotFoundError(`Repository not found: ${repo}`); 233 } 234 235 return { did: row.did, doId: row.do_id }; 236} 237 238export async function insertAccount( 239 db: D1Database, 240 account: { did: string; handle: string; doId: string; jwkThumbprint?: string }, 241): Promise<void> { 242 await db.prepare( 243 "INSERT INTO accounts (did, handle, do_id, jwk_thumbprint) VALUES (?, ?, ?, ?)", 244 ).bind(account.did, account.handle, account.doId, account.jwkThumbprint ?? null).run(); 245} 246 247export async function resolveAccountForTakedown( 248 db: D1Database, 249 did: string, 250): Promise<TakedownAccount | null> { 251 const row = await db.prepare( 252 "SELECT did, handle, do_id FROM accounts WHERE did = ?", 253 ).bind(did).first<{ did: string; handle: string; do_id: string }>(); 254 255 return row ? { did: row.did, handle: row.handle, doId: row.do_id } : null; 256} 257 258export async function deactivateAccount(db: D1Database, did: string): Promise<void> { 259 await db.prepare("UPDATE accounts SET active = 0 WHERE did = ?").bind(did).run(); 260} 261 262export async function finalizeTakedown( 263 db: D1Database, 264 input: TakedownAuditInput, 265): Promise<void> { 266 await db.batch([ 267 db.prepare("DELETE FROM accounts WHERE did = ?").bind(input.did), 268 db.prepare("DELETE FROM oauth_sessions WHERE did = ?").bind(input.did), 269 db.prepare("DELETE FROM oauth_tokens WHERE did = ?").bind(input.did), 270 db.prepare("DELETE FROM oauth_codes WHERE did = ?").bind(input.did), 271 db.prepare("DELETE FROM invite_quotas WHERE did = ?").bind(input.did), 272 db.prepare("DELETE FROM keepers WHERE did = ?").bind(input.did), 273 db.prepare("UPDATE invites SET keeper_label = NULL WHERE spent_by_did = ?").bind(input.did), 274 db.prepare( 275 `INSERT INTO takedowns 276 (did, handle, actor, records_deleted, blobs_deleted, collections) 277 VALUES (?, ?, ?, ?, ?, ?)`, 278 ).bind( 279 input.did, 280 input.handle, 281 input.actor, 282 input.recordsDeleted, 283 input.blobsDeleted, 284 JSON.stringify(input.collections), 285 ), 286 ]); 287} 288 289export async function handleExists(db: D1Database, handle: string): Promise<boolean> { 290 const row = await db.prepare( 291 "SELECT 1 FROM accounts WHERE handle = ? LIMIT 1", 292 ).bind(handle).first(); 293 294 return row !== null; 295} 296 297export async function isAccountActive(db: D1Database, did: string): Promise<boolean> { 298 const row = await db.prepare( 299 "SELECT 1 FROM accounts WHERE did = ? AND active = 1 LIMIT 1", 300 ).bind(did).first(); 301 302 return row !== null; 303} 304 305export async function upsertKeeper( 306 db: D1Database, 307 did: string, 308 handle: string, 309 source: string = "operator", 310): Promise<KeeperRecord> { 311 await db.prepare( 312 "INSERT INTO keepers (did, handle, keeper_source) VALUES (?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, keeper_source = excluded.keeper_source, updated_at = datetime('now')", 313 ).bind(did, handle, source).run(); 314 315 const row = await db.prepare( 316 "SELECT did, handle, keeper_source, verified, updated_at FROM keepers WHERE did = ?", 317 ).bind(did).first<KeeperRecord>(); 318 if (!row) { 319 throw new Error("Keeper row missing after upsert"); 320 } 321 return row; 322} 323 324export async function getKeeper( 325 db: D1Database, 326 did: string, 327): Promise<KeeperRecord | null> { 328 return db.prepare( 329 "SELECT did, handle, keeper_source, verified, updated_at FROM keepers WHERE did = ?", 330 ).bind(did).first<KeeperRecord>(); 331} 332 333export async function listKeepers( 334 db: D1Database, 335 opts: { limit: number; cursor?: KeeperListCursor }, 336): Promise<KeeperRecord[]> { 337 const conditions: string[] = []; 338 const binds: unknown[] = []; 339 340 if (opts.cursor) { 341 conditions.push("(updated_at, did) < (?, ?)"); 342 binds.push(opts.cursor.updatedAt, opts.cursor.did); 343 } 344 345 const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; 346 binds.push(opts.limit); 347 348 const result = await db.prepare( 349 `SELECT did, handle, keeper_source, verified, updated_at FROM keepers ${where} ORDER BY updated_at DESC, did DESC LIMIT ?`, 350 ).bind(...binds).all<KeeperRecord>(); 351 return result.results; 352} 353 354export async function deleteKeeper( 355 db: D1Database, 356 did: string, 357): Promise<"deleted" | "not_found"> { 358 const res = await db.prepare("DELETE FROM keepers WHERE did = ?").bind(did).run(); 359 return res.meta.changes === 1 ? "deleted" : "not_found"; 360} 361 362export function generateInviteToken(): string { 363 const bytes = new Uint8Array(INVITE_TOKEN_LENGTH); 364 crypto.getRandomValues(bytes); 365 let token = ""; 366 for (const byte of bytes) { 367 token += INVITE_TOKEN_ALPHABET[byte % INVITE_TOKEN_ALPHABET.length]; 368 } 369 return token; 370} 371 372export async function mintRookInvite( 373 db: D1Database, 374 did: string, 375 quota: number, 376): Promise<{ token: string; remaining: number } | null> { 377 const token = generateInviteToken(); 378 const res = await db.prepare( 379 "INSERT INTO invites (token, minted_by) SELECT ?, ? WHERE (SELECT COUNT(*) FROM invites WHERE minted_by = ?) < ?", 380 ).bind(token, did, did, quota).run(); 381 382 if (res.meta.changes === 0) { 383 return null; 384 } 385 386 const row = await db.prepare( 387 "SELECT COUNT(*) AS count FROM invites WHERE minted_by = ?", 388 ).bind(did).first<{ count: number }>(); 389 const count = row?.count ?? 0; 390 391 return { token, remaining: quota - count }; 392} 393 394export async function mintOrgInvite( 395 db: D1Database, 396 opts: { idempotencyKey?: string; keeperLabel?: string | null } = {}, 397): Promise<{ token: string; keeper_label: string | null; replayed: boolean }> { 398 const key = opts.idempotencyKey; 399 400 // Idempotent path: a retry carrying the same key returns the invite minted by 401 // the first request instead of stranding another unspent invite. 402 if (key) { 403 const existing = await db.prepare( 404 "SELECT token, keeper_label FROM invites WHERE idempotency_key = ?", 405 ).bind(key).first<{ token: string; keeper_label: string | null }>(); 406 if (existing) { 407 return { token: existing.token, keeper_label: existing.keeper_label, replayed: true }; 408 } 409 } 410 411 const token = generateInviteToken(); 412 try { 413 await db.prepare( 414 "INSERT INTO invites (token, minted_by, idempotency_key, keeper_label) VALUES (?, 'org', ?, ?)", 415 ).bind(token, key ?? null, opts.keeperLabel ?? null).run(); 416 } catch (err) { 417 // Concurrent first-use of the same key: the partial unique index rejects the 418 // loser, which then resolves to the winner's token. 419 if (key && err instanceof Error && err.message.includes("UNIQUE constraint failed")) { 420 const existing = await db.prepare( 421 "SELECT token, keeper_label FROM invites WHERE idempotency_key = ?", 422 ).bind(key).first<{ token: string; keeper_label: string | null }>(); 423 if (existing) { 424 return { token: existing.token, keeper_label: existing.keeper_label, replayed: true }; 425 } 426 } 427 throw err; 428 } 429 430 return { token, keeper_label: opts.keeperLabel ?? null, replayed: false }; 431} 432 433export async function getEffectiveQuota(db: D1Database, did: string): Promise<number> { 434 const override = await db.prepare( 435 "SELECT quota FROM invite_quotas WHERE did = ?", 436 ).bind(did).first<{ quota: number }>(); 437 if (override) { 438 return override.quota; 439 } 440 441 const row = await db.prepare( 442 "SELECT value FROM config WHERE key = 'invite_quota_default'", 443 ).first<{ value: string }>(); 444 if (!row) { 445 throw new Error("invite_quota_default is missing"); 446 } 447 448 const quota = Number(row.value); 449 if (!Number.isInteger(quota) || quota < 0) { 450 throw new Error("invite_quota_default must be a non-negative integer"); 451 } 452 return quota; 453} 454 455export async function setInviteQuota( 456 db: D1Database, 457 did: string, 458 quota: number, 459): Promise<void> { 460 await db.prepare( 461 "INSERT INTO invite_quotas (did, quota) VALUES (?, ?) ON CONFLICT(did) DO UPDATE SET quota = excluded.quota", 462 ).bind(did, quota).run(); 463} 464 465export async function setInviteQuotaDefault( 466 db: D1Database, 467 value: number, 468): Promise<void> { 469 await db.prepare( 470 "INSERT INTO config (key, value, updated_at) VALUES ('invite_quota_default', ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')", 471 ).bind(String(value)).run(); 472} 473 474export type InviteListState = "unspent" | "spent"; 475 476export async function listInvites( 477 db: D1Database, 478 opts: { limit: number; cursor?: InviteListCursor; state?: InviteListState }, 479): Promise<InviteRecord[]> { 480 const conditions: string[] = []; 481 const binds: unknown[] = []; 482 483 if (opts.cursor) { 484 conditions.push("(minted_at, token) < (?, ?)"); 485 binds.push(opts.cursor.mintedAt, opts.cursor.token); 486 } 487 // "unspent" excludes both spent and pending (in-flight) invites — a pending 488 // invite is spent_by_did = 'pending', which is NOT NULL. 489 if (opts.state === "unspent") { 490 conditions.push("spent_by_did IS NULL"); 491 } else if (opts.state === "spent") { 492 conditions.push("spent_by_did IS NOT NULL"); 493 } 494 495 const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; 496 binds.push(opts.limit); 497 498 const result = await db.prepare( 499 `SELECT token, minted_by, minted_at, spent_by_did, spent_at, keeper_label FROM invites ${where} ORDER BY minted_at DESC, token DESC LIMIT ?`, 500 ).bind(...binds).all<InviteRecord>(); 501 return result.results; 502} 503 504/** 505 * Revoke (hard-delete) a specific unspent invite. Only an unspent invite can be 506 * revoked; a spent or in-flight ('pending') invite is preserved and reported as 507 * "spent" so the caller can reject clearly. Deleting rather than flagging keeps 508 * the spend path (isInviteAvailable / spendInvitePending) untouched. 509 */ 510export async function revokeInvite( 511 db: D1Database, 512 token: string, 513): Promise<"revoked" | "spent" | "not_found"> { 514 const res = await db.prepare( 515 "DELETE FROM invites WHERE token = ? AND spent_by_did IS NULL", 516 ).bind(token).run(); 517 if (res.meta.changes === 1) { 518 return "revoked"; 519 } 520 521 const row = await db.prepare( 522 "SELECT 1 FROM invites WHERE token = ? LIMIT 1", 523 ).bind(token).first(); 524 return row ? "spent" : "not_found"; 525} 526 527export async function isInviteAvailable(db: D1Database, token: string): Promise<boolean> { 528 const row = await db.prepare( 529 "SELECT 1 FROM invites WHERE token = ? AND spent_by_did IS NULL LIMIT 1", 530 ).bind(token).first(); 531 532 return row !== null; 533} 534 535export async function getInviteKeeperLabel( 536 db: D1Database, 537 token: string, 538): Promise<string | null> { 539 const row = await db.prepare( 540 "SELECT keeper_label FROM invites WHERE token = ?", 541 ).bind(token).first<{ keeper_label: string | null }>(); 542 return row?.keeper_label ?? null; 543} 544 545export async function spendInvitePending(db: D1Database, token: string): Promise<boolean> { 546 const res = await db.prepare( 547 "UPDATE invites SET spent_by_did = 'pending', spent_at = datetime('now') WHERE token = ? AND spent_by_did IS NULL", 548 ).bind(token).run(); 549 550 return res.meta.changes === 1; 551} 552 553export async function finalizeInviteSpend( 554 db: D1Database, 555 token: string, 556 did: string, 557): Promise<boolean> { 558 const res = await db.prepare( 559 "UPDATE invites SET spent_by_did = ? WHERE token = ? AND spent_by_did = 'pending'", 560 ).bind(did, token).run(); 561 562 return res.meta.changes === 1; 563} 564 565export async function unspendInvite( 566 db: D1Database, 567 token: string, 568 spentByDid: string, 569): Promise<boolean> { 570 const res = await db.prepare( 571 "UPDATE invites SET spent_by_did = NULL, spent_at = NULL WHERE token = ? AND spent_by_did = ?", 572 ).bind(token, spentByDid).run(); 573 574 return res.meta.changes === 1; 575} 576 577export async function resolveByThumbprint( 578 db: D1Database, 579 thumbprint: string, 580): Promise<{ did: string; doId: string }> { 581 const row = await db.prepare( 582 "SELECT did, do_id FROM accounts WHERE jwk_thumbprint = ? AND active = 1", 583 ).bind(thumbprint).first<{ did: string; do_id: string }>(); 584 585 if (!row) { 586 throw new RepoNotFoundError("No account found for thumbprint"); 587 } 588 589 return { did: row.did, doId: row.do_id }; 590}