@alteran/astro 0.3.8 → 0.5.2

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 (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -30
  3. package/index.js +34 -28
  4. package/migrations/0007_bored_spitfire.sql +26 -0
  5. package/migrations/0008_furry_ozymandias.sql +2 -0
  6. package/migrations/meta/0007_snapshot.json +534 -0
  7. package/migrations/meta/0008_snapshot.json +548 -0
  8. package/migrations/meta/_journal.json +14 -0
  9. package/package.json +10 -9
  10. package/src/app.ts +8 -4
  11. package/src/db/account.ts +25 -6
  12. package/src/db/dal.ts +34 -23
  13. package/src/db/repo.ts +35 -35
  14. package/src/db/schema.ts +5 -1
  15. package/src/db/seed.ts +5 -13
  16. package/src/entrypoints/server.ts +2 -22
  17. package/src/handlers/root.ts +4 -4
  18. package/src/lib/account-state.ts +156 -0
  19. package/src/lib/actor.ts +28 -12
  20. package/src/lib/appview/auth-policy.ts +66 -0
  21. package/src/lib/appview/did-resolver.ts +233 -0
  22. package/src/lib/appview/proxy.ts +221 -0
  23. package/src/lib/appview/service-config.ts +61 -0
  24. package/src/lib/appview/service-jwt.ts +93 -0
  25. package/src/lib/appview/types.ts +25 -0
  26. package/src/lib/appview.ts +5 -532
  27. package/src/lib/auth-errors.ts +24 -0
  28. package/src/lib/auth.ts +63 -15
  29. package/src/lib/blockstore-gc.ts +2 -1
  30. package/src/lib/cache.ts +30 -4
  31. package/src/lib/chat.ts +14 -8
  32. package/src/lib/commit.ts +26 -36
  33. package/src/lib/config.ts +26 -15
  34. package/src/lib/did-document.ts +32 -0
  35. package/src/lib/errors.ts +54 -0
  36. package/src/lib/feed.ts +18 -19
  37. package/src/lib/firehose/frames.ts +87 -47
  38. package/src/lib/firehose/validation.ts +3 -3
  39. package/src/lib/jwt.ts +85 -177
  40. package/src/lib/labeler.ts +43 -30
  41. package/src/lib/logger.ts +4 -0
  42. package/src/lib/mst/block-map.ts +172 -0
  43. package/src/lib/mst/blockstore.ts +56 -93
  44. package/src/lib/mst/index.ts +1 -0
  45. package/src/lib/mst/leaf.ts +25 -0
  46. package/src/lib/mst/mst.ts +81 -237
  47. package/src/lib/mst/serialize.ts +97 -0
  48. package/src/lib/mst/types.ts +21 -0
  49. package/src/lib/oauth/clients.ts +67 -0
  50. package/src/lib/oauth/dpop-errors.ts +15 -0
  51. package/src/lib/oauth/dpop.ts +150 -0
  52. package/src/lib/oauth/resource.ts +199 -0
  53. package/src/lib/oauth/store.ts +77 -0
  54. package/src/lib/preferences.ts +9 -34
  55. package/src/lib/refresh-session.ts +161 -0
  56. package/src/lib/relay.ts +10 -8
  57. package/src/lib/secrets.ts +6 -7
  58. package/src/lib/sequencer.ts +12 -3
  59. package/src/lib/service-auth.ts +184 -0
  60. package/src/lib/session-tokens.ts +28 -76
  61. package/src/lib/streaming-car.ts +3 -0
  62. package/src/lib/tracing.ts +4 -3
  63. package/src/lib/util.ts +65 -15
  64. package/src/middleware.ts +1 -1
  65. package/src/pages/.well-known/did.json.ts +27 -30
  66. package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
  67. package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
  68. package/src/pages/debug/record.ts +1 -1
  69. package/src/pages/debug/sequencer.ts +28 -0
  70. package/src/pages/oauth/authorize.ts +78 -0
  71. package/src/pages/oauth/consent.ts +80 -0
  72. package/src/pages/oauth/par.ts +121 -0
  73. package/src/pages/oauth/token.ts +158 -0
  74. package/src/pages/xrpc/[...nsid].ts +61 -0
  75. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
  76. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
  77. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
  78. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
  79. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
  80. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
  81. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
  82. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
  83. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
  84. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
  85. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
  86. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
  87. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
  88. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
  89. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
  90. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
  91. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
  92. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
  93. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
  94. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
  95. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
  96. package/src/pages/xrpc/com.atproto.server.createSession.ts +31 -11
  97. package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
  98. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
  99. package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
  100. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
  101. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +71 -22
  102. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
  103. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
  104. package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
  105. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
  106. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
  107. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
  108. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
  109. package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
  110. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +1 -1
  111. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
  112. package/src/services/car.ts +207 -55
  113. package/src/services/r2-blob-store.ts +1 -1
  114. package/src/services/repo/blockstore-ops.ts +29 -0
  115. package/src/services/repo/operations.ts +133 -0
  116. package/src/services/repo-manager.ts +202 -253
  117. package/src/worker/runtime.ts +53 -8
  118. package/src/worker/sequencer/broadcast.ts +91 -0
  119. package/src/worker/sequencer/cid-helpers.ts +39 -0
  120. package/src/worker/sequencer/payload.ts +84 -0
  121. package/src/worker/sequencer/types.ts +36 -0
  122. package/src/worker/sequencer/upgrade.ts +141 -0
  123. package/src/worker/sequencer.ts +288 -412
  124. package/types/env.d.ts +15 -3
  125. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
  126. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
  127. package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
  128. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
  129. package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
  130. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
  131. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
  132. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  133. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  134. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  135. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  136. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
package/src/lib/relay.ts CHANGED
@@ -12,7 +12,9 @@ export function resolvePdsHostname(env: Env, requestUrl?: string): string | null
12
12
  try {
13
13
  const url = new URL(requestUrl);
14
14
  host = url.hostname;
15
- } catch {}
15
+ } catch {
16
+ // Malformed request URL: leave host unset so the caller can choose a fallback.
17
+ }
16
18
  }
17
19
 
18
20
  if (!host) return null;
@@ -53,12 +55,12 @@ export function getRelayHosts(env: Env): string[] {
53
55
  */
54
56
  export async function requestCrawl(relayHost: string, pdsHostname: string): Promise<Response> {
55
57
  const url = `https://${relayHost}/xrpc/com.atproto.sync.requestCrawl`;
56
- const res = await fetch(url, {
58
+ const response = await fetch(url, {
57
59
  method: 'POST',
58
60
  headers: { 'content-type': 'application/json' },
59
61
  body: JSON.stringify({ hostname: pdsHostname }),
60
62
  });
61
- return res;
63
+ return response;
62
64
  }
63
65
 
64
66
  // In-memory isolation-scoped throttle to avoid spamming relays on every request.
@@ -88,12 +90,12 @@ export async function notifyRelaysIfNeeded(env: Env, requestUrl?: string): Promi
88
90
  await Promise.allSettled(
89
91
  relays.map(async (relay) => {
90
92
  try {
91
- const res = await requestCrawl(relay, hostname);
92
- if (!res.ok) {
93
- console.warn('requestCrawl failed', { relay, status: res.status });
93
+ const response = await requestCrawl(relay, hostname);
94
+ if (!response.ok) {
95
+ console.warn('requestCrawl failed', { relay, status: response.status });
94
96
  }
95
- } catch (err) {
96
- console.warn('requestCrawl error', { relay, error: String(err) });
97
+ } catch (error) {
98
+ console.warn('requestCrawl error', { relay, error: String(error) });
97
99
  }
98
100
  }),
99
101
  );
@@ -10,16 +10,15 @@ const SECRET_KEYS = [
10
10
  "REFRESH_TOKEN_SECRET",
11
11
  "SESSION_JWT_SECRET",
12
12
  "REPO_SIGNING_KEY",
13
- "REPO_SIGNING_KEY_PUBLIC",
14
13
  "PDS_PLC_ROTATION_KEY",
15
- "PDS_SERVICE_SIGNING_KEY_HEX",
16
14
  ] as const satisfies readonly (keyof Env)[];
17
15
 
18
16
  function isSecretStoreBinding(value: unknown): value is SecretsStoreSecret {
19
17
  return (
20
18
  !!value &&
21
19
  typeof value === "object" &&
22
- typeof (value as any).get === "function"
20
+ "get" in value &&
21
+ typeof (value as { get: unknown }).get === "function"
23
22
  );
24
23
  }
25
24
 
@@ -37,13 +36,13 @@ export async function resolveSecret(
37
36
  * Non-secret bindings (DB, BLOBS, SEQUENCER, vars) are preserved as-is.
38
37
  */
39
38
  export async function resolveEnvSecrets<E extends Env>(env: E): Promise<E> {
40
- const resolved: Record<string, unknown> = { ...env };
39
+ const resolved = { ...env } as Record<string, unknown>;
41
40
 
42
41
  await Promise.all(
43
42
  SECRET_KEYS.map(async (key) => {
44
- const val = await resolveSecret((env as any)[key]);
45
- if (val !== undefined) {
46
- resolved[key as string] = val;
43
+ const value = await resolveSecret(env[key]);
44
+ if (value !== undefined) {
45
+ resolved[key as string] = value;
47
46
  }
48
47
  }),
49
48
  );
@@ -1,10 +1,19 @@
1
1
  import type { Env } from '../env';
2
2
 
3
3
  export async function notifySequencer(env: Env, obj: unknown) {
4
- if (!env.SEQUENCER) return;
4
+ if (!env.SEQUENCER) {
5
+ console.warn('notifySequencer: SEQUENCER binding missing');
6
+ return;
7
+ }
5
8
  try {
6
9
  const id = env.SEQUENCER.idFromName('default');
7
10
  const stub = env.SEQUENCER.get(id);
8
- await stub.fetch('https://sequencer/commit', { method: 'POST', body: JSON.stringify(obj) });
9
- } catch {}
11
+ await stub.fetch('https://sequencer/commit', {
12
+ method: 'POST',
13
+ headers: { 'content-type': 'application/json' },
14
+ body: JSON.stringify(obj),
15
+ });
16
+ } catch (e) {
17
+ console.warn('notifySequencer: failed to POST /commit to sequencer', e);
18
+ }
10
19
  }
@@ -0,0 +1,184 @@
1
+ import type { Env } from '../env';
2
+ import { resolveSecret } from './secrets';
3
+
4
+ /**
5
+ * Service auth verification for external services (like video.bsky.app)
6
+ *
7
+ * Service auth JWTs are signed by the service's key and contain:
8
+ * - iss: The service's DID (e.g., did:web:video.bsky.app)
9
+ * - aud: The PDS's DID (must match our PDS_DID)
10
+ * - lxm: The lexicon method being authorized
11
+ * - exp/iat: Standard JWT timing claims
12
+ */
13
+
14
+ interface ServiceAuthPayload {
15
+ iss: string;
16
+ aud: string;
17
+ lxm?: string;
18
+ exp?: number;
19
+ iat?: number;
20
+ jti?: string;
21
+ }
22
+
23
+ interface ServiceAuthResult {
24
+ iss: string;
25
+ aud: string;
26
+ lxm?: string;
27
+ }
28
+
29
+ /**
30
+ * Check if a Bearer token is a service auth token (has lxm claim)
31
+ */
32
+ export function isServiceAuthToken(token: string): boolean {
33
+ try {
34
+ const parts = token.split('.');
35
+ if (parts.length !== 3) return false;
36
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
37
+ return payload.lxm != null;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Decode JWT payload without verification (for initial inspection)
45
+ */
46
+ function decodeJwtPayload(token: string): ServiceAuthPayload | null {
47
+ try {
48
+ const parts = token.split('.');
49
+ if (parts.length !== 3) return null;
50
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
51
+ return payload;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Resolve a DID document and extract the atproto verification key
59
+ */
60
+ async function getVerificationKey(did: string): Promise<string | null> {
61
+ try {
62
+ let url: string;
63
+ if (did.startsWith('did:web:')) {
64
+ const host = did.slice('did:web:'.length).replace(/:/g, '/');
65
+ url = `https://${host}/.well-known/did.json`;
66
+ } else if (did.startsWith('did:plc:')) {
67
+ url = `https://plc.directory/${did}`;
68
+ } else {
69
+ return null;
70
+ }
71
+
72
+ const response = await fetch(url, {
73
+ headers: { 'Accept': 'application/json' },
74
+ });
75
+ if (!response.ok) return null;
76
+
77
+ const doc = await response.json() as any;
78
+
79
+ // Find atproto verification method
80
+ const methods = doc.verificationMethod || [];
81
+ for (const method of methods) {
82
+ if (method.id?.endsWith('#atproto') && method.publicKeyMultibase) {
83
+ return method.publicKeyMultibase;
84
+ }
85
+ }
86
+ return null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Verify ES256K signature using the public key from DID document
94
+ */
95
+ async function verifyES256KSignature(
96
+ token: string,
97
+ publicKeyMultibase: string
98
+ ): Promise<boolean> {
99
+ try {
100
+ const { Secp256k1Keypair } = await import('@atproto/crypto');
101
+
102
+ const parts = token.split('.');
103
+ if (parts.length !== 3) return false;
104
+
105
+ const [headerB64, payloadB64, signatureB64] = parts;
106
+ const signingInput = `${headerB64}.${payloadB64}`;
107
+
108
+ // Decode signature from base64url
109
+ const sigB64 = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
110
+ const sigBin = atob(sigB64);
111
+ const signature = new Uint8Array(sigBin.length);
112
+ for (let i = 0; i < sigBin.length; i++) {
113
+ signature[i] = sigBin.charCodeAt(i);
114
+ }
115
+
116
+ // The multibase key starts with 'z' for base58btc encoding
117
+ // Format: z + multicodec prefix (0xe7 0x01 for secp256k1) + compressed public key
118
+ if (!publicKeyMultibase.startsWith('z')) {
119
+ return false;
120
+ }
121
+
122
+ // Use @atproto/crypto to verify
123
+ const { verifySignature } = await import('@atproto/crypto');
124
+ const data = new TextEncoder().encode(signingInput);
125
+
126
+ // Convert multibase to did:key format for verification
127
+ const didKey = `did:key:${publicKeyMultibase}`;
128
+ const isValid = await verifySignature(didKey, data, signature);
129
+
130
+ return isValid;
131
+ } catch (e) {
132
+ console.error('Service auth signature verification failed:', e);
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Verify a service auth request
139
+ *
140
+ * @param env - Environment with PDS_DID
141
+ * @param request - The incoming request
142
+ * @returns The verified service auth payload, or null if not service auth / invalid
143
+ */
144
+ export async function verifyServiceAuth(
145
+ env: Env,
146
+ request: Request
147
+ ): Promise<ServiceAuthResult | null> {
148
+ const auth = request.headers.get('authorization');
149
+ if (!auth?.startsWith('Bearer ')) return null;
150
+
151
+ const token = auth.slice(7).trim();
152
+ if (!isServiceAuthToken(token)) return null;
153
+
154
+ const payload = decodeJwtPayload(token);
155
+ if (!payload || !payload.iss || !payload.aud) return null;
156
+
157
+ // Check audience matches our PDS DID (accept both did:plc and did:web forms)
158
+ const pdsDid = await resolveSecret(env.PDS_DID);
159
+ const hostname = env.PDS_HOSTNAME || env.PDS_HANDLE;
160
+ const didWebAlt = hostname ? `did:web:${hostname}` : null;
161
+ const validAudiences = [pdsDid, didWebAlt].filter(Boolean) as string[];
162
+
163
+ if (!validAudiences.includes(payload.aud)) return null;
164
+
165
+ // Check expiration
166
+ if (payload.exp) {
167
+ const now = Math.floor(Date.now() / 1000);
168
+ if (payload.exp < now) return null;
169
+ }
170
+
171
+ // Get the issuer's verification key from their DID document
172
+ const publicKey = await getVerificationKey(payload.iss);
173
+ if (!publicKey) return null;
174
+
175
+ // Verify the signature
176
+ const isValid = await verifyES256KSignature(token, publicKey);
177
+ if (!isValid) return null;
178
+
179
+ return {
180
+ iss: payload.iss,
181
+ aud: payload.aud,
182
+ lxm: payload.lxm,
183
+ };
184
+ }
@@ -2,6 +2,8 @@ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
2
2
  import type { Env } from '../env';
3
3
  import { getRuntimeString } from './secrets';
4
4
  import { getOrCreateSecret } from '../db/account';
5
+ import { InvalidToken, ServerMisconfigured } from './errors';
6
+ import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
5
7
 
6
8
  const SESSION_SECRET_KEY = 'session_jwt_secret';
7
9
  const GRACE_PERIOD_SECONDS = 2 * 60 * 60;
@@ -25,10 +27,8 @@ async function getJwtKey(env: Env): Promise<Uint8Array> {
25
27
  }
26
28
 
27
29
  async function getServiceDid(env: Env): Promise<string> {
28
- const did = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
29
- if (!did) {
30
- throw new Error('PDS_DID is not configured');
31
- }
30
+ const did = await getRuntimeString(env, 'PDS_DID', '');
31
+ if (!did) throw new ServerMisconfigured('PDS_DID is not configured');
32
32
  return did;
33
33
  }
34
34
 
@@ -72,10 +72,10 @@ export async function verifyRefreshToken(env: Env, token: string) {
72
72
  const serviceDid = await getServiceDid(env);
73
73
  const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid);
74
74
  if (header.typ !== 'refresh+jwt') {
75
- throw new Error('Invalid token type');
75
+ throw new InvalidToken('Invalid token type');
76
76
  }
77
77
  if (payload.scope !== 'refresh') {
78
- throw new Error('Invalid refresh token scope');
78
+ throw new InvalidToken('Invalid refresh token scope');
79
79
  }
80
80
  return {
81
81
  payload,
@@ -93,10 +93,10 @@ export async function verifyAccessToken(env: Env, token: string) {
93
93
  const serviceDid = await getServiceDid(env);
94
94
  const { header, payload } = await decodeAndVerifyJwt(key, token, 'at+jwt', serviceDid);
95
95
  if (header.typ !== 'at+jwt') {
96
- throw new Error('Invalid token type');
96
+ throw new InvalidToken('Invalid token type');
97
97
  }
98
98
  if (payload.scope === 'refresh') {
99
- throw new Error('Unexpected scope for access token');
99
+ throw new InvalidToken('Unexpected scope for access token');
100
100
  }
101
101
  return payload;
102
102
  }
@@ -121,82 +121,34 @@ type RefreshTokenPayload = TokenPayload & { jti: string };
121
121
  type TokenHeader = { alg: 'HS256'; typ: 'at+jwt' | 'refresh+jwt' };
122
122
 
123
123
  async function signJwt(key: Uint8Array, typ: TokenHeader['typ'], payload: TokenPayload): Promise<string> {
124
- const header: TokenHeader = { alg: 'HS256', typ };
125
- const encodedHeader = base64UrlEncode(JSON.stringify(header));
126
- const encodedPayload = base64UrlEncode(JSON.stringify(payload));
127
- const data = `${encodedHeader}.${encodedPayload}`;
128
- const signature = await hmacSign(key, data);
129
- return `${data}.${signature}`;
124
+ // jose will set standard claims via dedicated methods; we also keep custom claims in payload
125
+ const signer = new SignJWT(payload as JWTPayload)
126
+ .setProtectedHeader({ alg: 'HS256', typ })
127
+ .setSubject(payload.sub)
128
+ .setAudience(payload.aud)
129
+ .setIssuedAt(payload.iat)
130
+ .setExpirationTime(payload.exp);
131
+ return await signer.sign(key);
130
132
  }
131
133
 
132
134
  async function decodeAndVerifyJwt(key: Uint8Array, token: string, expectedTyp: TokenHeader['typ'], audience: string) {
133
- const parts = token.split('.');
134
- if (parts.length !== 3) {
135
- throw new Error('Invalid token format');
136
- }
137
- const header = JSON.parse(base64UrlDecode(parts[0])) as TokenHeader;
138
- const payload = JSON.parse(base64UrlDecode(parts[1])) as TokenPayload;
139
-
140
- if (header.alg !== 'HS256' || header.typ !== expectedTyp) {
141
- throw new Error('Unexpected token header');
135
+ const { payload, protectedHeader } = await jwtVerify(token, key, {
136
+ algorithms: ['HS256'],
137
+ audience,
138
+ });
139
+ if (protectedHeader.typ !== expectedTyp) {
140
+ throw new InvalidToken('Unexpected token header');
142
141
  }
143
- if (payload.aud !== audience) {
144
- throw new Error('Token audience mismatch');
145
- }
146
- if (!payload.sub) {
147
- throw new Error('Token missing subject');
142
+ if (!payload.sub || typeof payload.sub !== 'string') {
143
+ throw new InvalidToken('Token missing subject');
148
144
  }
149
145
  if (typeof payload.exp !== 'number') {
150
- throw new Error('Token missing expiry');
151
- }
152
-
153
- const data = `${parts[0]}.${parts[1]}`;
154
- const ok = await hmacVerify(key, data, parts[2]);
155
- if (!ok) {
156
- throw new Error('Invalid token signature');
146
+ throw new InvalidToken('Token missing expiry');
157
147
  }
158
-
159
- return { header, payload };
148
+ return { header: protectedHeader as TokenHeader, payload: payload as unknown as TokenPayload };
160
149
  }
161
150
 
162
151
  function generateTokenId(): string {
163
- const bytes = randomBytes(32);
164
- return base64UrlEncode(bytes);
165
- }
166
-
167
- async function hmacSign(keyBytes: Uint8Array, data: string): Promise<string> {
168
- const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
169
- const signature = await crypto.subtle.sign('HMAC', cryptoKey, textEncoder.encode(data));
170
- return base64UrlEncode(new Uint8Array(signature));
171
- }
172
-
173
- async function hmacVerify(keyBytes: Uint8Array, data: string, signatureB64: string): Promise<boolean> {
174
- const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
175
- return crypto.subtle.verify('HMAC', cryptoKey, base64UrlDecodeToBytes(signatureB64), textEncoder.encode(data));
176
- }
177
-
178
- function base64UrlEncode(value: string | Uint8Array): string {
179
- const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
180
- let binary = '';
181
- for (let i = 0; i < bytes.length; i++) {
182
- binary += String.fromCharCode(bytes[i]);
183
- }
184
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
185
- }
186
-
187
- function base64UrlDecode(encoded: string): string {
188
- const pad = encoded.length % 4 === 2 ? '==' : encoded.length % 4 === 3 ? '=' : '';
189
- const binary = atob(encoded.replace(/-/g, '+').replace(/_/g, '/') + pad);
190
- return binary;
152
+ return bytesToHex(randomBytes(16));
191
153
  }
192
-
193
- function base64UrlDecodeToBytes(encoded: string): Uint8Array {
194
- const binary = base64UrlDecode(encoded);
195
- const bytes = new Uint8Array(binary.length);
196
- for (let i = 0; i < binary.length; i++) {
197
- bytes[i] = binary.charCodeAt(i);
198
- }
199
- return bytes;
200
- }
201
-
202
- const textEncoder = new TextEncoder();
154
+ // removed custom HMAC/base64url helpers in favor of jose
@@ -58,6 +58,9 @@ function concat(parts: Uint8Array[]): Uint8Array {
58
58
  * });
59
59
  * ```
60
60
  */
61
+ // StreamingCarEncoder is a class because it caches the encoded header
62
+ // once and then writes one block at a time; the cache state plus the
63
+ // per-block writeBlock() method belong on the same object.
61
64
  export class StreamingCarEncoder {
62
65
  private headerBytes: Uint8Array;
63
66
 
@@ -12,9 +12,10 @@ export interface TraceSpan {
12
12
  labels?: Record<string, string>;
13
13
  }
14
14
 
15
- /**
16
- * Performance tracer for measuring operation durations
17
- */
15
+ // Tracer is a class because each instance owns a Map of in-flight spans;
16
+ // start() and end() are paired stateful calls that need shared access to
17
+ // that map. The global `tracer` plus per-request instances both rely on
18
+ // this isolation.
18
19
  export class Tracer {
19
20
  private spans: Map<string, TraceSpan> = new Map();
20
21
 
package/src/lib/util.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { APIContext } from 'astro';
2
1
  import { CID } from 'multiformats/cid';
3
2
  import * as dagCbor from '@ipld/dag-cbor';
4
3
  import { sha256 } from 'multiformats/hashes/sha2';
4
+ import type { Env } from '../env';
5
+ import { PayloadTooLarge } from './errors';
5
6
 
6
7
  export function tryParse(json: string): unknown {
7
8
  try {
@@ -11,33 +12,30 @@ export function tryParse(json: string): unknown {
11
12
  }
12
13
  }
13
14
 
14
- // JSON helper with size cap
15
- export async function readJson(request: Request): Promise<any> {
15
+ export async function readJson(request: Request): Promise<unknown> {
16
16
  const max = 64 * 1024;
17
17
  const text = await request.text();
18
- if (text.length > max) throw new Error('PayloadTooLarge');
18
+ if (text.length > max) throw new PayloadTooLarge();
19
19
  return JSON.parse(text || '{}');
20
20
  }
21
21
 
22
- export async function readJsonBounded(env: any, request: Request): Promise<any> {
23
- const raw = (env.PDS_MAX_JSON_BYTES as string | undefined) ?? '65536';
22
+ export async function readJsonBounded(env: Env, request: Request): Promise<unknown> {
23
+ const raw = env.PDS_MAX_JSON_BYTES ?? '65536';
24
24
  const max = Number(raw) > 0 ? Number(raw) : 65536;
25
25
  const text = await request.text();
26
- if (text.length > max) {
27
- const err: any = new Error('PayloadTooLarge');
28
- err.code = 'PayloadTooLarge';
29
- throw err;
30
- }
26
+ if (text.length > max) throw new PayloadTooLarge();
31
27
  return JSON.parse(text || '{}');
32
28
  }
33
29
 
34
30
  export function bearerToken(request: Request): string | null {
35
31
  const auth = request.headers.get('authorization');
36
- if (!auth || !auth.startsWith('Bearer ')) return null;
37
- return auth.slice(7);
32
+ if (!auth) return null;
33
+ if (auth.startsWith('Bearer ')) return auth.slice(7);
34
+ if (auth.startsWith('DPoP ')) return auth.slice(5);
35
+ return null;
38
36
  }
39
37
 
40
- export function isAllowedMime(env: any, mime: string): boolean {
38
+ export function isAllowedMime(env: Env, mime: string): boolean {
41
39
  const def = [
42
40
  // Images
43
41
  'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif',
@@ -50,7 +48,7 @@ export function isAllowedMime(env: any, mime: string): boolean {
50
48
  // Generic fallback
51
49
  'application/octet-stream'
52
50
  ];
53
- const raw = (env.PDS_ALLOWED_MIME as string | undefined) ?? def.join(',');
51
+ const raw = env.PDS_ALLOWED_MIME ?? def.join(',');
54
52
  const set = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
55
53
 
56
54
  // Extract base MIME type (remove charset and other parameters)
@@ -59,6 +57,58 @@ export function isAllowedMime(env: any, mime: string): boolean {
59
57
  return set.has(baseMime);
60
58
  }
61
59
 
60
+ export function baseMime(mime: string | null | undefined): string {
61
+ if (!mime) return 'application/octet-stream';
62
+ return mime.toLowerCase().split(';')[0].trim();
63
+ }
64
+
65
+ // Best-effort MIME sniffing for common image/video/audio formats.
66
+ // Prefer this over client-provided header when possible, mirroring upstream PDS.
67
+ export function sniffMime(buf: ArrayBuffer): string | null {
68
+ const bytes = new Uint8Array(buf);
69
+ const len = bytes.length;
70
+ const ascii = (start: number, n: number) =>
71
+ String.fromCharCode(...bytes.slice(start, start + n));
72
+
73
+ if (len >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
74
+ return 'image/jpeg';
75
+ }
76
+ if (
77
+ len >= 8 &&
78
+ bytes[0] === 0x89 && ascii(1, 3) === 'PNG' && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a
79
+ ) {
80
+ return 'image/png';
81
+ }
82
+ if (len >= 6) {
83
+ const sig6 = ascii(0, 6);
84
+ if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return 'image/gif';
85
+ }
86
+ if (len >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
87
+ return 'image/webp';
88
+ }
89
+ // ISO BMFF / MP4 / AVIF / QuickTime: find 'ftyp' within first 256 bytes
90
+ {
91
+ const window = Math.min(len, 256);
92
+ for (let i = 0; i + 8 <= window; i++) {
93
+ if (ascii(i, 4) === 'ftyp') {
94
+ const brand = ascii(i + 4, 4);
95
+ const mp4Brands = new Set(['isom', 'iso2', 'mp41', 'mp42', 'avc1', 'MSNV', '3gp4', 'M4V ']);
96
+ if (brand === 'avif' || brand === 'avis' || brand === 'mif1' || brand === 'msf1') return 'image/avif';
97
+ if (brand === 'qt ') return 'video/quicktime';
98
+ if (mp4Brands.has(brand)) return 'video/mp4';
99
+ // Unknown brand: still likely MP4 container
100
+ return 'video/mp4';
101
+ }
102
+ }
103
+ }
104
+ // WebM/Matroska (EBML)
105
+ if (len >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
106
+ // Could be audio/webm or video/webm; default to video/webm
107
+ return 'video/webm';
108
+ }
109
+ return null;
110
+ }
111
+
62
112
  export function randomRkey(): string {
63
113
  return crypto.randomUUID().replace(/-/g, '').substring(0, 13);
64
114
  }
package/src/middleware.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { defineMiddleware, sequence } from 'astro:middleware';
1
+ import { defineMiddleware, sequence } from 'astro/middleware';
2
2
 
3
3
  const cors = defineMiddleware(async ({ locals, request }, next) => {
4
4
  // Match atproto CORS implementation: use wildcard for public endpoints