@alteran/astro 0.3.9 → 0.6.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -30
  3. package/index.js +34 -28
  4. package/migrations/0007_bored_spitfire.sql +26 -0
  5. package/migrations/0008_furry_ozymandias.sql +2 -0
  6. package/migrations/meta/0007_snapshot.json +534 -0
  7. package/migrations/meta/0008_snapshot.json +548 -0
  8. package/migrations/meta/_journal.json +14 -0
  9. package/package.json +10 -9
  10. package/src/app.ts +8 -4
  11. package/src/db/account.ts +25 -6
  12. package/src/db/client.ts +1 -1
  13. package/src/db/dal.ts +34 -23
  14. package/src/db/repo.ts +38 -38
  15. package/src/db/schema.ts +5 -1
  16. package/src/db/seed.ts +5 -13
  17. package/src/entrypoints/server.ts +2 -22
  18. package/src/handlers/debug.ts +1 -1
  19. package/src/handlers/ready.ts +1 -1
  20. package/src/handlers/root.ts +4 -4
  21. package/src/handlers/xrpc.server.refreshSession.ts +6 -6
  22. package/src/lib/account-state.ts +156 -0
  23. package/src/lib/actor.ts +29 -13
  24. package/src/lib/appview/auth-policy.ts +66 -0
  25. package/src/lib/appview/did-resolver.ts +233 -0
  26. package/src/lib/appview/proxy.ts +221 -0
  27. package/src/lib/appview/service-config.ts +61 -0
  28. package/src/lib/appview/service-jwt.ts +93 -0
  29. package/src/lib/appview/types.ts +25 -0
  30. package/src/lib/appview.ts +5 -532
  31. package/src/lib/auth-errors.ts +24 -0
  32. package/src/lib/auth.ts +63 -15
  33. package/src/lib/blockstore-gc.ts +6 -5
  34. package/src/lib/cache.ts +30 -4
  35. package/src/lib/chat.ts +20 -14
  36. package/src/lib/commit-log-pruning.ts +2 -2
  37. package/src/lib/commit.ts +26 -36
  38. package/src/lib/config.ts +26 -15
  39. package/src/lib/did-document.ts +32 -0
  40. package/src/lib/errors.ts +54 -0
  41. package/src/lib/feed.ts +18 -19
  42. package/src/lib/firehose/frames.ts +87 -47
  43. package/src/lib/firehose/validation.ts +3 -3
  44. package/src/lib/jwt.ts +85 -177
  45. package/src/lib/labeler.ts +43 -30
  46. package/src/lib/logger.ts +4 -0
  47. package/src/lib/mst/block-map.ts +172 -0
  48. package/src/lib/mst/blockstore.ts +56 -93
  49. package/src/lib/mst/index.ts +1 -0
  50. package/src/lib/mst/leaf.ts +25 -0
  51. package/src/lib/mst/mst.ts +81 -237
  52. package/src/lib/mst/serialize.ts +97 -0
  53. package/src/lib/mst/types.ts +21 -0
  54. package/src/lib/oauth/clients.ts +67 -0
  55. package/src/lib/oauth/dpop-errors.ts +15 -0
  56. package/src/lib/oauth/dpop.ts +150 -0
  57. package/src/lib/oauth/resource.ts +199 -0
  58. package/src/lib/oauth/store.ts +77 -0
  59. package/src/lib/preferences.ts +12 -37
  60. package/src/lib/ratelimit.ts +4 -4
  61. package/src/lib/refresh-session.ts +161 -0
  62. package/src/lib/relay.ts +10 -8
  63. package/src/lib/secrets.ts +6 -7
  64. package/src/lib/sequencer.ts +14 -5
  65. package/src/lib/service-auth.ts +184 -0
  66. package/src/lib/session-tokens.ts +28 -76
  67. package/src/lib/streaming-car.ts +3 -0
  68. package/src/lib/tracing.ts +4 -3
  69. package/src/lib/util.ts +65 -15
  70. package/src/middleware.ts +1 -1
  71. package/src/pages/.well-known/did.json.ts +27 -30
  72. package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
  73. package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
  74. package/src/pages/debug/blob/[...key].ts +2 -2
  75. package/src/pages/debug/db/bootstrap.ts +1 -1
  76. package/src/pages/debug/db/commits.ts +1 -1
  77. package/src/pages/debug/gc/blobs.ts +1 -1
  78. package/src/pages/debug/record.ts +1 -1
  79. package/src/pages/debug/sequencer.ts +28 -0
  80. package/src/pages/health.ts +4 -4
  81. package/src/pages/oauth/authorize.ts +78 -0
  82. package/src/pages/oauth/consent.ts +80 -0
  83. package/src/pages/oauth/par.ts +121 -0
  84. package/src/pages/oauth/token.ts +158 -0
  85. package/src/pages/ready.ts +2 -2
  86. package/src/pages/xrpc/[...nsid].ts +61 -0
  87. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
  88. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
  89. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
  90. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
  91. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
  92. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
  93. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
  94. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
  95. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
  96. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
  97. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
  98. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
  99. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
  100. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
  101. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
  102. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
  103. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
  104. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
  105. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
  106. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
  107. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
  108. package/src/pages/xrpc/com.atproto.server.createSession.ts +32 -12
  109. package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
  110. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
  111. package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
  112. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
  113. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +72 -23
  114. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
  115. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
  116. package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
  117. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
  118. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
  119. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
  120. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
  121. package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
  122. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +2 -2
  123. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
  124. package/src/services/car.ts +209 -57
  125. package/src/services/r2-blob-store.ts +4 -4
  126. package/src/services/repo/blockstore-ops.ts +29 -0
  127. package/src/services/repo/operations.ts +133 -0
  128. package/src/services/repo-manager.ts +203 -254
  129. package/src/worker/runtime.ts +56 -11
  130. package/src/worker/sequencer/broadcast.ts +91 -0
  131. package/src/worker/sequencer/cid-helpers.ts +39 -0
  132. package/src/worker/sequencer/payload.ts +84 -0
  133. package/src/worker/sequencer/types.ts +36 -0
  134. package/src/worker/sequencer/upgrade.ts +141 -0
  135. package/src/worker/sequencer.ts +264 -406
  136. package/types/env.d.ts +18 -6
  137. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
  138. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
  139. package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
  140. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
  141. package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
  142. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
  143. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
  144. package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +0 -23
  145. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  146. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  147. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  148. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  149. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
  150. package/src/pages/xrpc/app.bsky.unspecced.getSuggestedFeeds.ts +0 -23
@@ -1,5 +1,5 @@
1
1
  import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
3
3
 
4
4
  export const prerender = false;
5
5
 
@@ -15,8 +15,15 @@ export const prerender = false;
15
15
  export async function POST({ locals, request }: APIContext) {
16
16
  const { env } = locals.runtime;
17
17
 
18
- if (!(await isAuthorized(request, env))) {
19
- return unauthorized();
18
+ try {
19
+ if (!(await isAuthorized(request, env))) {
20
+ return unauthorized();
21
+ }
22
+ } catch (error) {
23
+ if (error instanceof AuthTokenExpiredError) {
24
+ return expiredToken();
25
+ }
26
+ throw error;
20
27
  }
21
28
 
22
29
  return new Response(null, { status: 200 });
@@ -1,4 +1,5 @@
1
1
  import type { APIContext } from 'astro';
2
+ import { getAppViewConfig } from '../../lib/appview';
2
3
 
3
4
  export const prerender = false;
4
5
 
@@ -10,8 +11,8 @@ export async function GET({ locals, url }: APIContext) {
10
11
  const { env } = locals.runtime;
11
12
 
12
13
  const handle = url.searchParams.get('handle');
13
- const configuredHandle = env.PDS_HANDLE || 'user.example.com';
14
- const did = env.PDS_DID || 'did:example:single-user';
14
+ const configuredHandle = String(env.PDS_HANDLE || 'user.example.com');
15
+ const did = String(env.PDS_DID || 'did:example:single-user');
15
16
 
16
17
  if (!handle) {
17
18
  return new Response(
@@ -20,8 +21,8 @@ export async function GET({ locals, url }: APIContext) {
20
21
  );
21
22
  }
22
23
 
23
- // Single-user PDS: only resolve if handle matches configured handle
24
- if (handle === configuredHandle) {
24
+ // Single-user PDS: resolve the local configured handle directly
25
+ if (handle.toLowerCase() === configuredHandle.toLowerCase()) {
25
26
  return new Response(
26
27
  JSON.stringify({ did }),
27
28
  {
@@ -31,8 +32,38 @@ export async function GET({ locals, url }: APIContext) {
31
32
  );
32
33
  }
33
34
 
34
- return new Response(
35
- JSON.stringify({ error: 'HandleNotFound' }),
36
- { status: 404, headers: { 'Content-Type': 'application/json' } }
37
- );
38
- }
35
+ // For non-local handles, mirror upstream PDS behavior:
36
+ // proxy the resolution to the configured AppView (or api.bsky.app by default).
37
+ try {
38
+ const app = getAppViewConfig(env);
39
+ const base = app?.url || 'https://api.bsky.app';
40
+ const upstream = new URL('/xrpc/com.atproto.identity.resolveHandle', base);
41
+ upstream.searchParams.set('handle', handle);
42
+
43
+ const response = await fetch(upstream.toString(), {
44
+ headers: { accept: 'application/json' },
45
+ });
46
+
47
+ if (response.ok) {
48
+ // Pass through upstream JSON (e.g. { did })
49
+ return new Response(await response.text(), {
50
+ status: 200,
51
+ headers: { 'Content-Type': 'application/json' },
52
+ });
53
+ }
54
+
55
+ // Map upstream failures to the standard InvalidRequest shape used by PDS
56
+ const text = await response.text().catch(() => '');
57
+ const body = text ? (() => { try { return JSON.parse(text); } catch { return null; } })() : null;
58
+ const message = body?.message || 'Unable to resolve handle';
59
+ return new Response(
60
+ JSON.stringify({ error: 'InvalidRequest', message }),
61
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
62
+ );
63
+ } catch {
64
+ return new Response(
65
+ JSON.stringify({ error: 'InvalidRequest', message: 'Unable to resolve handle' }),
66
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
67
+ );
68
+ }
69
+ }
@@ -1,5 +1,5 @@
1
1
  import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
3
3
  import { resolveSecret } from '../../lib/secrets';
4
4
 
5
5
  export const prerender = false;
@@ -15,7 +15,14 @@ export const prerender = false;
15
15
  export async function POST({ locals, request }: APIContext) {
16
16
  const { env } = locals.runtime;
17
17
 
18
- if (!(await isAuthorized(request, env))) return unauthorized();
18
+ try {
19
+ if (!(await isAuthorized(request, env))) return unauthorized();
20
+ } catch (error) {
21
+ if (error instanceof AuthTokenExpiredError) {
22
+ return expiredToken();
23
+ }
24
+ throw error;
25
+ }
19
26
 
20
27
  try {
21
28
  const body = await request.json() as {
@@ -36,53 +43,58 @@ export async function POST({ locals, request }: APIContext) {
36
43
  return jsonErr(400, 'InvalidRequest', 'PDS_DID is not configured');
37
44
  }
38
45
 
39
- // Load PLC rotation key (hex-encoded secp256k1 private key).
40
- // MUST be the rotation key currently present in the PLC document.
41
- const privHex = ((await resolveSecret(env.PDS_PLC_ROTATION_KEY as any)) || '').trim();
46
+ const privHex = ((await resolveSecret(env.PDS_PLC_ROTATION_KEY)) || '').trim();
42
47
  if (!privHex) {
43
48
  return jsonErr(500, 'ServerMisconfigured', 'PDS_PLC_ROTATION_KEY is not configured');
44
49
  }
45
- // Lazy-load deps compatible with Workers runtime
46
50
  const { Secp256k1Keypair } = await import('@atproto/crypto');
47
- const dagCbor: any = await import('@ipld/dag-cbor');
51
+ const dagCbor = await import('@ipld/dag-cbor');
48
52
  const { sha256 } = await import('multiformats/hashes/sha2');
49
53
  const { CID } = await import('multiformats/cid');
50
- const u8a: any = await import('uint8arrays');
54
+ const u8a = await import('uint8arrays');
51
55
 
52
56
  const signer = await Secp256k1Keypair.import(privHex);
53
57
 
54
- // Fetch last op for prev CID
55
- const lastRes = await fetch(`https://plc.directory/${encodeURIComponent(did)}/log/last`);
56
- if (!lastRes.ok) {
57
- const text = await lastRes.text();
58
- return jsonErr(lastRes.status, 'PlcFetchFailed', `Failed to fetch last op: ${text}`);
58
+ const lastResponse = await fetch(`https://plc.directory/${encodeURIComponent(did)}/log/last`);
59
+ if (!lastResponse.ok) {
60
+ const text = await lastResponse.text();
61
+ return jsonErr(lastResponse.status, 'PlcFetchFailed', `Failed to fetch last op: ${text}`);
59
62
  }
60
- const lastOp = await lastRes.json();
61
- if ((lastOp as any)?.type === 'plc_tombstone') {
63
+ const lastOp = (await lastResponse.json()) as { type?: string };
64
+ if (lastOp?.type === 'plc_tombstone') {
62
65
  return jsonErr(400, 'DidTombstoned', 'DID is tombstoned');
63
66
  }
64
67
  const lastOpCbor = dagCbor.encode(lastOp);
65
68
  const mh = await sha256.digest(lastOpCbor);
66
69
  const prevCid = CID.createV1(dagCbor.code, mh);
67
70
 
68
- // Fetch current document data as defaults and to verify rotation key
69
- const dataRes = await fetch(`https://plc.directory/${encodeURIComponent(did)}/data`);
70
- if (!dataRes.ok) {
71
- const text = await dataRes.text();
72
- return jsonErr(dataRes.status, 'PlcFetchFailed', `Failed to fetch document data: ${text}`);
71
+ const dataResponse = await fetch(`https://plc.directory/${encodeURIComponent(did)}/data`);
72
+ if (!dataResponse.ok) {
73
+ const text = await dataResponse.text();
74
+ return jsonErr(dataResponse.status, 'PlcFetchFailed', `Failed to fetch document data: ${text}`);
73
75
  }
74
- const doc = (await dataRes.json()) as any;
76
+ type PlcDoc = {
77
+ rotationKeys?: string[];
78
+ alsoKnownAs?: string[];
79
+ verificationMethods?: Record<string, string>;
80
+ services?: Record<string, { type?: string; endpoint?: string }>;
81
+ };
82
+ const doc = (await dataResponse.json()) as PlcDoc;
75
83
 
76
84
  const rotationKeys = body.rotationKeys ?? doc.rotationKeys ?? [];
77
85
  const alsoKnownAs = body.alsoKnownAs ?? doc.alsoKnownAs ?? [];
78
86
  const verificationMethods = body.verificationMethods ?? doc.verificationMethods ?? {};
79
- const services = body.services ?? doc.services ?? {};
87
+ const services = (body.services ?? doc.services ?? {}) as Record<
88
+ string,
89
+ { type?: string; endpoint?: string }
90
+ >;
80
91
 
81
- if (!services.atproto_pds || typeof services.atproto_pds !== 'object') {
92
+ const pdsService = services.atproto_pds;
93
+ if (!pdsService || typeof pdsService !== 'object') {
82
94
  return jsonErr(400, 'InvalidRequest', 'Missing atproto_pds service in PLC operation');
83
95
  }
84
- if (!services.atproto_pds.type) {
85
- services.atproto_pds.type = 'AtprotoPersonalDataServer';
96
+ if (!pdsService.type) {
97
+ pdsService.type = 'AtprotoPersonalDataServer';
86
98
  }
87
99
 
88
100
  const unsignedOp = {
@@ -96,10 +108,9 @@ export async function POST({ locals, request }: APIContext) {
96
108
 
97
109
  const bytes = dagCbor.encode(unsignedOp);
98
110
  const sig = await signer.sign(bytes);
99
- const sigB64 = (u8a.toString as any)(sig, 'base64url');
111
+ const sigB64 = u8a.toString(sig, 'base64url');
100
112
  const operation = { ...unsignedOp, sig: sigB64 };
101
113
 
102
- // sanity: ensure our configured rotation key is included
103
114
  const signerDid = (await (await import('@atproto/crypto')).Secp256k1Keypair.import(privHex)).did();
104
115
  if (!rotationKeys.includes(signerDid)) {
105
116
  return jsonErr(
@@ -113,9 +124,10 @@ export async function POST({ locals, request }: APIContext) {
113
124
  status: 200,
114
125
  headers: { 'Content-Type': 'application/json' },
115
126
  });
116
- } catch (error: any) {
127
+ } catch (error) {
117
128
  console.error('signPlcOperation error:', error);
118
- return jsonErr(500, 'InternalServerError', error?.message || 'Failed to sign PLC operation');
129
+ const message = error instanceof Error ? error.message : 'Failed to sign PLC operation';
130
+ return jsonErr(500, 'InternalServerError', message);
119
131
  }
120
132
  }
121
133
 
@@ -1,5 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
- import { isAuthorized, unauthorized } from '../../lib/auth';
2
+ import { errorMessage } from '../../lib/errors';
3
+ import { AuthTokenExpiredError, expiredToken, isAuthorized, unauthorized } from '../../lib/auth';
3
4
  import { resolveSecret } from '../../lib/secrets';
4
5
 
5
6
  export const prerender = false;
@@ -14,7 +15,14 @@ export const prerender = false;
14
15
  export async function POST({ locals, request }: APIContext) {
15
16
  const { env } = locals.runtime;
16
17
 
17
- if (!(await isAuthorized(request, env))) return unauthorized();
18
+ try {
19
+ if (!(await isAuthorized(request, env))) return unauthorized();
20
+ } catch (error) {
21
+ if (error instanceof AuthTokenExpiredError) {
22
+ return expiredToken();
23
+ }
24
+ throw error;
25
+ }
18
26
 
19
27
  try {
20
28
  const body = await request.json() as { operation?: any };
@@ -30,7 +38,13 @@ export async function POST({ locals, request }: APIContext) {
30
38
  );
31
39
  }
32
40
 
33
- const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
41
+ const did = await resolveSecret(env.PDS_DID);
42
+ if (!did) {
43
+ return new Response(
44
+ JSON.stringify({ error: 'InvalidRequest', message: 'PDS_DID is not configured' }),
45
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
46
+ );
47
+ }
34
48
 
35
49
  console.log('Submitting PLC operation:', {
36
50
  did,
@@ -78,14 +92,14 @@ export async function POST({ locals, request }: APIContext) {
78
92
  JSON.stringify({ success: true }),
79
93
  { status: 200, headers: { 'Content-Type': 'application/json' } }
80
94
  );
81
- } catch (error: any) {
95
+ } catch (error) {
82
96
  console.error('Submit PLC operation error:', error);
83
97
  return new Response(
84
98
  JSON.stringify({
85
99
  error: 'InternalServerError',
86
- message: error.message || 'Failed to submit PLC operation'
100
+ message: errorMessage(error) || 'Failed to submit PLC operation'
87
101
  }),
88
102
  { status: 500, headers: { 'Content-Type': 'application/json' } }
89
103
  );
90
104
  }
91
- }
105
+ }
@@ -11,7 +11,7 @@ export async function POST({ locals, request }: APIContext) {
11
11
  const { env } = locals.runtime;
12
12
 
13
13
  try {
14
- const body = await readJson(request);
14
+ const body = (await readJson(request)) as { handle?: string };
15
15
  const { handle } = body;
16
16
 
17
17
  if (!handle) {
@@ -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 { isAuthorized, unauthorized } from '../../lib/auth';
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
- if (!(await isAuthorized(request, env))) return unauthorized();
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 ?? 'did:example:single-user';
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 results = [];
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 write of writes) {
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
- uri: `at://${repo}/${collection}/${rkey}`,
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 { commitCid, rev } = await bumpRoot(env);
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 { isAuthorized, unauthorized } from '../../lib/auth';
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
- if (!(await isAuthorized(request, env))) return unauthorized();
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: any) {
21
- if (e?.code === 'PayloadTooLarge') {
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, record } = body ?? {};
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 commit = await repo.createRecord(collection, record, rkey);
49
+ const result = await repo.createRecord(collection, record, rkey);
31
50
  await notifySequencer(env, {
32
- did: env.PDS_DID ?? 'did:example:single-user',
33
- commitCid: commit.commitCid,
34
- rev: commit.rev,
35
- data: commit.commitData,
36
- sig: commit.sig,
37
- ops: commit.ops,
38
- blocks: commit.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
- return new Response(JSON.stringify(commit), {
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 { isAuthorized, unauthorized } from '../../lib/auth';
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
- if (!(await isAuthorized(request, env))) return unauthorized();
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: any) {
21
- if (e?.code === 'PayloadTooLarge') {
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
- const commit = await repo.deleteRecord(collection, rkey);
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 ?? 'did:example:single-user',
33
- commitCid: commit.commitCid,
34
- rev: commit.rev,
35
- data: commit.commitData,
36
- sig: commit.sig,
37
- ops: commit.ops,
38
- blocks: commit.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
- return new Response(JSON.stringify(commit), {
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 || 'did:example:single-user';
14
- const did = env.PDS_DID || 'did:example:single-user';
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