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.
- package/LICENSE +180 -0
- package/README.md +140 -0
- package/openapi.json +859 -0
- package/package.json +68 -0
- package/src/config.js +81 -0
- package/src/crypto/cid.js +49 -0
- package/src/index.js +45 -0
- package/src/masl/document.js +148 -0
- package/src/middleware/auth.js +11 -0
- package/src/middleware/cors.js +31 -0
- package/src/routes/mountPoints.js +86 -0
- package/src/routes/operator.js +944 -0
- package/src/routes/rasl.js +139 -0
- package/src/server.js +83 -0
- package/src/static.js +168 -0
- package/src/storage/db.js +133 -0
- package/src/storage/files.js +47 -0
- package/src/storage/store.js +171 -0
- package/src/util/env.js +32 -0
- package/src/util/loadRaslerConfig.js +14 -0
- package/src/util/mime.js +29 -0
- package/src/util/normalizeMountPath.js +12 -0
- package/src/util/parseJsonConfig.js +57 -0
- package/src/util/parseSize.js +16 -0
- package/src/watcher.js +42 -0
|
@@ -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
|
+
}
|