@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,33 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRecord as dalGetRecord, putRecord as dalPutRecord } from '@alteran/db/dal';
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 uri = url.searchParams.get('uri');
10
+ if (!uri) return new Response('missing uri', { status: 400 });
11
+
12
+ const row = await dalGetRecord(env, uri);
13
+ if (!row) return new Response('not found', { status: 404 });
14
+
15
+ return new Response(JSON.stringify(row), { headers: { 'Content-Type': 'application/json' } });
16
+ }
17
+
18
+ export async function POST({ locals, request }: APIContext) {
19
+ const { env } = locals.runtime;
20
+ const body = (await request.json()) as { uri?: string; json?: unknown };
21
+ const uri = body.uri;
22
+ if (!uri) return new Response('missing uri', { status: 400 });
23
+
24
+ const did = env.PDS_DID ?? 'did:example:single-user';
25
+ const row = {
26
+ uri,
27
+ did,
28
+ cid: 'cid-dev',
29
+ json: JSON.stringify(body.json ?? { hello: 'world' }),
30
+ };
31
+ await dalPutRecord(env, row);
32
+ return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
33
+ }
@@ -0,0 +1,68 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ interface HealthCheck {
6
+ status: 'healthy' | 'unhealthy';
7
+ timestamp: string;
8
+ checks: {
9
+ database: { status: 'ok' | 'error'; message?: string };
10
+ storage: { status: 'ok' | 'error'; message?: string };
11
+ };
12
+ }
13
+
14
+ export async function GET({ locals }: APIContext) {
15
+ const { env } = locals.runtime;
16
+ const checks: HealthCheck['checks'] = {
17
+ database: { status: 'ok' },
18
+ storage: { status: 'ok' },
19
+ };
20
+
21
+ let overallStatus: 'healthy' | 'unhealthy' = 'healthy';
22
+
23
+ // Check D1 database connectivity
24
+ try {
25
+ if (env.DB) {
26
+ await env.DB.prepare('SELECT 1').first();
27
+ checks.database.status = 'ok';
28
+ } else {
29
+ checks.database.status = 'error';
30
+ checks.database.message = 'Database not configured';
31
+ overallStatus = 'unhealthy';
32
+ }
33
+ } catch (error) {
34
+ checks.database.status = 'error';
35
+ checks.database.message = error instanceof Error ? error.message : 'Database connection failed';
36
+ overallStatus = 'unhealthy';
37
+ }
38
+
39
+ // Check R2 storage connectivity
40
+ try {
41
+ if (env.BLOBS) {
42
+ // Simple list operation to verify connectivity
43
+ await env.BLOBS.list({ limit: 1 });
44
+ checks.storage.status = 'ok';
45
+ } else {
46
+ checks.storage.status = 'error';
47
+ checks.storage.message = 'Storage not configured';
48
+ overallStatus = 'unhealthy';
49
+ }
50
+ } catch (error) {
51
+ checks.storage.status = 'error';
52
+ checks.storage.message = error instanceof Error ? error.message : 'Storage connection failed';
53
+ overallStatus = 'unhealthy';
54
+ }
55
+
56
+ const response: HealthCheck = {
57
+ status: overallStatus,
58
+ timestamp: new Date().toISOString(),
59
+ checks,
60
+ };
61
+
62
+ const status = overallStatus === 'healthy' ? 200 : 503;
63
+
64
+ return new Response(JSON.stringify(response, null, 2), {
65
+ status,
66
+ headers: { 'Content-Type': 'application/json' },
67
+ });
68
+ }
@@ -0,0 +1,57 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ ---
4
+
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8" />
8
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
9
+ <meta name="viewport" content="width=device-width" />
10
+ <meta name="generator" content={Astro.generator} />
11
+ <title>Alteran PDS</title>
12
+ <style>
13
+ body {
14
+ font-family: sans-serif;
15
+ display: flex;
16
+ justify-content: center;
17
+ align-items: center;
18
+ height: 100vh;
19
+ background-color: #f0f2f5;
20
+ }
21
+ .card {
22
+ background-color: white;
23
+ padding: 2rem;
24
+ border-radius: 0.5rem;
25
+ box-shadow: 0 0 1rem rgba(0, 0, 0, 0.1);
26
+ text-align: center;
27
+ }
28
+ h1 {
29
+ margin-top: 0;
30
+ }
31
+ .did, .handle {
32
+ font-family: monospace;
33
+ background-color: #eee;
34
+ padding: 0.25rem 0.5rem;
35
+ border-radius: 0.25rem;
36
+ }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div class="card">
41
+ <h1>Alteran</h1>
42
+ <p>This is a single-user ATProto Personal Data Server running on Cloudflare.</p>
43
+ <p>
44
+ <strong>Handle:</strong> <span class="handle">{import.meta.env.PDS_HANDLE}</span>
45
+ </p>
46
+ <p>
47
+ <strong>DID:</strong> <span class="did">{import.meta.env.PDS_DID}</span>
48
+ </p>
49
+ <p>
50
+ <a href="https://github.com/alteran-dev/alteran" target="_blank" rel="noopener noreferrer">
51
+ <Icon name="simple-icons:github" />
52
+ Source Code
53
+ </a>
54
+ </p>
55
+ </div>
56
+ </body>
57
+ </html>
@@ -0,0 +1,2 @@
1
+ export { GET } from '../handlers/root';
2
+
@@ -0,0 +1,16 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ export async function GET({ locals }: APIContext) {
6
+ const { env } = locals.runtime;
7
+
8
+ try {
9
+ if (env.DB) {
10
+ await env.DB.prepare('select 1').first();
11
+ }
12
+ return new Response('ok');
13
+ } catch (e) {
14
+ return new Response('db not ready', { status: 503 });
15
+ }
16
+ }
@@ -0,0 +1,38 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ /**
6
+ * com.atproto.identity.resolveHandle
7
+ * Resolve a handle to a DID
8
+ */
9
+ export async function GET({ locals, url }: APIContext) {
10
+ const { env } = locals.runtime;
11
+
12
+ const handle = url.searchParams.get('handle');
13
+ const configuredHandle = env.PDS_HANDLE || 'user.example.com';
14
+ const did = env.PDS_DID || 'did:example:single-user';
15
+
16
+ if (!handle) {
17
+ return new Response(
18
+ JSON.stringify({ error: 'InvalidRequest', message: 'handle parameter required' }),
19
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
20
+ );
21
+ }
22
+
23
+ // Single-user PDS: only resolve if handle matches configured handle
24
+ if (handle === configuredHandle) {
25
+ return new Response(
26
+ JSON.stringify({ did }),
27
+ {
28
+ status: 200,
29
+ headers: { 'Content-Type': 'application/json' },
30
+ }
31
+ );
32
+ }
33
+
34
+ return new Response(
35
+ JSON.stringify({ error: 'HandleNotFound' }),
36
+ { status: 404, headers: { 'Content-Type': 'application/json' } }
37
+ );
38
+ }
@@ -0,0 +1,45 @@
1
+ import type { APIContext } from 'astro';
2
+ import { readJson } from '@alteran/lib/util';
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * com.atproto.identity.updateHandle
8
+ * Update the handle for the repository
9
+ */
10
+ export async function POST({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+
13
+ try {
14
+ const body = await readJson(request);
15
+ const { handle } = body;
16
+
17
+ if (!handle) {
18
+ return new Response(
19
+ JSON.stringify({ error: 'InvalidRequest', message: 'handle required' }),
20
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
21
+ );
22
+ }
23
+
24
+ // TODO: Implement handle verification (DNS TXT or HTTP)
25
+ // TODO: Update PDS_HANDLE configuration
26
+ // For single-user PDS, this would require redeployment with new config
27
+
28
+ return new Response(
29
+ JSON.stringify({
30
+ error: 'NotImplemented',
31
+ message: 'Handle updates require PDS reconfiguration for single-user mode',
32
+ }),
33
+ {
34
+ status: 501,
35
+ headers: { 'Content-Type': 'application/json' },
36
+ }
37
+ );
38
+ } catch (error) {
39
+ console.error('updateHandle error:', error);
40
+ return new Response(
41
+ JSON.stringify({ error: 'InternalServerError', message: String(error) }),
42
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
43
+ );
44
+ }
45
+ }
@@ -0,0 +1,73 @@
1
+ import type { APIContext } from 'astro';
2
+ import { RepoManager } from '@alteran/services/repo-manager';
3
+ import { readJson } from '@alteran/lib/util';
4
+ import { bumpRoot } from '@alteran/db/repo';
5
+
6
+ export const prerender = false;
7
+
8
+ /**
9
+ * com.atproto.repo.applyWrites
10
+ * Apply a batch of repository writes atomically
11
+ */
12
+ export async function POST({ locals, request }: APIContext) {
13
+ const { env } = locals.runtime;
14
+
15
+ try {
16
+ const body = await readJson(request);
17
+ const { repo, writes, validate = true, swapCommit } = body;
18
+
19
+ if (!writes || !Array.isArray(writes)) {
20
+ return new Response(
21
+ JSON.stringify({ error: 'InvalidRequest', message: 'writes must be an array' }),
22
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
23
+ );
24
+ }
25
+
26
+ const repoManager = new RepoManager(env);
27
+ const results = [];
28
+
29
+ // Apply all writes atomically
30
+ for (const write of writes) {
31
+ const { $type, collection, rkey, value } = write;
32
+
33
+ if ($type === 'com.atproto.repo.applyWrites#create') {
34
+ const { mst, recordCid } = await repoManager.addRecord(collection, rkey, value);
35
+ results.push({
36
+ uri: `at://${repo}/${collection}/${rkey}`,
37
+ cid: recordCid.toString(),
38
+ });
39
+ } else if ($type === 'com.atproto.repo.applyWrites#update') {
40
+ const { mst, recordCid } = await repoManager.updateRecord(collection, rkey, value);
41
+ results.push({
42
+ uri: `at://${repo}/${collection}/${rkey}`,
43
+ cid: recordCid.toString(),
44
+ });
45
+ } else if ($type === 'com.atproto.repo.applyWrites#delete') {
46
+ await repoManager.deleteRecord(collection, rkey);
47
+ results.push({
48
+ uri: `at://${repo}/${collection}/${rkey}`,
49
+ });
50
+ }
51
+ }
52
+
53
+ // Bump repo root to create new commit
54
+ const { commitCid, rev } = await bumpRoot(env);
55
+
56
+ return new Response(
57
+ JSON.stringify({
58
+ commit: { cid: commitCid, rev },
59
+ results,
60
+ }),
61
+ {
62
+ status: 200,
63
+ headers: { 'Content-Type': 'application/json' },
64
+ }
65
+ );
66
+ } catch (error) {
67
+ console.error('applyWrites error:', error);
68
+ return new Response(
69
+ JSON.stringify({ error: 'InternalServerError', message: String(error) }),
70
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
71
+ );
72
+ }
73
+ }
@@ -0,0 +1,36 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '@alteran/lib/auth';
3
+ import { checkRate } from '@alteran/lib/ratelimit';
4
+ import { readJsonBounded } from '@alteran/lib/util';
5
+ import { RepoManager } from '@alteran/services/repo-manager';
6
+ import { notifySequencer } from '@alteran/lib/sequencer';
7
+
8
+ export const prerender = false;
9
+
10
+ export async function POST({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+ if (!(await isAuthorized(request, env))) return unauthorized();
13
+
14
+ const rateLimitResponse = await checkRate(env, request, 'writes');
15
+ if (rateLimitResponse) return rateLimitResponse;
16
+
17
+ let body: any;
18
+ try {
19
+ body = await readJsonBounded(env, request);
20
+ } catch (e: any) {
21
+ if (e?.code === 'PayloadTooLarge') {
22
+ return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
23
+ }
24
+ return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
25
+ }
26
+ const { collection, rkey, record } = body ?? {};
27
+ if (!collection || !record) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
28
+
29
+ const repo = new RepoManager(env);
30
+ const commit = await repo.createRecord(collection, record, rkey);
31
+ await notifySequencer(env, { type: 'commit', did: env.PDS_DID ?? 'did:example:single-user', commitCid: commit.commitCid, rev: commit.rev, ops: commit.ops });
32
+
33
+ return new Response(JSON.stringify(commit), {
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
@@ -0,0 +1,36 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '@alteran/lib/auth';
3
+ import { checkRate } from '@alteran/lib/ratelimit';
4
+ import { readJsonBounded } from '@alteran/lib/util';
5
+ import { RepoManager } from '@alteran/services/repo-manager';
6
+ import { notifySequencer } from '@alteran/lib/sequencer';
7
+
8
+ export const prerender = false;
9
+
10
+ export async function POST({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+ if (!(await isAuthorized(request, env))) return unauthorized();
13
+
14
+ const rateLimitResponse = await checkRate(env, request, 'writes');
15
+ if (rateLimitResponse) return rateLimitResponse;
16
+
17
+ let body: any;
18
+ try {
19
+ body = await readJsonBounded(env, request);
20
+ } catch (e: any) {
21
+ if (e?.code === 'PayloadTooLarge') {
22
+ return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
23
+ }
24
+ return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
25
+ }
26
+ const { collection, rkey } = body ?? {};
27
+ if (!collection || !rkey) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
28
+
29
+ const repo = new RepoManager(env);
30
+ const commit = await repo.deleteRecord(collection, rkey);
31
+ await notifySequencer(env, { type: 'commit', did: env.PDS_DID ?? 'did:example:single-user', commitCid: commit.commitCid, rev: commit.rev, ops: commit.ops });
32
+
33
+ return new Response(JSON.stringify(commit), {
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
@@ -0,0 +1,51 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRoot } from '@alteran/db/repo';
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * com.atproto.repo.describeRepo
8
+ * Get metadata about a repository
9
+ */
10
+ export async function GET({ locals, url }: APIContext) {
11
+ const { env } = locals.runtime;
12
+
13
+ const repo = url.searchParams.get('repo') || env.PDS_DID || 'did:example:single-user';
14
+ const did = env.PDS_DID || 'did:example:single-user';
15
+ const handle = env.PDS_HANDLE || 'user.example.com';
16
+
17
+ // Get repo root to check if repo exists
18
+ const root = await getRoot(env);
19
+
20
+ return new Response(
21
+ JSON.stringify({
22
+ did,
23
+ handle,
24
+ didDoc: {
25
+ '@context': ['https://www.w3.org/ns/did/v1'],
26
+ id: did,
27
+ alsoKnownAs: [`at://${handle}`],
28
+ verificationMethod: [],
29
+ service: [
30
+ {
31
+ id: '#atproto_pds',
32
+ type: 'AtprotoPersonalDataServer',
33
+ serviceEndpoint: `https://${handle}`,
34
+ },
35
+ ],
36
+ },
37
+ collections: [
38
+ 'app.bsky.feed.post',
39
+ 'app.bsky.feed.like',
40
+ 'app.bsky.feed.repost',
41
+ 'app.bsky.graph.follow',
42
+ 'app.bsky.actor.profile',
43
+ ],
44
+ handleIsCorrect: true,
45
+ }),
46
+ {
47
+ status: 200,
48
+ headers: { 'Content-Type': 'application/json' },
49
+ }
50
+ );
51
+ }
@@ -0,0 +1,25 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRecord as dalGetRecord } from '@alteran/db/dal';
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
+ let uri = url.searchParams.get('uri');
10
+ if (!uri) {
11
+ const repo = url.searchParams.get('repo') ?? (env.PDS_DID ?? 'did:example:single-user');
12
+ const collection = url.searchParams.get('collection');
13
+ const rkey = url.searchParams.get('rkey');
14
+ if (repo && collection && rkey) uri = `at://${repo}/${collection}/${rkey}`;
15
+ }
16
+
17
+ if (!uri) return new Response(JSON.stringify({ error: 'BadRequest', message: 'query param uri required' }), { status: 400 });
18
+
19
+ const row = await dalGetRecord(env, uri);
20
+ if (!row) return new Response(JSON.stringify({ error: 'NotFound' }), { status: 404 });
21
+
22
+ return new Response(JSON.stringify({ uri: row.uri, cid: row.cid, value: JSON.parse(row.json) }), {
23
+ headers: { 'Content-Type': 'application/json' },
24
+ });
25
+ }
@@ -0,0 +1,57 @@
1
+ import type { APIContext } from 'astro';
2
+ import { RepoManager } from '@alteran/services/repo-manager';
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * com.atproto.repo.listRecords
8
+ * List records in a collection with pagination
9
+ */
10
+ export async function GET({ locals, url }: APIContext) {
11
+ const { env } = locals.runtime;
12
+
13
+ const repo = url.searchParams.get('repo') || env.PDS_DID || 'did:example:single-user';
14
+ const collection = url.searchParams.get('collection');
15
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
16
+ const cursor = url.searchParams.get('cursor') || undefined;
17
+
18
+ if (!collection) {
19
+ return new Response(
20
+ JSON.stringify({ error: 'InvalidRequest', message: 'collection parameter required' }),
21
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
22
+ );
23
+ }
24
+
25
+ try {
26
+ const repoManager = new RepoManager(env);
27
+ const results = await repoManager.listRecords(collection, limit, cursor);
28
+
29
+ const records = await Promise.all(
30
+ results.map(async ({ key, cid }) => {
31
+ const record = await repoManager.getRecord(collection, key);
32
+ return {
33
+ uri: `at://${repo}/${collection}/${key}`,
34
+ cid: cid.toString(),
35
+ value: record,
36
+ };
37
+ })
38
+ );
39
+
40
+ return new Response(
41
+ JSON.stringify({
42
+ records,
43
+ cursor: records.length > 0 ? results[results.length - 1].key : undefined,
44
+ }),
45
+ {
46
+ status: 200,
47
+ headers: { 'Content-Type': 'application/json' },
48
+ }
49
+ );
50
+ } catch (error) {
51
+ console.error('listRecords error:', error);
52
+ return new Response(
53
+ JSON.stringify({ error: 'InternalServerError', message: String(error) }),
54
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
55
+ );
56
+ }
57
+ }
@@ -0,0 +1,36 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '@alteran/lib/auth';
3
+ import { checkRate } from '@alteran/lib/ratelimit';
4
+ import { readJsonBounded } from '@alteran/lib/util';
5
+ import { RepoManager } from '@alteran/services/repo-manager';
6
+ import { notifySequencer } from '@alteran/lib/sequencer';
7
+
8
+ export const prerender = false;
9
+
10
+ export async function POST({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+ if (!(await isAuthorized(request, env))) return unauthorized();
13
+
14
+ const rateLimitResponse = await checkRate(env, request, 'writes');
15
+ if (rateLimitResponse) return rateLimitResponse;
16
+
17
+ let body: any;
18
+ try {
19
+ body = await readJsonBounded(env, request);
20
+ } catch (e: any) {
21
+ if (e?.code === 'PayloadTooLarge') {
22
+ return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
23
+ }
24
+ return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
25
+ }
26
+ const { collection, rkey, record } = body ?? {};
27
+ if (!collection || !rkey || !record) return new Response(JSON.stringify({ error: 'BadRequest' }), { status: 400 });
28
+
29
+ const repo = new RepoManager(env);
30
+ const commit = await repo.putRecord(collection, rkey, record);
31
+ await notifySequencer(env, { type: 'commit', did: env.PDS_DID ?? 'did:example:single-user', commitCid: commit.commitCid, rev: commit.rev, ops: commit.ops });
32
+
33
+ return new Response(JSON.stringify(commit), {
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
@@ -0,0 +1,53 @@
1
+ import type { APIContext } from 'astro';
2
+ import { isAuthorized, unauthorized } from '@alteran/lib/auth';
3
+ import { checkRate } from '@alteran/lib/ratelimit';
4
+ import { isAllowedMime } from '@alteran/lib/util';
5
+ import { R2BlobStore } from '@alteran/services/r2-blob-store';
6
+ import { putBlobRef, checkBlobQuota, updateBlobQuota } from '@alteran/db/dal';
7
+
8
+ export const prerender = false;
9
+
10
+ export async function POST({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+ if (!(await isAuthorized(request, env))) return unauthorized();
13
+
14
+ const rateLimitResponse = await checkRate(env, request, 'blob');
15
+ if (rateLimitResponse) return rateLimitResponse;
16
+
17
+ const buf = await request.arrayBuffer();
18
+ const contentType = request.headers.get('content-type') ?? 'application/octet-stream';
19
+ if (!isAllowedMime(env, contentType)) return new Response(JSON.stringify({ error: 'UnsupportedMediaType' }), { status: 415 });
20
+
21
+ // Get DID from environment (single-user PDS)
22
+ const did = env.PDS_DID ?? 'did:example:single-user';
23
+
24
+ // Check quota before upload
25
+ const canUpload = await checkBlobQuota(env, did, buf.byteLength);
26
+ if (!canUpload) {
27
+ return new Response(
28
+ JSON.stringify({
29
+ error: 'BlobQuotaExceeded',
30
+ message: 'Blob storage quota exceeded'
31
+ }),
32
+ { status: 413 }
33
+ );
34
+ }
35
+
36
+ const store = new R2BlobStore(env);
37
+ try {
38
+ const res = await store.put(buf, { contentType });
39
+
40
+ // Register blob ref with CID-based key
41
+ await putBlobRef(env, did, res.sha256, res.key, contentType, res.size);
42
+
43
+ // Update quota
44
+ await updateBlobQuota(env, did, res.size, 1);
45
+
46
+ return new Response(JSON.stringify({ blob: { ref: { $link: res.key }, mimeType: contentType, size: res.size } }), {
47
+ headers: { 'Content-Type': 'application/json' },
48
+ });
49
+ } catch (e: any) {
50
+ if (String(e.message || '').startsWith('BlobTooLarge')) return new Response(JSON.stringify({ error: 'PayloadTooLarge' }), { status: 413 });
51
+ return new Response(JSON.stringify({ error: 'UploadFailed' }), { status: 500 });
52
+ }
53
+ }