@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.
- package/LICENSE +21 -0
- package/README.md +19 -30
- package/index.js +34 -28
- package/migrations/0007_bored_spitfire.sql +26 -0
- package/migrations/0008_furry_ozymandias.sql +2 -0
- package/migrations/meta/0007_snapshot.json +534 -0
- package/migrations/meta/0008_snapshot.json +548 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +10 -9
- package/src/app.ts +8 -4
- package/src/db/account.ts +25 -6
- package/src/db/client.ts +1 -1
- package/src/db/dal.ts +34 -23
- package/src/db/repo.ts +38 -38
- package/src/db/schema.ts +5 -1
- package/src/db/seed.ts +5 -13
- package/src/entrypoints/server.ts +2 -22
- package/src/handlers/debug.ts +1 -1
- package/src/handlers/ready.ts +1 -1
- package/src/handlers/root.ts +4 -4
- package/src/handlers/xrpc.server.refreshSession.ts +6 -6
- package/src/lib/account-state.ts +156 -0
- package/src/lib/actor.ts +29 -13
- package/src/lib/appview/auth-policy.ts +66 -0
- package/src/lib/appview/did-resolver.ts +233 -0
- package/src/lib/appview/proxy.ts +221 -0
- package/src/lib/appview/service-config.ts +61 -0
- package/src/lib/appview/service-jwt.ts +93 -0
- package/src/lib/appview/types.ts +25 -0
- package/src/lib/appview.ts +5 -532
- package/src/lib/auth-errors.ts +24 -0
- package/src/lib/auth.ts +63 -15
- package/src/lib/blockstore-gc.ts +6 -5
- package/src/lib/cache.ts +30 -4
- package/src/lib/chat.ts +20 -14
- package/src/lib/commit-log-pruning.ts +2 -2
- package/src/lib/commit.ts +26 -36
- package/src/lib/config.ts +26 -15
- package/src/lib/did-document.ts +32 -0
- package/src/lib/errors.ts +54 -0
- package/src/lib/feed.ts +18 -19
- package/src/lib/firehose/frames.ts +87 -47
- package/src/lib/firehose/validation.ts +3 -3
- package/src/lib/jwt.ts +85 -177
- package/src/lib/labeler.ts +43 -30
- package/src/lib/logger.ts +4 -0
- package/src/lib/mst/block-map.ts +172 -0
- package/src/lib/mst/blockstore.ts +56 -93
- package/src/lib/mst/index.ts +1 -0
- package/src/lib/mst/leaf.ts +25 -0
- package/src/lib/mst/mst.ts +81 -237
- package/src/lib/mst/serialize.ts +97 -0
- package/src/lib/mst/types.ts +21 -0
- package/src/lib/oauth/clients.ts +67 -0
- package/src/lib/oauth/dpop-errors.ts +15 -0
- package/src/lib/oauth/dpop.ts +150 -0
- package/src/lib/oauth/resource.ts +199 -0
- package/src/lib/oauth/store.ts +77 -0
- package/src/lib/preferences.ts +12 -37
- package/src/lib/ratelimit.ts +4 -4
- package/src/lib/refresh-session.ts +161 -0
- package/src/lib/relay.ts +10 -8
- package/src/lib/secrets.ts +6 -7
- package/src/lib/sequencer.ts +14 -5
- package/src/lib/service-auth.ts +184 -0
- package/src/lib/session-tokens.ts +28 -76
- package/src/lib/streaming-car.ts +3 -0
- package/src/lib/tracing.ts +4 -3
- package/src/lib/util.ts +65 -15
- package/src/middleware.ts +1 -1
- package/src/pages/.well-known/did.json.ts +27 -30
- package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
- package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
- package/src/pages/debug/blob/[...key].ts +2 -2
- package/src/pages/debug/db/bootstrap.ts +1 -1
- package/src/pages/debug/db/commits.ts +1 -1
- package/src/pages/debug/gc/blobs.ts +1 -1
- package/src/pages/debug/record.ts +1 -1
- package/src/pages/debug/sequencer.ts +28 -0
- package/src/pages/health.ts +4 -4
- package/src/pages/oauth/authorize.ts +78 -0
- package/src/pages/oauth/consent.ts +80 -0
- package/src/pages/oauth/par.ts +121 -0
- package/src/pages/oauth/token.ts +158 -0
- package/src/pages/ready.ts +2 -2
- package/src/pages/xrpc/[...nsid].ts +61 -0
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
- package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
- package/src/pages/xrpc/com.atproto.server.createSession.ts +32 -12
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
- package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +72 -23
- package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
- package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
- package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
- package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +2 -2
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
- package/src/services/car.ts +209 -57
- package/src/services/r2-blob-store.ts +4 -4
- package/src/services/repo/blockstore-ops.ts +29 -0
- package/src/services/repo/operations.ts +133 -0
- package/src/services/repo-manager.ts +203 -254
- package/src/worker/runtime.ts +56 -11
- package/src/worker/sequencer/broadcast.ts +91 -0
- package/src/worker/sequencer/cid-helpers.ts +39 -0
- package/src/worker/sequencer/payload.ts +84 -0
- package/src/worker/sequencer/types.ts +36 -0
- package/src/worker/sequencer/upgrade.ts +141 -0
- package/src/worker/sequencer.ts +264 -406
- package/types/env.d.ts +18 -6
- package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
- package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
- package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
- package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
- package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
- package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
- package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
- package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +0 -23
- package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
- package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
- package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
- package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
- package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
- 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
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
return
|
|
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
|
-
|
|
144
|
-
|
|
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/client.ts
CHANGED
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
|
-
|
|
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
|
|
20
|
-
return
|
|
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(
|
|
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
|
-
|
|
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
|
|
122
|
-
return
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
created_at: Date.now()
|
|
132
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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,9 +7,10 @@ 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
|
-
const db = drizzle(env.
|
|
13
|
+
const db = drizzle(env.ALTERAN_DB);
|
|
13
14
|
const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
|
|
14
15
|
return db.select().from(repo_root).where(eq(repo_root.did, did)).get();
|
|
15
16
|
}
|
|
@@ -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
|
|
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[];
|
|
@@ -26,40 +30,39 @@ export async function bumpRoot(env: Env, prevMstRoot?: CID): Promise<{
|
|
|
26
30
|
sig: string;
|
|
27
31
|
blocks: string; // base64-encoded CAR
|
|
28
32
|
}> {
|
|
29
|
-
const db = drizzle(env.
|
|
33
|
+
const db = drizzle(env.ALTERAN_DB);
|
|
30
34
|
const did = (await resolveSecret(env.PDS_DID)) ?? 'did:example:single-user';
|
|
31
35
|
|
|
32
|
-
//
|
|
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
|
-
//
|
|
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
|
|
42
|
-
|
|
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
|
-
//
|
|
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);
|
|
@@ -104,7 +107,7 @@ export async function bumpRoot(env: Env, prevMstRoot?: CID): Promise<{
|
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
export async function appendCommit(env: Env, cid: string, rev: string, data: string, sig: string) {
|
|
107
|
-
const db = drizzle(env.
|
|
110
|
+
const db = drizzle(env.ALTERAN_DB);
|
|
108
111
|
const ts = Date.now();
|
|
109
112
|
|
|
110
113
|
await db
|
|
@@ -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 (
|
|
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
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 {
|
|
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
|
|
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,
|
package/src/handlers/debug.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { putRecord as dalPutRecord, getRecord as dalGetRecord } from '../db/dal'
|
|
|
3
3
|
|
|
4
4
|
export async function POST_db_bootstrap(ctx: APIContext) {
|
|
5
5
|
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
6
|
-
const db = env.
|
|
6
|
+
const db = env.ALTERAN_DB;
|
|
7
7
|
await db.exec("CREATE TABLE IF NOT EXISTS record (uri TEXT PRIMARY KEY, cid TEXT NOT NULL, json TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')));");
|
|
8
8
|
await db.exec("CREATE TABLE IF NOT EXISTS blob (cid TEXT PRIMARY KEY, key TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL);");
|
|
9
9
|
await db.exec("CREATE TABLE IF NOT EXISTS blob_usage (record_uri TEXT NOT NULL, key TEXT NOT NULL);");
|
package/src/handlers/ready.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { APIContext } from 'astro';
|
|
|
2
2
|
|
|
3
3
|
export async function GET(ctx: APIContext) {
|
|
4
4
|
try {
|
|
5
|
-
const db = (ctx.locals as any).runtime?.env?.
|
|
5
|
+
const db = (ctx.locals as any).runtime?.env?.ALTERAN_DB ?? (ctx.locals as any).ALTERAN_DB ?? (globalThis as any).ALTERAN_DB;
|
|
6
6
|
if (db) {
|
|
7
7
|
await db.prepare('select 1').first();
|
|
8
8
|
}
|
package/src/handlers/root.ts
CHANGED
|
@@ -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,
|
|
@@ -23,9 +23,9 @@ export async function POST(ctx: APIContext) {
|
|
|
23
23
|
if (!ver || ver.payload.t !== 'refresh') return Response.json({ error: 'InvalidToken' }, { status: 401 });
|
|
24
24
|
|
|
25
25
|
const jtiOld = String(ver.payload.jti || '');
|
|
26
|
-
if (jtiOld && env.
|
|
27
|
-
await env.
|
|
28
|
-
const row: any = await env.
|
|
26
|
+
if (jtiOld && env.ALTERAN_DB) {
|
|
27
|
+
await env.ALTERAN_DB.exec('CREATE TABLE IF NOT EXISTS token_revocation (refresh_jti TEXT PRIMARY KEY, exp INTEGER NOT NULL);');
|
|
28
|
+
const row: any = await env.ALTERAN_DB.prepare('SELECT refresh_jti FROM token_revocation WHERE refresh_jti=?').bind(jtiOld).first();
|
|
29
29
|
if (row?.refresh_jti) return Response.json({ error: 'InvalidToken' }, { status: 401 });
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -34,9 +34,9 @@ export async function POST(ctx: APIContext) {
|
|
|
34
34
|
const jtiNew = crypto.randomUUID();
|
|
35
35
|
const accessJwt = await signJwt(ctx, { sub: did, handle, t: 'access' }, 'access');
|
|
36
36
|
const refreshJwt = await signJwt(ctx, { sub: did, handle, t: 'refresh', jti: jtiNew }, 'refresh');
|
|
37
|
-
if (jtiOld && ver.payload.exp && env.
|
|
38
|
-
await env.
|
|
39
|
-
await env.
|
|
37
|
+
if (jtiOld && ver.payload.exp && env.ALTERAN_DB) {
|
|
38
|
+
await env.ALTERAN_DB.exec('CREATE TABLE IF NOT EXISTS token_revocation (refresh_jti TEXT PRIMARY KEY, exp INTEGER NOT NULL);');
|
|
39
|
+
await env.ALTERAN_DB.prepare('INSERT OR REPLACE INTO token_revocation (refresh_jti, exp) VALUES (?,?)').bind(jtiOld, Number(ver.payload.exp)).run();
|
|
40
40
|
}
|
|
41
41
|
return Response.json({ did, handle, accessJwt, refreshJwt });
|
|
42
42
|
}
|
|
@@ -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
|
+
}
|