@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.
- package/LICENSE +21 -0
- package/README.md +19 -30
- package/index.js +34 -28
- package/migrations/0007_bored_spitfire.sql +26 -0
- package/migrations/0008_furry_ozymandias.sql +2 -0
- package/migrations/meta/0007_snapshot.json +534 -0
- package/migrations/meta/0008_snapshot.json +548 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +10 -9
- package/src/app.ts +8 -4
- package/src/db/account.ts +25 -6
- package/src/db/client.ts +1 -1
- package/src/db/dal.ts +34 -23
- package/src/db/repo.ts +38 -38
- package/src/db/schema.ts +5 -1
- package/src/db/seed.ts +5 -13
- package/src/entrypoints/server.ts +2 -22
- package/src/handlers/debug.ts +1 -1
- package/src/handlers/ready.ts +1 -1
- package/src/handlers/root.ts +4 -4
- package/src/handlers/xrpc.server.refreshSession.ts +6 -6
- package/src/lib/account-state.ts +156 -0
- package/src/lib/actor.ts +29 -13
- package/src/lib/appview/auth-policy.ts +66 -0
- package/src/lib/appview/did-resolver.ts +233 -0
- package/src/lib/appview/proxy.ts +221 -0
- package/src/lib/appview/service-config.ts +61 -0
- package/src/lib/appview/service-jwt.ts +93 -0
- package/src/lib/appview/types.ts +25 -0
- package/src/lib/appview.ts +5 -532
- package/src/lib/auth-errors.ts +24 -0
- package/src/lib/auth.ts +63 -15
- package/src/lib/blockstore-gc.ts +6 -5
- package/src/lib/cache.ts +30 -4
- package/src/lib/chat.ts +20 -14
- package/src/lib/commit-log-pruning.ts +2 -2
- package/src/lib/commit.ts +26 -36
- package/src/lib/config.ts +26 -15
- package/src/lib/did-document.ts +32 -0
- package/src/lib/errors.ts +54 -0
- package/src/lib/feed.ts +18 -19
- package/src/lib/firehose/frames.ts +87 -47
- package/src/lib/firehose/validation.ts +3 -3
- package/src/lib/jwt.ts +85 -177
- package/src/lib/labeler.ts +43 -30
- package/src/lib/logger.ts +4 -0
- package/src/lib/mst/block-map.ts +172 -0
- package/src/lib/mst/blockstore.ts +56 -93
- package/src/lib/mst/index.ts +1 -0
- package/src/lib/mst/leaf.ts +25 -0
- package/src/lib/mst/mst.ts +81 -237
- package/src/lib/mst/serialize.ts +97 -0
- package/src/lib/mst/types.ts +21 -0
- package/src/lib/oauth/clients.ts +67 -0
- package/src/lib/oauth/dpop-errors.ts +15 -0
- package/src/lib/oauth/dpop.ts +150 -0
- package/src/lib/oauth/resource.ts +199 -0
- package/src/lib/oauth/store.ts +77 -0
- package/src/lib/preferences.ts +12 -37
- package/src/lib/ratelimit.ts +4 -4
- package/src/lib/refresh-session.ts +161 -0
- package/src/lib/relay.ts +10 -8
- package/src/lib/secrets.ts +6 -7
- package/src/lib/sequencer.ts +14 -5
- package/src/lib/service-auth.ts +184 -0
- package/src/lib/session-tokens.ts +28 -76
- package/src/lib/streaming-car.ts +3 -0
- package/src/lib/tracing.ts +4 -3
- package/src/lib/util.ts +65 -15
- package/src/middleware.ts +1 -1
- package/src/pages/.well-known/did.json.ts +27 -30
- package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
- package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
- package/src/pages/debug/blob/[...key].ts +2 -2
- package/src/pages/debug/db/bootstrap.ts +1 -1
- package/src/pages/debug/db/commits.ts +1 -1
- package/src/pages/debug/gc/blobs.ts +1 -1
- package/src/pages/debug/record.ts +1 -1
- package/src/pages/debug/sequencer.ts +28 -0
- package/src/pages/health.ts +4 -4
- package/src/pages/oauth/authorize.ts +78 -0
- package/src/pages/oauth/consent.ts +80 -0
- package/src/pages/oauth/par.ts +121 -0
- package/src/pages/oauth/token.ts +158 -0
- package/src/pages/ready.ts +2 -2
- package/src/pages/xrpc/[...nsid].ts +61 -0
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
- package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
- package/src/pages/xrpc/com.atproto.server.createSession.ts +32 -12
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
- package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +72 -23
- package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
- package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
- package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
- package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +2 -2
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
- package/src/services/car.ts +209 -57
- package/src/services/r2-blob-store.ts +4 -4
- package/src/services/repo/blockstore-ops.ts +29 -0
- package/src/services/repo/operations.ts +133 -0
- package/src/services/repo-manager.ts +203 -254
- package/src/worker/runtime.ts +56 -11
- package/src/worker/sequencer/broadcast.ts +91 -0
- package/src/worker/sequencer/cid-helpers.ts +39 -0
- package/src/worker/sequencer/payload.ts +84 -0
- package/src/worker/sequencer/types.ts +36 -0
- package/src/worker/sequencer/upgrade.ts +141 -0
- package/src/worker/sequencer.ts +264 -406
- package/types/env.d.ts +18 -6
- package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
- package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
- package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
- package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
- package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
- package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
- package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
- package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +0 -23
- package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
- package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
- package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
- package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
- package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
- 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?:
|
|
15
|
-
pinnedPost?:
|
|
16
|
-
labels?:
|
|
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?:
|
|
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
|
-
|
|
50
|
-
|
|
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(
|
|
53
|
-
.
|
|
59
|
+
.bind(prefix, upperBound)
|
|
60
|
+
.all<{ json: string }>();
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|