@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,43 @@
1
+ import type { APIContext } from 'astro';
2
+
3
+ function bearer(req: Request): string | null {
4
+ const h = req.headers.get('authorization') || '';
5
+ const m = /^Bearer\s+(.+)$/i.exec(h);
6
+ return m ? m[1] : null;
7
+ }
8
+
9
+ async function verifyJwt(_ctx: APIContext, token: string): Promise<any | null> {
10
+ try { return { payload: JSON.parse(atob(token)) }; } catch { return null; }
11
+ }
12
+
13
+ async function signJwt(_ctx: APIContext, payload: any, _kind: 'access'|'refresh'): Promise<string> {
14
+ const base = { ...payload, exp: Math.floor(Date.now()/1000)+3600 };
15
+ return btoa(JSON.stringify(base));
16
+ }
17
+
18
+ export async function POST(ctx: APIContext) {
19
+ const env: any = (ctx.locals as any).runtime?.env ?? (ctx.locals as any) ?? (globalThis as any);
20
+ const token = bearer(ctx.request);
21
+ if (!token) return Response.json({ error: 'AuthRequired' }, { status: 401 });
22
+ const ver = await verifyJwt(ctx, token);
23
+ if (!ver || ver.payload.t !== 'refresh') return Response.json({ error: 'InvalidToken' }, { status: 401 });
24
+
25
+ const jtiOld = String(ver.payload.jti || '');
26
+ if (jtiOld && env.DB) {
27
+ await env.DB.exec('CREATE TABLE IF NOT EXISTS token_revocation (refresh_jti TEXT PRIMARY KEY, exp INTEGER NOT NULL);');
28
+ const row: any = await env.DB.prepare('SELECT refresh_jti FROM token_revocation WHERE refresh_jti=?').bind(jtiOld).first();
29
+ if (row?.refresh_jti) return Response.json({ error: 'InvalidToken' }, { status: 401 });
30
+ }
31
+
32
+ const did = String(ver.payload.sub || (env.PDS_DID ?? 'did:example:single-user'));
33
+ const handle = String(ver.payload.handle || env.PDS_HANDLE || 'user.example');
34
+ const jtiNew = crypto.randomUUID();
35
+ const accessJwt = await signJwt(ctx, { sub: did, handle, t: 'access' }, 'access');
36
+ const refreshJwt = await signJwt(ctx, { sub: did, handle, t: 'refresh', jti: jtiNew }, 'refresh');
37
+ if (jtiOld && ver.payload.exp && env.DB) {
38
+ await env.DB.exec('CREATE TABLE IF NOT EXISTS token_revocation (refresh_jti TEXT PRIMARY KEY, exp INTEGER NOT NULL);');
39
+ await env.DB.prepare('INSERT OR REPLACE INTO token_revocation (refresh_jti, exp) VALUES (?,?)').bind(jtiOld, Number(ver.payload.exp)).run();
40
+ }
41
+ return Response.json({ did, handle, accessJwt, refreshJwt });
42
+ }
43
+
@@ -0,0 +1,20 @@
1
+ import type { APIContext } from 'astro';
2
+ import { verifyJwt } from './jwt';
3
+
4
+ export async function isAuthorized(request: Request, env: any): Promise<boolean> {
5
+ const auth = request.headers.get('authorization');
6
+ if (!auth || !auth.startsWith('Bearer ')) return false;
7
+ const token = auth.slice(7);
8
+ // Prefer JWT
9
+ const ver = await verifyJwt(env, token).catch(() => null);
10
+ if (ver && ver.valid && ver.payload.t === 'access') return true;
11
+ // Back-compat local escape hatch if explicitly enabled
12
+ const allowDev = (env as any).PDS_ALLOW_DEV_TOKEN === '1';
13
+ if (allowDev && token === 'dev-access-token') return true;
14
+ if (allowDev && env.USER_PASSWORD && token === env.USER_PASSWORD) return true;
15
+ return false;
16
+ }
17
+
18
+ export function unauthorized() {
19
+ return new Response(JSON.stringify({ error: 'AuthRequired' }), { status: 401 });
20
+ }
@@ -0,0 +1,197 @@
1
+ import type { Env } from '../env';
2
+ import { drizzle } from 'drizzle-orm/d1';
3
+ import { blockstore, commit_log } from '../db/schema';
4
+ import { desc, notInArray, eq } from 'drizzle-orm';
5
+ import { logger } from './logger';
6
+ import { CID } from 'multiformats/cid';
7
+ import * as dagCbor from '@ipld/dag-cbor';
8
+
9
+ /**
10
+ * Collect CIDs referenced by recent commits
11
+ * This traverses the MST structure to find all blocks that are still in use
12
+ */
13
+ async function collectReferencedCids(env: Env, keepCommits: number = 10000): Promise<Set<string>> {
14
+ const db = drizzle(env.DB);
15
+ const referenced = new Set<string>();
16
+
17
+ // Get recent commits
18
+ const commits = await db
19
+ .select()
20
+ .from(commit_log)
21
+ .orderBy(desc(commit_log.seq))
22
+ .limit(keepCommits)
23
+ .all();
24
+
25
+ logger.debug('blockstore_gc', { message: 'Collecting referenced CIDs', commits: commits.length });
26
+
27
+ // For each commit, parse the commit data and collect referenced CIDs
28
+ for (const commit of commits) {
29
+ try {
30
+ const commitData = JSON.parse(commit.data);
31
+
32
+ // Add the commit CID itself
33
+ referenced.add(commit.cid);
34
+
35
+ // Add the data CID (MST root)
36
+ if (commitData.data) {
37
+ referenced.add(commitData.data);
38
+
39
+ // Traverse the MST to collect all node CIDs
40
+ await traverseMst(env, commitData.data, referenced);
41
+ }
42
+
43
+ // Add prev commit CID if present
44
+ if (commitData.prev) {
45
+ referenced.add(commitData.prev);
46
+ }
47
+ } catch (error) {
48
+ logger.warn('blockstore_gc', {
49
+ message: 'Failed to parse commit data',
50
+ cid: commit.cid,
51
+ error: String(error)
52
+ });
53
+ }
54
+ }
55
+
56
+ return referenced;
57
+ }
58
+
59
+ /**
60
+ * Recursively traverse MST nodes to collect all CIDs
61
+ */
62
+ async function traverseMst(env: Env, rootCid: string, referenced: Set<string>): Promise<void> {
63
+ const db = drizzle(env.DB);
64
+ const visited = new Set<string>();
65
+ const queue = [rootCid];
66
+
67
+ while (queue.length > 0) {
68
+ const cidStr = queue.shift()!;
69
+
70
+ if (visited.has(cidStr)) continue;
71
+ visited.add(cidStr);
72
+ referenced.add(cidStr);
73
+
74
+ try {
75
+ // Load the block
76
+ const block = await db
77
+ .select()
78
+ .from(blockstore)
79
+ .where(eq(blockstore.cid, cidStr))
80
+ .get();
81
+
82
+ if (!block || !block.bytes) continue;
83
+
84
+ // Decode the CBOR data (workers-safe base64)
85
+ const bin = atob(block.bytes);
86
+ const bytes = new Uint8Array(bin.length);
87
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
88
+ const data = dagCbor.decode(bytes) as any;
89
+
90
+ // If this is an MST node, collect child CIDs
91
+ if (data.l) {
92
+ // Left subtree
93
+ const leftCid = CID.decode(data.l);
94
+ queue.push(leftCid.toString());
95
+ }
96
+
97
+ if (data.e) {
98
+ // Entries with subtrees
99
+ for (const entry of data.e) {
100
+ if (entry.t) {
101
+ const treeCid = CID.decode(entry.t);
102
+ queue.push(treeCid.toString());
103
+ }
104
+ if (entry.v) {
105
+ // Record value CID
106
+ const valueCid = CID.decode(entry.v);
107
+ referenced.add(valueCid.toString());
108
+ }
109
+ }
110
+ }
111
+ } catch (error) {
112
+ logger.warn('blockstore_gc', {
113
+ message: 'Failed to traverse MST node',
114
+ cid: cidStr,
115
+ error: String(error)
116
+ });
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Remove orphaned blocks from the blockstore
123
+ * Keeps blocks referenced by recent commits (default: last 10000 commits)
124
+ *
125
+ * @param env - Worker environment
126
+ * @param keepCommits - Number of recent commits to preserve blocks for (default: 10000)
127
+ * @returns Number of blocks removed
128
+ */
129
+ export async function pruneOrphanedBlocks(env: Env, keepCommits: number = 10000): Promise<number> {
130
+ const db = drizzle(env.DB);
131
+
132
+ // Collect all CIDs referenced by recent commits
133
+ const referenced = await collectReferencedCids(env, keepCommits);
134
+
135
+ logger.info('blockstore_gc', {
136
+ message: 'Collected referenced CIDs',
137
+ count: referenced.size
138
+ });
139
+
140
+ // Get all blocks
141
+ const allBlocks = await db.select({ cid: blockstore.cid }).from(blockstore).all();
142
+
143
+ // Find orphaned blocks
144
+ const orphaned = allBlocks
145
+ .map(b => b.cid)
146
+ .filter(cid => cid && !referenced.has(cid));
147
+
148
+ if (orphaned.length === 0) {
149
+ logger.debug('blockstore_gc', { message: 'No orphaned blocks to remove' });
150
+ return 0;
151
+ }
152
+
153
+ // Delete orphaned blocks in batches
154
+ const batchSize = 100;
155
+ let removed = 0;
156
+
157
+ for (let i = 0; i < orphaned.length; i += batchSize) {
158
+ const batch = orphaned.slice(i, i + batchSize);
159
+ const result = await db
160
+ .delete(blockstore)
161
+ .where(notInArray(blockstore.cid, Array.from(referenced)))
162
+ .run();
163
+
164
+ removed += result.meta.changes || 0;
165
+ }
166
+
167
+ logger.info('blockstore_gc', {
168
+ message: 'Pruned orphaned blocks',
169
+ removed,
170
+ kept: referenced.size
171
+ });
172
+
173
+ return removed;
174
+ }
175
+
176
+ /**
177
+ * Get blockstore statistics
178
+ */
179
+ export async function getBlockstoreStats(env: Env): Promise<{
180
+ total: number;
181
+ totalSize: number;
182
+ }> {
183
+ const db = drizzle(env.DB);
184
+ const blocks = await db.select().from(blockstore).all();
185
+
186
+ const totalSize = blocks.reduce((sum, block) => {
187
+ if (!block.bytes) return sum;
188
+ // Approximate decoded size by counting base64 length * 3/4
189
+ const len = Math.floor((block.bytes.length * 3) / 4);
190
+ return sum + len;
191
+ }, 0);
192
+
193
+ return {
194
+ total: blocks.length,
195
+ totalSize,
196
+ };
197
+ }
@@ -0,0 +1,236 @@
1
+ // Types via tsconfig.app.json
2
+
3
+ /**
4
+ * Edge caching utilities for Cloudflare Workers
5
+ *
6
+ * Implements caching strategies for:
7
+ * - DID documents (/.well-known/did.json)
8
+ * - Well-known files (/.well-known/atproto-did)
9
+ * - Frequently-accessed records
10
+ * - Static assets
11
+ */
12
+
13
+ export interface CacheOptions {
14
+ /**
15
+ * Cache TTL in seconds
16
+ */
17
+ ttl: number;
18
+
19
+ /**
20
+ * Cache key prefix
21
+ */
22
+ prefix?: string;
23
+
24
+ /**
25
+ * Whether to use stale-while-revalidate
26
+ */
27
+ swr?: boolean;
28
+
29
+ /**
30
+ * Stale-while-revalidate duration in seconds
31
+ */
32
+ swrTtl?: number;
33
+ }
34
+
35
+ /**
36
+ * Default cache configurations for different content types
37
+ */
38
+ export const CACHE_CONFIGS = {
39
+ // DID documents rarely change
40
+ DID_DOCUMENT: {
41
+ ttl: 3600, // 1 hour
42
+ swr: true,
43
+ swrTtl: 86400, // 24 hours
44
+ },
45
+
46
+ // Well-known files are static
47
+ WELL_KNOWN: {
48
+ ttl: 3600, // 1 hour
49
+ swr: true,
50
+ swrTtl: 86400, // 24 hours
51
+ },
52
+
53
+ // Records can be cached briefly
54
+ RECORD: {
55
+ ttl: 60, // 1 minute
56
+ swr: true,
57
+ swrTtl: 300, // 5 minutes
58
+ },
59
+
60
+ // Repo snapshots can be cached
61
+ REPO_SNAPSHOT: {
62
+ ttl: 300, // 5 minutes
63
+ swr: true,
64
+ swrTtl: 3600, // 1 hour
65
+ },
66
+ } as const;
67
+
68
+ /**
69
+ * Generate cache headers for a response
70
+ */
71
+ export function getCacheHeaders(options: CacheOptions): Record<string, string> {
72
+ const headers: Record<string, string> = {};
73
+
74
+ if (options.swr && options.swrTtl) {
75
+ // Use stale-while-revalidate
76
+ headers['Cache-Control'] = `public, max-age=${options.ttl}, stale-while-revalidate=${options.swrTtl}`;
77
+ } else {
78
+ // Simple max-age
79
+ headers['Cache-Control'] = `public, max-age=${options.ttl}`;
80
+ }
81
+
82
+ // Add ETag for conditional requests
83
+ headers['Vary'] = 'Accept, Accept-Encoding';
84
+
85
+ return headers;
86
+ }
87
+
88
+ /**
89
+ * Create a cache key from request
90
+ */
91
+ export function getCacheKey(request: Request, prefix?: string): string {
92
+ const url = new URL(request.url);
93
+ const key = `${url.pathname}${url.search}`;
94
+ return prefix ? `${prefix}:${key}` : key;
95
+ }
96
+
97
+ /**
98
+ * Get cached response from Cache API
99
+ */
100
+ export async function getCachedResponse(
101
+ request: Request,
102
+ options?: { prefix?: string }
103
+ ): Promise<Response | null> {
104
+ try {
105
+ const cache = (caches as any).default as Cache;
106
+ const cacheKey = getCacheKey(request, options?.prefix);
107
+ const cacheUrl = new URL(cacheKey, request.url);
108
+ const cacheRequest = new Request(cacheUrl, request);
109
+
110
+ return (await cache.match(cacheRequest)) ?? null;
111
+ } catch (error) {
112
+ console.error('Cache read error:', error);
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Put response in cache
119
+ */
120
+ export async function putCachedResponse(
121
+ request: Request,
122
+ response: Response,
123
+ options: CacheOptions
124
+ ): Promise<void> {
125
+ try {
126
+ const cache = (caches as any).default as Cache;
127
+ const cacheKey = getCacheKey(request, options.prefix);
128
+ const cacheUrl = new URL(cacheKey, request.url);
129
+ const cacheRequest = new Request(cacheUrl, request);
130
+
131
+ // Clone response and add cache headers
132
+ const headers = new Headers(response.headers);
133
+ const cacheHeaders = getCacheHeaders(options);
134
+ for (const [key, value] of Object.entries(cacheHeaders)) {
135
+ headers.set(key, value);
136
+ }
137
+
138
+ const cachedResponse = new Response(response.body, {
139
+ status: response.status,
140
+ statusText: response.statusText,
141
+ headers,
142
+ });
143
+
144
+ await cache.put(cacheRequest, cachedResponse);
145
+ } catch (error) {
146
+ console.error('Cache write error:', error);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Invalidate cache entry
152
+ */
153
+ export async function invalidateCache(
154
+ request: Request,
155
+ options?: { prefix?: string }
156
+ ): Promise<boolean> {
157
+ try {
158
+ const cache = (caches as any).default as Cache;
159
+ const cacheKey = getCacheKey(request, options?.prefix);
160
+ const cacheUrl = new URL(cacheKey, request.url);
161
+ const cacheRequest = new Request(cacheUrl, request);
162
+
163
+ return await cache.delete(cacheRequest);
164
+ } catch (error) {
165
+ console.error('Cache invalidation error:', error);
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Middleware to handle caching for GET requests
172
+ */
173
+ export async function withCache(
174
+ request: Request,
175
+ handler: () => Promise<Response>,
176
+ options: CacheOptions
177
+ ): Promise<Response> {
178
+ // Only cache GET requests
179
+ if (request.method !== 'GET') {
180
+ return handler();
181
+ }
182
+
183
+ // Check cache first
184
+ const cached = await getCachedResponse(request, { prefix: options.prefix });
185
+ if (cached) {
186
+ return cached;
187
+ }
188
+
189
+ // Generate response
190
+ const response = await handler();
191
+
192
+ // Only cache successful responses
193
+ if (response.ok) {
194
+ // Don't await - cache in background
195
+ putCachedResponse(request, response.clone(), options);
196
+ }
197
+
198
+ return response;
199
+ }
200
+
201
+ /**
202
+ * Generate ETag from content
203
+ */
204
+ export async function generateETag(content: string | Uint8Array): Promise<string> {
205
+ const data = typeof content === 'string'
206
+ ? new TextEncoder().encode(content)
207
+ : content;
208
+
209
+ // Digest accepts a BufferSource, so pass the Uint8Array view directly
210
+ const hash = await crypto.subtle.digest('SHA-256', new Uint8Array(data));
211
+ const hashArray = Array.from(new Uint8Array(hash));
212
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
213
+
214
+ return `"${hashHex.substring(0, 16)}"`;
215
+ }
216
+
217
+ /**
218
+ * Check if request has matching ETag
219
+ */
220
+ export function checkETag(request: Request, etag: string): boolean {
221
+ const ifNoneMatch = request.headers.get('If-None-Match');
222
+ return ifNoneMatch === etag;
223
+ }
224
+
225
+ /**
226
+ * Create 304 Not Modified response
227
+ */
228
+ export function notModifiedResponse(etag: string): Response {
229
+ return new Response(null, {
230
+ status: 304,
231
+ headers: {
232
+ 'ETag': etag,
233
+ 'Cache-Control': 'public, max-age=3600',
234
+ },
235
+ });
236
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * CAR (Content Addressable aRchive) Reader
3
+ * Implements CAR v1 spec: https://ipld.io/specs/transport/car/carv1/
4
+ */
5
+
6
+ import { CID } from 'multiformats/cid';
7
+ import * as dagCbor from '@ipld/dag-cbor';
8
+
9
+ export interface CarHeader {
10
+ version: 1;
11
+ roots: CID[];
12
+ }
13
+
14
+ export interface CarBlock {
15
+ cid: CID;
16
+ bytes: Uint8Array;
17
+ }
18
+
19
+ /**
20
+ * Read varint from buffer
21
+ * Returns [value, bytesRead]
22
+ */
23
+ function readVarint(bytes: Uint8Array, offset: number): [number, number] {
24
+ let value = 0;
25
+ let shift = 0;
26
+ let bytesRead = 0;
27
+
28
+ while (offset + bytesRead < bytes.length) {
29
+ const byte = bytes[offset + bytesRead];
30
+ bytesRead++;
31
+
32
+ value |= (byte & 0x7f) << shift;
33
+
34
+ if ((byte & 0x80) === 0) {
35
+ return [value, bytesRead];
36
+ }
37
+
38
+ shift += 7;
39
+ }
40
+
41
+ throw new Error('Invalid varint: unexpected end of buffer');
42
+ }
43
+
44
+ /**
45
+ * Parse CAR header from bytes
46
+ */
47
+ export function parseCarHeader(bytes: Uint8Array): { header: CarHeader; offset: number } {
48
+ // Read header length
49
+ const [headerLength, headerLengthBytes] = readVarint(bytes, 0);
50
+
51
+ // Extract header bytes
52
+ const headerStart = headerLengthBytes;
53
+ const headerEnd = headerStart + headerLength;
54
+ const headerBytes = bytes.slice(headerStart, headerEnd);
55
+
56
+ // Decode header
57
+ const decoded = dagCbor.decode(headerBytes) as any;
58
+
59
+ if (decoded.version !== 1) {
60
+ throw new Error(`Unsupported CAR version: ${decoded.version}`);
61
+ }
62
+
63
+ // Roots are already CID objects from dag-cbor decode
64
+ const roots = (decoded.roots || []).map((r: any) => {
65
+ if (r instanceof Uint8Array) {
66
+ return CID.decode(r);
67
+ }
68
+ return r; // Already a CID
69
+ });
70
+
71
+ return {
72
+ header: { version: 1, roots },
73
+ offset: headerEnd,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Parse a single block from CAR bytes
79
+ * Returns the block and the new offset, or null if no more blocks
80
+ */
81
+ export function parseCarBlock(bytes: Uint8Array, offset: number): { block: CarBlock; offset: number } | null {
82
+ if (offset >= bytes.length) {
83
+ return null;
84
+ }
85
+
86
+ // Read block length
87
+ const [blockLength, blockLengthBytes] = readVarint(bytes, offset);
88
+ offset += blockLengthBytes;
89
+
90
+ if (offset + blockLength > bytes.length) {
91
+ throw new Error('Invalid CAR: block extends beyond buffer');
92
+ }
93
+
94
+ // Extract block bytes
95
+ const blockBytes = bytes.slice(offset, offset + blockLength);
96
+ offset += blockLength;
97
+
98
+ // Parse CID (first part of block) using decodeFirst to get remainder
99
+ const [cid, remainder] = CID.decodeFirst(blockBytes);
100
+
101
+ // Extract data (rest of block is the remainder)
102
+ const data = remainder;
103
+
104
+ return {
105
+ block: { cid, bytes: data },
106
+ offset,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Parse entire CAR file
112
+ */
113
+ export function parseCarFile(bytes: Uint8Array): { header: CarHeader; blocks: CarBlock[] } {
114
+ const { header, offset: initialOffset } = parseCarHeader(bytes);
115
+ const blocks: CarBlock[] = [];
116
+
117
+ let offset = initialOffset;
118
+ while (offset < bytes.length) {
119
+ const result = parseCarBlock(bytes, offset);
120
+ if (!result) break;
121
+
122
+ blocks.push(result.block);
123
+ offset = result.offset;
124
+ }
125
+
126
+ return { header, blocks };
127
+ }
128
+
129
+ /**
130
+ * Validate that block CID matches content
131
+ */
132
+ export async function validateBlock(block: CarBlock): Promise<boolean> {
133
+ try {
134
+ const decoded = dagCbor.decode(block.bytes);
135
+ const reencoded = dagCbor.encode(decoded);
136
+
137
+ // Verify bytes match
138
+ if (reencoded.length !== block.bytes.length) {
139
+ return false;
140
+ }
141
+
142
+ for (let i = 0; i < reencoded.length; i++) {
143
+ if (reencoded[i] !== block.bytes[i]) {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ // Verify CID matches
149
+ const { sha256 } = await import('multiformats/hashes/sha2');
150
+ const hash = await sha256.digest(block.bytes);
151
+ const expectedCid = CID.createV1(dagCbor.code, hash);
152
+
153
+ return block.cid.equals(expectedCid);
154
+ } catch {
155
+ return false;
156
+ }
157
+ }