rasler 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.
@@ -0,0 +1,944 @@
1
+ import { Router, json as expressJson } from 'express';
2
+ import multer from 'multer';
3
+ import { CarReader } from '@ipld/car';
4
+ import { base32 } from 'multiformats/bases/base32';
5
+ import * as dagCbor from '@ipld/dag-cbor';
6
+ import { computeDataCid, computeMaslCid, isMaslCid } from '../crypto/cid.js';
7
+ import { createSingleMasl, parseMasl, maslLinkedCids, maslIsBundle } from '../masl/document.js';
8
+ import { realpathSync } from 'fs';
9
+ import { requireApiSecret } from '../middleware/auth.js';
10
+ import { makeOperatorCors } from '../middleware/cors.js';
11
+ import { normalizeMountPath } from '../util/normalizeMountPath.js';
12
+
13
+ const upload = multer({ storage: multer.memoryStorage() });
14
+
15
+ /**
16
+ * @openapi
17
+ * components:
18
+ * schemas:
19
+ * ContentItem:
20
+ * type: object
21
+ * properties:
22
+ * cid:
23
+ * type: string
24
+ * example: bafkreid7qoywk7hv5udpjlmxqmr4of3d5jx4k5r2kvfm4vs3l4dz3t7ku
25
+ * maslCid:
26
+ * type: string
27
+ * nullable: true
28
+ * example: bafyreid7qoywk7hv5udpjlmxqmr4of3d5jx4k5r2kvfm4vs3l4dz3t7ku
29
+ * size:
30
+ * type: integer
31
+ * example: 4096
32
+ * pinned:
33
+ * type: boolean
34
+ * lastRequested:
35
+ * type: integer
36
+ * nullable: true
37
+ * description: Unix timestamp in milliseconds
38
+ * UploadResult:
39
+ * type: object
40
+ * properties:
41
+ * filename:
42
+ * type: string
43
+ * maslCid:
44
+ * type: string
45
+ * Error:
46
+ * type: object
47
+ * properties:
48
+ * error:
49
+ * type: string
50
+ */
51
+
52
+ function isCarFile(file) {
53
+ return (
54
+ file.mimetype === 'application/vnd.ipld.car' ||
55
+ file.mimetype === 'application/car' ||
56
+ file.originalname?.toLowerCase().endsWith('.car')
57
+ );
58
+ }
59
+
60
+ // Extract, verify, and return all blocks from a CAR file.
61
+ // Returns { maslCid, blocks: Map<cidStr, Uint8Array> } or throws with { status, error, ...extra }.
62
+ async function readCar(fileBuffer) {
63
+ let reader;
64
+ try {
65
+ reader = await CarReader.fromBytes(fileBuffer);
66
+ } catch {
67
+ throw { status: 400, error: 'Invalid CAR file' };
68
+ }
69
+
70
+ const blocks = new Map();
71
+ try {
72
+ for await (const { cid, bytes } of reader.blocks()) {
73
+ const cidStr = cid.toString(base32);
74
+ const isMasl = cid.code === dagCbor.code;
75
+ const actual = isMasl ? await computeMaslCid(bytes) : await computeDataCid(bytes);
76
+ if (actual !== cidStr) throw { status: 400, error: `CID mismatch for block ${cidStr}` };
77
+ blocks.set(cidStr, bytes);
78
+ }
79
+ } catch (err) {
80
+ if (err.status) throw err;
81
+ throw { status: 400, error: `Failed to read CAR blocks: ${err.message}` };
82
+ }
83
+
84
+ const roots = await reader.getRoots();
85
+ const maslRoot = roots.find(cid => cid.code === dagCbor.code);
86
+ if (!maslRoot) throw { status: 400, error: 'CAR root must be a MASL CID (dag-cbor codec)' };
87
+
88
+ const maslCid = maslRoot.toString(base32);
89
+ if (!blocks.has(maslCid)) throw { status: 400, error: 'MASL root block is missing from the CAR' };
90
+
91
+ let doc;
92
+ try { doc = parseMasl(blocks.get(maslCid)); } catch {
93
+ throw { status: 400, error: 'Failed to parse MASL document' };
94
+ }
95
+
96
+ const links = maslLinkedCids(doc);
97
+ const missing = links.filter(l => !blocks.has(l.cid)).map(l => l.cid);
98
+ if (missing.length > 0) throw { status: 400, error: 'CAR is missing linked data CIDs', missing };
99
+
100
+ return { maslCid, blocks, links };
101
+ }
102
+
103
+ // Base operator router: content management and node status (base fields).
104
+ // Auth and CORS are applied here, which also protects any operator extension router
105
+ // routes — Express runs router.use middleware for all requests entering the router
106
+ // even when no route matches, so auth is enforced before the request falls through
107
+ // to the overlay extension.
108
+ export function makeOperatorRouter({ store, selfOrigin, apiSecret, corsOrigins = [], staticRoots = [], mountPoints = [] }) {
109
+ const router = Router();
110
+ if (corsOrigins.length > 0) router.use(makeOperatorCors(corsOrigins));
111
+ router.use(expressJson({ limit: '10mb' }));
112
+ router.use(requireApiSecret(apiSecret));
113
+
114
+ /**
115
+ * @openapi
116
+ * /upload:
117
+ * post:
118
+ * tags: [Content]
119
+ * summary: Upload one or more files
120
+ * description: >
121
+ * Each file is stored unpinned. Files are either a raw data file (any
122
+ * content type) or a CARv1 bundle (detected by content-type
123
+ * `application/vnd.ipld.car` or a `.car` extension). A raw file gets a
124
+ * single-mode MASL wrapper generated automatically.
125
+ * requestBody:
126
+ * required: true
127
+ * content:
128
+ * multipart/form-data:
129
+ * schema:
130
+ * type: object
131
+ * required: [files]
132
+ * properties:
133
+ * files:
134
+ * type: array
135
+ * items:
136
+ * type: string
137
+ * format: binary
138
+ * responses:
139
+ * '200':
140
+ * description: All files stored successfully
141
+ * content:
142
+ * application/json:
143
+ * schema:
144
+ * type: object
145
+ * properties:
146
+ * uploads:
147
+ * type: array
148
+ * items:
149
+ * $ref: '#/components/schemas/UploadResult'
150
+ * '400':
151
+ * description: Invalid input (bad CAR, CID mismatch, no files)
152
+ * content:
153
+ * application/json:
154
+ * schema:
155
+ * $ref: '#/components/schemas/Error'
156
+ * '401':
157
+ * description: Missing or invalid operator secret
158
+ * '507':
159
+ * description: Insufficient storage
160
+ * content:
161
+ * application/json:
162
+ * schema:
163
+ * $ref: '#/components/schemas/Error'
164
+ */
165
+ router.post('/upload', upload.array('files'), async (req, res) => {
166
+ const files = req.files;
167
+ if (!files || files.length === 0) {
168
+ return res.status(400).json({ error: 'No files provided' });
169
+ }
170
+
171
+ const uploads = [];
172
+
173
+ for (const file of files) {
174
+ if (isCarFile(file)) {
175
+ let parsed;
176
+ try {
177
+ parsed = await readCar(file.buffer);
178
+ } catch (err) {
179
+ return res.status(err.status ?? 400).json(
180
+ err.missing ? { error: err.error, missing: err.missing } : { error: err.error }
181
+ );
182
+ }
183
+
184
+ const { maslCid, blocks, links } = parsed;
185
+ const totalBytes = [...blocks.values()].reduce((n, b) => n + b.length, 0);
186
+ if (store.getPoolAvailable() < totalBytes) {
187
+ if (!store.evictIfNeeded(totalBytes)) {
188
+ return res.status(507).json({ error: 'Insufficient storage' });
189
+ }
190
+ }
191
+
192
+ store.putContent(maslCid, blocks.get(maslCid));
193
+ for (const link of links) {
194
+ store.putContent(link.cid, blocks.get(link.cid), { maslCid });
195
+ }
196
+ uploads.push({ filename: file.originalname, maslCid });
197
+
198
+ } else {
199
+ const bytes = file.buffer;
200
+ const name = file.originalname ?? 'upload';
201
+ const type = file.mimetype ?? 'application/octet-stream';
202
+ const size = bytes.length;
203
+
204
+ let dataCid;
205
+ try { dataCid = await computeDataCid(bytes); } catch {
206
+ return res.status(500).json({ error: `CID computation failed for ${name}` });
207
+ }
208
+
209
+ let maslResult;
210
+ try {
211
+ maslResult = await createSingleMasl({ name, type, size, dataCid });
212
+ } catch {
213
+ return res.status(500).json({ error: `MASL creation failed for ${name}` });
214
+ }
215
+
216
+ const { cborBytes, maslCid } = maslResult;
217
+ const totalBytes = bytes.length + cborBytes.length;
218
+ if (store.getPoolAvailable() < totalBytes) {
219
+ if (!store.evictIfNeeded(totalBytes)) {
220
+ return res.status(507).json({ error: 'Insufficient storage' });
221
+ }
222
+ }
223
+
224
+ store.putContent(dataCid, bytes, { maslCid });
225
+ store.putContent(maslCid, cborBytes);
226
+ uploads.push({ filename: name, maslCid });
227
+ }
228
+ }
229
+
230
+ return res.status(200).json({ uploads });
231
+ });
232
+
233
+ /**
234
+ * @openapi
235
+ * /pin:
236
+ * post:
237
+ * tags: [Content]
238
+ * summary: Pin stored CIDs
239
+ * description: >
240
+ * Marks CIDs as operator-pinned so they are never evicted. Pinning a
241
+ * MASL CID also pins all its linked data CIDs; pinning a data CID also
242
+ * pins its MASL wrapper.
243
+ * requestBody:
244
+ * required: true
245
+ * content:
246
+ * application/json:
247
+ * schema:
248
+ * type: object
249
+ * required: [cids]
250
+ * properties:
251
+ * cids:
252
+ * type: array
253
+ * items:
254
+ * type: string
255
+ * example: ["bafyreid7qoywk7hv5udpjlmxqmr4of3d5jx4k5r2kvfm4vs3l4dz3t7ku"]
256
+ * responses:
257
+ * '200':
258
+ * description: CIDs pinned (includes cascade pins)
259
+ * content:
260
+ * application/json:
261
+ * schema:
262
+ * type: object
263
+ * properties:
264
+ * pinned:
265
+ * type: array
266
+ * items:
267
+ * type: string
268
+ * '400':
269
+ * description: cids missing or empty
270
+ * content:
271
+ * application/json:
272
+ * schema:
273
+ * $ref: '#/components/schemas/Error'
274
+ * '401':
275
+ * description: Missing or invalid operator secret
276
+ * '404':
277
+ * description: One or more CIDs not held locally
278
+ * content:
279
+ * application/json:
280
+ * schema:
281
+ * $ref: '#/components/schemas/Error'
282
+ */
283
+ router.post('/pin', (req, res) => {
284
+ const { cids } = req.body ?? {};
285
+ if (!Array.isArray(cids) || cids.length === 0) {
286
+ return res.status(400).json({ error: 'cids must be a non-empty array' });
287
+ }
288
+
289
+ const pinned = new Set();
290
+
291
+ for (const cid of cids) {
292
+ const entry = store.getContent(cid);
293
+ if (!entry) return res.status(404).json({ error: `CID not found: ${cid}` });
294
+
295
+ store.setPinned(cid, true);
296
+ pinned.add(cid);
297
+
298
+ const maslCid = entry.meta.masl_cid;
299
+ if (maslCid && store.hasContent(maslCid)) {
300
+ store.setPinned(maslCid, true);
301
+ pinned.add(maslCid);
302
+ }
303
+
304
+ for (const row of store.listContent()) {
305
+ if (row.masl_cid === cid) {
306
+ store.setPinned(row.cid, true);
307
+ pinned.add(row.cid);
308
+ }
309
+ }
310
+ }
311
+
312
+ return res.status(200).json({ pinned: [...pinned] });
313
+ });
314
+
315
+ /**
316
+ * @openapi
317
+ * /pin/{cid}:
318
+ * delete:
319
+ * tags: [Content]
320
+ * summary: Unpin a CID
321
+ * description: >
322
+ * Removes the operator pin from a CID, making it eligible for eviction.
323
+ * Unpinning a MASL CID also unpins its linked data CIDs; unpinning a
324
+ * data CID also unpins its MASL wrapper. Returns 200 even if the CID is
325
+ * not held.
326
+ * parameters:
327
+ * - in: path
328
+ * name: cid
329
+ * required: true
330
+ * schema:
331
+ * type: string
332
+ * responses:
333
+ * '200':
334
+ * description: Unpinned (or not found)
335
+ * content:
336
+ * application/json:
337
+ * schema:
338
+ * type: object
339
+ * properties:
340
+ * status:
341
+ * type: string
342
+ * enum: [ok, not found]
343
+ * '401':
344
+ * description: Missing or invalid operator secret
345
+ */
346
+ router.delete('/pin/:cid', (req, res) => {
347
+ const { cid } = req.params;
348
+ const meta = store.getContent(cid)?.meta;
349
+
350
+ if (!meta) return res.status(200).json({ status: 'not found' });
351
+
352
+ store.setPinned(cid, false);
353
+
354
+ if (meta.masl_cid && store.hasContent(meta.masl_cid)) {
355
+ store.setPinned(meta.masl_cid, false);
356
+ }
357
+
358
+ for (const row of store.listContent()) {
359
+ if (row.masl_cid === cid) store.setPinned(row.cid, false);
360
+ }
361
+
362
+ return res.status(200).json({ status: 'ok' });
363
+ });
364
+
365
+ /**
366
+ * @openapi
367
+ * /content:
368
+ * get:
369
+ * tags: [Content]
370
+ * summary: List locally held CIDs
371
+ * description: Cursor-based pagination over all CIDs held by this node, ordered by CID.
372
+ * parameters:
373
+ * - in: query
374
+ * name: limit
375
+ * schema:
376
+ * type: integer
377
+ * default: 50
378
+ * minimum: 1
379
+ * maximum: 200
380
+ * description: Number of items per page
381
+ * - in: query
382
+ * name: cursor
383
+ * schema:
384
+ * type: string
385
+ * description: Exclusive lower-bound CID from the previous page's `nextCursor`
386
+ * responses:
387
+ * '200':
388
+ * description: Page of content items
389
+ * content:
390
+ * application/json:
391
+ * schema:
392
+ * type: object
393
+ * properties:
394
+ * total:
395
+ * type: integer
396
+ * description: Total number of CIDs held (across all pages)
397
+ * items:
398
+ * type: array
399
+ * items:
400
+ * $ref: '#/components/schemas/ContentItem'
401
+ * nextCursor:
402
+ * type: string
403
+ * nullable: true
404
+ * description: Pass as `cursor` to fetch the next page; null on the last page
405
+ * '401':
406
+ * description: Missing or invalid operator secret
407
+ */
408
+ router.get('/content', (req, res) => {
409
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit ?? '50', 10) || 50));
410
+ const cursor = req.query.cursor ?? null;
411
+
412
+ const items = store.listContentPage(limit, cursor);
413
+ const total = store.countContent();
414
+ const nextCursor = items.length === limit ? items[items.length - 1].cid : null;
415
+
416
+ return res.status(200).json({
417
+ total,
418
+ items: items.map(row => ({
419
+ cid: row.cid,
420
+ maslCid: row.masl_cid ?? null,
421
+ size: row.size,
422
+ pinned: row.pinned === 1,
423
+ lastRequested: row.last_requested ?? null,
424
+ })),
425
+ nextCursor,
426
+ });
427
+ });
428
+
429
+ /**
430
+ * @openapi
431
+ * /content/{cid}:
432
+ * get:
433
+ * tags: [Content]
434
+ * summary: Get metadata for a single CID
435
+ * parameters:
436
+ * - in: path
437
+ * name: cid
438
+ * required: true
439
+ * schema:
440
+ * type: string
441
+ * responses:
442
+ * '200':
443
+ * description: CID metadata
444
+ * content:
445
+ * application/json:
446
+ * schema:
447
+ * $ref: '#/components/schemas/ContentItem'
448
+ * '401':
449
+ * description: Missing or invalid operator secret
450
+ * '404':
451
+ * description: CID not held locally
452
+ * content:
453
+ * application/json:
454
+ * schema:
455
+ * $ref: '#/components/schemas/Error'
456
+ * delete:
457
+ * tags: [Content]
458
+ * summary: Force-remove a CID
459
+ * description: >
460
+ * Permanently deletes a CID regardless of pin status. If the CID is a
461
+ * MASL document, all linked data CIDs that are held locally are also
462
+ * deleted. Returns the list of all CIDs actually removed.
463
+ * parameters:
464
+ * - in: path
465
+ * name: cid
466
+ * required: true
467
+ * schema:
468
+ * type: string
469
+ * responses:
470
+ * '200':
471
+ * description: CID(s) deleted
472
+ * content:
473
+ * application/json:
474
+ * schema:
475
+ * type: object
476
+ * properties:
477
+ * deleted:
478
+ * type: array
479
+ * items:
480
+ * type: string
481
+ * '401':
482
+ * description: Missing or invalid operator secret
483
+ * '404':
484
+ * description: CID not held locally
485
+ * content:
486
+ * application/json:
487
+ * schema:
488
+ * $ref: '#/components/schemas/Error'
489
+ */
490
+ router.get('/content/:cid', (req, res) => {
491
+ const { cid } = req.params;
492
+ const meta = store.getContentMeta(cid);
493
+ if (!meta) return res.status(404).json({ error: 'CID not found' });
494
+ return res.status(200).json({
495
+ cid: meta.cid,
496
+ maslCid: meta.masl_cid ?? null,
497
+ size: meta.size,
498
+ pinned: meta.pinned === 1,
499
+ lastRequested: meta.last_requested ?? null,
500
+ });
501
+ });
502
+
503
+ router.delete('/content/:cid', (req, res) => {
504
+ const { cid } = req.params;
505
+ const entry = store.getContent(cid);
506
+ if (!entry) return res.status(404).json({ error: 'CID not found' });
507
+
508
+ const deleted = [];
509
+
510
+ if (isMaslCid(cid)) {
511
+ let linkedCids = [];
512
+ try {
513
+ linkedCids = maslLinkedCids(parseMasl(entry.bytes)).map(l => l.cid);
514
+ } catch {
515
+ // Unparseable MASL — fall through and delete just the MASL itself
516
+ }
517
+ for (const linkedCid of linkedCids) {
518
+ if (store.hasContent(linkedCid)) {
519
+ store.deleteContent(linkedCid);
520
+ deleted.push(linkedCid);
521
+ }
522
+ }
523
+ }
524
+
525
+ store.deleteContent(cid);
526
+ deleted.push(cid);
527
+
528
+ return res.status(200).json({ deleted });
529
+ });
530
+
531
+ /**
532
+ * @openapi
533
+ * /static-roots:
534
+ * get:
535
+ * tags: [Content]
536
+ * summary: List configured static roots and their current MASL CIDs
537
+ * description: >
538
+ * Returns one entry per directory configured in STATIC_ROOTS. `maslCid`
539
+ * is null if background indexing has not yet completed for that root.
540
+ * responses:
541
+ * '200':
542
+ * description: Static root list
543
+ * content:
544
+ * application/json:
545
+ * schema:
546
+ * type: array
547
+ * items:
548
+ * type: object
549
+ * properties:
550
+ * path:
551
+ * type: string
552
+ * description: Configured directory path
553
+ * maslCid:
554
+ * type: string
555
+ * nullable: true
556
+ * description: Current bundle MASL CID, or null if not yet indexed
557
+ * '401':
558
+ * description: Missing or invalid operator secret
559
+ */
560
+ router.get('/static-roots', (req, res) => {
561
+ const result = staticRoots.map(root => {
562
+ const directory = typeof root === 'string' ? root : root.directory;
563
+ let maslCid = null;
564
+ try {
565
+ maslCid = store.staticRootMasls.get(realpathSync(directory)) ?? null;
566
+ } catch { /* root path does not exist */ }
567
+ return { path: directory, maslCid };
568
+ });
569
+ return res.status(200).json(result);
570
+ });
571
+
572
+ /**
573
+ * @openapi
574
+ * /mount-points:
575
+ * get:
576
+ * tags: [Content]
577
+ * summary: List all virtual hosts and their current MASL CIDs
578
+ * description: >
579
+ * Returns all mount points: those set via the operator API (`source:
580
+ * runtime`) and those configured in MOUNT_POINTS (`source: static`).
581
+ * Runtime entries take priority in serving. `hostname` is null for
582
+ * wildcard entries that match any `Host:` header. `maslCid` is null
583
+ * only for static entries whose background indexing has not yet
584
+ * completed.
585
+ * responses:
586
+ * '200':
587
+ * description: Mount point list
588
+ * content:
589
+ * application/json:
590
+ * schema:
591
+ * type: array
592
+ * items:
593
+ * type: object
594
+ * properties:
595
+ * hostname:
596
+ * type: string
597
+ * nullable: true
598
+ * description: >
599
+ * Hostname matched by this mount point, or null for
600
+ * a wildcard entry that matches any Host: header.
601
+ * mountPath:
602
+ * type: string
603
+ * description: URL path prefix for this mount point (e.g. / or /docs)
604
+ * path:
605
+ * type: string
606
+ * nullable: true
607
+ * description: Directory path (static entries only)
608
+ * maslCid:
609
+ * type: string
610
+ * nullable: true
611
+ * source:
612
+ * type: string
613
+ * enum: [runtime, static]
614
+ * '401':
615
+ * description: Missing or invalid operator secret
616
+ */
617
+ router.get('/mount-points', (req, res) => {
618
+ const result = [];
619
+ const seen = new Set();
620
+
621
+ // Runtime entries first (they take serving priority).
622
+ for (const mp of store.runtimeMountPoints) {
623
+ const key = `${mp.hostname}|${mp.prefix}`;
624
+ seen.add(key);
625
+ result.push({ hostname: mp.hostname || null, mountPath: mp.prefix || '/', path: null, maslCid: mp.maslCid, source: 'runtime' });
626
+ }
627
+
628
+ // Config-backed entries not overridden by a runtime mapping at the same (hostname, prefix).
629
+ for (const mp of mountPoints) {
630
+ const key = `${mp.hostname}|${mp.prefix}`;
631
+ if (seen.has(key)) continue;
632
+ let maslCid = null;
633
+ try { maslCid = store.staticRootMasls.get(realpathSync(mp.directory)) ?? null; } catch { /* directory may not exist */ }
634
+ result.push({ hostname: mp.hostname || null, mountPath: mp.prefix || '/', path: mp.directory, maslCid, source: 'static' });
635
+ }
636
+
637
+ return res.status(200).json(result);
638
+ });
639
+
640
+ /**
641
+ * @openapi
642
+ * /mount-points/{hostname}:
643
+ * put:
644
+ * tags: [Content]
645
+ * summary: Map a hostname to a bundle MASL CID (root mount)
646
+ * description: >
647
+ * Registers a runtime mount point at the hostname root. To mount at a
648
+ * path prefix instead, append it to the URL:
649
+ * `PUT /mount-points/{hostname}/{prefix}`. Use `-` as the hostname to
650
+ * create a wildcard mount point that matches any `Host:` header value
651
+ * (e.g. `PUT /mount-points/-` or `PUT /mount-points/-/docs`). The MASL
652
+ * CID must already be held locally and must be a bundle MASL. The MASL
653
+ * is pinned automatically to prevent eviction. This mapping takes
654
+ * priority over any static-root mapping for the same (hostname,
655
+ * mountPath) and persists across restarts.
656
+ * parameters:
657
+ * - in: path
658
+ * name: hostname
659
+ * required: true
660
+ * schema:
661
+ * type: string
662
+ * description: >
663
+ * Hostname to match against the `Host:` header. Use `-` to match
664
+ * any hostname (wildcard).
665
+ * requestBody:
666
+ * required: true
667
+ * content:
668
+ * application/json:
669
+ * schema:
670
+ * type: object
671
+ * required: [maslCid]
672
+ * properties:
673
+ * maslCid:
674
+ * type: string
675
+ * responses:
676
+ * '200':
677
+ * description: Mapping set
678
+ * content:
679
+ * application/json:
680
+ * schema:
681
+ * type: object
682
+ * properties:
683
+ * hostname:
684
+ * type: string
685
+ * nullable: true
686
+ * description: Matched hostname, or null for a wildcard mount
687
+ * mountPath:
688
+ * type: string
689
+ * maslCid:
690
+ * type: string
691
+ * '400':
692
+ * description: Invalid or non-bundle MASL CID
693
+ * content:
694
+ * application/json:
695
+ * schema:
696
+ * $ref: '#/components/schemas/Error'
697
+ * '401':
698
+ * description: Missing or invalid operator secret
699
+ * '404':
700
+ * description: CID not held locally
701
+ * content:
702
+ * application/json:
703
+ * schema:
704
+ * $ref: '#/components/schemas/Error'
705
+ * delete:
706
+ * tags: [Content]
707
+ * summary: Remove a runtime mount point mapping (root)
708
+ * description: >
709
+ * Removes the root mapping for this hostname. To remove a prefixed
710
+ * mapping, use `DELETE /mount-points/{hostname}/{prefix}`. Use `-` as
711
+ * the hostname to target a wildcard mount point.
712
+ * parameters:
713
+ * - in: path
714
+ * name: hostname
715
+ * required: true
716
+ * schema:
717
+ * type: string
718
+ * description: >
719
+ * Hostname of the mapping to remove. Use `-` for a wildcard mount.
720
+ * responses:
721
+ * '200':
722
+ * description: Mapping removed
723
+ * content:
724
+ * application/json:
725
+ * schema:
726
+ * type: object
727
+ * properties:
728
+ * status:
729
+ * type: string
730
+ * enum: [ok]
731
+ * '401':
732
+ * description: Missing or invalid operator secret
733
+ * '404':
734
+ * description: Runtime mapping not found
735
+ * content:
736
+ * application/json:
737
+ * schema:
738
+ * $ref: '#/components/schemas/Error'
739
+ *
740
+ * /mount-points/{hostname}/{prefix}:
741
+ * put:
742
+ * tags: [Content]
743
+ * summary: Map a hostname + path prefix to a bundle MASL CID
744
+ * description: >
745
+ * Same as `PUT /mount-points/{hostname}` but mounts at a path prefix
746
+ * (e.g. `/docs`). The prefix is stripped before MASL resource lookup.
747
+ * Use `-` as the hostname for a wildcard mount at the given prefix.
748
+ * parameters:
749
+ * - in: path
750
+ * name: hostname
751
+ * required: true
752
+ * schema:
753
+ * type: string
754
+ * description: >
755
+ * Hostname to match, or `-` for any hostname.
756
+ * - in: path
757
+ * name: prefix
758
+ * required: true
759
+ * schema:
760
+ * type: string
761
+ * requestBody:
762
+ * required: true
763
+ * content:
764
+ * application/json:
765
+ * schema:
766
+ * type: object
767
+ * required: [maslCid]
768
+ * properties:
769
+ * maslCid:
770
+ * type: string
771
+ * responses:
772
+ * '200':
773
+ * description: Mapping set
774
+ * content:
775
+ * application/json:
776
+ * schema:
777
+ * type: object
778
+ * properties:
779
+ * hostname:
780
+ * type: string
781
+ * nullable: true
782
+ * description: Matched hostname, or null for a wildcard mount
783
+ * mountPath:
784
+ * type: string
785
+ * maslCid:
786
+ * type: string
787
+ * '400':
788
+ * $ref: '#/components/responses/400'
789
+ * '401':
790
+ * description: Missing or invalid operator secret
791
+ * '404':
792
+ * $ref: '#/components/responses/404'
793
+ * delete:
794
+ * tags: [Content]
795
+ * summary: Remove a runtime mount point mapping (prefixed)
796
+ * parameters:
797
+ * - in: path
798
+ * name: hostname
799
+ * required: true
800
+ * schema:
801
+ * type: string
802
+ * description: >
803
+ * Hostname of the mapping to remove. Use `-` for a wildcard mount.
804
+ * - in: path
805
+ * name: prefix
806
+ * required: true
807
+ * schema:
808
+ * type: string
809
+ * responses:
810
+ * '200':
811
+ * description: Mapping removed
812
+ * content:
813
+ * application/json:
814
+ * schema:
815
+ * type: object
816
+ * properties:
817
+ * status:
818
+ * type: string
819
+ * enum: [ok]
820
+ * '401':
821
+ * description: Missing or invalid operator secret
822
+ * '404':
823
+ * description: Runtime mapping not found
824
+ * content:
825
+ * application/json:
826
+ * schema:
827
+ * $ref: '#/components/schemas/Error'
828
+ */
829
+ function getMountPrefix(req) {
830
+ const { hostname } = req.params;
831
+ return normalizeMountPath(req.path.slice('/mount-points/'.length + hostname.length) || '/');
832
+ }
833
+
834
+ function handleMountPointPut(req, res) {
835
+ const { hostname: hostnameParam } = req.params;
836
+ const hostname = hostnameParam === '-' ? '' : hostnameParam;
837
+ const prefix = getMountPrefix(req);
838
+ const { maslCid } = req.body ?? {};
839
+ if (!maslCid || typeof maslCid !== 'string') {
840
+ return res.status(400).json({ error: 'maslCid is required' });
841
+ }
842
+ if (!isMaslCid(maslCid)) {
843
+ return res.status(400).json({ error: 'maslCid must be a dag-cbor CID' });
844
+ }
845
+ const entry = store.getContent(maslCid);
846
+ if (!entry) {
847
+ return res.status(404).json({ error: 'CID not held locally' });
848
+ }
849
+ let doc;
850
+ try { doc = parseMasl(entry.bytes); } catch {
851
+ return res.status(400).json({ error: 'Failed to parse MASL document' });
852
+ }
853
+ if (!maslIsBundle(doc)) {
854
+ return res.status(400).json({ error: 'maslCid must refer to a bundle MASL' });
855
+ }
856
+ store.setPinned(maslCid, true);
857
+ store.setMountPoint(hostname, prefix, maslCid);
858
+ return res.status(200).json({ hostname: hostname || null, mountPath: prefix || '/', maslCid });
859
+ }
860
+
861
+ function handleMountPointDelete(req, res) {
862
+ const { hostname: hostnameParam } = req.params;
863
+ const hostname = hostnameParam === '-' ? '' : hostnameParam;
864
+ const prefix = getMountPrefix(req);
865
+ const exists = store.runtimeMountPoints.some(mp => mp.hostname === hostname && mp.prefix === prefix);
866
+ if (!exists) {
867
+ return res.status(404).json({ error: 'Runtime virtual host mapping not found' });
868
+ }
869
+ store.deleteMountPoint(hostname, prefix);
870
+ return res.status(200).json({ status: 'ok' });
871
+ }
872
+
873
+ router.put('/mount-points/:hostname', handleMountPointPut);
874
+ router.put('/mount-points/:hostname/*prefix', handleMountPointPut);
875
+ router.delete('/mount-points/:hostname', handleMountPointDelete);
876
+ router.delete('/mount-points/:hostname/*prefix', handleMountPointDelete);
877
+
878
+ /**
879
+ * @openapi
880
+ * /status:
881
+ * get:
882
+ * tags: [Node status]
883
+ * summary: Get node status
884
+ * description: >
885
+ * Returns the node's public origin and current storage metrics. Overlay
886
+ * routers may add additional fields to the response before the terminator
887
+ * sends it.
888
+ * responses:
889
+ * '200':
890
+ * description: Node status
891
+ * content:
892
+ * application/json:
893
+ * schema:
894
+ * type: object
895
+ * properties:
896
+ * origin:
897
+ * type: string
898
+ * description: Public origin of this node (protocol + host)
899
+ * example: https://node1.example.com
900
+ * storage:
901
+ * type: object
902
+ * properties:
903
+ * totalCapacity:
904
+ * type: integer
905
+ * description: Total storage budget in bytes
906
+ * poolUsed:
907
+ * type: integer
908
+ * description: Bytes used by unpinned (evictable) content
909
+ * poolAvailable:
910
+ * type: integer
911
+ * description: Bytes remaining before eviction is triggered
912
+ * pinnedUsed:
913
+ * type: integer
914
+ * description: Bytes used by pinned content (not evictable)
915
+ * pinnedCount:
916
+ * type: integer
917
+ * description: Number of pinned CIDs
918
+ * '401':
919
+ * description: Missing or invalid operator secret
920
+ */
921
+ // Base /status: writes local fields and falls through to overlay/terminator.
922
+ router.get('/status', (req, res, next) => {
923
+ res.locals.status = {
924
+ origin: selfOrigin,
925
+ storage: {
926
+ totalCapacity: store.totalCapacity,
927
+ poolUsed: store.getPoolUsed(),
928
+ poolAvailable: store.getPoolAvailable(),
929
+ pinnedUsed: store.getPinnedUsed(),
930
+ pinnedCount: store.countPinned(),
931
+ },
932
+ };
933
+ next();
934
+ });
935
+
936
+ return router;
937
+ }
938
+
939
+ // Terminator: sends the /status response assembled by base and overlay handlers.
940
+ export function makeOperatorStatusTerminator() {
941
+ const router = Router();
942
+ router.get('/status', (req, res) => res.status(200).json(res.locals.status));
943
+ return router;
944
+ }