@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.
Files changed (90) hide show
  1. package/README.md +558 -0
  2. package/index.d.ts +12 -0
  3. package/index.js +129 -0
  4. package/package.json +75 -0
  5. package/src/_worker.ts +44 -0
  6. package/src/app.ts +10 -0
  7. package/src/db/client.ts +7 -0
  8. package/src/db/dal.ts +97 -0
  9. package/src/db/repo.ts +135 -0
  10. package/src/db/schema.ts +89 -0
  11. package/src/db/seed.ts +14 -0
  12. package/src/env.d.ts +4 -0
  13. package/src/handlers/debug.ts +34 -0
  14. package/src/handlers/health.ts +6 -0
  15. package/src/handlers/ready.ts +14 -0
  16. package/src/handlers/root.ts +5 -0
  17. package/src/handlers/wellknown.ts +7 -0
  18. package/src/handlers/xrpc.repo.core.ts +57 -0
  19. package/src/handlers/xrpc.server.createSession.ts +25 -0
  20. package/src/handlers/xrpc.server.refreshSession.ts +43 -0
  21. package/src/lib/auth.ts +20 -0
  22. package/src/lib/blockstore-gc.ts +197 -0
  23. package/src/lib/cache.ts +236 -0
  24. package/src/lib/car-reader.ts +157 -0
  25. package/src/lib/commit-log-pruning.ts +76 -0
  26. package/src/lib/commit.ts +162 -0
  27. package/src/lib/config.ts +208 -0
  28. package/src/lib/errors.ts +142 -0
  29. package/src/lib/firehose/frames.ts +229 -0
  30. package/src/lib/firehose/parse.ts +82 -0
  31. package/src/lib/firehose/validation.ts +9 -0
  32. package/src/lib/handle.ts +90 -0
  33. package/src/lib/jwt.ts +150 -0
  34. package/src/lib/logger.ts +73 -0
  35. package/src/lib/metrics.ts +194 -0
  36. package/src/lib/mst/blockstore.ts +105 -0
  37. package/src/lib/mst/index.ts +3 -0
  38. package/src/lib/mst/mst.ts +643 -0
  39. package/src/lib/mst/util.ts +86 -0
  40. package/src/lib/ratelimit.ts +34 -0
  41. package/src/lib/sequencer.ts +10 -0
  42. package/src/lib/streaming-car.ts +137 -0
  43. package/src/lib/token-cleanup.ts +38 -0
  44. package/src/lib/tracing.ts +136 -0
  45. package/src/lib/util.ts +55 -0
  46. package/src/middleware.ts +102 -0
  47. package/src/pages/.well-known/atproto-did.ts +7 -0
  48. package/src/pages/.well-known/did.json.ts +76 -0
  49. package/src/pages/debug/blob/[...key].ts +27 -0
  50. package/src/pages/debug/db/bootstrap.ts +23 -0
  51. package/src/pages/debug/db/commits.ts +20 -0
  52. package/src/pages/debug/gc/blobs.ts +16 -0
  53. package/src/pages/debug/record.ts +33 -0
  54. package/src/pages/health.ts +68 -0
  55. package/src/pages/index.astro +57 -0
  56. package/src/pages/index.ts +2 -0
  57. package/src/pages/ready.ts +16 -0
  58. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +38 -0
  59. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +45 -0
  60. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +73 -0
  61. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +36 -0
  62. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +36 -0
  63. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +51 -0
  64. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +25 -0
  65. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +57 -0
  66. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +36 -0
  67. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +53 -0
  68. package/src/pages/xrpc/com.atproto.server.createSession.ts +92 -0
  69. package/src/pages/xrpc/com.atproto.server.deleteSession.ts +25 -0
  70. package/src/pages/xrpc/com.atproto.server.describeServer.ts +17 -0
  71. package/src/pages/xrpc/com.atproto.server.getSession.ts +46 -0
  72. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +67 -0
  73. package/src/pages/xrpc/com.atproto.sync.getBlocks.json.ts +16 -0
  74. package/src/pages/xrpc/com.atproto.sync.getBlocks.ts +56 -0
  75. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +20 -0
  76. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +43 -0
  77. package/src/pages/xrpc/com.atproto.sync.getHead.ts +11 -0
  78. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +42 -0
  79. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +63 -0
  80. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +20 -0
  81. package/src/pages/xrpc/com.atproto.sync.getRepo.range.ts +34 -0
  82. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +17 -0
  83. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +53 -0
  84. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +31 -0
  85. package/src/services/car.ts +249 -0
  86. package/src/services/r2-blob-store.ts +87 -0
  87. package/src/services/repo-manager.ts +339 -0
  88. package/src/shims/astro-internal-handler.d.ts +4 -0
  89. package/src/worker/sequencer.ts +563 -0
  90. 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
+
@@ -0,0 +1,7 @@
1
+ // Types via tsconfig.app.json
2
+ import { drizzle } from 'drizzle-orm/d1';
3
+ import type { Env } from '../env';
4
+
5
+ export function getDb(env: Env) {
6
+ return drizzle(env.DB);
7
+ }
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
+ }
@@ -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,4 @@
1
+ /// <reference path="../.astro/types.d.ts" />
2
+ /// <reference path="../types/env.d.ts" />
3
+
4
+ export type { Env, PdsLocals } from '../types/env';
@@ -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,6 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export async function GET(_ctx: APIContext) {
4
+ return new Response('ok', { status: 200 });
5
+ }
6
+
@@ -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,5 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export async function GET(_ctx: APIContext) {
4
+ return new Response('Alteran is alive', { status: 200 });
5
+ }
@@ -0,0 +1,7 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export async function GET(ctx: APIContext) {
4
+ const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
5
+ return new Response(env.PDS_DID ?? '', { status: 200 });
6
+ }
7
+
@@ -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
+