@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/cli.js +1165 -59
- package/dist/index.d.ts +183 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +865 -37
- package/dist/index.js.map +1 -1
- package/package.json +11 -3
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
5956
|
+
active,
|
|
5186
5957
|
validDid: true,
|
|
5958
|
+
repoCommit: status.head,
|
|
5187
5959
|
repoRev: status.rev,
|
|
5188
|
-
repoBlocks
|
|
5189
|
-
indexedRecords
|
|
5960
|
+
repoBlocks,
|
|
5961
|
+
indexedRecords,
|
|
5190
5962
|
privateStateValues: null,
|
|
5191
|
-
expectedBlobs
|
|
5192
|
-
importedBlobs
|
|
5963
|
+
expectedBlobs,
|
|
5964
|
+
importedBlobs
|
|
5193
5965
|
});
|
|
5194
5966
|
} catch (err) {
|
|
5195
5967
|
return c.json({
|
|
5196
|
-
|
|
5968
|
+
active: false,
|
|
5197
5969
|
validDid: true,
|
|
5970
|
+
repoCommit: null,
|
|
5198
5971
|
repoRev: null,
|
|
5199
|
-
repoBlocks:
|
|
5200
|
-
indexedRecords:
|
|
5972
|
+
repoBlocks: 0,
|
|
5973
|
+
indexedRecords: 0,
|
|
5201
5974
|
privateStateValues: null,
|
|
5202
|
-
expectedBlobs:
|
|
5203
|
-
importedBlobs:
|
|
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.
|
|
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
|
|