@alteran/astro 0.1.0
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/README.md +558 -0
- package/index.d.ts +12 -0
- package/index.js +129 -0
- package/package.json +75 -0
- package/src/_worker.ts +44 -0
- package/src/app.ts +10 -0
- package/src/db/client.ts +7 -0
- package/src/db/dal.ts +97 -0
- package/src/db/repo.ts +135 -0
- package/src/db/schema.ts +89 -0
- package/src/db/seed.ts +14 -0
- package/src/env.d.ts +4 -0
- package/src/handlers/debug.ts +34 -0
- package/src/handlers/health.ts +6 -0
- package/src/handlers/ready.ts +14 -0
- package/src/handlers/root.ts +5 -0
- package/src/handlers/wellknown.ts +7 -0
- package/src/handlers/xrpc.repo.core.ts +57 -0
- package/src/handlers/xrpc.server.createSession.ts +25 -0
- package/src/handlers/xrpc.server.refreshSession.ts +43 -0
- package/src/lib/auth.ts +20 -0
- package/src/lib/blockstore-gc.ts +197 -0
- package/src/lib/cache.ts +236 -0
- package/src/lib/car-reader.ts +157 -0
- package/src/lib/commit-log-pruning.ts +76 -0
- package/src/lib/commit.ts +162 -0
- package/src/lib/config.ts +208 -0
- package/src/lib/errors.ts +142 -0
- package/src/lib/firehose/frames.ts +229 -0
- package/src/lib/firehose/parse.ts +82 -0
- package/src/lib/firehose/validation.ts +9 -0
- package/src/lib/handle.ts +90 -0
- package/src/lib/jwt.ts +150 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/metrics.ts +194 -0
- package/src/lib/mst/blockstore.ts +105 -0
- package/src/lib/mst/index.ts +3 -0
- package/src/lib/mst/mst.ts +643 -0
- package/src/lib/mst/util.ts +86 -0
- package/src/lib/ratelimit.ts +34 -0
- package/src/lib/sequencer.ts +10 -0
- package/src/lib/streaming-car.ts +137 -0
- package/src/lib/token-cleanup.ts +38 -0
- package/src/lib/tracing.ts +136 -0
- package/src/lib/util.ts +55 -0
- package/src/middleware.ts +102 -0
- package/src/pages/.well-known/atproto-did.ts +7 -0
- package/src/pages/.well-known/did.json.ts +76 -0
- package/src/pages/debug/blob/[...key].ts +27 -0
- package/src/pages/debug/db/bootstrap.ts +23 -0
- package/src/pages/debug/db/commits.ts +20 -0
- package/src/pages/debug/gc/blobs.ts +16 -0
- package/src/pages/debug/record.ts +33 -0
- package/src/pages/health.ts +68 -0
- package/src/pages/index.astro +57 -0
- package/src/pages/index.ts +2 -0
- package/src/pages/ready.ts +16 -0
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +38 -0
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +45 -0
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +73 -0
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +51 -0
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +25 -0
- package/src/pages/xrpc/com.atproto.repo.listRecords.ts +57 -0
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +53 -0
- package/src/pages/xrpc/com.atproto.server.createSession.ts +92 -0
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +25 -0
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +17 -0
- package/src/pages/xrpc/com.atproto.server.getSession.ts +46 -0
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +67 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.json.ts +16 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.ts +56 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +43 -0
- package/src/pages/xrpc/com.atproto.sync.getHead.ts +11 -0
- package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +42 -0
- package/src/pages/xrpc/com.atproto.sync.getRecord.ts +63 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.range.ts +34 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.ts +17 -0
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +53 -0
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +31 -0
- package/src/services/car.ts +249 -0
- package/src/services/r2-blob-store.ts +87 -0
- package/src/services/repo-manager.ts +339 -0
- package/src/shims/astro-internal-handler.d.ts +4 -0
- package/src/worker/sequencer.ts +563 -0
- package/types/env.d.ts +48 -0
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alteran/astro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
|
|
5
|
+
"module": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"import": "./index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"src",
|
|
18
|
+
"types"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "astro dev",
|
|
25
|
+
"build": "astro build",
|
|
26
|
+
"preview": "astro preview",
|
|
27
|
+
"deploy": "astro build && wrangler deploy",
|
|
28
|
+
"iac:deploy": "bunx alchemy deploy iac/alchemy.run.ts",
|
|
29
|
+
"iac:destroy": "bunx alchemy destroy iac/alchemy.run.ts",
|
|
30
|
+
"iac:plan": "bunx alchemy plan iac/alchemy.run.ts",
|
|
31
|
+
"db:generate": "bunx drizzle-kit generate",
|
|
32
|
+
"db:apply": "wrangler d1 migrations apply pds",
|
|
33
|
+
"db:apply:local": "bunx wrangler d1 migrations apply pds --local",
|
|
34
|
+
"db:apply:local:direct": "wrangler d1 migrations apply pds --local",
|
|
35
|
+
"db:reset:local": "rm -rf .wrangler/state && rm -rf drizzle && bun run db:generate && bun run db:apply:local"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@astrojs/cloudflare": "^11.0.4",
|
|
39
|
+
"@atproto/api": "^0.17.0",
|
|
40
|
+
"@cloudflare/vite-plugin": "^1.13.8",
|
|
41
|
+
"alchemy": "^0.70.2",
|
|
42
|
+
"@types/bun": "latest",
|
|
43
|
+
"astro": "^4.16.9",
|
|
44
|
+
"drizzle-kit": "^0.31.5",
|
|
45
|
+
"miniflare": "3",
|
|
46
|
+
"vite": "^7.1.8",
|
|
47
|
+
"wrangler": "^4.40.3"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"typescript": "^5"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@iconify-json/simple-icons": "^1.2.53",
|
|
54
|
+
"@ipld/car": "^5.4.2",
|
|
55
|
+
"@ipld/dag-cbor": "^9.2.5",
|
|
56
|
+
"@noble/hashes": "^2.0.1",
|
|
57
|
+
"astro-icon": "^1.1.5",
|
|
58
|
+
"dotenv": "^17.2.3",
|
|
59
|
+
"drizzle-orm": "^0.44.6",
|
|
60
|
+
"@cloudflare/workers-types": "^4.20251001.0",
|
|
61
|
+
"get-port": "^7.1.0",
|
|
62
|
+
"hono": "^4.9.9",
|
|
63
|
+
"multiformats": "^13.4.1",
|
|
64
|
+
"uint8arrays": "^5.1.0"
|
|
65
|
+
},
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "git+https://github.com/alteran-dev/alteran.git"
|
|
69
|
+
},
|
|
70
|
+
"bugs": {
|
|
71
|
+
"url": "https://github.com/alteran-dev/alteran/issues"
|
|
72
|
+
},
|
|
73
|
+
"homepage": "https://github.com/alteran-dev/alteran#readme",
|
|
74
|
+
"license": "MIT"
|
|
75
|
+
}
|
package/src/_worker.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { handle } from 'astro/internal/handler';
|
|
2
|
+
import { onRequest } from './middleware';
|
|
3
|
+
import { seed } from './db/seed';
|
|
4
|
+
import { validateConfigOrThrow } from './lib/config';
|
|
5
|
+
import type { Env } from './env';
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
9
|
+
// Validate configuration on startup (fail fast if invalid)
|
|
10
|
+
try {
|
|
11
|
+
validateConfigOrThrow(env);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
return new Response(
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
error: 'ConfigurationError',
|
|
16
|
+
message: error instanceof Error ? error.message : 'Invalid configuration',
|
|
17
|
+
}),
|
|
18
|
+
{
|
|
19
|
+
status: 500,
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await seed(env.DB, env.PDS_DID ?? 'did:example:single-user');
|
|
26
|
+
|
|
27
|
+
const url = new URL(request.url);
|
|
28
|
+
if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') {
|
|
29
|
+
const upgrade = request.headers.get('upgrade');
|
|
30
|
+
if (upgrade !== 'websocket') return new Response('Expected websocket', { status: 426 });
|
|
31
|
+
if (!env.SEQUENCER) return new Response('Sequencer not configured', { status: 503 });
|
|
32
|
+
|
|
33
|
+
const id = env.SEQUENCER.idFromName('default');
|
|
34
|
+
const stub = env.SEQUENCER.get(id);
|
|
35
|
+
return stub.fetch(request as any);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const locals: any = { runtime: { env, ctx, request } };
|
|
39
|
+
return await onRequest(locals as any, async () => await handle(locals as any));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Export Durable Object(s)
|
|
44
|
+
export { Sequencer } from './worker/sequencer';
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function createApp() {
|
|
2
|
+
return {
|
|
3
|
+
// Lazy-import the worker to avoid hard dependency on Astro internals during tests
|
|
4
|
+
fetch: async (req: Request, env: any, ctx: ExecutionContext) => {
|
|
5
|
+
const worker = await import('./_worker');
|
|
6
|
+
return (worker as any).default.fetch(req, env, ctx);
|
|
7
|
+
},
|
|
8
|
+
} as const;
|
|
9
|
+
}
|
|
10
|
+
|
package/src/db/client.ts
ADDED
package/src/db/dal.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { getDb } from './client';
|
|
2
|
+
import { record, type NewRecordRow, blob_ref, blob_usage, blob_quota } from './schema';
|
|
3
|
+
import type { Env } from '../env';
|
|
4
|
+
import { eq, inArray, and } from 'drizzle-orm';
|
|
5
|
+
|
|
6
|
+
export async function putRecord(env: Env, row: NewRecordRow) {
|
|
7
|
+
const db = getDb(env);
|
|
8
|
+
await db.insert(record).values(row).onConflictDoUpdate({ target: record.uri, set: { cid: row.cid, json: row.json } });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function getRecord(env: Env, uri: string) {
|
|
12
|
+
const db = getDb(env);
|
|
13
|
+
const res = await db.select().from(record).where(eq(record.uri, uri)).get();
|
|
14
|
+
return res ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function deleteRecord(env: Env, uri: string) {
|
|
18
|
+
const db = getDb(env);
|
|
19
|
+
await db.delete(record).where(eq(record.uri, uri)).run();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listRecords(env: Env) {
|
|
23
|
+
const db = getDb(env);
|
|
24
|
+
return db.select().from(record).all();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getRecordsByCids(env: Env, cids: string[]) {
|
|
28
|
+
if (!cids.length) return [] as Awaited<ReturnType<typeof listRecords>>;
|
|
29
|
+
const db = getDb(env);
|
|
30
|
+
return db.select().from(record).where(inArray(record.cid, cids)).all();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function putBlobRef(env: Env, did: string, cid: string, key: string, mime: string, size: number) {
|
|
34
|
+
const db = getDb(env);
|
|
35
|
+
await db
|
|
36
|
+
.insert(blob_ref)
|
|
37
|
+
.values({ did, cid, key, mime, size })
|
|
38
|
+
.onConflictDoUpdate({ target: blob_ref.cid, set: { did, key, mime, size } });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function setRecordBlobUsage(env: Env, uri: string, keys: string[]) {
|
|
42
|
+
const db = getDb(env);
|
|
43
|
+
// remove existing usage for this record
|
|
44
|
+
await db.delete(blob_usage).where(eq(blob_usage.recordUri, uri)).run();
|
|
45
|
+
// insert new usage
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
await db.insert(blob_usage).values({ recordUri: uri, key }).run();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function listOrphanBlobKeys(env: Env): Promise<string[]> {
|
|
52
|
+
const db = getDb(env);
|
|
53
|
+
// select keys in blob that are not referenced in blob_usage
|
|
54
|
+
const all = await db.select().from(blob_ref).all();
|
|
55
|
+
const used = new Set((await db.select().from(blob_usage).all()).map((u) => u.key));
|
|
56
|
+
return all.map((b) => b.key).filter((k) => !used.has(k));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function deleteBlobByKey(env: Env, key: string) {
|
|
60
|
+
const db = getDb(env);
|
|
61
|
+
await db.delete(blob_ref).where(eq(blob_ref.key, key)).run();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getBlobQuota(env: Env, did: string) {
|
|
65
|
+
const db = getDb(env);
|
|
66
|
+
const quota = await db.select().from(blob_quota).where(eq(blob_quota.did, did)).get();
|
|
67
|
+
return quota ?? { did, total_bytes: 0, blob_count: 0, updated_at: Date.now() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function updateBlobQuota(env: Env, did: string, bytesAdded: number, countAdded: number) {
|
|
71
|
+
const db = getDb(env);
|
|
72
|
+
const current = await getBlobQuota(env, did);
|
|
73
|
+
|
|
74
|
+
await db
|
|
75
|
+
.insert(blob_quota)
|
|
76
|
+
.values({
|
|
77
|
+
did,
|
|
78
|
+
total_bytes: current.total_bytes + bytesAdded,
|
|
79
|
+
blob_count: current.blob_count + countAdded,
|
|
80
|
+
updated_at: Date.now(),
|
|
81
|
+
})
|
|
82
|
+
.onConflictDoUpdate({
|
|
83
|
+
target: blob_quota.did,
|
|
84
|
+
set: {
|
|
85
|
+
total_bytes: current.total_bytes + bytesAdded,
|
|
86
|
+
blob_count: current.blob_count + countAdded,
|
|
87
|
+
updated_at: Date.now(),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function checkBlobQuota(env: Env, did: string, additionalBytes: number): Promise<boolean> {
|
|
93
|
+
const quota = await getBlobQuota(env, did);
|
|
94
|
+
const maxBytes = parseInt((env as any).PDS_BLOB_QUOTA_BYTES || '10737418240', 10); // Default: 10GB
|
|
95
|
+
|
|
96
|
+
return (quota.total_bytes + additionalBytes) <= maxBytes;
|
|
97
|
+
}
|
package/src/db/repo.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { repo_root, commit_log } from './schema';
|
|
5
|
+
import { RepoManager } from '../services/repo-manager';
|
|
6
|
+
import { createCommit, signCommit, commitCid, generateTid, serializeCommit } from '../lib/commit';
|
|
7
|
+
import { CID } from 'multiformats/cid';
|
|
8
|
+
|
|
9
|
+
export async function getRoot(env: Env) {
|
|
10
|
+
const db = drizzle(env.DB);
|
|
11
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
12
|
+
return db.select().from(repo_root).where(eq(repo_root.did, did)).get();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Bump the repository root to a new revision with signed commit
|
|
17
|
+
*/
|
|
18
|
+
export async function bumpRoot(env: Env, prevMstRoot?: CID): Promise<{
|
|
19
|
+
commitCid: string;
|
|
20
|
+
rev: string;
|
|
21
|
+
ops: import('../lib/firehose/frames').RepoOp[];
|
|
22
|
+
mstRoot: CID;
|
|
23
|
+
}> {
|
|
24
|
+
const db = drizzle(env.DB);
|
|
25
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
26
|
+
|
|
27
|
+
// Resolve signing key (use ephemeral dev key if not configured and not production)
|
|
28
|
+
const signingKey = await getSigningKey(env);
|
|
29
|
+
|
|
30
|
+
// Get current repo state
|
|
31
|
+
const row = await db.select().from(repo_root).where(eq(repo_root.did, did)).get();
|
|
32
|
+
const prevCommitCid = row?.commitCid ? CID.parse(row.commitCid) : null;
|
|
33
|
+
|
|
34
|
+
// Get the current MST root
|
|
35
|
+
const repoManager = new RepoManager(env);
|
|
36
|
+
const mst = await repoManager.getOrCreateRoot();
|
|
37
|
+
const mstRootCid = await mst.getPointer();
|
|
38
|
+
|
|
39
|
+
// Extract operations if we have a previous MST root
|
|
40
|
+
const ops = prevMstRoot
|
|
41
|
+
? await repoManager.extractOps(prevMstRoot, mstRootCid)
|
|
42
|
+
: [];
|
|
43
|
+
|
|
44
|
+
// Generate new revision (TID)
|
|
45
|
+
const rev = generateTid();
|
|
46
|
+
|
|
47
|
+
// Create commit
|
|
48
|
+
const commit = createCommit(did, mstRootCid, rev, prevCommitCid);
|
|
49
|
+
|
|
50
|
+
// Sign commit
|
|
51
|
+
const signedCommit = await signCommit(commit, signingKey);
|
|
52
|
+
|
|
53
|
+
// Calculate commit CID
|
|
54
|
+
const cid = await commitCid(signedCommit);
|
|
55
|
+
const cidString = cid.toString();
|
|
56
|
+
|
|
57
|
+
// Update repo root
|
|
58
|
+
await db
|
|
59
|
+
.insert(repo_root)
|
|
60
|
+
.values({
|
|
61
|
+
did,
|
|
62
|
+
commitCid: cidString,
|
|
63
|
+
rev: parseInt(rev, 36), // Convert TID to number for compatibility
|
|
64
|
+
})
|
|
65
|
+
.onConflictDoUpdate({
|
|
66
|
+
target: repo_root.did,
|
|
67
|
+
set: {
|
|
68
|
+
commitCid: cidString,
|
|
69
|
+
rev: parseInt(rev, 36),
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
.run();
|
|
73
|
+
|
|
74
|
+
// Serialize commit for storage
|
|
75
|
+
const commitBytes = serializeCommit(signedCommit);
|
|
76
|
+
const commitData = JSON.stringify({
|
|
77
|
+
did: signedCommit.did,
|
|
78
|
+
version: signedCommit.version,
|
|
79
|
+
data: signedCommit.data.toString(),
|
|
80
|
+
rev: signedCommit.rev,
|
|
81
|
+
prev: signedCommit.prev?.toString() || null,
|
|
82
|
+
});
|
|
83
|
+
// Encode signature to base64 (workers-safe)
|
|
84
|
+
let s = '';
|
|
85
|
+
for (const b of signedCommit.sig) s += String.fromCharCode(b);
|
|
86
|
+
const sigBase64 = btoa(s);
|
|
87
|
+
|
|
88
|
+
// Append to commit log
|
|
89
|
+
await appendCommit(env, cidString, rev, commitData, sigBase64);
|
|
90
|
+
|
|
91
|
+
return { commitCid: cidString, rev, ops, mstRoot: mstRootCid };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function appendCommit(env: Env, cid: string, rev: string, data: string, sig: string) {
|
|
95
|
+
const db = drizzle(env.DB);
|
|
96
|
+
const ts = Date.now();
|
|
97
|
+
|
|
98
|
+
await db
|
|
99
|
+
.insert(commit_log)
|
|
100
|
+
.values({
|
|
101
|
+
cid,
|
|
102
|
+
rev,
|
|
103
|
+
data,
|
|
104
|
+
sig,
|
|
105
|
+
ts,
|
|
106
|
+
})
|
|
107
|
+
.run();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cache for dev-mode ephemeral signing key (in-memory for worker/astro dev)
|
|
111
|
+
let cachedDevSigningKey: string | undefined;
|
|
112
|
+
|
|
113
|
+
async function getSigningKey(env: Env): Promise<string> {
|
|
114
|
+
const configured = env.REPO_SIGNING_KEY;
|
|
115
|
+
if (configured && configured.trim() !== '') return configured;
|
|
116
|
+
|
|
117
|
+
const envName = (env as any).ENVIRONMENT || 'development';
|
|
118
|
+
if (envName !== 'production') {
|
|
119
|
+
if (cachedDevSigningKey) return cachedDevSigningKey;
|
|
120
|
+
// Generate an ephemeral Ed25519 keypair and cache private key (PKCS#8 base64)
|
|
121
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
122
|
+
{ name: 'Ed25519', namedCurve: 'Ed25519' } as any,
|
|
123
|
+
true,
|
|
124
|
+
['sign', 'verify']
|
|
125
|
+
);
|
|
126
|
+
const pkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
127
|
+
let s = '';
|
|
128
|
+
const u8 = new Uint8Array(pkcs8);
|
|
129
|
+
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
|
|
130
|
+
cachedDevSigningKey = btoa(s);
|
|
131
|
+
return cachedDevSigningKey;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new Error('REPO_SIGNING_KEY not configured');
|
|
135
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
|
|
3
|
+
export const repo_root = sqliteTable('repo_root', {
|
|
4
|
+
did: text('did').primaryKey().notNull(),
|
|
5
|
+
commitCid: text('commit_cid').notNull(),
|
|
6
|
+
rev: integer('rev').notNull(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const record = sqliteTable('record', {
|
|
10
|
+
uri: text('uri').primaryKey().notNull(),
|
|
11
|
+
did: text('did').notNull(),
|
|
12
|
+
cid: text('cid').notNull(),
|
|
13
|
+
json: text('json').notNull(),
|
|
14
|
+
createdAt: integer('created_at', { mode: 'number' }).default(0),
|
|
15
|
+
}, (table) => ({
|
|
16
|
+
// Index for collection queries (did + collection extracted from uri)
|
|
17
|
+
didIdx: index('record_did_idx').on(table.did),
|
|
18
|
+
// Index for CID lookups (used in getRecordsByCids)
|
|
19
|
+
cidIdx: index('record_cid_idx').on(table.cid),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
export const blob_ref = sqliteTable('blob', {
|
|
23
|
+
cid: text('cid').primaryKey().notNull(),
|
|
24
|
+
did: text('did').notNull(),
|
|
25
|
+
key: text('key').notNull(),
|
|
26
|
+
mime: text('mime').notNull(),
|
|
27
|
+
size: integer('size').notNull(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const blob_usage = sqliteTable('blob_usage', {
|
|
31
|
+
recordUri: text('record_uri').notNull(),
|
|
32
|
+
key: text('key').notNull(),
|
|
33
|
+
}, (table) => ({
|
|
34
|
+
// Composite primary key on recordUri and key
|
|
35
|
+
pk: primaryKey({ columns: [table.recordUri, table.key] }),
|
|
36
|
+
// Index for GC queries (finding blobs by record)
|
|
37
|
+
recordUriIdx: index('blob_usage_record_uri_idx').on(table.recordUri),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// Commit log stores full commit history for firehose and sync
|
|
41
|
+
// Pruning policy: Keep last N commits (default: 10000) to prevent unbounded growth
|
|
42
|
+
// Older commits can be safely removed as they're not needed for sync after a certain point
|
|
43
|
+
// The MST and current repo state are preserved independently
|
|
44
|
+
export const commit_log = sqliteTable('commit_log', {
|
|
45
|
+
seq: integer('seq').primaryKey(),
|
|
46
|
+
cid: text('cid').notNull(),
|
|
47
|
+
rev: text('rev').notNull(), // TID format
|
|
48
|
+
data: text('data').notNull(), // Full commit object as JSON
|
|
49
|
+
sig: text('sig').notNull(), // Signature as base64
|
|
50
|
+
ts: integer('ts').notNull(),
|
|
51
|
+
}, (table) => ({
|
|
52
|
+
// Index for pruning old commits
|
|
53
|
+
seqIdx: index('commit_log_seq_idx').on(table.seq),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// Blockstore stores MST nodes (Merkle Search Tree blocks)
|
|
57
|
+
// Each MST node is stored as a CBOR-encoded block identified by its CID
|
|
58
|
+
// GC policy: Remove blocks not referenced by recent commits (keep blocks from last N commits)
|
|
59
|
+
export const blockstore = sqliteTable('blockstore', {
|
|
60
|
+
cid: text('cid').primaryKey(),
|
|
61
|
+
bytes: text('bytes'),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const token_revocation = sqliteTable('token_revocation', {
|
|
65
|
+
jti: text('jti').primaryKey().notNull(),
|
|
66
|
+
exp: integer('exp').notNull(),
|
|
67
|
+
revoked_at: integer('revoked_at').notNull(),
|
|
68
|
+
}, (table) => ({
|
|
69
|
+
// Index for cleanup queries (finding expired tokens)
|
|
70
|
+
expIdx: index('token_revocation_exp_idx').on(table.exp),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
export const login_attempts = sqliteTable('login_attempts', {
|
|
74
|
+
ip: text('ip').primaryKey().notNull(),
|
|
75
|
+
attempts: integer('attempts').notNull().default(0),
|
|
76
|
+
locked_until: integer('locked_until'),
|
|
77
|
+
last_attempt: integer('last_attempt').notNull(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Blob quota tracking per DID
|
|
81
|
+
export const blob_quota = sqliteTable('blob_quota', {
|
|
82
|
+
did: text('did').primaryKey().notNull(),
|
|
83
|
+
total_bytes: integer('total_bytes').notNull().default(0),
|
|
84
|
+
blob_count: integer('blob_count').notNull().default(0),
|
|
85
|
+
updated_at: integer('updated_at').notNull(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export type RecordRow = typeof record.$inferSelect;
|
|
89
|
+
export type NewRecordRow = typeof record.$inferInsert;
|
package/src/db/seed.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
}
|
|
14
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { putRecord as dalPutRecord, getRecord as dalGetRecord } from '../db/dal';
|
|
3
|
+
|
|
4
|
+
export async function POST_db_bootstrap(ctx: APIContext) {
|
|
5
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
6
|
+
const db = env.DB;
|
|
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
|
+
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
|
+
await db.exec("CREATE TABLE IF NOT EXISTS blob_usage (record_uri TEXT NOT NULL, key TEXT NOT NULL);");
|
|
10
|
+
await db.exec("CREATE TABLE IF NOT EXISTS repo_root (did TEXT PRIMARY KEY, commit_cid TEXT NOT NULL, rev INTEGER NOT NULL);");
|
|
11
|
+
await db.exec("CREATE INDEX IF NOT EXISTS record_cid_idx ON record(cid);");
|
|
12
|
+
await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_record_idx ON blob_usage(record_uri);");
|
|
13
|
+
return new Response('ok');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function POST_record(ctx: APIContext) {
|
|
17
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
18
|
+
const body: any = await ctx.request.json().catch(() => ({} as any));
|
|
19
|
+
const uri = body.uri;
|
|
20
|
+
if (!uri) return new Response('missing uri', { status: 400 });
|
|
21
|
+
const row = { uri, cid: 'cid-dev', json: JSON.stringify(body.json ?? { hello: 'world' }) } as const;
|
|
22
|
+
await dalPutRecord(env, row as any);
|
|
23
|
+
return Response.json({ ok: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function GET_record(ctx: APIContext) {
|
|
27
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
28
|
+
const url = new URL(ctx.request.url);
|
|
29
|
+
const uri = url.searchParams.get('uri');
|
|
30
|
+
if (!uri) return new Response('missing uri', { status: 400 });
|
|
31
|
+
const row = await dalGetRecord(env, uri);
|
|
32
|
+
if (!row) return new Response('Not Found', { status: 404 });
|
|
33
|
+
return Response.json(row);
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
|
|
3
|
+
export async function GET(ctx: APIContext) {
|
|
4
|
+
try {
|
|
5
|
+
const db = (ctx.locals as any).runtime?.env?.DB ?? (ctx.locals as any).DB ?? (globalThis as any).DB;
|
|
6
|
+
if (db) {
|
|
7
|
+
await db.prepare('select 1').first();
|
|
8
|
+
}
|
|
9
|
+
return new Response('ok', { status: 200 });
|
|
10
|
+
} catch {
|
|
11
|
+
return new Response('db not ready', { status: 503 });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import { RepoManager } from '../services/repo-manager';
|
|
3
|
+
|
|
4
|
+
async function readJson(req: Request): Promise<any> { try { return await req.json(); } catch { return {}; } }
|
|
5
|
+
|
|
6
|
+
function bearer(req: Request): string | null { const h = req.headers.get('authorization')||''; const m = /^Bearer\s+(.+)$/i.exec(h); return m?m[1]:null; }
|
|
7
|
+
async function verifyJwt(_ctx: APIContext, token: string): Promise<any | null> { try { return { payload: JSON.parse(atob(token)) }; } catch { return null; } }
|
|
8
|
+
|
|
9
|
+
async function ensureAuth(ctx: APIContext): Promise<boolean> {
|
|
10
|
+
const token = bearer(ctx.request);
|
|
11
|
+
if (!token) return false;
|
|
12
|
+
const ver = await verifyJwt(ctx, token);
|
|
13
|
+
return !!ver;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function createRecord(ctx: APIContext) {
|
|
17
|
+
if (!(await ensureAuth(ctx))) return Response.json({ error: 'AuthRequired' }, { status: 401 });
|
|
18
|
+
const { collection, rkey, record } = await readJson(ctx.request);
|
|
19
|
+
if (!collection || !record) return Response.json({ error: 'BadRequest' }, { status: 400 });
|
|
20
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
21
|
+
const repo = new RepoManager(env);
|
|
22
|
+
const commit = await repo.createRecord(collection, record, rkey);
|
|
23
|
+
return Response.json(commit);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function putRecord(ctx: APIContext) {
|
|
27
|
+
if (!(await ensureAuth(ctx))) return Response.json({ error: 'AuthRequired' }, { status: 401 });
|
|
28
|
+
const { collection, rkey, record } = await readJson(ctx.request);
|
|
29
|
+
if (!collection || !rkey || !record) return Response.json({ error: 'BadRequest' }, { status: 400 });
|
|
30
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
31
|
+
const repo = new RepoManager(env);
|
|
32
|
+
const commit = await repo.putRecord(collection, rkey, record);
|
|
33
|
+
return Response.json(commit);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function deleteRecord(ctx: APIContext) {
|
|
37
|
+
if (!(await ensureAuth(ctx))) return Response.json({ error: 'AuthRequired' }, { status: 401 });
|
|
38
|
+
const { collection, rkey } = await readJson(ctx.request);
|
|
39
|
+
if (!collection || !rkey) return Response.json({ error: 'BadRequest' }, { status: 400 });
|
|
40
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
41
|
+
const repo = new RepoManager(env);
|
|
42
|
+
const commit = await repo.deleteRecord(collection, rkey);
|
|
43
|
+
return Response.json(commit);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function getRecord(ctx: APIContext) {
|
|
47
|
+
const url = new URL(ctx.request.url);
|
|
48
|
+
const repo = url.searchParams.get('repo') || '';
|
|
49
|
+
const collection = url.searchParams.get('collection') || '';
|
|
50
|
+
const rkey = url.searchParams.get('rkey') || '';
|
|
51
|
+
if (!repo || !collection || !rkey) return Response.json({ error: 'BadRequest' }, { status: 400 });
|
|
52
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
53
|
+
const manager = new RepoManager(env);
|
|
54
|
+
const res = await manager.getRecord(collection, rkey);
|
|
55
|
+
if (!res) return Response.json({ error: 'NotFound' }, { status: 404 });
|
|
56
|
+
return Response.json(res);
|
|
57
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
|
|
3
|
+
async function readJson(req: Request): Promise<any> {
|
|
4
|
+
try { return await req.json(); } catch { return {}; }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function signJwt(_ctx: APIContext, payload: any, _kind: 'access'|'refresh'): Promise<string> {
|
|
8
|
+
// Keep simple non-cryptographic token for tests; reuse existing shape
|
|
9
|
+
const base = { ...payload, exp: Math.floor(Date.now()/1000)+3600 };
|
|
10
|
+
return btoa(JSON.stringify(base));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function POST(ctx: APIContext) {
|
|
14
|
+
const { identifier, password } = await readJson(ctx.request);
|
|
15
|
+
const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
|
|
16
|
+
const ok = !!password && password === (env.USER_PASSWORD ?? 'changeme');
|
|
17
|
+
if (!ok) return Response.json({ error: 'InvalidPassword' }, { status: 401 });
|
|
18
|
+
const did = env.PDS_DID ?? 'did:example:single-user';
|
|
19
|
+
const handle = env.PDS_HANDLE ?? identifier ?? 'user.example';
|
|
20
|
+
const jti = crypto.randomUUID();
|
|
21
|
+
const accessJwt = await signJwt(ctx, { sub: did, handle, t: 'access' }, 'access');
|
|
22
|
+
const refreshJwt = await signJwt(ctx, { sub: did, handle, t: 'refresh', jti }, 'refresh');
|
|
23
|
+
return Response.json({ did, handle, accessJwt, refreshJwt });
|
|
24
|
+
}
|
|
25
|
+
|