@alteran/astro 0.3.9 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) 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/dal.ts +34 -23
  13. package/src/db/repo.ts +35 -35
  14. package/src/db/schema.ts +5 -1
  15. package/src/db/seed.ts +5 -13
  16. package/src/entrypoints/server.ts +2 -22
  17. package/src/handlers/root.ts +4 -4
  18. package/src/lib/account-state.ts +156 -0
  19. package/src/lib/actor.ts +28 -12
  20. package/src/lib/appview/auth-policy.ts +66 -0
  21. package/src/lib/appview/did-resolver.ts +233 -0
  22. package/src/lib/appview/proxy.ts +221 -0
  23. package/src/lib/appview/service-config.ts +61 -0
  24. package/src/lib/appview/service-jwt.ts +93 -0
  25. package/src/lib/appview/types.ts +25 -0
  26. package/src/lib/appview.ts +5 -532
  27. package/src/lib/auth-errors.ts +24 -0
  28. package/src/lib/auth.ts +63 -15
  29. package/src/lib/blockstore-gc.ts +2 -1
  30. package/src/lib/cache.ts +30 -4
  31. package/src/lib/chat.ts +14 -8
  32. package/src/lib/commit.ts +26 -36
  33. package/src/lib/config.ts +26 -15
  34. package/src/lib/did-document.ts +32 -0
  35. package/src/lib/errors.ts +54 -0
  36. package/src/lib/feed.ts +18 -19
  37. package/src/lib/firehose/frames.ts +87 -47
  38. package/src/lib/firehose/validation.ts +3 -3
  39. package/src/lib/jwt.ts +85 -177
  40. package/src/lib/labeler.ts +43 -30
  41. package/src/lib/logger.ts +4 -0
  42. package/src/lib/mst/block-map.ts +172 -0
  43. package/src/lib/mst/blockstore.ts +56 -93
  44. package/src/lib/mst/index.ts +1 -0
  45. package/src/lib/mst/leaf.ts +25 -0
  46. package/src/lib/mst/mst.ts +81 -237
  47. package/src/lib/mst/serialize.ts +97 -0
  48. package/src/lib/mst/types.ts +21 -0
  49. package/src/lib/oauth/clients.ts +67 -0
  50. package/src/lib/oauth/dpop-errors.ts +15 -0
  51. package/src/lib/oauth/dpop.ts +150 -0
  52. package/src/lib/oauth/resource.ts +199 -0
  53. package/src/lib/oauth/store.ts +77 -0
  54. package/src/lib/preferences.ts +9 -34
  55. package/src/lib/refresh-session.ts +161 -0
  56. package/src/lib/relay.ts +10 -8
  57. package/src/lib/secrets.ts +6 -7
  58. package/src/lib/sequencer.ts +12 -3
  59. package/src/lib/service-auth.ts +184 -0
  60. package/src/lib/session-tokens.ts +28 -76
  61. package/src/lib/streaming-car.ts +3 -0
  62. package/src/lib/tracing.ts +4 -3
  63. package/src/lib/util.ts +65 -15
  64. package/src/middleware.ts +1 -1
  65. package/src/pages/.well-known/did.json.ts +27 -30
  66. package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
  67. package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
  68. package/src/pages/debug/record.ts +1 -1
  69. package/src/pages/debug/sequencer.ts +28 -0
  70. package/src/pages/oauth/authorize.ts +78 -0
  71. package/src/pages/oauth/consent.ts +80 -0
  72. package/src/pages/oauth/par.ts +121 -0
  73. package/src/pages/oauth/token.ts +158 -0
  74. package/src/pages/xrpc/[...nsid].ts +61 -0
  75. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
  76. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
  77. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
  78. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
  79. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
  80. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
  81. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
  82. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
  83. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
  84. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
  85. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
  86. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
  87. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
  88. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
  89. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
  90. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
  91. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
  92. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
  93. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
  94. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
  95. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
  96. package/src/pages/xrpc/com.atproto.server.createSession.ts +31 -11
  97. package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
  98. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
  99. package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
  100. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
  101. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +71 -22
  102. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
  103. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
  104. package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
  105. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
  106. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
  107. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
  108. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
  109. package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
  110. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +1 -1
  111. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
  112. package/src/services/car.ts +207 -55
  113. package/src/services/r2-blob-store.ts +1 -1
  114. package/src/services/repo/blockstore-ops.ts +29 -0
  115. package/src/services/repo/operations.ts +133 -0
  116. package/src/services/repo-manager.ts +202 -253
  117. package/src/worker/runtime.ts +53 -8
  118. package/src/worker/sequencer/broadcast.ts +91 -0
  119. package/src/worker/sequencer/cid-helpers.ts +39 -0
  120. package/src/worker/sequencer/payload.ts +84 -0
  121. package/src/worker/sequencer/types.ts +36 -0
  122. package/src/worker/sequencer/upgrade.ts +141 -0
  123. package/src/worker/sequencer.ts +263 -405
  124. package/types/env.d.ts +15 -3
  125. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
  126. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
  127. package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
  128. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
  129. package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
  130. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
  131. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
  132. package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +0 -23
  133. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  134. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  135. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  136. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  137. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
  138. package/src/pages/xrpc/app.bsky.unspecced.getSuggestedFeeds.ts +0 -23
package/src/db/account.ts CHANGED
@@ -26,7 +26,8 @@ export async function getAccountByIdentifier(env: Env, identifier: string): Prom
26
26
  if (ident.handle) clauses.push(eq(account.handle, ident.handle));
27
27
  if (clauses.length === 0) return null;
28
28
  const where = clauses.length === 1 ? clauses[0] : or(...clauses);
29
- return db.select().from(account).where(where).get();
29
+ const row = await db.select().from(account).where(where).get();
30
+ return row ?? null;
30
31
  }
31
32
 
32
33
  export async function createAccount(env: Env, data: {
@@ -105,7 +106,12 @@ export async function markRefreshTokenRotated(env: Env, id: string, nextId: stri
105
106
 
106
107
  export async function getRefreshToken(env: Env, id: string): Promise<RefreshTokenRow | null> {
107
108
  const db = getDb(env);
108
- return db.select().from(refresh_token_store).where(eq(refresh_token_store.id, id)).get();
109
+ const row = await db
110
+ .select()
111
+ .from(refresh_token_store)
112
+ .where(eq(refresh_token_store.id, id))
113
+ .get();
114
+ return row ?? null;
109
115
  }
110
116
 
111
117
  export async function deleteRefreshToken(env: Env, id: string): Promise<void> {
@@ -115,8 +121,8 @@ export async function deleteRefreshToken(env: Env, id: string): Promise<void> {
115
121
 
116
122
  export async function cleanupExpiredRefreshTokens(env: Env, now: number): Promise<number> {
117
123
  const db = getDb(env);
118
- const res = await db.delete(refresh_token_store).where(lt(refresh_token_store.expiresAt, now)).run();
119
- return res.meta.changes ?? 0;
124
+ const response = await db.delete(refresh_token_store).where(lt(refresh_token_store.expiresAt, now)).run();
125
+ return response.meta.changes ?? 0;
120
126
  }
121
127
 
122
128
  export async function getSecret(env: Env, key: string): Promise<string | null> {
@@ -137,9 +143,22 @@ export async function setSecret(env: Env, key: string, value: string): Promise<v
137
143
  }
138
144
 
139
145
  export async function getOrCreateSecret(env: Env, key: string, factory: () => Promise<string>): Promise<string> {
146
+ // Check if secret already exists
140
147
  const existing = await getSecret(env, key);
141
148
  if (existing) return existing;
149
+
150
+ // Generate a new value
142
151
  const value = await factory();
143
- await setSecret(env, key, value);
144
- return value;
152
+
153
+ // Use onConflictDoNothing to avoid race conditions where multiple Workers
154
+ // might try to create the secret simultaneously. The first one wins.
155
+ const db = getDb(env);
156
+ await db
157
+ .insert(secret)
158
+ .values({ key, value, updatedAt: NOW() })
159
+ .onConflictDoNothing();
160
+
161
+ // Re-read to get the actual stored value (might be from another Worker)
162
+ const stored = await getSecret(env, key);
163
+ return stored ?? value;
145
164
  }
package/src/db/dal.ts CHANGED
@@ -2,10 +2,15 @@ import { getDb } from './client';
2
2
  import { record, type NewRecordRow, blob_ref, blob_usage, blob_quota } from './schema';
3
3
  import type { Env } from '../env';
4
4
  import { eq, inArray, and, sql } from 'drizzle-orm';
5
+ import { type AccountState, toRow, fromRow } from '../lib/account-state';
5
6
 
6
7
  export async function putRecord(env: Env, row: NewRecordRow) {
7
8
  const db = getDb(env);
8
- await db.insert(record).values(row).onConflictDoUpdate({
9
+ const toInsert: NewRecordRow = {
10
+ ...row,
11
+ createdAt: row.createdAt ?? Date.now(),
12
+ };
13
+ await db.insert(record).values(toInsert).onConflictDoUpdate({
9
14
  target: record.uri,
10
15
  set: {
11
16
  cid: sql.raw(`excluded.${record.cid.name}`),
@@ -16,8 +21,8 @@ export async function putRecord(env: Env, row: NewRecordRow) {
16
21
 
17
22
  export async function getRecord(env: Env, uri: string) {
18
23
  const db = getDb(env);
19
- const res = await db.select().from(record).where(eq(record.uri, uri)).get();
20
- return res ?? null;
24
+ const result = await db.select().from(record).where(eq(record.uri, uri)).get();
25
+ return result ?? null;
21
26
  }
22
27
 
23
28
  export async function deleteRecord(env: Env, uri: string) {
@@ -109,40 +114,46 @@ export async function updateBlobQuota(env: Env, did: string, bytesAdded: number,
109
114
 
110
115
  export async function checkBlobQuota(env: Env, did: string, additionalBytes: number): Promise<boolean> {
111
116
  const quota = await getBlobQuota(env, did);
112
- const maxBytes = parseInt((env as any).PDS_BLOB_QUOTA_BYTES || '10737418240', 10); // Default: 10GB
117
+ const maxBytes = parseInt(env.PDS_BLOB_QUOTA_BYTES || '10737418240', 10);
113
118
 
114
119
  return (quota.total_bytes + additionalBytes) <= maxBytes;
115
120
  }
116
121
 
117
- // Account state management for migration support
118
- export async function getAccountState(env: Env, did: string) {
122
+ // Account state management for migration support. Reads/writes route through
123
+ // the AccountState FSM so the persisted row stays consistent with whatever the
124
+ // firehose broadcast emits.
125
+ export async function getAccountState(env: Env, did: string): Promise<AccountState | null> {
119
126
  const db = getDb(env);
120
127
  const { account_state } = await import('./schema');
121
- const state = await db.select().from(account_state).where(eq(account_state.did, did)).get();
122
- return state ?? null;
128
+ const row = await db.select().from(account_state).where(eq(account_state.did, did)).get();
129
+ return row ? fromRow(row) : null;
123
130
  }
124
131
 
125
- export async function createAccountState(env: Env, did: string, active: boolean = false) {
132
+ export async function setAccountState(env: Env, did: string, state: AccountState): Promise<void> {
126
133
  const db = getDb(env);
127
134
  const { account_state } = await import('./schema');
128
- await db.insert(account_state).values({
129
- did,
130
- active,
131
- created_at: Date.now(),
132
- }).run();
135
+ const row = toRow(state);
136
+ await db
137
+ .insert(account_state)
138
+ .values({ did, ...row, created_at: Date.now() })
139
+ .onConflictDoUpdate({
140
+ target: account_state.did,
141
+ set: row,
142
+ })
143
+ .run();
133
144
  }
134
145
 
135
- export async function setAccountActive(env: Env, did: string, active: boolean) {
136
- const db = getDb(env);
137
- const { account_state } = await import('./schema');
138
- await db.update(account_state)
139
- .set({ active })
140
- .where(eq(account_state.did, did))
141
- .run();
146
+ export async function createAccountState(env: Env, did: string, active: boolean = false): Promise<void> {
147
+ await setAccountState(env, did, active ? { tag: 'active' } : { tag: 'deactivated' });
148
+ }
149
+
150
+ export async function setAccountActive(env: Env, did: string, active: boolean): Promise<void> {
151
+ await setAccountState(env, did, active ? { tag: 'active' } : { tag: 'deactivated' });
142
152
  }
143
153
 
144
154
  export async function isAccountActive(env: Env, did: string): Promise<boolean> {
145
155
  const state = await getAccountState(env, did);
146
- // If no account state exists, assume active (backward compatibility)
147
- return state?.active ?? true;
156
+ // If no account state row exists, assume active (backward compatibility
157
+ // with rows that predate the migration).
158
+ return state === null ? true : state.tag === 'active';
148
159
  }
package/src/db/repo.ts CHANGED
@@ -7,6 +7,7 @@ import { createCommit, signCommit, commitCid, generateTid, serializeCommit } fro
7
7
  import { CID } from 'multiformats/cid';
8
8
  import { resolveSecret } from '../lib/secrets';
9
9
  import { encodeBlocksForCommit } from '../services/car';
10
+ import { ServerMisconfigured } from '../lib/errors';
10
11
 
11
12
  export async function getRoot(env: Env) {
12
13
  const db = drizzle(env.DB);
@@ -17,7 +18,10 @@ export async function getRoot(env: Env) {
17
18
  /**
18
19
  * Bump the repository root to a new revision with signed commit
19
20
  */
20
- export async function bumpRoot(env: Env, prevMstRoot?: CID): Promise<{
21
+ export async function bumpRoot(env: Env, prevMstRoot?: CID, currentMstRoot?: CID, opts?: {
22
+ ops?: import('../lib/firehose/frames').RepoOp[];
23
+ newMstBlocks?: Array<[CID, Uint8Array]>;
24
+ }): Promise<{
21
25
  commitCid: string;
22
26
  rev: string;
23
27
  ops: import('../lib/firehose/frames').RepoOp[];
@@ -29,37 +33,36 @@ export async function bumpRoot(env: Env, prevMstRoot?: CID): Promise<{
29
33
  const db = drizzle(env.DB);
30
34
  const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
31
35
 
32
- // Resolve signing key (use ephemeral dev key if not configured and not production)
36
+ // Falls back to an ephemeral key only in non-production so dev runs work
37
+ // without REPO_SIGNING_KEY; prod always requires the configured key.
33
38
  const signingKey = await getSigningKey(env);
34
39
 
35
- // Get current repo state
36
40
  const row = await db.select().from(repo_root).where(eq(repo_root.did, did)).get();
37
41
  const prevCommitCid = row?.commitCid ? CID.parse(row.commitCid) : null;
38
42
 
39
- // Get the current MST root
43
+ // Prefer caller-provided pointer to avoid an extra MST load on the
44
+ // batched-write path that already knows the new root.
40
45
  const repoManager = new RepoManager(env);
41
- const mst = await repoManager.getOrCreateRoot();
42
- const mstRootCid = await mst.getPointer();
46
+ const mstRootCid = currentMstRoot
47
+ ? currentMstRoot
48
+ : await (async () => {
49
+ const mst = await repoManager.getOrCreateRoot();
50
+ return mst.getPointer();
51
+ })();
52
+
53
+ // Caller-provided ops avoid the more expensive full-tree diff.
54
+ const ops = opts?.ops !== undefined
55
+ ? opts.ops
56
+ : (prevMstRoot ? await repoManager.extractOps(prevMstRoot, mstRootCid) : []);
43
57
 
44
- // Extract operations if we have a previous MST root
45
- const ops = prevMstRoot
46
- ? await repoManager.extractOps(prevMstRoot, mstRootCid)
47
- : [];
48
-
49
- // Generate new revision (TID)
50
58
  const rev = generateTid();
51
-
52
- // Create commit
53
59
  const commit = createCommit(did, mstRootCid, rev, prevCommitCid);
54
-
55
- // Sign commit
56
60
  const signedCommit = await signCommit(commit, signingKey);
57
-
58
- // Calculate commit CID
59
61
  const cid = await commitCid(signedCommit);
60
62
  const cidString = cid.toString();
61
63
 
62
- // Update repo root - use sql.raw with excluded to properly reference INSERT values
64
+ // sql.raw('excluded.X') references the just-inserted VALUES so the upsert
65
+ // updates with the new value rather than a stale parameter.
63
66
  await db
64
67
  .insert(repo_root)
65
68
  .values({
@@ -94,7 +97,7 @@ export async function bumpRoot(env: Env, prevMstRoot?: CID): Promise<{
94
97
  await appendCommit(env, cidString, rev, commitData, sigBase64);
95
98
 
96
99
  // Encode blocks as CAR for firehose
97
- const blocksBytes = await encodeBlocksForCommit(env, cid, mstRootCid, ops);
100
+ const blocksBytes = await encodeBlocksForCommit(env, cid, mstRootCid, ops, opts?.newMstBlocks);
98
101
  // Encode to base64 (workers-safe)
99
102
  let blocksBase64 = '';
100
103
  for (const b of blocksBytes) blocksBase64 += String.fromCharCode(b);
@@ -119,29 +122,26 @@ export async function appendCommit(env: Env, cid: string, rev: string, data: str
119
122
  .run();
120
123
  }
121
124
 
122
- // Cache for dev-mode ephemeral signing key (in-memory for worker/astro dev)
125
+ // Cache for dev-mode ephemeral signing key (hex string)
123
126
  let cachedDevSigningKey: string | undefined;
124
127
 
125
128
  async function getSigningKey(env: Env): Promise<string> {
126
- const configured = await resolveSecret(env.REPO_SIGNING_KEY);
127
- if (configured && configured.trim() !== '') return configured;
129
+ const configured = await resolveSecret((env as any).REPO_SIGNING_KEY);
130
+ if (configured && configured.trim() !== '') return configured.trim();
128
131
 
129
132
  const envName = (env as any).ENVIRONMENT || 'development';
130
133
  if (envName !== 'production') {
131
134
  if (cachedDevSigningKey) return cachedDevSigningKey;
132
- // Generate an ephemeral Ed25519 keypair and cache private key (PKCS#8 base64)
133
- const keyPair = await crypto.subtle.generateKey(
134
- { name: 'Ed25519', namedCurve: 'Ed25519' } as any,
135
- true,
136
- ['sign', 'verify']
137
- );
138
- const pkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
139
- let s = '';
140
- const u8 = new Uint8Array(pkcs8);
141
- for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
142
- cachedDevSigningKey = btoa(s);
135
+ // Generate an ephemeral secp256k1 keypair and cache private key (hex)
136
+ const { Secp256k1Keypair } = await import('@atproto/crypto');
137
+ const kp = await Secp256k1Keypair.create({ exportable: true });
138
+ const privBytes = await kp.export();
139
+ // to hex
140
+ let hex = '';
141
+ for (const b of privBytes) hex += b.toString(16).padStart(2, '0');
142
+ cachedDevSigningKey = hex;
143
143
  return cachedDevSigningKey;
144
144
  }
145
145
 
146
- throw new Error('REPO_SIGNING_KEY not configured');
146
+ throw new ServerMisconfigured('REPO_SIGNING_KEY not configured');
147
147
  }
package/src/db/schema.ts CHANGED
@@ -103,10 +103,14 @@ export const blob_quota = sqliteTable('blob_quota', {
103
103
  updated_at: integer('updated_at').notNull(),
104
104
  });
105
105
 
106
- // Account state for migration support (single-user PDS)
106
+ // Account state for migration support (single-user PDS). The active flag
107
+ // stays for legacy reads, but the full FSM is recovered from
108
+ // (active, status, suspended_until) via fromRow in src/lib/account-state.ts.
107
109
  export const account_state = sqliteTable('account_state', {
108
110
  did: text('did').primaryKey().notNull(),
109
111
  active: integer('active', { mode: 'boolean' }).notNull().default(false),
112
+ status: text('status'),
113
+ suspended_until: integer('suspended_until'),
110
114
  created_at: integer('created_at').notNull(),
111
115
  });
112
116
 
package/src/db/seed.ts CHANGED
@@ -1,14 +1,6 @@
1
- import { drizzle } from 'drizzle-orm/d1';
2
- import { repo_root } from './schema';
3
-
4
- export async function seed(db: D1Database, did: string) {
5
- const d1 = drizzle(db);
6
- const rows = await d1.select().from(repo_root).all();
7
- if (rows.length === 0) {
8
- await d1.insert(repo_root).values({
9
- did,
10
- commitCid: 'bafyreih2y3p6t2i4y567q2z5q2z5q2z5q2z5q2z5q2z5q2z5q2z5q2z5q',
11
- rev: 0,
12
- });
13
- }
1
+ export async function seed(_db: D1Database, _did: string) {
2
+ // Intentional no-op. The repo_root row is created via UPSERT by bumpRoot()
3
+ // on the first write. Previously this function seeded a placeholder commit
4
+ // CID that wasn't a valid CIDv1, causing the first applyWrites to throw
5
+ // `SyntaxError: Unexpected end of data` when CID.parse decoded it.
14
6
  }
@@ -1,29 +1,9 @@
1
1
  import type { SSRManifest } from 'astro';
2
- import { App } from 'astro/app';
3
- import { handle } from '@astrojs/cloudflare/handler';
2
+ import { createPdsFetchHandler } from '../worker/runtime';
4
3
  import { Sequencer } from '../worker/sequencer';
5
4
 
6
5
  export function createExports(manifest: SSRManifest) {
7
- const app = new App(manifest);
8
- const fetch = async (request: Request, env: unknown, context: unknown) => {
9
- // Ensure ASSETS binding exists to satisfy @astrojs/cloudflare handler
10
- // even when the worker has no static asset binding configured.
11
- const e = env as any;
12
- if (!e?.ASSETS || typeof e.ASSETS.fetch !== 'function') {
13
- e.ASSETS = {
14
- async fetch() {
15
- return new Response('Not Found', {
16
- status: 404,
17
- headers: { 'Cache-Control': 'public, max-age=60' },
18
- });
19
- },
20
- };
21
- }
22
-
23
- // Delegate to the Cloudflare adapter handler while preserving Alteran additions.
24
- return await handle(manifest, app, request, e, context as any);
25
- };
26
-
6
+ const fetch = createPdsFetchHandler({ manifest });
27
7
  return {
28
8
  default: { fetch },
29
9
  Sequencer,
@@ -1,8 +1,8 @@
1
1
  import type { APIContext } from 'astro';
2
2
 
3
3
  const HTML_TEMPLATE = (
4
- handle,
5
- did,
4
+ handle: string,
5
+ did: string,
6
6
  ) => `<!DOCTYPE html>
7
7
  <html lang="en">
8
8
  <head>
@@ -96,8 +96,8 @@ const HTML_TEMPLATE = (
96
96
 
97
97
  export async function GET({ locals }: APIContext) {
98
98
  const { env } = locals.runtime ?? {};
99
- const handle = env?.PDS_HANDLE ?? 'unknown.handle';
100
- const did = env?.PDS_DID ?? 'did:plc:unknown';
99
+ const handle = String(env?.PDS_HANDLE ?? 'unknown.handle');
100
+ const did = String(env?.PDS_DID ?? 'did:plc:unknown');
101
101
 
102
102
  return new Response(HTML_TEMPLATE(handle, did), {
103
103
  status: 200,
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Finite-state machine for account lifecycle.
3
+ *
4
+ * The AT Protocol wire format encodes account status as two fields:
5
+ * { active: boolean; status?: string }
6
+ *
7
+ * That representation lets you express illegal combinations (e.g.
8
+ * `active: true` paired with `status: "takendown"`). Internally we model
9
+ * the same domain as a discriminated union so the compiler enforces the
10
+ * invariant: every state is either active OR carries a specific reason
11
+ * for being inactive, never both. Conversion to the wire shape happens
12
+ * only at the firehose boundary.
13
+ */
14
+
15
+ export type AccountState =
16
+ | { readonly tag: 'active' }
17
+ | { readonly tag: 'takendown' }
18
+ | { readonly tag: 'suspended'; readonly until?: string }
19
+ | { readonly tag: 'deactivated' }
20
+ | { readonly tag: 'deleted' };
21
+
22
+ export type AccountStateTag = AccountState['tag'];
23
+
24
+ const ACTIVE: AccountState = { tag: 'active' };
25
+ const TAKENDOWN: AccountState = { tag: 'takendown' };
26
+ const DEACTIVATED: AccountState = { tag: 'deactivated' };
27
+ const DELETED: AccountState = { tag: 'deleted' };
28
+
29
+ export type AccountEvent =
30
+ | { readonly tag: 'activate' }
31
+ | { readonly tag: 'takedown' }
32
+ | { readonly tag: 'suspend'; readonly until?: string }
33
+ | { readonly tag: 'deactivate' }
34
+ | { readonly tag: 'delete' };
35
+
36
+ /**
37
+ * Apply an event to a state and return the next state.
38
+ *
39
+ * Illegal transitions (e.g. activating a deleted account) throw; this is a
40
+ * fail-fast contract so bugs surface at the call site instead of silently
41
+ * corrupting the firehose. Callers should validate authorization before
42
+ * passing an event to transition.
43
+ */
44
+ export function transition(state: AccountState, event: AccountEvent): AccountState {
45
+ if (state.tag === 'deleted') {
46
+ throw new Error(`Account is deleted; cannot apply ${event.tag}`);
47
+ }
48
+ switch (event.tag) {
49
+ case 'activate':
50
+ return ACTIVE;
51
+ case 'takedown':
52
+ return TAKENDOWN;
53
+ case 'suspend':
54
+ return event.until ? { tag: 'suspended', until: event.until } : { tag: 'suspended' };
55
+ case 'deactivate':
56
+ return DEACTIVATED;
57
+ case 'delete':
58
+ return DELETED;
59
+ }
60
+ }
61
+
62
+ export type AccountWireStatus = {
63
+ readonly active: boolean;
64
+ readonly status?: string;
65
+ };
66
+
67
+ // The AT Protocol firehose #account event carries only { active, status } —
68
+ // the spec has no slot for `until`. Wire round-trips through
69
+ // fromWireStatus(toWireStatus) are intentionally lossy on the suspended
70
+ // expiry; persistence uses toRow/fromRow which preserve the full FSM.
71
+ export function toWireStatus(state: AccountState): AccountWireStatus {
72
+ switch (state.tag) {
73
+ case 'active':
74
+ return { active: true };
75
+ case 'takendown':
76
+ return { active: false, status: 'takendown' };
77
+ case 'suspended':
78
+ return { active: false, status: 'suspended' };
79
+ case 'deactivated':
80
+ return { active: false, status: 'deactivated' };
81
+ case 'deleted':
82
+ return { active: false, status: 'deleted' };
83
+ }
84
+ }
85
+
86
+ export function fromWireStatus(wire: AccountWireStatus): AccountState {
87
+ if (wire.active) return ACTIVE;
88
+ switch (wire.status) {
89
+ case 'takendown':
90
+ return TAKENDOWN;
91
+ case 'suspended':
92
+ return { tag: 'suspended' };
93
+ case 'deactivated':
94
+ return DEACTIVATED;
95
+ case 'deleted':
96
+ return DELETED;
97
+ default:
98
+ // Unknown / missing status from an older wire payload — treat as a
99
+ // suspended account so reads still gate but writes are blocked.
100
+ return { tag: 'suspended' };
101
+ }
102
+ }
103
+
104
+ export function isActive(state: AccountState): boolean {
105
+ return state.tag === 'active';
106
+ }
107
+
108
+ // Persistence shape. The account_state table mirrors this row exactly. We
109
+ // keep both `active` (for legacy reads + cheap filters) and `status` /
110
+ // `suspended_until` so the full FSM can be recovered from D1.
111
+ export type AccountStateRow = {
112
+ readonly active: boolean;
113
+ readonly status: string | null;
114
+ readonly suspended_until: number | null;
115
+ };
116
+
117
+ export function toRow(state: AccountState): AccountStateRow {
118
+ switch (state.tag) {
119
+ case 'active':
120
+ return { active: true, status: null, suspended_until: null };
121
+ case 'takendown':
122
+ return { active: false, status: 'takendown', suspended_until: null };
123
+ case 'suspended':
124
+ return {
125
+ active: false,
126
+ status: 'suspended',
127
+ suspended_until: state.until ? Date.parse(state.until) : null,
128
+ };
129
+ case 'deactivated':
130
+ return { active: false, status: 'deactivated', suspended_until: null };
131
+ case 'deleted':
132
+ return { active: false, status: 'deleted', suspended_until: null };
133
+ }
134
+ }
135
+
136
+ export function fromRow(row: AccountStateRow): AccountState {
137
+ if (row.active) return ACTIVE;
138
+ switch (row.status) {
139
+ case 'takendown':
140
+ return TAKENDOWN;
141
+ case 'suspended': {
142
+ const until = row.suspended_until !== null
143
+ ? new Date(row.suspended_until).toISOString()
144
+ : undefined;
145
+ return until ? { tag: 'suspended', until } : { tag: 'suspended' };
146
+ }
147
+ case 'deleted':
148
+ return DELETED;
149
+ case 'deactivated':
150
+ default:
151
+ // null / unknown status with active=false is the bootstrap state for
152
+ // freshly-created accounts that haven't been activated yet. Treat it
153
+ // as deactivated so reads still gate but no special-case is needed.
154
+ return DEACTIVATED;
155
+ }
156
+ }
package/src/lib/actor.ts CHANGED
@@ -11,9 +11,9 @@ interface ProfileRecord {
11
11
  website?: string;
12
12
  avatar?: string;
13
13
  banner?: string;
14
- joinedViaStarterPack?: any;
15
- pinnedPost?: any;
16
- labels?: any;
14
+ joinedViaStarterPack?: unknown;
15
+ pinnedPost?: unknown;
16
+ labels?: unknown;
17
17
  createdAt?: string;
18
18
  }
19
19
 
@@ -26,7 +26,7 @@ export interface PrimaryActor {
26
26
  website?: string;
27
27
  avatar?: string;
28
28
  banner?: string;
29
- labels?: any;
29
+ labels?: unknown;
30
30
  createdAt?: string;
31
31
  }
32
32
 
@@ -46,17 +46,33 @@ export async function fetchProfileRecord(env: Env, did: string): Promise<Profile
46
46
  }
47
47
 
48
48
  // Fallback: pick the most recent profile record regardless of DID
49
+ // Use range scan to avoid D1 LIKE complexity limits
50
+ // Profile URIs have format: at://<did>/app.bsky.actor.profile/self
51
+ const prefix = `at://`;
52
+ const suffix = `/${PROFILE_COLLECTION}/`;
53
+ const upperBound = `at://~`; // '~' sorts after all valid DIDs
54
+
55
+ // Find any profile record - scan from "at://" to "at://~" and filter in app
49
56
  const fallback = await env.DB.prepare(
50
- 'SELECT json FROM record WHERE uri LIKE ? ORDER BY rowid DESC LIMIT 1'
57
+ 'SELECT json FROM record WHERE uri >= ? AND uri < ? ORDER BY rowid DESC LIMIT 50'
51
58
  )
52
- .bind(`%/${PROFILE_COLLECTION}/%`)
53
- .first<{ json: string }>();
59
+ .bind(prefix, upperBound)
60
+ .all<{ json: string }>();
54
61
 
55
- if (fallback?.json) {
56
- try {
57
- return JSON.parse(fallback.json) as ProfileRecord;
58
- } catch {
59
- return null;
62
+ // Filter for profile records in memory (D1 can't do complex patterns)
63
+ if (fallback?.results) {
64
+ for (const row of fallback.results) {
65
+ if (row.json && typeof row.json === 'string') {
66
+ try {
67
+ // Check if this is a profile record by URI pattern
68
+ const parsed = JSON.parse(row.json);
69
+ if (parsed.$type === 'app.bsky.actor.profile') {
70
+ return parsed as ProfileRecord;
71
+ }
72
+ } catch {
73
+ continue;
74
+ }
75
+ }
60
76
  }
61
77
  }
62
78
 
@@ -0,0 +1,66 @@
1
+ import type { AuthScope } from './types';
2
+
3
+ const DEFAULT_ACCESS_SCOPE: AuthScope = 'com.atproto.access';
4
+ export const TAKENDOWN_SCOPE: AuthScope = 'com.atproto.takendown';
5
+
6
+ export const PRIVILEGED_SCOPES: ReadonlySet<AuthScope> = new Set([
7
+ 'com.atproto.access',
8
+ 'com.atproto.appPassPrivileged',
9
+ ]);
10
+
11
+ export const PRIVILEGED_METHODS: ReadonlySet<string> = new Set([
12
+ 'chat.bsky.actor.deleteAccount',
13
+ 'chat.bsky.actor.exportAccountData',
14
+ 'chat.bsky.convo.deleteMessageForSelf',
15
+ 'chat.bsky.convo.getConvo',
16
+ 'chat.bsky.convo.getConvoForMembers',
17
+ 'chat.bsky.convo.getLog',
18
+ 'chat.bsky.convo.getMessages',
19
+ 'chat.bsky.convo.leaveConvo',
20
+ 'chat.bsky.convo.listConvos',
21
+ 'chat.bsky.convo.muteConvo',
22
+ 'chat.bsky.convo.sendMessage',
23
+ 'chat.bsky.convo.sendMessageBatch',
24
+ 'chat.bsky.convo.unmuteConvo',
25
+ 'chat.bsky.convo.updateRead',
26
+ 'com.atproto.server.createAccount',
27
+ ]);
28
+
29
+ export const PROTECTED_METHODS: ReadonlySet<string> = new Set([
30
+ 'com.atproto.admin.sendEmail',
31
+ 'com.atproto.identity.requestPlcOperationSignature',
32
+ 'com.atproto.identity.signPlcOperation',
33
+ 'com.atproto.identity.updateHandle',
34
+ 'com.atproto.server.activateAccount',
35
+ 'com.atproto.server.confirmEmail',
36
+ 'com.atproto.server.createAppPassword',
37
+ 'com.atproto.server.deactivateAccount',
38
+ 'com.atproto.server.getAccountInviteCodes',
39
+ 'com.atproto.server.getSession',
40
+ 'com.atproto.server.listAppPasswords',
41
+ 'com.atproto.server.requestAccountDelete',
42
+ 'com.atproto.server.requestEmailConfirmation',
43
+ 'com.atproto.server.requestEmailUpdate',
44
+ 'com.atproto.server.revokeAppPassword',
45
+ 'com.atproto.server.updateEmail',
46
+ ]);
47
+
48
+ export function resolveAuthScope(scope: unknown): AuthScope {
49
+ if (typeof scope !== 'string') {
50
+ return DEFAULT_ACCESS_SCOPE;
51
+ }
52
+
53
+ switch (scope) {
54
+ case 'access':
55
+ return 'com.atproto.access';
56
+ case 'com.atproto.access':
57
+ case 'com.atproto.appPass':
58
+ case 'com.atproto.appPassPrivileged':
59
+ case 'com.atproto.signupQueued':
60
+ case 'com.atproto.takendown':
61
+ return scope;
62
+ default:
63
+ console.warn('Unknown auth scope, treating as access scope', scope);
64
+ return DEFAULT_ACCESS_SCOPE;
65
+ }
66
+ }