@alteran/astro 0.7.6 → 0.8.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/README.md +25 -25
- package/migrations/0010_eminent_klaw.sql +37 -0
- package/migrations/0011_chief_darwin.sql +31 -0
- package/migrations/0012_backfill_blob_usage.sql +39 -0
- package/migrations/meta/0010_snapshot.json +790 -0
- package/migrations/meta/0011_snapshot.json +813 -0
- package/migrations/meta/_journal.json +22 -1
- package/package.json +24 -41
- package/src/db/blob.ts +323 -0
- package/src/db/dal.ts +224 -78
- package/src/db/repo.ts +205 -25
- package/src/db/schema.ts +14 -5
- package/src/handlers/debug.ts +4 -3
- package/src/lib/appview/auth-policy.ts +7 -24
- package/src/lib/appview/proxy.ts +56 -23
- package/src/lib/appview/types.ts +1 -6
- package/src/lib/auth-scope.ts +399 -0
- package/src/lib/auth.ts +40 -39
- package/src/lib/commit.ts +37 -15
- package/src/lib/did-document.ts +4 -5
- package/src/lib/jwt.ts +3 -1
- package/src/lib/mime.ts +9 -0
- package/src/lib/oauth/observability.ts +53 -12
- package/src/lib/oauth/resource.ts +49 -0
- package/src/lib/preference-policy.ts +45 -0
- package/src/lib/preferences.ts +0 -4
- package/src/lib/public-host.ts +127 -0
- package/src/lib/ratelimit.ts +37 -12
- package/src/lib/relay.ts +7 -27
- package/src/lib/repo-write-blob-constraints.ts +141 -0
- package/src/lib/repo-write-data.ts +195 -0
- package/src/lib/repo-write-error.ts +46 -0
- package/src/lib/repo-write-validation.ts +463 -0
- package/src/lib/session-tokens.ts +22 -5
- package/src/lib/unsupported-routes.ts +32 -0
- package/src/lib/util.ts +57 -2
- package/src/pages/.well-known/atproto-did.ts +15 -3
- package/src/pages/.well-known/did.json.ts +13 -7
- package/src/pages/debug/db/bootstrap.ts +4 -3
- package/src/pages/debug/gc/blobs.ts +11 -8
- package/src/pages/debug/record.ts +11 -0
- package/src/pages/oauth/token.ts +78 -33
- package/src/pages/xrpc/[...nsid].ts +17 -9
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
- package/src/services/car.ts +13 -0
- package/src/services/repo/apply-prepared-writes.ts +185 -0
- package/src/services/repo/blob-refs.ts +48 -0
- package/src/services/repo/blockstore-ops.ts +59 -17
- package/src/services/repo/list-blobs.ts +43 -0
- package/src/services/repo-manager.ts +221 -78
- package/src/worker/runtime.ts +1 -1
- package/src/worker/sequencer/upgrade.ts +4 -1
|
@@ -1,52 +1,48 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { Env } from '../../env';
|
|
3
|
+
import { PayloadTooLarge } from '../../lib/errors';
|
|
4
|
+
import {
|
|
5
|
+
verifyResourceRequestHybrid,
|
|
6
|
+
dpopResourceUnauthorized,
|
|
7
|
+
handleResourceAuthError,
|
|
8
|
+
insufficientScopeResponse,
|
|
9
|
+
type ResourceAuthContext,
|
|
10
|
+
} from '../../lib/oauth/resource';
|
|
11
|
+
import { canUploadBlob } from '../../lib/auth-scope';
|
|
4
12
|
import { verifyServiceAuth, isServiceAuthToken } from '../../lib/service-auth';
|
|
5
13
|
import { checkRate } from '../../lib/ratelimit';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
14
|
+
import { sniffMime, baseMime, readBodyBounded, readStreamBounded } from '../../lib/util';
|
|
15
|
+
import {
|
|
16
|
+
deleteUnreferencedBlobRef,
|
|
17
|
+
isAccountActive,
|
|
18
|
+
registerBlobRefWithQuota,
|
|
19
|
+
sweepEligibleUnreferencedBlobKeys,
|
|
20
|
+
} from '../../db/dal';
|
|
9
21
|
import { resolveSecret } from '../../lib/secrets';
|
|
10
22
|
import { CID } from 'multiformats/cid';
|
|
11
23
|
import { sha256 } from 'multiformats/hashes/sha2';
|
|
24
|
+
import { jsonError } from '../../lib/repo-write-error';
|
|
12
25
|
|
|
13
26
|
export const prerender = false;
|
|
14
27
|
|
|
28
|
+
const UPLOAD_BLOB_LXM = 'com.atproto.repo.uploadBlob';
|
|
29
|
+
|
|
30
|
+
type UploadAuth =
|
|
31
|
+
| { tag: 'service' }
|
|
32
|
+
| { tag: 'user'; auth: ResourceAuthContext };
|
|
33
|
+
|
|
15
34
|
export async function POST({ locals, request }: APIContext) {
|
|
16
35
|
const { env } = locals.runtime;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
|
|
21
|
-
|
|
22
|
-
let isServiceAuth = false;
|
|
23
|
-
if (token && isServiceAuthToken(token)) {
|
|
24
|
-
const serviceAuth = await verifyServiceAuth(env, request);
|
|
25
|
-
if (serviceAuth) {
|
|
26
|
-
isServiceAuth = true;
|
|
27
|
-
} else {
|
|
28
|
-
return new Response(
|
|
29
|
-
JSON.stringify({ error: 'AuthRequired', message: 'Invalid service auth token' }),
|
|
30
|
-
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
31
|
-
);
|
|
32
|
-
}
|
|
36
|
+
const did = await resolveSecret(env.PDS_DID);
|
|
37
|
+
if (!did) {
|
|
38
|
+
return jsonError('InternalServerError', 'PDS_DID is not configured', 500);
|
|
33
39
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
const auth = await verifyResourceRequestHybrid(env, request);
|
|
39
|
-
if (!auth) return dpopResourceUnauthorized(env);
|
|
40
|
-
} catch (error) {
|
|
41
|
-
const handled = await handleResourceAuthError(env, error);
|
|
42
|
-
if (handled) return handled;
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
40
|
+
const uploadAuth = await authenticateUpload(env, request, did);
|
|
41
|
+
if (uploadAuth instanceof Response) return uploadAuth;
|
|
42
|
+
if (uploadAuth.tag === 'user' && uploadAuth.auth.did !== did) {
|
|
43
|
+
return jsonError('InvalidRequest', 'authenticated user does not own this repo', 400);
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
// Get DID from environment (single-user PDS)
|
|
48
|
-
const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
|
|
49
|
-
|
|
50
46
|
// Check if account is active
|
|
51
47
|
const active = await isAccountActive(env, did);
|
|
52
48
|
if (!active) {
|
|
@@ -59,72 +55,197 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
59
55
|
);
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
const rateLimitResponse = await checkRate(env, request, 'blob');
|
|
58
|
+
const rateLimitResponse = await checkRate(env, request, 'blob', { key: did });
|
|
63
59
|
if (rateLimitResponse) return rateLimitResponse;
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Fallback to raw body if decompression not supported
|
|
76
|
-
buf = await request.arrayBuffer();
|
|
61
|
+
const encoding = uploadEncoding(request);
|
|
62
|
+
if (!isSupportedUploadEncoding(encoding)) {
|
|
63
|
+
return jsonError('InvalidRequest', `unsupported content-encoding: ${encoding}`, 400);
|
|
64
|
+
}
|
|
65
|
+
let bytes: Uint8Array;
|
|
66
|
+
try {
|
|
67
|
+
bytes = await readUploadBytes(request, maxBlobBytes(env), encoding);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof PayloadTooLarge) {
|
|
70
|
+
return jsonError('PayloadTooLarge', 'blob exceeds maximum size', 413);
|
|
77
71
|
}
|
|
78
|
-
|
|
79
|
-
buf = await request.arrayBuffer();
|
|
72
|
+
return jsonError('InvalidRequest', 'failed to read upload body', 400);
|
|
80
73
|
}
|
|
74
|
+
const buf = toArrayBuffer(bytes);
|
|
81
75
|
const headerMime = baseMime(request.headers.get('content-type'));
|
|
82
76
|
const sniffed = sniffMime(buf);
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
const contentType = resolveUploadMime(headerMime, sniffed);
|
|
78
|
+
if (uploadAuth.tag === 'user' && !canUploadBlob(uploadAuth.auth.access, contentType)) {
|
|
79
|
+
return insufficientScopeResponse();
|
|
80
|
+
}
|
|
85
81
|
|
|
86
82
|
// Skip MIME type validation during migration - accept all types
|
|
87
83
|
// Uncomment to enforce: if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
|
|
88
84
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return new Response(
|
|
93
|
-
JSON.stringify({
|
|
94
|
-
error: 'BlobQuotaExceeded',
|
|
95
|
-
message: 'Blob storage quota exceeded'
|
|
96
|
-
}),
|
|
97
|
-
{ status: 413 }
|
|
98
|
-
);
|
|
99
|
-
}
|
|
85
|
+
await sweepEligibleUnreferencedBlobKeys(env, { did, limit: 20 }).catch((error) => {
|
|
86
|
+
console.warn('[uploadBlob] Failed to sweep dereferenced blobs:', error);
|
|
87
|
+
});
|
|
100
88
|
|
|
101
|
-
|
|
89
|
+
let registeredCid: string | undefined;
|
|
102
90
|
try {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
// Compute a CIDv1 (raw) for the blob so clients receive a valid CID link
|
|
106
|
-
const digest = await sha256.digest(new Uint8Array(buf));
|
|
107
|
-
const cid = CID.createV1(0x55, digest); // 0x55 = raw codec
|
|
108
|
-
const cidStr = cid.toString();
|
|
91
|
+
const identity = await blobIdentity(env, buf);
|
|
109
92
|
|
|
110
|
-
|
|
111
|
-
|
|
93
|
+
const registration = await registerBlobRefWithQuota(
|
|
94
|
+
env,
|
|
95
|
+
did,
|
|
96
|
+
identity.cid,
|
|
97
|
+
identity.key,
|
|
98
|
+
contentType,
|
|
99
|
+
identity.size,
|
|
100
|
+
);
|
|
101
|
+
if (registration.tag === 'quotaExceeded') {
|
|
102
|
+
return new Response(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
error: 'BlobQuotaExceeded',
|
|
105
|
+
message: 'Blob storage quota exceeded',
|
|
106
|
+
}),
|
|
107
|
+
{ status: 413, headers: { 'Content-Type': 'application/json' } },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (registration.tag === 'registered') registeredCid = identity.cid;
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
await updateBlobQuota(env, did, response.size, 1);
|
|
112
|
+
await ensureBlobObject(env, registration.blob.key, buf, registration.blob.mime, registration.blob.size);
|
|
115
113
|
|
|
116
114
|
// Mirror upstream shape exactly; helpful debugging header
|
|
117
115
|
// Conform to lexicon: blob object must include $type: 'blob'
|
|
118
|
-
const body = {
|
|
116
|
+
const body = {
|
|
117
|
+
blob: {
|
|
118
|
+
$type: 'blob',
|
|
119
|
+
ref: { $link: registration.blob.cid },
|
|
120
|
+
mimeType: registration.blob.mime,
|
|
121
|
+
size: registration.blob.size,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
119
124
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
120
125
|
// Debug-only headers (safe for clients to ignore)
|
|
121
126
|
headers['x-sniffed-mime'] = sniffed || '';
|
|
122
127
|
headers['x-header-mime'] = headerMime;
|
|
123
|
-
if (
|
|
128
|
+
if (encoding) headers['x-upload-encoding'] = encoding;
|
|
124
129
|
|
|
125
130
|
return new Response(JSON.stringify(body), { headers });
|
|
126
|
-
} catch (
|
|
127
|
-
if (
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (registeredCid) await deleteUnreferencedBlobRef(env, did, registeredCid).catch(() => undefined);
|
|
133
|
+
if (error instanceof PayloadTooLarge) {
|
|
134
|
+
return jsonError('PayloadTooLarge', 'blob exceeds maximum size', 413);
|
|
135
|
+
}
|
|
128
136
|
return new Response(JSON.stringify({ error: 'UploadFailed' }), { status: 500 });
|
|
129
137
|
}
|
|
130
138
|
}
|
|
139
|
+
|
|
140
|
+
async function authenticateUpload(env: Env, request: Request, did: string): Promise<UploadAuth | Response> {
|
|
141
|
+
const authHeader = request.headers.get('authorization');
|
|
142
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
|
|
143
|
+
|
|
144
|
+
if (token && isServiceAuthToken(token)) {
|
|
145
|
+
const serviceAuth = await verifyServiceAuth(env, request);
|
|
146
|
+
if (!serviceAuth || serviceAuth.lxm !== UPLOAD_BLOB_LXM || serviceAuth.iss !== did) {
|
|
147
|
+
return jsonError('InvalidToken', 'Invalid service auth token', 401);
|
|
148
|
+
}
|
|
149
|
+
return { tag: 'service' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const auth = await verifyResourceRequestHybrid(env, request);
|
|
154
|
+
if (!auth) return dpopResourceUnauthorized(env);
|
|
155
|
+
return { tag: 'user', auth };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const handled = await handleResourceAuthError(env, error);
|
|
158
|
+
if (handled) return handled;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readUploadBytes(
|
|
164
|
+
request: Request,
|
|
165
|
+
maxBytes: number,
|
|
166
|
+
encoding: string,
|
|
167
|
+
): Promise<Uint8Array> {
|
|
168
|
+
if (!isCompressedEncoding(encoding)) {
|
|
169
|
+
return readBodyBounded(request, maxBytes);
|
|
170
|
+
}
|
|
171
|
+
const decompressed = request.body?.pipeThrough(createDecompressionStream(encoding));
|
|
172
|
+
return readStreamBounded(decompressed ?? null, maxBytes);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function uploadEncoding(request: Request): string {
|
|
176
|
+
return (request.headers.get('content-encoding') || '').trim().toLowerCase();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isCompressedEncoding(encoding: string): boolean {
|
|
180
|
+
return encoding === 'gzip' || encoding === 'deflate' || encoding === 'deflate-raw';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isSupportedUploadEncoding(encoding: string): boolean {
|
|
184
|
+
return encoding === '' || isCompressedEncoding(encoding);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveUploadMime(headerMime: string, sniffed: string | null): string {
|
|
188
|
+
if (!sniffed) return headerMime;
|
|
189
|
+
if (sniffed === 'video/webm' && (headerMime === 'audio/webm' || headerMime === 'video/webm')) {
|
|
190
|
+
return headerMime;
|
|
191
|
+
}
|
|
192
|
+
if (sniffed === 'video/mp4' && (headerMime === 'audio/mp4' || headerMime === 'video/mp4')) {
|
|
193
|
+
return headerMime;
|
|
194
|
+
}
|
|
195
|
+
return sniffed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createDecompressionStream(encoding: string): TransformStream<Uint8Array, Uint8Array> {
|
|
199
|
+
const Decompression = globalThis.DecompressionStream as unknown as {
|
|
200
|
+
new (format: string): TransformStream<Uint8Array, Uint8Array>;
|
|
201
|
+
};
|
|
202
|
+
return new Decompression(encoding);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
206
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type BlobIdentity = {
|
|
210
|
+
cid: string;
|
|
211
|
+
key: string;
|
|
212
|
+
size: number;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
async function blobIdentity(env: Env, body: ArrayBuffer): Promise<BlobIdentity> {
|
|
216
|
+
const size = body.byteLength;
|
|
217
|
+
const limit = maxBlobBytes(env);
|
|
218
|
+
if (size > limit) throw new PayloadTooLarge('blob exceeds maximum size');
|
|
219
|
+
|
|
220
|
+
const digest = await sha256.digest(new Uint8Array(body));
|
|
221
|
+
const cid = CID.createV1(0x55, digest);
|
|
222
|
+
return {
|
|
223
|
+
cid: cid.toString(),
|
|
224
|
+
key: `blobs/by-cid/${base64Url(digest.digest)}`,
|
|
225
|
+
size,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function ensureBlobObject(
|
|
230
|
+
env: Env,
|
|
231
|
+
key: string,
|
|
232
|
+
body: ArrayBuffer,
|
|
233
|
+
contentType: string,
|
|
234
|
+
expectedSize: number,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const existing = await env.ALTERAN_BLOBS.head(key);
|
|
237
|
+
if (existing?.size === expectedSize) return;
|
|
238
|
+
await env.ALTERAN_BLOBS.put(key, body, { httpMetadata: { contentType } });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function maxBlobBytes(env: Env, defaultMax = 5 * 1024 * 1024): number {
|
|
242
|
+
const raw = env.PDS_MAX_BLOB_SIZE;
|
|
243
|
+
const parsed = raw ? Number(raw) : defaultMax;
|
|
244
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMax;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function base64Url(bytes: Uint8Array): string {
|
|
248
|
+
let value = '';
|
|
249
|
+
for (const byte of bytes) value += String.fromCharCode(byte);
|
|
250
|
+
return btoa(value).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
251
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { errorMessage } from '../../lib/errors';
|
|
3
|
-
import { authErrorResponse,
|
|
3
|
+
import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
|
|
4
|
+
import { canAccessFullAccount } from '../../lib/auth-scope';
|
|
4
5
|
import { getAccountState } from '../../db/dal';
|
|
5
6
|
import { toWireStatus } from '../../lib/account-state';
|
|
6
7
|
import { getDb } from '../../db/client';
|
|
@@ -23,7 +24,8 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
23
24
|
const { env } = locals.runtime;
|
|
24
25
|
|
|
25
26
|
try {
|
|
26
|
-
|
|
27
|
+
const auth = await authenticateRequest(request, env);
|
|
28
|
+
if (!auth || !canAccessFullAccount(auth.access)) return unauthorized();
|
|
27
29
|
} catch (error) {
|
|
28
30
|
const handled = await authErrorResponse(env, error);
|
|
29
31
|
if (handled) return handled;
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { NSID, ensureValidDid } from '@atproto/syntax';
|
|
3
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
|
|
3
4
|
import { createServiceAuthToken } from '../../lib/appview';
|
|
5
|
+
import { canMakeRpcCall, type AuthAccessContext } from '../../lib/auth-scope';
|
|
6
|
+
import { PRIVILEGED_METHODS, PROTECTED_METHODS } from '../../lib/appview/auth-policy';
|
|
4
7
|
|
|
5
8
|
export const prerender = false;
|
|
6
9
|
|
|
10
|
+
const METHOD_SCOPED_MAX_EXPIRATION_SECONDS = 60 * 60;
|
|
11
|
+
const METHODLESS_MAX_EXPIRATION_SECONDS = 60;
|
|
12
|
+
|
|
13
|
+
const SERVICE_AUTH_PROTECTED_METHODS: ReadonlySet<string> = new Set([
|
|
14
|
+
...PROTECTED_METHODS,
|
|
15
|
+
'com.atproto.admin.deleteAccount',
|
|
16
|
+
'com.atproto.identity.submitPlcOperation',
|
|
17
|
+
'com.atproto.repo.importRepo',
|
|
18
|
+
'com.atproto.server.deleteAccount',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS: ReadonlySet<string> = new Set([
|
|
22
|
+
'com.atproto.server.createAccount',
|
|
23
|
+
]);
|
|
24
|
+
|
|
7
25
|
export async function GET({ locals, request }: APIContext) {
|
|
8
26
|
const { env } = locals.runtime;
|
|
9
|
-
let auth:
|
|
27
|
+
let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
|
|
10
28
|
try {
|
|
11
|
-
|
|
12
|
-
if (!
|
|
29
|
+
const verified = await verifyResourceRequestHybrid(env, request);
|
|
30
|
+
if (!verified) return dpopResourceUnauthorized(env);
|
|
31
|
+
auth = verified;
|
|
13
32
|
} catch (error) {
|
|
14
33
|
const handled = await handleResourceAuthError(env, error);
|
|
15
34
|
if (handled) return handled;
|
|
@@ -23,35 +42,39 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
23
42
|
|
|
24
43
|
const audience = audienceParam?.trim();
|
|
25
44
|
if (!audience) {
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
return jsonError('MissingAudience', undefined, 400);
|
|
46
|
+
}
|
|
47
|
+
if (!isValidServiceAudience(audience)) {
|
|
48
|
+
return jsonError('InvalidRequest', 'aud must be a DID or DID service reference', 400);
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
const lexiconMethod = lexParam && lexParam.trim() !== '' ? lexParam.trim() : null;
|
|
52
|
+
if (lexiconMethod !== null && !NSID.isValid(lexiconMethod)) {
|
|
53
|
+
return jsonError('InvalidRequest', 'lxm must be a valid NSID', 400);
|
|
54
|
+
}
|
|
55
|
+
if (!isAccountActiveForServiceAuth(auth.access)) {
|
|
56
|
+
return jsonError('AccountInactive', 'Account is not active', 403);
|
|
57
|
+
}
|
|
58
|
+
if (!canIssueServiceAuth(auth.access, lexiconMethod, audience)) {
|
|
59
|
+
return insufficientScopeResponse();
|
|
60
|
+
}
|
|
33
61
|
|
|
34
62
|
let expiresIn = 60;
|
|
63
|
+
const maxExpiresIn = lexiconMethod ? METHOD_SCOPED_MAX_EXPIRATION_SECONDS : METHODLESS_MAX_EXPIRATION_SECONDS;
|
|
35
64
|
const now = Math.floor(Date.now() / 1000);
|
|
36
65
|
if (expParam !== null) {
|
|
37
66
|
if (!/^-?\d+$/.test(expParam)) {
|
|
38
|
-
return
|
|
39
|
-
status: 400,
|
|
40
|
-
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
});
|
|
67
|
+
return jsonError('BadExpiration', 'expiration must be an integer timestamp', 400);
|
|
42
68
|
}
|
|
43
69
|
const exp = Number(expParam);
|
|
70
|
+
if (!Number.isSafeInteger(exp)) {
|
|
71
|
+
return jsonError('BadExpiration', 'expiration must be a safe integer timestamp', 400);
|
|
72
|
+
}
|
|
44
73
|
if (exp <= now) {
|
|
45
|
-
return
|
|
46
|
-
status: 400,
|
|
47
|
-
headers: { 'Content-Type': 'application/json' },
|
|
48
|
-
});
|
|
74
|
+
return jsonError('BadExpiration', 'expiration is in the past', 400);
|
|
49
75
|
}
|
|
50
|
-
if (exp - now >
|
|
51
|
-
return
|
|
52
|
-
status: 400,
|
|
53
|
-
headers: { 'Content-Type': 'application/json' },
|
|
54
|
-
});
|
|
76
|
+
if (exp - now > maxExpiresIn) {
|
|
77
|
+
return jsonError('BadExpiration', 'expiration too far in future', 400);
|
|
55
78
|
}
|
|
56
79
|
expiresIn = Math.max(1, exp - now);
|
|
57
80
|
}
|
|
@@ -69,3 +92,47 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
69
92
|
});
|
|
70
93
|
}
|
|
71
94
|
}
|
|
95
|
+
|
|
96
|
+
function canIssueServiceAuth(
|
|
97
|
+
access: AuthAccessContext,
|
|
98
|
+
lexiconMethod: string | null,
|
|
99
|
+
audience: string,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (lexiconMethod === null) {
|
|
102
|
+
return access.isFullAccess || access.isAppPassword;
|
|
103
|
+
}
|
|
104
|
+
if (SERVICE_AUTH_PROTECTED_METHODS.has(lexiconMethod)) return false;
|
|
105
|
+
if (access.isOAuth) return canMakeRpcCall(access, lexiconMethod, audience);
|
|
106
|
+
if (APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS.has(lexiconMethod)) {
|
|
107
|
+
return access.isFullAccess;
|
|
108
|
+
}
|
|
109
|
+
if (PRIVILEGED_METHODS.has(lexiconMethod)) {
|
|
110
|
+
return access.isPrivileged;
|
|
111
|
+
}
|
|
112
|
+
return canMakeRpcCall(access, lexiconMethod, audience);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isAccountActiveForServiceAuth(access: AuthAccessContext): boolean {
|
|
116
|
+
if (access.isTakendown || access.isSignupQueued) return false;
|
|
117
|
+
return access.accountStatus === 'active';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isValidServiceAudience(audience: string): boolean {
|
|
121
|
+
const parts = audience.split('#');
|
|
122
|
+
if (parts.length > 2) return false;
|
|
123
|
+
const [did, fragment] = parts;
|
|
124
|
+
if (!did) return false;
|
|
125
|
+
try {
|
|
126
|
+
ensureValidDid(did);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return fragment === undefined || /^[A-Za-z][A-Za-z0-9._:-]*$/.test(fragment);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function jsonError(error: string, message: string | undefined, status: number): Response {
|
|
134
|
+
return new Response(JSON.stringify(message ? { error, message } : { error }), {
|
|
135
|
+
status,
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
|
|
3
3
|
import { getAccountByIdentifier } from '../../db/account';
|
|
4
|
+
import { buildDidDocument } from '../../lib/did-document';
|
|
4
5
|
|
|
5
6
|
export const prerender = false;
|
|
6
7
|
|
|
@@ -27,6 +28,7 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
27
28
|
const did = authContext.claims.sub;
|
|
28
29
|
const account = await getAccountByIdentifier(env, did);
|
|
29
30
|
const handle = account?.handle ?? (env.PDS_HANDLE as string) ?? 'user.example.com';
|
|
31
|
+
const didDoc = await buildDidDocument(env, did, handle);
|
|
30
32
|
|
|
31
33
|
return new Response(
|
|
32
34
|
JSON.stringify({
|
|
@@ -35,19 +37,7 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
35
37
|
email: account?.email ?? (env.PDS_EMAIL as string | undefined) ?? 'user@example.com',
|
|
36
38
|
emailConfirmed: true,
|
|
37
39
|
emailAuthFactor: false,
|
|
38
|
-
didDoc
|
|
39
|
-
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
40
|
-
id: did,
|
|
41
|
-
alsoKnownAs: [`at://${handle}`],
|
|
42
|
-
verificationMethod: [],
|
|
43
|
-
service: [
|
|
44
|
-
{
|
|
45
|
-
id: '#atproto_pds',
|
|
46
|
-
type: 'AtprotoPersonalDataServer',
|
|
47
|
-
serviceEndpoint: `https://${env.PDS_HOSTNAME ?? handle}`,
|
|
48
|
-
},
|
|
49
|
-
],
|
|
50
|
-
},
|
|
40
|
+
didDoc,
|
|
51
41
|
}),
|
|
52
42
|
{
|
|
53
43
|
status: 200,
|