@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,93 @@
1
+ import type { Env } from '../../env';
2
+ import { getRuntimeString } from '../secrets';
3
+ import { ServerMisconfigured } from '../errors';
4
+ import { getAppViewConfig } from './service-config';
5
+
6
+ function encodeBase64Url(bytes: Uint8Array): string {
7
+ let binary = '';
8
+ for (let i = 0; i < bytes.length; i++) {
9
+ binary += String.fromCharCode(bytes[i]);
10
+ }
11
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
12
+ }
13
+
14
+ function encodeJson(obj: Record<string, unknown>): string {
15
+ return encodeBase64Url(new TextEncoder().encode(JSON.stringify(obj)));
16
+ }
17
+
18
+ function randomHex(bytes = 16): string {
19
+ const arr = new Uint8Array(bytes);
20
+ crypto.getRandomValues(arr);
21
+ return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
22
+ }
23
+
24
+ async function es256kSign(privateKey: string, data: string): Promise<Uint8Array> {
25
+ const cleaned = privateKey.trim();
26
+ const { Secp256k1Keypair } = await import('@atproto/crypto');
27
+ const keypair = /^[0-9a-fA-F]{64}$/.test(cleaned)
28
+ ? await Secp256k1Keypair.import(cleaned)
29
+ : await Secp256k1Keypair.import(base64ToBytes(cleaned));
30
+ return keypair.sign(new TextEncoder().encode(data));
31
+ }
32
+
33
+ function base64ToBytes(value: string): Uint8Array {
34
+ const binary = atob(value.replace(/\s+/g, ''));
35
+ const bytes = new Uint8Array(binary.length);
36
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
37
+ return bytes;
38
+ }
39
+
40
+ export async function createServiceJwt(
41
+ env: Env,
42
+ issuerDid: string,
43
+ audienceDid: string,
44
+ lexiconMethod: string | null,
45
+ expiresInSeconds = 60,
46
+ ): Promise<string> {
47
+ const now = Math.floor(Date.now() / 1000);
48
+ const exp = now + Math.max(1, expiresInSeconds);
49
+ const payload: Record<string, unknown> = {
50
+ iss: issuerDid,
51
+ aud: audienceDid,
52
+ iat: now,
53
+ exp,
54
+ jti: randomHex(),
55
+ };
56
+ if (lexiconMethod) payload.lxm = lexiconMethod;
57
+
58
+ const privateKey = ((await getRuntimeString(env, 'REPO_SIGNING_KEY', '')) ?? '').trim();
59
+ if (!privateKey) {
60
+ throw new ServerMisconfigured('REPO_SIGNING_KEY not configured for ES256K service-auth');
61
+ }
62
+
63
+ const header = { typ: 'JWT', alg: 'ES256K' };
64
+ const encodedHeader = encodeJson(header);
65
+ const encodedPayload = encodeJson(payload);
66
+ const toSign = `${encodedHeader}.${encodedPayload}`;
67
+ const signature = await es256kSign(privateKey, toSign);
68
+ return `${toSign}.${encodeBase64Url(signature)}`;
69
+ }
70
+
71
+ export async function getAppViewServiceToken(
72
+ env: Env,
73
+ did: string,
74
+ aud?: string,
75
+ lxm?: string | null,
76
+ expiresInSeconds = 60,
77
+ ): Promise<string> {
78
+ const config = getAppViewConfig(env);
79
+ if (!config) {
80
+ throw new ServerMisconfigured('AppView not configured');
81
+ }
82
+ return createServiceJwt(env, did, aud ?? config.did, lxm ?? null, expiresInSeconds);
83
+ }
84
+
85
+ export async function createServiceAuthToken(
86
+ env: Env,
87
+ issuerDid: string,
88
+ audienceDid: string,
89
+ lexiconMethod: string | null,
90
+ expiresInSeconds = 60,
91
+ ): Promise<string> {
92
+ return createServiceJwt(env, issuerDid, audienceDid, lexiconMethod, expiresInSeconds);
93
+ }
@@ -0,0 +1,25 @@
1
+ export type ServiceId = 'bsky_appview' | 'bsky_chat' | 'atproto_labeler';
2
+
3
+ export type ServiceConfig = {
4
+ readonly id: ServiceId;
5
+ readonly url: string;
6
+ readonly did: string;
7
+ };
8
+
9
+ export type AppViewConfig = {
10
+ readonly url: string;
11
+ readonly did: string;
12
+ readonly cdnUrlPattern?: string;
13
+ };
14
+
15
+ export type ProxyTarget = {
16
+ readonly did: string;
17
+ readonly url: string;
18
+ };
19
+
20
+ export type AuthScope =
21
+ | 'com.atproto.access'
22
+ | 'com.atproto.appPass'
23
+ | 'com.atproto.appPassPrivileged'
24
+ | 'com.atproto.signupQueued'
25
+ | 'com.atproto.takendown';
@@ -1,532 +1,5 @@
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
- console.log('proxyAppView called:', { lxm, url: request.url });
354
-
355
- const config = getAppViewConfig(env);
356
- if (!config) {
357
- console.log('proxyAppView: No appview config, using fallback');
358
- return fallback ? await fallback() : new Response('AppView not configured', { status: 501 });
359
- }
360
-
361
- console.log('proxyAppView: AppView config found:', { url: config.url, did: config.did });
362
-
363
- const auth = await authenticateRequest(request, env);
364
- if (!auth) {
365
- console.log('proxyAppView: Authentication failed');
366
- return unauthorized();
367
- }
368
-
369
- if (!auth.claims.sub) {
370
- console.log('proxyAppView: No subject in auth claims');
371
- return new Response(JSON.stringify({ error: 'InvalidToken' }), {
372
- status: 401,
373
- headers: { 'Content-Type': 'application/json' },
374
- });
375
- }
376
-
377
- console.log('proxyAppView: Authenticated as', auth.claims.sub);
378
-
379
- if (PROTECTED_METHODS.has(lxm)) {
380
- console.log('proxyAppView: Method is protected, cannot proxy');
381
- return new Response(
382
- JSON.stringify({ error: 'InvalidToken', message: 'method cannot be proxied' }),
383
- {
384
- status: 400,
385
- headers: { 'Content-Type': 'application/json' },
386
- },
387
- );
388
- }
389
-
390
- const scope = resolveAuthScope(auth.claims.scope);
391
- if (scope === TAKENDOWN_SCOPE) {
392
- console.log('proxyAppView: Account is takendown');
393
- return new Response(JSON.stringify({ error: 'AccountTakendown' }), {
394
- status: 403,
395
- headers: { 'Content-Type': 'application/json' },
396
- });
397
- }
398
-
399
- if (!PRIVILEGED_SCOPES.has(scope) && PRIVILEGED_METHODS.has(lxm)) {
400
- console.log('proxyAppView: Insufficient privileges for method');
401
- return new Response(JSON.stringify({ error: 'InvalidToken' }), {
402
- status: 401,
403
- headers: { 'Content-Type': 'application/json' },
404
- });
405
- }
406
-
407
- let target: ProxyTarget = { did: config.did, url: config.url };
408
- const proxyHeader = request.headers.get('atproto-proxy');
409
- if (proxyHeader) {
410
- console.log('proxyAppView: Resolving proxy header:', proxyHeader);
411
- try {
412
- target = await resolveProxyTarget(env, proxyHeader, config);
413
- } catch (error) {
414
- console.error('AppView proxy header error:', error);
415
- const isHeaderError = error instanceof ProxyHeaderError;
416
- return new Response(
417
- JSON.stringify({ error: isHeaderError ? 'InvalidProxyHeader' : 'ProxyResolutionFailed' }),
418
- {
419
- status: isHeaderError ? 400 : 502,
420
- headers: { 'Content-Type': 'application/json' },
421
- },
422
- );
423
- }
424
- }
425
-
426
- const originalUrl = new URL(request.url);
427
- const upstreamUrl = new URL(target.url);
428
- upstreamUrl.pathname = originalUrl.pathname;
429
- upstreamUrl.search = originalUrl.search;
430
- upstreamUrl.hash = '';
431
-
432
- console.log('proxyAppView: Proxying to', upstreamUrl.toString());
433
-
434
- const headers = new Headers();
435
- for (const header of FORWARDED_HEADERS) {
436
- const value = request.headers.get(header);
437
- if (value) headers.set(header, value);
438
- }
439
-
440
- let serviceJwt: string;
441
- try {
442
- console.log('proxyAppView: Creating service JWT for', { iss: auth.claims.sub, aud: target.did, lxm });
443
- serviceJwt = await createServiceJwt(env, auth.claims.sub, target.did, lxm);
444
- console.log('proxyAppView: Service JWT created successfully');
445
- } catch (error) {
446
- console.error('AppView service token error:', error);
447
- if (fallback) {
448
- console.log('proxyAppView: Using fallback due to JWT error');
449
- return fallback();
450
- }
451
- return new Response(JSON.stringify({ error: 'ServiceAuthUnavailable' }), {
452
- status: 503,
453
- headers: { 'Content-Type': 'application/json' },
454
- });
455
- }
456
-
457
- headers.set('authorization', `Bearer ${serviceJwt}`);
458
-
459
- const method = request.method.toUpperCase();
460
- if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
461
- console.log('proxyAppView: Method not allowed:', method);
462
- return new Response(JSON.stringify({ error: 'MethodNotAllowed' }), {
463
- status: 405,
464
- headers: {
465
- 'Content-Type': 'application/json',
466
- Allow: 'GET, HEAD, POST',
467
- },
468
- });
469
- }
470
-
471
- if (!headers.has('accept-encoding')) {
472
- headers.set('accept-encoding', 'identity');
473
- }
474
-
475
- if (method === 'POST') {
476
- const contentType = request.headers.get('content-type');
477
- if (contentType) headers.set('content-type', contentType);
478
- const contentEncoding = request.headers.get('content-encoding');
479
- if (contentEncoding) headers.set('content-encoding', contentEncoding);
480
- }
481
-
482
- try {
483
- const init: RequestInit = {
484
- method,
485
- headers,
486
- };
487
-
488
- if (method === 'POST') {
489
- init.body = request.body as any;
490
- (init as any).duplex = 'half';
491
- }
492
-
493
- console.log('proxyAppView: Fetching upstream');
494
- const upstream = await fetch(upstreamUrl.toString(), init);
495
- console.log('proxyAppView: Upstream response:', { status: upstream.status, statusText: upstream.statusText });
496
-
497
- const responseHeaders = new Headers(upstream.headers);
498
- return new Response(upstream.body, {
499
- status: upstream.status,
500
- statusText: upstream.statusText,
501
- headers: responseHeaders,
502
- });
503
- } catch (error) {
504
- console.error('AppView proxy error:', error);
505
- if (fallback) {
506
- console.log('proxyAppView: Using fallback due to upstream error');
507
- return fallback();
508
- }
509
- return new Response(JSON.stringify({ error: 'UpstreamUnavailable' }), {
510
- status: 502,
511
- headers: { 'Content-Type': 'application/json' },
512
- });
513
- }
514
- }
515
-
516
- export async function getAppViewServiceToken(env: Env, did: string, aud?: string, lxm?: string | null, expiresInSeconds = 60) {
517
- const config = getAppViewConfig(env);
518
- if (!config) {
519
- throw new Error('AppView not configured');
520
- }
521
- return createServiceJwt(env, did, aud ?? config.did, lxm ?? null, expiresInSeconds);
522
- }
523
-
524
- export async function createServiceAuthToken(
525
- env: Env,
526
- issuerDid: string,
527
- audienceDid: string,
528
- lexiconMethod: string | null,
529
- expiresInSeconds = 60,
530
- ): Promise<string> {
531
- return createServiceJwt(env, issuerDid, audienceDid, lexiconMethod, expiresInSeconds);
532
- }
1
+ export type { AppViewConfig, ServiceConfig, ServiceId, ProxyTarget, AuthScope } from './appview/types';
2
+ export { getAppViewConfig } from './appview/service-config';
3
+ export { createServiceAuthToken, getAppViewServiceToken } from './appview/service-jwt';
4
+ export { proxyAppView } from './appview/proxy';
5
+ export type { ProxyAppViewOptions } from './appview/proxy';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Control-flow marker thrown by the auth pipeline when an access token has
3
+ * expired but is otherwise valid. Callers translate this into the
4
+ * `ExpiredToken` XRPC response so clients know to refresh rather than
5
+ * re-authenticate.
6
+ */
7
+ export class AuthTokenExpiredError extends Error {
8
+ readonly code = 'ExpiredToken';
9
+
10
+ constructor(message: string = 'Token has expired') {
11
+ super(message);
12
+ this.name = 'AuthTokenExpiredError';
13
+ }
14
+ }
15
+
16
+ export function expiredToken(message: string = 'Token has expired'): Response {
17
+ return new Response(
18
+ JSON.stringify({ error: 'ExpiredToken', message }),
19
+ {
20
+ status: 400,
21
+ headers: { 'Content-Type': 'application/json' },
22
+ },
23
+ );
24
+ }