underpost 3.0.0 → 3.0.2

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.
@@ -10,6 +10,7 @@ import fs from 'fs-extra';
10
10
  import { shellExec } from './process.js';
11
11
  import { loggerFactory } from './logger.js';
12
12
  import { JSONweb } from './client-formatted.js';
13
+ import { ssrFactory } from './ssr.js';
13
14
 
14
15
  /**
15
16
  * Builds API documentation using Swagger
@@ -62,53 +63,91 @@ const buildApiDocs = async ({
62
63
  components: {
63
64
  schemas: {
64
65
  userRequest: {
65
- username: 'user123',
66
- password: 'Password123',
67
- email: 'user@example.com',
66
+ type: 'object',
67
+ required: ['username', 'password', 'email'],
68
+ properties: {
69
+ username: { type: 'string', example: 'user123' },
70
+ password: { type: 'string', example: 'Password123!' },
71
+ email: { type: 'string', format: 'email', example: 'user@example.com' },
72
+ },
68
73
  },
69
74
  userResponse: {
70
- status: 'success',
71
- data: {
72
- token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7Il9pZCI6IjY2YzM3N2Y1N2Y5OWU1OTY5YjgxZG...',
73
- user: {
74
- _id: '66c377f57f99e5969b81de89',
75
- email: 'user@example.com',
76
- emailConfirmed: false,
77
- username: 'user123',
78
- role: 'user',
79
- profileImageId: '66c377f57f99e5969b81de87',
75
+ type: 'object',
76
+ properties: {
77
+ status: { type: 'string', example: 'success' },
78
+ data: {
79
+ type: 'object',
80
+ properties: {
81
+ token: {
82
+ type: 'string',
83
+ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7Il9pZCI6IjY2YzM3N2Y1N2Y5OWU1OTY5YjgxZG...',
84
+ },
85
+ user: {
86
+ type: 'object',
87
+ properties: {
88
+ _id: { type: 'string', example: '66c377f57f99e5969b81de89' },
89
+ email: { type: 'string', format: 'email', example: 'user@example.com' },
90
+ emailConfirmed: { type: 'boolean', example: false },
91
+ username: { type: 'string', example: 'user123' },
92
+ role: { type: 'string', example: 'user' },
93
+ profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' },
94
+ },
95
+ },
96
+ },
80
97
  },
81
98
  },
82
99
  },
83
100
  userUpdateResponse: {
84
- status: 'success',
85
- data: {
86
- _id: '66c377f57f99e5969b81de89',
87
- email: 'user@example.com',
88
- emailConfirmed: false,
89
- username: 'user123222',
90
- role: 'user',
91
- profileImageId: '66c377f57f99e5969b81de87',
101
+ type: 'object',
102
+ properties: {
103
+ status: { type: 'string', example: 'success' },
104
+ data: {
105
+ type: 'object',
106
+ properties: {
107
+ _id: { type: 'string', example: '66c377f57f99e5969b81de89' },
108
+ email: { type: 'string', format: 'email', example: 'user@example.com' },
109
+ emailConfirmed: { type: 'boolean', example: false },
110
+ username: { type: 'string', example: 'user123222' },
111
+ role: { type: 'string', example: 'user' },
112
+ profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' },
113
+ },
114
+ },
92
115
  },
93
116
  },
94
117
  userGetResponse: {
95
- status: 'success',
96
- data: {
97
- _id: '66c377f57f99e5969b81de89',
98
- email: 'user@example.com',
99
- emailConfirmed: false,
100
- username: 'user123222',
101
- role: 'user',
102
- profileImageId: '66c377f57f99e5969b81de87',
118
+ type: 'object',
119
+ properties: {
120
+ status: { type: 'string', example: 'success' },
121
+ data: {
122
+ type: 'object',
123
+ properties: {
124
+ _id: { type: 'string', example: '66c377f57f99e5969b81de89' },
125
+ email: { type: 'string', format: 'email', example: 'user@example.com' },
126
+ emailConfirmed: { type: 'boolean', example: false },
127
+ username: { type: 'string', example: 'user123222' },
128
+ role: { type: 'string', example: 'user' },
129
+ profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' },
130
+ },
131
+ },
103
132
  },
104
133
  },
105
134
  userLogInRequest: {
106
- email: 'user@example.com',
107
- password: 'Password123',
135
+ type: 'object',
136
+ required: ['email', 'password'],
137
+ properties: {
138
+ email: { type: 'string', format: 'email', example: 'user@example.com' },
139
+ password: { type: 'string', example: 'Password123!' },
140
+ },
108
141
  },
109
142
  userBadRequestResponse: {
110
- status: 'error',
111
- message: 'Bad request. Please check your inputs, and try again',
143
+ type: 'object',
144
+ properties: {
145
+ status: { type: 'string', example: 'error' },
146
+ message: {
147
+ type: 'string',
148
+ example: 'Bad request. Please check your inputs, and try again',
149
+ },
150
+ },
112
151
  },
113
152
  },
114
153
  securitySchemes: {
@@ -120,6 +159,44 @@ const buildApiDocs = async ({
120
159
  },
121
160
  };
122
161
 
162
+ /**
163
+ * swagger-autogen has no requestBody annotation support — it only handles
164
+ * #swagger.parameters, responses, security, etc. We define the requestBody
165
+ * objects here and inject them into the generated JSON as a post-processing step.
166
+ *
167
+ * Each key is an "<method> <path>" pair matching the generated paths object.
168
+ * The value is a valid OAS 3.0 requestBody object.
169
+ */
170
+ const requestBodies = {
171
+ 'post /user': {
172
+ description: 'User registration data',
173
+ required: true,
174
+ content: {
175
+ 'application/json': {
176
+ schema: { $ref: '#/components/schemas/userRequest' },
177
+ },
178
+ },
179
+ },
180
+ 'post /user/auth': {
181
+ description: 'User login credentials',
182
+ required: true,
183
+ content: {
184
+ 'application/json': {
185
+ schema: { $ref: '#/components/schemas/userLogInRequest' },
186
+ },
187
+ },
188
+ },
189
+ 'put /user/{id}': {
190
+ description: 'User fields to update',
191
+ required: true,
192
+ content: {
193
+ 'application/json': {
194
+ schema: { $ref: '#/components/schemas/userRequest' },
195
+ },
196
+ },
197
+ },
198
+ };
199
+
123
200
  logger.warn('build swagger api docs', doc.info);
124
201
 
125
202
  // swagger-autogen@2.9.2 bug: getProducesTag, getConsumesTag, getResponsesTag missing __¬¬¬__ decode before eval
@@ -148,6 +225,33 @@ const buildApiDocs = async ({
148
225
  }
149
226
 
150
227
  await swaggerAutoGen({ openapi: '3.0.0' })(outputFile, routes, doc);
228
+
229
+ // Post-process: inject requestBody into operations — swagger-autogen silently
230
+ // ignores #swagger.requestBody annotations and has no internal OAS-3 body support.
231
+ if (fs.existsSync(outputFile)) {
232
+ const swaggerJson = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
233
+ let patched = false;
234
+
235
+ for (const [key, requestBody] of Object.entries(requestBodies)) {
236
+ const [method, ...pathParts] = key.split(' ');
237
+ const opPath = pathParts.join(' ');
238
+ if (swaggerJson.paths?.[opPath]?.[method]) {
239
+ swaggerJson.paths[opPath][method].requestBody = requestBody;
240
+ // Remove any stale in:body entry from parameters (OAS 3.0 doesn't allow it)
241
+ if (Array.isArray(swaggerJson.paths[opPath][method].parameters)) {
242
+ swaggerJson.paths[opPath][method].parameters = swaggerJson.paths[opPath][method].parameters.filter(
243
+ (p) => p.in !== 'body',
244
+ );
245
+ }
246
+ patched = true;
247
+ }
248
+ }
249
+
250
+ if (patched) {
251
+ fs.writeFileSync(outputFile, JSON.stringify(swaggerJson, null, 2), 'utf8');
252
+ // logger.warn('swagger post-process: requestBody injected', Object.keys(requestBodies));
253
+ }
254
+ }
151
255
  });
152
256
  };
153
257
 
@@ -247,4 +351,18 @@ const buildDocs = async ({
247
351
  });
248
352
  };
249
353
 
250
- export { buildDocs };
354
+ /**
355
+ * Builds Swagger UI customization options by rendering the SwaggerDarkMode SSR body component.
356
+ * Returns the customCss and customJsStr strings required by swagger-ui-express to enable
357
+ * a dark/light mode toggle button with a black/gray gradient dark theme.
358
+ * @function buildSwaggerUiOptions
359
+ * @memberof clientBuildDocs
360
+ * @returns {Promise<{customCss: string, customJsStr: string}>} Swagger UI setup options
361
+ */
362
+ const buildSwaggerUiOptions = async () => {
363
+ const swaggerDarkMode = await ssrFactory('./src/client/ssr/body/SwaggerDarkMode.js');
364
+ const { css, js } = swaggerDarkMode();
365
+ return { customCss: css, customJsStr: js };
366
+ };
367
+
368
+ export { buildDocs, buildSwaggerUiOptions };
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Lightweight IPFS HTTP client for communicating with a Kubo (go-ipfs) node
3
+ * and an IPFS Cluster daemon running side-by-side in the same StatefulSet.
4
+ *
5
+ * Kubo API (port 5001) – add / pin / cat content.
6
+ * Cluster API (port 9094) – replicate pins across the cluster.
7
+ *
8
+ * Uses native `FormData` + `Blob` (Node ≥ 18) for reliable multipart encoding.
9
+ *
10
+ * @module src/server/ipfs-client.js
11
+ * @namespace IpfsClient
12
+ */
13
+
14
+ import stringify from 'fast-json-stable-stringify';
15
+ import { loggerFactory } from './logger.js';
16
+
17
+ const logger = loggerFactory(import.meta);
18
+
19
+ // ─────────────────────────────────────────────────────────
20
+ // URL helpers
21
+ // ─────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Base URL of the Kubo RPC API (port 5001).
25
+ * @returns {string}
26
+ */
27
+ const getIpfsApiUrl = () =>
28
+ process.env.IPFS_API_URL || `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:5001`;
29
+
30
+ /**
31
+ * Base URL of the IPFS Cluster REST API (port 9094).
32
+ * @returns {string}
33
+ */
34
+ const getClusterApiUrl = () =>
35
+ process.env.IPFS_CLUSTER_API_URL ||
36
+ `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:9094`;
37
+
38
+ /**
39
+ * Base URL of the IPFS HTTP Gateway (port 8080).
40
+ * @returns {string}
41
+ */
42
+ const getGatewayUrl = () =>
43
+ process.env.IPFS_GATEWAY_URL ||
44
+ `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:8080`;
45
+
46
+ // ─────────────────────────────────────────────────────────
47
+ // Core: add content
48
+ // ─────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * @typedef {Object} IpfsAddResult
52
+ * @property {string} cid – CID (Content Identifier) returned by the node.
53
+ * @property {number} size – Cumulative DAG size reported by the node.
54
+ */
55
+
56
+ /**
57
+ * Add arbitrary bytes to the Kubo node AND pin them on the IPFS Cluster.
58
+ *
59
+ * 1. `POST /api/v0/add?pin=true` to Kubo (5001) – stores + locally pins.
60
+ * 2. `POST /pins/<CID>` to the Cluster REST API (9094) – replicates the pin
61
+ * across every peer so `GET /pins` on the cluster shows the content.
62
+ * 3. Copies into MFS so the Web UI "Files" section shows the file.
63
+ *
64
+ * @param {Buffer|string} content – raw bytes or a UTF-8 string to store.
65
+ * @param {string} [filename='data'] – logical filename for the upload.
66
+ * @param {string} [mfsPath] – optional full MFS path.
67
+ * When omitted defaults to `/pinned/<filename>`.
68
+ * @returns {Promise<IpfsAddResult|null>} `null` when the node is unreachable.
69
+ */
70
+ const addToIpfs = async (content, filename = 'data', mfsPath) => {
71
+ const kuboUrl = getIpfsApiUrl();
72
+ const clusterUrl = getClusterApiUrl();
73
+
74
+ // Build multipart body using native FormData + Blob (Node ≥ 18).
75
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
76
+ const formData = new FormData();
77
+ formData.append('file', new Blob([buf]), filename);
78
+
79
+ // ── Step 1: add to Kubo ──────────────────────────────
80
+ let cid;
81
+ let size;
82
+ try {
83
+ const res = await fetch(`${kuboUrl}/api/v0/add?pin=true&cid-version=1`, {
84
+ method: 'POST',
85
+ body: formData,
86
+ });
87
+
88
+ if (!res.ok) {
89
+ const text = await res.text();
90
+ logger.error(`IPFS Kubo add failed (${res.status}): ${text}`);
91
+ return null;
92
+ }
93
+
94
+ const json = await res.json();
95
+ cid = json.Hash;
96
+ size = Number(json.Size);
97
+ logger.info(`IPFS Kubo add OK – CID: ${cid}, size: ${size}`);
98
+ } catch (err) {
99
+ logger.warn(`IPFS Kubo node unreachable at ${kuboUrl}: ${err.message}`);
100
+ return null;
101
+ }
102
+
103
+ // ── Step 2: pin to the Cluster ───────────────────────
104
+ try {
105
+ const clusterRes = await fetch(`${clusterUrl}/pins/${encodeURIComponent(cid)}`, {
106
+ method: 'POST',
107
+ });
108
+
109
+ if (!clusterRes.ok) {
110
+ const text = await clusterRes.text();
111
+ logger.warn(`IPFS Cluster pin failed (${clusterRes.status}): ${text}`);
112
+ } else {
113
+ logger.info(`IPFS Cluster pin OK – CID: ${cid}`);
114
+ }
115
+ } catch (err) {
116
+ logger.warn(`IPFS Cluster unreachable at ${clusterUrl}: ${err.message}`);
117
+ }
118
+
119
+ // ── Step 3: copy into MFS so the Web UI "Files" section shows it ─
120
+ const destPath = mfsPath || `/pinned/${filename}`;
121
+ const destDir = destPath.substring(0, destPath.lastIndexOf('/')) || '/';
122
+ try {
123
+ // Ensure parent directory exists in MFS
124
+ await fetch(`${kuboUrl}/api/v0/files/mkdir?arg=${encodeURIComponent(destDir)}&parents=true`, { method: 'POST' });
125
+
126
+ // Remove existing entry if present (cp fails on duplicates)
127
+ await fetch(`${kuboUrl}/api/v0/files/rm?arg=${encodeURIComponent(destPath)}&force=true`, {
128
+ method: 'POST',
129
+ });
130
+
131
+ // Copy the CID into MFS
132
+ const cpRes = await fetch(
133
+ `${kuboUrl}/api/v0/files/cp?arg=/ipfs/${encodeURIComponent(cid)}&arg=${encodeURIComponent(destPath)}`,
134
+ { method: 'POST' },
135
+ );
136
+
137
+ if (!cpRes.ok) {
138
+ const text = await cpRes.text();
139
+ logger.warn(`IPFS MFS cp failed (${cpRes.status}): ${text}`);
140
+ } else {
141
+ logger.info(`IPFS MFS cp OK – ${destPath} → ${cid}`);
142
+ }
143
+ } catch (err) {
144
+ logger.warn(`IPFS MFS cp unreachable: ${err.message}`);
145
+ }
146
+
147
+ return { cid, size };
148
+ };
149
+
150
+ // ─────────────────────────────────────────────────────────
151
+ // Convenience wrappers
152
+ // ─────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Add a JSON-serialisable object to IPFS.
156
+ *
157
+ * @param {any} obj – value to serialise.
158
+ * @param {string} [filename='data.json']
159
+ * @param {string} [mfsPath] – optional full MFS destination path.
160
+ * @returns {Promise<IpfsAddResult|null>}
161
+ */
162
+ const addJsonToIpfs = async (obj, filename = 'data.json', mfsPath) => {
163
+ const payload = stringify(obj);
164
+ return addToIpfs(Buffer.from(payload, 'utf-8'), filename, mfsPath);
165
+ };
166
+
167
+ /**
168
+ * Add a binary buffer (e.g. a PNG image) to IPFS.
169
+ *
170
+ * @param {Buffer} buffer – raw image / file bytes.
171
+ * @param {string} filename – e.g. `"atlas.png"`.
172
+ * @param {string} [mfsPath] – optional full MFS destination path.
173
+ * @returns {Promise<IpfsAddResult|null>}
174
+ */
175
+ const addBufferToIpfs = async (buffer, filename, mfsPath) => {
176
+ return addToIpfs(buffer, filename, mfsPath);
177
+ };
178
+
179
+ // ─────────────────────────────────────────────────────────
180
+ // Pin management
181
+ // ─────────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Explicitly pin an existing CID on both the Kubo node and the Cluster.
185
+ *
186
+ * @param {string} cid
187
+ * @param {string} [type='recursive'] – `'recursive'` | `'direct'`
188
+ * @returns {Promise<boolean>} `true` when at least the Kubo pin succeeded.
189
+ */
190
+ const pinCid = async (cid, type = 'recursive') => {
191
+ const kuboUrl = getIpfsApiUrl();
192
+ const clusterUrl = getClusterApiUrl();
193
+ let kuboOk = false;
194
+
195
+ // Kubo pin
196
+ try {
197
+ const res = await fetch(`${kuboUrl}/api/v0/pin/add?arg=${encodeURIComponent(cid)}&type=${type}`, {
198
+ method: 'POST',
199
+ });
200
+ if (!res.ok) {
201
+ const text = await res.text();
202
+ logger.error(`IPFS Kubo pin/add failed (${res.status}): ${text}`);
203
+ } else {
204
+ kuboOk = true;
205
+ logger.info(`IPFS Kubo pin OK – CID: ${cid} (${type})`);
206
+ }
207
+ } catch (err) {
208
+ logger.warn(`IPFS Kubo pin unreachable: ${err.message}`);
209
+ }
210
+
211
+ // Cluster pin
212
+ try {
213
+ const clusterRes = await fetch(`${clusterUrl}/pins/${encodeURIComponent(cid)}`, {
214
+ method: 'POST',
215
+ });
216
+ if (!clusterRes.ok) {
217
+ const text = await clusterRes.text();
218
+ logger.warn(`IPFS Cluster pin failed (${clusterRes.status}): ${text}`);
219
+ } else {
220
+ logger.info(`IPFS Cluster pin OK – CID: ${cid}`);
221
+ }
222
+ } catch (err) {
223
+ logger.warn(`IPFS Cluster pin unreachable: ${err.message}`);
224
+ }
225
+
226
+ return kuboOk;
227
+ };
228
+
229
+ /**
230
+ * Unpin a CID from both the Kubo node and the Cluster.
231
+ *
232
+ * @param {string} cid
233
+ * @returns {Promise<boolean>}
234
+ */
235
+ const unpinCid = async (cid) => {
236
+ const kuboUrl = getIpfsApiUrl();
237
+ const clusterUrl = getClusterApiUrl();
238
+ let kuboOk = false;
239
+
240
+ // Cluster unpin
241
+ try {
242
+ const clusterRes = await fetch(`${clusterUrl}/pins/${encodeURIComponent(cid)}`, {
243
+ method: 'DELETE',
244
+ });
245
+ if (!clusterRes.ok) {
246
+ const text = await clusterRes.text();
247
+ logger.warn(`IPFS Cluster unpin failed (${clusterRes.status}): ${text}`);
248
+ } else {
249
+ logger.info(`IPFS Cluster unpin OK – CID: ${cid}`);
250
+ }
251
+ } catch (err) {
252
+ logger.warn(`IPFS Cluster unpin unreachable: ${err.message}`);
253
+ }
254
+
255
+ // Kubo unpin
256
+ try {
257
+ const res = await fetch(`${kuboUrl}/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`, {
258
+ method: 'POST',
259
+ });
260
+ if (!res.ok) {
261
+ const text = await res.text();
262
+ // "not pinned or pinned indirectly" means the CID is already unpinned – treat as success
263
+ if (text.includes('not pinned')) {
264
+ kuboOk = true;
265
+ logger.info(`IPFS Kubo unpin – CID already not pinned: ${cid}`);
266
+ } else {
267
+ logger.warn(`IPFS Kubo pin/rm failed (${res.status}): ${text}`);
268
+ }
269
+ } else {
270
+ kuboOk = true;
271
+ logger.info(`IPFS Kubo unpin OK – CID: ${cid}`);
272
+ }
273
+ } catch (err) {
274
+ logger.warn(`IPFS Kubo unpin unreachable: ${err.message}`);
275
+ }
276
+
277
+ return kuboOk;
278
+ };
279
+
280
+ // ─────────────────────────────────────────────────────────
281
+ // Retrieval
282
+ // ─────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Retrieve raw bytes for a CID from the IPFS HTTP Gateway (port 8080).
286
+ *
287
+ * @param {string} cid
288
+ * @returns {Promise<Buffer|null>}
289
+ */
290
+ const getFromIpfs = async (cid) => {
291
+ const url = getGatewayUrl();
292
+ try {
293
+ const res = await fetch(`${url}/ipfs/${encodeURIComponent(cid)}`);
294
+ if (!res.ok) {
295
+ logger.error(`IPFS gateway GET failed (${res.status}) for ${cid}`);
296
+ return null;
297
+ }
298
+ const arrayBuffer = await res.arrayBuffer();
299
+ return Buffer.from(arrayBuffer);
300
+ } catch (err) {
301
+ logger.warn(`IPFS gateway unreachable at ${url}: ${err.message}`);
302
+ return null;
303
+ }
304
+ };
305
+
306
+ // ─────────────────────────────────────────────────────────
307
+ // Diagnostics
308
+ // ─────────────────────────────────────────────────────────
309
+
310
+ /**
311
+ * List all pins tracked by the IPFS Cluster (port 9094).
312
+ * Each line in the response is a JSON object with at least a `cid` field.
313
+ *
314
+ * @returns {Promise<Array<{ cid: string, name: string, peer_map: object }>>}
315
+ */
316
+ const listClusterPins = async () => {
317
+ const clusterUrl = getClusterApiUrl();
318
+ try {
319
+ const res = await fetch(`${clusterUrl}/pins`);
320
+ if (res.status === 204) {
321
+ // 204 No Content → the cluster has no pins at all.
322
+ return [];
323
+ }
324
+ if (!res.ok) {
325
+ const text = await res.text();
326
+ logger.error(`IPFS Cluster list pins failed (${res.status}): ${text}`);
327
+ return [];
328
+ }
329
+ const text = await res.text();
330
+ // The cluster streams one JSON object per line (NDJSON).
331
+ return text
332
+ .split('\n')
333
+ .filter((line) => line.trim())
334
+ .map((line) => {
335
+ try {
336
+ return JSON.parse(line);
337
+ } catch {
338
+ return null;
339
+ }
340
+ })
341
+ .filter(Boolean);
342
+ } catch (err) {
343
+ logger.warn(`IPFS Cluster unreachable at ${clusterUrl}: ${err.message}`);
344
+ return [];
345
+ }
346
+ };
347
+
348
+ /**
349
+ * List pins tracked by the local Kubo node (port 5001).
350
+ *
351
+ * @param {string} [type='recursive'] – `'all'` | `'recursive'` | `'direct'` | `'indirect'`
352
+ * @returns {Promise<Object<string, { Type: string }>>} Map of CID → pin info.
353
+ */
354
+ const listKuboPins = async (type = 'recursive') => {
355
+ const kuboUrl = getIpfsApiUrl();
356
+ try {
357
+ const res = await fetch(`${kuboUrl}/api/v0/pin/ls?type=${type}`, {
358
+ method: 'POST',
359
+ });
360
+ if (!res.ok) {
361
+ const text = await res.text();
362
+ logger.error(`IPFS Kubo pin/ls failed (${res.status}): ${text}`);
363
+ return {};
364
+ }
365
+ const json = await res.json();
366
+ return json.Keys || {};
367
+ } catch (err) {
368
+ logger.warn(`IPFS Kubo pin/ls unreachable: ${err.message}`);
369
+ return {};
370
+ }
371
+ };
372
+
373
+ // ─────────────────────────────────────────────────────────
374
+ // MFS management
375
+ // ─────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * Remove a file or directory from the Kubo MFS (Mutable File System).
379
+ * This cleans up entries visible in the IPFS Web UI "Files" section.
380
+ *
381
+ * @param {string} mfsPath – Full MFS path to remove, e.g. `/pinned/myfile.json`
382
+ * or `/object-layer/itemId`.
383
+ * @param {boolean} [recursive=true] – When `true`, removes directories recursively.
384
+ * @returns {Promise<boolean>} `true` when the removal succeeded or the path didn't exist.
385
+ */
386
+ const removeMfsPath = async (mfsPath, recursive = true) => {
387
+ const kuboUrl = getIpfsApiUrl();
388
+ try {
389
+ // First check if the path exists via stat; if it doesn't we can return early.
390
+ const statRes = await fetch(`${kuboUrl}/api/v0/files/stat?arg=${encodeURIComponent(mfsPath)}`, { method: 'POST' });
391
+ if (!statRes.ok) {
392
+ // Path doesn't exist – nothing to remove.
393
+ logger.info(`IPFS MFS rm – path does not exist, skipping: ${mfsPath}`);
394
+ return true;
395
+ }
396
+
397
+ const rmRes = await fetch(
398
+ `${kuboUrl}/api/v0/files/rm?arg=${encodeURIComponent(mfsPath)}&force=true${recursive ? '&recursive=true' : ''}`,
399
+ { method: 'POST' },
400
+ );
401
+ if (!rmRes.ok) {
402
+ const text = await rmRes.text();
403
+ logger.warn(`IPFS MFS rm failed (${rmRes.status}): ${text}`);
404
+ return false;
405
+ }
406
+ logger.info(`IPFS MFS rm OK – ${mfsPath}`);
407
+ return true;
408
+ } catch (err) {
409
+ logger.warn(`IPFS MFS rm unreachable: ${err.message}`);
410
+ return false;
411
+ }
412
+ };
413
+
414
+ // ─────────────────────────────────────────────────────────
415
+ // Export
416
+ // ─────────────────────────────────────────────────────────
417
+
418
+ const IpfsClient = {
419
+ getIpfsApiUrl,
420
+ getClusterApiUrl,
421
+ getGatewayUrl,
422
+ addToIpfs,
423
+ addJsonToIpfs,
424
+ addBufferToIpfs,
425
+ pinCid,
426
+ unpinCid,
427
+ getFromIpfs,
428
+ listClusterPins,
429
+ listKuboPins,
430
+ removeMfsPath,
431
+ };
432
+
433
+ export { IpfsClient };