@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.
- 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/dal.ts +34 -23
- package/src/db/repo.ts +35 -35
- package/src/db/schema.ts +5 -1
- package/src/db/seed.ts +5 -13
- package/src/entrypoints/server.ts +2 -22
- package/src/handlers/root.ts +4 -4
- package/src/lib/account-state.ts +156 -0
- package/src/lib/actor.ts +28 -12
- 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 +2 -1
- package/src/lib/cache.ts +30 -4
- package/src/lib/chat.ts +14 -8
- 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 +9 -34
- 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 +12 -3
- 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/record.ts +1 -1
- package/src/pages/debug/sequencer.ts +28 -0
- 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/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 +31 -11
- 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 +71 -22
- 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 +1 -1
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
- package/src/services/car.ts +207 -55
- package/src/services/r2-blob-store.ts +1 -1
- package/src/services/repo/blockstore-ops.ts +29 -0
- package/src/services/repo/operations.ts +133 -0
- package/src/services/repo-manager.ts +202 -253
- package/src/worker/runtime.ts +53 -8
- 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 +263 -405
- package/types/env.d.ts +15 -3
- 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/relay.ts
CHANGED
|
@@ -12,7 +12,9 @@ export function resolvePdsHostname(env: Env, requestUrl?: string): string | null
|
|
|
12
12
|
try {
|
|
13
13
|
const url = new URL(requestUrl);
|
|
14
14
|
host = url.hostname;
|
|
15
|
-
} catch {
|
|
15
|
+
} catch {
|
|
16
|
+
// Malformed request URL: leave host unset so the caller can choose a fallback.
|
|
17
|
+
}
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
if (!host) return null;
|
|
@@ -53,12 +55,12 @@ export function getRelayHosts(env: Env): string[] {
|
|
|
53
55
|
*/
|
|
54
56
|
export async function requestCrawl(relayHost: string, pdsHostname: string): Promise<Response> {
|
|
55
57
|
const url = `https://${relayHost}/xrpc/com.atproto.sync.requestCrawl`;
|
|
56
|
-
const
|
|
58
|
+
const response = await fetch(url, {
|
|
57
59
|
method: 'POST',
|
|
58
60
|
headers: { 'content-type': 'application/json' },
|
|
59
61
|
body: JSON.stringify({ hostname: pdsHostname }),
|
|
60
62
|
});
|
|
61
|
-
return
|
|
63
|
+
return response;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
// In-memory isolation-scoped throttle to avoid spamming relays on every request.
|
|
@@ -88,12 +90,12 @@ export async function notifyRelaysIfNeeded(env: Env, requestUrl?: string): Promi
|
|
|
88
90
|
await Promise.allSettled(
|
|
89
91
|
relays.map(async (relay) => {
|
|
90
92
|
try {
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
93
|
-
console.warn('requestCrawl failed', { relay, status:
|
|
93
|
+
const response = await requestCrawl(relay, hostname);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
console.warn('requestCrawl failed', { relay, status: response.status });
|
|
94
96
|
}
|
|
95
|
-
} catch (
|
|
96
|
-
console.warn('requestCrawl error', { relay, error: String(
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.warn('requestCrawl error', { relay, error: String(error) });
|
|
97
99
|
}
|
|
98
100
|
}),
|
|
99
101
|
);
|
package/src/lib/secrets.ts
CHANGED
|
@@ -10,16 +10,15 @@ const SECRET_KEYS = [
|
|
|
10
10
|
"REFRESH_TOKEN_SECRET",
|
|
11
11
|
"SESSION_JWT_SECRET",
|
|
12
12
|
"REPO_SIGNING_KEY",
|
|
13
|
-
"REPO_SIGNING_KEY_PUBLIC",
|
|
14
13
|
"PDS_PLC_ROTATION_KEY",
|
|
15
|
-
"PDS_SERVICE_SIGNING_KEY_HEX",
|
|
16
14
|
] as const satisfies readonly (keyof Env)[];
|
|
17
15
|
|
|
18
16
|
function isSecretStoreBinding(value: unknown): value is SecretsStoreSecret {
|
|
19
17
|
return (
|
|
20
18
|
!!value &&
|
|
21
19
|
typeof value === "object" &&
|
|
22
|
-
|
|
20
|
+
"get" in value &&
|
|
21
|
+
typeof (value as { get: unknown }).get === "function"
|
|
23
22
|
);
|
|
24
23
|
}
|
|
25
24
|
|
|
@@ -37,13 +36,13 @@ export async function resolveSecret(
|
|
|
37
36
|
* Non-secret bindings (DB, BLOBS, SEQUENCER, vars) are preserved as-is.
|
|
38
37
|
*/
|
|
39
38
|
export async function resolveEnvSecrets<E extends Env>(env: E): Promise<E> {
|
|
40
|
-
const resolved
|
|
39
|
+
const resolved = { ...env } as Record<string, unknown>;
|
|
41
40
|
|
|
42
41
|
await Promise.all(
|
|
43
42
|
SECRET_KEYS.map(async (key) => {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
resolved[key as string] =
|
|
43
|
+
const value = await resolveSecret(env[key]);
|
|
44
|
+
if (value !== undefined) {
|
|
45
|
+
resolved[key as string] = value;
|
|
47
46
|
}
|
|
48
47
|
}),
|
|
49
48
|
);
|
package/src/lib/sequencer.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { Env } from '../env';
|
|
2
2
|
|
|
3
3
|
export async function notifySequencer(env: Env, obj: unknown) {
|
|
4
|
-
if (!env.SEQUENCER)
|
|
4
|
+
if (!env.SEQUENCER) {
|
|
5
|
+
console.warn('notifySequencer: SEQUENCER binding missing');
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
5
8
|
try {
|
|
6
9
|
const id = env.SEQUENCER.idFromName('default');
|
|
7
10
|
const stub = env.SEQUENCER.get(id);
|
|
8
|
-
await stub.fetch('https://sequencer/commit', {
|
|
9
|
-
|
|
11
|
+
await stub.fetch('https://sequencer/commit', {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
body: JSON.stringify(obj),
|
|
15
|
+
});
|
|
16
|
+
} catch (e) {
|
|
17
|
+
console.warn('notifySequencer: failed to POST /commit to sequencer', e);
|
|
18
|
+
}
|
|
10
19
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { resolveSecret } from './secrets';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Service auth verification for external services (like video.bsky.app)
|
|
6
|
+
*
|
|
7
|
+
* Service auth JWTs are signed by the service's key and contain:
|
|
8
|
+
* - iss: The service's DID (e.g., did:web:video.bsky.app)
|
|
9
|
+
* - aud: The PDS's DID (must match our PDS_DID)
|
|
10
|
+
* - lxm: The lexicon method being authorized
|
|
11
|
+
* - exp/iat: Standard JWT timing claims
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface ServiceAuthPayload {
|
|
15
|
+
iss: string;
|
|
16
|
+
aud: string;
|
|
17
|
+
lxm?: string;
|
|
18
|
+
exp?: number;
|
|
19
|
+
iat?: number;
|
|
20
|
+
jti?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ServiceAuthResult {
|
|
24
|
+
iss: string;
|
|
25
|
+
aud: string;
|
|
26
|
+
lxm?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a Bearer token is a service auth token (has lxm claim)
|
|
31
|
+
*/
|
|
32
|
+
export function isServiceAuthToken(token: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const parts = token.split('.');
|
|
35
|
+
if (parts.length !== 3) return false;
|
|
36
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
37
|
+
return payload.lxm != null;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decode JWT payload without verification (for initial inspection)
|
|
45
|
+
*/
|
|
46
|
+
function decodeJwtPayload(token: string): ServiceAuthPayload | null {
|
|
47
|
+
try {
|
|
48
|
+
const parts = token.split('.');
|
|
49
|
+
if (parts.length !== 3) return null;
|
|
50
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
51
|
+
return payload;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a DID document and extract the atproto verification key
|
|
59
|
+
*/
|
|
60
|
+
async function getVerificationKey(did: string): Promise<string | null> {
|
|
61
|
+
try {
|
|
62
|
+
let url: string;
|
|
63
|
+
if (did.startsWith('did:web:')) {
|
|
64
|
+
const host = did.slice('did:web:'.length).replace(/:/g, '/');
|
|
65
|
+
url = `https://${host}/.well-known/did.json`;
|
|
66
|
+
} else if (did.startsWith('did:plc:')) {
|
|
67
|
+
url = `https://plc.directory/${did}`;
|
|
68
|
+
} else {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const response = await fetch(url, {
|
|
73
|
+
headers: { 'Accept': 'application/json' },
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) return null;
|
|
76
|
+
|
|
77
|
+
const doc = await response.json() as any;
|
|
78
|
+
|
|
79
|
+
// Find atproto verification method
|
|
80
|
+
const methods = doc.verificationMethod || [];
|
|
81
|
+
for (const method of methods) {
|
|
82
|
+
if (method.id?.endsWith('#atproto') && method.publicKeyMultibase) {
|
|
83
|
+
return method.publicKeyMultibase;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verify ES256K signature using the public key from DID document
|
|
94
|
+
*/
|
|
95
|
+
async function verifyES256KSignature(
|
|
96
|
+
token: string,
|
|
97
|
+
publicKeyMultibase: string
|
|
98
|
+
): Promise<boolean> {
|
|
99
|
+
try {
|
|
100
|
+
const { Secp256k1Keypair } = await import('@atproto/crypto');
|
|
101
|
+
|
|
102
|
+
const parts = token.split('.');
|
|
103
|
+
if (parts.length !== 3) return false;
|
|
104
|
+
|
|
105
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
106
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
107
|
+
|
|
108
|
+
// Decode signature from base64url
|
|
109
|
+
const sigB64 = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
|
|
110
|
+
const sigBin = atob(sigB64);
|
|
111
|
+
const signature = new Uint8Array(sigBin.length);
|
|
112
|
+
for (let i = 0; i < sigBin.length; i++) {
|
|
113
|
+
signature[i] = sigBin.charCodeAt(i);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The multibase key starts with 'z' for base58btc encoding
|
|
117
|
+
// Format: z + multicodec prefix (0xe7 0x01 for secp256k1) + compressed public key
|
|
118
|
+
if (!publicKeyMultibase.startsWith('z')) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Use @atproto/crypto to verify
|
|
123
|
+
const { verifySignature } = await import('@atproto/crypto');
|
|
124
|
+
const data = new TextEncoder().encode(signingInput);
|
|
125
|
+
|
|
126
|
+
// Convert multibase to did:key format for verification
|
|
127
|
+
const didKey = `did:key:${publicKeyMultibase}`;
|
|
128
|
+
const isValid = await verifySignature(didKey, data, signature);
|
|
129
|
+
|
|
130
|
+
return isValid;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error('Service auth signature verification failed:', e);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Verify a service auth request
|
|
139
|
+
*
|
|
140
|
+
* @param env - Environment with PDS_DID
|
|
141
|
+
* @param request - The incoming request
|
|
142
|
+
* @returns The verified service auth payload, or null if not service auth / invalid
|
|
143
|
+
*/
|
|
144
|
+
export async function verifyServiceAuth(
|
|
145
|
+
env: Env,
|
|
146
|
+
request: Request
|
|
147
|
+
): Promise<ServiceAuthResult | null> {
|
|
148
|
+
const auth = request.headers.get('authorization');
|
|
149
|
+
if (!auth?.startsWith('Bearer ')) return null;
|
|
150
|
+
|
|
151
|
+
const token = auth.slice(7).trim();
|
|
152
|
+
if (!isServiceAuthToken(token)) return null;
|
|
153
|
+
|
|
154
|
+
const payload = decodeJwtPayload(token);
|
|
155
|
+
if (!payload || !payload.iss || !payload.aud) return null;
|
|
156
|
+
|
|
157
|
+
// Check audience matches our PDS DID (accept both did:plc and did:web forms)
|
|
158
|
+
const pdsDid = await resolveSecret(env.PDS_DID);
|
|
159
|
+
const hostname = env.PDS_HOSTNAME || env.PDS_HANDLE;
|
|
160
|
+
const didWebAlt = hostname ? `did:web:${hostname}` : null;
|
|
161
|
+
const validAudiences = [pdsDid, didWebAlt].filter(Boolean) as string[];
|
|
162
|
+
|
|
163
|
+
if (!validAudiences.includes(payload.aud)) return null;
|
|
164
|
+
|
|
165
|
+
// Check expiration
|
|
166
|
+
if (payload.exp) {
|
|
167
|
+
const now = Math.floor(Date.now() / 1000);
|
|
168
|
+
if (payload.exp < now) return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Get the issuer's verification key from their DID document
|
|
172
|
+
const publicKey = await getVerificationKey(payload.iss);
|
|
173
|
+
if (!publicKey) return null;
|
|
174
|
+
|
|
175
|
+
// Verify the signature
|
|
176
|
+
const isValid = await verifyES256KSignature(token, publicKey);
|
|
177
|
+
if (!isValid) return null;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
iss: payload.iss,
|
|
181
|
+
aud: payload.aud,
|
|
182
|
+
lxm: payload.lxm,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -2,6 +2,8 @@ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
|
|
|
2
2
|
import type { Env } from '../env';
|
|
3
3
|
import { getRuntimeString } from './secrets';
|
|
4
4
|
import { getOrCreateSecret } from '../db/account';
|
|
5
|
+
import { InvalidToken, ServerMisconfigured } from './errors';
|
|
6
|
+
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
|
5
7
|
|
|
6
8
|
const SESSION_SECRET_KEY = 'session_jwt_secret';
|
|
7
9
|
const GRACE_PERIOD_SECONDS = 2 * 60 * 60;
|
|
@@ -25,10 +27,8 @@ async function getJwtKey(env: Env): Promise<Uint8Array> {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
async function getServiceDid(env: Env): Promise<string> {
|
|
28
|
-
const did = await getRuntimeString(env, 'PDS_DID', '
|
|
29
|
-
if (!did)
|
|
30
|
-
throw new Error('PDS_DID is not configured');
|
|
31
|
-
}
|
|
30
|
+
const did = await getRuntimeString(env, 'PDS_DID', '');
|
|
31
|
+
if (!did) throw new ServerMisconfigured('PDS_DID is not configured');
|
|
32
32
|
return did;
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -72,10 +72,10 @@ export async function verifyRefreshToken(env: Env, token: string) {
|
|
|
72
72
|
const serviceDid = await getServiceDid(env);
|
|
73
73
|
const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid);
|
|
74
74
|
if (header.typ !== 'refresh+jwt') {
|
|
75
|
-
throw new
|
|
75
|
+
throw new InvalidToken('Invalid token type');
|
|
76
76
|
}
|
|
77
77
|
if (payload.scope !== 'refresh') {
|
|
78
|
-
throw new
|
|
78
|
+
throw new InvalidToken('Invalid refresh token scope');
|
|
79
79
|
}
|
|
80
80
|
return {
|
|
81
81
|
payload,
|
|
@@ -93,10 +93,10 @@ export async function verifyAccessToken(env: Env, token: string) {
|
|
|
93
93
|
const serviceDid = await getServiceDid(env);
|
|
94
94
|
const { header, payload } = await decodeAndVerifyJwt(key, token, 'at+jwt', serviceDid);
|
|
95
95
|
if (header.typ !== 'at+jwt') {
|
|
96
|
-
throw new
|
|
96
|
+
throw new InvalidToken('Invalid token type');
|
|
97
97
|
}
|
|
98
98
|
if (payload.scope === 'refresh') {
|
|
99
|
-
throw new
|
|
99
|
+
throw new InvalidToken('Unexpected scope for access token');
|
|
100
100
|
}
|
|
101
101
|
return payload;
|
|
102
102
|
}
|
|
@@ -121,82 +121,34 @@ type RefreshTokenPayload = TokenPayload & { jti: string };
|
|
|
121
121
|
type TokenHeader = { alg: 'HS256'; typ: 'at+jwt' | 'refresh+jwt' };
|
|
122
122
|
|
|
123
123
|
async function signJwt(key: Uint8Array, typ: TokenHeader['typ'], payload: TokenPayload): Promise<string> {
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
// jose will set standard claims via dedicated methods; we also keep custom claims in payload
|
|
125
|
+
const signer = new SignJWT(payload as JWTPayload)
|
|
126
|
+
.setProtectedHeader({ alg: 'HS256', typ })
|
|
127
|
+
.setSubject(payload.sub)
|
|
128
|
+
.setAudience(payload.aud)
|
|
129
|
+
.setIssuedAt(payload.iat)
|
|
130
|
+
.setExpirationTime(payload.exp);
|
|
131
|
+
return await signer.sign(key);
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
async function decodeAndVerifyJwt(key: Uint8Array, token: string, expectedTyp: TokenHeader['typ'], audience: string) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (header.alg !== 'HS256' || header.typ !== expectedTyp) {
|
|
141
|
-
throw new Error('Unexpected token header');
|
|
135
|
+
const { payload, protectedHeader } = await jwtVerify(token, key, {
|
|
136
|
+
algorithms: ['HS256'],
|
|
137
|
+
audience,
|
|
138
|
+
});
|
|
139
|
+
if (protectedHeader.typ !== expectedTyp) {
|
|
140
|
+
throw new InvalidToken('Unexpected token header');
|
|
142
141
|
}
|
|
143
|
-
if (payload.
|
|
144
|
-
throw new
|
|
145
|
-
}
|
|
146
|
-
if (!payload.sub) {
|
|
147
|
-
throw new Error('Token missing subject');
|
|
142
|
+
if (!payload.sub || typeof payload.sub !== 'string') {
|
|
143
|
+
throw new InvalidToken('Token missing subject');
|
|
148
144
|
}
|
|
149
145
|
if (typeof payload.exp !== 'number') {
|
|
150
|
-
throw new
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const data = `${parts[0]}.${parts[1]}`;
|
|
154
|
-
const ok = await hmacVerify(key, data, parts[2]);
|
|
155
|
-
if (!ok) {
|
|
156
|
-
throw new Error('Invalid token signature');
|
|
146
|
+
throw new InvalidToken('Token missing expiry');
|
|
157
147
|
}
|
|
158
|
-
|
|
159
|
-
return { header, payload };
|
|
148
|
+
return { header: protectedHeader as TokenHeader, payload: payload as unknown as TokenPayload };
|
|
160
149
|
}
|
|
161
150
|
|
|
162
151
|
function generateTokenId(): string {
|
|
163
|
-
|
|
164
|
-
return base64UrlEncode(bytes);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function hmacSign(keyBytes: Uint8Array, data: string): Promise<string> {
|
|
168
|
-
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
169
|
-
const signature = await crypto.subtle.sign('HMAC', cryptoKey, textEncoder.encode(data));
|
|
170
|
-
return base64UrlEncode(new Uint8Array(signature));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function hmacVerify(keyBytes: Uint8Array, data: string, signatureB64: string): Promise<boolean> {
|
|
174
|
-
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
175
|
-
return crypto.subtle.verify('HMAC', cryptoKey, base64UrlDecodeToBytes(signatureB64), textEncoder.encode(data));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function base64UrlEncode(value: string | Uint8Array): string {
|
|
179
|
-
const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
|
|
180
|
-
let binary = '';
|
|
181
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
182
|
-
binary += String.fromCharCode(bytes[i]);
|
|
183
|
-
}
|
|
184
|
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function base64UrlDecode(encoded: string): string {
|
|
188
|
-
const pad = encoded.length % 4 === 2 ? '==' : encoded.length % 4 === 3 ? '=' : '';
|
|
189
|
-
const binary = atob(encoded.replace(/-/g, '+').replace(/_/g, '/') + pad);
|
|
190
|
-
return binary;
|
|
152
|
+
return bytesToHex(randomBytes(16));
|
|
191
153
|
}
|
|
192
|
-
|
|
193
|
-
function base64UrlDecodeToBytes(encoded: string): Uint8Array {
|
|
194
|
-
const binary = base64UrlDecode(encoded);
|
|
195
|
-
const bytes = new Uint8Array(binary.length);
|
|
196
|
-
for (let i = 0; i < binary.length; i++) {
|
|
197
|
-
bytes[i] = binary.charCodeAt(i);
|
|
198
|
-
}
|
|
199
|
-
return bytes;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const textEncoder = new TextEncoder();
|
|
154
|
+
// removed custom HMAC/base64url helpers in favor of jose
|
package/src/lib/streaming-car.ts
CHANGED
|
@@ -58,6 +58,9 @@ function concat(parts: Uint8Array[]): Uint8Array {
|
|
|
58
58
|
* });
|
|
59
59
|
* ```
|
|
60
60
|
*/
|
|
61
|
+
// StreamingCarEncoder is a class because it caches the encoded header
|
|
62
|
+
// once and then writes one block at a time; the cache state plus the
|
|
63
|
+
// per-block writeBlock() method belong on the same object.
|
|
61
64
|
export class StreamingCarEncoder {
|
|
62
65
|
private headerBytes: Uint8Array;
|
|
63
66
|
|
package/src/lib/tracing.ts
CHANGED
|
@@ -12,9 +12,10 @@ export interface TraceSpan {
|
|
|
12
12
|
labels?: Record<string, string>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// Tracer is a class because each instance owns a Map of in-flight spans;
|
|
16
|
+
// start() and end() are paired stateful calls that need shared access to
|
|
17
|
+
// that map. The global `tracer` plus per-request instances both rely on
|
|
18
|
+
// this isolation.
|
|
18
19
|
export class Tracer {
|
|
19
20
|
private spans: Map<string, TraceSpan> = new Map();
|
|
20
21
|
|
package/src/lib/util.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { APIContext } from 'astro';
|
|
2
1
|
import { CID } from 'multiformats/cid';
|
|
3
2
|
import * as dagCbor from '@ipld/dag-cbor';
|
|
4
3
|
import { sha256 } from 'multiformats/hashes/sha2';
|
|
4
|
+
import type { Env } from '../env';
|
|
5
|
+
import { PayloadTooLarge } from './errors';
|
|
5
6
|
|
|
6
7
|
export function tryParse(json: string): unknown {
|
|
7
8
|
try {
|
|
@@ -11,33 +12,30 @@ export function tryParse(json: string): unknown {
|
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
export async function readJson(request: Request): Promise<any> {
|
|
15
|
+
export async function readJson(request: Request): Promise<unknown> {
|
|
16
16
|
const max = 64 * 1024;
|
|
17
17
|
const text = await request.text();
|
|
18
|
-
if (text.length > max) throw new
|
|
18
|
+
if (text.length > max) throw new PayloadTooLarge();
|
|
19
19
|
return JSON.parse(text || '{}');
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function readJsonBounded(env:
|
|
23
|
-
const raw =
|
|
22
|
+
export async function readJsonBounded(env: Env, request: Request): Promise<unknown> {
|
|
23
|
+
const raw = env.PDS_MAX_JSON_BYTES ?? '65536';
|
|
24
24
|
const max = Number(raw) > 0 ? Number(raw) : 65536;
|
|
25
25
|
const text = await request.text();
|
|
26
|
-
if (text.length > max)
|
|
27
|
-
const err: any = new Error('PayloadTooLarge');
|
|
28
|
-
err.code = 'PayloadTooLarge';
|
|
29
|
-
throw err;
|
|
30
|
-
}
|
|
26
|
+
if (text.length > max) throw new PayloadTooLarge();
|
|
31
27
|
return JSON.parse(text || '{}');
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
export function bearerToken(request: Request): string | null {
|
|
35
31
|
const auth = request.headers.get('authorization');
|
|
36
|
-
if (!auth
|
|
37
|
-
return auth.slice(7);
|
|
32
|
+
if (!auth) return null;
|
|
33
|
+
if (auth.startsWith('Bearer ')) return auth.slice(7);
|
|
34
|
+
if (auth.startsWith('DPoP ')) return auth.slice(5);
|
|
35
|
+
return null;
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
export function isAllowedMime(env:
|
|
38
|
+
export function isAllowedMime(env: Env, mime: string): boolean {
|
|
41
39
|
const def = [
|
|
42
40
|
// Images
|
|
43
41
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif',
|
|
@@ -50,7 +48,7 @@ export function isAllowedMime(env: any, mime: string): boolean {
|
|
|
50
48
|
// Generic fallback
|
|
51
49
|
'application/octet-stream'
|
|
52
50
|
];
|
|
53
|
-
const raw =
|
|
51
|
+
const raw = env.PDS_ALLOWED_MIME ?? def.join(',');
|
|
54
52
|
const set = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
|
|
55
53
|
|
|
56
54
|
// Extract base MIME type (remove charset and other parameters)
|
|
@@ -59,6 +57,58 @@ export function isAllowedMime(env: any, mime: string): boolean {
|
|
|
59
57
|
return set.has(baseMime);
|
|
60
58
|
}
|
|
61
59
|
|
|
60
|
+
export function baseMime(mime: string | null | undefined): string {
|
|
61
|
+
if (!mime) return 'application/octet-stream';
|
|
62
|
+
return mime.toLowerCase().split(';')[0].trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Best-effort MIME sniffing for common image/video/audio formats.
|
|
66
|
+
// Prefer this over client-provided header when possible, mirroring upstream PDS.
|
|
67
|
+
export function sniffMime(buf: ArrayBuffer): string | null {
|
|
68
|
+
const bytes = new Uint8Array(buf);
|
|
69
|
+
const len = bytes.length;
|
|
70
|
+
const ascii = (start: number, n: number) =>
|
|
71
|
+
String.fromCharCode(...bytes.slice(start, start + n));
|
|
72
|
+
|
|
73
|
+
if (len >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
74
|
+
return 'image/jpeg';
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
len >= 8 &&
|
|
78
|
+
bytes[0] === 0x89 && ascii(1, 3) === 'PNG' && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a
|
|
79
|
+
) {
|
|
80
|
+
return 'image/png';
|
|
81
|
+
}
|
|
82
|
+
if (len >= 6) {
|
|
83
|
+
const sig6 = ascii(0, 6);
|
|
84
|
+
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return 'image/gif';
|
|
85
|
+
}
|
|
86
|
+
if (len >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
|
|
87
|
+
return 'image/webp';
|
|
88
|
+
}
|
|
89
|
+
// ISO BMFF / MP4 / AVIF / QuickTime: find 'ftyp' within first 256 bytes
|
|
90
|
+
{
|
|
91
|
+
const window = Math.min(len, 256);
|
|
92
|
+
for (let i = 0; i + 8 <= window; i++) {
|
|
93
|
+
if (ascii(i, 4) === 'ftyp') {
|
|
94
|
+
const brand = ascii(i + 4, 4);
|
|
95
|
+
const mp4Brands = new Set(['isom', 'iso2', 'mp41', 'mp42', 'avc1', 'MSNV', '3gp4', 'M4V ']);
|
|
96
|
+
if (brand === 'avif' || brand === 'avis' || brand === 'mif1' || brand === 'msf1') return 'image/avif';
|
|
97
|
+
if (brand === 'qt ') return 'video/quicktime';
|
|
98
|
+
if (mp4Brands.has(brand)) return 'video/mp4';
|
|
99
|
+
// Unknown brand: still likely MP4 container
|
|
100
|
+
return 'video/mp4';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// WebM/Matroska (EBML)
|
|
105
|
+
if (len >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
|
|
106
|
+
// Could be audio/webm or video/webm; default to video/webm
|
|
107
|
+
return 'video/webm';
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
62
112
|
export function randomRkey(): string {
|
|
63
113
|
return crypto.randomUUID().replace(/-/g, '').substring(0, 13);
|
|
64
114
|
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineMiddleware, sequence } from 'astro
|
|
1
|
+
import { defineMiddleware, sequence } from 'astro/middleware';
|
|
2
2
|
|
|
3
3
|
const cors = defineMiddleware(async ({ locals, request }, next) => {
|
|
4
4
|
// Match atproto CORS implementation: use wildcard for public endpoints
|