@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
|
@@ -2,9 +2,13 @@ import type { APIContext } from 'astro';
|
|
|
2
2
|
import { RepoManager } from '../../services/repo-manager';
|
|
3
3
|
import { readJson } from '../../lib/util';
|
|
4
4
|
import { bumpRoot } from '../../db/repo';
|
|
5
|
-
import {
|
|
5
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
|
|
6
6
|
import { isAccountActive } from '../../db/dal';
|
|
7
7
|
import { checkRate } from '../../lib/ratelimit';
|
|
8
|
+
import { notifySequencer } from '../../lib/sequencer';
|
|
9
|
+
import { encodeBlocksForCommit } from '../../services/car';
|
|
10
|
+
import { CID } from 'multiformats/cid';
|
|
11
|
+
import { putRecord as dalPutRecord } from '../../db/dal';
|
|
8
12
|
|
|
9
13
|
export const prerender = false;
|
|
10
14
|
|
|
@@ -14,10 +18,17 @@ export const prerender = false;
|
|
|
14
18
|
*/
|
|
15
19
|
export async function POST({ locals, request }: APIContext) {
|
|
16
20
|
const { env } = locals.runtime;
|
|
17
|
-
|
|
21
|
+
try {
|
|
22
|
+
const auth = await verifyResourceRequestHybrid(env, request);
|
|
23
|
+
if (!auth) return dpopResourceUnauthorized(env);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const handled = await handleResourceAuthError(env, error);
|
|
26
|
+
if (handled) return handled;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
18
29
|
|
|
19
30
|
// Check if account is active
|
|
20
|
-
const did = env.PDS_DID
|
|
31
|
+
const did = env.PDS_DID as string;
|
|
21
32
|
const active = await isAccountActive(env, did);
|
|
22
33
|
if (!active) {
|
|
23
34
|
return new Response(
|
|
@@ -33,7 +44,12 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
33
44
|
if (rateLimitResponse) return rateLimitResponse;
|
|
34
45
|
|
|
35
46
|
try {
|
|
36
|
-
const body = await readJson(request)
|
|
47
|
+
const body = (await readJson(request)) as {
|
|
48
|
+
repo?: string;
|
|
49
|
+
writes?: unknown[];
|
|
50
|
+
validate?: boolean;
|
|
51
|
+
swapCommit?: string;
|
|
52
|
+
};
|
|
37
53
|
const { repo, writes, validate = true, swapCommit } = body;
|
|
38
54
|
|
|
39
55
|
if (!writes || !Array.isArray(writes)) {
|
|
@@ -44,34 +60,107 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
const repoManager = new RepoManager(env);
|
|
47
|
-
const
|
|
63
|
+
const pdsDid = typeof env.PDS_DID === 'string' ? env.PDS_DID : '';
|
|
64
|
+
type WriteResult = { $type: string; uri?: string; cid?: string; validationStatus?: string };
|
|
65
|
+
const results: WriteResult[] = [];
|
|
66
|
+
// Accumulate ops and new MST blocks for this batch
|
|
67
|
+
const opsForCommit: { action: 'create'|'update'|'delete'; path: string; cid: import('multiformats/cid').CID | null }[] = [];
|
|
68
|
+
const newMstBlocksAll: Array<[import('multiformats/cid').CID, Uint8Array]> = [];
|
|
69
|
+
let firstPrevMst: import('multiformats/cid').CID | null = null;
|
|
70
|
+
let lastMst: import('../../lib/mst').MST | null = null;
|
|
48
71
|
|
|
72
|
+
type WriteOperation = {
|
|
73
|
+
$type?: string;
|
|
74
|
+
collection?: string;
|
|
75
|
+
rkey?: string;
|
|
76
|
+
value?: Record<string, unknown>;
|
|
77
|
+
};
|
|
49
78
|
// Apply all writes atomically
|
|
50
|
-
for (const
|
|
79
|
+
for (const rawWrite of writes) {
|
|
80
|
+
const write = rawWrite as WriteOperation;
|
|
51
81
|
const { $type, collection, rkey, value } = write;
|
|
82
|
+
if (typeof collection !== 'string' || typeof rkey !== 'string') {
|
|
83
|
+
return new Response(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
error: 'InvalidRequest',
|
|
86
|
+
message: 'collection and rkey are required strings on every write',
|
|
87
|
+
}),
|
|
88
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
52
91
|
|
|
53
92
|
if ($type === 'com.atproto.repo.applyWrites#create') {
|
|
54
|
-
const { mst, recordCid } = await repoManager.addRecord(collection, rkey, value);
|
|
93
|
+
const { mst, recordCid, prevMstRoot, newMstBlocks } = await repoManager.addRecord(collection, rkey, value);
|
|
94
|
+
if (!firstPrevMst) firstPrevMst = prevMstRoot;
|
|
95
|
+
lastMst = mst;
|
|
96
|
+
opsForCommit.push({ action: 'create', path: `${collection}/${rkey}`, cid: recordCid });
|
|
97
|
+
for (const [cid, bytes] of newMstBlocks) newMstBlocksAll.push([cid, bytes]);
|
|
98
|
+
// Persist JSON for local reads
|
|
99
|
+
await dalPutRecord(env, {
|
|
100
|
+
uri: `at://${pdsDid}/${collection}/${rkey}`,
|
|
101
|
+
did: pdsDid,
|
|
102
|
+
cid: recordCid.toString(),
|
|
103
|
+
json: JSON.stringify(value),
|
|
104
|
+
});
|
|
55
105
|
results.push({
|
|
106
|
+
$type: 'com.atproto.repo.applyWrites#createResult',
|
|
56
107
|
uri: `at://${repo}/${collection}/${rkey}`,
|
|
57
108
|
cid: recordCid.toString(),
|
|
109
|
+
validationStatus: 'valid',
|
|
58
110
|
});
|
|
59
111
|
} else if ($type === 'com.atproto.repo.applyWrites#update') {
|
|
60
|
-
const { mst, recordCid } = await repoManager.updateRecord(collection, rkey, value);
|
|
112
|
+
const { mst, recordCid, prevMstRoot, newMstBlocks } = await repoManager.updateRecord(collection, rkey, value);
|
|
113
|
+
if (!firstPrevMst) firstPrevMst = prevMstRoot;
|
|
114
|
+
lastMst = mst;
|
|
115
|
+
opsForCommit.push({ action: 'update', path: `${collection}/${rkey}`, cid: recordCid });
|
|
116
|
+
for (const [cid, bytes] of newMstBlocks) newMstBlocksAll.push([cid, bytes]);
|
|
117
|
+
await dalPutRecord(env, {
|
|
118
|
+
uri: `at://${pdsDid}/${collection}/${rkey}`,
|
|
119
|
+
did: pdsDid,
|
|
120
|
+
cid: recordCid.toString(),
|
|
121
|
+
json: JSON.stringify(value),
|
|
122
|
+
});
|
|
61
123
|
results.push({
|
|
124
|
+
$type: 'com.atproto.repo.applyWrites#updateResult',
|
|
62
125
|
uri: `at://${repo}/${collection}/${rkey}`,
|
|
63
126
|
cid: recordCid.toString(),
|
|
127
|
+
validationStatus: 'valid',
|
|
64
128
|
});
|
|
65
129
|
} else if ($type === 'com.atproto.repo.applyWrites#delete') {
|
|
66
|
-
await repoManager.deleteRecord(collection, rkey);
|
|
130
|
+
const { mst, prevMstRoot, newMstBlocks } = await repoManager.deleteRecord(collection, rkey);
|
|
131
|
+
if (!firstPrevMst) firstPrevMst = prevMstRoot;
|
|
132
|
+
lastMst = mst;
|
|
133
|
+
opsForCommit.push({ action: 'delete', path: `${collection}/${rkey}`, cid: null });
|
|
134
|
+
for (const [cid, bytes] of newMstBlocks) newMstBlocksAll.push([cid, bytes]);
|
|
67
135
|
results.push({
|
|
68
|
-
|
|
136
|
+
$type: 'com.atproto.repo.applyWrites#deleteResult',
|
|
69
137
|
});
|
|
70
138
|
}
|
|
71
139
|
}
|
|
72
140
|
|
|
73
141
|
// Bump repo root to create new commit
|
|
74
|
-
const
|
|
142
|
+
const currentRoot = lastMst ? await lastMst.getPointer() : undefined;
|
|
143
|
+
const { commitCid, rev, commitData, sig, blocks } = await bumpRoot(env, firstPrevMst ?? undefined, currentRoot, {
|
|
144
|
+
ops: opsForCommit,
|
|
145
|
+
newMstBlocks: newMstBlocksAll,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Notify sequencer about the commit for firehose
|
|
149
|
+
try {
|
|
150
|
+
// Prefer commitData/sig/blocks returned by bumpRoot (authoritative)
|
|
151
|
+
await notifySequencer(env, {
|
|
152
|
+
did: pdsDid,
|
|
153
|
+
commitCid,
|
|
154
|
+
rev,
|
|
155
|
+
data: commitData,
|
|
156
|
+
sig,
|
|
157
|
+
ops: opsForCommit,
|
|
158
|
+
...(blocks ? { blocks } : {}),
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('Failed to notify sequencer:', error);
|
|
162
|
+
// Don't fail the request if sequencer notification fails
|
|
163
|
+
}
|
|
75
164
|
|
|
76
165
|
return new Response(
|
|
77
166
|
JSON.stringify({
|
|
@@ -85,6 +174,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
85
174
|
);
|
|
86
175
|
} catch (error) {
|
|
87
176
|
console.error('applyWrites error:', error);
|
|
177
|
+
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');
|
|
88
178
|
return new Response(
|
|
89
179
|
JSON.stringify({ error: 'InternalServerError', message: String(error) }),
|
|
90
180
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { errorCode, errorMessage } from '../../lib/errors';
|
|
3
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
|
|
3
4
|
import { checkRate } from '../../lib/ratelimit';
|
|
4
5
|
import { readJsonBounded } from '../../lib/util';
|
|
5
6
|
import { RepoManager } from '../../services/repo-manager';
|
|
@@ -9,7 +10,14 @@ export const prerender = false;
|
|
|
9
10
|
|
|
10
11
|
export async function POST({ locals, request }: APIContext) {
|
|
11
12
|
const { env } = locals.runtime;
|
|
12
|
-
|
|
13
|
+
try {
|
|
14
|
+
const auth = await verifyResourceRequestHybrid(env, request);
|
|
15
|
+
if (!auth) return dpopResourceUnauthorized(env);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const handled = await handleResourceAuthError(env, error);
|
|
18
|
+
if (handled) return handled;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
const rateLimitResponse = await checkRate(env, request, 'writes');
|
|
15
23
|
if (rateLimitResponse) return rateLimitResponse;
|
|
@@ -17,28 +25,50 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
17
25
|
let body: any;
|
|
18
26
|
try {
|
|
19
27
|
body = await readJsonBounded(env, request);
|
|
20
|
-
} catch (e
|
|
21
|
-
if (e
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (errorCode(e) === 'PayloadTooLarge') {
|
|
22
30
|
return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
|
|
23
31
|
}
|
|
24
32
|
return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
|
|
25
33
|
}
|
|
26
|
-
const { collection, rkey
|
|
34
|
+
const { collection, rkey } = body ?? {};
|
|
35
|
+
let { record } = body ?? {};
|
|
27
36
|
if (!collection || !record) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
|
|
28
37
|
|
|
38
|
+
// Minimal schema alignment for app.bsky.feed.post: ensure required fields
|
|
39
|
+
if (collection === 'app.bsky.feed.post' && record && typeof record === 'object') {
|
|
40
|
+
if (typeof record.text !== 'string') {
|
|
41
|
+
record.text = '';
|
|
42
|
+
}
|
|
43
|
+
if (typeof record.createdAt !== 'string') {
|
|
44
|
+
record.createdAt = new Date().toISOString();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
const repo = new RepoManager(env);
|
|
30
|
-
const
|
|
49
|
+
const result = await repo.createRecord(collection, record, rkey);
|
|
31
50
|
await notifySequencer(env, {
|
|
32
|
-
did: env.PDS_DID
|
|
33
|
-
commitCid:
|
|
34
|
-
rev:
|
|
35
|
-
data:
|
|
36
|
-
sig:
|
|
37
|
-
ops:
|
|
38
|
-
blocks:
|
|
51
|
+
did: env.PDS_DID as string,
|
|
52
|
+
commitCid: result.commitCid,
|
|
53
|
+
rev: result.rev,
|
|
54
|
+
data: result.commitData,
|
|
55
|
+
sig: result.sig,
|
|
56
|
+
ops: result.ops,
|
|
57
|
+
blocks: result.blocks
|
|
39
58
|
});
|
|
40
59
|
|
|
41
|
-
|
|
60
|
+
// Conform to official PDS response schema
|
|
61
|
+
const out = {
|
|
62
|
+
uri: result.uri,
|
|
63
|
+
cid: result.cid,
|
|
64
|
+
commit: {
|
|
65
|
+
cid: result.commitCid,
|
|
66
|
+
rev: result.rev,
|
|
67
|
+
},
|
|
68
|
+
validationStatus: 'unknown' as const,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return new Response(JSON.stringify(out), {
|
|
42
72
|
headers: { 'Content-Type': 'application/json' },
|
|
43
73
|
});
|
|
44
74
|
}
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { errorCode, errorMessage } from '../../lib/errors';
|
|
3
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
|
|
3
4
|
import { checkRate } from '../../lib/ratelimit';
|
|
4
5
|
import { readJsonBounded } from '../../lib/util';
|
|
5
6
|
import { RepoManager } from '../../services/repo-manager';
|
|
7
|
+
import { bumpRoot } from '../../db/repo';
|
|
6
8
|
import { notifySequencer } from '../../lib/sequencer';
|
|
7
9
|
|
|
8
10
|
export const prerender = false;
|
|
9
11
|
|
|
10
12
|
export async function POST({ locals, request }: APIContext) {
|
|
11
13
|
const { env } = locals.runtime;
|
|
12
|
-
|
|
14
|
+
try {
|
|
15
|
+
const auth = await verifyResourceRequestHybrid(env, request);
|
|
16
|
+
if (!auth) return dpopResourceUnauthorized(env);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
const handled = await handleResourceAuthError(env, error);
|
|
19
|
+
if (handled) return handled;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
13
22
|
|
|
14
23
|
const rateLimitResponse = await checkRate(env, request, 'writes');
|
|
15
24
|
if (rateLimitResponse) return rateLimitResponse;
|
|
@@ -17,8 +26,8 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
17
26
|
let body: any;
|
|
18
27
|
try {
|
|
19
28
|
body = await readJsonBounded(env, request);
|
|
20
|
-
} catch (e
|
|
21
|
-
if (e
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (errorCode(e) === 'PayloadTooLarge') {
|
|
22
31
|
return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
|
|
23
32
|
}
|
|
24
33
|
return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
|
|
@@ -27,18 +36,37 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
27
36
|
if (!collection || !rkey) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
|
|
28
37
|
|
|
29
38
|
const repo = new RepoManager(env);
|
|
30
|
-
|
|
39
|
+
// Perform the delete in the MST, gather prev/new roots & new blocks
|
|
40
|
+
const { mst, prevMstRoot, uri, newMstBlocks } = await repo.deleteRecord(collection, rkey);
|
|
41
|
+
|
|
42
|
+
// Build ops & bump the repo root to create a signed commit
|
|
43
|
+
const currentRoot = await mst.getPointer();
|
|
44
|
+
const opsForCommit = [{ action: 'delete' as const, path: `${collection}/${rkey}`, cid: null }];
|
|
45
|
+
const { commitCid, rev, commitData, sig, blocks } = await bumpRoot(env, prevMstRoot ?? undefined, currentRoot, {
|
|
46
|
+
ops: opsForCommit,
|
|
47
|
+
newMstBlocks: Array.from(newMstBlocks),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Notify sequencer with a complete payload matching handleCommitNotification
|
|
31
51
|
await notifySequencer(env, {
|
|
32
|
-
did: env.PDS_DID
|
|
33
|
-
commitCid
|
|
34
|
-
rev
|
|
35
|
-
data:
|
|
36
|
-
sig
|
|
37
|
-
ops:
|
|
38
|
-
blocks
|
|
52
|
+
did: env.PDS_DID as string,
|
|
53
|
+
commitCid,
|
|
54
|
+
rev,
|
|
55
|
+
data: commitData,
|
|
56
|
+
sig,
|
|
57
|
+
ops: opsForCommit,
|
|
58
|
+
blocks,
|
|
39
59
|
});
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
// Respond with official schema
|
|
62
|
+
const out = {
|
|
63
|
+
commit: {
|
|
64
|
+
cid: commitCid,
|
|
65
|
+
rev,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return new Response(JSON.stringify(out), {
|
|
42
70
|
headers: { 'Content-Type': 'application/json' },
|
|
43
71
|
});
|
|
44
72
|
}
|
|
@@ -10,8 +10,8 @@ export const prerender = false;
|
|
|
10
10
|
export async function GET({ locals, url }: APIContext) {
|
|
11
11
|
const { env } = locals.runtime;
|
|
12
12
|
|
|
13
|
-
const repo = url.searchParams.get('repo') || env.PDS_DID
|
|
14
|
-
const did = env.PDS_DID
|
|
13
|
+
const repo = url.searchParams.get('repo') || (env.PDS_DID as string);
|
|
14
|
+
const did = env.PDS_DID as string;
|
|
15
15
|
const handle = env.PDS_HANDLE || 'user.example.com';
|
|
16
16
|
|
|
17
17
|
// Get repo root to check if repo exists
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
2
|
import { getRecord as dalGetRecord } from '../../db/dal';
|
|
3
|
+
import { proxyAppView } from '../../lib/appview';
|
|
3
4
|
|
|
4
5
|
export const prerender = false;
|
|
5
6
|
|
|
@@ -8,7 +9,7 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
8
9
|
const url = new URL(request.url);
|
|
9
10
|
let uri = url.searchParams.get('uri');
|
|
10
11
|
if (!uri) {
|
|
11
|
-
const repo = url.searchParams.get('repo') ?? (env.PDS_DID
|
|
12
|
+
const repo = url.searchParams.get('repo') ?? (env.PDS_DID as string);
|
|
12
13
|
const collection = url.searchParams.get('collection');
|
|
13
14
|
const rkey = url.searchParams.get('rkey');
|
|
14
15
|
if (repo && collection && rkey) uri = `at://${repo}/${collection}/${rkey}`;
|
|
@@ -16,6 +17,18 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
16
17
|
|
|
17
18
|
if (!uri) return new Response(JSON.stringify({ error: 'BadRequest', message: 'query param uri required' }), { status: 400 });
|
|
18
19
|
|
|
20
|
+
// If the repo is not hosted here, proxy to AppView like upstream PDS does
|
|
21
|
+
const localDid = env.PDS_DID || '';
|
|
22
|
+
const repoParam = url.searchParams.get('repo') || '';
|
|
23
|
+
let repoDid = repoParam;
|
|
24
|
+
if (!repoDid && uri.startsWith('at://')) {
|
|
25
|
+
const m = uri.match(/^at:\/\/([^/]+)\//);
|
|
26
|
+
if (m) repoDid = m[1];
|
|
27
|
+
}
|
|
28
|
+
if (repoDid && localDid && repoDid !== localDid) {
|
|
29
|
+
return proxyAppView({ request, env, lxm: 'com.atproto.repo.getRecord' });
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
const row = await dalGetRecord(env, uri);
|
|
20
33
|
if (!row) return new Response(JSON.stringify({ error: 'NotFound' }), { status: 404 });
|
|
21
34
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { errorMessage } from '../../lib/errors';
|
|
3
|
+
import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
|
|
3
4
|
import { getDb } from '../../db/client';
|
|
4
5
|
import { record, blob_ref } from '../../db/schema';
|
|
5
6
|
import { eq } from 'drizzle-orm';
|
|
@@ -16,10 +17,17 @@ export const prerender = false;
|
|
|
16
17
|
export async function GET({ locals, request, url }: APIContext) {
|
|
17
18
|
const { env } = locals.runtime;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
try {
|
|
21
|
+
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof AuthTokenExpiredError) {
|
|
24
|
+
return expiredToken();
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
try {
|
|
22
|
-
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
30
|
+
const did = String(env.PDS_DID ?? 'did:example:single-user');
|
|
23
31
|
const limit = parseInt(url.searchParams.get('limit') || '500');
|
|
24
32
|
const cursor = url.searchParams.get('cursor') || '';
|
|
25
33
|
|
|
@@ -76,13 +84,13 @@ export async function GET({ locals, request, url }: APIContext) {
|
|
|
76
84
|
}),
|
|
77
85
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
78
86
|
);
|
|
79
|
-
} catch (error
|
|
87
|
+
} catch (error) {
|
|
80
88
|
return new Response(
|
|
81
89
|
JSON.stringify({
|
|
82
90
|
error: 'InternalServerError',
|
|
83
|
-
message: error
|
|
91
|
+
message: errorMessage(error) || 'Failed to list missing blobs'
|
|
84
92
|
}),
|
|
85
93
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
86
94
|
);
|
|
87
95
|
}
|
|
88
|
-
}
|
|
96
|
+
}
|
|
@@ -10,7 +10,7 @@ export const prerender = false;
|
|
|
10
10
|
export async function GET({ locals, url }: APIContext) {
|
|
11
11
|
const { env } = locals.runtime;
|
|
12
12
|
|
|
13
|
-
const repo = url.searchParams.get('repo') || env.PDS_DID
|
|
13
|
+
const repo = url.searchParams.get('repo') || (env.PDS_DID as string);
|
|
14
14
|
const collection = url.searchParams.get('collection');
|
|
15
15
|
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
16
16
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { errorCode, errorMessage } from '../../lib/errors';
|
|
3
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
|
|
3
4
|
import { checkRate } from '../../lib/ratelimit';
|
|
4
5
|
import { readJsonBounded } from '../../lib/util';
|
|
5
6
|
import { RepoManager } from '../../services/repo-manager';
|
|
@@ -9,7 +10,14 @@ export const prerender = false;
|
|
|
9
10
|
|
|
10
11
|
export async function POST({ locals, request }: APIContext) {
|
|
11
12
|
const { env } = locals.runtime;
|
|
12
|
-
|
|
13
|
+
try {
|
|
14
|
+
const auth = await verifyResourceRequestHybrid(env, request);
|
|
15
|
+
if (!auth) return dpopResourceUnauthorized(env);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const handled = await handleResourceAuthError(env, error);
|
|
18
|
+
if (handled) return handled;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
const rateLimitResponse = await checkRate(env, request, 'writes');
|
|
15
23
|
if (rateLimitResponse) return rateLimitResponse;
|
|
@@ -17,28 +25,48 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
17
25
|
let body: any;
|
|
18
26
|
try {
|
|
19
27
|
body = await readJsonBounded(env, request);
|
|
20
|
-
} catch (e
|
|
21
|
-
if (e
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (errorCode(e) === 'PayloadTooLarge') {
|
|
22
30
|
return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
|
|
23
31
|
}
|
|
24
32
|
return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
|
|
25
33
|
}
|
|
26
|
-
const { collection, rkey
|
|
34
|
+
const { collection, rkey } = body ?? {};
|
|
35
|
+
let { record } = body ?? {};
|
|
27
36
|
if (!collection || !rkey || !record) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
|
|
28
37
|
|
|
38
|
+
if (collection === 'app.bsky.feed.post' && record && typeof record === 'object') {
|
|
39
|
+
if (typeof record.text !== 'string') {
|
|
40
|
+
record.text = '';
|
|
41
|
+
}
|
|
42
|
+
if (typeof record.createdAt !== 'string') {
|
|
43
|
+
record.createdAt = new Date().toISOString();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
const repo = new RepoManager(env);
|
|
30
|
-
const
|
|
48
|
+
const result = await repo.putRecord(collection, rkey, record);
|
|
31
49
|
await notifySequencer(env, {
|
|
32
|
-
did: env.PDS_DID
|
|
33
|
-
commitCid:
|
|
34
|
-
rev:
|
|
35
|
-
data:
|
|
36
|
-
sig:
|
|
37
|
-
ops:
|
|
38
|
-
blocks:
|
|
50
|
+
did: env.PDS_DID as string,
|
|
51
|
+
commitCid: result.commitCid,
|
|
52
|
+
rev: result.rev,
|
|
53
|
+
data: result.commitData,
|
|
54
|
+
sig: result.sig,
|
|
55
|
+
ops: result.ops,
|
|
56
|
+
blocks: result.blocks
|
|
39
57
|
});
|
|
40
58
|
|
|
41
|
-
|
|
59
|
+
const out = {
|
|
60
|
+
uri: result.uri,
|
|
61
|
+
cid: result.cid,
|
|
62
|
+
commit: {
|
|
63
|
+
cid: result.commitCid,
|
|
64
|
+
rev: result.rev,
|
|
65
|
+
},
|
|
66
|
+
validationStatus: 'unknown' as const,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return new Response(JSON.stringify(out), {
|
|
42
70
|
headers: { 'Content-Type': 'application/json' },
|
|
43
71
|
});
|
|
44
72
|
}
|
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
import type { APIContext } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { errorMessage } from '../../lib/errors';
|
|
3
|
+
import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
|
|
4
|
+
import { verifyServiceAuth, isServiceAuthToken } from '../../lib/service-auth';
|
|
3
5
|
import { checkRate } from '../../lib/ratelimit';
|
|
4
|
-
import { isAllowedMime } from '../../lib/util';
|
|
6
|
+
import { isAllowedMime, sniffMime, baseMime } from '../../lib/util';
|
|
5
7
|
import { R2BlobStore } from '../../services/r2-blob-store';
|
|
6
8
|
import { putBlobRef, checkBlobQuota, updateBlobQuota, isAccountActive } from '../../db/dal';
|
|
7
9
|
import { resolveSecret } from '../../lib/secrets';
|
|
10
|
+
import { CID } from 'multiformats/cid';
|
|
11
|
+
import { sha256 } from 'multiformats/hashes/sha2';
|
|
8
12
|
|
|
9
13
|
export const prerender = false;
|
|
10
14
|
|
|
11
15
|
export async function POST({ locals, request }: APIContext) {
|
|
12
16
|
const { env } = locals.runtime;
|
|
13
|
-
|
|
17
|
+
|
|
18
|
+
// Check if this is a service auth request (from video.bsky.app, etc.)
|
|
19
|
+
const authHeader = request.headers.get('authorization');
|
|
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
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If not service auth, verify as user request
|
|
36
|
+
if (!isServiceAuth) {
|
|
37
|
+
try {
|
|
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
|
+
}
|
|
45
|
+
}
|
|
14
46
|
|
|
15
47
|
// Get DID from environment (single-user PDS)
|
|
16
48
|
const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
|
|
@@ -30,12 +62,29 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
30
62
|
const rateLimitResponse = await checkRate(env, request, 'blob');
|
|
31
63
|
if (rateLimitResponse) return rateLimitResponse;
|
|
32
64
|
|
|
33
|
-
|
|
34
|
-
const
|
|
65
|
+
// Decompress if Content-Encoding is present (some clients may compress uploads)
|
|
66
|
+
const enc = (request.headers.get('content-encoding') || '').toLowerCase();
|
|
67
|
+
let buf: ArrayBuffer;
|
|
68
|
+
if (enc && (enc === 'gzip' || enc === 'br' || enc === 'deflate')) {
|
|
69
|
+
try {
|
|
70
|
+
// @ts-ignore: DecompressionStream is available in CF Workers runtime
|
|
71
|
+
const ds = new DecompressionStream(enc);
|
|
72
|
+
const decompressed = request.body?.pipeThrough(ds);
|
|
73
|
+
buf = await new Response(decompressed).arrayBuffer();
|
|
74
|
+
} catch {
|
|
75
|
+
// Fallback to raw body if decompression not supported
|
|
76
|
+
buf = await request.arrayBuffer();
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
buf = await request.arrayBuffer();
|
|
80
|
+
}
|
|
81
|
+
const headerMime = baseMime(request.headers.get('content-type'));
|
|
82
|
+
const sniffed = sniffMime(buf);
|
|
83
|
+
// Prefer sniffed MIME like upstream PDS; fall back to header
|
|
84
|
+
const contentType = sniffed || headerMime;
|
|
35
85
|
|
|
36
86
|
// Skip MIME type validation during migration - accept all types
|
|
37
|
-
// Uncomment
|
|
38
|
-
// if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
|
|
87
|
+
// Uncomment to enforce: if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
|
|
39
88
|
|
|
40
89
|
// Check quota before upload
|
|
41
90
|
const canUpload = await checkBlobQuota(env, did, buf.byteLength);
|
|
@@ -51,19 +100,31 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
51
100
|
|
|
52
101
|
const store = new R2BlobStore(env);
|
|
53
102
|
try {
|
|
54
|
-
const
|
|
103
|
+
const response = await store.put(buf, { contentType });
|
|
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();
|
|
55
109
|
|
|
56
110
|
// Register blob ref with CID-based key
|
|
57
|
-
await putBlobRef(env, did,
|
|
111
|
+
await putBlobRef(env, did, cidStr, response.key, contentType, response.size);
|
|
58
112
|
|
|
59
113
|
// Update quota
|
|
60
|
-
await updateBlobQuota(env, did,
|
|
114
|
+
await updateBlobQuota(env, did, response.size, 1);
|
|
115
|
+
|
|
116
|
+
// Mirror upstream shape exactly; helpful debugging header
|
|
117
|
+
// Conform to lexicon: blob object must include $type: 'blob'
|
|
118
|
+
const body = { blob: { $type: 'blob', ref: { $link: cidStr }, mimeType: contentType, size: response.size } };
|
|
119
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
120
|
+
// Debug-only headers (safe for clients to ignore)
|
|
121
|
+
headers['x-sniffed-mime'] = sniffed || '';
|
|
122
|
+
headers['x-header-mime'] = headerMime;
|
|
123
|
+
if (enc) headers['x-upload-encoding'] = enc;
|
|
61
124
|
|
|
62
|
-
return new Response(JSON.stringify(
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
} catch (e: any) {
|
|
66
|
-
if (String(e.message || '').startsWith('BlobTooLarge')) return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
|
|
125
|
+
return new Response(JSON.stringify(body), { headers });
|
|
126
|
+
} catch (e) {
|
|
127
|
+
if (String(errorMessage(e) || '').startsWith('BlobTooLarge')) return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
|
|
67
128
|
return new Response(JSON.stringify({ error: 'UploadFailed' }), { status: 500 });
|
|
68
129
|
}
|
|
69
130
|
}
|