@falari/harness 0.0.0-stage → 0.8.9

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/lib/pin.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * PIN-sealed storage for the Agent Key.
3
+ *
4
+ * The far_ export string is the only credential this plugin holds, and other
5
+ * plugins in the same profile can read the credentials store. So the string is
6
+ * never written in clear text: it is sealed with AES-256-GCM under a key derived
7
+ * from a PIN the user types (scrypt, 2^15 N), and the plaintext lives only in
8
+ * this process's memory while the panel is unlocked.
9
+ *
10
+ * The record shape is deliberately self-describing: a future KDF change can add
11
+ * a version without guessing what an older file used.
12
+ */
13
+ import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCallback } from 'node:crypto';
14
+ import { promisify } from 'node:util';
15
+
16
+ const scrypt = promisify(scryptCallback);
17
+
18
+ /** scrypt cost: about 100 ms per attempt on a laptop, which is the point. */
19
+ const SCRYPT_N = 1 << 15;
20
+ const SCRYPT_R = 8;
21
+ const SCRYPT_P = 1;
22
+ const KEY_BYTES = 32;
23
+ const SALT_BYTES = 16;
24
+ const IV_BYTES = 12;
25
+ const TAG_BYTES = 16;
26
+
27
+ /** A PIN nobody else can guess: six characters is the floor, not a target. */
28
+ export const MIN_PIN_LENGTH = 6;
29
+ export const SEALED_VERSION = 2;
30
+
31
+ export class PinError extends Error {
32
+ constructor(message, options = {}) {
33
+ super(message);
34
+ this.name = 'PinError';
35
+ this.code = options.code || 'pin_error';
36
+ this.hint = options.hint;
37
+ }
38
+ }
39
+
40
+ /** Reject a PIN before it is used, so a mistake cannot become the only key. */
41
+ export function validatePin(pin) {
42
+ const text = String(pin ?? '');
43
+ if (text.length < MIN_PIN_LENGTH) {
44
+ throw new PinError(`PIN must be at least ${MIN_PIN_LENGTH} characters.`, {
45
+ code: 'pin_too_short',
46
+ hint: 'Use at least six characters; a long phrase is stronger than four digits.',
47
+ });
48
+ }
49
+ if (text.length > 256) {
50
+ throw new PinError('PIN must be at most 256 characters.', { code: 'pin_too_long' });
51
+ }
52
+ if (!text.trim()) {
53
+ throw new PinError('PIN cannot be blank.', { code: 'pin_too_short' });
54
+ }
55
+ return text;
56
+ }
57
+
58
+ async function deriveKey(pin, salt, params) {
59
+ const buffer = await scrypt(String(pin).normalize('NFKC'), salt, KEY_BYTES, {
60
+ N: params.n,
61
+ r: params.r,
62
+ p: params.p,
63
+ maxmem: 256 * 1024 * 1024,
64
+ });
65
+ return buffer;
66
+ }
67
+
68
+ /** True when this record is a sealed secret rather than a legacy plaintext one. */
69
+ export function isSealed(record) {
70
+ return Boolean(record && record.v === SEALED_VERSION && typeof record.ct === 'string');
71
+ }
72
+
73
+ /**
74
+ * Seal a secret under the PIN. Returns the record body to store: no plaintext,
75
+ * and everything needed to open it again with the same PIN.
76
+ */
77
+ export async function sealSecret(secret, pin) {
78
+ const text = String(secret ?? '');
79
+ if (!text) throw new PinError('Nothing to seal.', { code: 'empty_secret' });
80
+ const normalized = validatePin(pin);
81
+ const salt = randomBytes(SALT_BYTES);
82
+ const iv = randomBytes(IV_BYTES);
83
+ const params = { n: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P };
84
+ const key = await deriveKey(normalized, salt, params);
85
+ const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES });
86
+ const ct = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
87
+ const tag = cipher.getAuthTag();
88
+ return {
89
+ v: SEALED_VERSION,
90
+ kdf: 'scrypt',
91
+ n: params.n,
92
+ r: params.r,
93
+ p: params.p,
94
+ salt: salt.toString('base64'),
95
+ iv: iv.toString('base64'),
96
+ tag: tag.toString('base64'),
97
+ ct: ct.toString('base64'),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Open a sealed record. A wrong PIN and a tampered record are indistinguishable
103
+ * on purpose: GCM's tag fails for both, and saying which one it was would leak.
104
+ */
105
+ export async function openSecret(record, pin) {
106
+ if (!isSealed(record)) throw new PinError('Record is not a sealed secret.', { code: 'not_sealed' });
107
+ const salt = Buffer.from(String(record.salt || ''), 'base64');
108
+ const iv = Buffer.from(String(record.iv || ''), 'base64');
109
+ const tag = Buffer.from(String(record.tag || ''), 'base64');
110
+ if (salt.length !== SALT_BYTES || iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
111
+ throw new PinError('Sealed record is malformed.', { code: 'sealed_malformed' });
112
+ }
113
+ const params = { n: Number(record.n) || SCRYPT_N, r: Number(record.r) || SCRYPT_R, p: Number(record.p) || SCRYPT_P };
114
+ const key = await deriveKey(validatePin(pin), salt, params);
115
+ const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES });
116
+ decipher.setAuthTag(tag);
117
+ try {
118
+ const plain = Buffer.concat([decipher.update(Buffer.from(String(record.ct || ''), 'base64')), decipher.final()]);
119
+ return plain.toString('utf8');
120
+ } catch {
121
+ throw new PinError('That PIN does not open this key.', {
122
+ code: 'wrong_pin',
123
+ hint: 'The PIN is not recoverable: without it the stored key cannot be read, and the wallet export must be entered again.',
124
+ });
125
+ }
126
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Browser routes for the Web panel and settings page.
3
+ *
4
+ * The panel runs in the same origin as Host, so every write is checked against
5
+ * the Host header and the response never carries CORS headers. Nothing here
6
+ * returns the Agent Key.
7
+ */
8
+ import { publicError, ServiceError } from './errors.js';
9
+
10
+ const MAX_JSON_BODY = 64 * 1024;
11
+
12
+ function sendJson(res, status, payload) {
13
+ const body = JSON.stringify(payload);
14
+ res.writeHead(status, {
15
+ 'content-type': 'application/json; charset=utf-8',
16
+ 'cache-control': 'no-store',
17
+ 'content-length': Buffer.byteLength(body),
18
+ });
19
+ res.end(body);
20
+ }
21
+
22
+ function sendError(res, error) {
23
+ sendJson(res, statusFor(error), { ok: false, error: publicError(error) });
24
+ }
25
+
26
+ export function statusFor(error) {
27
+ const code = error && typeof error.code === 'string' ? error.code : '';
28
+ if (code === 'payload_too_large' || code === 'file_too_large') return 413;
29
+ if (code === 'not_found' || code === 'task_not_found' || code === 'source_missing') return 404;
30
+ if (code === 'method_not_allowed' || code === 'unsupported_action') return 405;
31
+ if (code === 'budget_shortfall' || code === 'no_agent_key' || code === 'permission_missing') return 409;
32
+ // Lock state is a precondition, not a bad request: 423 keeps it distinct from
33
+ // a real validation failure, and 401 is reserved for a wrong PIN.
34
+ if (code === 'agent_key_locked' || code === 'pin_required' || code === 'no_sealed_key' || code === 'upload_in_progress') return 423;
35
+ if (code === 'wrong_pin') return 401;
36
+ if (code === 'pin_blocked') return 429;
37
+ if (code === 'data_key_missing' || code === 'path_outside_download_dir' || code === 'path_outside_workspace') return 400;
38
+ return 400;
39
+ }
40
+
41
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
42
+
43
+ /**
44
+ * The panel talks to the Host over loopback. Refusing other peers keeps the
45
+ * key-changing and paid-upload routes away from a listener that a composition
46
+ * may have bound to a wider address; a relay that proxies to localhost still
47
+ * arrives from a loopback address.
48
+ */
49
+ function isLoopback(req) {
50
+ const address = req.socket && req.socket.remoteAddress;
51
+ if (!address) return true;
52
+ return LOOPBACK.has(address) || address.startsWith('127.');
53
+ }
54
+
55
+ function isSameOrigin(req) {
56
+ const origin = req.headers.origin;
57
+ if (!origin) return true;
58
+ try {
59
+ return new URL(origin).host === req.headers.host;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ async function readJsonBody(req, limit = MAX_JSON_BODY) {
66
+ const chunks = [];
67
+ let size = 0;
68
+ for await (const chunk of req) {
69
+ size += chunk.length;
70
+ if (size > limit) throw new ServiceError('payload_too_large', `Request body exceeds ${limit} bytes.`);
71
+ chunks.push(chunk);
72
+ }
73
+ if (size === 0) return {};
74
+ try {
75
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
76
+ } catch {
77
+ throw new ServiceError('invalid_json', 'Request body is not valid JSON.');
78
+ }
79
+ }
80
+
81
+ function methodNotAllowed(res, allowed) {
82
+ sendJson(res, 405, { ok: false, error: { code: 'method_not_allowed', message: `${allowed} only.` } });
83
+ }
84
+
85
+ export function registerRoutes(ctx, service, config, prefix) {
86
+ const webServer = ctx.get('webServer');
87
+ if (!webServer || typeof webServer.register !== 'function') {
88
+ ctx.logger?.debug?.('falari-harness: webServer unavailable; settings API not mounted');
89
+ return;
90
+ }
91
+
92
+ const dispose = webServer.register({
93
+ kind: 'prefix',
94
+ path: prefix,
95
+ handler: async (req, res) => {
96
+ if (!config.allowRemoteRoutes && !isLoopback(req)) {
97
+ sendJson(res, 403, { ok: false, error: { code: 'remote_forbidden', message: 'Falari Harness routes are local-only.' } });
98
+ return;
99
+ }
100
+ if (!isSameOrigin(req)) {
101
+ sendJson(res, 403, { ok: false, error: { code: 'cross_origin', message: 'Cross-origin requests are not allowed.' } });
102
+ return;
103
+ }
104
+ const url = new URL(req.url || prefix, 'http://localhost');
105
+ const full = url.pathname;
106
+ const path = full.startsWith(prefix) ? full.slice(prefix.length) || '/' : full;
107
+ const parts = path.split('/').filter(Boolean);
108
+ try {
109
+ await dispatch(req, res, url, parts);
110
+ } catch (error) {
111
+ sendError(res, error);
112
+ }
113
+ },
114
+ });
115
+ ctx.effect(() => dispose, 'falari-harness: routes');
116
+
117
+ async function dispatch(req, res, url, parts) {
118
+ // /status and /health
119
+ if (parts.length === 1 && parts[0] === 'status') {
120
+ if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
121
+ const refresh = url.searchParams.get('refresh') === '1';
122
+ return sendJson(res, 200, { ok: true, value: await service.keyStatus({ refresh }) });
123
+ }
124
+ if (parts.length === 1 && parts[0] === 'health') {
125
+ const security = await service.securityView();
126
+ return sendJson(res, 200, {
127
+ ok: true,
128
+ value: {
129
+ service: 'falari-harness',
130
+ configured: security.mode !== 'none',
131
+ encrypted: security.mode === 'sealed',
132
+ locked: security.locked,
133
+ network: config.network,
134
+ },
135
+ });
136
+ }
137
+ if (parts.length === 1 && parts[0] === 'config') {
138
+ if (req.method === 'GET') {
139
+ return sendJson(res, 200, {
140
+ ok: true,
141
+ value: {
142
+ network: config.network,
143
+ defaultPrivacy: config.defaultPrivacy,
144
+ defaultDuration: config.defaultDuration,
145
+ maxFileSizeBytes: config.maxFileSizeBytes,
146
+ lockIdleMinutes: config.lockIdleMinutes,
147
+ allowUploadOutsideWorkspace: config.allowUploadOutsideWorkspace,
148
+ allowRemoteRoutes: config.allowRemoteRoutes,
149
+ downloadDir: config.downloadDir || service.downloadDir,
150
+ gatewayUrl: config.gatewayUrl || '',
151
+ retrievalUrl: config.retrievalUrl || '',
152
+ explorerUrl: config.explorerUrl || '',
153
+ timeoutMs: config.timeoutMs,
154
+ },
155
+ });
156
+ }
157
+ if (req.method === 'POST') {
158
+ const body = await readJsonBody(req);
159
+ return sendJson(res, 200, { ok: true, value: await service.updateConfig(body) });
160
+ }
161
+ return methodNotAllowed(res, 'GET or POST');
162
+ }
163
+
164
+ // /key
165
+ if (parts.length === 1 && parts[0] === 'key') {
166
+ if (req.method === 'GET') {
167
+ return sendJson(res, 200, { ok: true, value: await service.keyStatus({ refresh: url.searchParams.get('refresh') === '1' }) });
168
+ }
169
+ if (req.method === 'DELETE') return sendJson(res, 200, { ok: true, value: await service.clearKey() });
170
+ if (req.method !== 'POST') return methodNotAllowed(res, 'GET, POST or DELETE');
171
+ const body = await readJsonBody(req);
172
+ const key = typeof body.key === 'string' ? body.key : '';
173
+ const pin = typeof body.pin === 'string' ? body.pin : '';
174
+ if (!key.trim()) throw new ServiceError('missing_key', 'Paste the far_ Agent Key first.');
175
+ if (!pin.trim()) {
176
+ throw new ServiceError(
177
+ 'missing_pin',
178
+ 'A PIN is required: the key is stored encrypted, never in clear text.',
179
+ 'Pick a PIN of at least six characters. It is the only thing that can open the stored key.',
180
+ );
181
+ }
182
+ return sendJson(res, 200, { ok: true, value: await service.configureKey(key, pin) });
183
+ }
184
+
185
+ // /key/unlock and /key/lock move the plaintext between the PIN and memory.
186
+ if (parts.length === 2 && parts[0] === 'key' && parts[1] === 'unlock') {
187
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
188
+ const body = await readJsonBody(req);
189
+ const pin = typeof body.pin === 'string' ? body.pin : '';
190
+ if (!pin) throw new ServiceError('missing_pin', 'Enter the PIN that protects this key.');
191
+ return sendJson(res, 200, { ok: true, value: await service.unlock(pin) });
192
+ }
193
+ if (parts.length === 2 && parts[0] === 'key' && parts[1] === 'lock') {
194
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
195
+ const body = await readJsonBody(req);
196
+ // Locking mid-upload stops the queue and strands a paid task, so it takes
197
+ // an explicit force; the panel offers it only when the user insists.
198
+ if (!body.force && (await service.hasActiveUploads())) {
199
+ throw new ServiceError(
200
+ 'upload_in_progress',
201
+ 'An upload is still running. Locking now would pause it until the PIN is entered again.',
202
+ 'Wait for the upload to finish, or send force:true to lock anyway.',
203
+ );
204
+ }
205
+ const locked = service.lock('panel');
206
+ return sendJson(res, 200, { ok: true, value: { locked, security: await service.securityView() } });
207
+ }
208
+ if (parts.length === 2 && parts[0] === 'key' && parts[1] === 'secure') {
209
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
210
+ const body = await readJsonBody(req);
211
+ const pin = typeof body.pin === 'string' ? body.pin : '';
212
+ if (!pin) throw new ServiceError('missing_pin', 'Set a PIN to encrypt the stored key.');
213
+ return sendJson(res, 200, { ok: true, value: await service.secureLegacyKey(pin) });
214
+ }
215
+ if (parts.length === 2 && parts[0] === 'key' && parts[1] === 'security') {
216
+ if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
217
+ return sendJson(res, 200, { ok: true, value: await service.securityView() });
218
+ }
219
+
220
+ // /quote
221
+ if (parts.length === 1 && parts[0] === 'quote') {
222
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
223
+ const body = await readJsonBody(req);
224
+ return sendJson(res, 200, {
225
+ ok: true,
226
+ value: await service.quote({ sizeBytes: Number(body.sizeBytes), duration: body.duration }),
227
+ });
228
+ }
229
+
230
+ // /uploads — raw octet-stream body, one file per request
231
+ if (parts.length === 1 && parts[0] === 'uploads') {
232
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
233
+ const declared = Number(req.headers['content-length'] ?? 0);
234
+ if (Number.isFinite(declared) && declared > config.maxFileSizeBytes) {
235
+ throw new ServiceError(
236
+ 'file_too_large',
237
+ `The file is above the configured limit of ${config.maxFileSizeBytes} bytes.`,
238
+ 'Raise maxFileSizeBytes in the plugin configuration to allow larger uploads.',
239
+ );
240
+ }
241
+ const task = await service.saveUploadStream(req, {
242
+ fileName: url.searchParams.get('fileName') || '',
243
+ privacy: url.searchParams.get('privacy') || undefined,
244
+ duration: url.searchParams.get('duration') || undefined,
245
+ });
246
+ return sendJson(res, 202, { ok: true, value: task });
247
+ }
248
+
249
+ // /intents and /intents/:id
250
+ if (parts.length >= 1 && parts[0] === 'intents') {
251
+ if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
252
+ if (parts.length === 1) {
253
+ const limit = Number(url.searchParams.get('limit') ?? 50);
254
+ return sendJson(res, 200, { ok: true, value: await service.listIntents({ limit }) });
255
+ }
256
+ if (parts.length === 2) {
257
+ return sendJson(res, 200, { ok: true, value: await service.getIntent(decodeURIComponent(parts[1])) });
258
+ }
259
+ throw new ServiceError('not_found', 'Unknown Falari Harness route.');
260
+ }
261
+
262
+ // /downloads — POST starts a task, GET /downloads/:id reads it
263
+ if (parts.length >= 1 && parts[0] === 'downloads') {
264
+ if (parts.length === 1) {
265
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
266
+ const body = await readJsonBody(req);
267
+ return sendJson(res, 202, {
268
+ ok: true,
269
+ value: await service.startDownload({
270
+ intentId: body.intentId,
271
+ dataKey: typeof body.dataKey === 'string' ? body.dataKey : undefined,
272
+ outPath: typeof body.outPath === 'string' ? body.outPath : undefined,
273
+ }),
274
+ });
275
+ }
276
+ if (parts.length === 2 && req.method === 'GET') {
277
+ return sendJson(res, 200, { ok: true, value: await service.getTask(decodeURIComponent(parts[1])) });
278
+ }
279
+ throw new ServiceError('not_found', 'Unknown Falari Harness route.');
280
+ }
281
+
282
+ // /tasks and /tasks/:id[/action]
283
+ if (parts.length >= 1 && parts[0] === 'tasks') {
284
+ if (parts.length === 1) {
285
+ if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
286
+ return sendJson(res, 200, { ok: true, value: await service.listTasks() });
287
+ }
288
+ const id = decodeURIComponent(parts[1]);
289
+ if (parts.length === 2) {
290
+ if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
291
+ return sendJson(res, 200, { ok: true, value: await service.getTask(id) });
292
+ }
293
+ // /tasks/:id/refund previews the escrow maths; /tasks/:id/cancel performs
294
+ // the termination and answers with what the chain actually released.
295
+ if (parts.length === 3 && parts[2] === 'refund') {
296
+ if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
297
+ return sendJson(res, 200, { ok: true, value: await service.refundPreview(id) });
298
+ }
299
+ if (parts.length === 3 && parts[2] === 'cancel') {
300
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
301
+ return sendJson(res, 200, { ok: true, value: await service.cancelUpload(id) });
302
+ }
303
+ if (parts.length === 3 && req.method === 'POST') {
304
+ return sendJson(res, 200, { ok: true, value: await service.taskAction(id, parts[2]) });
305
+ }
306
+ throw new ServiceError('not_found', 'Unknown Falari Harness route.');
307
+ }
308
+
309
+ throw new ServiceError('not_found', 'Unknown Falari Harness route.');
310
+ }
311
+ }
package/lib/sdk.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The bundled Falari Agent SDK (@falari/agent + @falari/precompiles).
3
+ *
4
+ * Both packages are not published to npm, so `scripts/build-sdk.sh` bundles
5
+ * them into `lib/falari-sdk.cjs`; this module is the single place that loads
6
+ * that bundle so every Host module shares one instance.
7
+ */
8
+ import { createRequire } from 'node:module';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ export const sdk = require('./falari-sdk.cjs');
13
+ export const { FalariAgent, FalariError, ApiError } = sdk;