@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.
Files changed (75) hide show
  1. package/README.md +25 -25
  2. package/migrations/0010_eminent_klaw.sql +37 -0
  3. package/migrations/0011_chief_darwin.sql +31 -0
  4. package/migrations/0012_backfill_blob_usage.sql +39 -0
  5. package/migrations/meta/0010_snapshot.json +790 -0
  6. package/migrations/meta/0011_snapshot.json +813 -0
  7. package/migrations/meta/_journal.json +22 -1
  8. package/package.json +24 -41
  9. package/src/db/blob.ts +323 -0
  10. package/src/db/dal.ts +224 -78
  11. package/src/db/repo.ts +205 -25
  12. package/src/db/schema.ts +14 -5
  13. package/src/handlers/debug.ts +4 -3
  14. package/src/lib/appview/auth-policy.ts +7 -24
  15. package/src/lib/appview/proxy.ts +56 -23
  16. package/src/lib/appview/types.ts +1 -6
  17. package/src/lib/auth-scope.ts +399 -0
  18. package/src/lib/auth.ts +40 -39
  19. package/src/lib/commit.ts +37 -15
  20. package/src/lib/did-document.ts +4 -5
  21. package/src/lib/jwt.ts +3 -1
  22. package/src/lib/mime.ts +9 -0
  23. package/src/lib/oauth/observability.ts +53 -12
  24. package/src/lib/oauth/resource.ts +49 -0
  25. package/src/lib/preference-policy.ts +45 -0
  26. package/src/lib/preferences.ts +0 -4
  27. package/src/lib/public-host.ts +127 -0
  28. package/src/lib/ratelimit.ts +37 -12
  29. package/src/lib/relay.ts +7 -27
  30. package/src/lib/repo-write-blob-constraints.ts +141 -0
  31. package/src/lib/repo-write-data.ts +195 -0
  32. package/src/lib/repo-write-error.ts +46 -0
  33. package/src/lib/repo-write-validation.ts +463 -0
  34. package/src/lib/session-tokens.ts +22 -5
  35. package/src/lib/unsupported-routes.ts +32 -0
  36. package/src/lib/util.ts +57 -2
  37. package/src/pages/.well-known/atproto-did.ts +15 -3
  38. package/src/pages/.well-known/did.json.ts +13 -7
  39. package/src/pages/debug/db/bootstrap.ts +4 -3
  40. package/src/pages/debug/gc/blobs.ts +11 -8
  41. package/src/pages/debug/record.ts +11 -0
  42. package/src/pages/oauth/token.ts +78 -33
  43. package/src/pages/xrpc/[...nsid].ts +17 -9
  44. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
  45. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
  46. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
  47. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
  48. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
  49. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
  50. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
  51. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
  52. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
  53. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
  54. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
  55. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
  56. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
  57. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
  58. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
  59. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
  60. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
  61. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
  62. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
  63. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
  64. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
  65. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
  66. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
  67. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
  68. package/src/services/car.ts +13 -0
  69. package/src/services/repo/apply-prepared-writes.ts +185 -0
  70. package/src/services/repo/blob-refs.ts +48 -0
  71. package/src/services/repo/blockstore-ops.ts +59 -17
  72. package/src/services/repo/list-blobs.ts +43 -0
  73. package/src/services/repo-manager.ts +221 -78
  74. package/src/worker/runtime.ts +1 -1
  75. package/src/worker/sequencer/upgrade.ts +4 -1
@@ -1,11 +1,11 @@
1
1
  import type { APIContext } from 'astro';
2
+ import { ensureValidDid } from '@atproto/syntax';
3
+ import { CID } from 'multiformats/cid';
2
4
  import { getDb } from '../../db/client';
3
5
  import { blob_ref } from '../../db/schema';
4
- import { eq } from 'drizzle-orm';
5
- import { CID } from 'multiformats/cid';
6
- import { sha256 } from 'multiformats/hashes/sha2';
7
- import { putBlobRef } from '../../db/dal';
8
- import { isAccountActive } from '../../db/dal';
6
+ import { and, eq } from 'drizzle-orm';
7
+ import { blobKeyHasUsage, isAccountActive } from '../../db/dal';
8
+ import { resolveSecret } from '../../lib/secrets';
9
9
 
10
10
  export const prerender = false;
11
11
 
@@ -23,98 +23,50 @@ export async function GET({ locals, url }: APIContext) {
23
23
  const { env } = locals.runtime;
24
24
 
25
25
  try {
26
- const configuredDid = typeof env.PDS_DID === 'string' ? env.PDS_DID : undefined;
27
- const did = url.searchParams.get('did') ?? configuredDid;
26
+ const configuredDid = await resolveSecret(env.PDS_DID);
27
+ const did = url.searchParams.get('did');
28
28
  const cid = url.searchParams.get('cid');
29
29
  if (!did || !cid) {
30
- return new Response(
31
- JSON.stringify({
32
- error: 'InvalidRequest',
33
- message: 'did and cid parameters are required'
34
- }),
35
- { status: 400, headers: { 'Content-Type': 'application/json' } }
36
- );
30
+ return jsonError('InvalidRequest', 'did and cid parameters are required');
31
+ }
32
+ if (!isValidDid(did) || !isValidCid(cid)) {
33
+ return jsonError('InvalidRequest', 'did and cid parameters must be valid');
34
+ }
35
+ if (!configuredDid || did !== configuredDid) {
36
+ return jsonError('RepoNotFound', 'Repo not found');
37
37
  }
38
38
 
39
39
  const active = await isAccountActive(env, did);
40
40
  if (!active) {
41
- return new Response(
42
- JSON.stringify({ error: 'AccountInactive', message: 'Account is not active' }),
43
- { status: 403, headers: { 'Content-Type': 'application/json' } }
44
- );
41
+ return jsonError('RepoDeactivated', 'Repo is deactivated');
45
42
  }
46
43
 
47
44
  const db = getDb(env);
48
45
 
49
- // Look up blob metadata by CID
50
- let blobMeta = await db
46
+ const blobMeta = await db
51
47
  .select()
52
48
  .from(blob_ref)
53
- .where(eq(blob_ref.cid, cid))
49
+ .where(and(eq(blob_ref.did, did), eq(blob_ref.cid, cid)))
54
50
  .get();
55
51
 
56
- let key: string | null = blobMeta?.key ?? null;
57
- let mime: string = blobMeta?.mime ?? 'application/octet-stream';
58
- let size: number | null = blobMeta?.size ?? null;
59
-
60
- // Fallback for older uploads: derive R2 key from CID (raw/sha256) if DB row missing
61
- if (!key) {
62
- try {
63
- const link = CID.parse(cid);
64
- // blob CIDs are raw (0x55) with sha256 multihash
65
- if (link.multihash.code !== sha256.code) {
66
- return new Response(
67
- JSON.stringify({ error: 'InvalidRequest', message: 'Unsupported multihash' }),
68
- { status: 400, headers: { 'Content-Type': 'application/json' } }
69
- );
70
- }
71
- // Recreate legacy R2 key scheme used by store.put()
72
- const digest = link.multihash.digest; // Uint8Array
73
- // base64url encode
74
- let s = '';
75
- for (const b of digest) s += String.fromCharCode(b);
76
- const b64url = btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
77
- key = `blobs/by-cid/${b64url}`;
78
- } catch {
79
- key = null;
80
- }
81
- }
82
-
83
- if (!key) {
84
- return new Response(
85
- JSON.stringify({ error: 'InvalidRequest', message: 'Blob not found' }),
86
- { status: 400, headers: { 'Content-Type': 'application/json' } }
87
- );
52
+ if (!blobMeta || !(await blobKeyHasUsage(env, did, blobMeta.key))) {
53
+ return blobNotFound();
88
54
  }
89
55
 
90
56
  // Fetch blob from R2
91
57
  const r2 = env.ALTERAN_BLOBS;
92
- const object = await r2.get(key);
58
+ const object = await r2.get(blobMeta.key);
93
59
 
94
- if (!object) {
95
- return new Response(
96
- JSON.stringify({ error: 'InvalidRequest', message: 'Blob not found' }),
97
- { status: 400, headers: { 'Content-Type': 'application/json' } }
98
- );
60
+ if (!object || object.size !== Number(blobMeta.size)) {
61
+ return blobNotFound();
99
62
  }
100
63
 
101
- if (!blobMeta) {
102
- try {
103
- size = object.size ?? size ?? 0;
104
- await putBlobRef(env, did, cid, key, mime, Number(size ?? 0));
105
- } catch (backfillError) {
106
- // Backfill is opportunistic; serving the blob is the priority.
107
- console.warn('getBlob backfill failed:', backfillError);
108
- }
109
- }
110
-
111
- // workers-types' ReadableStream lacks the DOM-types readMany member; the
112
- // shapes are wire-compatible at runtime, so widen through unknown.
113
- return new Response(object.body as unknown as ReadableStream<Uint8Array>, {
64
+ const body = await responseBody(object);
65
+ return new Response(body, {
114
66
  status: 200,
115
67
  headers: {
116
- 'Content-Type': mime,
117
- ...(size != null ? { 'Content-Length': String(size) } : {}),
68
+ 'Content-Type': blobMeta.mime,
69
+ 'Content-Length': String(blobMeta.size),
118
70
  'Cache-Control': 'public, max-age=31536000, immutable',
119
71
  'x-content-type-options': 'nosniff',
120
72
  'content-security-policy': "default-src 'none'; sandbox",
@@ -131,3 +83,69 @@ export async function GET({ locals, url }: APIContext) {
131
83
  );
132
84
  }
133
85
  }
86
+
87
+ function blobNotFound(): Response {
88
+ return jsonError('BlobNotFound', 'Blob not found');
89
+ }
90
+
91
+ function jsonError(error: string, message: string, status = 400): Response {
92
+ return new Response(
93
+ JSON.stringify({ error, message }),
94
+ { status, headers: { 'Content-Type': 'application/json' } },
95
+ );
96
+ }
97
+
98
+ function isValidDid(did: string): boolean {
99
+ try {
100
+ ensureValidDid(did);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function isValidCid(cid: string): boolean {
108
+ try {
109
+ CID.parse(cid);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ async function responseBody(object: {
117
+ body: unknown;
118
+ arrayBuffer(): Promise<ArrayBuffer>;
119
+ }): Promise<BodyInit> {
120
+ try {
121
+ return localReadableStream(object.body as ReadableStream<Uint8Array>);
122
+ } catch (error) {
123
+ // Miniflare exposes R2 bodies via a workerd object that can't be cloned
124
+ // across the worker boundary; fall back to a buffered read when that happens.
125
+ if (error instanceof Error && error.message.includes('can not be cloned')) {
126
+ return object.arrayBuffer();
127
+ }
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ function localReadableStream(source: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
133
+ const reader = source.getReader();
134
+ return new ReadableStream<Uint8Array>({
135
+ async pull(controller) {
136
+ try {
137
+ const chunk = await reader.read();
138
+ if (chunk.done) {
139
+ controller.close();
140
+ return;
141
+ }
142
+ controller.enqueue(new Uint8Array(chunk.value));
143
+ } catch (error) {
144
+ controller.error(error);
145
+ }
146
+ },
147
+ cancel(reason) {
148
+ return reader.cancel(reason);
149
+ },
150
+ });
151
+ }
@@ -1,7 +1,8 @@
1
1
  import type { APIContext } from 'astro';
2
- import { drizzle } from 'drizzle-orm/d1';
3
- import { blob_ref } from '../../db/schema';
4
- import { eq, gt, and } from 'drizzle-orm';
2
+ import { ensureValidDid, isValidTid } from '@atproto/syntax';
3
+ import { isAccountActive } from '../../db/dal';
4
+ import { listBlobCids } from '../../services/repo/list-blobs';
5
+ import { resolveSecret } from '../../lib/secrets';
5
6
 
6
7
  export const prerender = false;
7
8
 
@@ -12,31 +13,29 @@ export const prerender = false;
12
13
  export async function GET({ locals, url }: APIContext) {
13
14
  const { env } = locals.runtime;
14
15
 
15
- const did = url.searchParams.get('did') || (env.PDS_DID as string);
16
- const since = url.searchParams.get('since') || '';
17
- const limit = parseInt(url.searchParams.get('limit') || '500', 10);
16
+ const configuredDid = await resolveSecret(env.PDS_DID);
17
+ const did = url.searchParams.get('did') ?? undefined;
18
+ const since = url.searchParams.get('since') ?? undefined;
19
+ const cursor = url.searchParams.get('cursor') ?? undefined;
20
+ const limit = parseLimit(url.searchParams.get('limit'));
21
+ if (!did) return jsonError('InvalidRequest', 'did is required');
22
+ if (!isValidDid(did)) return jsonError('InvalidRequest', 'did must be valid');
23
+ if (!limit) return jsonError('InvalidRequest', 'limit must be an integer between 1 and 1000');
24
+ if (since && !isValidTid(since)) return jsonError('InvalidRequest', 'since must be a valid TID');
25
+ if (!configuredDid || did !== configuredDid) return jsonError('RepoNotFound', 'Repo not found');
18
26
 
19
27
  try {
20
- const db = drizzle(env.ALTERAN_DB);
21
-
22
- const blobs = since
23
- ? await db
24
- .select()
25
- .from(blob_ref)
26
- .where(and(eq(blob_ref.did, did), gt(blob_ref.cid, since)))
27
- .limit(limit)
28
- .all()
29
- : await db
30
- .select()
31
- .from(blob_ref)
32
- .where(eq(blob_ref.did, did))
33
- .limit(limit)
34
- .all();
28
+ const active = await isAccountActive(env, did);
29
+ if (!active) {
30
+ return jsonError('RepoDeactivated', 'Repo is deactivated');
31
+ }
32
+
33
+ const blobs = await listBlobCids(env, { did, since, cursor, limit });
35
34
 
36
35
  return new Response(
37
36
  JSON.stringify({
38
- cids: blobs.map(b => b.cid),
39
- cursor: blobs.length > 0 ? blobs[blobs.length - 1].cid : undefined,
37
+ cids: blobs.cids,
38
+ ...(blobs.cursor ? { cursor: blobs.cursor } : {}),
40
39
  }),
41
40
  {
42
41
  status: 200,
@@ -51,3 +50,26 @@ export async function GET({ locals, url }: APIContext) {
51
50
  );
52
51
  }
53
52
  }
53
+
54
+ function parseLimit(value: string | null): number | null {
55
+ if (value === null || value === '') return 500;
56
+ if (!/^\d+$/.test(value)) return null;
57
+ const limit = Number(value);
58
+ return Number.isInteger(limit) && limit >= 1 && limit <= 1000 ? limit : null;
59
+ }
60
+
61
+ function jsonError(error: string, message: string): Response {
62
+ return new Response(
63
+ JSON.stringify({ error, message }),
64
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
65
+ );
66
+ }
67
+
68
+ function isValidDid(did: string): boolean {
69
+ try {
70
+ ensureValidDid(did);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
@@ -174,10 +174,15 @@ export async function encodeBlocksForCommit(
174
174
  mstRoot: CID,
175
175
  ops: Array<{ path: string; cid: CID | null }>,
176
176
  newMstBlocks?: Array<[CID, Uint8Array]>,
177
+ commitBlock?: Uint8Array,
178
+ newRecordBlocks?: Array<readonly [CID, Uint8Array]>,
177
179
  ): Promise<Uint8Array> {
178
180
  const blockstore = new D1Blockstore(env);
179
181
  const blocks: { cid: CID; bytes: Uint8Array }[] = [];
180
182
  const seen = new Set<string>();
183
+ const stagedRecordBlocks = new Map(
184
+ (newRecordBlocks ?? []).map(([cid, bytes]) => [cid.toString(), bytes]),
185
+ );
181
186
 
182
187
  // Helper to add block if not already seen
183
188
  const addBlock = async (cid: CID) => {
@@ -185,6 +190,14 @@ export async function encodeBlocksForCommit(
185
190
  if (seen.has(cidStr)) return;
186
191
  seen.add(cidStr);
187
192
  let bytes = await blockstore.get(cid);
193
+ if (!bytes) {
194
+ bytes = stagedRecordBlocks.get(cidStr) ?? null;
195
+ }
196
+ if (!bytes) {
197
+ if (cidStr === commitCid.toString() && commitBlock) {
198
+ bytes = commitBlock;
199
+ }
200
+ }
188
201
  if (!bytes) {
189
202
  // Attempt to reconstruct commit block from commit_log if this is the commit cid
190
203
  if (cidStr === commitCid.toString()) {
@@ -0,0 +1,185 @@
1
+ import type { CID } from 'multiformats/cid';
2
+ import type { Env } from '../../env';
3
+ import { bumpRoot } from '../../db/repo';
4
+ import {
5
+ deleteRecordStatements,
6
+ getRecordBlobKeys,
7
+ putRecordStatements,
8
+ setRecordBlobUsageStatements,
9
+ type BlobKeyRef,
10
+ } from '../../db/dal';
11
+ import type { RepoOp } from '../../lib/firehose/frames';
12
+ import { RepoWriteError } from '../../lib/repo-write-error';
13
+ import type { MST } from '../../lib/mst';
14
+ import type { PreparedWrite, ValidationStatus } from '../../lib/repo-write-validation';
15
+ import {
16
+ collectUnstoredMstBlocks,
17
+ encodeRecordBlock,
18
+ type EncodedBlock,
19
+ } from './blockstore-ops';
20
+ import { assertBlobKeysAvailable } from './blob-refs';
21
+
22
+ export interface BatchCommitResult {
23
+ commit: {
24
+ cid: string;
25
+ rev: string;
26
+ } | null;
27
+ commitCid?: string;
28
+ rev?: string;
29
+ ops: RepoOp[];
30
+ commitData?: string;
31
+ sig?: string;
32
+ blocks?: string;
33
+ results: Array<{
34
+ $type: string;
35
+ uri?: string;
36
+ cid?: string;
37
+ validationStatus?: ValidationStatus;
38
+ }>;
39
+ dereferencedBlobKeys: BlobKeyRef[];
40
+ }
41
+
42
+ type SideEffect =
43
+ | { action: 'put'; uri: string; cid: string; record: unknown; blobKeys: string[] }
44
+ | { action: 'delete'; uri: string };
45
+
46
+ export async function applyPreparedWritesToRepo(
47
+ env: Env,
48
+ did: string,
49
+ root: MST,
50
+ writes: PreparedWrite[],
51
+ expectedCommitCid?: string | null,
52
+ ): Promise<BatchCommitResult> {
53
+ let mst = root;
54
+ const prevMstRoot = await mst.getPointer();
55
+ const ops: RepoOp[] = [];
56
+ const sideEffects: SideEffect[] = [];
57
+ const results: BatchCommitResult['results'] = [];
58
+ const recordBlocks: EncodedBlock[] = [];
59
+
60
+ for (const write of writes) {
61
+ const path = `${write.collection}/${write.rkey}`;
62
+ const uri = `at://${did}/${path}`;
63
+ if (write.action === 'delete') {
64
+ const prev = await mst.get(path);
65
+ if (!prev) throw new RepoWriteError('InvalidRequest', 'record does not exist');
66
+ mst = await mst.delete(path);
67
+ ops.push({ action: 'delete', path, cid: null, prev });
68
+ sideEffects.push({ action: 'delete', uri });
69
+ results.push({ $type: 'com.atproto.repo.applyWrites#deleteResult' });
70
+ continue;
71
+ }
72
+
73
+ const prev = await mst.get(path);
74
+ if (write.action === 'update') {
75
+ if (!prev) throw new RepoWriteError('InvalidRequest', 'record does not exist');
76
+ const recordBlock = await encodeRecordBlock(write.record);
77
+ const [recordCid] = recordBlock;
78
+ recordBlocks.push(recordBlock);
79
+ mst = await mst.update(path, recordCid);
80
+ ops.push({ action: 'update', path, cid: recordCid, prev });
81
+ addPutEffect(sideEffects, results, uri, recordCid, write);
82
+ continue;
83
+ }
84
+
85
+ if (prev) throw new RepoWriteError('InvalidRequest', 'record already exists');
86
+ const recordBlock = await encodeRecordBlock(write.record);
87
+ const [recordCid] = recordBlock;
88
+ recordBlocks.push(recordBlock);
89
+ mst = await mst.add(path, recordCid);
90
+ ops.push({ action: 'create', path, cid: recordCid });
91
+ addPutEffect(sideEffects, results, uri, recordCid, write);
92
+ }
93
+
94
+ const dereferencedBlobKeys = await findDereferencedBlobKeys(env, did, sideEffects);
95
+ const currentRoot = await mst.getPointer();
96
+ const newMstBlocks = await collectUnstoredMstBlocks(mst);
97
+ const requiredBlobKeys = sideEffects.flatMap((effect) =>
98
+ effect.action === 'put' ? effect.blobKeys : [],
99
+ );
100
+ await assertBlobKeysAvailable(env, did, requiredBlobKeys);
101
+
102
+ const { commitCid, rev, commitData, sig, blocks } = await bumpRoot(
103
+ env,
104
+ prevMstRoot ?? undefined,
105
+ currentRoot,
106
+ {
107
+ ops,
108
+ newMstBlocks: Array.from(newMstBlocks),
109
+ newRecordBlocks: recordBlocks,
110
+ expectedCommitCid,
111
+ requiredBlobKeys,
112
+ sideEffectStatements: (guard) => sideEffects.flatMap((effect) => {
113
+ if (effect.action === 'delete') {
114
+ return [
115
+ ...deleteRecordStatements(env, effect.uri, guard),
116
+ ...setRecordBlobUsageStatements(env, did, effect.uri, [], guard),
117
+ ];
118
+ }
119
+ return [
120
+ ...putRecordStatements(env, {
121
+ uri: effect.uri,
122
+ did,
123
+ cid: effect.cid,
124
+ json: JSON.stringify(effect.record),
125
+ }, guard),
126
+ ...setRecordBlobUsageStatements(env, did, effect.uri, effect.blobKeys, guard),
127
+ ];
128
+ }),
129
+ },
130
+ );
131
+
132
+ return {
133
+ commit: { cid: commitCid, rev },
134
+ commitCid,
135
+ rev,
136
+ ops,
137
+ commitData,
138
+ sig,
139
+ blocks,
140
+ results,
141
+ dereferencedBlobKeys: dereferencedBlobKeys.map((key) => ({ did, key })),
142
+ };
143
+ }
144
+
145
+ function addPutEffect(
146
+ sideEffects: SideEffect[],
147
+ results: BatchCommitResult['results'],
148
+ uri: string,
149
+ recordCid: CID,
150
+ write: Exclude<PreparedWrite, { action: 'delete' }>,
151
+ ): void {
152
+ const cid = recordCid.toString();
153
+ sideEffects.push({
154
+ action: 'put',
155
+ uri,
156
+ cid,
157
+ record: write.record,
158
+ blobKeys: write.blobKeys,
159
+ });
160
+ results.push({
161
+ $type: `com.atproto.repo.applyWrites#${write.action}Result`,
162
+ uri,
163
+ cid,
164
+ validationStatus: write.validationStatus,
165
+ });
166
+ }
167
+
168
+ async function findDereferencedBlobKeys(
169
+ env: Env,
170
+ did: string,
171
+ sideEffects: SideEffect[],
172
+ ): Promise<string[]> {
173
+ const previousBlobKeysByUri = new Map<string, string[]>();
174
+ const finalBlobKeysByUri = new Map<string, string[]>();
175
+ for (const effect of sideEffects) {
176
+ if (!previousBlobKeysByUri.has(effect.uri)) {
177
+ previousBlobKeysByUri.set(effect.uri, await getRecordBlobKeys(env, did, effect.uri));
178
+ }
179
+ finalBlobKeysByUri.set(effect.uri, effect.action === 'put' ? effect.blobKeys : []);
180
+ }
181
+ return Array.from(previousBlobKeysByUri).flatMap(([uri, previousKeys]) => {
182
+ const finalKeys = finalBlobKeysByUri.get(uri) ?? [];
183
+ return previousKeys.filter((key) => !finalKeys.includes(key));
184
+ });
185
+ }
@@ -0,0 +1,48 @@
1
+ import type { Env } from '../../env';
2
+ import { RepoWriteError } from '../../lib/repo-write-error';
3
+ import { collectBlobRefs } from '../../lib/repo-write-data';
4
+
5
+ export async function assertBlobKeysAvailable(env: Env, did: string, keys: string[]): Promise<void> {
6
+ for (const key of new Set(keys)) {
7
+ const row = await env.ALTERAN_DB.prepare(
8
+ 'SELECT cid, size FROM blob WHERE did = ? AND key = ? LIMIT 1',
9
+ )
10
+ .bind(did, key)
11
+ .first<{ cid: string; size: number }>();
12
+ if (!row) {
13
+ throw new RepoWriteError('BlobNotFound', 'blob not found');
14
+ }
15
+ const object = await env.ALTERAN_BLOBS.head(key);
16
+ if (!object || object.size !== Number(row.size)) {
17
+ throw new RepoWriteError('BlobNotFound', `blob not found: ${row.cid}`);
18
+ }
19
+ }
20
+ }
21
+
22
+ export async function resolveRecordBlobKeys(env: Env, did: string, record: unknown): Promise<string[]> {
23
+ const refs: Array<{ cid: string; mimeType: string; size: number }> = [];
24
+ collectBlobRefs(record, refs);
25
+ const keys = new Set<string>();
26
+ for (const ref of refs) {
27
+ const row = await env.ALTERAN_DB.prepare(
28
+ 'SELECT key, mime, size FROM blob WHERE did = ? AND cid = ? LIMIT 1',
29
+ )
30
+ .bind(did, ref.cid)
31
+ .first<{ key: string; mime: string; size: number }>();
32
+ if (!row) {
33
+ throw new RepoWriteError('BlobNotFound', `blob not found: ${ref.cid}`);
34
+ }
35
+ if (row.mime !== ref.mimeType) {
36
+ throw new RepoWriteError('InvalidMimeType', `blob mime type mismatch: ${ref.cid}`);
37
+ }
38
+ if (Number(row.size) !== ref.size) {
39
+ throw new RepoWriteError('InvalidSize', `blob size mismatch: ${ref.cid}`);
40
+ }
41
+ const object = await env.ALTERAN_BLOBS.head(row.key);
42
+ if (!object || object.size !== Number(row.size)) {
43
+ throw new RepoWriteError('BlobNotFound', `blob not found: ${ref.cid}`);
44
+ }
45
+ keys.add(row.key);
46
+ }
47
+ return Array.from(keys);
48
+ }
@@ -1,29 +1,71 @@
1
1
  import { CID } from 'multiformats/cid';
2
2
  import * as dagCbor from '@ipld/dag-cbor';
3
+ import { fromString as bytesFromString } from 'uint8arrays/from-string';
3
4
  import { cidForCbor } from '../../lib/mst/util';
4
- import type { D1Blockstore, MST, BlockMap } from '../../lib/mst';
5
+ import type { MST, BlockMap } from '../../lib/mst';
6
+ import { RepoWriteError } from '../../lib/repo-write-error';
5
7
 
6
- export async function storeRecord(
7
- blockstore: D1Blockstore,
8
+ export type EncodedBlock = readonly [CID, Uint8Array];
9
+
10
+ export function recordToIpld(record: unknown, depth = 0): unknown {
11
+ if (depth > 100) {
12
+ throw new RepoWriteError('InvalidRequest', 'record is too deeply nested');
13
+ }
14
+ if (Array.isArray(record)) {
15
+ return record.map((item) => recordToIpld(item, depth + 1));
16
+ }
17
+ if (!record || typeof record !== 'object') {
18
+ return record;
19
+ }
20
+ if (record instanceof Uint8Array || CID.asCID(record)) {
21
+ return record;
22
+ }
23
+
24
+ const obj = record as Record<string, unknown>;
25
+ const keys = Object.keys(obj);
26
+ if (keys.length === 1 && typeof obj.$link === 'string') {
27
+ return CID.parse(obj.$link);
28
+ }
29
+ if (keys.length === 1 && typeof obj.$bytes === 'string') {
30
+ return bytesFromString(obj.$bytes, 'base64');
31
+ }
32
+
33
+ const converted: Record<string, unknown> = Object.create(null);
34
+ for (const [key, value] of Object.entries(obj)) {
35
+ if (key === '__proto__') {
36
+ throw new RepoWriteError('InvalidRequest', 'record contains a forbidden object key');
37
+ }
38
+ converted[key] = recordToIpld(value, depth + 1);
39
+ }
40
+ return converted;
41
+ }
42
+
43
+ export async function cidForRecord(record: unknown): Promise<CID> {
44
+ try {
45
+ return cidForCbor(recordToIpld(record));
46
+ } catch (error) {
47
+ if (error instanceof RepoWriteError) throw error;
48
+ throw new RepoWriteError('InvalidRequest', 'record is not dag-cbor encodable');
49
+ }
50
+ }
51
+
52
+ export async function encodeRecordBlock(
8
53
  record: unknown,
9
- ): Promise<CID> {
10
- const bytes = dagCbor.encode(record);
11
- const cid = await cidForCbor(record);
12
- await blockstore.put(cid, bytes);
13
- return cid;
54
+ ): Promise<EncodedBlock> {
55
+ try {
56
+ const ipldRecord = recordToIpld(record);
57
+ const bytes = dagCbor.encode(ipldRecord);
58
+ const cid = await cidForCbor(ipldRecord);
59
+ return [cid, bytes] as const;
60
+ } catch (error) {
61
+ if (error instanceof RepoWriteError) throw error;
62
+ throw new RepoWriteError('InvalidRequest', 'record is not dag-cbor encodable');
63
+ }
14
64
  }
15
65
 
16
- export async function storeMstBlocks(
17
- blockstore: D1Blockstore,
66
+ export async function collectUnstoredMstBlocks(
18
67
  mst: MST,
19
68
  ): Promise<BlockMap> {
20
69
  const diff = await mst.getUnstoredBlocks();
21
- for (const [cid, bytes] of diff.blocks) {
22
- console.log(
23
- `[RepoManager] Storing new MST block: ${cid.toString()}, size: ${bytes.length}`,
24
- );
25
- await blockstore.put(cid, bytes);
26
- }
27
- console.log(`[RepoManager] Stored ${diff.blocks.size} new MST blocks`);
28
70
  return diff.blocks;
29
71
  }