@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
@@ -1,52 +1,48 @@
1
1
  import type { APIContext } from 'astro';
2
- import { errorMessage } from '../../lib/errors';
3
- import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
2
+ import type { Env } from '../../env';
3
+ import { PayloadTooLarge } from '../../lib/errors';
4
+ import {
5
+ verifyResourceRequestHybrid,
6
+ dpopResourceUnauthorized,
7
+ handleResourceAuthError,
8
+ insufficientScopeResponse,
9
+ type ResourceAuthContext,
10
+ } from '../../lib/oauth/resource';
11
+ import { canUploadBlob } from '../../lib/auth-scope';
4
12
  import { verifyServiceAuth, isServiceAuthToken } from '../../lib/service-auth';
5
13
  import { checkRate } from '../../lib/ratelimit';
6
- import { isAllowedMime, sniffMime, baseMime } from '../../lib/util';
7
- import { R2BlobStore } from '../../services/r2-blob-store';
8
- import { putBlobRef, checkBlobQuota, updateBlobQuota, isAccountActive } from '../../db/dal';
14
+ import { sniffMime, baseMime, readBodyBounded, readStreamBounded } from '../../lib/util';
15
+ import {
16
+ deleteUnreferencedBlobRef,
17
+ isAccountActive,
18
+ registerBlobRefWithQuota,
19
+ sweepEligibleUnreferencedBlobKeys,
20
+ } from '../../db/dal';
9
21
  import { resolveSecret } from '../../lib/secrets';
10
22
  import { CID } from 'multiformats/cid';
11
23
  import { sha256 } from 'multiformats/hashes/sha2';
24
+ import { jsonError } from '../../lib/repo-write-error';
12
25
 
13
26
  export const prerender = false;
14
27
 
28
+ const UPLOAD_BLOB_LXM = 'com.atproto.repo.uploadBlob';
29
+
30
+ type UploadAuth =
31
+ | { tag: 'service' }
32
+ | { tag: 'user'; auth: ResourceAuthContext };
33
+
15
34
  export async function POST({ locals, request }: APIContext) {
16
35
  const { env } = locals.runtime;
17
-
18
- // Check if this is a service auth request (from video.bsky.app, etc.)
19
- const authHeader = request.headers.get('authorization');
20
- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
21
-
22
- let isServiceAuth = false;
23
- if (token && isServiceAuthToken(token)) {
24
- const serviceAuth = await verifyServiceAuth(env, request);
25
- if (serviceAuth) {
26
- isServiceAuth = true;
27
- } else {
28
- return new Response(
29
- JSON.stringify({ error: 'AuthRequired', message: 'Invalid service auth token' }),
30
- { status: 401, headers: { 'Content-Type': 'application/json' } }
31
- );
32
- }
36
+ const did = await resolveSecret(env.PDS_DID);
37
+ if (!did) {
38
+ return jsonError('InternalServerError', 'PDS_DID is not configured', 500);
33
39
  }
34
-
35
- // If not service auth, verify as user request
36
- if (!isServiceAuth) {
37
- try {
38
- const auth = await verifyResourceRequestHybrid(env, request);
39
- if (!auth) return dpopResourceUnauthorized(env);
40
- } catch (error) {
41
- const handled = await handleResourceAuthError(env, error);
42
- if (handled) return handled;
43
- throw error;
44
- }
40
+ const uploadAuth = await authenticateUpload(env, request, did);
41
+ if (uploadAuth instanceof Response) return uploadAuth;
42
+ if (uploadAuth.tag === 'user' && uploadAuth.auth.did !== did) {
43
+ return jsonError('InvalidRequest', 'authenticated user does not own this repo', 400);
45
44
  }
46
45
 
47
- // Get DID from environment (single-user PDS)
48
- const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
49
-
50
46
  // Check if account is active
51
47
  const active = await isAccountActive(env, did);
52
48
  if (!active) {
@@ -59,72 +55,197 @@ export async function POST({ locals, request }: APIContext) {
59
55
  );
60
56
  }
61
57
 
62
- const rateLimitResponse = await checkRate(env, request, 'blob');
58
+ const rateLimitResponse = await checkRate(env, request, 'blob', { key: did });
63
59
  if (rateLimitResponse) return rateLimitResponse;
64
60
 
65
- // Decompress if Content-Encoding is present (some clients may compress uploads)
66
- const enc = (request.headers.get('content-encoding') || '').toLowerCase();
67
- let buf: ArrayBuffer;
68
- if (enc && (enc === 'gzip' || enc === 'br' || enc === 'deflate')) {
69
- try {
70
- // @ts-ignore: DecompressionStream is available in CF Workers runtime
71
- const ds = new DecompressionStream(enc);
72
- const decompressed = request.body?.pipeThrough(ds);
73
- buf = await new Response(decompressed).arrayBuffer();
74
- } catch {
75
- // Fallback to raw body if decompression not supported
76
- buf = await request.arrayBuffer();
61
+ const encoding = uploadEncoding(request);
62
+ if (!isSupportedUploadEncoding(encoding)) {
63
+ return jsonError('InvalidRequest', `unsupported content-encoding: ${encoding}`, 400);
64
+ }
65
+ let bytes: Uint8Array;
66
+ try {
67
+ bytes = await readUploadBytes(request, maxBlobBytes(env), encoding);
68
+ } catch (error) {
69
+ if (error instanceof PayloadTooLarge) {
70
+ return jsonError('PayloadTooLarge', 'blob exceeds maximum size', 413);
77
71
  }
78
- } else {
79
- buf = await request.arrayBuffer();
72
+ return jsonError('InvalidRequest', 'failed to read upload body', 400);
80
73
  }
74
+ const buf = toArrayBuffer(bytes);
81
75
  const headerMime = baseMime(request.headers.get('content-type'));
82
76
  const sniffed = sniffMime(buf);
83
- // Prefer sniffed MIME like upstream PDS; fall back to header
84
- const contentType = sniffed || headerMime;
77
+ const contentType = resolveUploadMime(headerMime, sniffed);
78
+ if (uploadAuth.tag === 'user' && !canUploadBlob(uploadAuth.auth.access, contentType)) {
79
+ return insufficientScopeResponse();
80
+ }
85
81
 
86
82
  // Skip MIME type validation during migration - accept all types
87
83
  // Uncomment to enforce: if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
88
84
 
89
- // Check quota before upload
90
- const canUpload = await checkBlobQuota(env, did, buf.byteLength);
91
- if (!canUpload) {
92
- return new Response(
93
- JSON.stringify({
94
- error: 'BlobQuotaExceeded',
95
- message: 'Blob storage quota exceeded'
96
- }),
97
- { status: 413 }
98
- );
99
- }
85
+ await sweepEligibleUnreferencedBlobKeys(env, { did, limit: 20 }).catch((error) => {
86
+ console.warn('[uploadBlob] Failed to sweep dereferenced blobs:', error);
87
+ });
100
88
 
101
- const store = new R2BlobStore(env);
89
+ let registeredCid: string | undefined;
102
90
  try {
103
- const response = await store.put(buf, { contentType });
104
-
105
- // Compute a CIDv1 (raw) for the blob so clients receive a valid CID link
106
- const digest = await sha256.digest(new Uint8Array(buf));
107
- const cid = CID.createV1(0x55, digest); // 0x55 = raw codec
108
- const cidStr = cid.toString();
91
+ const identity = await blobIdentity(env, buf);
109
92
 
110
- // Register blob ref with CID-based key
111
- await putBlobRef(env, did, cidStr, response.key, contentType, response.size);
93
+ const registration = await registerBlobRefWithQuota(
94
+ env,
95
+ did,
96
+ identity.cid,
97
+ identity.key,
98
+ contentType,
99
+ identity.size,
100
+ );
101
+ if (registration.tag === 'quotaExceeded') {
102
+ return new Response(
103
+ JSON.stringify({
104
+ error: 'BlobQuotaExceeded',
105
+ message: 'Blob storage quota exceeded',
106
+ }),
107
+ { status: 413, headers: { 'Content-Type': 'application/json' } },
108
+ );
109
+ }
110
+ if (registration.tag === 'registered') registeredCid = identity.cid;
112
111
 
113
- // Update quota
114
- await updateBlobQuota(env, did, response.size, 1);
112
+ await ensureBlobObject(env, registration.blob.key, buf, registration.blob.mime, registration.blob.size);
115
113
 
116
114
  // Mirror upstream shape exactly; helpful debugging header
117
115
  // Conform to lexicon: blob object must include $type: 'blob'
118
- const body = { blob: { $type: 'blob', ref: { $link: cidStr }, mimeType: contentType, size: response.size } };
116
+ const body = {
117
+ blob: {
118
+ $type: 'blob',
119
+ ref: { $link: registration.blob.cid },
120
+ mimeType: registration.blob.mime,
121
+ size: registration.blob.size,
122
+ },
123
+ };
119
124
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
120
125
  // Debug-only headers (safe for clients to ignore)
121
126
  headers['x-sniffed-mime'] = sniffed || '';
122
127
  headers['x-header-mime'] = headerMime;
123
- if (enc) headers['x-upload-encoding'] = enc;
128
+ if (encoding) headers['x-upload-encoding'] = encoding;
124
129
 
125
130
  return new Response(JSON.stringify(body), { headers });
126
- } catch (e) {
127
- if (String(errorMessage(e) || '').startsWith('BlobTooLarge')) return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
131
+ } catch (error) {
132
+ if (registeredCid) await deleteUnreferencedBlobRef(env, did, registeredCid).catch(() => undefined);
133
+ if (error instanceof PayloadTooLarge) {
134
+ return jsonError('PayloadTooLarge', 'blob exceeds maximum size', 413);
135
+ }
128
136
  return new Response(JSON.stringify({ error: 'UploadFailed' }), { status: 500 });
129
137
  }
130
138
  }
139
+
140
+ async function authenticateUpload(env: Env, request: Request, did: string): Promise<UploadAuth | Response> {
141
+ const authHeader = request.headers.get('authorization');
142
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
143
+
144
+ if (token && isServiceAuthToken(token)) {
145
+ const serviceAuth = await verifyServiceAuth(env, request);
146
+ if (!serviceAuth || serviceAuth.lxm !== UPLOAD_BLOB_LXM || serviceAuth.iss !== did) {
147
+ return jsonError('InvalidToken', 'Invalid service auth token', 401);
148
+ }
149
+ return { tag: 'service' };
150
+ }
151
+
152
+ try {
153
+ const auth = await verifyResourceRequestHybrid(env, request);
154
+ if (!auth) return dpopResourceUnauthorized(env);
155
+ return { tag: 'user', auth };
156
+ } catch (error) {
157
+ const handled = await handleResourceAuthError(env, error);
158
+ if (handled) return handled;
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ async function readUploadBytes(
164
+ request: Request,
165
+ maxBytes: number,
166
+ encoding: string,
167
+ ): Promise<Uint8Array> {
168
+ if (!isCompressedEncoding(encoding)) {
169
+ return readBodyBounded(request, maxBytes);
170
+ }
171
+ const decompressed = request.body?.pipeThrough(createDecompressionStream(encoding));
172
+ return readStreamBounded(decompressed ?? null, maxBytes);
173
+ }
174
+
175
+ function uploadEncoding(request: Request): string {
176
+ return (request.headers.get('content-encoding') || '').trim().toLowerCase();
177
+ }
178
+
179
+ function isCompressedEncoding(encoding: string): boolean {
180
+ return encoding === 'gzip' || encoding === 'deflate' || encoding === 'deflate-raw';
181
+ }
182
+
183
+ function isSupportedUploadEncoding(encoding: string): boolean {
184
+ return encoding === '' || isCompressedEncoding(encoding);
185
+ }
186
+
187
+ function resolveUploadMime(headerMime: string, sniffed: string | null): string {
188
+ if (!sniffed) return headerMime;
189
+ if (sniffed === 'video/webm' && (headerMime === 'audio/webm' || headerMime === 'video/webm')) {
190
+ return headerMime;
191
+ }
192
+ if (sniffed === 'video/mp4' && (headerMime === 'audio/mp4' || headerMime === 'video/mp4')) {
193
+ return headerMime;
194
+ }
195
+ return sniffed;
196
+ }
197
+
198
+ function createDecompressionStream(encoding: string): TransformStream<Uint8Array, Uint8Array> {
199
+ const Decompression = globalThis.DecompressionStream as unknown as {
200
+ new (format: string): TransformStream<Uint8Array, Uint8Array>;
201
+ };
202
+ return new Decompression(encoding);
203
+ }
204
+
205
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
206
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
207
+ }
208
+
209
+ type BlobIdentity = {
210
+ cid: string;
211
+ key: string;
212
+ size: number;
213
+ };
214
+
215
+ async function blobIdentity(env: Env, body: ArrayBuffer): Promise<BlobIdentity> {
216
+ const size = body.byteLength;
217
+ const limit = maxBlobBytes(env);
218
+ if (size > limit) throw new PayloadTooLarge('blob exceeds maximum size');
219
+
220
+ const digest = await sha256.digest(new Uint8Array(body));
221
+ const cid = CID.createV1(0x55, digest);
222
+ return {
223
+ cid: cid.toString(),
224
+ key: `blobs/by-cid/${base64Url(digest.digest)}`,
225
+ size,
226
+ };
227
+ }
228
+
229
+ async function ensureBlobObject(
230
+ env: Env,
231
+ key: string,
232
+ body: ArrayBuffer,
233
+ contentType: string,
234
+ expectedSize: number,
235
+ ): Promise<void> {
236
+ const existing = await env.ALTERAN_BLOBS.head(key);
237
+ if (existing?.size === expectedSize) return;
238
+ await env.ALTERAN_BLOBS.put(key, body, { httpMetadata: { contentType } });
239
+ }
240
+
241
+ function maxBlobBytes(env: Env, defaultMax = 5 * 1024 * 1024): number {
242
+ const raw = env.PDS_MAX_BLOB_SIZE;
243
+ const parsed = raw ? Number(raw) : defaultMax;
244
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMax;
245
+ }
246
+
247
+ function base64Url(bytes: Uint8Array): string {
248
+ let value = '';
249
+ for (const byte of bytes) value += String.fromCharCode(byte);
250
+ return btoa(value).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
251
+ }
@@ -1,6 +1,7 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { errorMessage } from '../../lib/errors';
3
- import { authErrorResponse, isAuthorized, unauthorized } from '../../lib/auth';
3
+ import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
4
+ import { canAccessFullAccount } from '../../lib/auth-scope';
4
5
  import { getAccountState } from '../../db/dal';
5
6
  import { toWireStatus } from '../../lib/account-state';
6
7
  import { getDb } from '../../db/client';
@@ -23,7 +24,8 @@ export async function GET({ locals, request }: APIContext) {
23
24
  const { env } = locals.runtime;
24
25
 
25
26
  try {
26
- if (!(await isAuthorized(request, env))) return unauthorized();
27
+ const auth = await authenticateRequest(request, env);
28
+ if (!auth || !canAccessFullAccount(auth.access)) return unauthorized();
27
29
  } catch (error) {
28
30
  const handled = await authErrorResponse(env, error);
29
31
  if (handled) return handled;
@@ -1,15 +1,34 @@
1
1
  import type { APIContext } from 'astro';
2
- import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
2
+ import { NSID, ensureValidDid } from '@atproto/syntax';
3
+ import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
3
4
  import { createServiceAuthToken } from '../../lib/appview';
5
+ import { canMakeRpcCall, type AuthAccessContext } from '../../lib/auth-scope';
6
+ import { PRIVILEGED_METHODS, PROTECTED_METHODS } from '../../lib/appview/auth-policy';
4
7
 
5
8
  export const prerender = false;
6
9
 
10
+ const METHOD_SCOPED_MAX_EXPIRATION_SECONDS = 60 * 60;
11
+ const METHODLESS_MAX_EXPIRATION_SECONDS = 60;
12
+
13
+ const SERVICE_AUTH_PROTECTED_METHODS: ReadonlySet<string> = new Set([
14
+ ...PROTECTED_METHODS,
15
+ 'com.atproto.admin.deleteAccount',
16
+ 'com.atproto.identity.submitPlcOperation',
17
+ 'com.atproto.repo.importRepo',
18
+ 'com.atproto.server.deleteAccount',
19
+ ]);
20
+
21
+ const APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS: ReadonlySet<string> = new Set([
22
+ 'com.atproto.server.createAccount',
23
+ ]);
24
+
7
25
  export async function GET({ locals, request }: APIContext) {
8
26
  const { env } = locals.runtime;
9
- let auth: { did: string; token: string } | null = null;
27
+ let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
10
28
  try {
11
- auth = await verifyResourceRequestHybrid(env, request);
12
- if (!auth) return dpopResourceUnauthorized(env);
29
+ const verified = await verifyResourceRequestHybrid(env, request);
30
+ if (!verified) return dpopResourceUnauthorized(env);
31
+ auth = verified;
13
32
  } catch (error) {
14
33
  const handled = await handleResourceAuthError(env, error);
15
34
  if (handled) return handled;
@@ -23,35 +42,39 @@ export async function GET({ locals, request }: APIContext) {
23
42
 
24
43
  const audience = audienceParam?.trim();
25
44
  if (!audience) {
26
- return new Response(JSON.stringify({ error: 'MissingAudience' }), {
27
- status: 400,
28
- headers: { 'Content-Type': 'application/json' },
29
- });
45
+ return jsonError('MissingAudience', undefined, 400);
46
+ }
47
+ if (!isValidServiceAudience(audience)) {
48
+ return jsonError('InvalidRequest', 'aud must be a DID or DID service reference', 400);
30
49
  }
31
50
 
32
51
  const lexiconMethod = lexParam && lexParam.trim() !== '' ? lexParam.trim() : null;
52
+ if (lexiconMethod !== null && !NSID.isValid(lexiconMethod)) {
53
+ return jsonError('InvalidRequest', 'lxm must be a valid NSID', 400);
54
+ }
55
+ if (!isAccountActiveForServiceAuth(auth.access)) {
56
+ return jsonError('AccountInactive', 'Account is not active', 403);
57
+ }
58
+ if (!canIssueServiceAuth(auth.access, lexiconMethod, audience)) {
59
+ return insufficientScopeResponse();
60
+ }
33
61
 
34
62
  let expiresIn = 60;
63
+ const maxExpiresIn = lexiconMethod ? METHOD_SCOPED_MAX_EXPIRATION_SECONDS : METHODLESS_MAX_EXPIRATION_SECONDS;
35
64
  const now = Math.floor(Date.now() / 1000);
36
65
  if (expParam !== null) {
37
66
  if (!/^-?\d+$/.test(expParam)) {
38
- return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration must be an integer timestamp' }), {
39
- status: 400,
40
- headers: { 'Content-Type': 'application/json' },
41
- });
67
+ return jsonError('BadExpiration', 'expiration must be an integer timestamp', 400);
42
68
  }
43
69
  const exp = Number(expParam);
70
+ if (!Number.isSafeInteger(exp)) {
71
+ return jsonError('BadExpiration', 'expiration must be a safe integer timestamp', 400);
72
+ }
44
73
  if (exp <= now) {
45
- return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration is in the past' }), {
46
- status: 400,
47
- headers: { 'Content-Type': 'application/json' },
48
- });
74
+ return jsonError('BadExpiration', 'expiration is in the past', 400);
49
75
  }
50
- if (exp - now > 3600) {
51
- return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration too far in future' }), {
52
- status: 400,
53
- headers: { 'Content-Type': 'application/json' },
54
- });
76
+ if (exp - now > maxExpiresIn) {
77
+ return jsonError('BadExpiration', 'expiration too far in future', 400);
55
78
  }
56
79
  expiresIn = Math.max(1, exp - now);
57
80
  }
@@ -69,3 +92,47 @@ export async function GET({ locals, request }: APIContext) {
69
92
  });
70
93
  }
71
94
  }
95
+
96
+ function canIssueServiceAuth(
97
+ access: AuthAccessContext,
98
+ lexiconMethod: string | null,
99
+ audience: string,
100
+ ): boolean {
101
+ if (lexiconMethod === null) {
102
+ return access.isFullAccess || access.isAppPassword;
103
+ }
104
+ if (SERVICE_AUTH_PROTECTED_METHODS.has(lexiconMethod)) return false;
105
+ if (access.isOAuth) return canMakeRpcCall(access, lexiconMethod, audience);
106
+ if (APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS.has(lexiconMethod)) {
107
+ return access.isFullAccess;
108
+ }
109
+ if (PRIVILEGED_METHODS.has(lexiconMethod)) {
110
+ return access.isPrivileged;
111
+ }
112
+ return canMakeRpcCall(access, lexiconMethod, audience);
113
+ }
114
+
115
+ function isAccountActiveForServiceAuth(access: AuthAccessContext): boolean {
116
+ if (access.isTakendown || access.isSignupQueued) return false;
117
+ return access.accountStatus === 'active';
118
+ }
119
+
120
+ function isValidServiceAudience(audience: string): boolean {
121
+ const parts = audience.split('#');
122
+ if (parts.length > 2) return false;
123
+ const [did, fragment] = parts;
124
+ if (!did) return false;
125
+ try {
126
+ ensureValidDid(did);
127
+ } catch {
128
+ return false;
129
+ }
130
+ return fragment === undefined || /^[A-Za-z][A-Za-z0-9._:-]*$/.test(fragment);
131
+ }
132
+
133
+ function jsonError(error: string, message: string | undefined, status: number): Response {
134
+ return new Response(JSON.stringify(message ? { error, message } : { error }), {
135
+ status,
136
+ headers: { 'Content-Type': 'application/json' },
137
+ });
138
+ }
@@ -1,6 +1,7 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
3
  import { getAccountByIdentifier } from '../../db/account';
4
+ import { buildDidDocument } from '../../lib/did-document';
4
5
 
5
6
  export const prerender = false;
6
7
 
@@ -27,6 +28,7 @@ export async function GET({ locals, request }: APIContext) {
27
28
  const did = authContext.claims.sub;
28
29
  const account = await getAccountByIdentifier(env, did);
29
30
  const handle = account?.handle ?? (env.PDS_HANDLE as string) ?? 'user.example.com';
31
+ const didDoc = await buildDidDocument(env, did, handle);
30
32
 
31
33
  return new Response(
32
34
  JSON.stringify({
@@ -35,19 +37,7 @@ export async function GET({ locals, request }: APIContext) {
35
37
  email: account?.email ?? (env.PDS_EMAIL as string | undefined) ?? 'user@example.com',
36
38
  emailConfirmed: true,
37
39
  emailAuthFactor: false,
38
- didDoc: {
39
- '@context': ['https://www.w3.org/ns/did/v1'],
40
- id: did,
41
- alsoKnownAs: [`at://${handle}`],
42
- verificationMethod: [],
43
- service: [
44
- {
45
- id: '#atproto_pds',
46
- type: 'AtprotoPersonalDataServer',
47
- serviceEndpoint: `https://${env.PDS_HOSTNAME ?? handle}`,
48
- },
49
- ],
50
- },
40
+ didDoc,
51
41
  }),
52
42
  {
53
43
  status: 200,