@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,34 @@
1
+ import type { Env } from '../env';
2
+
3
+ // Rate limiting (best-effort, D1 based)
4
+ export async function checkRate(env: Env, request: Request, bucket: 'writes' | 'blob'): Promise<Response | null> {
5
+ try {
6
+ const limit = Number((env.PDS_RATE_LIMIT_PER_MIN as string | undefined) ?? (bucket === 'blob' ? 30 : 60));
7
+ const now = Date.now();
8
+ const windowMs = 60_000;
9
+ const win = Math.floor(now / windowMs);
10
+ const ip = request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1';
11
+ await env.DB.exec("CREATE TABLE IF NOT EXISTS rate_limit (ip TEXT NOT NULL, bucket TEXT NOT NULL, window INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (ip,bucket,window))");
12
+ const row: any = await env.DB.prepare('SELECT count FROM rate_limit WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).first();
13
+ const count = row?.count ? Number(row.count) : 0;
14
+ if (count >= limit) return rateLimited();
15
+ if (count === 0) {
16
+ await env.DB.prepare('INSERT OR REPLACE INTO rate_limit (ip,bucket,window,count) VALUES (?,?,?,1)').bind(ip, bucket, win).run();
17
+ } else {
18
+ await env.DB.prepare('UPDATE rate_limit SET count=count+1 WHERE ip=? AND bucket=? AND window=?').bind(ip, bucket, win).run();
19
+ }
20
+
21
+ const headers = new Headers();
22
+ headers.set('x-ratelimit-limit', String(limit));
23
+ headers.set('x-ratelimit-remaining', String(Math.max(0, limit - count - 1)));
24
+ headers.set('x-ratelimit-window', '60s');
25
+
26
+ return null;
27
+ } catch {
28
+ return null; // fail-open
29
+ }
30
+ }
31
+
32
+ function rateLimited() {
33
+ return new Response(JSON.stringify({ error: 'RateLimited' }), { status: 429 });
34
+ }
@@ -0,0 +1,10 @@
1
+ import type { Env } from '../env';
2
+
3
+ export async function notifySequencer(env: Env, obj: unknown) {
4
+ if (!env.SEQUENCER) return;
5
+ try {
6
+ const id = env.SEQUENCER.idFromName('default');
7
+ const stub = env.SEQUENCER.get(id);
8
+ await stub.fetch('https://sequencer/commit', { method: 'POST', body: JSON.stringify(obj) });
9
+ } catch {}
10
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Streaming CAR encoder for Cloudflare Workers
3
+ *
4
+ * This module provides memory-efficient streaming CAR encoding to stay within
5
+ * the 128MB memory limit. Instead of buffering entire CAR files in memory,
6
+ * blocks are streamed as they're read from storage.
7
+ */
8
+
9
+ import { CID } from 'multiformats/cid';
10
+ import * as dagCbor from '@ipld/dag-cbor';
11
+
12
+ export interface Block {
13
+ cid: CID;
14
+ bytes: Uint8Array;
15
+ }
16
+
17
+ /**
18
+ * Encode a varint (variable-length integer)
19
+ */
20
+ function encodeVarint(n: number): Uint8Array {
21
+ const bytes: number[] = [];
22
+ while (n >= 0x80) {
23
+ bytes.push((n & 0x7f) | 0x80);
24
+ n >>>= 7;
25
+ }
26
+ bytes.push(n);
27
+ return new Uint8Array(bytes);
28
+ }
29
+
30
+ /**
31
+ * Concatenate Uint8Arrays efficiently
32
+ */
33
+ function concat(parts: Uint8Array[]): Uint8Array {
34
+ const size = parts.reduce((n, p) => n + p.byteLength, 0);
35
+ const buf = new Uint8Array(size);
36
+ let off = 0;
37
+ for (const p of parts) {
38
+ buf.set(p, off);
39
+ off += p.byteLength;
40
+ }
41
+ return buf;
42
+ }
43
+
44
+ /**
45
+ * Create a streaming CAR encoder
46
+ *
47
+ * Usage:
48
+ * ```typescript
49
+ * const stream = createStreamingCarEncoder([rootCid]);
50
+ * const readable = new ReadableStream({
51
+ * async start(controller) {
52
+ * controller.enqueue(stream.header());
53
+ * for await (const block of blocks) {
54
+ * controller.enqueue(stream.encodeBlock(block));
55
+ * }
56
+ * controller.close();
57
+ * }
58
+ * });
59
+ * ```
60
+ */
61
+ export class StreamingCarEncoder {
62
+ private headerBytes: Uint8Array;
63
+
64
+ constructor(roots: CID[]) {
65
+ // Encode header once
66
+ const header = dagCbor.encode({ version: 1, roots });
67
+ const headerLength = encodeVarint(header.byteLength);
68
+ this.headerBytes = concat([headerLength, header]);
69
+ }
70
+
71
+ /**
72
+ * Get the CAR header bytes
73
+ */
74
+ header(): Uint8Array {
75
+ return this.headerBytes;
76
+ }
77
+
78
+ /**
79
+ * Encode a single block for streaming
80
+ */
81
+ encodeBlock(block: Block): Uint8Array {
82
+ const blockData = concat([block.cid.bytes, block.bytes]);
83
+ const blockLength = encodeVarint(blockData.byteLength);
84
+ return concat([blockLength, blockData]);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Create a ReadableStream that encodes blocks as CAR format
90
+ *
91
+ * @param roots - Root CIDs for the CAR file
92
+ * @param blockIterator - Async iterator that yields blocks
93
+ * @returns ReadableStream of CAR-encoded bytes
94
+ */
95
+ export function createCarStream(
96
+ roots: CID[],
97
+ blockIterator: AsyncIterable<Block>
98
+ ): ReadableStream<Uint8Array> {
99
+ const encoder = new StreamingCarEncoder(roots);
100
+ let headerSent = false;
101
+
102
+ return new ReadableStream({
103
+ async start(controller) {
104
+ try {
105
+ // Send header first
106
+ controller.enqueue(encoder.header());
107
+ headerSent = true;
108
+
109
+ // Stream blocks
110
+ for await (const block of blockIterator) {
111
+ controller.enqueue(encoder.encodeBlock(block));
112
+ }
113
+
114
+ controller.close();
115
+ } catch (error) {
116
+ controller.error(error);
117
+ }
118
+ },
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Create a block iterator from an array (for compatibility)
124
+ */
125
+ export async function* blocksFromArray(blocks: Block[]): AsyncIterable<Block> {
126
+ for (const block of blocks) {
127
+ yield block;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Estimate memory usage for a block
133
+ */
134
+ export function estimateBlockMemory(block: Block): number {
135
+ // CID bytes + block bytes + overhead
136
+ return block.cid.bytes.byteLength + block.bytes.byteLength + 100;
137
+ }
@@ -0,0 +1,38 @@
1
+ import type { Env } from '../env';
2
+ import { drizzle } from 'drizzle-orm/d1';
3
+ import { token_revocation } from '../db/schema';
4
+ import { lt } from 'drizzle-orm';
5
+
6
+ /**
7
+ * Clean up expired tokens from the revocation table
8
+ * This prevents the table from growing indefinitely
9
+ */
10
+ export async function cleanupExpiredTokens(env: Env): Promise<number> {
11
+ const db = drizzle(env.DB);
12
+ const now = Math.floor(Date.now() / 1000);
13
+
14
+ // Delete tokens where expiry is in the past
15
+ const result = await db.delete(token_revocation)
16
+ .where(lt(token_revocation.exp, now))
17
+ .run();
18
+
19
+ return result.meta.changes || 0;
20
+ }
21
+
22
+ /**
23
+ * Lazy cleanup - only runs cleanup occasionally (1% of requests)
24
+ * This spreads the cleanup load across requests
25
+ */
26
+ export async function lazyCleanupExpiredTokens(env: Env): Promise<void> {
27
+ // Only run cleanup 1% of the time to avoid overhead
28
+ if (Math.random() > 0.01) return;
29
+
30
+ try {
31
+ const deleted = await cleanupExpiredTokens(env);
32
+ if (deleted > 0) {
33
+ console.log(`Cleaned up ${deleted} expired tokens`);
34
+ }
35
+ } catch (error) {
36
+ console.error('Failed to cleanup expired tokens:', error);
37
+ }
38
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Performance Tracing
3
+ * Measures time spent in critical paths
4
+ */
5
+
6
+ import { metrics, METRICS } from './metrics';
7
+ import { logger } from './logger';
8
+
9
+ export interface TraceSpan {
10
+ name: string;
11
+ startTime: number;
12
+ labels?: Record<string, string>;
13
+ }
14
+
15
+ /**
16
+ * Performance tracer for measuring operation durations
17
+ */
18
+ export class Tracer {
19
+ private spans: Map<string, TraceSpan> = new Map();
20
+
21
+ /**
22
+ * Start a trace span
23
+ */
24
+ start(name: string, labels?: Record<string, string>): string {
25
+ const spanId = `${name}-${Date.now()}-${Math.random()}`;
26
+ this.spans.set(spanId, {
27
+ name,
28
+ startTime: Date.now(),
29
+ labels,
30
+ });
31
+ return spanId;
32
+ }
33
+
34
+ /**
35
+ * End a trace span and record metrics
36
+ */
37
+ end(spanId: string): number | null {
38
+ const span = this.spans.get(spanId);
39
+ if (!span) {
40
+ return null;
41
+ }
42
+
43
+ const duration = Date.now() - span.startTime;
44
+ this.spans.delete(spanId);
45
+
46
+ // Record metric based on span name
47
+ if (span.name.startsWith('db.')) {
48
+ metrics.observe(METRICS.DB_QUERY_DURATION_MS, duration, span.labels);
49
+ } else if (span.name.startsWith('r2.')) {
50
+ metrics.observe(METRICS.R2_OPERATION_DURATION_MS, duration, span.labels);
51
+ }
52
+
53
+ // Log slow operations (> 1 second)
54
+ if (duration > 1000) {
55
+ logger.warn(`Slow operation detected: ${span.name}`, {
56
+ duration,
57
+ ...span.labels,
58
+ });
59
+ }
60
+
61
+ return duration;
62
+ }
63
+
64
+ /**
65
+ * Trace an async function
66
+ */
67
+ async trace<T>(
68
+ name: string,
69
+ fn: () => Promise<T>,
70
+ labels?: Record<string, string>
71
+ ): Promise<T> {
72
+ const spanId = this.start(name, labels);
73
+ try {
74
+ return await fn();
75
+ } finally {
76
+ this.end(spanId);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Trace a synchronous function
82
+ */
83
+ traceSync<T>(
84
+ name: string,
85
+ fn: () => T,
86
+ labels?: Record<string, string>
87
+ ): T {
88
+ const spanId = this.start(name, labels);
89
+ try {
90
+ return fn();
91
+ } finally {
92
+ this.end(spanId);
93
+ }
94
+ }
95
+ }
96
+
97
+ // Global tracer instance
98
+ export const tracer = new Tracer();
99
+
100
+ /**
101
+ * Trace a database query
102
+ */
103
+ export async function traceDbQuery<T>(
104
+ operation: string,
105
+ fn: () => Promise<T>
106
+ ): Promise<T> {
107
+ return tracer.trace(`db.${operation}`, fn, { operation });
108
+ }
109
+
110
+ /**
111
+ * Trace an R2 operation
112
+ */
113
+ export async function traceR2Operation<T>(
114
+ operation: string,
115
+ fn: () => Promise<T>
116
+ ): Promise<T> {
117
+ return tracer.trace(`r2.${operation}`, fn, { operation });
118
+ }
119
+
120
+ /**
121
+ * Trace an auth operation
122
+ */
123
+ export async function traceAuthOperation<T>(
124
+ operation: string,
125
+ fn: () => Promise<T>
126
+ ): Promise<T> {
127
+ return tracer.trace(`auth.${operation}`, fn, { operation });
128
+ }
129
+
130
+ /**
131
+ * Create a request-scoped tracer
132
+ */
133
+ export function createRequestTracer(requestId: string): Tracer {
134
+ const requestTracer = new Tracer();
135
+ return requestTracer;
136
+ }
@@ -0,0 +1,55 @@
1
+ import type { APIContext } from 'astro';
2
+ import { CID } from 'multiformats/cid';
3
+ import * as dagCbor from '@ipld/dag-cbor';
4
+ import { sha256 } from 'multiformats/hashes/sha2';
5
+
6
+ export function tryParse(json: string): unknown {
7
+ try {
8
+ return JSON.parse(json);
9
+ } catch {
10
+ return json;
11
+ }
12
+ }
13
+
14
+ // JSON helper with size cap
15
+ export async function readJson(request: Request): Promise<any> {
16
+ const max = 64 * 1024;
17
+ const text = await request.text();
18
+ if (text.length > max) throw new Error('PayloadTooLarge');
19
+ return JSON.parse(text || '{}');
20
+ }
21
+
22
+ export async function readJsonBounded(env: any, request: Request): Promise<any> {
23
+ const raw = (env.PDS_MAX_JSON_BYTES as string | undefined) ?? '65536';
24
+ const max = Number(raw) > 0 ? Number(raw) : 65536;
25
+ const text = await request.text();
26
+ if (text.length > max) {
27
+ const err: any = new Error('PayloadTooLarge');
28
+ err.code = 'PayloadTooLarge';
29
+ throw err;
30
+ }
31
+ return JSON.parse(text || '{}');
32
+ }
33
+
34
+ export function bearerToken(request: Request): string | null {
35
+ const auth = request.headers.get('authorization');
36
+ if (!auth || !auth.startsWith('Bearer ')) return null;
37
+ return auth.slice(7);
38
+ }
39
+
40
+ export function isAllowedMime(env: any, mime: string): boolean {
41
+ const def = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
42
+ const raw = (env.PDS_ALLOWED_MIME as string | undefined) ?? def.join(',');
43
+ const set = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
44
+ return set.has(mime.toLowerCase());
45
+ }
46
+
47
+ export function randomRkey(): string {
48
+ return crypto.randomUUID().replace(/-/g, '').substring(0, 13);
49
+ }
50
+
51
+ export async function cidFromJson(json: any): Promise<CID> {
52
+ const bytes = dagCbor.encode(json);
53
+ const hash = await sha256.digest(bytes);
54
+ return CID.create(1, dagCbor.code, hash);
55
+ }
@@ -0,0 +1,102 @@
1
+ import { defineMiddleware, sequence } from 'astro:middleware';
2
+
3
+ const cors = defineMiddleware(async ({ locals, request }, next) => {
4
+ const { env } = (locals as any).runtime ?? (locals as any);
5
+ const corsOrigins = (env.PDS_CORS_ORIGIN ?? '*').split(',').map((s: string) => s.trim()).filter(Boolean);
6
+ const origin = request.headers.get('origin') ?? '';
7
+
8
+ // In production, never allow wildcard - require explicit origins
9
+ const isProduction = env.PDS_HOSTNAME && !env.PDS_HOSTNAME.includes('localhost');
10
+ const allowWildcard = !isProduction && corsOrigins.includes('*');
11
+
12
+ // Check if origin is in allowlist
13
+ const isAllowed = allowWildcard || corsOrigins.includes(origin);
14
+
15
+ if (request.method === 'OPTIONS') {
16
+ if (!isAllowed) {
17
+ return new Response('CORS origin not allowed', { status: 403 });
18
+ }
19
+
20
+ const headers = new Headers({
21
+ 'Access-Control-Allow-Origin': allowWildcard ? '*' : origin,
22
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
23
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
24
+ 'Access-Control-Max-Age': '86400', // 24 hours
25
+ });
26
+ return new Response(null, { status: 204, headers });
27
+ }
28
+
29
+ const response = await next();
30
+
31
+ if (isAllowed) {
32
+ response.headers.set('Access-Control-Allow-Origin', allowWildcard ? '*' : origin);
33
+ response.headers.set('Vary', 'Origin');
34
+ }
35
+
36
+ return response;
37
+ });
38
+
39
+ const logger = defineMiddleware(async ({ request, locals }, next) => {
40
+ const rid = crypto.randomUUID();
41
+ (locals as any).requestId = rid;
42
+
43
+ const start = Date.now();
44
+ const url = new URL(request.url);
45
+
46
+ try {
47
+ const response = await next();
48
+ const dur = Date.now() - start;
49
+
50
+ // Structured logging
51
+ console.log(JSON.stringify({
52
+ level: 'info',
53
+ type: 'request',
54
+ requestId: rid,
55
+ method: request.method,
56
+ path: url.pathname,
57
+ status: response.status,
58
+ duration: dur,
59
+ timestamp: new Date().toISOString(),
60
+ }));
61
+
62
+ // Track metrics (import dynamically to avoid circular deps)
63
+ try {
64
+ const { trackRequest } = await import('./lib/metrics');
65
+ trackRequest(request.method, url.pathname, response.status, dur);
66
+ } catch (e) {
67
+ // Metrics are optional, don't fail request
68
+ }
69
+
70
+ // Add request ID to response headers
71
+ response.headers.set('X-Request-ID', rid);
72
+
73
+ return response;
74
+ } catch (error) {
75
+ const dur = Date.now() - start;
76
+
77
+ // Log error
78
+ console.log(JSON.stringify({
79
+ level: 'error',
80
+ type: 'request',
81
+ requestId: rid,
82
+ method: request.method,
83
+ path: url.pathname,
84
+ duration: dur,
85
+ error: error instanceof Error ? error.message : String(error),
86
+ stack: error instanceof Error ? error.stack : undefined,
87
+ timestamp: new Date().toISOString(),
88
+ }));
89
+
90
+ // Track error metrics
91
+ try {
92
+ const { trackRequest } = await import('./lib/metrics');
93
+ trackRequest(request.method, url.pathname, 500, dur);
94
+ } catch (e) {
95
+ // Metrics are optional
96
+ }
97
+
98
+ throw error;
99
+ }
100
+ });
101
+
102
+ export const onRequest = sequence(cors, logger);
@@ -0,0 +1,7 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ export function GET({ locals }: APIContext) {
6
+ return new Response(locals.runtime.env.PDS_DID ?? '');
7
+ }
@@ -0,0 +1,76 @@
1
+ import type { APIContext } from 'astro';
2
+ import { withCache, CACHE_CONFIGS } from '../../lib/cache';
3
+ import { base58btc } from 'multiformats/bases/base58';
4
+
5
+ export const prerender = false;
6
+
7
+ /**
8
+ * DID Document endpoint for did:web
9
+ *
10
+ * Returns a DID document with:
11
+ * - Service endpoints (PDS, firehose)
12
+ * - Verification methods (signing key)
13
+ *
14
+ * Spec: https://w3c-ccg.github.io/did-method-web/
15
+ */
16
+ export async function GET({ locals, request }: APIContext) {
17
+ const { env } = locals.runtime;
18
+
19
+ return withCache(
20
+ request,
21
+ async () => {
22
+ const did = env.PDS_DID ?? 'did:example:single-user';
23
+ const handle = env.PDS_HANDLE ?? 'user.example.com';
24
+ const hostname = env.PDS_HOSTNAME ?? new URL(request.url).hostname;
25
+
26
+ // Public repository signing key (raw 32-byte Ed25519) base64-encoded
27
+ const pubKeyB64: string | undefined = (env as any).REPO_SIGNING_PUBLIC_KEY;
28
+ let publicKeyMultibase: string | undefined;
29
+ if (pubKeyB64) {
30
+ try {
31
+ const bin = atob(pubKeyB64.replace(/\s+/g, ''));
32
+ const raw = new Uint8Array(bin.length);
33
+ for (let i = 0; i < bin.length; i++) raw[i] = bin.charCodeAt(i);
34
+ if (raw.byteLength === 32) {
35
+ // multicodec: ed25519-pub = 0xED 0x01 prefix
36
+ const prefixed = new Uint8Array(2 + raw.byteLength);
37
+ prefixed[0] = 0xed; prefixed[1] = 0x01; prefixed.set(raw, 2);
38
+ publicKeyMultibase = base58btc.encode(prefixed);
39
+ }
40
+ } catch {}
41
+ }
42
+
43
+ // Build DID document
44
+ const didDocument = {
45
+ '@context': [
46
+ 'https://www.w3.org/ns/did/v1',
47
+ 'https://w3id.org/security/suites/ed25519-2020/v1',
48
+ ],
49
+ id: did,
50
+ alsoKnownAs: [`at://${handle}`],
51
+ verificationMethod: publicKeyMultibase ? [
52
+ {
53
+ id: `${did}#atproto`,
54
+ type: 'Ed25519VerificationKey2020',
55
+ controller: did,
56
+ publicKeyMultibase,
57
+ },
58
+ ] : [],
59
+ service: [
60
+ {
61
+ id: `${did}#atproto_pds`,
62
+ type: 'AtprotoPersonalDataServer',
63
+ serviceEndpoint: `https://${hostname}`,
64
+ },
65
+ ],
66
+ };
67
+
68
+ return new Response(JSON.stringify(didDocument, null, 2), {
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ },
72
+ });
73
+ },
74
+ CACHE_CONFIGS.DID_DOCUMENT
75
+ );
76
+ }
@@ -0,0 +1,27 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ export async function GET({ locals, params }: APIContext) {
6
+ const { env } = locals.runtime;
7
+ const key = params.key;
8
+ if (!key) return new Response('missing key', { status: 400 });
9
+
10
+ const obj = await env.BLOBS.get(key);
11
+ if (!obj) return new Response('not found', { status: 404 });
12
+
13
+ const body = obj.body as unknown as BodyInit | null;
14
+ return new Response(body ?? undefined, {
15
+ headers: { 'content-type': obj.httpMetadata?.contentType ?? 'application/octet-stream' },
16
+ });
17
+ }
18
+
19
+ export async function PUT({ locals, request, params }: APIContext) {
20
+ const { env } = locals.runtime;
21
+ const key = params.key;
22
+ if (!key) return new Response('missing key', { status: 400 });
23
+
24
+ const body = await request.arrayBuffer();
25
+ await env.BLOBS.put(key, body, { httpMetadata: { contentType: request.headers.get('content-type') ?? 'application/octet-stream' } });
26
+ return new Response('uploaded');
27
+ }
@@ -0,0 +1,23 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ export const prerender = false;
4
+
5
+ export async function POST({ locals }: APIContext) {
6
+ const { env } = locals.runtime;
7
+ // Gate to development by default. In production, require local hostname explicitly.
8
+ const envName = (env as any).ENVIRONMENT as string | undefined;
9
+ const host = env.PDS_HOSTNAME as string | undefined;
10
+ const isLocal = envName !== 'production' && (!host || host.includes('localhost') || host.startsWith('127.') || host === '::1');
11
+ if (!isLocal) {
12
+ return new Response('Not Found', { status: 404 });
13
+ }
14
+ const db = env.DB;
15
+ 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')));");
16
+ 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);");
17
+ await db.exec("CREATE TABLE IF NOT EXISTS blob_usage (record_uri TEXT NOT NULL, key TEXT NOT NULL);");
18
+ await db.exec("CREATE TABLE IF NOT EXISTS repo_root (did TEXT PRIMARY KEY, commit_cid TEXT NOT NULL, rev INTEGER NOT NULL);");
19
+ // Indexes
20
+ await db.exec("CREATE INDEX IF NOT EXISTS record_cid_idx ON record(cid);");
21
+ await db.exec("CREATE INDEX IF NOT EXISTS blob_usage_record_idx ON blob_usage(record_uri);");
22
+ return new Response('ok');
23
+ }
@@ -0,0 +1,20 @@
1
+ import type { APIContext } from 'astro';
2
+ import { drizzle } from 'drizzle-orm/d1';
3
+ import { commit_log } from '@alteran/db/schema';
4
+ import { desc } from 'drizzle-orm';
5
+
6
+ export const prerender = false;
7
+
8
+ export async function GET({ locals, request }: APIContext) {
9
+ const { env } = locals.runtime;
10
+ const host = env.PDS_HOSTNAME as string | undefined;
11
+ const envName = (env as any).ENVIRONMENT as string | undefined;
12
+ const isLocal = envName !== 'production' && (!host || host.includes('localhost') || host.startsWith('127.') || host === '::1');
13
+ if (!isLocal) return new Response('Not Found', { status: 404 });
14
+
15
+ const url = new URL(request.url);
16
+ const n = Math.min(Number(url.searchParams.get('n') ?? '20') || 20, 200);
17
+ const db = drizzle(env.DB);
18
+ const rows = await db.select().from(commit_log).orderBy(desc(commit_log.seq)).limit(n).all();
19
+ return new Response(JSON.stringify({ commits: rows }), { headers: { 'content-type': 'application/json' } });
20
+ }
@@ -0,0 +1,16 @@
1
+ import type { APIContext } from 'astro';
2
+ import { listOrphanBlobKeys, deleteBlobByKey } from '@alteran/db/dal';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function POST({ locals }: APIContext) {
7
+ const { env } = locals.runtime;
8
+ const keys = await listOrphanBlobKeys(env);
9
+ let deleted = 0;
10
+ for (const key of keys) {
11
+ await env.BLOBS.delete(key).catch(() => {});
12
+ await deleteBlobByKey(env, key);
13
+ deleted++;
14
+ }
15
+ return new Response(JSON.stringify({ deleted }), { headers: { 'Content-Type': 'application/json' } });
16
+ }