@alteran/astro 0.7.7 → 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 (73) 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/resource.ts +49 -0
  24. package/src/lib/preference-policy.ts +45 -0
  25. package/src/lib/preferences.ts +0 -4
  26. package/src/lib/public-host.ts +127 -0
  27. package/src/lib/ratelimit.ts +37 -12
  28. package/src/lib/relay.ts +7 -27
  29. package/src/lib/repo-write-blob-constraints.ts +141 -0
  30. package/src/lib/repo-write-data.ts +195 -0
  31. package/src/lib/repo-write-error.ts +46 -0
  32. package/src/lib/repo-write-validation.ts +463 -0
  33. package/src/lib/session-tokens.ts +22 -5
  34. package/src/lib/unsupported-routes.ts +32 -0
  35. package/src/lib/util.ts +57 -2
  36. package/src/pages/.well-known/atproto-did.ts +15 -3
  37. package/src/pages/.well-known/did.json.ts +13 -7
  38. package/src/pages/debug/db/bootstrap.ts +4 -3
  39. package/src/pages/debug/gc/blobs.ts +11 -8
  40. package/src/pages/debug/record.ts +11 -0
  41. package/src/pages/xrpc/[...nsid].ts +17 -9
  42. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
  43. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
  44. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
  45. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
  46. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
  47. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
  48. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
  49. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
  50. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
  51. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
  52. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
  53. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
  54. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
  55. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
  56. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
  57. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
  58. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
  59. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
  60. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
  61. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
  62. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
  63. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
  64. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
  65. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
  66. package/src/services/car.ts +13 -0
  67. package/src/services/repo/apply-prepared-writes.ts +185 -0
  68. package/src/services/repo/blob-refs.ts +48 -0
  69. package/src/services/repo/blockstore-ops.ts +59 -17
  70. package/src/services/repo/list-blobs.ts +43 -0
  71. package/src/services/repo-manager.ts +221 -78
  72. package/src/worker/runtime.ts +1 -1
  73. package/src/worker/sequencer/upgrade.ts +4 -1
@@ -1,14 +1,20 @@
1
1
  import type { APIContext } from 'astro';
2
- import { RepoManager } from '../../services/repo-manager';
3
- import { readJson } from '../../lib/util';
4
- import { bumpRoot } from '../../db/repo';
5
- import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
6
- import { isAccountActive } from '../../db/dal';
2
+ import { errorCode } from '../../lib/errors';
3
+ import { readJsonBounded } from '../../lib/util';
4
+ import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
5
+ import { canWriteRepo } from '../../lib/auth-scope';
6
+ import { deleteUnreferencedBlobKeys, isAccountActive, sweepEligibleUnreferencedBlobKeys } from '../../db/dal';
7
7
  import { checkRate } from '../../lib/ratelimit';
8
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';
9
+ import {
10
+ applyWritesAuthorizations,
11
+ assertRepoWriteInput,
12
+ handleRepoWriteError,
13
+ jsonError,
14
+ prepareApplyWrites,
15
+ RepoWriteError,
16
+ retryNoSwapCommit,
17
+ } from '../../lib/repo-write-validation';
12
18
 
13
19
  export const prerender = false;
14
20
 
@@ -17,155 +23,92 @@ export const prerender = false;
17
23
  * Apply a batch of repository writes atomically
18
24
  */
19
25
  export async function POST({ locals, request }: APIContext) {
20
- const { env } = locals.runtime;
26
+ const { env, ctx } = locals.runtime;
27
+ let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
21
28
  try {
22
- const auth = await verifyResourceRequestHybrid(env, request);
23
- if (!auth) return dpopResourceUnauthorized(env);
29
+ const verified = await verifyResourceRequestHybrid(env, request);
30
+ if (!verified) return dpopResourceUnauthorized(env);
31
+ auth = verified;
24
32
  } catch (error) {
25
33
  const handled = await handleResourceAuthError(env, error);
26
34
  if (handled) return handled;
27
35
  throw error;
28
36
  }
29
37
 
30
- // Check if account is active
31
- const did = env.PDS_DID as string;
32
- const active = await isAccountActive(env, did);
33
- if (!active) {
34
- return new Response(
35
- JSON.stringify({
36
- error: 'AccountDeactivated',
37
- message: 'Account is deactivated. Activate it before making changes.'
38
- }),
39
- { status: 403, headers: { 'Content-Type': 'application/json' } }
40
- );
38
+ let body: unknown;
39
+ try {
40
+ body = await readJsonBounded(env, request);
41
+ } catch (error) {
42
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
43
+ if (rateLimitResponse) return rateLimitResponse;
44
+ if (errorCode(error) === 'PayloadTooLarge') {
45
+ return jsonError('PayloadTooLarge', undefined, 413);
46
+ }
47
+ return jsonError('BadRequest');
41
48
  }
42
49
 
43
- const rateLimitResponse = await checkRate(env, request, 'writes');
44
- if (rateLimitResponse) return rateLimitResponse;
45
-
50
+ let writeRateCharged = false;
46
51
  try {
47
- const body = (await readJson(request)) as {
48
- repo?: string;
49
- writes?: unknown[];
50
- validate?: boolean;
51
- swapCommit?: string;
52
- };
53
- const { repo, writes, validate = true, swapCommit } = body;
54
-
55
- if (!writes || !Array.isArray(writes)) {
56
- return new Response(
57
- JSON.stringify({ error: 'InvalidRequest', message: 'writes must be an array' }),
58
- { status: 400, headers: { 'Content-Type': 'application/json' } }
59
- );
52
+ const input = assertRepoWriteInput('com.atproto.repo.applyWrites', body);
53
+ const writes = Array.isArray(input.writes) ? input.writes : [];
54
+ if (writes.length > 200) {
55
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
56
+ writeRateCharged = true;
57
+ if (rateLimitResponse) return rateLimitResponse;
58
+ return jsonError('InvalidRequest', 'Too many writes. Max: 200');
59
+ }
60
+ for (const write of applyWritesAuthorizations(input)) {
61
+ if (!canWriteRepo(auth.access, write.collection, write.action)) return insufficientScopeResponse();
60
62
  }
61
63
 
62
- const repoManager = new RepoManager(env);
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;
71
-
72
- type WriteOperation = {
73
- $type?: string;
74
- collection?: string;
75
- rkey?: string;
76
- value?: Record<string, unknown>;
77
- };
78
- // Apply all writes atomically
79
- for (const rawWrite of writes) {
80
- const write = rawWrite as WriteOperation;
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
- }
64
+ const rateLimitResponse = await checkRate(env, request, 'writes', {
65
+ key: auth.did,
66
+ cost: writes.length,
67
+ });
68
+ writeRateCharged = true;
69
+ if (rateLimitResponse) return rateLimitResponse;
91
70
 
92
- if ($type === 'com.atproto.repo.applyWrites#create') {
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
- });
105
- results.push({
106
- $type: 'com.atproto.repo.applyWrites#createResult',
107
- uri: `at://${repo}/${collection}/${rkey}`,
108
- cid: recordCid.toString(),
109
- validationStatus: 'valid',
110
- });
111
- } else if ($type === 'com.atproto.repo.applyWrites#update') {
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
- });
123
- results.push({
124
- $type: 'com.atproto.repo.applyWrites#updateResult',
125
- uri: `at://${repo}/${collection}/${rkey}`,
126
- cid: recordCid.toString(),
127
- validationStatus: 'valid',
128
- });
129
- } else if ($type === 'com.atproto.repo.applyWrites#delete') {
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]);
135
- results.push({
136
- $type: 'com.atproto.repo.applyWrites#deleteResult',
137
- });
138
- }
71
+ // Check if account is active
72
+ const active = await isAccountActive(env, auth.did);
73
+ if (!active) {
74
+ return new Response(
75
+ JSON.stringify({
76
+ error: 'AccountDeactivated',
77
+ message: 'Account is deactivated. Activate it before making changes.'
78
+ }),
79
+ { status: 403, headers: { 'Content-Type': 'application/json' } }
80
+ );
139
81
  }
140
82
 
141
- // Bump repo root to create new commit
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,
83
+ const { prepared, applied } = await retryNoSwapCommit(input, async () => {
84
+ const prepared = await prepareApplyWrites(env, auth, input);
85
+ const applied = await prepared.repo.applyPreparedWrites(prepared.writes, prepared.expectedCommitCid);
86
+ return { prepared, applied };
146
87
  });
147
88
 
148
89
  // Notify sequencer about the commit for firehose
149
- try {
150
- // Prefer commitData/sig/blocks returned by bumpRoot (authoritative)
90
+ if (applied.commit && applied.commitCid && applied.rev && applied.commitData && applied.sig && applied.blocks) {
151
91
  await notifySequencer(env, {
152
- did: pdsDid,
153
- commitCid,
154
- rev,
155
- data: commitData,
156
- sig,
157
- ops: opsForCommit,
158
- ...(blocks ? { blocks } : {}),
92
+ did: prepared.did,
93
+ commitCid: applied.commitCid,
94
+ rev: applied.rev,
95
+ data: applied.commitData,
96
+ sig: applied.sig,
97
+ ops: applied.ops,
98
+ blocks: applied.blocks,
99
+ });
100
+ await deleteUnreferencedBlobKeys(env, applied.dereferencedBlobKeys).catch((error) => {
101
+ console.warn('[applyWrites] Failed to clean dereferenced blobs:', error);
159
102
  });
160
- } catch (error) {
161
- console.error('Failed to notify sequencer:', error);
162
- // Don't fail the request if sequencer notification fails
103
+ ctx?.waitUntil(sweepEligibleUnreferencedBlobKeys(env).catch((error) => {
104
+ console.warn('[applyWrites] Failed to sweep dereferenced blobs:', error);
105
+ }));
163
106
  }
164
107
 
165
108
  return new Response(
166
109
  JSON.stringify({
167
- commit: { cid: commitCid, rev },
168
- results,
110
+ ...(applied.commit ? { commit: applied.commit } : {}),
111
+ results: applied.results,
169
112
  }),
170
113
  {
171
114
  status: 200,
@@ -173,11 +116,19 @@ export async function POST({ locals, request }: APIContext) {
173
116
  }
174
117
  );
175
118
  } catch (error) {
176
- console.error('applyWrites error:', error);
177
- console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');
178
- return new Response(
179
- JSON.stringify({ error: 'InternalServerError', message: String(error) }),
180
- { status: 500, headers: { 'Content-Type': 'application/json' } }
181
- );
119
+ if (!writeRateCharged && error instanceof RepoWriteError) {
120
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
121
+ if (rateLimitResponse) return rateLimitResponse;
122
+ }
123
+ try {
124
+ return handleRepoWriteError(error);
125
+ } catch (unhandled) {
126
+ console.error('applyWrites error:', unhandled);
127
+ console.error('Error stack:', unhandled instanceof Error ? unhandled.stack : 'No stack');
128
+ return new Response(
129
+ JSON.stringify({ error: 'InternalServerError', message: String(unhandled) }),
130
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
131
+ );
132
+ }
182
133
  }
183
134
  }
@@ -1,74 +1,101 @@
1
1
  import type { APIContext } from 'astro';
2
- import { errorCode, errorMessage } from '../../lib/errors';
3
- import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
2
+ import { errorCode } from '../../lib/errors';
3
+ import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
4
+ import { canWriteRepo } from '../../lib/auth-scope';
4
5
  import { checkRate } from '../../lib/ratelimit';
5
6
  import { readJsonBounded } from '../../lib/util';
6
- import { RepoManager } from '../../services/repo-manager';
7
7
  import { notifySequencer } from '../../lib/sequencer';
8
+ import { isAccountActive } from '../../db/dal';
9
+ import {
10
+ assertRepoWriteInput,
11
+ createRecordAuthorizations,
12
+ handleRepoWriteError,
13
+ jsonError,
14
+ prepareCreateRecord,
15
+ RepoWriteError,
16
+ retryNoSwapCommit,
17
+ } from '../../lib/repo-write-validation';
8
18
 
9
19
  export const prerender = false;
10
20
 
11
21
  export async function POST({ locals, request }: APIContext) {
12
22
  const { env } = locals.runtime;
23
+ let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
13
24
  try {
14
- const auth = await verifyResourceRequestHybrid(env, request);
15
- if (!auth) return dpopResourceUnauthorized(env);
25
+ const verified = await verifyResourceRequestHybrid(env, request);
26
+ if (!verified) return dpopResourceUnauthorized(env);
27
+ auth = verified;
16
28
  } catch (error) {
17
29
  const handled = await handleResourceAuthError(env, error);
18
30
  if (handled) return handled;
19
31
  throw error;
20
32
  }
21
33
 
22
- const rateLimitResponse = await checkRate(env, request, 'writes');
23
- if (rateLimitResponse) return rateLimitResponse;
24
-
25
- let body: any;
34
+ let body: unknown;
26
35
  try {
27
36
  body = await readJsonBounded(env, request);
28
- } catch (e) {
29
- if (errorCode(e) === 'PayloadTooLarge') {
30
- return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
37
+ } catch (error) {
38
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
39
+ if (rateLimitResponse) return rateLimitResponse;
40
+ if (errorCode(error) === 'PayloadTooLarge') {
41
+ return jsonError('PayloadTooLarge', undefined, 413);
31
42
  }
32
- return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
43
+ return jsonError('BadRequest');
33
44
  }
34
- const { collection, rkey } = body ?? {};
35
- let { record } = body ?? {};
36
- if (!collection || !record) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
37
45
 
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();
46
+ let writeRateCharged = false;
47
+ try {
48
+ const input = assertRepoWriteInput('com.atproto.repo.createRecord', body);
49
+ for (const write of createRecordAuthorizations(input)) {
50
+ if (!canWriteRepo(auth.access, write.collection, write.action)) return insufficientScopeResponse();
45
51
  }
46
- }
47
52
 
48
- const repo = new RepoManager(env);
49
- const result = await repo.createRecord(collection, record, rkey);
50
- await notifySequencer(env, {
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
58
- });
53
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
54
+ writeRateCharged = true;
55
+ if (rateLimitResponse) return rateLimitResponse;
59
56
 
60
- // Conform to official PDS response schema
61
- const out = {
62
- uri: result.uri,
63
- cid: result.cid,
64
- commit: {
65
- cid: result.commitCid,
57
+ if (!(await isAccountActive(env, auth.did))) {
58
+ return jsonError('AccountDeactivated', 'Account is deactivated. Activate it before making changes.', 403);
59
+ }
60
+
61
+ const { prepared, result } = await retryNoSwapCommit(input, async () => {
62
+ const prepared = await prepareCreateRecord(env, auth, input);
63
+ const { write, repo } = prepared;
64
+ const result = await repo.createRecord(
65
+ write.collection,
66
+ write.record,
67
+ write.rkey,
68
+ write.blobKeys,
69
+ prepared.expectedCommitCid,
70
+ );
71
+ return { prepared, result };
72
+ });
73
+ await notifySequencer(env, {
74
+ did: prepared.did,
75
+ commitCid: result.commitCid,
66
76
  rev: result.rev,
67
- },
68
- validationStatus: 'unknown' as const,
69
- };
77
+ data: result.commitData,
78
+ sig: result.sig,
79
+ ops: result.ops,
80
+ blocks: result.blocks
81
+ });
70
82
 
71
- return new Response(JSON.stringify(out), {
72
- headers: { 'Content-Type': 'application/json' },
73
- });
83
+ return new Response(JSON.stringify({
84
+ uri: result.uri,
85
+ cid: result.cid,
86
+ commit: {
87
+ cid: result.commitCid,
88
+ rev: result.rev,
89
+ },
90
+ validationStatus: prepared.write.validationStatus,
91
+ }), {
92
+ headers: { 'Content-Type': 'application/json' },
93
+ });
94
+ } catch (error) {
95
+ if (!writeRateCharged && error instanceof RepoWriteError) {
96
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
97
+ if (rateLimitResponse) return rateLimitResponse;
98
+ }
99
+ return handleRepoWriteError(error);
100
+ }
74
101
  }
@@ -1,72 +1,145 @@
1
1
  import type { APIContext } from 'astro';
2
- import { errorCode, errorMessage } from '../../lib/errors';
3
- import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
2
+ import { errorCode } from '../../lib/errors';
3
+ import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
4
+ import { canWriteRepo } from '../../lib/auth-scope';
4
5
  import { checkRate } from '../../lib/ratelimit';
5
6
  import { readJsonBounded } from '../../lib/util';
6
- import { RepoManager } from '../../services/repo-manager';
7
- import { bumpRoot } from '../../db/repo';
7
+ import { assertRepoHead, bumpRoot } from '../../db/repo';
8
8
  import { notifySequencer } from '../../lib/sequencer';
9
+ import {
10
+ deleteRecordStatements,
11
+ deleteUnreferencedBlobKeys,
12
+ getRecordBlobKeys,
13
+ isAccountActive,
14
+ setRecordBlobUsageStatements,
15
+ sweepEligibleUnreferencedBlobKeys,
16
+ } from '../../db/dal';
17
+ import {
18
+ assertRepoWriteInput,
19
+ deleteRecordAuthorizations,
20
+ handleRepoWriteError,
21
+ jsonError,
22
+ prepareDeleteRecord,
23
+ RepoWriteError,
24
+ retryNoSwapCommit,
25
+ } from '../../lib/repo-write-validation';
9
26
 
10
27
  export const prerender = false;
11
28
 
12
29
  export async function POST({ locals, request }: APIContext) {
13
- const { env } = locals.runtime;
30
+ const { env, ctx } = locals.runtime;
31
+ let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
14
32
  try {
15
- const auth = await verifyResourceRequestHybrid(env, request);
16
- if (!auth) return dpopResourceUnauthorized(env);
33
+ const verified = await verifyResourceRequestHybrid(env, request);
34
+ if (!verified) return dpopResourceUnauthorized(env);
35
+ auth = verified;
17
36
  } catch (error) {
18
37
  const handled = await handleResourceAuthError(env, error);
19
38
  if (handled) return handled;
20
39
  throw error;
21
40
  }
22
41
 
23
- const rateLimitResponse = await checkRate(env, request, 'writes');
24
- if (rateLimitResponse) return rateLimitResponse;
25
-
26
- let body: any;
42
+ let body: unknown;
27
43
  try {
28
44
  body = await readJsonBounded(env, request);
29
- } catch (e) {
30
- if (errorCode(e) === 'PayloadTooLarge') {
31
- return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
45
+ } catch (error) {
46
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
47
+ if (rateLimitResponse) return rateLimitResponse;
48
+ if (errorCode(error) === 'PayloadTooLarge') {
49
+ return jsonError('PayloadTooLarge', undefined, 413);
32
50
  }
33
- return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
51
+ return jsonError('BadRequest');
34
52
  }
35
- const { collection, rkey } = body ?? {};
36
- if (!collection || !rkey) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
37
53
 
38
- const repo = new RepoManager(env);
39
- // Perform the delete in the MST, gather prev/new roots & new blocks
40
- const { mst, prevMstRoot, uri, newMstBlocks } = await repo.deleteRecord(collection, rkey);
54
+ let writeRateCharged = false;
55
+ try {
56
+ const input = assertRepoWriteInput('com.atproto.repo.deleteRecord', body);
57
+ for (const write of deleteRecordAuthorizations(input)) {
58
+ if (!canWriteRepo(auth.access, write.collection, write.action)) return insufficientScopeResponse();
59
+ }
60
+
61
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
62
+ writeRateCharged = true;
63
+ if (rateLimitResponse) return rateLimitResponse;
41
64
 
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
- });
65
+ if (!(await isAccountActive(env, auth.did))) {
66
+ return jsonError('AccountDeactivated', 'Account is deactivated. Activate it before making changes.', 403);
67
+ }
49
68
 
50
- // Notify sequencer with a complete payload matching handleCommitNotification
51
- await notifySequencer(env, {
52
- did: env.PDS_DID as string,
53
- commitCid,
54
- rev,
55
- data: commitData,
56
- sig,
57
- ops: opsForCommit,
58
- blocks,
59
- });
69
+ const committed = await retryNoSwapCommit(input, async () => {
70
+ const prepared = await prepareDeleteRecord(env, auth, input);
71
+ const { write, repo } = prepared;
72
+ if (!prepared.currentCid) {
73
+ await assertRepoHead(env, prepared.did, prepared.expectedCommitCid);
74
+ return { prepared, result: null };
75
+ }
60
76
 
61
- // Respond with official schema
62
- const out = {
63
- commit: {
64
- cid: commitCid,
77
+ const { mst, prevMstRoot, uri, newMstBlocks, currentCid } = await repo.deleteRecord(
78
+ write.collection,
79
+ write.rkey,
80
+ );
81
+ const previousBlobKeys = await getRecordBlobKeys(env, prepared.did, uri);
82
+ const currentRoot = await mst.getPointer();
83
+ const opsForCommit = [{
84
+ action: 'delete' as const,
85
+ path: `${write.collection}/${write.rkey}`,
86
+ cid: null,
87
+ prev: currentCid,
88
+ }];
89
+ const result = await bumpRoot(env, prevMstRoot ?? undefined, currentRoot, {
90
+ ops: opsForCommit,
91
+ newMstBlocks: Array.from(newMstBlocks),
92
+ expectedCommitCid: prepared.expectedCommitCid,
93
+ sideEffectStatements: (guard) => [
94
+ ...deleteRecordStatements(env, uri, guard),
95
+ ...setRecordBlobUsageStatements(env, prepared.did, uri, [], guard),
96
+ ],
97
+ });
98
+ return {
99
+ prepared,
100
+ result: {
101
+ ...result,
102
+ opsForCommit,
103
+ dereferencedBlobKeys: previousBlobKeys.map((key) => ({ did: prepared.did, key })),
104
+ },
105
+ };
106
+ });
107
+ if (!committed.result) {
108
+ return new Response(JSON.stringify({}), {
109
+ headers: { 'Content-Type': 'application/json' },
110
+ });
111
+ }
112
+ const { prepared, result } = committed;
113
+ const { commitCid, rev, commitData, sig, blocks, opsForCommit } = result;
114
+ await notifySequencer(env, {
115
+ did: prepared.did,
116
+ commitCid,
65
117
  rev,
66
- },
67
- };
118
+ data: commitData,
119
+ sig,
120
+ ops: opsForCommit,
121
+ blocks,
122
+ });
123
+ await deleteUnreferencedBlobKeys(env, result.dereferencedBlobKeys).catch((error) => {
124
+ console.warn('[deleteRecord] Failed to clean dereferenced blobs:', error);
125
+ });
126
+ ctx?.waitUntil(sweepEligibleUnreferencedBlobKeys(env).catch((error) => {
127
+ console.warn('[deleteRecord] Failed to sweep dereferenced blobs:', error);
128
+ }));
68
129
 
69
- return new Response(JSON.stringify(out), {
70
- headers: { 'Content-Type': 'application/json' },
71
- });
130
+ return new Response(JSON.stringify({
131
+ commit: {
132
+ cid: commitCid,
133
+ rev,
134
+ },
135
+ }), {
136
+ headers: { 'Content-Type': 'application/json' },
137
+ });
138
+ } catch (error) {
139
+ if (!writeRateCharged && error instanceof RepoWriteError) {
140
+ const rateLimitResponse = await checkRate(env, request, 'writes', { key: auth.did });
141
+ if (rateLimitResponse) return rateLimitResponse;
142
+ }
143
+ return handleRepoWriteError(error);
144
+ }
72
145
  }