@alteran/astro 0.3.9 → 0.6.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 (150) 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/client.ts +1 -1
  13. package/src/db/dal.ts +34 -23
  14. package/src/db/repo.ts +38 -38
  15. package/src/db/schema.ts +5 -1
  16. package/src/db/seed.ts +5 -13
  17. package/src/entrypoints/server.ts +2 -22
  18. package/src/handlers/debug.ts +1 -1
  19. package/src/handlers/ready.ts +1 -1
  20. package/src/handlers/root.ts +4 -4
  21. package/src/handlers/xrpc.server.refreshSession.ts +6 -6
  22. package/src/lib/account-state.ts +156 -0
  23. package/src/lib/actor.ts +29 -13
  24. package/src/lib/appview/auth-policy.ts +66 -0
  25. package/src/lib/appview/did-resolver.ts +233 -0
  26. package/src/lib/appview/proxy.ts +221 -0
  27. package/src/lib/appview/service-config.ts +61 -0
  28. package/src/lib/appview/service-jwt.ts +93 -0
  29. package/src/lib/appview/types.ts +25 -0
  30. package/src/lib/appview.ts +5 -532
  31. package/src/lib/auth-errors.ts +24 -0
  32. package/src/lib/auth.ts +63 -15
  33. package/src/lib/blockstore-gc.ts +6 -5
  34. package/src/lib/cache.ts +30 -4
  35. package/src/lib/chat.ts +20 -14
  36. package/src/lib/commit-log-pruning.ts +2 -2
  37. package/src/lib/commit.ts +26 -36
  38. package/src/lib/config.ts +26 -15
  39. package/src/lib/did-document.ts +32 -0
  40. package/src/lib/errors.ts +54 -0
  41. package/src/lib/feed.ts +18 -19
  42. package/src/lib/firehose/frames.ts +87 -47
  43. package/src/lib/firehose/validation.ts +3 -3
  44. package/src/lib/jwt.ts +85 -177
  45. package/src/lib/labeler.ts +43 -30
  46. package/src/lib/logger.ts +4 -0
  47. package/src/lib/mst/block-map.ts +172 -0
  48. package/src/lib/mst/blockstore.ts +56 -93
  49. package/src/lib/mst/index.ts +1 -0
  50. package/src/lib/mst/leaf.ts +25 -0
  51. package/src/lib/mst/mst.ts +81 -237
  52. package/src/lib/mst/serialize.ts +97 -0
  53. package/src/lib/mst/types.ts +21 -0
  54. package/src/lib/oauth/clients.ts +67 -0
  55. package/src/lib/oauth/dpop-errors.ts +15 -0
  56. package/src/lib/oauth/dpop.ts +150 -0
  57. package/src/lib/oauth/resource.ts +199 -0
  58. package/src/lib/oauth/store.ts +77 -0
  59. package/src/lib/preferences.ts +12 -37
  60. package/src/lib/ratelimit.ts +4 -4
  61. package/src/lib/refresh-session.ts +161 -0
  62. package/src/lib/relay.ts +10 -8
  63. package/src/lib/secrets.ts +6 -7
  64. package/src/lib/sequencer.ts +14 -5
  65. package/src/lib/service-auth.ts +184 -0
  66. package/src/lib/session-tokens.ts +28 -76
  67. package/src/lib/streaming-car.ts +3 -0
  68. package/src/lib/tracing.ts +4 -3
  69. package/src/lib/util.ts +65 -15
  70. package/src/middleware.ts +1 -1
  71. package/src/pages/.well-known/did.json.ts +27 -30
  72. package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
  73. package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
  74. package/src/pages/debug/blob/[...key].ts +2 -2
  75. package/src/pages/debug/db/bootstrap.ts +1 -1
  76. package/src/pages/debug/db/commits.ts +1 -1
  77. package/src/pages/debug/gc/blobs.ts +1 -1
  78. package/src/pages/debug/record.ts +1 -1
  79. package/src/pages/debug/sequencer.ts +28 -0
  80. package/src/pages/health.ts +4 -4
  81. package/src/pages/oauth/authorize.ts +78 -0
  82. package/src/pages/oauth/consent.ts +80 -0
  83. package/src/pages/oauth/par.ts +121 -0
  84. package/src/pages/oauth/token.ts +158 -0
  85. package/src/pages/ready.ts +2 -2
  86. package/src/pages/xrpc/[...nsid].ts +61 -0
  87. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
  88. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
  89. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
  90. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
  91. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
  92. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
  93. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
  94. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
  95. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
  96. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
  97. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
  98. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
  99. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
  100. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
  101. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
  102. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
  103. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
  104. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
  105. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
  106. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
  107. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
  108. package/src/pages/xrpc/com.atproto.server.createSession.ts +32 -12
  109. package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
  110. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
  111. package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
  112. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
  113. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +72 -23
  114. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
  115. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
  116. package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
  117. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
  118. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
  119. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
  120. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
  121. package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
  122. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +2 -2
  123. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
  124. package/src/services/car.ts +209 -57
  125. package/src/services/r2-blob-store.ts +4 -4
  126. package/src/services/repo/blockstore-ops.ts +29 -0
  127. package/src/services/repo/operations.ts +133 -0
  128. package/src/services/repo-manager.ts +203 -254
  129. package/src/worker/runtime.ts +56 -11
  130. package/src/worker/sequencer/broadcast.ts +91 -0
  131. package/src/worker/sequencer/cid-helpers.ts +39 -0
  132. package/src/worker/sequencer/payload.ts +84 -0
  133. package/src/worker/sequencer/types.ts +36 -0
  134. package/src/worker/sequencer/upgrade.ts +141 -0
  135. package/src/worker/sequencer.ts +264 -406
  136. package/types/env.d.ts +18 -6
  137. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
  138. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
  139. package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
  140. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
  141. package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
  142. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
  143. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
  144. package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +0 -23
  145. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  146. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  147. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  148. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  149. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
  150. package/src/pages/xrpc/app.bsky.unspecced.getSuggestedFeeds.ts +0 -23
@@ -0,0 +1,15 @@
1
+ // DPoP verification raises `use_dpop_nonce` errors that must carry the current
2
+ // nonce back to the client (the OAuth 2.1 / DPoP RFC requires the next request
3
+ // to include this nonce in the proof JWT). Modelling it as a typed class lets
4
+ // callers narrow with `instanceof` instead of poking string properties off an
5
+ // any-typed Error.
6
+ export class DpopNonceError extends Error {
7
+ public readonly code = 'use_dpop_nonce' as const;
8
+ public readonly nonce: string;
9
+
10
+ constructor(message: string, nonce: string) {
11
+ super(message);
12
+ this.name = 'DpopNonceError';
13
+ this.nonce = nonce;
14
+ }
15
+ }
@@ -0,0 +1,150 @@
1
+ import type { Env } from '../../env';
2
+ import { errorMessage } from '../errors';
3
+ import { getOrCreateSecret, setSecret, getSecret } from '../../db/account';
4
+ import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
5
+ import { DpopNonceError } from './dpop-errors';
6
+
7
+ // DPoP nonce management and proof verification utilities
8
+
9
+ const NONCE_AUTHZ_KEY = 'oauth_dpop_nonce_authz';
10
+ const NONCE_TTL_SEC = 120; // rotate roughly every 2 minutes
11
+
12
+ export type DpopVerification = {
13
+ jkt: string; // JWK thumbprint
14
+ jwk: JsonWebKey;
15
+ payload: Record<string, unknown>;
16
+ };
17
+
18
+ function b64url(bytes: Uint8Array | ArrayBuffer): string {
19
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
20
+ let s = '';
21
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
22
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
23
+ }
24
+
25
+ // removed local b64urlToBytes helper
26
+
27
+ export async function getAuthzNonce(env: Env): Promise<string> {
28
+ const now = Math.floor(Date.now() / 1000);
29
+ const existingRaw = await getSecret(env, NONCE_AUTHZ_KEY);
30
+ if (existingRaw) {
31
+ try {
32
+ const parsed = JSON.parse(existingRaw) as { v: string; ts: number };
33
+ if (typeof parsed.v === 'string' && typeof parsed.ts === 'number') {
34
+ if (now - parsed.ts < NONCE_TTL_SEC) return parsed.v;
35
+ }
36
+ } catch {
37
+ // Corrupt cached nonce: fall through and mint a fresh one.
38
+ }
39
+ }
40
+ const v = crypto.randomUUID().replace(/-/g, '');
41
+ const rec = JSON.stringify({ v, ts: now });
42
+ await setSecret(env, NONCE_AUTHZ_KEY, rec);
43
+ return v;
44
+ }
45
+
46
+ export function setDpopNonceHeader(headers: Headers, nonce: string) {
47
+ headers.set('DPoP-Nonce', nonce);
48
+ }
49
+
50
+ // Compute RFC7638 JWK thumbprint for P-256 JWK
51
+ async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
52
+ // Per RFC7638, canonical JSON with these members in lexicographic order
53
+ const obj: Record<string, string> = {
54
+ crv: String(jwk.crv ?? ''),
55
+ kty: String(jwk.kty ?? ''),
56
+ x: String(jwk.x ?? ''),
57
+ y: String(jwk.y ?? ''),
58
+ };
59
+ const json = JSON.stringify(obj);
60
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(json));
61
+ return b64url(digest);
62
+ }
63
+
64
+ function urlWithoutHash(u: string): string {
65
+ try {
66
+ const url = new URL(u);
67
+ url.hash = '';
68
+ return url.toString();
69
+ } catch {
70
+ return u;
71
+ }
72
+ }
73
+
74
+ // removed local DER conversion; jose handles verification
75
+
76
+ export async function verifyDpop(env: Env, request: Request, opts?: { requireNonce?: boolean }): Promise<DpopVerification> {
77
+ const dpop = request.headers.get('DPoP');
78
+ const nonce = await getAuthzNonce(env);
79
+ if (!dpop) {
80
+ throw new DpopNonceError('DPoP required', nonce);
81
+ }
82
+ const [h, p] = dpop.split('.');
83
+ if (!h || !p) {
84
+ throw new DpopNonceError('Invalid DPoP', nonce);
85
+ }
86
+ const header = decodeProtectedHeader(dpop) as Record<string, unknown>;
87
+
88
+ if (header.typ !== 'dpop+jwt' || header.alg !== 'ES256' || !header.jwk) {
89
+ throw new DpopNonceError('Invalid DPoP header', nonce);
90
+ }
91
+
92
+ // Verify signature using JOSE
93
+ const key = await importJWK(header.jwk as JoseJWK, 'ES256');
94
+ const verified = await compactVerify(dpop, key);
95
+ const payload = JSON.parse(new TextDecoder().decode(verified.payload)) as Record<string, unknown>;
96
+
97
+ const method = request.method.toUpperCase();
98
+ const url = urlWithoutHash(request.url);
99
+ if (payload.htm !== method || payload.htu !== url) {
100
+ throw new DpopNonceError('DPoP htm/htu mismatch', nonce);
101
+ }
102
+
103
+ const now = Math.floor(Date.now() / 1000);
104
+ if (typeof payload.iat !== 'number' || now - payload.iat > 300) {
105
+ throw new DpopNonceError('DPoP iat too old', nonce);
106
+ }
107
+
108
+ if (opts?.requireNonce !== false) {
109
+ if (!payload.nonce || payload.nonce !== nonce) {
110
+ throw new DpopNonceError('use_dpop_nonce', nonce);
111
+ }
112
+ }
113
+
114
+ const jkt = await jwkThumbprint(header.jwk as JsonWebKey);
115
+ return { jkt, jwk: header.jwk as JsonWebKey, payload };
116
+ }
117
+
118
+ export function dpopErrorResponse(_env: Env, error: DpopNonceError): Response {
119
+ const body = JSON.stringify({ error: 'use_dpop_nonce', error_description: 'Authorization server requires nonce in DPoP proof' });
120
+ const headers = new Headers({ 'Content-Type': 'application/json' });
121
+ if (error.nonce) headers.set('DPoP-Nonce', error.nonce);
122
+ return new Response(body, { status: 401, headers });
123
+ }
124
+
125
+ export async function withDpop<T>(env: Env, request: Request, fn: (ver: DpopVerification) => Promise<T>): Promise<Response> {
126
+ try {
127
+ const ver = await verifyDpop(env, request);
128
+ const result = await fn(ver);
129
+ // Always include current nonce
130
+ const nonce = await getAuthzNonce(env);
131
+ const headers = new Headers({ 'Content-Type': 'application/json' });
132
+ setDpopNonceHeader(headers, nonce);
133
+ if (result instanceof Response) {
134
+ result.headers.set('DPoP-Nonce', nonce);
135
+ return result;
136
+ }
137
+ return new Response(JSON.stringify(result), { status: 200, headers });
138
+ } catch (e) {
139
+ if (e instanceof DpopNonceError) {
140
+ return dpopErrorResponse(env, e);
141
+ }
142
+ const headers = new Headers({ 'Content-Type': 'application/json' });
143
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: errorMessage(e) ?? 'Unknown error' }), { status: 400, headers });
144
+ }
145
+ }
146
+
147
+ export async function sha256b64url(input: string): Promise<string> {
148
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
149
+ return b64url(digest);
150
+ }
@@ -0,0 +1,199 @@
1
+ import type { Env } from '../../env';
2
+ import { errorCode, errorMessage } from '../errors';
3
+ import { verifyAccessToken } from '../session-tokens';
4
+ import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
5
+
6
+ const NONCE_PDS_KEY = 'oauth_dpop_nonce_pds';
7
+
8
+ type ResourceAuthErrorCode = 'use_dpop_nonce' | 'expired_token' | 'invalid_token';
9
+
10
+ export class ResourceAuthError extends Error {
11
+ public readonly code: ResourceAuthErrorCode;
12
+ public readonly nonce?: string;
13
+
14
+ constructor(code: ResourceAuthErrorCode, opts: { message?: string; nonce?: string } = {}) {
15
+ super(opts.message ?? code);
16
+ this.code = code;
17
+ this.nonce = opts.nonce;
18
+ }
19
+ }
20
+
21
+ function b64url(bytes: Uint8Array | ArrayBuffer): string {
22
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
23
+ let s = '';
24
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
25
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
26
+ }
27
+
28
+ function urlWithoutHash(u: string): string {
29
+ try { const url = new URL(u); url.hash = ''; return url.toString(); } catch { return u; }
30
+ }
31
+ // removed local b64urlToBytes and DER helpers; jose handles verification
32
+
33
+ async function getNonce(env: Env): Promise<string> {
34
+ const { getSecret, setSecret } = await import('../../db/account');
35
+ const now = Math.floor(Date.now() / 1000);
36
+ const raw = await getSecret(env, NONCE_PDS_KEY);
37
+ if (raw) {
38
+ try {
39
+ const cached = JSON.parse(raw) as { v: string; ts: number };
40
+ if (now - cached.ts < 120) return cached.v;
41
+ } catch {
42
+ // Corrupt cached nonce: fall through and mint a fresh one.
43
+ }
44
+ }
45
+ const v = crypto.randomUUID().replace(/-/g, '');
46
+ await setSecret(env, NONCE_PDS_KEY, JSON.stringify({ v, ts: now }));
47
+ return v;
48
+ }
49
+
50
+ export async function verifyResourceRequest(env: Env, request: Request): Promise<{ did: string; token: string } | null> {
51
+ const auth = request.headers.get('authorization');
52
+ if (!auth) return null;
53
+
54
+ const match = auth.match(/^(\S+)\s+(.+)$/);
55
+ if (!match) return null;
56
+
57
+ const [, schemeRaw, tokenRaw] = match;
58
+ const scheme = schemeRaw?.toLowerCase();
59
+ const token = tokenRaw?.trim();
60
+ if (!scheme || !token) return null;
61
+
62
+ if (scheme === 'dpop') {
63
+ return verifyDpopAccess(env, request, token);
64
+ }
65
+
66
+ if (scheme === 'bearer') {
67
+ const payload = await verifyAccessTokenOrThrow(env, token);
68
+ return { did: payload.sub as string, token };
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ async function verifyDpopAccess(env: Env, request: Request, accessToken: string) {
75
+ const nonce = await getNonce(env);
76
+ const dpop = request.headers.get('DPoP');
77
+ if (!dpop) {
78
+ throw new ResourceAuthError('use_dpop_nonce', { nonce });
79
+ }
80
+
81
+ const [h,p] = dpop.split('.');
82
+ if (!h||!p) throw new ResourceAuthError('use_dpop_nonce', { nonce });
83
+ const header = decodeProtectedHeader(dpop) as any;
84
+ if (header.typ !== 'dpop+jwt' || header.alg !== 'ES256' || !header.jwk) throw new ResourceAuthError('use_dpop_nonce', { nonce });
85
+ const method = request.method.toUpperCase();
86
+ const url = urlWithoutHash(request.url);
87
+ const verified = await compactVerify(dpop, await importJWK(header.jwk as JoseJWK, 'ES256'));
88
+ const payload = JSON.parse(new TextDecoder().decode(verified.payload));
89
+ if (payload.htm !== method || payload.htu !== url) throw new ResourceAuthError('use_dpop_nonce', { nonce });
90
+ const now = Math.floor(Date.now()/1000);
91
+ if (typeof payload.iat !== 'number' || now - payload.iat > 300) throw new ResourceAuthError('use_dpop_nonce', { nonce });
92
+ if (!payload.nonce || payload.nonce !== nonce) throw new ResourceAuthError('use_dpop_nonce', { nonce });
93
+ // Verify ath binding
94
+ const enc = new TextEncoder();
95
+ const accessBytes = enc.encode(accessToken);
96
+ const accessBuf = (() => { const b = new ArrayBuffer(accessBytes.byteLength); new Uint8Array(b).set(accessBytes); return b; })();
97
+ const expectedAth = await crypto.subtle.digest('SHA-256', accessBuf);
98
+ const expectedAthB64 = b64url(expectedAth);
99
+ if (payload.ath !== expectedAthB64) throw new ResourceAuthError('use_dpop_nonce', { nonce });
100
+ // Verify signature with JOSE
101
+ const key = await importJWK(header.jwk as JoseJWK, 'ES256');
102
+ // already verified above, nothing else to do
103
+
104
+ const tokenPayload = await verifyAccessTokenOrThrow(env, accessToken);
105
+ return { did: tokenPayload.sub as string, token: accessToken };
106
+ }
107
+
108
+ async function verifyAccessTokenOrThrow(env: Env, token: string) {
109
+ let payloadJwt: Awaited<ReturnType<typeof verifyAccessToken>>;
110
+ try {
111
+ payloadJwt = await verifyAccessToken(env, token);
112
+ } catch (error) {
113
+ if (errorCode(error) === 'ERR_JWT_EXPIRED') {
114
+ throw new ResourceAuthError('expired_token');
115
+ }
116
+ if (errorCode(error)) {
117
+ throw new ResourceAuthError('invalid_token');
118
+ }
119
+ throw error;
120
+ }
121
+
122
+ if (!payloadJwt || !payloadJwt.sub) {
123
+ throw new ResourceAuthError('invalid_token');
124
+ }
125
+ return payloadJwt;
126
+ }
127
+
128
+ export async function dpopResourceUnauthorized(env: Env, message?: string, nonceOverride?: string): Promise<Response> {
129
+ const nonce = nonceOverride ?? await getNonce(env);
130
+ const headers = new Headers();
131
+ headers.set('WWW-Authenticate', 'DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof"');
132
+ headers.set('DPoP-Nonce', nonce);
133
+ headers.set('Content-Type', 'application/json');
134
+ const body = JSON.stringify({ error: 'use_dpop_nonce', error_description: message ?? 'DPoP nonce required' });
135
+ return new Response(body, { status: 401, headers });
136
+ }
137
+
138
+ /**
139
+ * Hybrid authentication that supports both DPoP (OAuth) and Bearer (legacy XRPC) tokens.
140
+ * Tries DPoP first, then falls back to Bearer for backward compatibility with official Bluesky apps.
141
+ */
142
+ type VerifyResourceHybridDeps = {
143
+ verifyAccessToken: typeof verifyAccessTokenOrThrow;
144
+ };
145
+
146
+ const defaultVerifyHybridDeps: VerifyResourceHybridDeps = {
147
+ verifyAccessToken: verifyAccessTokenOrThrow,
148
+ };
149
+
150
+ export async function verifyResourceRequestHybrid(
151
+ env: Env,
152
+ request: Request,
153
+ deps: VerifyResourceHybridDeps = defaultVerifyHybridDeps,
154
+ ): Promise<{ did: string; token: string } | null> {
155
+ const auth = request.headers.get('authorization');
156
+ if (!auth) return null;
157
+
158
+ // Try DPoP authentication first (new OAuth flow)
159
+ if (auth.startsWith('DPoP ')) {
160
+ try {
161
+ const result = await verifyResourceRequest(env, request);
162
+ if (result) return result;
163
+ } catch (e) {
164
+ // If it's a nonce error, propagate it
165
+ if (errorCode(e) === 'use_dpop_nonce') throw e;
166
+ // Otherwise fall through to Bearer
167
+ }
168
+ }
169
+
170
+ // Fall back to Bearer token authentication (legacy XRPC flow)
171
+ if (auth.startsWith('Bearer ')) {
172
+ const token = auth.slice(7).trim();
173
+ const payloadJwt = await deps.verifyAccessToken(env, token);
174
+ return { did: payloadJwt.sub as string, token };
175
+ }
176
+
177
+ return null;
178
+ }
179
+
180
+ function jsonError(error: string, message: string, status: number): Response {
181
+ return new Response(JSON.stringify({ error, message }), {
182
+ status,
183
+ headers: { 'Content-Type': 'application/json' },
184
+ });
185
+ }
186
+
187
+ export async function handleResourceAuthError(env: Env, error: unknown): Promise<Response | null> {
188
+ if (!(error instanceof ResourceAuthError)) {
189
+ return null;
190
+ }
191
+ switch (error.code) {
192
+ case 'use_dpop_nonce':
193
+ return dpopResourceUnauthorized(env, undefined, error.nonce);
194
+ case 'expired_token':
195
+ return jsonError('ExpiredToken', 'Access token expired', 400);
196
+ case 'invalid_token':
197
+ return jsonError('InvalidToken', 'Invalid or malformed access token', 400);
198
+ }
199
+ }
@@ -0,0 +1,77 @@
1
+ import type { Env } from '../../env';
2
+ import { getSecret, setSecret } from '../../db/account';
3
+
4
+ const PAR_PREFIX = 'oauth:par:';
5
+ const CODE_PREFIX = 'oauth:code:';
6
+
7
+ export interface ParRecord {
8
+ client_id: string;
9
+ redirect_uri: string;
10
+ code_challenge: string;
11
+ code_challenge_method: 'S256';
12
+ scope: string;
13
+ state: string;
14
+ login_hint?: string;
15
+ dpopJkt: string;
16
+ createdAt: number;
17
+ expiresAt: number;
18
+ }
19
+
20
+ export interface CodeRecord {
21
+ code: string;
22
+ client_id: string;
23
+ redirect_uri: string;
24
+ code_challenge: string;
25
+ scope: string;
26
+ dpopJkt: string;
27
+ did: string;
28
+ createdAt: number;
29
+ expiresAt: number;
30
+ used?: boolean;
31
+ }
32
+
33
+ export async function savePar(env: Env, id: string, rec: ParRecord): Promise<void> {
34
+ await setSecret(env, PAR_PREFIX + id, JSON.stringify(rec));
35
+ }
36
+
37
+ export async function loadPar(env: Env, id: string): Promise<ParRecord | null> {
38
+ const raw = await getSecret(env, PAR_PREFIX + id);
39
+ if (!raw) return null;
40
+ try {
41
+ const rec = JSON.parse(raw) as ParRecord;
42
+ if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) return null;
43
+ return rec;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export async function deletePar(env: Env, id: string): Promise<void> {
50
+ // Overwrite with expired to minimize API surface
51
+ await setSecret(env, PAR_PREFIX + id, JSON.stringify({}));
52
+ }
53
+
54
+ export async function saveCode(env: Env, code: string, rec: CodeRecord): Promise<void> {
55
+ await setSecret(env, CODE_PREFIX + code, JSON.stringify(rec));
56
+ }
57
+
58
+ export async function loadCode(env: Env, code: string): Promise<CodeRecord | null> {
59
+ const raw = await getSecret(env, CODE_PREFIX + code);
60
+ if (!raw) return null;
61
+ try {
62
+ const rec = JSON.parse(raw) as CodeRecord;
63
+ if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) return null;
64
+ return rec;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export async function consumeCode(env: Env, code: string): Promise<CodeRecord | null> {
71
+ const rec = await loadCode(env, code);
72
+ if (!rec) return null;
73
+ if (rec.used) return null;
74
+ rec.used = true;
75
+ await setSecret(env, CODE_PREFIX + code, JSON.stringify(rec));
76
+ return rec;
77
+ }
@@ -1,71 +1,46 @@
1
1
  import type { Env } from '../env';
2
2
  import { resolveSecret } from './secrets';
3
+ import { ServerMisconfigured } from './errors';
3
4
 
4
5
  let tableEnsured = false;
5
6
 
6
7
  async function ensureTable(env: Env) {
7
8
  if (tableEnsured) return;
8
- await env.DB.exec(
9
+ await env.ALTERAN_DB.exec(
9
10
  'CREATE TABLE IF NOT EXISTS actor_preferences (did TEXT PRIMARY KEY, json TEXT NOT NULL, updated_at INTEGER NOT NULL)'
10
11
  );
11
12
  tableEnsured = true;
12
13
  }
13
14
 
14
- const DEFAULT_PREFERENCES = [
15
- {
16
- $type: 'app.bsky.actor.defs#savedFeedsPrefV2',
17
- items: [],
18
- },
19
- {
20
- $type: 'app.bsky.actor.defs#feedViewPref',
21
- feed: 'home',
22
- hideReplies: false,
23
- hideRepliesByUnfollowed: false,
24
- hideRepliesByLikeCount: 0,
25
- hideReposts: false,
26
- hideQuotePosts: false,
27
- },
28
- {
29
- $type: 'app.bsky.actor.defs#threadViewPref',
30
- sort: 'oldest',
31
- prioritizeFollowedUsers: true,
32
- },
33
- {
34
- $type: 'app.bsky.actor.defs#labelersPref',
35
- labelers: [
36
- {
37
- did: 'did:plc:ar7c4by46qjdydhdevvrndac',
38
- },
39
- ],
40
- },
41
- ];
15
+ // No defaults — return empty when nothing stored to avoid local fallbacks
42
16
 
43
17
  export async function getActorPreferences(env: Env): Promise<{ did: string; preferences: any[] }> {
44
18
  await ensureTable(env);
45
- const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
46
- const row = await env.DB.prepare('SELECT json FROM actor_preferences WHERE did = ?')
19
+ const did = await resolveSecret(env.PDS_DID);
20
+ if (!did) throw new ServerMisconfigured('PDS_DID is not configured');
21
+ const row = await env.ALTERAN_DB.prepare('SELECT json FROM actor_preferences WHERE did = ?')
47
22
  .bind(did)
48
23
  .first<{ json: string }>();
49
24
 
50
25
  if (!row?.json) {
51
- return { did, preferences: DEFAULT_PREFERENCES };
26
+ return { did, preferences: [] };
52
27
  }
53
28
 
54
29
  try {
55
30
  const parsed = JSON.parse(row.json);
56
31
  const preferences = Array.isArray(parsed) ? parsed : [];
57
- // If preferences exist but are empty, return defaults
58
- return { did, preferences: preferences.length > 0 ? preferences : DEFAULT_PREFERENCES };
32
+ return { did, preferences };
59
33
  } catch {
60
- return { did, preferences: DEFAULT_PREFERENCES };
34
+ return { did, preferences: [] };
61
35
  }
62
36
  }
63
37
 
64
38
  export async function setActorPreferences(env: Env, preferences: any[]): Promise<void> {
65
39
  await ensureTable(env);
66
- const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
40
+ const did = await resolveSecret(env.PDS_DID);
41
+ if (!did) throw new ServerMisconfigured('PDS_DID is not configured');
67
42
  const now = Date.now();
68
- await env.DB.prepare(
43
+ await env.ALTERAN_DB.prepare(
69
44
  'INSERT INTO actor_preferences (did, json, updated_at) VALUES (?, ?, ?) ON CONFLICT(did) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at'
70
45
  )
71
46
  .bind(did, JSON.stringify(preferences ?? []), now)
@@ -8,14 +8,14 @@ export async function checkRate(env: Env, request: Request, bucket: 'writes' | '
8
8
  const windowMs = 60_000;
9
9
  const win = Math.floor(now / windowMs);
10
10
  const ip = request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1';
11
- await env.DB.exec("CREATE TABLE IF NOT EXISTS rate_limit (ip TEXT NOT NULL, bucket TEXT NOT NULL, window INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (ip,bucket,window))");
12
- const row: any = await env.DB.prepare('SELECT count FROM rate_limit WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).first();
11
+ await env.ALTERAN_DB.exec("CREATE TABLE IF NOT EXISTS rate_limit (ip TEXT NOT NULL, bucket TEXT NOT NULL, window INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (ip,bucket,window))");
12
+ const row: any = await env.ALTERAN_DB.prepare('SELECT count FROM rate_limit WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).first();
13
13
  const count = row?.count ? Number(row.count) : 0;
14
14
  if (count >= limit) return rateLimited();
15
15
  if (count === 0) {
16
- await env.DB.prepare('INSERT OR REPLACE INTO rate_limit (ip,bucket,window,count) VALUES (?,?,?,1)').bind(ip, bucket, win).run();
16
+ await env.ALTERAN_DB.prepare('INSERT OR REPLACE INTO rate_limit (ip,bucket,window,count) VALUES (?,?,?,1)').bind(ip, bucket, win).run();
17
17
  } else {
18
- await env.DB.prepare('UPDATE rate_limit SET count=count+1 WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).run();
18
+ await env.ALTERAN_DB.prepare('UPDATE rate_limit SET count=count+1 WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).run();
19
19
  }
20
20
 
21
21
  const headers = new Headers();