@alteran/astro 0.1.13 → 0.3.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.
Files changed (62) hide show
  1. package/README.md +28 -3
  2. package/index.js +2 -4
  3. package/migrations/0006_adorable_spectrum.sql +11 -0
  4. package/migrations/meta/0006_snapshot.json +429 -0
  5. package/migrations/meta/_journal.json +7 -0
  6. package/package.json +6 -3
  7. package/src/db/account.ts +145 -0
  8. package/src/db/dal.ts +27 -9
  9. package/src/db/repo.ts +9 -8
  10. package/src/db/schema.ts +29 -11
  11. package/src/lib/actor.ts +133 -0
  12. package/src/lib/appview.ts +508 -0
  13. package/src/lib/auth.ts +26 -3
  14. package/src/lib/blob-refs.ts +9 -13
  15. package/src/lib/chat.ts +238 -0
  16. package/src/lib/config.ts +15 -7
  17. package/src/lib/feed.ts +165 -0
  18. package/src/lib/jwt.ts +144 -47
  19. package/src/lib/labeler.ts +91 -0
  20. package/src/lib/mst/blockstore.ts +98 -14
  21. package/src/lib/password.ts +40 -0
  22. package/src/lib/preferences.ts +73 -0
  23. package/src/lib/relay.ts +101 -0
  24. package/src/lib/secrets.ts +4 -1
  25. package/src/lib/session-tokens.ts +202 -0
  26. package/src/lib/token-cleanup.ts +3 -12
  27. package/src/lib/util.ts +17 -2
  28. package/src/middleware.ts +20 -21
  29. package/src/pages/.well-known/did.json.ts +45 -32
  30. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +23 -0
  31. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +34 -0
  32. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +42 -0
  33. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +36 -0
  34. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +42 -0
  35. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +37 -0
  36. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +26 -0
  37. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +35 -0
  38. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +29 -0
  39. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +29 -0
  40. package/src/pages/xrpc/app.bsky.labeler.getServices.ts +29 -0
  41. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +20 -0
  42. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +27 -0
  43. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +19 -0
  44. package/src/pages/xrpc/app.bsky.unspecced.getConfig.ts +15 -0
  45. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +26 -0
  46. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +37 -0
  47. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +64 -66
  48. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +24 -0
  49. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +127 -0
  50. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +91 -0
  51. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +6 -2
  52. package/src/pages/xrpc/com.atproto.server.createSession.ts +36 -8
  53. package/src/pages/xrpc/com.atproto.server.describeServer.ts +37 -4
  54. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +64 -0
  55. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +55 -32
  56. package/src/services/repo-manager.ts +15 -6
  57. package/src/worker/runtime.ts +9 -0
  58. package/types/env.d.ts +10 -1
  59. package/src/pages/xrpc/com.atproto.repo.importRepo.ts +0 -142
  60. package/src/pages/xrpc/com.atproto.server.activateAccount.ts +0 -53
  61. package/src/pages/xrpc/com.atproto.server.createAccount.ts +0 -99
  62. package/src/pages/xrpc/com.atproto.server.deactivateAccount.ts +0 -53
@@ -0,0 +1,508 @@
1
+ import { Secp256k1Keypair } from '@atproto/crypto';
2
+ import type { Env } from '../env';
3
+ import { resolveSecret } from './secrets';
4
+ import { authenticateRequest, unauthorized } from './auth';
5
+
6
+ const DEFAULT_APPVIEW_URL = 'https://public.api.bsky.app';
7
+ const DEFAULT_APPVIEW_DID = 'did:web:api.bsky.app';
8
+
9
+ export interface AppViewConfig {
10
+ url: string;
11
+ did: string;
12
+ cdnUrlPattern?: string;
13
+ }
14
+
15
+ let cachedSigningKey: Promise<Secp256k1Keypair> | null = null;
16
+
17
+ const didDocumentCache = new Map<string, Promise<unknown>>();
18
+
19
+ function encodeBase64Url(bytes: Uint8Array): string {
20
+ let binary = '';
21
+ for (let i = 0; i < bytes.length; i++) {
22
+ binary += String.fromCharCode(bytes[i]);
23
+ }
24
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
25
+ }
26
+
27
+ function encodeJson(obj: Record<string, unknown>): string {
28
+ const encoder = new TextEncoder();
29
+ return encodeBase64Url(encoder.encode(JSON.stringify(obj)));
30
+ }
31
+
32
+ function randomHex(bytes = 16): string {
33
+ const arr = new Uint8Array(bytes);
34
+ crypto.getRandomValues(arr);
35
+ return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
36
+ }
37
+
38
+ interface ProxyTarget {
39
+ did: string;
40
+ url: string;
41
+ }
42
+
43
+ type AuthScope =
44
+ | 'com.atproto.access'
45
+ | 'com.atproto.appPass'
46
+ | 'com.atproto.appPassPrivileged'
47
+ | 'com.atproto.signupQueued'
48
+ | 'com.atproto.takendown';
49
+
50
+ const DEFAULT_ACCESS_SCOPE: AuthScope = 'com.atproto.access';
51
+ const TAKENDOWN_SCOPE: AuthScope = 'com.atproto.takendown';
52
+ const PRIVILEGED_SCOPES = new Set<AuthScope>([
53
+ 'com.atproto.access',
54
+ 'com.atproto.appPassPrivileged',
55
+ ]);
56
+
57
+ const PRIVILEGED_METHODS = new Set<string>([
58
+ 'chat.bsky.actor.deleteAccount',
59
+ 'chat.bsky.actor.exportAccountData',
60
+ 'chat.bsky.convo.deleteMessageForSelf',
61
+ 'chat.bsky.convo.getConvo',
62
+ 'chat.bsky.convo.getConvoForMembers',
63
+ 'chat.bsky.convo.getLog',
64
+ 'chat.bsky.convo.getMessages',
65
+ 'chat.bsky.convo.leaveConvo',
66
+ 'chat.bsky.convo.listConvos',
67
+ 'chat.bsky.convo.muteConvo',
68
+ 'chat.bsky.convo.sendMessage',
69
+ 'chat.bsky.convo.sendMessageBatch',
70
+ 'chat.bsky.convo.unmuteConvo',
71
+ 'chat.bsky.convo.updateRead',
72
+ 'com.atproto.server.createAccount',
73
+ ]);
74
+
75
+ const PROTECTED_METHODS = new Set<string>([
76
+ 'com.atproto.admin.sendEmail',
77
+ 'com.atproto.identity.requestPlcOperationSignature',
78
+ 'com.atproto.identity.signPlcOperation',
79
+ 'com.atproto.identity.updateHandle',
80
+ 'com.atproto.server.activateAccount',
81
+ 'com.atproto.server.confirmEmail',
82
+ 'com.atproto.server.createAppPassword',
83
+ 'com.atproto.server.deactivateAccount',
84
+ 'com.atproto.server.getAccountInviteCodes',
85
+ 'com.atproto.server.getSession',
86
+ 'com.atproto.server.listAppPasswords',
87
+ 'com.atproto.server.requestAccountDelete',
88
+ 'com.atproto.server.requestEmailConfirmation',
89
+ 'com.atproto.server.requestEmailUpdate',
90
+ 'com.atproto.server.revokeAppPassword',
91
+ 'com.atproto.server.updateEmail',
92
+ ]);
93
+
94
+ class ProxyHeaderError extends Error {}
95
+
96
+ function resolveAuthScope(scope: unknown): AuthScope {
97
+ if (typeof scope !== 'string') {
98
+ return DEFAULT_ACCESS_SCOPE;
99
+ }
100
+
101
+ switch (scope) {
102
+ case 'com.atproto.access':
103
+ case 'com.atproto.appPass':
104
+ case 'com.atproto.appPassPrivileged':
105
+ case 'com.atproto.signupQueued':
106
+ case 'com.atproto.takendown':
107
+ return scope;
108
+ default:
109
+ console.warn('Unknown auth scope, treating as access scope', scope);
110
+ return DEFAULT_ACCESS_SCOPE;
111
+ }
112
+ }
113
+
114
+ function parseProxyHeader(header: string): { did: string; serviceId: string } {
115
+ const value = header.trim();
116
+ const hashIndex = value.indexOf('#');
117
+
118
+ if (hashIndex <= 0 || hashIndex === value.length - 1) {
119
+ throw new ProxyHeaderError('invalid format');
120
+ }
121
+
122
+ if (value.indexOf('#', hashIndex + 1) !== -1) {
123
+ throw new ProxyHeaderError('invalid format');
124
+ }
125
+
126
+ const did = value.slice(0, hashIndex);
127
+ const serviceId = value.slice(hashIndex);
128
+
129
+ if (!did.startsWith('did:')) {
130
+ throw new ProxyHeaderError('invalid DID');
131
+ }
132
+
133
+ if (!serviceId.startsWith('#')) {
134
+ throw new ProxyHeaderError('invalid service id');
135
+ }
136
+
137
+ if (value.includes(' ')) {
138
+ throw new ProxyHeaderError('invalid format');
139
+ }
140
+
141
+ return { did, serviceId };
142
+ }
143
+
144
+ async function resolveProxyTarget(
145
+ env: Env,
146
+ proxyHeader: string,
147
+ config: AppViewConfig,
148
+ ): Promise<ProxyTarget> {
149
+ const { did, serviceId } = parseProxyHeader(proxyHeader);
150
+
151
+ if (did === config.did && serviceId === '#bsky_appview') {
152
+ return { did, url: config.url };
153
+ }
154
+
155
+ const didDoc = await resolveDidDocument(env, did);
156
+ const endpoint = getServiceEndpointFromDidDoc(didDoc, did, serviceId);
157
+
158
+ if (!endpoint) {
159
+ throw new ProxyHeaderError('service id not found in DID document');
160
+ }
161
+
162
+ return { did, url: endpoint };
163
+ }
164
+
165
+ async function resolveDidDocument(env: Env, did: string): Promise<any> {
166
+ const existing = didDocumentCache.get(did);
167
+ if (existing) {
168
+ return existing;
169
+ }
170
+
171
+ const loader = fetchDidDocument(env, did).catch((error) => {
172
+ didDocumentCache.delete(did);
173
+ throw error;
174
+ });
175
+
176
+ didDocumentCache.set(did, loader);
177
+ return loader;
178
+ }
179
+
180
+ async function fetchDidDocument(_env: Env, did: string): Promise<any> {
181
+ let url: string;
182
+ if (did.startsWith('did:web:')) {
183
+ url = buildDidWebUrl(did);
184
+ } else if (did.startsWith('did:plc:')) {
185
+ url = `https://plc.directory/${did}`;
186
+ } else {
187
+ throw new ProxyHeaderError('unsupported DID method');
188
+ }
189
+
190
+ const res = await fetch(url, {
191
+ headers: {
192
+ accept: 'application/did+json, application/json;q=0.9',
193
+ },
194
+ });
195
+
196
+ if (!res.ok) {
197
+ throw new ProxyHeaderError('failed to resolve DID document');
198
+ }
199
+
200
+ return res.json();
201
+ }
202
+
203
+ function buildDidWebUrl(did: string): string {
204
+ const suffix = did.slice('did:web:'.length);
205
+ const parts = suffix.split(':').map((segment) => {
206
+ try {
207
+ return decodeURIComponent(segment);
208
+ } catch {
209
+ throw new ProxyHeaderError('invalid did:web encoding');
210
+ }
211
+ });
212
+
213
+ const host = parts.shift();
214
+ if (!host) throw new ProxyHeaderError('invalid did:web value');
215
+
216
+ if (parts.length === 0) {
217
+ return `https://${host}/.well-known/did.json`;
218
+ }
219
+
220
+ const path = parts.join('/');
221
+ return `https://${host}/${path}/did.json`;
222
+ }
223
+
224
+ function getServiceEndpointFromDidDoc(didDoc: any, did: string, serviceId: string): string | null {
225
+ if (!didDoc || typeof didDoc !== 'object') return null;
226
+ const services = Array.isArray((didDoc as any).service) ? (didDoc as any).service : [];
227
+ if (!services.length) return null;
228
+
229
+ const targets = new Set<string>([serviceId]);
230
+ const docId = typeof (didDoc as any).id === 'string' ? (didDoc as any).id : undefined;
231
+ if (docId && !serviceId.startsWith(docId)) {
232
+ targets.add(`${docId}${serviceId}`);
233
+ }
234
+
235
+ for (const service of services) {
236
+ if (!service || typeof service !== 'object') continue;
237
+ const id = typeof service.id === 'string' ? service.id : undefined;
238
+ if (!id || !targets.has(id)) continue;
239
+
240
+ const endpoint = extractServiceEndpoint(service);
241
+ if (endpoint) return endpoint;
242
+ }
243
+
244
+ return null;
245
+ }
246
+
247
+ function extractServiceEndpoint(service: any): string | null {
248
+ const endpoint = service?.serviceEndpoint;
249
+ if (typeof endpoint === 'string') return endpoint;
250
+ if (endpoint && typeof endpoint === 'object') {
251
+ if (typeof endpoint.uri === 'string') return endpoint.uri;
252
+ if (Array.isArray(endpoint.urls)) {
253
+ const first = endpoint.urls.find((value: unknown) => typeof value === 'string');
254
+ if (typeof first === 'string') return first;
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+
260
+ async function getServiceSigningKey(env: Env): Promise<Secp256k1Keypair> {
261
+ if (!cachedSigningKey) {
262
+ cachedSigningKey = (async () => {
263
+ const configured =
264
+ (await resolveSecret(env.PDS_SERVICE_SIGNING_KEY_HEX as any)) ??
265
+ (await resolveSecret(env.PDS_PLC_ROTATION_KEY as any));
266
+
267
+ if (!configured || configured.trim() === '') {
268
+ throw new Error('Service signing key is not configured');
269
+ }
270
+
271
+ return Secp256k1Keypair.import(configured.trim());
272
+ })();
273
+ }
274
+
275
+ return cachedSigningKey;
276
+ }
277
+
278
+ export function getAppViewConfig(env: Env): AppViewConfig | null {
279
+ const url = (typeof env.PDS_BSKY_APP_VIEW_URL === 'string' && env.PDS_BSKY_APP_VIEW_URL.trim() !== '')
280
+ ? env.PDS_BSKY_APP_VIEW_URL.trim()
281
+ : DEFAULT_APPVIEW_URL;
282
+ const did = (typeof env.PDS_BSKY_APP_VIEW_DID === 'string' && env.PDS_BSKY_APP_VIEW_DID.trim() !== '')
283
+ ? env.PDS_BSKY_APP_VIEW_DID.trim()
284
+ : DEFAULT_APPVIEW_DID;
285
+
286
+ if (!url || !did) return null;
287
+
288
+ const cdn = typeof env.PDS_BSKY_APP_VIEW_CDN_URL_PATTERN === 'string'
289
+ ? env.PDS_BSKY_APP_VIEW_CDN_URL_PATTERN.trim()
290
+ : undefined;
291
+
292
+ return { url, did, cdnUrlPattern: cdn || undefined };
293
+ }
294
+
295
+ async function createServiceJwt(
296
+ env: Env,
297
+ issuerDid: string,
298
+ audienceDid: string,
299
+ lexiconMethod: string | null,
300
+ expiresInSeconds = 60,
301
+ ): Promise<string> {
302
+ const keypair = await getServiceSigningKey(env);
303
+ const now = Math.floor(Date.now() / 1000);
304
+ const exp = now + Math.max(1, expiresInSeconds);
305
+ const header = {
306
+ typ: 'JWT',
307
+ alg: keypair.jwtAlg,
308
+ };
309
+ const payload: Record<string, unknown> = {
310
+ iss: issuerDid,
311
+ aud: audienceDid,
312
+ iat: now,
313
+ exp,
314
+ jti: randomHex(),
315
+ };
316
+ if (lexiconMethod) {
317
+ payload.lxm = lexiconMethod;
318
+ }
319
+
320
+ const encodedHeader = encodeJson(header);
321
+ const encodedPayload = encodeJson(payload);
322
+ const toSign = `${encodedHeader}.${encodedPayload}`;
323
+ const signature = await keypair.sign(new TextEncoder().encode(toSign));
324
+ const encodedSignature = encodeBase64Url(signature);
325
+ return `${toSign}.${encodedSignature}`;
326
+ }
327
+
328
+ const FORWARDED_HEADERS = [
329
+ 'accept',
330
+ 'accept-encoding',
331
+ 'accept-language',
332
+ 'atproto-accept-labelers',
333
+ 'atproto-accept-personalized-feed',
334
+ 'cache-control',
335
+ 'if-none-match',
336
+ 'if-modified-since',
337
+ 'pragma',
338
+ 'x-bsky-topics',
339
+ 'x-bsky-feeds',
340
+ 'x-bsky-latest',
341
+ 'x-bsky-appview-features',
342
+ 'user-agent',
343
+ ];
344
+
345
+ export interface ProxyAppViewOptions {
346
+ request: Request;
347
+ env: Env;
348
+ lxm: string;
349
+ fallback?: () => Promise<Response>;
350
+ }
351
+
352
+ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppViewOptions): Promise<Response> {
353
+ const config = getAppViewConfig(env);
354
+ if (!config) {
355
+ return fallback ? await fallback() : new Response('AppView not configured', { status: 501 });
356
+ }
357
+
358
+ const auth = await authenticateRequest(request, env);
359
+ if (!auth) return unauthorized();
360
+
361
+ if (!auth.claims.sub) {
362
+ return new Response(JSON.stringify({ error: 'InvalidToken' }), {
363
+ status: 401,
364
+ headers: { 'Content-Type': 'application/json' },
365
+ });
366
+ }
367
+
368
+ if (PROTECTED_METHODS.has(lxm)) {
369
+ return new Response(
370
+ JSON.stringify({ error: 'InvalidToken', message: 'method cannot be proxied' }),
371
+ {
372
+ status: 400,
373
+ headers: { 'Content-Type': 'application/json' },
374
+ },
375
+ );
376
+ }
377
+
378
+ const scope = resolveAuthScope(auth.claims.scope);
379
+ if (scope === TAKENDOWN_SCOPE) {
380
+ return new Response(JSON.stringify({ error: 'AccountTakendown' }), {
381
+ status: 403,
382
+ headers: { 'Content-Type': 'application/json' },
383
+ });
384
+ }
385
+
386
+ if (!PRIVILEGED_SCOPES.has(scope) && PRIVILEGED_METHODS.has(lxm)) {
387
+ return new Response(JSON.stringify({ error: 'InvalidToken' }), {
388
+ status: 401,
389
+ headers: { 'Content-Type': 'application/json' },
390
+ });
391
+ }
392
+
393
+ let target: ProxyTarget = { did: config.did, url: config.url };
394
+ const proxyHeader = request.headers.get('atproto-proxy');
395
+ if (proxyHeader) {
396
+ try {
397
+ target = await resolveProxyTarget(env, proxyHeader, config);
398
+ } catch (error) {
399
+ console.error('AppView proxy header error:', error);
400
+ const isHeaderError = error instanceof ProxyHeaderError;
401
+ return new Response(
402
+ JSON.stringify({ error: isHeaderError ? 'InvalidProxyHeader' : 'ProxyResolutionFailed' }),
403
+ {
404
+ status: isHeaderError ? 400 : 502,
405
+ headers: { 'Content-Type': 'application/json' },
406
+ },
407
+ );
408
+ }
409
+ }
410
+
411
+ const originalUrl = new URL(request.url);
412
+ const upstreamUrl = new URL(target.url);
413
+ upstreamUrl.pathname = originalUrl.pathname;
414
+ upstreamUrl.search = originalUrl.search;
415
+ upstreamUrl.hash = '';
416
+
417
+ const headers = new Headers();
418
+ for (const header of FORWARDED_HEADERS) {
419
+ const value = request.headers.get(header);
420
+ if (value) headers.set(header, value);
421
+ }
422
+
423
+ let serviceJwt: string;
424
+ try {
425
+ serviceJwt = await createServiceJwt(env, auth.claims.sub, target.did, lxm);
426
+ } catch (error) {
427
+ console.error('AppView service token error:', error);
428
+ if (fallback) {
429
+ return fallback();
430
+ }
431
+ return new Response(JSON.stringify({ error: 'ServiceAuthUnavailable' }), {
432
+ status: 503,
433
+ headers: { 'Content-Type': 'application/json' },
434
+ });
435
+ }
436
+
437
+ headers.set('authorization', `Bearer ${serviceJwt}`);
438
+
439
+ const method = request.method.toUpperCase();
440
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
441
+ return new Response(JSON.stringify({ error: 'MethodNotAllowed' }), {
442
+ status: 405,
443
+ headers: {
444
+ 'Content-Type': 'application/json',
445
+ Allow: 'GET, HEAD, POST',
446
+ },
447
+ });
448
+ }
449
+
450
+ if (!headers.has('accept-encoding')) {
451
+ headers.set('accept-encoding', 'identity');
452
+ }
453
+
454
+ if (method === 'POST') {
455
+ const contentType = request.headers.get('content-type');
456
+ if (contentType) headers.set('content-type', contentType);
457
+ const contentEncoding = request.headers.get('content-encoding');
458
+ if (contentEncoding) headers.set('content-encoding', contentEncoding);
459
+ }
460
+
461
+ try {
462
+ const init: RequestInit = {
463
+ method,
464
+ headers,
465
+ };
466
+
467
+ if (method === 'POST') {
468
+ init.body = request.body as any;
469
+ (init as any).duplex = 'half';
470
+ }
471
+
472
+ const upstream = await fetch(upstreamUrl.toString(), init);
473
+
474
+ const responseHeaders = new Headers(upstream.headers);
475
+ return new Response(upstream.body, {
476
+ status: upstream.status,
477
+ statusText: upstream.statusText,
478
+ headers: responseHeaders,
479
+ });
480
+ } catch (error) {
481
+ console.error('AppView proxy error:', error);
482
+ if (fallback) {
483
+ return fallback();
484
+ }
485
+ return new Response(JSON.stringify({ error: 'UpstreamUnavailable' }), {
486
+ status: 502,
487
+ headers: { 'Content-Type': 'application/json' },
488
+ });
489
+ }
490
+ }
491
+
492
+ export async function getAppViewServiceToken(env: Env, did: string, aud?: string, lxm?: string | null, expiresInSeconds = 60) {
493
+ const config = getAppViewConfig(env);
494
+ if (!config) {
495
+ throw new Error('AppView not configured');
496
+ }
497
+ return createServiceJwt(env, did, aud ?? config.did, lxm ?? null, expiresInSeconds);
498
+ }
499
+
500
+ export async function createServiceAuthToken(
501
+ env: Env,
502
+ issuerDid: string,
503
+ audienceDid: string,
504
+ lexiconMethod: string | null,
505
+ expiresInSeconds = 60,
506
+ ): Promise<string> {
507
+ return createServiceJwt(env, issuerDid, audienceDid, lexiconMethod, expiresInSeconds);
508
+ }
package/src/lib/auth.ts CHANGED
@@ -1,12 +1,21 @@
1
1
  import type { APIContext } from 'astro';
2
- import { verifyJwt } from './jwt';
2
+ import type { Env } from '../env';
3
+ import { verifyJwt, type JwtClaims } from './jwt';
3
4
 
4
- export async function isAuthorized(request: Request, env: any): Promise<boolean> {
5
+ export interface AuthContext {
6
+ token: string;
7
+ claims: JwtClaims;
8
+ }
9
+
10
+ export async function isAuthorized(request: Request, env: Env): Promise<boolean> {
5
11
  const auth = request.headers.get('authorization');
6
12
  if (!auth || !auth.startsWith('Bearer ')) return false;
7
13
  const token = auth.slice(7);
8
14
  // Prefer JWT
9
- const ver = await verifyJwt(env, token).catch(() => null);
15
+ const ver = await verifyJwt(env, token).catch((err) => {
16
+ console.error('JWT verification error:', err);
17
+ return null;
18
+ });
10
19
  if (ver && ver.valid && ver.payload.t === 'access') return true;
11
20
  // Back-compat local escape hatch if explicitly enabled
12
21
  const allowDev = (env as any).PDS_ALLOW_DEV_TOKEN === '1';
@@ -18,3 +27,17 @@ export async function isAuthorized(request: Request, env: any): Promise<boolean>
18
27
  export function unauthorized() {
19
28
  return new Response(JSON.stringify({ error: 'AuthRequired' }), { status: 401 });
20
29
  }
30
+
31
+ export async function authenticateRequest(request: Request, env: Env): Promise<AuthContext | null> {
32
+ const auth = request.headers.get('authorization');
33
+ if (!auth || !auth.startsWith('Bearer ')) return null;
34
+ const token = auth.slice(7);
35
+ const ver = await verifyJwt(env, token).catch((err) => {
36
+ console.error('JWT verification error:', err);
37
+ return null;
38
+ });
39
+ if (!ver || !ver.valid) return null;
40
+ const claims = ver.payload as JwtClaims;
41
+ if (claims.t !== 'access') return null;
42
+ return { token, claims };
43
+ }
@@ -24,25 +24,21 @@ function extractBlobRefsRecursive(obj: any, refs: Set<string>): void {
24
24
  if (!obj || typeof obj !== 'object') return;
25
25
 
26
26
  // Check for blob reference pattern ($type: 'blob')
27
+ // This is the ONLY correct way to identify blobs in AT Protocol
27
28
  if (obj.$type === 'blob' && obj.ref) {
28
- if (typeof obj.ref === 'object' && obj.ref.$link) {
29
- refs.add(obj.ref.$link);
29
+ if (typeof obj.ref === 'object') {
30
+ // Handle both IPLD link formats: {"$link": "..."} and {"/": "..."}
31
+ const cid = obj.ref.$link || obj.ref['/'];
32
+ if (cid && typeof cid === 'string') {
33
+ refs.add(cid);
34
+ }
30
35
  } else if (typeof obj.ref === 'string') {
31
36
  refs.add(obj.ref);
32
37
  }
38
+ return; // Don't recurse into blob objects
33
39
  }
34
40
 
35
- // Check for direct CID link pattern
36
- if (obj.$link && typeof obj.$link === 'string') {
37
- // Only add if it looks like a blob CID (not a record CID)
38
- // Blob CIDs typically start with specific multihash prefixes
39
- refs.add(obj.$link);
40
- }
41
-
42
- // Check for legacy blob patterns
43
- if (obj.cid && typeof obj.cid === 'string') {
44
- refs.add(obj.cid);
45
- }
41
+ // DO NOT extract $link or cid fields outside of $type: 'blob' - those are record references!
46
42
 
47
43
  // Recurse into nested objects and arrays
48
44
  if (Array.isArray(obj)) {