@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,15 +1,34 @@
1
1
  import type { APIContext } from 'astro';
2
- import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError } from '../../lib/oauth/resource';
2
+ import { NSID, ensureValidDid } from '@atproto/syntax';
3
+ import { verifyResourceRequestHybrid, dpopResourceUnauthorized, handleResourceAuthError, insufficientScopeResponse } from '../../lib/oauth/resource';
3
4
  import { createServiceAuthToken } from '../../lib/appview';
5
+ import { canMakeRpcCall, type AuthAccessContext } from '../../lib/auth-scope';
6
+ import { PRIVILEGED_METHODS, PROTECTED_METHODS } from '../../lib/appview/auth-policy';
4
7
 
5
8
  export const prerender = false;
6
9
 
10
+ const METHOD_SCOPED_MAX_EXPIRATION_SECONDS = 60 * 60;
11
+ const METHODLESS_MAX_EXPIRATION_SECONDS = 60;
12
+
13
+ const SERVICE_AUTH_PROTECTED_METHODS: ReadonlySet<string> = new Set([
14
+ ...PROTECTED_METHODS,
15
+ 'com.atproto.admin.deleteAccount',
16
+ 'com.atproto.identity.submitPlcOperation',
17
+ 'com.atproto.repo.importRepo',
18
+ 'com.atproto.server.deleteAccount',
19
+ ]);
20
+
21
+ const APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS: ReadonlySet<string> = new Set([
22
+ 'com.atproto.server.createAccount',
23
+ ]);
24
+
7
25
  export async function GET({ locals, request }: APIContext) {
8
26
  const { env } = locals.runtime;
9
- let auth: { did: string; token: string } | null = null;
27
+ let auth: NonNullable<Awaited<ReturnType<typeof verifyResourceRequestHybrid>>>;
10
28
  try {
11
- auth = await verifyResourceRequestHybrid(env, request);
12
- if (!auth) return dpopResourceUnauthorized(env);
29
+ const verified = await verifyResourceRequestHybrid(env, request);
30
+ if (!verified) return dpopResourceUnauthorized(env);
31
+ auth = verified;
13
32
  } catch (error) {
14
33
  const handled = await handleResourceAuthError(env, error);
15
34
  if (handled) return handled;
@@ -23,35 +42,39 @@ export async function GET({ locals, request }: APIContext) {
23
42
 
24
43
  const audience = audienceParam?.trim();
25
44
  if (!audience) {
26
- return new Response(JSON.stringify({ error: 'MissingAudience' }), {
27
- status: 400,
28
- headers: { 'Content-Type': 'application/json' },
29
- });
45
+ return jsonError('MissingAudience', undefined, 400);
46
+ }
47
+ if (!isValidServiceAudience(audience)) {
48
+ return jsonError('InvalidRequest', 'aud must be a DID or DID service reference', 400);
30
49
  }
31
50
 
32
51
  const lexiconMethod = lexParam && lexParam.trim() !== '' ? lexParam.trim() : null;
52
+ if (lexiconMethod !== null && !NSID.isValid(lexiconMethod)) {
53
+ return jsonError('InvalidRequest', 'lxm must be a valid NSID', 400);
54
+ }
55
+ if (!isAccountActiveForServiceAuth(auth.access)) {
56
+ return jsonError('AccountInactive', 'Account is not active', 403);
57
+ }
58
+ if (!canIssueServiceAuth(auth.access, lexiconMethod, audience)) {
59
+ return insufficientScopeResponse();
60
+ }
33
61
 
34
62
  let expiresIn = 60;
63
+ const maxExpiresIn = lexiconMethod ? METHOD_SCOPED_MAX_EXPIRATION_SECONDS : METHODLESS_MAX_EXPIRATION_SECONDS;
35
64
  const now = Math.floor(Date.now() / 1000);
36
65
  if (expParam !== null) {
37
66
  if (!/^-?\d+$/.test(expParam)) {
38
- return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration must be an integer timestamp' }), {
39
- status: 400,
40
- headers: { 'Content-Type': 'application/json' },
41
- });
67
+ return jsonError('BadExpiration', 'expiration must be an integer timestamp', 400);
42
68
  }
43
69
  const exp = Number(expParam);
70
+ if (!Number.isSafeInteger(exp)) {
71
+ return jsonError('BadExpiration', 'expiration must be a safe integer timestamp', 400);
72
+ }
44
73
  if (exp <= now) {
45
- return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration is in the past' }), {
46
- status: 400,
47
- headers: { 'Content-Type': 'application/json' },
48
- });
74
+ return jsonError('BadExpiration', 'expiration is in the past', 400);
49
75
  }
50
- if (exp - now > 3600) {
51
- return new Response(JSON.stringify({ error: 'BadExpiration', message: 'expiration too far in future' }), {
52
- status: 400,
53
- headers: { 'Content-Type': 'application/json' },
54
- });
76
+ if (exp - now > maxExpiresIn) {
77
+ return jsonError('BadExpiration', 'expiration too far in future', 400);
55
78
  }
56
79
  expiresIn = Math.max(1, exp - now);
57
80
  }
@@ -69,3 +92,47 @@ export async function GET({ locals, request }: APIContext) {
69
92
  });
70
93
  }
71
94
  }
95
+
96
+ function canIssueServiceAuth(
97
+ access: AuthAccessContext,
98
+ lexiconMethod: string | null,
99
+ audience: string,
100
+ ): boolean {
101
+ if (lexiconMethod === null) {
102
+ return access.isFullAccess || access.isAppPassword;
103
+ }
104
+ if (SERVICE_AUTH_PROTECTED_METHODS.has(lexiconMethod)) return false;
105
+ if (access.isOAuth) return canMakeRpcCall(access, lexiconMethod, audience);
106
+ if (APP_PASSWORD_DENIED_SERVICE_AUTH_METHODS.has(lexiconMethod)) {
107
+ return access.isFullAccess;
108
+ }
109
+ if (PRIVILEGED_METHODS.has(lexiconMethod)) {
110
+ return access.isPrivileged;
111
+ }
112
+ return canMakeRpcCall(access, lexiconMethod, audience);
113
+ }
114
+
115
+ function isAccountActiveForServiceAuth(access: AuthAccessContext): boolean {
116
+ if (access.isTakendown || access.isSignupQueued) return false;
117
+ return access.accountStatus === 'active';
118
+ }
119
+
120
+ function isValidServiceAudience(audience: string): boolean {
121
+ const parts = audience.split('#');
122
+ if (parts.length > 2) return false;
123
+ const [did, fragment] = parts;
124
+ if (!did) return false;
125
+ try {
126
+ ensureValidDid(did);
127
+ } catch {
128
+ return false;
129
+ }
130
+ return fragment === undefined || /^[A-Za-z][A-Za-z0-9._:-]*$/.test(fragment);
131
+ }
132
+
133
+ function jsonError(error: string, message: string | undefined, status: number): Response {
134
+ return new Response(JSON.stringify(message ? { error, message } : { error }), {
135
+ status,
136
+ headers: { 'Content-Type': 'application/json' },
137
+ });
138
+ }
@@ -1,6 +1,7 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { authErrorResponse, authenticateRequest, unauthorized } from '../../lib/auth';
3
3
  import { getAccountByIdentifier } from '../../db/account';
4
+ import { buildDidDocument } from '../../lib/did-document';
4
5
 
5
6
  export const prerender = false;
6
7
 
@@ -27,6 +28,7 @@ export async function GET({ locals, request }: APIContext) {
27
28
  const did = authContext.claims.sub;
28
29
  const account = await getAccountByIdentifier(env, did);
29
30
  const handle = account?.handle ?? (env.PDS_HANDLE as string) ?? 'user.example.com';
31
+ const didDoc = await buildDidDocument(env, did, handle);
30
32
 
31
33
  return new Response(
32
34
  JSON.stringify({
@@ -35,19 +37,7 @@ export async function GET({ locals, request }: APIContext) {
35
37
  email: account?.email ?? (env.PDS_EMAIL as string | undefined) ?? 'user@example.com',
36
38
  emailConfirmed: true,
37
39
  emailAuthFactor: false,
38
- didDoc: {
39
- '@context': ['https://www.w3.org/ns/did/v1'],
40
- id: did,
41
- alsoKnownAs: [`at://${handle}`],
42
- verificationMethod: [],
43
- service: [
44
- {
45
- id: '#atproto_pds',
46
- type: 'AtprotoPersonalDataServer',
47
- serviceEndpoint: `https://${env.PDS_HOSTNAME ?? handle}`,
48
- },
49
- ],
50
- },
40
+ didDoc,
51
41
  }),
52
42
  {
53
43
  status: 200,
@@ -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()) {