@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
package/src/lib/actor.ts CHANGED
@@ -11,9 +11,9 @@ interface ProfileRecord {
11
11
  website?: string;
12
12
  avatar?: string;
13
13
  banner?: string;
14
- joinedViaStarterPack?: any;
15
- pinnedPost?: any;
16
- labels?: any;
14
+ joinedViaStarterPack?: unknown;
15
+ pinnedPost?: unknown;
16
+ labels?: unknown;
17
17
  createdAt?: string;
18
18
  }
19
19
 
@@ -26,7 +26,7 @@ export interface PrimaryActor {
26
26
  website?: string;
27
27
  avatar?: string;
28
28
  banner?: string;
29
- labels?: any;
29
+ labels?: unknown;
30
30
  createdAt?: string;
31
31
  }
32
32
 
@@ -46,17 +46,33 @@ export async function fetchProfileRecord(env: Env, did: string): Promise<Profile
46
46
  }
47
47
 
48
48
  // Fallback: pick the most recent profile record regardless of DID
49
- const fallback = await env.DB.prepare(
50
- 'SELECT json FROM record WHERE uri LIKE ? ORDER BY rowid DESC LIMIT 1'
49
+ // Use range scan to avoid D1 LIKE complexity limits
50
+ // Profile URIs have format: at://<did>/app.bsky.actor.profile/self
51
+ const prefix = `at://`;
52
+ const suffix = `/${PROFILE_COLLECTION}/`;
53
+ const upperBound = `at://~`; // '~' sorts after all valid DIDs
54
+
55
+ // Find any profile record - scan from "at://" to "at://~" and filter in app
56
+ const fallback = await env.ALTERAN_DB.prepare(
57
+ 'SELECT json FROM record WHERE uri >= ? AND uri < ? ORDER BY rowid DESC LIMIT 50'
51
58
  )
52
- .bind(`%/${PROFILE_COLLECTION}/%`)
53
- .first<{ json: string }>();
59
+ .bind(prefix, upperBound)
60
+ .all<{ json: string }>();
54
61
 
55
- if (fallback?.json) {
56
- try {
57
- return JSON.parse(fallback.json) as ProfileRecord;
58
- } catch {
59
- return null;
62
+ // Filter for profile records in memory (D1 can't do complex patterns)
63
+ if (fallback?.results) {
64
+ for (const row of fallback.results) {
65
+ if (row.json && typeof row.json === 'string') {
66
+ try {
67
+ // Check if this is a profile record by URI pattern
68
+ const parsed = JSON.parse(row.json);
69
+ if (parsed.$type === 'app.bsky.actor.profile') {
70
+ return parsed as ProfileRecord;
71
+ }
72
+ } catch {
73
+ continue;
74
+ }
75
+ }
60
76
  }
61
77
  }
62
78
 
@@ -0,0 +1,66 @@
1
+ import type { AuthScope } from './types';
2
+
3
+ const DEFAULT_ACCESS_SCOPE: AuthScope = 'com.atproto.access';
4
+ export const TAKENDOWN_SCOPE: AuthScope = 'com.atproto.takendown';
5
+
6
+ export const PRIVILEGED_SCOPES: ReadonlySet<AuthScope> = new Set([
7
+ 'com.atproto.access',
8
+ 'com.atproto.appPassPrivileged',
9
+ ]);
10
+
11
+ export const PRIVILEGED_METHODS: ReadonlySet<string> = new Set([
12
+ 'chat.bsky.actor.deleteAccount',
13
+ 'chat.bsky.actor.exportAccountData',
14
+ 'chat.bsky.convo.deleteMessageForSelf',
15
+ 'chat.bsky.convo.getConvo',
16
+ 'chat.bsky.convo.getConvoForMembers',
17
+ 'chat.bsky.convo.getLog',
18
+ 'chat.bsky.convo.getMessages',
19
+ 'chat.bsky.convo.leaveConvo',
20
+ 'chat.bsky.convo.listConvos',
21
+ 'chat.bsky.convo.muteConvo',
22
+ 'chat.bsky.convo.sendMessage',
23
+ 'chat.bsky.convo.sendMessageBatch',
24
+ 'chat.bsky.convo.unmuteConvo',
25
+ 'chat.bsky.convo.updateRead',
26
+ 'com.atproto.server.createAccount',
27
+ ]);
28
+
29
+ export const PROTECTED_METHODS: ReadonlySet<string> = new Set([
30
+ 'com.atproto.admin.sendEmail',
31
+ 'com.atproto.identity.requestPlcOperationSignature',
32
+ 'com.atproto.identity.signPlcOperation',
33
+ 'com.atproto.identity.updateHandle',
34
+ 'com.atproto.server.activateAccount',
35
+ 'com.atproto.server.confirmEmail',
36
+ 'com.atproto.server.createAppPassword',
37
+ 'com.atproto.server.deactivateAccount',
38
+ 'com.atproto.server.getAccountInviteCodes',
39
+ 'com.atproto.server.getSession',
40
+ 'com.atproto.server.listAppPasswords',
41
+ 'com.atproto.server.requestAccountDelete',
42
+ 'com.atproto.server.requestEmailConfirmation',
43
+ 'com.atproto.server.requestEmailUpdate',
44
+ 'com.atproto.server.revokeAppPassword',
45
+ 'com.atproto.server.updateEmail',
46
+ ]);
47
+
48
+ export function resolveAuthScope(scope: unknown): AuthScope {
49
+ if (typeof scope !== 'string') {
50
+ return DEFAULT_ACCESS_SCOPE;
51
+ }
52
+
53
+ switch (scope) {
54
+ case 'access':
55
+ return 'com.atproto.access';
56
+ case 'com.atproto.access':
57
+ case 'com.atproto.appPass':
58
+ case 'com.atproto.appPassPrivileged':
59
+ case 'com.atproto.signupQueued':
60
+ case 'com.atproto.takendown':
61
+ return scope;
62
+ default:
63
+ console.warn('Unknown auth scope, treating as access scope', scope);
64
+ return DEFAULT_ACCESS_SCOPE;
65
+ }
66
+ }
@@ -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
+ }