open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents
atprotocol
pds
cloudflare
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}