@ascorbic/pds 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { DurableObject, env, waitUntil } from "cloudflare:workers";
2
2
  import { BlockMap, ReadableBlockstore, Repo, WriteOpAction, blocksToCarFile, readCarWithRoot } from "@atproto/repo";
3
3
  import { Secp256k1Keypair, randomStr, verifySignature } from "@atproto/crypto";
4
- import { CID } from "@atproto/lex-data";
4
+ import { CID, asCid, isBlobRef } from "@atproto/lex-data";
5
5
  import { TID, check, didDocument, getServiceEndpoint } from "@atproto/common-web";
6
6
  import { AtUri, ensureValidDid, ensureValidHandle } from "@atproto/syntax";
7
7
  import { cidForRawBytes, decode, encode } from "@atproto/lex-cbor";
8
8
  import { Hono } from "hono";
9
9
  import { cors } from "hono/cors";
10
10
  import { SignJWT, jwtVerify } from "jose";
11
- import { compare } from "bcryptjs";
11
+ import { compare, compare as compare$1 } from "bcryptjs";
12
+ import { ATProtoOAuthProvider } from "@ascorbic/atproto-oauth-provider";
12
13
  import { Lexicons, jsonToLex } from "@atproto/lexicon";
13
14
 
14
15
  //#region rolldown:runtime
@@ -42,8 +43,9 @@ var SqliteRepoStorage = class extends ReadableBlockstore {
42
43
  }
43
44
  /**
44
45
  * Initialize the database schema. Should be called once on DO startup.
46
+ * @param initialActive - Whether the account should start in active state (default true)
45
47
  */
46
- initSchema() {
48
+ initSchema(initialActive = true) {
47
49
  this.sql.exec(`
48
50
  -- Block storage (MST nodes + record blocks)
49
51
  CREATE TABLE IF NOT EXISTS blocks (
@@ -59,12 +61,13 @@ var SqliteRepoStorage = class extends ReadableBlockstore {
59
61
  id INTEGER PRIMARY KEY CHECK (id = 1),
60
62
  root_cid TEXT,
61
63
  rev TEXT,
62
- seq INTEGER NOT NULL DEFAULT 0
64
+ seq INTEGER NOT NULL DEFAULT 0,
65
+ active INTEGER NOT NULL DEFAULT 1
63
66
  );
64
67
 
65
68
  -- Initialize with empty state if not exists
66
- INSERT OR IGNORE INTO repo_state (id, root_cid, rev, seq)
67
- VALUES (1, NULL, NULL, 0);
69
+ INSERT OR IGNORE INTO repo_state (id, root_cid, rev, seq, active)
70
+ VALUES (1, NULL, NULL, 0, ${initialActive ? 1 : 0});
68
71
 
69
72
  -- Firehose events (sequenced commit log)
70
73
  CREATE TABLE IF NOT EXISTS firehose_events (
@@ -84,6 +87,23 @@ var SqliteRepoStorage = class extends ReadableBlockstore {
84
87
 
85
88
  -- Initialize with empty preferences array if not exists
86
89
  INSERT OR IGNORE INTO preferences (id, data) VALUES (1, '[]');
90
+
91
+ -- Track blob references in records (populated during importRepo)
92
+ CREATE TABLE IF NOT EXISTS record_blob (
93
+ recordUri TEXT NOT NULL,
94
+ blobCid TEXT NOT NULL,
95
+ PRIMARY KEY (recordUri, blobCid)
96
+ );
97
+
98
+ CREATE INDEX IF NOT EXISTS idx_record_blob_cid ON record_blob(blobCid);
99
+
100
+ -- Track successfully imported blobs (populated during uploadBlob)
101
+ CREATE TABLE IF NOT EXISTS imported_blobs (
102
+ cid TEXT PRIMARY KEY,
103
+ size INTEGER NOT NULL,
104
+ mimeType TEXT,
105
+ createdAt TEXT NOT NULL DEFAULT (datetime('now'))
106
+ );
87
107
  `);
88
108
  }
89
109
  /**
@@ -215,6 +235,318 @@ var SqliteRepoStorage = class extends ReadableBlockstore {
215
235
  const data = JSON.stringify(preferences);
216
236
  this.sql.exec("UPDATE preferences SET data = ? WHERE id = 1", data);
217
237
  }
238
+ /**
239
+ * Get the activation state of the account.
240
+ */
241
+ async getActive() {
242
+ const rows = this.sql.exec("SELECT active FROM repo_state WHERE id = 1").toArray();
243
+ return rows.length > 0 ? rows[0].active === 1 : true;
244
+ }
245
+ /**
246
+ * Set the activation state of the account.
247
+ */
248
+ async setActive(active) {
249
+ this.sql.exec("UPDATE repo_state SET active = ? WHERE id = 1", active ? 1 : 0);
250
+ }
251
+ /**
252
+ * Add a blob reference from a record.
253
+ */
254
+ addRecordBlob(recordUri, blobCid) {
255
+ this.sql.exec("INSERT OR IGNORE INTO record_blob (recordUri, blobCid) VALUES (?, ?)", recordUri, blobCid);
256
+ }
257
+ /**
258
+ * Add multiple blob references from a record.
259
+ */
260
+ addRecordBlobs(recordUri, blobCids) {
261
+ for (const cid of blobCids) this.addRecordBlob(recordUri, cid);
262
+ }
263
+ /**
264
+ * Remove all blob references for a record.
265
+ */
266
+ removeRecordBlobs(recordUri) {
267
+ this.sql.exec("DELETE FROM record_blob WHERE recordUri = ?", recordUri);
268
+ }
269
+ /**
270
+ * Track an imported blob.
271
+ */
272
+ trackImportedBlob(cid, size, mimeType) {
273
+ this.sql.exec("INSERT OR REPLACE INTO imported_blobs (cid, size, mimeType) VALUES (?, ?, ?)", cid, size, mimeType);
274
+ }
275
+ /**
276
+ * Check if a blob has been imported.
277
+ */
278
+ isBlobImported(cid) {
279
+ return this.sql.exec("SELECT 1 FROM imported_blobs WHERE cid = ? LIMIT 1", cid).toArray().length > 0;
280
+ }
281
+ /**
282
+ * Count expected blobs (distinct blobs referenced by records).
283
+ */
284
+ countExpectedBlobs() {
285
+ const rows = this.sql.exec("SELECT COUNT(DISTINCT blobCid) as count FROM record_blob").toArray();
286
+ return rows.length > 0 ? rows[0].count ?? 0 : 0;
287
+ }
288
+ /**
289
+ * Count imported blobs.
290
+ */
291
+ countImportedBlobs() {
292
+ const rows = this.sql.exec("SELECT COUNT(*) as count FROM imported_blobs").toArray();
293
+ return rows.length > 0 ? rows[0].count ?? 0 : 0;
294
+ }
295
+ /**
296
+ * List blobs that are referenced but not yet imported.
297
+ */
298
+ listMissingBlobs(limit = 500, cursor) {
299
+ const blobs = [];
300
+ const query = cursor ? `SELECT rb.blobCid, rb.recordUri FROM record_blob rb
301
+ LEFT JOIN imported_blobs ib ON rb.blobCid = ib.cid
302
+ WHERE ib.cid IS NULL AND rb.blobCid > ?
303
+ ORDER BY rb.blobCid
304
+ LIMIT ?` : `SELECT rb.blobCid, rb.recordUri FROM record_blob rb
305
+ LEFT JOIN imported_blobs ib ON rb.blobCid = ib.cid
306
+ WHERE ib.cid IS NULL
307
+ ORDER BY rb.blobCid
308
+ LIMIT ?`;
309
+ const rows = cursor ? this.sql.exec(query, cursor, limit + 1).toArray() : this.sql.exec(query, limit + 1).toArray();
310
+ for (const row of rows.slice(0, limit)) blobs.push({
311
+ cid: row.blobCid,
312
+ recordUri: row.recordUri
313
+ });
314
+ return {
315
+ blobs,
316
+ cursor: rows.length > limit ? blobs[blobs.length - 1]?.cid : void 0
317
+ };
318
+ }
319
+ /**
320
+ * Clear all blob tracking data (for testing).
321
+ */
322
+ clearBlobTracking() {
323
+ this.sql.exec("DELETE FROM record_blob");
324
+ this.sql.exec("DELETE FROM imported_blobs");
325
+ }
326
+ };
327
+
328
+ //#endregion
329
+ //#region src/oauth-storage.ts
330
+ /**
331
+ * SQLite-backed OAuth storage for Cloudflare Durable Objects.
332
+ *
333
+ * Implements the OAuthStorage interface from @ascorbic/atproto-oauth-provider,
334
+ * storing OAuth data in SQLite tables within a Durable Object.
335
+ */
336
+ var SqliteOAuthStorage = class {
337
+ constructor(sql) {
338
+ this.sql = sql;
339
+ }
340
+ /**
341
+ * Initialize the OAuth database schema. Should be called once on DO startup.
342
+ */
343
+ initSchema() {
344
+ this.sql.exec(`
345
+ -- Authorization codes (5 min TTL)
346
+ CREATE TABLE IF NOT EXISTS oauth_auth_codes (
347
+ code TEXT PRIMARY KEY,
348
+ client_id TEXT NOT NULL,
349
+ redirect_uri TEXT NOT NULL,
350
+ code_challenge TEXT NOT NULL,
351
+ code_challenge_method TEXT NOT NULL DEFAULT 'S256',
352
+ scope TEXT NOT NULL,
353
+ sub TEXT NOT NULL,
354
+ expires_at INTEGER NOT NULL
355
+ );
356
+
357
+ CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON oauth_auth_codes(expires_at);
358
+
359
+ -- OAuth tokens
360
+ CREATE TABLE IF NOT EXISTS oauth_tokens (
361
+ access_token TEXT PRIMARY KEY,
362
+ refresh_token TEXT NOT NULL UNIQUE,
363
+ client_id TEXT NOT NULL,
364
+ sub TEXT NOT NULL,
365
+ scope TEXT NOT NULL,
366
+ dpop_jkt TEXT,
367
+ issued_at INTEGER NOT NULL,
368
+ expires_at INTEGER NOT NULL,
369
+ revoked INTEGER NOT NULL DEFAULT 0
370
+ );
371
+
372
+ CREATE INDEX IF NOT EXISTS idx_tokens_refresh ON oauth_tokens(refresh_token);
373
+ CREATE INDEX IF NOT EXISTS idx_tokens_sub ON oauth_tokens(sub);
374
+ CREATE INDEX IF NOT EXISTS idx_tokens_expires ON oauth_tokens(expires_at);
375
+
376
+ -- Cached client metadata
377
+ CREATE TABLE IF NOT EXISTS oauth_clients (
378
+ client_id TEXT PRIMARY KEY,
379
+ client_name TEXT NOT NULL,
380
+ redirect_uris TEXT NOT NULL,
381
+ logo_uri TEXT,
382
+ client_uri TEXT,
383
+ cached_at INTEGER NOT NULL
384
+ );
385
+
386
+ -- PAR requests (90 sec TTL)
387
+ CREATE TABLE IF NOT EXISTS oauth_par_requests (
388
+ request_uri TEXT PRIMARY KEY,
389
+ client_id TEXT NOT NULL,
390
+ params TEXT NOT NULL,
391
+ expires_at INTEGER NOT NULL
392
+ );
393
+
394
+ CREATE INDEX IF NOT EXISTS idx_par_expires ON oauth_par_requests(expires_at);
395
+
396
+ -- DPoP nonces for replay prevention (5 min TTL)
397
+ CREATE TABLE IF NOT EXISTS oauth_nonces (
398
+ nonce TEXT PRIMARY KEY,
399
+ created_at INTEGER NOT NULL
400
+ );
401
+
402
+ CREATE INDEX IF NOT EXISTS idx_nonces_created ON oauth_nonces(created_at);
403
+ `);
404
+ }
405
+ /**
406
+ * Clean up expired entries. Should be called periodically.
407
+ */
408
+ cleanup() {
409
+ const now = Date.now();
410
+ this.sql.exec("DELETE FROM oauth_auth_codes WHERE expires_at < ?", now);
411
+ this.sql.exec("DELETE FROM oauth_tokens WHERE expires_at < ? AND revoked = 0", now);
412
+ this.sql.exec("DELETE FROM oauth_par_requests WHERE expires_at < ?", now);
413
+ const nonceExpiry = now - 300 * 1e3;
414
+ this.sql.exec("DELETE FROM oauth_nonces WHERE created_at < ?", nonceExpiry);
415
+ }
416
+ async saveAuthCode(code, data) {
417
+ this.sql.exec(`INSERT INTO oauth_auth_codes
418
+ (code, client_id, redirect_uri, code_challenge, code_challenge_method, scope, sub, expires_at)
419
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, code, data.clientId, data.redirectUri, data.codeChallenge, data.codeChallengeMethod, data.scope, data.sub, data.expiresAt);
420
+ }
421
+ async getAuthCode(code) {
422
+ const rows = this.sql.exec(`SELECT client_id, redirect_uri, code_challenge, code_challenge_method, scope, sub, expires_at
423
+ FROM oauth_auth_codes WHERE code = ?`, code).toArray();
424
+ if (rows.length === 0) return null;
425
+ const row = rows[0];
426
+ const expiresAt = row.expires_at;
427
+ if (Date.now() > expiresAt) {
428
+ this.sql.exec("DELETE FROM oauth_auth_codes WHERE code = ?", code);
429
+ return null;
430
+ }
431
+ return {
432
+ clientId: row.client_id,
433
+ redirectUri: row.redirect_uri,
434
+ codeChallenge: row.code_challenge,
435
+ codeChallengeMethod: row.code_challenge_method,
436
+ scope: row.scope,
437
+ sub: row.sub,
438
+ expiresAt
439
+ };
440
+ }
441
+ async deleteAuthCode(code) {
442
+ this.sql.exec("DELETE FROM oauth_auth_codes WHERE code = ?", code);
443
+ }
444
+ async saveTokens(data) {
445
+ this.sql.exec(`INSERT INTO oauth_tokens
446
+ (access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked)
447
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, data.accessToken, data.refreshToken, data.clientId, data.sub, data.scope, data.dpopJkt ?? null, data.issuedAt, data.expiresAt, data.revoked ? 1 : 0);
448
+ }
449
+ async getTokenByAccess(accessToken) {
450
+ const rows = this.sql.exec(`SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked
451
+ FROM oauth_tokens WHERE access_token = ?`, accessToken).toArray();
452
+ if (rows.length === 0) return null;
453
+ const row = rows[0];
454
+ const revoked = Boolean(row.revoked);
455
+ const expiresAt = row.expires_at;
456
+ if (revoked || Date.now() > expiresAt) return null;
457
+ return {
458
+ accessToken: row.access_token,
459
+ refreshToken: row.refresh_token,
460
+ clientId: row.client_id,
461
+ sub: row.sub,
462
+ scope: row.scope,
463
+ dpopJkt: row.dpop_jkt ?? void 0,
464
+ issuedAt: row.issued_at,
465
+ expiresAt,
466
+ revoked
467
+ };
468
+ }
469
+ async getTokenByRefresh(refreshToken) {
470
+ const rows = this.sql.exec(`SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked
471
+ FROM oauth_tokens WHERE refresh_token = ?`, refreshToken).toArray();
472
+ if (rows.length === 0) return null;
473
+ const row = rows[0];
474
+ const revoked = Boolean(row.revoked);
475
+ if (revoked) return null;
476
+ return {
477
+ accessToken: row.access_token,
478
+ refreshToken: row.refresh_token,
479
+ clientId: row.client_id,
480
+ sub: row.sub,
481
+ scope: row.scope,
482
+ dpopJkt: row.dpop_jkt ?? void 0,
483
+ issuedAt: row.issued_at,
484
+ expiresAt: row.expires_at,
485
+ revoked
486
+ };
487
+ }
488
+ async revokeToken(accessToken) {
489
+ this.sql.exec("UPDATE oauth_tokens SET revoked = 1 WHERE access_token = ?", accessToken);
490
+ }
491
+ async revokeAllTokens(sub) {
492
+ this.sql.exec("UPDATE oauth_tokens SET revoked = 1 WHERE sub = ?", sub);
493
+ }
494
+ async saveClient(clientId, metadata) {
495
+ this.sql.exec(`INSERT OR REPLACE INTO oauth_clients
496
+ (client_id, client_name, redirect_uris, logo_uri, client_uri, cached_at)
497
+ VALUES (?, ?, ?, ?, ?, ?)`, clientId, metadata.clientName, JSON.stringify(metadata.redirectUris), metadata.logoUri ?? null, metadata.clientUri ?? null, metadata.cachedAt ?? Date.now());
498
+ }
499
+ async getClient(clientId) {
500
+ const rows = this.sql.exec(`SELECT client_id, client_name, redirect_uris, logo_uri, client_uri, cached_at
501
+ FROM oauth_clients WHERE client_id = ?`, clientId).toArray();
502
+ if (rows.length === 0) return null;
503
+ const row = rows[0];
504
+ return {
505
+ clientId: row.client_id,
506
+ clientName: row.client_name,
507
+ redirectUris: JSON.parse(row.redirect_uris),
508
+ logoUri: row.logo_uri ?? void 0,
509
+ clientUri: row.client_uri ?? void 0,
510
+ cachedAt: row.cached_at
511
+ };
512
+ }
513
+ async savePAR(requestUri, data) {
514
+ this.sql.exec(`INSERT INTO oauth_par_requests (request_uri, client_id, params, expires_at)
515
+ VALUES (?, ?, ?, ?)`, requestUri, data.clientId, JSON.stringify(data.params), data.expiresAt);
516
+ }
517
+ async getPAR(requestUri) {
518
+ const rows = this.sql.exec(`SELECT client_id, params, expires_at FROM oauth_par_requests WHERE request_uri = ?`, requestUri).toArray();
519
+ if (rows.length === 0) return null;
520
+ const row = rows[0];
521
+ const expiresAt = row.expires_at;
522
+ if (Date.now() > expiresAt) {
523
+ this.sql.exec("DELETE FROM oauth_par_requests WHERE request_uri = ?", requestUri);
524
+ return null;
525
+ }
526
+ return {
527
+ clientId: row.client_id,
528
+ params: JSON.parse(row.params),
529
+ expiresAt
530
+ };
531
+ }
532
+ async deletePAR(requestUri) {
533
+ this.sql.exec("DELETE FROM oauth_par_requests WHERE request_uri = ?", requestUri);
534
+ }
535
+ async checkAndSaveNonce(nonce) {
536
+ if (this.sql.exec("SELECT 1 FROM oauth_nonces WHERE nonce = ? LIMIT 1", nonce).toArray().length > 0) return false;
537
+ this.sql.exec("INSERT INTO oauth_nonces (nonce, created_at) VALUES (?, ?)", nonce, Date.now());
538
+ return true;
539
+ }
540
+ /**
541
+ * Clear all OAuth data (for testing).
542
+ */
543
+ destroy() {
544
+ this.sql.exec("DELETE FROM oauth_auth_codes");
545
+ this.sql.exec("DELETE FROM oauth_tokens");
546
+ this.sql.exec("DELETE FROM oauth_clients");
547
+ this.sql.exec("DELETE FROM oauth_par_requests");
548
+ this.sql.exec("DELETE FROM oauth_nonces");
549
+ }
218
550
  };
219
551
 
220
552
  //#endregion
@@ -361,6 +693,7 @@ var BlobStore = class {
361
693
  */
362
694
  var AccountDurableObject = class extends DurableObject {
363
695
  storage = null;
696
+ oauthStorage = null;
364
697
  repo = null;
365
698
  keypair = null;
366
699
  sequencer = null;
@@ -379,8 +712,11 @@ var AccountDurableObject = class extends DurableObject {
379
712
  async ensureStorageInitialized() {
380
713
  if (!this.storageInitialized) await this.ctx.blockConcurrencyWhile(async () => {
381
714
  if (this.storageInitialized) return;
715
+ const initialActive = this.env.INITIAL_ACTIVE === void 0 || this.env.INITIAL_ACTIVE === "true" || this.env.INITIAL_ACTIVE === "1";
382
716
  this.storage = new SqliteRepoStorage(this.ctx.storage.sql);
383
- this.storage.initSchema();
717
+ this.storage.initSchema(initialActive);
718
+ this.oauthStorage = new SqliteOAuthStorage(this.ctx.storage.sql);
719
+ this.oauthStorage.initSchema();
384
720
  this.sequencer = new Sequencer(this.ctx.storage.sql);
385
721
  this.storageInitialized = true;
386
722
  });
@@ -407,6 +743,13 @@ var AccountDurableObject = class extends DurableObject {
407
743
  return this.storage;
408
744
  }
409
745
  /**
746
+ * Get the OAuth storage adapter for OAuth operations.
747
+ */
748
+ async getOAuthStorage() {
749
+ await this.ensureStorageInitialized();
750
+ return this.oauthStorage;
751
+ }
752
+ /**
410
753
  * Get the Repo instance for repository operations.
411
754
  */
412
755
  async getRepo() {
@@ -414,6 +757,12 @@ var AccountDurableObject = class extends DurableObject {
414
757
  return this.repo;
415
758
  }
416
759
  /**
760
+ * Ensure the account is active. Throws error if deactivated.
761
+ */
762
+ async ensureActive() {
763
+ if (!await (await this.getStorage()).getActive()) throw new Error("AccountDeactivated: Account is deactivated. Call activateAccount to enable writes.");
764
+ }
765
+ /**
417
766
  * Get the signing keypair for repository operations.
418
767
  */
419
768
  async getKeypair() {
@@ -455,7 +804,7 @@ var AccountDurableObject = class extends DurableObject {
455
804
  if (!record) return null;
456
805
  return {
457
806
  cid: recordCid.toString(),
458
- record
807
+ record: serializeRecord(record)
459
808
  };
460
809
  }
461
810
  /**
@@ -473,7 +822,7 @@ var AccountDurableObject = class extends DurableObject {
473
822
  records.push({
474
823
  uri: AtUri.make(repo.did, record.collection, record.rkey).toString(),
475
824
  cid: record.cid.toString(),
476
- value: record.record
825
+ value: serializeRecord(record.record)
477
826
  });
478
827
  if (records.length >= opts.limit + 1) break;
479
828
  }
@@ -489,6 +838,7 @@ var AccountDurableObject = class extends DurableObject {
489
838
  * RPC method: Create a record
490
839
  */
491
840
  async rpcCreateRecord(collection, rkey, record) {
841
+ await this.ensureActive();
492
842
  const repo = await this.getRepo();
493
843
  const keypair = await this.getKeypair();
494
844
  const actualRkey = rkey || TID.nextStr();
@@ -539,6 +889,7 @@ var AccountDurableObject = class extends DurableObject {
539
889
  * RPC method: Delete a record
540
890
  */
541
891
  async rpcDeleteRecord(collection, rkey) {
892
+ await this.ensureActive();
542
893
  const repo = await this.getRepo();
543
894
  const keypair = await this.getKeypair();
544
895
  if (!await repo.getRecord(collection, rkey)) return null;
@@ -578,6 +929,7 @@ var AccountDurableObject = class extends DurableObject {
578
929
  * RPC method: Put a record (create or update)
579
930
  */
580
931
  async rpcPutRecord(collection, rkey, record) {
932
+ await this.ensureActive();
581
933
  const repo = await this.getRepo();
582
934
  const keypair = await this.getKeypair();
583
935
  const op = await repo.getRecord(collection, rkey) !== null ? {
@@ -633,6 +985,7 @@ var AccountDurableObject = class extends DurableObject {
633
985
  * RPC method: Apply multiple writes (batch create/update/delete)
634
986
  */
635
987
  async rpcApplyWrites(writes) {
988
+ await this.ensureActive();
636
989
  const repo = await this.getRepo();
637
990
  const keypair = await this.getKeypair();
638
991
  const ops = [];
@@ -761,23 +1114,54 @@ var AccountDurableObject = class extends DurableObject {
761
1114
  return blocksToCarFile(root, blocks);
762
1115
  }
763
1116
  /**
1117
+ * RPC method: Get specific blocks by CID as CAR file
1118
+ * Used for partial sync and migration.
1119
+ */
1120
+ async rpcGetBlocks(cids) {
1121
+ const storage = await this.getStorage();
1122
+ const root = await storage.getRoot();
1123
+ if (!root) throw new Error("No repository root found");
1124
+ const blocks = new BlockMap();
1125
+ for (const cidStr of cids) {
1126
+ const cid = CID.parse(cidStr);
1127
+ const bytes = await storage.getBytes(cid);
1128
+ if (bytes) blocks.set(cid, bytes);
1129
+ }
1130
+ return blocksToCarFile(root, blocks);
1131
+ }
1132
+ /**
764
1133
  * RPC method: Import repo from CAR file
765
1134
  * This is used for account migration - importing an existing repository
766
1135
  * from another PDS.
767
1136
  */
768
1137
  async rpcImportRepo(carBytes) {
769
1138
  await this.ensureStorageInitialized();
770
- if (await this.storage.getRoot()) throw new Error("Repository already exists. Cannot import over existing repository.");
1139
+ const isActive = await this.storage.getActive();
1140
+ const existingRoot = await this.storage.getRoot();
1141
+ if (isActive && existingRoot) throw new Error("Repository already exists. Cannot import over existing repository.");
1142
+ if (existingRoot) {
1143
+ await this.storage.destroy();
1144
+ this.repo = null;
1145
+ this.repoInitialized = false;
1146
+ }
771
1147
  const { root: rootCid, blocks } = await readCarWithRoot(carBytes);
772
1148
  const importRev = TID.nextStr();
773
1149
  await this.storage.putMany(blocks, importRev);
774
1150
  this.keypair = await Secp256k1Keypair.import(this.env.SIGNING_KEY);
775
1151
  this.repo = await Repo.load(this.storage, rootCid);
1152
+ await this.storage.updateRoot(rootCid, this.repo.commit.rev);
776
1153
  if (this.repo.did !== this.env.DID) {
777
1154
  await this.storage.destroy();
778
1155
  throw new Error(`DID mismatch: CAR file contains DID ${this.repo.did}, but expected ${this.env.DID}`);
779
1156
  }
780
1157
  this.repoInitialized = true;
1158
+ for await (const record of this.repo.walkRecords()) {
1159
+ const blobCids = extractBlobCids(record.record);
1160
+ if (blobCids.length > 0) {
1161
+ const uri = AtUri.make(this.repo.did, record.collection, record.rkey).toString();
1162
+ this.storage.addRecordBlobs(uri, blobCids);
1163
+ }
1164
+ }
781
1165
  return {
782
1166
  did: this.repo.did,
783
1167
  rev: this.repo.commit.rev,
@@ -791,7 +1175,9 @@ var AccountDurableObject = class extends DurableObject {
791
1175
  if (!this.blobStore) throw new Error("Blob storage not configured");
792
1176
  const MAX_BLOB_SIZE = 5 * 1024 * 1024;
793
1177
  if (bytes.length > MAX_BLOB_SIZE) throw new Error(`Blob too large: ${bytes.length} bytes (max ${MAX_BLOB_SIZE})`);
794
- return this.blobStore.putBlob(bytes, mimeType);
1178
+ const blobRef = await this.blobStore.putBlob(bytes, mimeType);
1179
+ (await this.getStorage()).trackImportedBlob(blobRef.ref.$link, bytes.length, mimeType);
1180
+ return blobRef;
795
1181
  }
796
1182
  /**
797
1183
  * RPC method: Get a blob from R2
@@ -919,6 +1305,76 @@ var AccountDurableObject = class extends DurableObject {
919
1305
  await (await this.getStorage()).putPreferences(preferences);
920
1306
  }
921
1307
  /**
1308
+ * RPC method: Get account activation state
1309
+ */
1310
+ async rpcGetActive() {
1311
+ return (await this.getStorage()).getActive();
1312
+ }
1313
+ /**
1314
+ * RPC method: Activate account
1315
+ */
1316
+ async rpcActivateAccount() {
1317
+ await (await this.getStorage()).setActive(true);
1318
+ }
1319
+ /**
1320
+ * RPC method: Deactivate account
1321
+ */
1322
+ async rpcDeactivateAccount() {
1323
+ await (await this.getStorage()).setActive(false);
1324
+ }
1325
+ /**
1326
+ * RPC method: Count blocks in storage
1327
+ */
1328
+ async rpcCountBlocks() {
1329
+ return (await this.getStorage()).countBlocks();
1330
+ }
1331
+ /**
1332
+ * RPC method: Count records in repository
1333
+ */
1334
+ async rpcCountRecords() {
1335
+ const repo = await this.getRepo();
1336
+ let count = 0;
1337
+ for await (const _record of repo.walkRecords()) count++;
1338
+ return count;
1339
+ }
1340
+ /**
1341
+ * RPC method: Count expected blobs (referenced in records)
1342
+ */
1343
+ async rpcCountExpectedBlobs() {
1344
+ return (await this.getStorage()).countExpectedBlobs();
1345
+ }
1346
+ /**
1347
+ * RPC method: Count imported blobs
1348
+ */
1349
+ async rpcCountImportedBlobs() {
1350
+ return (await this.getStorage()).countImportedBlobs();
1351
+ }
1352
+ /**
1353
+ * RPC method: List missing blobs (referenced but not imported)
1354
+ */
1355
+ async rpcListMissingBlobs(limit = 500, cursor) {
1356
+ return (await this.getStorage()).listMissingBlobs(limit, cursor);
1357
+ }
1358
+ /**
1359
+ * RPC method: Reset migration state.
1360
+ * Clears imported repo and blob tracking to allow re-import.
1361
+ * Only works when account is deactivated.
1362
+ */
1363
+ async rpcResetMigration() {
1364
+ const storage = await this.getStorage();
1365
+ if (await storage.getActive()) throw new Error("AccountActive: Cannot reset migration on an active account. Deactivate first.");
1366
+ const blocksDeleted = await storage.countBlocks();
1367
+ const blobsCleared = storage.countImportedBlobs();
1368
+ await storage.destroy();
1369
+ storage.clearBlobTracking();
1370
+ this.repo = null;
1371
+ this.repoInitialized = false;
1372
+ return {
1373
+ blocksDeleted,
1374
+ blobsCleared
1375
+ };
1376
+ }
1377
+ /**
922
1378
  * Emit an identity event to notify downstream services to refresh identity cache.
923
1379
  */
924
1380
  async rpcEmitIdentityEvent(handle) {
@@ -949,6 +1405,62 @@ var AccountDurableObject = class extends DurableObject {
949
1405
  }
950
1406
  return { seq };
951
1407
  }
1408
+ /** Save an authorization code */
1409
+ async rpcSaveAuthCode(code, data) {
1410
+ await (await this.getOAuthStorage()).saveAuthCode(code, data);
1411
+ }
1412
+ /** Get authorization code data */
1413
+ async rpcGetAuthCode(code) {
1414
+ return (await this.getOAuthStorage()).getAuthCode(code);
1415
+ }
1416
+ /** Delete an authorization code */
1417
+ async rpcDeleteAuthCode(code) {
1418
+ await (await this.getOAuthStorage()).deleteAuthCode(code);
1419
+ }
1420
+ /** Save token data */
1421
+ async rpcSaveTokens(data) {
1422
+ await (await this.getOAuthStorage()).saveTokens(data);
1423
+ }
1424
+ /** Get token data by access token */
1425
+ async rpcGetTokenByAccess(accessToken) {
1426
+ return (await this.getOAuthStorage()).getTokenByAccess(accessToken);
1427
+ }
1428
+ /** Get token data by refresh token */
1429
+ async rpcGetTokenByRefresh(refreshToken) {
1430
+ return (await this.getOAuthStorage()).getTokenByRefresh(refreshToken);
1431
+ }
1432
+ /** Revoke a token */
1433
+ async rpcRevokeToken(accessToken) {
1434
+ await (await this.getOAuthStorage()).revokeToken(accessToken);
1435
+ }
1436
+ /** Revoke all tokens for a user */
1437
+ async rpcRevokeAllTokens(sub) {
1438
+ await (await this.getOAuthStorage()).revokeAllTokens(sub);
1439
+ }
1440
+ /** Save client metadata */
1441
+ async rpcSaveClient(clientId, metadata) {
1442
+ await (await this.getOAuthStorage()).saveClient(clientId, metadata);
1443
+ }
1444
+ /** Get client metadata */
1445
+ async rpcGetClient(clientId) {
1446
+ return (await this.getOAuthStorage()).getClient(clientId);
1447
+ }
1448
+ /** Save PAR data */
1449
+ async rpcSavePAR(requestUri, data) {
1450
+ await (await this.getOAuthStorage()).savePAR(requestUri, data);
1451
+ }
1452
+ /** Get PAR data */
1453
+ async rpcGetPAR(requestUri) {
1454
+ return (await this.getOAuthStorage()).getPAR(requestUri);
1455
+ }
1456
+ /** Delete PAR data */
1457
+ async rpcDeletePAR(requestUri) {
1458
+ await (await this.getOAuthStorage()).deletePAR(requestUri);
1459
+ }
1460
+ /** Check and save DPoP nonce */
1461
+ async rpcCheckAndSaveNonce(nonce) {
1462
+ return (await this.getOAuthStorage()).checkAndSaveNonce(nonce);
1463
+ }
952
1464
  /**
953
1465
  * HTTP fetch handler for WebSocket upgrades.
954
1466
  * This is used instead of RPC to avoid WebSocket serialization errors.
@@ -958,6 +1470,40 @@ var AccountDurableObject = class extends DurableObject {
958
1470
  return new Response("Method not allowed", { status: 405 });
959
1471
  }
960
1472
  };
1473
+ /**
1474
+ * Serialize a record for JSON by converting CID objects to { $link: "..." } format.
1475
+ * CBOR-decoded records contain raw CID objects that need conversion for JSON serialization.
1476
+ */
1477
+ function serializeRecord(obj) {
1478
+ if (obj === null || obj === void 0) return obj;
1479
+ const cid = asCid(obj);
1480
+ if (cid) return { $link: cid.toString() };
1481
+ if (Array.isArray(obj)) return obj.map(serializeRecord);
1482
+ if (typeof obj === "object") {
1483
+ const result = {};
1484
+ for (const [key, value] of Object.entries(obj)) result[key] = serializeRecord(value);
1485
+ return result;
1486
+ }
1487
+ return obj;
1488
+ }
1489
+ /**
1490
+ * Extract blob CIDs from a record by recursively searching for blob references.
1491
+ * Blob refs have the structure: { $type: "blob", ref: CID, mimeType, size }
1492
+ */
1493
+ function extractBlobCids(obj) {
1494
+ const cids = [];
1495
+ function walk(value) {
1496
+ if (value === null || value === void 0) return;
1497
+ if (isBlobRef(value)) {
1498
+ cids.push(value.ref.toString());
1499
+ return;
1500
+ }
1501
+ if (Array.isArray(value)) for (const item of value) walk(item);
1502
+ else if (typeof value === "object") for (const key of Object.keys(value)) walk(value[key]);
1503
+ }
1504
+ walk(obj);
1505
+ return cids;
1506
+ }
961
1507
 
962
1508
  //#endregion
963
1509
  //#region src/service-auth.ts
@@ -1095,14 +1641,184 @@ async function verifyRefreshToken(token, jwtSecret, serviceDid) {
1095
1641
  return payload;
1096
1642
  }
1097
1643
 
1644
+ //#endregion
1645
+ //#region src/oauth.ts
1646
+ /**
1647
+ * OAuth 2.1 integration for the PDS
1648
+ *
1649
+ * Connects the @ascorbic/atproto-oauth-provider package with the PDS
1650
+ * by providing storage through Durable Objects and user authentication
1651
+ * through the existing session system.
1652
+ */
1653
+ /**
1654
+ * Proxy storage class that delegates to DO RPC methods
1655
+ *
1656
+ * This is needed because SqliteOAuthStorage instances contain a SQL connection
1657
+ * that can't be serialized across the DO RPC boundary. Instead, we delegate each
1658
+ * storage operation to individual RPC methods that pass only serializable data.
1659
+ */
1660
+ var DOProxyOAuthStorage = class {
1661
+ constructor(accountDO) {
1662
+ this.accountDO = accountDO;
1663
+ }
1664
+ async saveAuthCode(code, data) {
1665
+ await this.accountDO.rpcSaveAuthCode(code, data);
1666
+ }
1667
+ async getAuthCode(code) {
1668
+ return this.accountDO.rpcGetAuthCode(code);
1669
+ }
1670
+ async deleteAuthCode(code) {
1671
+ await this.accountDO.rpcDeleteAuthCode(code);
1672
+ }
1673
+ async saveTokens(data) {
1674
+ await this.accountDO.rpcSaveTokens(data);
1675
+ }
1676
+ async getTokenByAccess(accessToken) {
1677
+ return this.accountDO.rpcGetTokenByAccess(accessToken);
1678
+ }
1679
+ async getTokenByRefresh(refreshToken) {
1680
+ return this.accountDO.rpcGetTokenByRefresh(refreshToken);
1681
+ }
1682
+ async revokeToken(accessToken) {
1683
+ await this.accountDO.rpcRevokeToken(accessToken);
1684
+ }
1685
+ async revokeAllTokens(sub) {
1686
+ await this.accountDO.rpcRevokeAllTokens(sub);
1687
+ }
1688
+ async saveClient(clientId, metadata) {
1689
+ await this.accountDO.rpcSaveClient(clientId, metadata);
1690
+ }
1691
+ async getClient(clientId) {
1692
+ return this.accountDO.rpcGetClient(clientId);
1693
+ }
1694
+ async savePAR(requestUri, data) {
1695
+ await this.accountDO.rpcSavePAR(requestUri, data);
1696
+ }
1697
+ async getPAR(requestUri) {
1698
+ return this.accountDO.rpcGetPAR(requestUri);
1699
+ }
1700
+ async deletePAR(requestUri) {
1701
+ await this.accountDO.rpcDeletePAR(requestUri);
1702
+ }
1703
+ async checkAndSaveNonce(nonce) {
1704
+ return this.accountDO.rpcCheckAndSaveNonce(nonce);
1705
+ }
1706
+ };
1707
+ /**
1708
+ * Get the OAuth provider for the given environment
1709
+ * Exported for use in auth middleware for token verification
1710
+ */
1711
+ function getProvider(env$2) {
1712
+ return new ATProtoOAuthProvider({
1713
+ storage: new DOProxyOAuthStorage(getAccountDO$1(env$2)),
1714
+ issuer: `https://${env$2.PDS_HOSTNAME}`,
1715
+ dpopRequired: true,
1716
+ enablePAR: true,
1717
+ verifyUser: async (password) => {
1718
+ if (!await compare(password, env$2.PASSWORD_HASH)) return null;
1719
+ return {
1720
+ sub: env$2.DID,
1721
+ handle: env$2.HANDLE
1722
+ };
1723
+ }
1724
+ });
1725
+ }
1726
+ let getAccountDO$1;
1727
+ /**
1728
+ * Create OAuth routes for the PDS
1729
+ *
1730
+ * This creates a Hono sub-app with all OAuth endpoints:
1731
+ * - GET /.well-known/oauth-authorization-server - Server metadata
1732
+ * - GET /oauth/authorize - Authorization endpoint
1733
+ * - POST /oauth/authorize - Handle authorization consent
1734
+ * - POST /oauth/token - Token endpoint
1735
+ * - POST /oauth/par - Pushed Authorization Request
1736
+ *
1737
+ * @param accountDOGetter Function to get the account DO stub
1738
+ */
1739
+ function createOAuthApp(accountDOGetter) {
1740
+ getAccountDO$1 = accountDOGetter;
1741
+ const oauth = new Hono();
1742
+ oauth.get("/.well-known/oauth-authorization-server", (c) => {
1743
+ return getProvider(c.env).handleMetadata();
1744
+ });
1745
+ oauth.get("/.well-known/oauth-protected-resource", (c) => {
1746
+ const issuer = `https://${c.env.PDS_HOSTNAME}`;
1747
+ return c.json({
1748
+ resource: issuer,
1749
+ authorization_servers: [issuer],
1750
+ scopes_supported: [
1751
+ "atproto",
1752
+ "transition:generic",
1753
+ "transition:chat.bsky"
1754
+ ]
1755
+ });
1756
+ });
1757
+ oauth.get("/oauth/authorize", async (c) => {
1758
+ return getProvider(c.env).handleAuthorize(c.req.raw);
1759
+ });
1760
+ oauth.post("/oauth/authorize", async (c) => {
1761
+ return getProvider(c.env).handleAuthorize(c.req.raw);
1762
+ });
1763
+ oauth.post("/oauth/token", async (c) => {
1764
+ return getProvider(c.env).handleToken(c.req.raw);
1765
+ });
1766
+ oauth.post("/oauth/par", async (c) => {
1767
+ return getProvider(c.env).handlePAR(c.req.raw);
1768
+ });
1769
+ oauth.post("/oauth/revoke", async (c) => {
1770
+ const contentType = c.req.header("Content-Type") ?? "";
1771
+ let token;
1772
+ try {
1773
+ if (contentType.includes("application/json")) token = (await c.req.json()).token;
1774
+ else if (contentType.includes("application/x-www-form-urlencoded")) {
1775
+ const body = await c.req.text();
1776
+ token = Object.fromEntries(new URLSearchParams(body).entries()).token;
1777
+ } else if (!contentType) token = void 0;
1778
+ else return c.json({
1779
+ error: "invalid_request",
1780
+ error_description: "Content-Type must be application/x-www-form-urlencoded (per RFC 7009) or application/json"
1781
+ }, 400);
1782
+ } catch {
1783
+ return c.json({
1784
+ error: "invalid_request",
1785
+ error_description: "Failed to parse request body"
1786
+ }, 400);
1787
+ }
1788
+ if (!token) return c.json({});
1789
+ const accountDO = getAccountDO$1(c.env);
1790
+ await accountDO.rpcRevokeToken(token);
1791
+ const tokenData = await accountDO.rpcGetTokenByRefresh(token);
1792
+ if (tokenData) await accountDO.rpcRevokeToken(tokenData.accessToken);
1793
+ return c.json({});
1794
+ });
1795
+ return oauth;
1796
+ }
1797
+
1098
1798
  //#endregion
1099
1799
  //#region src/middleware/auth.ts
1100
1800
  async function requireAuth(c, next) {
1101
1801
  const auth = c.req.header("Authorization");
1102
- if (!auth?.startsWith("Bearer ")) return c.json({
1802
+ if (!auth) return c.json({
1103
1803
  error: "AuthMissing",
1104
1804
  message: "Authorization header required"
1105
1805
  }, 401);
1806
+ if (auth.startsWith("DPoP ")) {
1807
+ const tokenData = await getProvider(c.env).verifyAccessToken(c.req.raw);
1808
+ if (!tokenData) return c.json({
1809
+ error: "AuthenticationRequired",
1810
+ message: "Invalid OAuth access token"
1811
+ }, 401);
1812
+ c.set("auth", {
1813
+ did: tokenData.sub,
1814
+ scope: tokenData.scope
1815
+ });
1816
+ return next();
1817
+ }
1818
+ if (!auth.startsWith("Bearer ")) return c.json({
1819
+ error: "AuthMissing",
1820
+ message: "Invalid authorization scheme"
1821
+ }, 401);
1106
1822
  const token = auth.slice(7);
1107
1823
  if (token === c.env.AUTH_TOKEN) {
1108
1824
  c.set("auth", {
@@ -1345,28 +2061,33 @@ async function handleXrpcProxy(c, didResolver$1, getKeypair$1) {
1345
2061
  const endpoint = isChat ? "https://api.bsky.chat" : "https://api.bsky.app";
1346
2062
  targetUrl = new URL(`/xrpc/${lxm}${url.search}`, endpoint);
1347
2063
  }
1348
- const auth = c.req.header("Authorization");
1349
2064
  let headers = {};
1350
- if (auth?.startsWith("Bearer ")) {
2065
+ const auth = c.req.header("Authorization");
2066
+ let userDid;
2067
+ if (auth?.startsWith("DPoP ")) try {
2068
+ const tokenData = await getProvider(c.env).verifyAccessToken(c.req.raw);
2069
+ if (tokenData) userDid = tokenData.sub;
2070
+ } catch {}
2071
+ else if (auth?.startsWith("Bearer ")) {
1351
2072
  const token = auth.slice(7);
1352
2073
  const serviceDid = `did:web:${c.env.PDS_HOSTNAME}`;
1353
2074
  try {
1354
- let userDid;
1355
2075
  if (token === c.env.AUTH_TOKEN) userDid = c.env.DID;
1356
2076
  else {
1357
2077
  const payload = await verifyAccessToken(token, c.env.JWT_SECRET, serviceDid);
1358
- if (!payload.sub) throw new Error("Missing sub claim in token");
1359
- userDid = payload.sub;
2078
+ if (payload.sub) userDid = payload.sub;
1360
2079
  }
1361
- const keypair = await getKeypair$1();
1362
- headers["Authorization"] = `Bearer ${await createServiceJwt({
1363
- iss: userDid,
1364
- aud: audienceDid,
1365
- lxm,
1366
- keypair
1367
- })}`;
1368
2080
  } catch {}
1369
2081
  }
2082
+ if (userDid) try {
2083
+ const keypair = await getKeypair$1();
2084
+ headers["Authorization"] = `Bearer ${await createServiceJwt({
2085
+ iss: userDid,
2086
+ aud: audienceDid,
2087
+ lxm,
2088
+ keypair
2089
+ })}`;
2090
+ } catch {}
1370
2091
  const forwardHeaders = new Headers(c.req.raw.headers);
1371
2092
  for (const header of [
1372
2093
  "authorization",
@@ -1483,6 +2204,38 @@ async function listBlobs(c, _accountDO) {
1483
2204
  if (listed.truncated && listed.cursor) result.cursor = listed.cursor;
1484
2205
  return c.json(result);
1485
2206
  }
2207
+ async function getBlocks(c, accountDO) {
2208
+ const did = c.req.query("did");
2209
+ const cidsParam = c.req.queries("cids");
2210
+ if (!did) return c.json({
2211
+ error: "InvalidRequest",
2212
+ message: "Missing required parameter: did"
2213
+ }, 400);
2214
+ if (!cidsParam || cidsParam.length === 0) return c.json({
2215
+ error: "InvalidRequest",
2216
+ message: "Missing required parameter: cids"
2217
+ }, 400);
2218
+ try {
2219
+ ensureValidDid(did);
2220
+ } catch (err) {
2221
+ return c.json({
2222
+ error: "InvalidRequest",
2223
+ message: `Invalid DID format: ${err instanceof Error ? err.message : String(err)}`
2224
+ }, 400);
2225
+ }
2226
+ if (did !== c.env.DID) return c.json({
2227
+ error: "RepoNotFound",
2228
+ message: `Repository not found for DID: ${did}`
2229
+ }, 404);
2230
+ const carBytes = await accountDO.rpcGetBlocks(cidsParam);
2231
+ return new Response(carBytes, {
2232
+ status: 200,
2233
+ headers: {
2234
+ "Content-Type": "application/vnd.ipld.car",
2235
+ "Content-Length": carBytes.length.toString()
2236
+ }
2237
+ });
2238
+ }
1486
2239
  async function getBlob(c, _accountDO) {
1487
2240
  const did = c.req.query("did");
1488
2241
  const cid = c.req.query("cid");
@@ -5066,6 +5819,17 @@ async function importRepo(c, accountDO) {
5066
5819
  throw err;
5067
5820
  }
5068
5821
  }
5822
+ /**
5823
+ * List blobs that are referenced in records but not yet imported.
5824
+ * Used during migration to track which blobs still need to be uploaded.
5825
+ */
5826
+ async function listMissingBlobs(c, accountDO) {
5827
+ const limitStr = c.req.query("limit");
5828
+ const cursor = c.req.query("cursor");
5829
+ const limit = limitStr ? Math.min(Number.parseInt(limitStr, 10), 500) : 500;
5830
+ const result = await accountDO.rpcListMissingBlobs(limit, cursor || void 0);
5831
+ return c.json(result);
5832
+ }
5069
5833
 
5070
5834
  //#endregion
5071
5835
  //#region src/xrpc/server.ts
@@ -5089,7 +5853,7 @@ async function createSession(c) {
5089
5853
  error: "AuthenticationRequired",
5090
5854
  message: "Invalid identifier or password"
5091
5855
  }, 401);
5092
- if (!await compare(password, c.env.PASSWORD_HASH)) return c.json({
5856
+ if (!await compare$1(password, c.env.PASSWORD_HASH)) return c.json({
5093
5857
  error: "AuthenticationRequired",
5094
5858
  message: "Invalid identifier or password"
5095
5859
  }, 401);
@@ -5176,31 +5940,40 @@ async function deleteSession(c) {
5176
5940
  return c.json({});
5177
5941
  }
5178
5942
  /**
5179
- * Get account status - used for migration checks
5943
+ * Get account status - used for migration checks and progress tracking
5180
5944
  */
5181
5945
  async function getAccountStatus(c, accountDO) {
5182
5946
  try {
5183
5947
  const status = await accountDO.rpcGetRepoStatus();
5948
+ const active = await accountDO.rpcGetActive();
5949
+ const [repoBlocks, indexedRecords, expectedBlobs, importedBlobs] = await Promise.all([
5950
+ accountDO.rpcCountBlocks(),
5951
+ accountDO.rpcCountRecords(),
5952
+ accountDO.rpcCountExpectedBlobs(),
5953
+ accountDO.rpcCountImportedBlobs()
5954
+ ]);
5184
5955
  return c.json({
5185
- activated: true,
5956
+ active,
5186
5957
  validDid: true,
5958
+ repoCommit: status.head,
5187
5959
  repoRev: status.rev,
5188
- repoBlocks: null,
5189
- indexedRecords: null,
5960
+ repoBlocks,
5961
+ indexedRecords,
5190
5962
  privateStateValues: null,
5191
- expectedBlobs: null,
5192
- importedBlobs: null
5963
+ expectedBlobs,
5964
+ importedBlobs
5193
5965
  });
5194
5966
  } catch (err) {
5195
5967
  return c.json({
5196
- activated: false,
5968
+ active: false,
5197
5969
  validDid: true,
5970
+ repoCommit: null,
5198
5971
  repoRev: null,
5199
- repoBlocks: null,
5200
- indexedRecords: null,
5972
+ repoBlocks: 0,
5973
+ indexedRecords: 0,
5201
5974
  privateStateValues: null,
5202
- expectedBlobs: null,
5203
- importedBlobs: null
5975
+ expectedBlobs: 0,
5976
+ importedBlobs: 0
5204
5977
  });
5205
5978
  }
5206
5979
  }
@@ -5224,10 +5997,58 @@ async function getServiceAuth(c) {
5224
5997
  });
5225
5998
  return c.json({ token });
5226
5999
  }
6000
+ /**
6001
+ * Activate account - enables writes and firehose events
6002
+ */
6003
+ async function activateAccount(c, accountDO) {
6004
+ try {
6005
+ await accountDO.rpcActivateAccount();
6006
+ return c.json({ success: true });
6007
+ } catch (err) {
6008
+ return c.json({
6009
+ error: "InternalServerError",
6010
+ message: err instanceof Error ? err.message : "Unknown error"
6011
+ }, 500);
6012
+ }
6013
+ }
6014
+ /**
6015
+ * Deactivate account - disables writes while keeping reads available
6016
+ */
6017
+ async function deactivateAccount(c, accountDO) {
6018
+ try {
6019
+ await accountDO.rpcDeactivateAccount();
6020
+ return c.json({ success: true });
6021
+ } catch (err) {
6022
+ return c.json({
6023
+ error: "InternalServerError",
6024
+ message: err instanceof Error ? err.message : "Unknown error"
6025
+ }, 500);
6026
+ }
6027
+ }
6028
+ /**
6029
+ * Reset migration state - clears imported repo and blob tracking.
6030
+ * Only works on deactivated accounts.
6031
+ */
6032
+ async function resetMigration(c, accountDO) {
6033
+ try {
6034
+ const result = await accountDO.rpcResetMigration();
6035
+ return c.json(result);
6036
+ } catch (err) {
6037
+ const message = err instanceof Error ? err.message : "Unknown error";
6038
+ if (message.includes("AccountActive")) return c.json({
6039
+ error: "AccountActive",
6040
+ message: "Cannot reset migration on an active account. Deactivate first."
6041
+ }, 400);
6042
+ return c.json({
6043
+ error: "InternalServerError",
6044
+ message
6045
+ }, 500);
6046
+ }
6047
+ }
5227
6048
 
5228
6049
  //#endregion
5229
6050
  //#region package.json
5230
- var version = "0.1.0";
6051
+ var version = "0.2.0";
5231
6052
 
5232
6053
  //#endregion
5233
6054
  //#region src/index.ts
@@ -5309,6 +6130,7 @@ app.get("/health", (c) => c.json({
5309
6130
  }));
5310
6131
  app.get("/xrpc/com.atproto.sync.getRepo", (c) => getRepo(c, getAccountDO(c.env)));
5311
6132
  app.get("/xrpc/com.atproto.sync.getRepoStatus", (c) => getRepoStatus(c, getAccountDO(c.env)));
6133
+ app.get("/xrpc/com.atproto.sync.getBlocks", (c) => getBlocks(c, getAccountDO(c.env)));
5312
6134
  app.get("/xrpc/com.atproto.sync.getBlob", (c) => getBlob(c, getAccountDO(c.env)));
5313
6135
  app.get("/xrpc/com.atproto.sync.listRepos", (c) => listRepos(c, getAccountDO(c.env)));
5314
6136
  app.get("/xrpc/com.atproto.sync.listBlobs", (c) => listBlobs(c, getAccountDO(c.env)));
@@ -5328,6 +6150,7 @@ app.post("/xrpc/com.atproto.repo.uploadBlob", requireAuth, (c) => uploadBlob(c,
5328
6150
  app.post("/xrpc/com.atproto.repo.applyWrites", requireAuth, (c) => applyWrites(c, getAccountDO(c.env)));
5329
6151
  app.post("/xrpc/com.atproto.repo.putRecord", requireAuth, (c) => putRecord(c, getAccountDO(c.env)));
5330
6152
  app.post("/xrpc/com.atproto.repo.importRepo", requireAuth, (c) => importRepo(c, getAccountDO(c.env)));
6153
+ app.get("/xrpc/com.atproto.repo.listMissingBlobs", requireAuth, (c) => listMissingBlobs(c, getAccountDO(c.env)));
5331
6154
  app.get("/xrpc/com.atproto.server.describeServer", describeServer);
5332
6155
  app.use("/xrpc/com.atproto.identity.resolveHandle", async (c, next) => {
5333
6156
  if (c.req.query("handle") === c.env.HANDLE) return c.json({ did: c.env.DID });
@@ -5338,6 +6161,9 @@ app.post("/xrpc/com.atproto.server.refreshSession", refreshSession);
5338
6161
  app.get("/xrpc/com.atproto.server.getSession", getSession);
5339
6162
  app.post("/xrpc/com.atproto.server.deleteSession", deleteSession);
5340
6163
  app.get("/xrpc/com.atproto.server.getAccountStatus", requireAuth, (c) => getAccountStatus(c, getAccountDO(c.env)));
6164
+ app.post("/xrpc/com.atproto.server.activateAccount", requireAuth, (c) => activateAccount(c, getAccountDO(c.env)));
6165
+ app.post("/xrpc/com.atproto.server.deactivateAccount", requireAuth, (c) => deactivateAccount(c, getAccountDO(c.env)));
6166
+ app.post("/xrpc/gg.mk.experimental.resetMigration", requireAuth, (c) => resetMigration(c, getAccountDO(c.env)));
5341
6167
  app.get("/xrpc/com.atproto.server.getServiceAuth", requireAuth, getServiceAuth);
5342
6168
  app.get("/xrpc/app.bsky.actor.getPreferences", requireAuth, async (c) => {
5343
6169
  const result = await getAccountDO(c.env).rpcGetPreferences();
@@ -5362,6 +6188,8 @@ app.post("/admin/emit-identity", requireAuth, async (c) => {
5362
6188
  const result = await getAccountDO(c.env).rpcEmitIdentityEvent(c.env.HANDLE);
5363
6189
  return c.json(result);
5364
6190
  });
6191
+ const oauthApp = createOAuthApp(getAccountDO);
6192
+ app.route("/", oauthApp);
5365
6193
  app.all("/xrpc/*", (c) => handleXrpcProxy(c, didResolver, getKeypair));
5366
6194
  var src_default = app;
5367
6195