@feasibleone/blong-gogo 1.14.2 → 1.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.15.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.14.3...blong-gogo-v1.15.0) (2026-03-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * built-in REST-FS server component for remote filesystem over Gateway ([#108](https://github.com/feasibleone/blong/issues/108)) ([352ef2a](https://github.com/feasibleone/blong/commit/352ef2a86a4da1f29eaf931c6057d018918fedaf))
9
+
10
+ ## [1.14.3](https://github.com/feasibleone/blong/compare/blong-gogo-v1.14.2...blong-gogo-v1.14.3) (2026-03-24)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * type generation ([425c3ca](https://github.com/feasibleone/blong/commit/425c3cac5863253c39138a3245be876f0e0a74dd))
16
+
3
17
  ## [1.14.2](https://github.com/feasibleone/blong/compare/blong-gogo-v1.14.1...blong-gogo-v1.14.2) (2026-03-23)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
package/src/Gateway.ts CHANGED
@@ -153,6 +153,7 @@ export default class Gateway extends Internal implements IGateway {
153
153
  #routes: RouteOptions[];
154
154
  #local: ILocal;
155
155
  #errorFields: [string, unknown][] = [];
156
+ #plugins: {plugin: unknown; options: unknown}[] = [];
156
157
 
157
158
  public constructor(
158
159
  config: IConfig,
@@ -195,6 +196,10 @@ export default class Gateway extends Internal implements IGateway {
195
196
  return Object.freeze({...this.#config});
196
197
  }
197
198
 
199
+ public registerPlugin(plugin: unknown, options?: unknown): void {
200
+ this.#plugins.push({plugin, options});
201
+ }
202
+
198
203
  // https://github.com/openzipkin/b3-propagation
199
204
  private _forward(headers: object): object {
200
205
  return [
@@ -498,6 +503,8 @@ export default class Gateway extends Internal implements IGateway {
498
503
  await this.#server.register(swagger, {
499
504
  version: '',
500
505
  });
506
+ for (const {plugin, options} of this.#plugins)
507
+ await this.#server.register(plugin, options);
501
508
  this.#routes.forEach(route => this.#server.route(route));
502
509
  } finally {
503
510
  await old?.close();
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Tests for RestFs component — path traversal, CRUD, and edge cases via Fastify injection.
3
+ */
4
+
5
+ import assert from 'node:assert';
6
+ import {mkdir, readFile, rm, symlink, writeFile} from 'node:fs/promises';
7
+ import {tmpdir} from 'node:os';
8
+ import {join} from 'node:path';
9
+ import {after, before, describe, it} from 'node:test';
10
+
11
+ import fastify, {type FastifyInstance} from 'fastify';
12
+ import fp from 'fastify-plugin';
13
+ import RestFs from './RestFs.ts';
14
+
15
+ /**
16
+ * Helper: create an isolated RestFs + Fastify instance for testing.
17
+ * Returns { server, baseDir, cleanup }.
18
+ */
19
+ async function setup(options?: {shell?: boolean}): Promise<{
20
+ server: FastifyInstance;
21
+ baseDir: string;
22
+ cleanup: () => Promise<void>;
23
+ }> {
24
+ const baseDir = join(tmpdir(), `restfs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
25
+ await mkdir(baseDir, {recursive: true});
26
+
27
+ // RestFs expects a gateway with registerPlugin — we accumulate the plugin and register it ourselves
28
+ let capturedPlugin: unknown;
29
+ let capturedOptions: unknown;
30
+ const fakeGateway = {
31
+ registerPlugin(plugin: unknown, opts?: unknown) {
32
+ capturedPlugin = plugin;
33
+ capturedOptions = opts;
34
+ },
35
+ };
36
+
37
+ const restFs = new (RestFs as any)(
38
+ {enabled: true, baseDir, routePrefix: '/api/fs', maxFileSize: 52428800, auth: false, shell: options?.shell ?? false},
39
+ {gateway: fakeGateway},
40
+ );
41
+ await restFs.init();
42
+
43
+ const server = fastify();
44
+ // Stub auth config so routes don't require it
45
+ server.addHook('preValidation', (_req, _reply, done) => done());
46
+ if (capturedPlugin) await server.register(capturedPlugin as any, capturedOptions as any);
47
+ await server.ready();
48
+
49
+ return {
50
+ server,
51
+ baseDir,
52
+ cleanup: async () => {
53
+ await server.close();
54
+ await rm(baseDir, {recursive: true, force: true});
55
+ },
56
+ };
57
+ }
58
+
59
+ describe('RestFs', () => {
60
+ let server: FastifyInstance;
61
+ let baseDir: string;
62
+ let cleanup: () => Promise<void>;
63
+
64
+ before(async () => {
65
+ ({server, baseDir, cleanup} = await setup());
66
+ });
67
+
68
+ after(async () => {
69
+ await cleanup();
70
+ });
71
+
72
+ // ---- CRUD ----
73
+
74
+ describe('CRUD operations', () => {
75
+ it('GET /stat/* — returns 404 for non-existent path', async () => {
76
+ const res = await server.inject({method: 'GET', url: '/api/fs/stat/does-not-exist'});
77
+ assert.strictEqual(res.statusCode, 404);
78
+ });
79
+
80
+ it('POST /mkdir/* — creates a directory', async () => {
81
+ const res = await server.inject({method: 'POST', url: '/api/fs/mkdir/test-dir'});
82
+ assert.strictEqual(res.statusCode, 200);
83
+ assert.deepStrictEqual(res.json(), {success: true});
84
+ });
85
+
86
+ it('GET /stat/* — returns metadata for existing directory', async () => {
87
+ const res = await server.inject({method: 'GET', url: '/api/fs/stat/test-dir'});
88
+ assert.strictEqual(res.statusCode, 200);
89
+ const body = res.json();
90
+ assert.strictEqual(body.type, 'directory');
91
+ assert.ok(typeof body.mtime === 'number');
92
+ });
93
+
94
+ it('POST /write/* — writes a file', async () => {
95
+ const content = Buffer.from('hello world');
96
+ const res = await server.inject({
97
+ method: 'POST',
98
+ url: '/api/fs/write/test-dir/file.txt',
99
+ headers: {'content-type': 'application/octet-stream'},
100
+ payload: content,
101
+ });
102
+ assert.strictEqual(res.statusCode, 200);
103
+ assert.deepStrictEqual(res.json(), {success: true});
104
+
105
+ // Verify the file on disk
106
+ const written = await readFile(join(baseDir, 'test-dir', 'file.txt'));
107
+ assert.deepStrictEqual(written, content);
108
+ });
109
+
110
+ it('POST /write/* — returns 415 for non-binary body', async () => {
111
+ const res = await server.inject({
112
+ method: 'POST',
113
+ url: '/api/fs/write/test-dir/bad.txt',
114
+ headers: {'content-type': 'application/json'},
115
+ payload: JSON.stringify({text: 'oops'}),
116
+ });
117
+ assert.strictEqual(res.statusCode, 415);
118
+ assert.ok(res.json().error.includes('octet-stream'));
119
+ });
120
+
121
+ it('GET /read/* — reads file contents back', async () => {
122
+ const res = await server.inject({method: 'GET', url: '/api/fs/read/test-dir/file.txt'});
123
+ assert.strictEqual(res.statusCode, 200);
124
+ assert.strictEqual(res.body, 'hello world');
125
+ });
126
+
127
+ it('GET /read/* — returns 404 for non-existent file', async () => {
128
+ const res = await server.inject({method: 'GET', url: '/api/fs/read/nope.txt'});
129
+ assert.strictEqual(res.statusCode, 404);
130
+ });
131
+
132
+ it('GET /readdir/* — lists directory entries', async () => {
133
+ const res = await server.inject({method: 'GET', url: '/api/fs/readdir/test-dir'});
134
+ assert.strictEqual(res.statusCode, 200);
135
+ const entries = res.json();
136
+ assert.ok(Array.isArray(entries));
137
+ assert.ok(entries.some((e: {name: string}) => e.name === 'file.txt'));
138
+ });
139
+
140
+ it('POST /rename — renames a file', async () => {
141
+ const res = await server.inject({
142
+ method: 'POST',
143
+ url: '/api/fs/rename',
144
+ payload: {oldPath: 'test-dir/file.txt', newPath: 'test-dir/renamed.txt'},
145
+ });
146
+ assert.strictEqual(res.statusCode, 200);
147
+ assert.deepStrictEqual(res.json(), {success: true});
148
+ });
149
+
150
+ it('POST /copy — copies a file', async () => {
151
+ const res = await server.inject({
152
+ method: 'POST',
153
+ url: '/api/fs/copy',
154
+ payload: {source: 'test-dir/renamed.txt', destination: 'test-dir/copied.txt'},
155
+ });
156
+ assert.strictEqual(res.statusCode, 200);
157
+ assert.deepStrictEqual(res.json(), {success: true});
158
+
159
+ const content = await readFile(join(baseDir, 'test-dir', 'copied.txt'), 'utf-8');
160
+ assert.strictEqual(content, 'hello world');
161
+ });
162
+
163
+ it('DELETE /delete/* — deletes a file', async () => {
164
+ const res = await server.inject({method: 'DELETE', url: '/api/fs/delete/test-dir/copied.txt'});
165
+ assert.strictEqual(res.statusCode, 200);
166
+ assert.deepStrictEqual(res.json(), {success: true});
167
+ });
168
+
169
+ it('DELETE /delete/* — returns 404 for non-existent path', async () => {
170
+ const res = await server.inject({method: 'DELETE', url: '/api/fs/delete/nope.txt'});
171
+ assert.strictEqual(res.statusCode, 404);
172
+ });
173
+
174
+ it('DELETE /delete/* — deletes directory recursively', async () => {
175
+ const res = await server.inject({
176
+ method: 'DELETE',
177
+ url: '/api/fs/delete/test-dir?recursive=true',
178
+ });
179
+ assert.strictEqual(res.statusCode, 200);
180
+ });
181
+ });
182
+
183
+ // ---- Path traversal ----
184
+
185
+ describe('Path traversal protection', () => {
186
+ // Note: URL-level .. traversal is normalized by Fastify before routing,
187
+ // resulting in 404 (route not matched). This is valid HTTP-level protection.
188
+ // The handler-level resolveSafePath protection is tested via body-based
189
+ // endpoints (rename, copy) below, which bypass URL normalization.
190
+
191
+ it('rejects .. traversal in URL (Fastify normalizes → 404)', async () => {
192
+ const res = await server.inject({method: 'GET', url: '/api/fs/stat/../../../etc/passwd'});
193
+ assert.strictEqual(res.statusCode, 404);
194
+ });
195
+
196
+ it('rejects .. traversal in readdir URL (Fastify normalizes → 404)', async () => {
197
+ const res = await server.inject({method: 'GET', url: '/api/fs/readdir/../../'});
198
+ assert.strictEqual(res.statusCode, 404);
199
+ });
200
+
201
+ it('rejects .. traversal in write URL (Fastify normalizes → 404)', async () => {
202
+ const res = await server.inject({
203
+ method: 'POST',
204
+ url: '/api/fs/write/../escape.txt',
205
+ headers: {'content-type': 'application/octet-stream'},
206
+ payload: Buffer.from('pwned'),
207
+ });
208
+ assert.strictEqual(res.statusCode, 404);
209
+ });
210
+
211
+ it('rejects .. traversal in rename (oldPath) — handler-level', async () => {
212
+ const res = await server.inject({
213
+ method: 'POST',
214
+ url: '/api/fs/rename',
215
+ payload: {oldPath: '../../etc/passwd', newPath: 'safe.txt'},
216
+ });
217
+ assert.strictEqual(res.statusCode, 403);
218
+ });
219
+
220
+ it('rejects .. traversal in rename (newPath) — handler-level', async () => {
221
+ await writeFile(join(baseDir, 'a.txt'), 'test');
222
+ const res = await server.inject({
223
+ method: 'POST',
224
+ url: '/api/fs/rename',
225
+ payload: {oldPath: 'a.txt', newPath: '../../escape.txt'},
226
+ });
227
+ assert.strictEqual(res.statusCode, 403);
228
+ });
229
+
230
+ it('rejects .. traversal in copy (destination) — handler-level', async () => {
231
+ await writeFile(join(baseDir, 'b.txt'), 'test');
232
+ const res = await server.inject({
233
+ method: 'POST',
234
+ url: '/api/fs/copy',
235
+ payload: {source: 'b.txt', destination: '../../escape.txt'},
236
+ });
237
+ assert.strictEqual(res.statusCode, 403);
238
+ });
239
+ });
240
+
241
+ // ---- Symlink escape ----
242
+
243
+ describe('Symlink escape protection', () => {
244
+ it('rejects symlink pointing outside baseDir', async () => {
245
+ const linkPath = join(baseDir, 'evil-link');
246
+ try {
247
+ await symlink('/tmp', linkPath);
248
+ } catch {
249
+ // symlink creation may fail on some CI — skip
250
+ return;
251
+ }
252
+ const res = await server.inject({method: 'GET', url: '/api/fs/stat/evil-link'});
253
+ assert.strictEqual(res.statusCode, 403);
254
+ });
255
+
256
+ it('rejects write through symlinked parent directory', async () => {
257
+ const linkPath = join(baseDir, 'escape-dir');
258
+ try {
259
+ await symlink('/tmp', linkPath);
260
+ } catch {
261
+ return;
262
+ }
263
+ const res = await server.inject({
264
+ method: 'POST',
265
+ url: '/api/fs/write/escape-dir/file.txt',
266
+ headers: {'content-type': 'application/octet-stream'},
267
+ payload: Buffer.from('pwned'),
268
+ });
269
+ assert.strictEqual(res.statusCode, 403);
270
+ });
271
+ });
272
+ });
273
+
274
+ describe('RestFs disabled', () => {
275
+ it('does not register plugin when enabled is false', async () => {
276
+ let registered = false;
277
+ const fakeGateway = {
278
+ registerPlugin() {
279
+ registered = true;
280
+ },
281
+ };
282
+ const restFs = new (RestFs as any)(
283
+ {enabled: false, baseDir: '/tmp', routePrefix: '/api/fs', maxFileSize: 1024, auth: false, shell: false},
284
+ {gateway: fakeGateway},
285
+ );
286
+ await restFs.init();
287
+ assert.strictEqual(registered, false, 'Plugin should not be registered when disabled');
288
+ });
289
+
290
+ it('does not register plugin when gateway is missing', async () => {
291
+ const restFs = new (RestFs as any)(
292
+ {enabled: true, baseDir: '/tmp', routePrefix: '/api/fs', maxFileSize: 1024, auth: false, shell: false},
293
+ {},
294
+ );
295
+ // Should not throw
296
+ await restFs.init();
297
+ });
298
+ });
299
+
300
+ describe('RestFs shell endpoint', () => {
301
+ let server: FastifyInstance;
302
+ let baseDir: string;
303
+ let cleanup: () => Promise<void>;
304
+
305
+ before(async () => {
306
+ ({server, baseDir, cleanup} = await setup({shell: true}));
307
+ });
308
+
309
+ after(async () => {
310
+ await cleanup();
311
+ });
312
+
313
+ it('POST /shell — executes a command and streams output', async () => {
314
+ const res = await server.inject({
315
+ method: 'POST',
316
+ url: '/api/fs/shell',
317
+ payload: {command: 'echo hello'},
318
+ });
319
+ // Shell uses raw streaming; inject returns the raw response
320
+ assert.strictEqual(res.statusCode, 200);
321
+ assert.ok(res.body.includes('hello'));
322
+ });
323
+
324
+ it('POST /shell — returns 400 when command is missing', async () => {
325
+ const res = await server.inject({
326
+ method: 'POST',
327
+ url: '/api/fs/shell',
328
+ payload: {command: ''},
329
+ });
330
+ assert.strictEqual(res.statusCode, 400);
331
+ });
332
+
333
+ it('POST /shell — validates cwd within baseDir', async () => {
334
+ const res = await server.inject({
335
+ method: 'POST',
336
+ url: '/api/fs/shell',
337
+ payload: {command: 'pwd', cwd: '../../'},
338
+ });
339
+ assert.strictEqual(res.statusCode, 400);
340
+ });
341
+ });
package/src/RestFs.ts ADDED
@@ -0,0 +1,386 @@
1
+ import {Internal, type ILog} from '@feasibleone/blong/types';
2
+ import type {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify';
3
+ import fp from 'fastify-plugin';
4
+ import {spawn} from 'node:child_process';
5
+ import {
6
+ copyFile,
7
+ cp,
8
+ mkdir,
9
+ readdir,
10
+ readFile,
11
+ realpath,
12
+ rename,
13
+ rm,
14
+ stat,
15
+ unlink,
16
+ writeFile,
17
+ } from 'node:fs/promises';
18
+ import {dirname, isAbsolute, join, relative, resolve, sep} from 'node:path';
19
+ import {pipeline} from 'node:stream/promises';
20
+
21
+ interface IConfig {
22
+ enabled: boolean;
23
+ baseDir: string;
24
+ routePrefix: string;
25
+ maxFileSize: number;
26
+ auth: false | 'jwt';
27
+ shell: boolean;
28
+ }
29
+
30
+ interface IGatewayWithPlugins {
31
+ registerPlugin(plugin: unknown, options?: unknown): void;
32
+ }
33
+
34
+ export default class RestFs extends Internal {
35
+ #config: IConfig = {
36
+ enabled: false,
37
+ baseDir: process.cwd(),
38
+ routePrefix: '/api/fs',
39
+ maxFileSize: 52428800, // 50MB
40
+ auth: false,
41
+ shell: false,
42
+ };
43
+
44
+ #gateway: IGatewayWithPlugins;
45
+
46
+ public constructor(
47
+ config: IConfig,
48
+ {log, gateway}: {log?: ILog; gateway?: IGatewayWithPlugins},
49
+ ) {
50
+ super({log});
51
+ this.merge(this.#config, config);
52
+ this.#gateway = gateway;
53
+ }
54
+
55
+ public async init(): Promise<void> {
56
+ if (!this.#config.enabled || !this.#gateway) return;
57
+
58
+ const config = this.#config;
59
+ const baseDir = resolve(config.baseDir);
60
+
61
+ await mkdir(baseDir, {recursive: true});
62
+
63
+ const isWithinBase = (candidate: string): boolean => {
64
+ if (candidate === baseDir) return true;
65
+ const rel = relative(baseDir, candidate);
66
+ return !rel.startsWith('..') && !isAbsolute(rel) && !rel.startsWith('..' + sep);
67
+ };
68
+
69
+ const resolveSafePath = async (requestPath: string): Promise<string> => {
70
+ const joined = join(baseDir, requestPath || '/');
71
+ const resolved = resolve(joined);
72
+ if (!isWithinBase(resolved)) {
73
+ const error = new Error('Access denied: path outside base directory');
74
+ (error as NodeJS.ErrnoException).code = 'EACCES';
75
+ throw error;
76
+ }
77
+ // Walk up to the nearest existing ancestor and realpath-check it
78
+ // to prevent symlink-escape attacks on non-existent paths
79
+ let check = resolved;
80
+ let real: string | undefined;
81
+ while (check !== baseDir) {
82
+ try {
83
+ real = await realpath(check);
84
+ break;
85
+ } catch (err) {
86
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
87
+ check = dirname(check);
88
+ continue;
89
+ }
90
+ throw err;
91
+ }
92
+ }
93
+ if (real && !isWithinBase(real)) {
94
+ const error = new Error('Access denied: path outside base directory');
95
+ (error as NodeJS.ErrnoException).code = 'EACCES';
96
+ throw error;
97
+ }
98
+ // If the full path exists, return the realpath; otherwise the resolved path
99
+ if (real && check === resolved) return real;
100
+ return resolved;
101
+ };
102
+
103
+ const fsError = (
104
+ reply: FastifyReply,
105
+ err: unknown,
106
+ ): FastifyReply => {
107
+ const error = err as NodeJS.ErrnoException;
108
+ switch (error.code) {
109
+ case 'ENOENT':
110
+ return reply.code(404).send({error: 'Not found'});
111
+ case 'EACCES':
112
+ return reply.code(403).send({error: error.message || 'Access denied'});
113
+ case 'ENOTEMPTY':
114
+ return reply
115
+ .code(400)
116
+ .send({error: 'Directory not empty (use recursive=true)'});
117
+ default:
118
+ return reply.code(500).send({error: error.message || 'Internal server error'});
119
+ }
120
+ };
121
+
122
+ const authConfig = config.auth;
123
+
124
+ const plugin = fp(
125
+ async (server: FastifyInstance) => {
126
+ const prefix = config.routePrefix;
127
+
128
+ // Add raw body parser for octet-stream content
129
+ server.addContentTypeParser(
130
+ 'application/octet-stream',
131
+ {parseAs: 'buffer', bodyLimit: config.maxFileSize},
132
+ (_req, body, done) => {
133
+ done(null, body);
134
+ },
135
+ );
136
+
137
+ // GET /stat/* — file/directory metadata
138
+ server.route({
139
+ method: 'GET',
140
+ url: `${prefix}/stat/*`,
141
+ config: {auth: authConfig},
142
+ handler: async (
143
+ request: FastifyRequest<{Params: {'*': string}}>,
144
+ reply,
145
+ ) => {
146
+ try {
147
+ const fullPath = await resolveSafePath(request.params['*']);
148
+ const stats = await stat(fullPath);
149
+ return {
150
+ type: stats.isDirectory() ? 'directory' : 'file',
151
+ ctime: stats.ctimeMs,
152
+ mtime: stats.mtimeMs,
153
+ size: stats.size,
154
+ };
155
+ } catch (err) {
156
+ return fsError(reply, err);
157
+ }
158
+ },
159
+ });
160
+
161
+ // GET /readdir/* — directory listing
162
+ server.route({
163
+ method: 'GET',
164
+ url: `${prefix}/readdir/*`,
165
+ config: {auth: authConfig},
166
+ handler: async (
167
+ request: FastifyRequest<{Params: {'*': string}}>,
168
+ reply,
169
+ ) => {
170
+ try {
171
+ const fullPath = await resolveSafePath(request.params['*']);
172
+ const entries = await readdir(fullPath, {withFileTypes: true});
173
+ return entries.map(entry => ({
174
+ name: entry.name,
175
+ type: entry.isDirectory() ? 'directory' : 'file',
176
+ }));
177
+ } catch (err) {
178
+ return fsError(reply, err);
179
+ }
180
+ },
181
+ });
182
+
183
+ // POST /mkdir/* — create directory
184
+ server.route({
185
+ method: 'POST',
186
+ url: `${prefix}/mkdir/*`,
187
+ config: {auth: authConfig},
188
+ handler: async (
189
+ request: FastifyRequest<{Params: {'*': string}}>,
190
+ reply,
191
+ ) => {
192
+ try {
193
+ const fullPath = await resolveSafePath(request.params['*']);
194
+ await mkdir(fullPath, {recursive: true});
195
+ return {success: true};
196
+ } catch (err) {
197
+ return fsError(reply, err);
198
+ }
199
+ },
200
+ });
201
+
202
+ // GET /read/* — read file contents
203
+ server.route({
204
+ method: 'GET',
205
+ url: `${prefix}/read/*`,
206
+ config: {auth: authConfig},
207
+ handler: async (
208
+ request: FastifyRequest<{Params: {'*': string}}>,
209
+ reply,
210
+ ) => {
211
+ try {
212
+ const fullPath = await resolveSafePath(request.params['*']);
213
+ const content = await readFile(fullPath);
214
+ return reply.type('application/octet-stream').send(content);
215
+ } catch (err) {
216
+ return fsError(reply, err);
217
+ }
218
+ },
219
+ });
220
+
221
+ // POST /write/* — write file contents
222
+ server.route({
223
+ method: 'POST',
224
+ url: `${prefix}/write/*`,
225
+ config: {auth: authConfig},
226
+ handler: async (
227
+ request: FastifyRequest<{Params: {'*': string}}>,
228
+ reply,
229
+ ) => {
230
+ try {
231
+ const body = request.body;
232
+ if (!Buffer.isBuffer(body)) {
233
+ return reply.code(415).send({
234
+ error: 'Unsupported media type: expected application/octet-stream',
235
+ });
236
+ }
237
+ const fullPath = await resolveSafePath(request.params['*']);
238
+ await mkdir(dirname(fullPath), {recursive: true});
239
+ await writeFile(fullPath, body);
240
+ return {success: true};
241
+ } catch (err) {
242
+ return fsError(reply, err);
243
+ }
244
+ },
245
+ });
246
+
247
+ // DELETE /delete/* — delete file or directory
248
+ server.route({
249
+ method: 'DELETE',
250
+ url: `${prefix}/delete/*`,
251
+ config: {auth: authConfig},
252
+ handler: async (
253
+ request: FastifyRequest<{
254
+ Params: {'*': string};
255
+ Querystring: {recursive?: string};
256
+ }>,
257
+ reply,
258
+ ) => {
259
+ try {
260
+ const fullPath = await resolveSafePath(request.params['*']);
261
+ const recursive = request.query.recursive === 'true';
262
+ const stats = await stat(fullPath);
263
+ if (stats.isDirectory()) {
264
+ await rm(fullPath, {recursive});
265
+ } else {
266
+ await unlink(fullPath);
267
+ }
268
+ return {success: true};
269
+ } catch (err) {
270
+ return fsError(reply, err);
271
+ }
272
+ },
273
+ });
274
+
275
+ // POST /rename — rename/move
276
+ server.route({
277
+ method: 'POST',
278
+ url: `${prefix}/rename`,
279
+ config: {auth: authConfig},
280
+ handler: async (
281
+ request: FastifyRequest<{Body: {oldPath: string; newPath: string}}>,
282
+ reply,
283
+ ) => {
284
+ try {
285
+ const {oldPath, newPath} = request.body;
286
+ const oldFullPath = await resolveSafePath(oldPath);
287
+ const newFullPath = await resolveSafePath(newPath);
288
+ await rename(oldFullPath, newFullPath);
289
+ return {success: true};
290
+ } catch (err) {
291
+ return fsError(reply, err);
292
+ }
293
+ },
294
+ });
295
+
296
+ // POST /copy — copy file or directory
297
+ server.route({
298
+ method: 'POST',
299
+ url: `${prefix}/copy`,
300
+ config: {auth: authConfig},
301
+ handler: async (
302
+ request: FastifyRequest<{
303
+ Body: {source: string; destination: string};
304
+ }>,
305
+ reply,
306
+ ) => {
307
+ try {
308
+ const {source, destination} = request.body;
309
+ const sourceFullPath = await resolveSafePath(source);
310
+ const destFullPath = await resolveSafePath(destination);
311
+ const stats = await stat(sourceFullPath);
312
+ if (stats.isDirectory()) {
313
+ await cp(sourceFullPath, destFullPath, {recursive: true});
314
+ } else {
315
+ await copyFile(sourceFullPath, destFullPath);
316
+ }
317
+ return {success: true};
318
+ } catch (err) {
319
+ return fsError(reply, err);
320
+ }
321
+ },
322
+ });
323
+
324
+ // POST /shell — execute shell command with streaming output
325
+ if (config.shell) {
326
+ server.route({
327
+ method: 'POST',
328
+ url: `${prefix}/shell`,
329
+ // Shell endpoint always requires auth when enabled
330
+ config: {auth: authConfig || 'jwt'},
331
+ handler: async (
332
+ request: FastifyRequest<{Body: {command: string; cwd?: string}}>,
333
+ reply,
334
+ ) => {
335
+ const {command, cwd} = request.body;
336
+ if (!command) {
337
+ return reply.code(400).send({error: 'Command is required'});
338
+ }
339
+
340
+ let workingDir: string;
341
+ try {
342
+ workingDir = cwd
343
+ ? await resolveSafePath(cwd)
344
+ : baseDir;
345
+ } catch {
346
+ return reply
347
+ .code(400)
348
+ .send({error: 'Invalid working directory'});
349
+ }
350
+
351
+ reply.raw.writeHead(200, {
352
+ 'content-type': 'text/plain; charset=utf-8',
353
+ 'transfer-encoding': 'chunked',
354
+ 'cache-control': 'no-cache',
355
+ 'x-content-type-options': 'nosniff',
356
+ });
357
+
358
+ const child = spawn(command, [], {
359
+ cwd: workingDir,
360
+ shell: true,
361
+ env: process.env,
362
+ });
363
+
364
+ request.raw.on('close', () => child.kill());
365
+
366
+ try {
367
+ await Promise.all([
368
+ pipeline(child.stdout, reply.raw, {end: false}),
369
+ pipeline(child.stderr, reply.raw, {end: false}),
370
+ new Promise(resolve => child.on('close', resolve)),
371
+ ]);
372
+ } catch {
373
+ // Streaming error — client may have disconnected
374
+ } finally {
375
+ reply.raw.end();
376
+ }
377
+ },
378
+ });
379
+ }
380
+ },
381
+ {name: 'rest-fs'},
382
+ );
383
+
384
+ this.#gateway.registerPlugin(plugin);
385
+ }
386
+ }
package/src/Watch.ts CHANGED
@@ -151,11 +151,11 @@ export default class Watch extends Internal implements IWatch {
151
151
  });
152
152
 
153
153
  declare module '@feasibleone/blong' {
154
- interface IRemoteHandler {
154
+ interface ISchema {
155
155
  ${names
156
156
  .map(
157
157
  name =>
158
- `${name}<T=ReturnType<${name}>>(params: Parameters<${name}>[0], $meta: IMeta): T;`,
158
+ `${name}(params: Parameters<${name}>[0], $meta: IMeta): ReturnType<${name}>;`,
159
159
  )
160
160
  .join('\n')}
161
161
  }
package/src/load.ts CHANGED
@@ -265,6 +265,7 @@ export default async function loadRealm<T extends TSchema>(
265
265
  local: {},
266
266
  rpcServer: {},
267
267
  gateway: {},
268
+ restFs: {},
268
269
  });
269
270
  items = [
270
271
  function log() {
@@ -299,6 +300,9 @@ export default async function loadRealm<T extends TSchema>(
299
300
  function gateway() {
300
301
  return import('./Gateway.ts');
301
302
  },
303
+ function restFs() {
304
+ return import('./RestFs.ts');
305
+ },
302
306
  function registry() {
303
307
  return import('./Registry.ts');
304
308
  },