@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
@@ -0,0 +1,92 @@
1
+ import type { APIContext } from 'astro';
2
+ import { signJwt } from '@alteran/lib/jwt';
3
+ import { readJson } from '@alteran/lib/util';
4
+ import { drizzle } from 'drizzle-orm/d1';
5
+ import { login_attempts } from '@alteran/db/schema';
6
+ import { eq } from 'drizzle-orm';
7
+
8
+ export const prerender = false;
9
+
10
+ const MAX_LOGIN_ATTEMPTS = 5;
11
+ const LOCKOUT_DURATION_SEC = 15 * 60; // 15 minutes
12
+
13
+ export async function POST({ locals, request }: APIContext) {
14
+ const { env } = locals.runtime;
15
+ const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown';
16
+
17
+ const db = drizzle(env.DB);
18
+ const now = Math.floor(Date.now() / 1000);
19
+
20
+ // Check if IP is locked out
21
+ const attempt = await db.select().from(login_attempts).where(eq(login_attempts.ip, clientIp)).get();
22
+ if (attempt && attempt.locked_until && attempt.locked_until > now) {
23
+ const remainingSeconds = attempt.locked_until - now;
24
+ return new Response(
25
+ JSON.stringify({
26
+ error: 'RateLimitExceeded',
27
+ message: `Account locked due to too many failed attempts. Try again in ${Math.ceil(remainingSeconds / 60)} minutes.`
28
+ }),
29
+ { status: 429, headers: { 'Content-Type': 'application/json' } }
30
+ );
31
+ }
32
+
33
+ const { identifier, password } = await readJson(request).catch(() => ({ identifier: '', password: '' }));
34
+ const ok = !!password && password === (env.USER_PASSWORD ?? 'changeme');
35
+
36
+ if (!ok) {
37
+ // Track failed attempt
38
+ const currentAttempts = (attempt?.attempts || 0) + 1;
39
+ const lockedUntil = currentAttempts >= MAX_LOGIN_ATTEMPTS ? now + LOCKOUT_DURATION_SEC : null;
40
+
41
+ if (attempt) {
42
+ await db.update(login_attempts)
43
+ .set({
44
+ attempts: currentAttempts,
45
+ locked_until: lockedUntil,
46
+ last_attempt: now
47
+ })
48
+ .where(eq(login_attempts.ip, clientIp))
49
+ .run();
50
+ } else {
51
+ await db.insert(login_attempts).values({
52
+ ip: clientIp,
53
+ attempts: currentAttempts,
54
+ locked_until: lockedUntil,
55
+ last_attempt: now,
56
+ }).run();
57
+ }
58
+
59
+ if (lockedUntil) {
60
+ return new Response(
61
+ JSON.stringify({
62
+ error: 'RateLimitExceeded',
63
+ message: 'Too many failed login attempts. Account locked for 15 minutes.'
64
+ }),
65
+ { status: 429, headers: { 'Content-Type': 'application/json' } }
66
+ );
67
+ }
68
+
69
+ return new Response(
70
+ JSON.stringify({
71
+ error: 'AuthRequired',
72
+ message: 'Invalid credentials'
73
+ }),
74
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
75
+ );
76
+ }
77
+
78
+ // Successful login - reset attempts
79
+ if (attempt) {
80
+ await db.delete(login_attempts).where(eq(login_attempts.ip, clientIp)).run();
81
+ }
82
+
83
+ const did = env.PDS_DID ?? 'did:example:single-user';
84
+ const handle = env.PDS_HANDLE ?? identifier ?? 'user.example';
85
+ const jti = crypto.randomUUID();
86
+ const accessJwt = await signJwt(env, { sub: did, handle, t: 'access' }, 'access');
87
+ const refreshJwt = await signJwt(env, { sub: did, handle, t: 'refresh', jti }, 'refresh');
88
+
89
+ return new Response(JSON.stringify({ did, handle, accessJwt, refreshJwt }), {
90
+ headers: { 'Content-Type': 'application/json' },
91
+ });
92
+ }
@@ -0,0 +1,25 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ /**
6
+ * com.atproto.server.deleteSession
7
+ * Delete the current session (logout)
8
+ */
9
+ export async function POST({ locals }: APIContext) {
10
+ const { env } = locals.runtime;
11
+
12
+ // TODO: Implement proper session revocation
13
+ // For single-user PDS, we just return success
14
+ // In a full implementation, this would:
15
+ // 1. Extract refresh token from Authorization header
16
+ // 2. Add it to a blacklist/revocation list
17
+ // 3. Invalidate associated access tokens
18
+
19
+ return new Response(JSON.stringify({}), {
20
+ status: 200,
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,17 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ export function GET({ locals }: APIContext) {
6
+ const { env } = locals.runtime;
7
+ const body = {
8
+ version: 'experimental',
9
+ did: env.PDS_DID ?? null,
10
+ handle: env.PDS_HANDLE ?? null,
11
+ inviteCodeRequired: false,
12
+ links: {},
13
+ };
14
+ return new Response(JSON.stringify(body), {
15
+ headers: { 'Content-Type': 'application/json' },
16
+ });
17
+ }
@@ -0,0 +1,46 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ /**
6
+ * com.atproto.server.getSession
7
+ * Get information about the current session
8
+ */
9
+ export async function GET({ locals }: APIContext) {
10
+ const { env } = locals.runtime;
11
+
12
+ // TODO: Implement proper session validation from Authorization header
13
+ // For now, return basic session info for single-user PDS
14
+
15
+ const did = env.PDS_DID ?? 'did:example:single-user';
16
+ const handle = env.PDS_HANDLE ?? 'user.example.com';
17
+
18
+ return new Response(
19
+ JSON.stringify({
20
+ did,
21
+ handle,
22
+ email: 'user@example.com', // Single-user PDS doesn't have email
23
+ emailConfirmed: true,
24
+ emailAuthFactor: false,
25
+ didDoc: {
26
+ '@context': ['https://www.w3.org/ns/did/v1'],
27
+ id: did,
28
+ alsoKnownAs: [`at://${handle}`],
29
+ verificationMethod: [],
30
+ service: [
31
+ {
32
+ id: '#atproto_pds',
33
+ type: 'AtprotoPersonalDataServer',
34
+ serviceEndpoint: `https://${handle}`,
35
+ },
36
+ ],
37
+ },
38
+ }),
39
+ {
40
+ status: 200,
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ },
44
+ }
45
+ );
46
+ };
@@ -0,0 +1,67 @@
1
+ import type { APIContext } from 'astro';
2
+ import { signJwt, verifyJwt } from '@alteran/lib/jwt';
3
+ import { bearerToken } from '@alteran/lib/util';
4
+ import { lazyCleanupExpiredTokens } from '@alteran/lib/token-cleanup';
5
+ import { drizzle } from 'drizzle-orm/d1';
6
+ import { token_revocation } from '@alteran/db/schema';
7
+ import { eq } from 'drizzle-orm';
8
+
9
+ export const prerender = false;
10
+
11
+ export async function POST({ locals, request }: APIContext) {
12
+ const { env } = locals.runtime;
13
+ const token = bearerToken(request);
14
+ if (!token) {
15
+ return new Response(
16
+ JSON.stringify({ error: 'AuthRequired', message: 'No authorization token provided' }),
17
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
18
+ );
19
+ }
20
+
21
+ const ver = await verifyJwt(env, token).catch(() => null);
22
+ if (!ver || ver.payload.t !== 'refresh') {
23
+ return new Response(
24
+ JSON.stringify({ error: 'InvalidToken', message: 'Invalid or expired refresh token' }),
25
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
26
+ );
27
+ }
28
+
29
+ // Reject if JTI is revoked (single-use refresh tokens)
30
+ const jtiOld = String(ver.payload.jti || '');
31
+ if (jtiOld) {
32
+ const db = drizzle(env.DB);
33
+ const revoked = await db.select().from(token_revocation).where(eq(token_revocation.jti, jtiOld)).get();
34
+ if (revoked) {
35
+ return new Response(
36
+ JSON.stringify({ error: 'InvalidToken', message: 'Refresh token has already been used' }),
37
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
38
+ );
39
+ }
40
+ }
41
+
42
+ const did = String(ver.payload.sub || (env.PDS_DID ?? 'did:example:single-user'));
43
+ const handle = String(ver.payload.handle || env.PDS_HANDLE || 'user.example');
44
+
45
+ // Rotate: generate new token pair with new JTI
46
+ const jtiNew = crypto.randomUUID();
47
+ const accessJwt = await signJwt(env, { sub: did, handle, t: 'access' }, 'access');
48
+ const refreshJwt = await signJwt(env, { sub: did, handle, t: 'refresh', jti: jtiNew }, 'refresh');
49
+
50
+ // Revoke old refresh token by inserting into revocation table
51
+ if (jtiOld && ver.payload.exp) {
52
+ const db = drizzle(env.DB);
53
+ const now = Math.floor(Date.now() / 1000);
54
+ await db.insert(token_revocation).values({
55
+ jti: jtiOld,
56
+ exp: Number(ver.payload.exp),
57
+ revoked_at: now,
58
+ }).run();
59
+ }
60
+
61
+ // Lazy cleanup of expired tokens (runs 1% of the time)
62
+ lazyCleanupExpiredTokens(env).catch(console.error);
63
+
64
+ return new Response(JSON.stringify({ did, handle, accessJwt, refreshJwt }), {
65
+ headers: { 'Content-Type': 'application/json' },
66
+ });
67
+ }
@@ -0,0 +1,16 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRecordsByCids as dalGetByCids } from '@alteran/db/dal';
3
+ import { tryParse } from '@alteran/lib/util';
4
+
5
+ export const prerender = false;
6
+
7
+ export async function GET({ locals, request }: APIContext) {
8
+ const { env } = locals.runtime;
9
+ const url = new URL(request.url);
10
+ const cids = (url.searchParams.get('cids') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
11
+ const rows = await dalGetByCids(env, cids);
12
+ const blocks = rows.map((r) => ({ cid: r.cid, value: tryParse(r.json) }));
13
+ return new Response(JSON.stringify({ blocks }), {
14
+ headers: { 'Content-Type': 'application/json' },
15
+ });
16
+ }
@@ -0,0 +1,56 @@
1
+ import type { APIContext } from 'astro';
2
+ import { NotFound } from '@alteran/lib/errors';
3
+ import { D1Blockstore } from '@alteran/lib/mst';
4
+ import { CID } from 'multiformats/cid';
5
+ import { encodeExistingBlocksToCAR } from '@alteran/services/car';
6
+
7
+ export const prerender = false;
8
+
9
+ export async function GET({ locals, request }: APIContext) {
10
+ const { env } = locals.runtime;
11
+ const url = new URL(request.url);
12
+ const cids = (url.searchParams.get('cids') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
13
+
14
+ if (!cids.length) {
15
+ return new Response(
16
+ JSON.stringify({ error: 'InvalidRequest', message: 'cids parameter required' }),
17
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
18
+ );
19
+ }
20
+
21
+ const blockstore = new D1Blockstore(env);
22
+ const roots: CID[] = [];
23
+ const blocks: { cid: CID; bytes: Uint8Array }[] = [];
24
+ const missingCids: string[] = [];
25
+
26
+ for (const c of cids) {
27
+ try {
28
+ const cid = CID.parse(c);
29
+ const bytes = await blockstore.get(cid);
30
+ if (bytes) {
31
+ roots.push(cid);
32
+ blocks.push({ cid, bytes });
33
+ } else {
34
+ missingCids.push(c);
35
+ }
36
+ } catch {
37
+ missingCids.push(c);
38
+ }
39
+ }
40
+
41
+ if (missingCids.length > 0) {
42
+ return new NotFound(
43
+ `Blocks not found: ${missingCids.join(', ')}`,
44
+ { missingCids }
45
+ ).toResponse(locals.requestId);
46
+ }
47
+
48
+ const carBytes = encodeExistingBlocksToCAR(roots, blocks);
49
+
50
+ return new Response(carBytes as any, {
51
+ headers: {
52
+ 'Content-Type': 'application/vnd.ipld.car; version=1',
53
+ 'Content-Disposition': 'inline; filename="blocks.car"',
54
+ },
55
+ });
56
+ }
@@ -0,0 +1,20 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRoot as getRepoRoot } from '@alteran/db/repo';
3
+ import { listRecords as dalListRecords } from '@alteran/db/dal';
4
+ import { tryParse } from '@alteran/lib/util';
5
+
6
+ export const prerender = false;
7
+
8
+ export async function GET({ locals, request }: APIContext) {
9
+ const { env } = locals.runtime;
10
+ const url = new URL(request.url);
11
+ const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
12
+ const head = await getRepoRoot(env);
13
+ const rows = await dalListRecords(env);
14
+ const records = rows
15
+ .filter((r) => r.uri.startsWith(`at://${did}/`))
16
+ .map((r) => ({ uri: r.uri, cid: r.cid, value: tryParse(r.json) }));
17
+ return new Response(JSON.stringify({ did, head: head?.commitCid ?? null, rev: head?.rev ?? 0, records }), {
18
+ headers: { 'Content-Type': 'application/json' },
19
+ });
20
+ }
@@ -0,0 +1,43 @@
1
+ import type { APIContext } from 'astro';
2
+ import { buildRepoCar, buildRepoCarRange } from '@alteran/services/car';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET({ locals, request }: APIContext) {
7
+ const { env } = locals.runtime;
8
+ const url = new URL(request.url);
9
+ const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
10
+
11
+ // Support commit range queries
12
+ const fromParam = url.searchParams.get('from');
13
+ const toParam = url.searchParams.get('to');
14
+
15
+ let car;
16
+ if (fromParam && toParam) {
17
+ // Return commits in range [from, to]
18
+ const fromSeq = parseInt(fromParam, 10);
19
+ const toSeq = parseInt(toParam, 10);
20
+
21
+ if (isNaN(fromSeq) || isNaN(toSeq) || fromSeq < 0 || toSeq < fromSeq) {
22
+ return new Response(
23
+ JSON.stringify({
24
+ error: 'InvalidRequest',
25
+ message: 'Invalid commit range: from and to must be valid sequence numbers with from <= to'
26
+ }),
27
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
28
+ );
29
+ }
30
+
31
+ car = await buildRepoCarRange(env, fromSeq, toSeq);
32
+ } else {
33
+ // Return full repo snapshot
34
+ car = await buildRepoCar(env, did);
35
+ }
36
+
37
+ return new Response(car.bytes as any, {
38
+ headers: {
39
+ 'Content-Type': 'application/vnd.ipld.car; version=1',
40
+ 'Content-Disposition': 'inline; filename="checkout.car"',
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,11 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRoot as getRepoRoot } from '@alteran/db/repo';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET({ locals }: APIContext) {
7
+ const { env } = locals.runtime;
8
+ const root = await getRepoRoot(env);
9
+ if (!root) return new Response(JSON.stringify({ root: null, rev: 0 }), { headers: { 'Content-Type': 'application/json' } });
10
+ return new Response(JSON.stringify({ root: root.commitCid, rev: root.rev }), { headers: { 'Content-Type': 'application/json' } });
11
+ }
@@ -0,0 +1,42 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRoot } from '@alteran/db/repo';
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * com.atproto.sync.getLatestCommit
8
+ * Get the latest commit CID and revision for a repository
9
+ */
10
+ export async function GET({ locals, url }: APIContext) {
11
+ const { env } = locals.runtime;
12
+
13
+ const did = url.searchParams.get('did') || env.PDS_DID || 'did:example:single-user';
14
+
15
+ try {
16
+ const root = await getRoot(env);
17
+
18
+ if (!root) {
19
+ return new Response(
20
+ JSON.stringify({ error: 'RepoNotFound' }),
21
+ { status: 404, headers: { 'Content-Type': 'application/json' } }
22
+ );
23
+ }
24
+
25
+ return new Response(
26
+ JSON.stringify({
27
+ cid: root.commitCid,
28
+ rev: root.rev.toString(),
29
+ }),
30
+ {
31
+ status: 200,
32
+ headers: { 'Content-Type': 'application/json' },
33
+ }
34
+ );
35
+ } catch (error) {
36
+ console.error('getLatestCommit error:', error);
37
+ return new Response(
38
+ JSON.stringify({ error: 'InternalServerError', message: String(error) }),
39
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
40
+ );
41
+ }
42
+ }
@@ -0,0 +1,63 @@
1
+ import type { APIContext } from 'astro';
2
+ import { RepoManager } from '@alteran/services/repo-manager';
3
+ import { encodeRecordBlock } from '@alteran/services/car';
4
+ import * as dagCbor from '@ipld/dag-cbor';
5
+ import { CID } from 'multiformats/cid';
6
+ import { sha256 } from 'multiformats/hashes/sha2';
7
+
8
+ export const prerender = false;
9
+
10
+ /**
11
+ * com.atproto.sync.getRecord
12
+ * Get a single record as a CAR file
13
+ */
14
+ export async function GET({ locals, url }: APIContext) {
15
+ const { env } = locals.runtime;
16
+
17
+ const did = url.searchParams.get('did') || env.PDS_DID || 'did:example:single-user';
18
+ const collection = url.searchParams.get('collection');
19
+ const rkey = url.searchParams.get('rkey');
20
+
21
+ if (!collection || !rkey) {
22
+ return new Response(
23
+ JSON.stringify({ error: 'InvalidRequest', message: 'collection and rkey required' }),
24
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
25
+ );
26
+ }
27
+
28
+ try {
29
+ const repoManager = new RepoManager(env);
30
+ const record = await repoManager.getRecord(collection, rkey);
31
+
32
+ if (!record) {
33
+ return new Response(
34
+ JSON.stringify({ error: 'RecordNotFound' }),
35
+ { status: 404, headers: { 'Content-Type': 'application/json' } }
36
+ );
37
+ }
38
+
39
+ // Encode the record as a single-block CAR snapshot (root = record CID)
40
+ const { cid, bytes } = await encodeRecordBlock(record);
41
+
42
+ // Minimal CAR encoding (header + single block)
43
+ const header = dagCbor.encode({ version: 1, roots: [cid] });
44
+ const varint = (n: number) => { const a:number[]=[]; while(n>=0x80){a.push((n&0x7f)|0x80); n>>>=7;} a.push(n); return new Uint8Array(a); };
45
+ const concat = (parts: Uint8Array[]) => { const len = parts.reduce((n,p)=>n+p.byteLength,0); const out = new Uint8Array(len); let o=0; for(const p of parts){out.set(p,o); o+=p.byteLength;} return out; };
46
+ const block = concat([cid.bytes, bytes]);
47
+ const carBytes = concat([varint(header.byteLength), header, varint(block.byteLength), block]);
48
+
49
+ return new Response(carBytes as any, {
50
+ status: 200,
51
+ headers: {
52
+ 'Content-Type': 'application/vnd.ipld.car; version=1',
53
+ 'Content-Disposition': 'inline; filename="record.car"',
54
+ },
55
+ });
56
+ } catch (error) {
57
+ console.error('getRecord error:', error);
58
+ return new Response(
59
+ JSON.stringify({ error: 'InternalServerError', message: String(error) }),
60
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
61
+ );
62
+ }
63
+ }
@@ -0,0 +1,20 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRoot as getRepoRoot } from '@alteran/db/repo';
3
+ import { listRecords as dalListRecords } from '@alteran/db/dal';
4
+ import { tryParse } from '@alteran/lib/util';
5
+
6
+ export const prerender = false;
7
+
8
+ export async function GET({ locals, request }: APIContext) {
9
+ const { env } = locals.runtime;
10
+ const url = new URL(request.url);
11
+ const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
12
+ const head = await getRepoRoot(env);
13
+ const rows = await dalListRecords(env);
14
+ const records = rows
15
+ .filter((r) => r.uri.startsWith(`at://${did}/`))
16
+ .map((r) => ({ uri: r.uri, cid: r.cid, value: tryParse(r.json) }));
17
+ return new Response(JSON.stringify({ did, head: head?.commitCid ?? null, rev: head?.rev ?? 0, records }), {
18
+ headers: { 'Content-Type': 'application/json' },
19
+ });
20
+ }
@@ -0,0 +1,34 @@
1
+ import type { APIContext } from 'astro';
2
+ import { buildRepoCarRange } from '@alteran/services/car';
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * Non-standard helper route used by tests to stream a CAR by commit seq range.
8
+ * Query: ?from=<seq>&to=<seq>
9
+ */
10
+ export async function GET({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+ const url = new URL(request.url);
13
+
14
+ const fromParam = url.searchParams.get('from');
15
+ const toParam = url.searchParams.get('to');
16
+
17
+ const fromSeq = parseInt(String(fromParam ?? ''), 10);
18
+ const toSeq = parseInt(String(toParam ?? ''), 10);
19
+
20
+ if (!Number.isFinite(fromSeq) || !Number.isFinite(toSeq) || fromSeq < 0 || toSeq < fromSeq) {
21
+ return new Response(
22
+ JSON.stringify({ error: 'InvalidRequest', message: 'Provide valid numeric from <= to' }),
23
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
24
+ );
25
+ }
26
+
27
+ const car = await buildRepoCarRange(env, fromSeq, toSeq);
28
+ return new Response(car.bytes as any, {
29
+ headers: {
30
+ 'Content-Type': 'application/vnd.ipld.car; version=1',
31
+ 'Content-Disposition': 'inline; filename="repo-range.car"',
32
+ },
33
+ });
34
+ }
@@ -0,0 +1,17 @@
1
+ import type { APIContext } from 'astro';
2
+ import { buildRepoCar } from '@alteran/services/car';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET({ locals, request }: APIContext) {
7
+ const { env } = locals.runtime;
8
+ const url = new URL(request.url);
9
+ const did = url.searchParams.get('did') ?? (env.PDS_DID ?? 'did:example:single-user');
10
+ const car = await buildRepoCar(env, did);
11
+ return new Response(car.bytes as any, {
12
+ headers: {
13
+ 'content-type': 'application/vnd.ipld.car; version=1',
14
+ 'content-disposition': 'inline; filename="repo.car"',
15
+ },
16
+ });
17
+ }
@@ -0,0 +1,53 @@
1
+ import type { APIContext } from 'astro';
2
+ import { drizzle } from 'drizzle-orm/d1';
3
+ import { blob_ref } from '@alteran/db/schema';
4
+ import { eq, gt, and } from 'drizzle-orm';
5
+
6
+ export const prerender = false;
7
+
8
+ /**
9
+ * com.atproto.sync.listBlobs
10
+ * List blob CIDs for a DID
11
+ */
12
+ export async function GET({ locals, url }: APIContext) {
13
+ const { env } = locals.runtime;
14
+
15
+ const did = url.searchParams.get('did') || env.PDS_DID || 'did:example:single-user';
16
+ const since = url.searchParams.get('since') || '';
17
+ const limit = parseInt(url.searchParams.get('limit') || '500', 10);
18
+
19
+ try {
20
+ const db = drizzle(env.DB);
21
+
22
+ const blobs = since
23
+ ? await db
24
+ .select()
25
+ .from(blob_ref)
26
+ .where(and(eq(blob_ref.did, did), gt(blob_ref.cid, since)))
27
+ .limit(limit)
28
+ .all()
29
+ : await db
30
+ .select()
31
+ .from(blob_ref)
32
+ .where(eq(blob_ref.did, did))
33
+ .limit(limit)
34
+ .all();
35
+
36
+ return new Response(
37
+ JSON.stringify({
38
+ cids: blobs.map(b => b.cid),
39
+ cursor: blobs.length > 0 ? blobs[blobs.length - 1].cid : undefined,
40
+ }),
41
+ {
42
+ status: 200,
43
+ headers: { 'Content-Type': 'application/json' },
44
+ }
45
+ );
46
+ } catch (error) {
47
+ console.error('listBlobs error:', error);
48
+ return new Response(
49
+ JSON.stringify({ error: 'InternalServerError', message: String(error) }),
50
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
51
+ );
52
+ }
53
+ }
@@ -0,0 +1,31 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ /**
6
+ * com.atproto.sync.listRepos
7
+ * List repositories (single-user PDS returns one repo)
8
+ */
9
+ export async function GET({ locals, url }: APIContext) {
10
+ const { env } = locals.runtime;
11
+
12
+ const did = env.PDS_DID || 'did:example:single-user';
13
+ const handle = env.PDS_HANDLE || 'user.example.com';
14
+
15
+ return new Response(
16
+ JSON.stringify({
17
+ repos: [
18
+ {
19
+ did,
20
+ head: '', // TODO: Get from repo_root
21
+ rev: '', // TODO: Get from repo_root
22
+ active: true,
23
+ },
24
+ ],
25
+ }),
26
+ {
27
+ status: 200,
28
+ headers: { 'Content-Type': 'application/json' },
29
+ }
30
+ );
31
+ }