@alteran/astro 0.3.9 → 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 (138) 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 +263 -405
  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.getSuggestedFeeds.ts +0 -23
  133. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  134. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  135. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  136. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  137. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
  138. package/src/pages/xrpc/app.bsky.unspecced.getSuggestedFeeds.ts +0 -23
@@ -0,0 +1,233 @@
1
+ import type { Env } from '../../env';
2
+ import { InvalidProxyHeader, UpstreamProxyFailure } from '../errors';
3
+ import type { ProxyTarget, ServiceConfig, ServiceId } from './types';
4
+
5
+ type DidService = {
6
+ readonly id?: unknown;
7
+ readonly serviceEndpoint?: unknown;
8
+ };
9
+
10
+ type DidDocument = {
11
+ readonly id?: unknown;
12
+ readonly service?: unknown;
13
+ };
14
+
15
+ // The cache key is derived from a user-supplied `atproto-proxy` header. Without
16
+ // bounds, a noisy or hostile client can grow this map without limit. Cap the
17
+ // entry count and expire entries after a fixed TTL so memory stays predictable
18
+ // across long-lived isolates.
19
+ const CACHE_MAX = 1024;
20
+ const CACHE_TTL_MS = 10 * 60 * 1000;
21
+
22
+ type CacheEntry = {
23
+ readonly doc: Promise<DidDocument>;
24
+ readonly expiresAt: number;
25
+ };
26
+ const didDocumentCache = new Map<string, CacheEntry>();
27
+
28
+ let clock: () => number = () => Date.now();
29
+ let fetchOverride: ((did: string) => Promise<DidDocument>) | null = null;
30
+
31
+ function getCached(did: string): Promise<DidDocument> | null {
32
+ const entry = didDocumentCache.get(did);
33
+ if (!entry) return null;
34
+ if (entry.expiresAt <= clock()) {
35
+ didDocumentCache.delete(did);
36
+ return null;
37
+ }
38
+ // Re-insert to mark as most recently used. Map preserves insertion order, so
39
+ // the oldest entry is whatever keys().next() returns when we evict.
40
+ didDocumentCache.delete(did);
41
+ didDocumentCache.set(did, entry);
42
+ return entry.doc;
43
+ }
44
+
45
+ function setCached(did: string, doc: Promise<DidDocument>): void {
46
+ if (didDocumentCache.size >= CACHE_MAX) {
47
+ const oldest = didDocumentCache.keys().next().value;
48
+ if (oldest !== undefined) didDocumentCache.delete(oldest);
49
+ }
50
+ didDocumentCache.set(did, { doc, expiresAt: clock() + CACHE_TTL_MS });
51
+ }
52
+
53
+ export function parseProxyHeader(header: string): { did: string; serviceId: string } {
54
+ const value = header.trim();
55
+ const hashIndex = value.indexOf('#');
56
+
57
+ if (hashIndex <= 0 || hashIndex === value.length - 1) {
58
+ throw new InvalidProxyHeader('invalid format');
59
+ }
60
+
61
+ if (value.indexOf('#', hashIndex + 1) !== -1) {
62
+ throw new InvalidProxyHeader('invalid format');
63
+ }
64
+
65
+ const did = value.slice(0, hashIndex);
66
+ const serviceId = value.slice(hashIndex);
67
+
68
+ if (!did.startsWith('did:')) {
69
+ throw new InvalidProxyHeader('invalid DID');
70
+ }
71
+
72
+ if (!serviceId.startsWith('#')) {
73
+ throw new InvalidProxyHeader('invalid service id');
74
+ }
75
+
76
+ if (value.includes(' ')) {
77
+ throw new InvalidProxyHeader('invalid format');
78
+ }
79
+
80
+ return { did, serviceId };
81
+ }
82
+
83
+ export async function resolveProxyTargetWithRegistry(
84
+ env: Env,
85
+ proxyHeader: string,
86
+ registry: Record<ServiceId, ServiceConfig>,
87
+ ): Promise<ProxyTarget> {
88
+ const { did, serviceId } = parseProxyHeader(proxyHeader);
89
+
90
+ const trimmedServiceId = serviceId.startsWith('#') ? serviceId.slice(1) : serviceId;
91
+ const known = registry[trimmedServiceId as ServiceId];
92
+ if (known && did === known.did) {
93
+ return { did, url: known.url };
94
+ }
95
+
96
+ const didDoc = await resolveDidDocument(env, did);
97
+ const endpoint = getServiceEndpointFromDidDoc(didDoc, serviceId);
98
+ if (!endpoint) {
99
+ throw new InvalidProxyHeader('service id not found in DID document');
100
+ }
101
+ return { did, url: endpoint };
102
+ }
103
+
104
+ async function resolveDidDocument(env: Env, did: string): Promise<DidDocument> {
105
+ const existing = getCached(did);
106
+ if (existing) return existing;
107
+
108
+ const loader = fetchDidDocument(env, did).catch((error) => {
109
+ didDocumentCache.delete(did);
110
+ throw error;
111
+ });
112
+
113
+ setCached(did, loader);
114
+ return loader;
115
+ }
116
+
117
+ async function fetchDidDocument(_env: Env, did: string): Promise<DidDocument> {
118
+ if (fetchOverride) return fetchOverride(did);
119
+
120
+ let url: string;
121
+ if (did.startsWith('did:web:')) {
122
+ url = buildDidWebUrl(did);
123
+ } else if (did.startsWith('did:plc:')) {
124
+ url = `https://plc.directory/${did}`;
125
+ } else {
126
+ throw new InvalidProxyHeader('unsupported DID method');
127
+ }
128
+
129
+ const response = await fetch(url, {
130
+ headers: {
131
+ accept: 'application/did+json, application/json;q=0.9',
132
+ },
133
+ });
134
+
135
+ if (!response.ok) {
136
+ throw new UpstreamProxyFailure('failed to resolve DID document');
137
+ }
138
+
139
+ return parseDidDocument(await response.json());
140
+ }
141
+
142
+ function parseDidDocument(value: unknown): DidDocument {
143
+ if (!value || typeof value !== 'object') {
144
+ throw new UpstreamProxyFailure('DID document is not an object');
145
+ }
146
+ const record = value as Record<string, unknown>;
147
+ if (typeof record.id !== 'string') {
148
+ throw new UpstreamProxyFailure('DID document missing id');
149
+ }
150
+ if (record.service !== undefined && !Array.isArray(record.service)) {
151
+ throw new UpstreamProxyFailure('DID document service field is not an array');
152
+ }
153
+ return record as DidDocument;
154
+ }
155
+
156
+ function buildDidWebUrl(did: string): string {
157
+ const suffix = did.slice('did:web:'.length);
158
+ const parts = suffix.split(':').map((segment) => {
159
+ try {
160
+ return decodeURIComponent(segment);
161
+ } catch {
162
+ throw new InvalidProxyHeader('invalid did:web encoding');
163
+ }
164
+ });
165
+
166
+ const host = parts.shift();
167
+ if (!host) throw new InvalidProxyHeader('invalid did:web value');
168
+
169
+ if (parts.length === 0) {
170
+ return `https://${host}/.well-known/did.json`;
171
+ }
172
+
173
+ return `https://${host}/${parts.join('/')}/did.json`;
174
+ }
175
+
176
+ function getServiceEndpointFromDidDoc(didDoc: DidDocument, serviceId: string): string | null {
177
+ if (!didDoc || typeof didDoc !== 'object') return null;
178
+ const services = Array.isArray(didDoc.service) ? (didDoc.service as DidService[]) : [];
179
+ if (!services.length) return null;
180
+
181
+ const targets = new Set<string>([serviceId]);
182
+ const docId = typeof didDoc.id === 'string' ? didDoc.id : undefined;
183
+ if (docId && !serviceId.startsWith(docId)) {
184
+ targets.add(`${docId}${serviceId}`);
185
+ }
186
+
187
+ for (const service of services) {
188
+ if (!service || typeof service !== 'object') continue;
189
+ const id = typeof service.id === 'string' ? service.id : undefined;
190
+ if (!id || !targets.has(id)) continue;
191
+
192
+ const endpoint = extractServiceEndpoint(service);
193
+ if (endpoint) return endpoint;
194
+ }
195
+
196
+ return null;
197
+ }
198
+
199
+ function extractServiceEndpoint(service: DidService): string | null {
200
+ const endpoint = service.serviceEndpoint;
201
+ if (typeof endpoint === 'string') return endpoint;
202
+ if (endpoint && typeof endpoint === 'object') {
203
+ const obj = endpoint as { uri?: unknown; urls?: unknown };
204
+ if (typeof obj.uri === 'string') return obj.uri;
205
+ if (Array.isArray(obj.urls)) {
206
+ const first = obj.urls.find((value: unknown) => typeof value === 'string');
207
+ if (typeof first === 'string') return first;
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
213
+ // Test seam — exercised only by tests/did-resolver-cache.test.ts. Production
214
+ // code does not import this object.
215
+ export const __testHooks = {
216
+ reset(): void {
217
+ didDocumentCache.clear();
218
+ clock = () => Date.now();
219
+ fetchOverride = null;
220
+ },
221
+ setClock(fn: () => number): void {
222
+ clock = fn;
223
+ },
224
+ setFetcher(fn: (did: string) => Promise<DidDocument>): void {
225
+ fetchOverride = fn;
226
+ },
227
+ cacheSize(): number {
228
+ return didDocumentCache.size;
229
+ },
230
+ async resolve(did: string): Promise<DidDocument> {
231
+ return resolveDidDocument({} as Env, did);
232
+ },
233
+ };
@@ -0,0 +1,221 @@
1
+ import type { Env } from '../../env';
2
+ import { AuthTokenExpiredError, authenticateRequest, expiredToken, unauthorized } from '../auth';
3
+ import { InvalidProxyHeader } from '../errors';
4
+ import {
5
+ PRIVILEGED_METHODS,
6
+ PRIVILEGED_SCOPES,
7
+ PROTECTED_METHODS,
8
+ TAKENDOWN_SCOPE,
9
+ resolveAuthScope,
10
+ } from './auth-policy';
11
+ import { resolveProxyTargetWithRegistry } from './did-resolver';
12
+ import {
13
+ defaultServiceForNsid,
14
+ getServiceRegistry,
15
+ } from './service-config';
16
+ import { createServiceJwt } from './service-jwt';
17
+ import type { ProxyTarget, ServiceConfig, ServiceId } from './types';
18
+
19
+ const FORWARDED_HEADERS = [
20
+ 'accept',
21
+ 'accept-encoding',
22
+ 'accept-language',
23
+ 'atproto-accept-labelers',
24
+ 'atproto-accept-personalized-feed',
25
+ 'cache-control',
26
+ 'if-none-match',
27
+ 'if-modified-since',
28
+ 'pragma',
29
+ 'x-bsky-topics',
30
+ 'x-bsky-feeds',
31
+ 'x-bsky-latest',
32
+ 'x-bsky-appview-features',
33
+ 'user-agent',
34
+ ];
35
+
36
+ // Endpoints where read-after-write freshness matters: dropping conditionals on
37
+ // the viewer's own requests avoids 304s that hide content they just wrote.
38
+ const RAW_SENSITIVE_METHODS: ReadonlySet<string> = new Set([
39
+ 'app.bsky.unspecced.getPostThreadV2',
40
+ 'app.bsky.feed.getFeed',
41
+ 'app.bsky.feed.getPosts',
42
+ 'app.bsky.feed.getTimeline',
43
+ 'app.bsky.feed.getAuthorFeed',
44
+ ]);
45
+
46
+ export interface ProxyAppViewOptions {
47
+ readonly request: Request;
48
+ readonly env: Env;
49
+ readonly lxm: string;
50
+ readonly fallback?: () => Promise<Response>;
51
+ }
52
+
53
+ export async function proxyAppView({
54
+ request,
55
+ env,
56
+ lxm,
57
+ fallback,
58
+ }: ProxyAppViewOptions): Promise<Response> {
59
+ console.log('proxyAppView called:', { lxm, url: request.url });
60
+ let registry: Record<ServiceId, ServiceConfig>;
61
+ try {
62
+ registry = getServiceRegistry(env);
63
+ } catch {
64
+ console.log('proxyAppView: No service config, using fallback');
65
+ return fallback ? await fallback() : new Response('Services not configured', { status: 501 });
66
+ }
67
+ const defaultService = defaultServiceForNsid(env, lxm);
68
+
69
+ if (env.PDS_APPVIEW_FORCE_FALLBACK === '1' && fallback) {
70
+ console.log('proxyAppView: PDS_APPVIEW_FORCE_FALLBACK=1, using fallback');
71
+ return fallback();
72
+ }
73
+
74
+ let auth;
75
+ try {
76
+ auth = await authenticateRequest(request, env);
77
+ } catch (error) {
78
+ if (error instanceof AuthTokenExpiredError) {
79
+ return expiredToken();
80
+ }
81
+ throw error;
82
+ }
83
+ if (!auth) {
84
+ return unauthorized();
85
+ }
86
+ if (!auth.claims.sub) {
87
+ return new Response(JSON.stringify({ error: 'InvalidToken' }), {
88
+ status: 401,
89
+ headers: { 'Content-Type': 'application/json' },
90
+ });
91
+ }
92
+
93
+ if (PROTECTED_METHODS.has(lxm)) {
94
+ return new Response(
95
+ JSON.stringify({ error: 'InvalidToken', message: 'method cannot be proxied' }),
96
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
97
+ );
98
+ }
99
+
100
+ const scope = resolveAuthScope(auth.claims.scope);
101
+ if (scope === TAKENDOWN_SCOPE) {
102
+ return new Response(JSON.stringify({ error: 'AccountTakendown' }), {
103
+ status: 403,
104
+ headers: { 'Content-Type': 'application/json' },
105
+ });
106
+ }
107
+ if (!PRIVILEGED_SCOPES.has(scope) && PRIVILEGED_METHODS.has(lxm)) {
108
+ return new Response(JSON.stringify({ error: 'InvalidToken' }), {
109
+ status: 401,
110
+ headers: { 'Content-Type': 'application/json' },
111
+ });
112
+ }
113
+
114
+ let target: ProxyTarget = { did: defaultService.did, url: defaultService.url };
115
+ const proxyHeader = request.headers.get('atproto-proxy');
116
+ if (proxyHeader) {
117
+ try {
118
+ target = await resolveProxyTargetWithRegistry(env, proxyHeader, registry);
119
+ } catch (error) {
120
+ console.error('AppView proxy header error:', error);
121
+ const isHeaderError = error instanceof InvalidProxyHeader;
122
+ return new Response(
123
+ JSON.stringify({ error: isHeaderError ? 'InvalidProxyHeader' : 'ProxyResolutionFailed' }),
124
+ {
125
+ status: isHeaderError ? 400 : 502,
126
+ headers: { 'Content-Type': 'application/json' },
127
+ },
128
+ );
129
+ }
130
+ }
131
+
132
+ const originalUrl = new URL(request.url);
133
+ const upstreamUrl = new URL(target.url);
134
+ upstreamUrl.pathname = originalUrl.pathname;
135
+ upstreamUrl.search = originalUrl.search;
136
+ upstreamUrl.hash = '';
137
+
138
+ const headers = new Headers();
139
+ for (const header of FORWARDED_HEADERS) {
140
+ const value = request.headers.get(header);
141
+ if (value) headers.set(header, value);
142
+ }
143
+
144
+ if (RAW_SENSITIVE_METHODS.has(lxm)) {
145
+ const viewerDid = auth.claims.sub;
146
+ if (viewerDid && viewerDid.startsWith('did:')) {
147
+ headers.delete('if-none-match');
148
+ headers.delete('if-modified-since');
149
+ }
150
+ }
151
+
152
+ // Service JWT is best-effort. Public AppView endpoints accept unauthenticated
153
+ // reads, so a mint failure here should not block the proxy — we forward
154
+ // without an Authorization header and let the upstream decide. Common
155
+ // reasons we silently fall through: missing signing key on the viewer's DID
156
+ // document, transient PLC lookup failure, or unsupported issuer DID method.
157
+ let serviceJwt: string | null = null;
158
+ try {
159
+ const issuerDid = auth.claims.sub;
160
+ if (!issuerDid || !issuerDid.startsWith('did:')) {
161
+ throw new Error(`Invalid issuer DID: ${issuerDid || '(empty)'}`);
162
+ }
163
+ serviceJwt = await createServiceJwt(env, issuerDid, target.did, lxm);
164
+ } catch (error) {
165
+ console.error('AppView service token error:', error);
166
+ serviceJwt = null;
167
+ }
168
+
169
+ if (serviceJwt) headers.set('authorization', `Bearer ${serviceJwt}`);
170
+
171
+ const method = request.method.toUpperCase();
172
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
173
+ return new Response(JSON.stringify({ error: 'MethodNotAllowed' }), {
174
+ status: 405,
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ Allow: 'GET, HEAD, POST',
178
+ },
179
+ });
180
+ }
181
+
182
+ if (!headers.has('accept-encoding')) {
183
+ headers.set('accept-encoding', 'identity');
184
+ }
185
+
186
+ if (method === 'POST') {
187
+ const contentType = request.headers.get('content-type');
188
+ if (contentType) headers.set('content-type', contentType);
189
+ const contentEncoding = request.headers.get('content-encoding');
190
+ if (contentEncoding) headers.set('content-encoding', contentEncoding);
191
+ }
192
+
193
+ try {
194
+ const init: RequestInit & { duplex?: 'half' } = {
195
+ method,
196
+ headers,
197
+ };
198
+
199
+ if (method === 'POST') {
200
+ init.body = request.body;
201
+ init.duplex = 'half';
202
+ }
203
+
204
+ const upstream = await fetch(upstreamUrl.toString(), init);
205
+ const responseHeaders = new Headers(upstream.headers);
206
+ return new Response(upstream.body, {
207
+ status: upstream.status,
208
+ statusText: upstream.statusText,
209
+ headers: responseHeaders,
210
+ });
211
+ } catch (error) {
212
+ console.error('AppView proxy error:', error);
213
+ if (fallback) {
214
+ return fallback();
215
+ }
216
+ return new Response(JSON.stringify({ error: 'UpstreamUnavailable' }), {
217
+ status: 502,
218
+ headers: { 'Content-Type': 'application/json' },
219
+ });
220
+ }
221
+ }
@@ -0,0 +1,61 @@
1
+ import type { Env } from '../../env';
2
+ import { ServerMisconfigured } from '../errors';
3
+ import type { AppViewConfig, ServiceConfig, ServiceId } from './types';
4
+
5
+ const DEFAULT_APPVIEW_URL = 'https://api.bsky.app';
6
+ const DEFAULT_APPVIEW_DID = 'did:web:api.bsky.app';
7
+ const DEFAULT_CHAT_URL = 'https://api.bsky.chat';
8
+ const DEFAULT_CHAT_DID = 'did:web:api.bsky.chat';
9
+ const DEFAULT_OZONE_URL = 'https://mod.bsky.app';
10
+ const DEFAULT_OZONE_DID = 'did:plc:ar7c4by46qjdydhdevvrndac';
11
+
12
+ function trimmedString(value: unknown): string | undefined {
13
+ if (typeof value !== 'string') return undefined;
14
+ const trimmed = value.trim();
15
+ return trimmed === '' ? undefined : trimmed;
16
+ }
17
+
18
+ export function getAppViewConfig(env: Env): AppViewConfig | null {
19
+ const url = trimmedString(env.PDS_BSKY_APP_VIEW_URL) ?? DEFAULT_APPVIEW_URL;
20
+ const did = trimmedString(env.PDS_BSKY_APP_VIEW_DID) ?? DEFAULT_APPVIEW_DID;
21
+ if (!url || !did) return null;
22
+ const cdnUrlPattern = trimmedString(env.PDS_BSKY_APP_VIEW_CDN_URL_PATTERN);
23
+ return { url, did, cdnUrlPattern };
24
+ }
25
+
26
+ function getChatConfig(env: Env): ServiceConfig {
27
+ return {
28
+ id: 'bsky_chat',
29
+ url: trimmedString(env.PDS_BSKY_CHAT_URL) ?? DEFAULT_CHAT_URL,
30
+ did: trimmedString(env.PDS_BSKY_CHAT_DID) ?? DEFAULT_CHAT_DID,
31
+ };
32
+ }
33
+
34
+ function getOzoneConfig(env: Env): ServiceConfig {
35
+ return {
36
+ id: 'atproto_labeler',
37
+ url: trimmedString(env.PDS_OZONE_URL) ?? DEFAULT_OZONE_URL,
38
+ did: trimmedString(env.PDS_OZONE_DID) ?? DEFAULT_OZONE_DID,
39
+ };
40
+ }
41
+
42
+ export function getServiceRegistry(env: Env): Record<ServiceId, ServiceConfig> {
43
+ const app = getAppViewConfig(env);
44
+ if (!app) {
45
+ throw new ServerMisconfigured('AppView not configured');
46
+ }
47
+ return {
48
+ bsky_appview: { id: 'bsky_appview', url: app.url, did: app.did },
49
+ bsky_chat: getChatConfig(env),
50
+ atproto_labeler: getOzoneConfig(env),
51
+ };
52
+ }
53
+
54
+ export function defaultServiceForNsid(env: Env, nsid: string): ServiceConfig {
55
+ const registry = getServiceRegistry(env);
56
+ if (nsid.startsWith('chat.bsky.')) return registry.bsky_chat;
57
+ if (nsid.startsWith('tools.ozone.') || nsid.startsWith('com.atproto.moderation.')) {
58
+ return registry.atproto_labeler;
59
+ }
60
+ return registry.bsky_appview;
61
+ }
@@ -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';