@alteran/astro 0.7.6 → 0.8.1

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.
Files changed (75) hide show
  1. package/README.md +25 -25
  2. package/migrations/0010_eminent_klaw.sql +37 -0
  3. package/migrations/0011_chief_darwin.sql +31 -0
  4. package/migrations/0012_backfill_blob_usage.sql +39 -0
  5. package/migrations/meta/0010_snapshot.json +790 -0
  6. package/migrations/meta/0011_snapshot.json +813 -0
  7. package/migrations/meta/_journal.json +22 -1
  8. package/package.json +24 -41
  9. package/src/db/blob.ts +323 -0
  10. package/src/db/dal.ts +224 -78
  11. package/src/db/repo.ts +205 -25
  12. package/src/db/schema.ts +14 -5
  13. package/src/handlers/debug.ts +4 -3
  14. package/src/lib/appview/auth-policy.ts +7 -24
  15. package/src/lib/appview/proxy.ts +56 -23
  16. package/src/lib/appview/types.ts +1 -6
  17. package/src/lib/auth-scope.ts +399 -0
  18. package/src/lib/auth.ts +40 -39
  19. package/src/lib/commit.ts +37 -15
  20. package/src/lib/did-document.ts +4 -5
  21. package/src/lib/jwt.ts +3 -1
  22. package/src/lib/mime.ts +9 -0
  23. package/src/lib/oauth/observability.ts +53 -12
  24. package/src/lib/oauth/resource.ts +49 -0
  25. package/src/lib/preference-policy.ts +45 -0
  26. package/src/lib/preferences.ts +0 -4
  27. package/src/lib/public-host.ts +127 -0
  28. package/src/lib/ratelimit.ts +37 -12
  29. package/src/lib/relay.ts +7 -27
  30. package/src/lib/repo-write-blob-constraints.ts +141 -0
  31. package/src/lib/repo-write-data.ts +195 -0
  32. package/src/lib/repo-write-error.ts +46 -0
  33. package/src/lib/repo-write-validation.ts +463 -0
  34. package/src/lib/session-tokens.ts +22 -5
  35. package/src/lib/unsupported-routes.ts +32 -0
  36. package/src/lib/util.ts +57 -2
  37. package/src/pages/.well-known/atproto-did.ts +15 -3
  38. package/src/pages/.well-known/did.json.ts +13 -7
  39. package/src/pages/debug/db/bootstrap.ts +4 -3
  40. package/src/pages/debug/gc/blobs.ts +11 -8
  41. package/src/pages/debug/record.ts +11 -0
  42. package/src/pages/oauth/token.ts +78 -33
  43. package/src/pages/xrpc/[...nsid].ts +17 -9
  44. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
  45. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
  46. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
  47. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
  48. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
  49. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
  50. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
  51. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
  52. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
  53. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
  54. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
  55. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
  56. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
  57. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
  58. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
  59. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
  60. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
  61. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
  62. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
  63. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
  64. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
  65. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
  66. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
  67. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
  68. package/src/services/car.ts +13 -0
  69. package/src/services/repo/apply-prepared-writes.ts +185 -0
  70. package/src/services/repo/blob-refs.ts +48 -0
  71. package/src/services/repo/blockstore-ops.ts +59 -17
  72. package/src/services/repo/list-blobs.ts +43 -0
  73. package/src/services/repo-manager.ts +221 -78
  74. package/src/worker/runtime.ts +1 -1
  75. package/src/worker/sequencer/upgrade.ts +4 -1
@@ -0,0 +1,463 @@
1
+ import { lexicons, jsonToLex } from '@atproto/api';
2
+ import { isValidNsid, isValidRecordKey, isValidTid } from '@atproto/syntax';
3
+ import { CID } from 'multiformats/cid';
4
+ import type { Env } from '../env';
5
+ import { resolveSecret } from './secrets';
6
+ import { generateTid } from './commit';
7
+ import { RepoManager } from '../services/repo-manager';
8
+ import { resolveRecordBlobKeys } from '../services/repo/blob-refs';
9
+ import { validateRawRecord } from './repo-write-data';
10
+ import { RepoWriteError } from './repo-write-error';
11
+ import { enforceRepoWriteLexiconConstraints } from './repo-write-blob-constraints';
12
+
13
+ export { handleRepoWriteError, invalidSwap, jsonError, RepoWriteError } from './repo-write-error';
14
+
15
+ export type ValidationStatus = 'valid' | 'unknown' | undefined;
16
+ export type RepoWriteAction = 'create' | 'update' | 'delete';
17
+
18
+ export type PreparedRecordWrite = {
19
+ action: Extract<RepoWriteAction, 'create' | 'update'>;
20
+ collection: string;
21
+ rkey: string;
22
+ record: Record<string, unknown>;
23
+ validationStatus: ValidationStatus;
24
+ blobKeys: string[];
25
+ };
26
+
27
+ export type PreparedDeleteWrite = {
28
+ action: 'delete';
29
+ collection: string;
30
+ rkey: string;
31
+ };
32
+
33
+ export type PreparedWrite = PreparedRecordWrite | PreparedDeleteWrite;
34
+
35
+ export type RepoWriteContext = {
36
+ did: string;
37
+ handle: string | undefined;
38
+ currentCommitCid: string | null;
39
+ repo: RepoManager;
40
+ };
41
+
42
+ export type RepoWriteContextWithSwap = RepoWriteContext & {
43
+ expectedCommitCid: string | null | undefined;
44
+ };
45
+
46
+ type AuthLike = { did: string };
47
+
48
+ export type RequestedWriteAuthorization = {
49
+ collection: string;
50
+ action: RepoWriteAction;
51
+ };
52
+
53
+ export function hasSwapCommit(input: Record<string, unknown>): boolean {
54
+ return Object.prototype.hasOwnProperty.call(input, 'swapCommit');
55
+ }
56
+
57
+ export async function retryNoSwapCommit<T>(
58
+ input: Record<string, unknown>,
59
+ write: () => Promise<T>,
60
+ ): Promise<T> {
61
+ const canRetry = !hasSwapCommit(input);
62
+ const maxAttempts = canRetry ? 3 : 1;
63
+ let lastError: unknown;
64
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
65
+ try {
66
+ return await write();
67
+ } catch (error) {
68
+ lastError = error;
69
+ if (!canRetry || !isRepoCommitConflict(error)) break;
70
+ }
71
+ }
72
+ throw lastError;
73
+ }
74
+
75
+ export async function buildRepoWriteContext(
76
+ env: Env,
77
+ auth: AuthLike,
78
+ repoValue: unknown,
79
+ ): Promise<RepoWriteContext> {
80
+ const did = await resolveSecret(env.PDS_DID);
81
+ if (!did) throw new RepoWriteError('InvalidRequest', 'PDS_DID is not configured');
82
+ const handle = await resolveSecret(env.PDS_HANDLE);
83
+
84
+ if (auth.did !== did) {
85
+ throw new RepoWriteError('InvalidRequest', 'authenticated user does not own this repo');
86
+ }
87
+
88
+ if (typeof repoValue !== 'string' || repoValue.length === 0) {
89
+ throw new RepoWriteError('InvalidRequest', 'repo is required');
90
+ }
91
+ const normalizedRepo = repoValue.toLowerCase();
92
+ const normalizedHandle = typeof handle === 'string' ? handle.toLowerCase() : undefined;
93
+ if (repoValue !== did && normalizedRepo !== normalizedHandle) {
94
+ throw new RepoWriteError('InvalidRequest', 'repo is not hosted by this PDS');
95
+ }
96
+
97
+ return {
98
+ did,
99
+ handle,
100
+ currentCommitCid: await getCurrentCommitCid(env, did),
101
+ repo: new RepoManager(env),
102
+ };
103
+ }
104
+
105
+ export async function prepareCreateRecord(
106
+ env: Env,
107
+ auth: AuthLike,
108
+ input: unknown,
109
+ ): Promise<RepoWriteContextWithSwap & { write: PreparedRecordWrite }> {
110
+ const body = assertRepoWriteInput('com.atproto.repo.createRecord', input);
111
+ const ctx = await buildRepoWriteContext(env, auth, body.repo);
112
+ const expectedCommitCid = expectedCommitCidForRequest(body, ctx.currentCommitCid);
113
+
114
+ const collection = requireString(body.collection, 'collection');
115
+ const rkey = typeof body.rkey === 'string' ? body.rkey : generateTid();
116
+ validatePath(collection, rkey);
117
+
118
+ const currentCid = await ctx.repo.getRecordCid(collection, rkey);
119
+ if (currentCid) {
120
+ throw new RepoWriteError('InvalidRequest', 'record already exists');
121
+ }
122
+
123
+ return {
124
+ ...ctx,
125
+ expectedCommitCid,
126
+ write: await prepareRecordWrite(env, ctx.did, {
127
+ action: 'create',
128
+ collection,
129
+ rkey,
130
+ record: body.record,
131
+ validate: body.validate,
132
+ }),
133
+ };
134
+ }
135
+
136
+ export async function preparePutRecord(
137
+ env: Env,
138
+ auth: AuthLike,
139
+ input: unknown,
140
+ ): Promise<RepoWriteContextWithSwap & { write: PreparedRecordWrite }> {
141
+ const body = assertRepoWriteInput('com.atproto.repo.putRecord', input);
142
+ const ctx = await buildRepoWriteContext(env, auth, body.repo);
143
+
144
+ const collection = requireString(body.collection, 'collection');
145
+ const rkey = requireString(body.rkey, 'rkey');
146
+ validatePath(collection, rkey);
147
+
148
+ const currentCid = await ctx.repo.getRecordCid(collection, rkey);
149
+ const write = await prepareRecordWrite(env, ctx.did, {
150
+ action: currentCid ? 'update' : 'create',
151
+ collection,
152
+ rkey,
153
+ record: body.record,
154
+ validate: body.validate,
155
+ });
156
+ const expectedCommitCid = expectedCommitCidForRequest(body, ctx.currentCommitCid);
157
+ checkSwapRecord(body.swapRecord, currentCid, true);
158
+
159
+ return {
160
+ ...ctx,
161
+ expectedCommitCid,
162
+ write,
163
+ };
164
+ }
165
+
166
+ export async function prepareDeleteRecord(
167
+ env: Env,
168
+ auth: AuthLike,
169
+ input: unknown,
170
+ ): Promise<RepoWriteContextWithSwap & { write: PreparedDeleteWrite; currentCid: CID | null }> {
171
+ const body = assertRepoWriteInput('com.atproto.repo.deleteRecord', input);
172
+ const ctx = await buildRepoWriteContext(env, auth, body.repo);
173
+
174
+ const collection = requireString(body.collection, 'collection');
175
+ const rkey = requireString(body.rkey, 'rkey');
176
+ validatePath(collection, rkey);
177
+
178
+ const currentCid = await ctx.repo.getRecordCid(collection, rkey);
179
+ const expectedCommitCid = expectedCommitCidForRequest(body, ctx.currentCommitCid);
180
+ checkSwapRecord(body.swapRecord, currentCid, false);
181
+
182
+ return {
183
+ ...ctx,
184
+ expectedCommitCid,
185
+ currentCid,
186
+ write: { action: 'delete', collection, rkey },
187
+ };
188
+ }
189
+
190
+ export async function prepareApplyWrites(
191
+ env: Env,
192
+ auth: AuthLike,
193
+ input: unknown,
194
+ ): Promise<RepoWriteContextWithSwap & { writes: PreparedWrite[] }> {
195
+ const body = assertRepoWriteInput('com.atproto.repo.applyWrites', input);
196
+ const ctx = await buildRepoWriteContext(env, auth, body.repo);
197
+ const expectedCommitCid = expectedCommitCidForRequest(body, ctx.currentCommitCid);
198
+
199
+ const rawWrites = Array.isArray(body.writes) ? body.writes : [];
200
+ if (rawWrites.length > 200) {
201
+ throw new RepoWriteError('InvalidRequest', 'Too many writes. Max: 200');
202
+ }
203
+ const prepared: PreparedWrite[] = [];
204
+ const state = new Map<string, boolean>();
205
+
206
+ for (const raw of rawWrites) {
207
+ const write = raw as Record<string, unknown>;
208
+ const type = write.$type;
209
+ const collection = requireString(write.collection, 'collection');
210
+ const rkey = typeof write.rkey === 'string' ? write.rkey : generateTid();
211
+ validatePath(collection, rkey);
212
+ const path = repoPath(collection, rkey);
213
+
214
+ let exists = state.get(path);
215
+ if (exists === undefined) {
216
+ exists = !!(await ctx.repo.getRecordCid(collection, rkey));
217
+ }
218
+
219
+ if (type === 'com.atproto.repo.applyWrites#create') {
220
+ if (exists) throw new RepoWriteError('InvalidRequest', 'record already exists');
221
+ const preparedWrite = await prepareRecordWrite(env, ctx.did, {
222
+ action: 'create',
223
+ collection,
224
+ rkey,
225
+ record: write.value,
226
+ validate: body.validate,
227
+ });
228
+ prepared.push(preparedWrite);
229
+ state.set(path, true);
230
+ continue;
231
+ }
232
+
233
+ if (type === 'com.atproto.repo.applyWrites#update') {
234
+ if (!exists) throw new RepoWriteError('InvalidRequest', 'record does not exist');
235
+ const preparedWrite = await prepareRecordWrite(env, ctx.did, {
236
+ action: 'update',
237
+ collection,
238
+ rkey,
239
+ record: write.value,
240
+ validate: body.validate,
241
+ });
242
+ prepared.push(preparedWrite);
243
+ state.set(path, true);
244
+ continue;
245
+ }
246
+
247
+ if (type === 'com.atproto.repo.applyWrites#delete') {
248
+ if (!exists) throw new RepoWriteError('InvalidRequest', 'record does not exist');
249
+ prepared.push({ action: 'delete', collection, rkey });
250
+ state.set(path, false);
251
+ continue;
252
+ }
253
+
254
+ throw new RepoWriteError('InvalidRequest', 'unsupported write type');
255
+ }
256
+
257
+ return { ...ctx, expectedCommitCid, writes: prepared };
258
+ }
259
+
260
+ export function repoPath(collection: string, rkey: string): string {
261
+ return `${collection}/${rkey}`;
262
+ }
263
+
264
+ export function createRecordAuthorizations(
265
+ input: Record<string, unknown>,
266
+ ): RequestedWriteAuthorization[] {
267
+ return [{ collection: requireString(input.collection, 'collection'), action: 'create' }];
268
+ }
269
+
270
+ export function putRecordAuthorizations(
271
+ input: Record<string, unknown>,
272
+ ): RequestedWriteAuthorization[] {
273
+ const collection = requireString(input.collection, 'collection');
274
+ if (input.swapRecord === null) {
275
+ return [{ collection, action: 'create' }];
276
+ }
277
+ if (typeof input.swapRecord === 'string') {
278
+ return [{ collection, action: 'update' }];
279
+ }
280
+ return [
281
+ { collection, action: 'create' },
282
+ { collection, action: 'update' },
283
+ ];
284
+ }
285
+
286
+ export function deleteRecordAuthorizations(
287
+ input: Record<string, unknown>,
288
+ ): RequestedWriteAuthorization[] {
289
+ return [{ collection: requireString(input.collection, 'collection'), action: 'delete' }];
290
+ }
291
+
292
+ export function applyWritesAuthorizations(
293
+ input: Record<string, unknown>,
294
+ ): RequestedWriteAuthorization[] {
295
+ const rawWrites = Array.isArray(input.writes) ? input.writes : [];
296
+ return rawWrites.map((raw) => {
297
+ const write = raw as Record<string, unknown>;
298
+ const collection = requireString(write.collection, 'collection');
299
+ switch (write.$type) {
300
+ case 'com.atproto.repo.applyWrites#create':
301
+ return { collection, action: 'create' };
302
+ case 'com.atproto.repo.applyWrites#update':
303
+ return { collection, action: 'update' };
304
+ case 'com.atproto.repo.applyWrites#delete':
305
+ return { collection, action: 'delete' };
306
+ default:
307
+ throw new RepoWriteError('InvalidRequest', 'unsupported write type');
308
+ }
309
+ });
310
+ }
311
+
312
+ export function assertRepoWriteInput(lexUri: string, input: unknown): Record<string, unknown> {
313
+ try {
314
+ return lexicons.assertValidXrpcInput(lexUri, input) as Record<string, unknown>;
315
+ } catch (error) {
316
+ throw new RepoWriteError('InvalidRequest', error instanceof Error ? error.message : 'invalid input');
317
+ }
318
+ }
319
+
320
+ function requireString(value: unknown, field: string): string {
321
+ if (typeof value !== 'string' || value.length === 0) {
322
+ throw new RepoWriteError('InvalidRequest', `${field} is required`);
323
+ }
324
+ return value;
325
+ }
326
+
327
+ function validatePath(collection: string, rkey: string): void {
328
+ if (!isValidNsid(collection)) {
329
+ throw new RepoWriteError('InvalidRequest', 'collection must be a valid NSID');
330
+ }
331
+ if (!isValidRecordKey(rkey)) {
332
+ throw new RepoWriteError('InvalidRequest', 'rkey must be a valid record key');
333
+ }
334
+ }
335
+
336
+ async function prepareRecordWrite(
337
+ env: Env,
338
+ did: string,
339
+ input: {
340
+ action: Extract<RepoWriteAction, 'create' | 'update'>;
341
+ collection: string;
342
+ rkey: string;
343
+ record: unknown;
344
+ validate: unknown;
345
+ },
346
+ ): Promise<PreparedRecordWrite> {
347
+ const record = validateRawRecord(input.collection, input.record);
348
+ const validationStatus = validateLexiconRecord(
349
+ input.collection,
350
+ input.rkey,
351
+ record,
352
+ input.validate,
353
+ );
354
+ const blobKeys = await validateBlobRefs(env, did, record);
355
+ return { ...input, record, validationStatus, blobKeys };
356
+ }
357
+
358
+ function validateLexiconRecord(
359
+ collection: string,
360
+ rkey: string,
361
+ record: Record<string, unknown>,
362
+ validate: unknown,
363
+ ): ValidationStatus {
364
+ if (validate === false) return undefined;
365
+
366
+ const def = lexicons.getDef(collection);
367
+ const knownRecord = def?.type === 'record' ? def : null;
368
+
369
+ if (!knownRecord) {
370
+ if (validate === true) {
371
+ throw new RepoWriteError('InvalidRequest', `Lexicon not found: ${collection}`);
372
+ }
373
+ return 'unknown';
374
+ }
375
+
376
+ enforceRecordKeyPolicy(knownRecord.key, rkey);
377
+
378
+ let result: ReturnType<typeof lexicons.validate>;
379
+ try {
380
+ result = lexicons.validate(collection, jsonToLex(record));
381
+ } catch (error) {
382
+ throw new RepoWriteError('InvalidRequest', error instanceof Error ? error.message : 'invalid record');
383
+ }
384
+ if (!result.success) {
385
+ throw new RepoWriteError('InvalidRequest', result.error?.message ?? 'invalid record');
386
+ }
387
+ enforceRepoWriteLexiconConstraints(knownRecord, record);
388
+ return 'valid';
389
+ }
390
+
391
+ function enforceRecordKeyPolicy(policy: unknown, rkey: string): void {
392
+ if (typeof policy !== 'string' || policy === '' || policy === 'any') return;
393
+ if (policy === 'tid') {
394
+ if (!isValidTid(rkey)) throw new RepoWriteError('InvalidRequest', 'rkey must be a valid TID');
395
+ return;
396
+ }
397
+ if (policy === 'nsid') {
398
+ if (!isValidNsid(rkey)) throw new RepoWriteError('InvalidRequest', 'rkey must be a valid NSID');
399
+ return;
400
+ }
401
+ if (policy.startsWith('literal:')) {
402
+ const expected = policy.slice('literal:'.length);
403
+ if (rkey !== expected) throw new RepoWriteError('InvalidRequest', `rkey must be ${expected}`);
404
+ }
405
+ }
406
+
407
+ function validateBlobRefs(
408
+ env: Env,
409
+ did: string,
410
+ record: Record<string, unknown>,
411
+ ): Promise<string[]> {
412
+ return resolveRecordBlobKeys(env, did, record);
413
+ }
414
+
415
+ function checkSwapCommit(value: unknown, currentCommitCid: string | null): void {
416
+ if (value === undefined) return;
417
+ const expected = parseSwapCid(value, 'swapCommit');
418
+ if (!currentCommitCid || !expected.equals(CID.parse(currentCommitCid))) {
419
+ throw new RepoWriteError('InvalidSwap', 'swapCommit mismatch');
420
+ }
421
+ }
422
+
423
+ function expectedCommitCidForRequest(
424
+ body: Record<string, unknown>,
425
+ currentCommitCid: string | null,
426
+ ): string | null | undefined {
427
+ if (hasSwapCommit(body)) checkSwapCommit(body.swapCommit, currentCommitCid);
428
+ return currentCommitCid;
429
+ }
430
+
431
+ function isRepoCommitConflict(error: unknown): boolean {
432
+ return error instanceof Error && error.name === 'RepoCommitConflictError';
433
+ }
434
+
435
+ function checkSwapRecord(value: unknown, currentCid: CID | null, nullable: boolean): void {
436
+ if (value === undefined) return;
437
+ if (value === null && nullable) {
438
+ if (currentCid) throw new RepoWriteError('InvalidSwap', 'swapRecord mismatch');
439
+ return;
440
+ }
441
+ const expected = parseSwapCid(value, 'swapRecord');
442
+ if (!currentCid || !expected.equals(currentCid)) {
443
+ throw new RepoWriteError('InvalidSwap', 'swapRecord mismatch');
444
+ }
445
+ }
446
+
447
+ async function getCurrentCommitCid(env: Env, did: string): Promise<string | null> {
448
+ const row = await env.ALTERAN_DB.prepare(
449
+ 'SELECT commit_cid AS commitCid FROM repo_root WHERE did = ? LIMIT 1',
450
+ ).bind(did).first<{ commitCid: string }>();
451
+ return row?.commitCid ?? null;
452
+ }
453
+
454
+ function parseSwapCid(value: unknown, field: 'swapCommit' | 'swapRecord'): CID {
455
+ if (typeof value !== 'string') {
456
+ throw new RepoWriteError('InvalidRequest', `${field} must be a CID`);
457
+ }
458
+ try {
459
+ return CID.parse(value);
460
+ } catch {
461
+ throw new RepoWriteError('InvalidRequest', `${field} must be a CID`);
462
+ }
463
+ }
@@ -4,11 +4,13 @@ import { getRuntimeString } from './secrets';
4
4
  import { getOrCreateSecret } from '../db/account';
5
5
  import { InvalidToken, ServerMisconfigured } from './errors';
6
6
  import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
7
+ import { AuthScope, isBearerAccessScope, isOAuthScope } from './auth-scope';
7
8
 
8
9
  const SESSION_SECRET_KEY = 'session_jwt_secret';
9
10
  const GRACE_PERIOD_SECONDS = 2 * 60 * 60;
10
11
  const ACCESS_TTL_SECONDS = 120 * 60; // 120 minutes
11
12
  const REFRESH_TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days
13
+ const LEGACY_REFRESH_SCOPE = 'refresh';
12
14
 
13
15
  async function loadSecret(env: Env): Promise<string> {
14
16
  const fromEnv = await getRuntimeString(env, 'SESSION_JWT_SECRET' as keyof Env, '');
@@ -45,10 +47,18 @@ export async function issueSessionTokens(env: Env, did: string, opts: IssueSessi
45
47
  const jwtKey = await getJwtKey(env);
46
48
  const serviceDid = await getServiceDid(env);
47
49
  const now = Math.floor(Date.now() / 1000);
50
+ const accessScope = opts.dpopJkt ? (opts.scope ?? 'atproto') : (opts.scope ?? AuthScope.Access);
51
+ if (opts.dpopJkt) {
52
+ if (!isOAuthScope(accessScope)) {
53
+ throw new InvalidToken('Invalid OAuth access token scope');
54
+ }
55
+ } else if (!isBearerAccessScope(accessScope)) {
56
+ throw new InvalidToken('Invalid access token scope');
57
+ }
48
58
 
49
59
  const accessExp = now + ACCESS_TTL_SECONDS;
50
60
  const accessPayload: TokenPayload = {
51
- scope: opts.dpopJkt ? (opts.scope ?? 'atproto') : 'access',
61
+ scope: accessScope,
52
62
  aud: serviceDid,
53
63
  sub: did,
54
64
  iat: now,
@@ -65,7 +75,7 @@ export async function issueSessionTokens(env: Env, did: string, opts: IssueSessi
65
75
  const jti = opts.jti ?? generateTokenId();
66
76
  const refreshExp = now + REFRESH_TTL_SECONDS;
67
77
  const refreshPayload: RefreshTokenPayload = {
68
- scope: 'refresh',
78
+ scope: AuthScope.Refresh,
69
79
  aud: serviceDid,
70
80
  sub: did,
71
81
  iat: now,
@@ -95,7 +105,9 @@ export async function verifyRefreshToken(env: Env, token: string, opts: { ignore
95
105
  if (header.typ !== 'refresh+jwt') {
96
106
  throw new InvalidToken('Invalid token type');
97
107
  }
98
- if (payload.scope !== 'refresh') {
108
+ // New refresh tokens use the ATProto scope string. Accept the previous local
109
+ // literal only so already-issued refresh credentials can rotate forward.
110
+ if (payload.scope !== AuthScope.Refresh && payload.scope !== LEGACY_REFRESH_SCOPE) {
99
111
  throw new InvalidToken('Invalid refresh token scope');
100
112
  }
101
113
  return {
@@ -116,8 +128,13 @@ export async function verifyAccessToken(env: Env, token: string) {
116
128
  if (header.typ !== 'at+jwt') {
117
129
  throw new InvalidToken('Invalid token type');
118
130
  }
119
- if (payload.scope === 'refresh') {
120
- throw new InvalidToken('Unexpected scope for access token');
131
+ const isOAuthToken = typeof (payload.cnf as { jkt?: unknown } | undefined)?.jkt === 'string';
132
+ if (isOAuthToken) {
133
+ if (!isOAuthScope(payload.scope)) {
134
+ throw new InvalidToken('Invalid OAuth access token scope');
135
+ }
136
+ } else if (!isBearerAccessScope(payload.scope)) {
137
+ throw new InvalidToken('Invalid access token scope');
121
138
  }
122
139
  return payload;
123
140
  }
@@ -0,0 +1,32 @@
1
+ const SINGLE_USER_UNSUPPORTED_ROUTES: ReadonlySet<string> = new Set([
2
+ 'com.atproto.server.createAccount',
3
+ 'com.atproto.server.reserveSigningKey',
4
+ 'com.atproto.server.createInviteCode',
5
+ 'com.atproto.server.createInviteCodes',
6
+ 'com.atproto.server.getAccountInviteCodes',
7
+ 'com.atproto.temp.addReservedHandle',
8
+ 'com.atproto.temp.checkHandleAvailability',
9
+ 'com.atproto.temp.checkSignupQueue',
10
+ 'com.atproto.temp.requestPhoneVerification',
11
+ 'com.atproto.temp.revokeAccountCredentials',
12
+ ]);
13
+
14
+ const SINGLE_USER_UNSUPPORTED_PREFIXES = ['com.atproto.admin.'];
15
+
16
+ export function isSingleUserUnsupportedRoute(nsid: string): boolean {
17
+ return SINGLE_USER_UNSUPPORTED_ROUTES.has(nsid) ||
18
+ SINGLE_USER_UNSUPPORTED_PREFIXES.some((prefix) => nsid.startsWith(prefix));
19
+ }
20
+
21
+ export function unsupportedSingleUserRouteResponse(nsid: string): Response {
22
+ return new Response(
23
+ JSON.stringify({
24
+ error: 'NotImplemented',
25
+ message: `${nsid} is intentionally unsupported by Alteran single-user PDS`,
26
+ }),
27
+ {
28
+ status: 501,
29
+ headers: { 'Content-Type': 'application/json' },
30
+ },
31
+ );
32
+ }
package/src/lib/util.ts CHANGED
@@ -20,13 +20,68 @@ export async function readJson(request: Request): Promise<unknown> {
20
20
  }
21
21
 
22
22
  export async function readJsonBounded(env: Env, request: Request): Promise<unknown> {
23
+ requireJsonContentType(request);
23
24
  const raw = env.PDS_MAX_JSON_BYTES ?? '65536';
24
25
  const max = Number(raw) > 0 ? Number(raw) : 65536;
25
- const text = await request.text();
26
- if (text.length > max) throw new PayloadTooLarge();
26
+ const bytes = await readBodyBounded(request, max);
27
+ const text = new TextDecoder().decode(bytes);
27
28
  return JSON.parse(text || '{}');
28
29
  }
29
30
 
31
+ export async function readBodyBounded(request: Request, maxBytes: number): Promise<Uint8Array> {
32
+ const contentLength = request.headers.get('content-length');
33
+ return readStreamBounded(request.body, maxBytes, contentLength);
34
+ }
35
+
36
+ export async function readStreamBounded(
37
+ stream: ReadableStream<Uint8Array> | null,
38
+ maxBytes: number,
39
+ contentLength?: string | null,
40
+ ): Promise<Uint8Array> {
41
+ if (contentLength != null) {
42
+ const parsed = Number(contentLength);
43
+ if (Number.isFinite(parsed) && parsed > maxBytes) throw new PayloadTooLarge();
44
+ }
45
+
46
+ if (!stream) return new Uint8Array();
47
+
48
+ const reader = stream.getReader();
49
+ const chunks: Uint8Array[] = [];
50
+ let total = 0;
51
+
52
+ try {
53
+ while (true) {
54
+ const chunk = await reader.read();
55
+ if (chunk.done) break;
56
+ const bytes = chunk.value instanceof Uint8Array ? chunk.value : new Uint8Array(chunk.value);
57
+ total += bytes.byteLength;
58
+ if (total > maxBytes) {
59
+ await reader.cancel();
60
+ throw new PayloadTooLarge();
61
+ }
62
+ chunks.push(bytes);
63
+ }
64
+ } finally {
65
+ reader.releaseLock();
66
+ }
67
+
68
+ const out = new Uint8Array(total);
69
+ let offset = 0;
70
+ for (const chunk of chunks) {
71
+ out.set(chunk, offset);
72
+ offset += chunk.byteLength;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ export function requireJsonContentType(request: Request): void {
78
+ const contentType = request.headers.get('content-type') ?? '';
79
+ const mediaType = contentType.split(';', 1)[0]?.trim().toLowerCase();
80
+ if (mediaType !== 'application/json') {
81
+ throw new TypeError('Content-Type must be application/json');
82
+ }
83
+ }
84
+
30
85
  export function bearerToken(request: Request): string | null {
31
86
  const auth = request.headers.get('authorization');
32
87
  if (!auth) return null;
@@ -1,7 +1,19 @@
1
1
  import type { APIContext } from 'astro';
2
+ import { configuredDid, requestMatchesConfiguredHandle } from '../../lib/public-host';
2
3
 
3
4
  export const prerender = false;
4
5
 
5
- export function GET({ locals }: APIContext) {
6
- return new Response(locals.runtime.env.PDS_DID ?? '');
7
- }
6
+ export async function GET({ locals, request }: APIContext) {
7
+ const { env } = locals.runtime;
8
+
9
+ if (!await requestMatchesConfiguredHandle(request, env)) {
10
+ return new Response('NotFound', {
11
+ status: 404,
12
+ headers: { 'Content-Type': 'text/plain' },
13
+ });
14
+ }
15
+
16
+ return new Response(await configuredDid(env), {
17
+ headers: { 'Content-Type': 'text/plain' },
18
+ });
19
+ }