@davvidbaker/flambe-cli 0.1.1 → 0.1.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.
@@ -0,0 +1,291 @@
1
+ // Local start/end/suspend/resume rules. HTTP is the adapter; SQLite is the source of truth.
2
+ export const COMMANDS = ['start', 'end', 'suspend', 'resume', 'status', 'message', 'plan'];
3
+ const RUNNING = new Set(['B', 'R', 'X']);
4
+ const ENDED = new Set(['E', 'J', 'V']);
5
+ const LIFECYCLE = new Set([...RUNNING, ...ENDED, 'S']);
6
+ const STOPWORDS = new Set('the and for with from into that this work task item stuff'.split(' '));
7
+ const tokens = name => name.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 4 && !STOPWORDS.has(t));
8
+
9
+ export class CommandError extends Error {
10
+ constructor(status, code, message, extra = {}) {
11
+ super(message);
12
+ this.status = status;
13
+ this.body = { error: { code, message, ...extra } };
14
+ }
15
+ }
16
+ function invalid(message) { throw new CommandError(422, 'INVALID_INPUT', message); }
17
+ function notFound() { throw new CommandError(404, 'NOT_FOUND', 'The requested resource was not found'); }
18
+ function positiveId(value, field) {
19
+ if (!['number', 'string'].includes(typeof value) || !/^\d+$/.test(String(value))
20
+ || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) invalid(`${field} must be a positive integer`);
21
+ return Number(value);
22
+ }
23
+ function optionalId(value, field) { return value == null ? null : positiveId(value, field); }
24
+ function bool(value, field) {
25
+ if (value !== undefined && typeof value !== 'boolean') invalid(`${field} must be boolean`);
26
+ return value ?? false;
27
+ }
28
+ function requiredText(value, field) {
29
+ if (typeof value !== 'string' || !value.trim()) invalid(`${field} is required`);
30
+ return value.trim();
31
+ }
32
+ function compare(a, b) { return Date.parse(a.timestamp) - Date.parse(b.timestamp) || a.id - b.id; }
33
+
34
+ /** Derive state only from lifecycle history; annotations never reopen work. */
35
+ export function commandState(trace, filters = {}) {
36
+ const latest = new Map();
37
+ const begins = new Map();
38
+ for (const event of trace.events) {
39
+ if (!event.activity || !LIFECYCLE.has(event.phase)) continue;
40
+ const id = event.activity.id;
41
+ if (!latest.has(id) || compare(event, latest.get(id)) > 0) latest.set(id, event);
42
+ if (event.phase === 'B' && (!begins.has(id) || compare(event, begins.get(id)) < 0)) begins.set(id, event);
43
+ }
44
+ const chain = id => {
45
+ const result = [];
46
+ const seen = new Set();
47
+ while (latest.has(id) && !seen.has(id)) {
48
+ seen.add(id);
49
+ const event = latest.get(id);
50
+ result.unshift(event);
51
+ id = event.activity.parent_id;
52
+ }
53
+ return result;
54
+ };
55
+ const threads = trace.threads.map(thread => ({ ...thread, availableActions: [{
56
+ operation: 'start', arguments: { trace_id: trace.id, thread_id: thread.id, parent_id: null },
57
+ }] }));
58
+ const activities = [...latest.values()].sort(compare).map(event => {
59
+ const activity = event.activity;
60
+ const ancestors = chain(activity.id);
61
+ const status = RUNNING.has(event.phase)
62
+ ? ancestors.slice(0, -1).some(a => a.phase === 'S') ? 'parent_suspended' : 'running'
63
+ : event.phase === 'S' ? 'suspended' : 'ended';
64
+ const descendants = [...latest.values()].filter(e => e.activity.id !== activity.id
65
+ && RUNNING.has(e.phase) && chain(e.activity.id).some(a => a.activity.id === activity.id));
66
+ const args = { trace_id: trace.id, activity_id: activity.id };
67
+ const end = { operation: 'end', arguments: args, ...(descendants.length ? {
68
+ requires: { force: true }, reason: 'open_descendants',
69
+ } : {}) };
70
+ const actions = status === 'running' ? [
71
+ { operation: 'start', arguments: { trace_id: trace.id, thread_id: activity.thread.id, parent_id: activity.id } },
72
+ { operation: 'suspend', arguments: args }, end,
73
+ ] : status === 'suspended' ? [{ operation: 'resume', arguments: args }, end] : [];
74
+ return {
75
+ id: activity.id, name: activity.name, threadId: activity.thread.id,
76
+ threadName: threads.find(t => t.id === activity.thread.id)?.name ?? null,
77
+ parentId: activity.parent_id ?? null, agentId: activity.agent_id ?? null,
78
+ path: ancestors.map(e => e.activity.name), categoryIds: activity.categories ?? [],
79
+ startedAt: begins.get(activity.id)?.timestamp ?? null,
80
+ latestEvent: { id: event.id, phase: event.phase, timestamp: event.timestamp,
81
+ ...(event.message ? { message: event.message } : {}) },
82
+ status, availableActions: actions,
83
+ };
84
+ });
85
+ return {
86
+ trace: { id: trace.id, name: trace.name },
87
+ threads: threads.filter(t => !filters.thread_id || t.id === filters.thread_id),
88
+ activities: activities.filter(a => (!filters.thread_id || a.threadId === filters.thread_id)
89
+ && (!filters.active_only || a.status === 'running')
90
+ && (!filters.suspended_only || ['suspended', 'parent_suspended'].includes(a.status))),
91
+ };
92
+ }
93
+
94
+ /** A command and all of its corrections commit together, or not at all. */
95
+ export function executeCommand(store, userId, command, attrs, headerAgent = null) {
96
+ if (!COMMANDS.includes(command) || !attrs || typeof attrs !== 'object' || Array.isArray(attrs)) {
97
+ invalid('command and arguments are required');
98
+ }
99
+ const traceId = positiveId(attrs.trace_id, 'trace_id');
100
+ const trace = store.getTrace(userId, traceId);
101
+ if (!trace) notFound();
102
+ const before = commandState(trace);
103
+ const byId = new Map(before.activities.map(a => [a.id, a]));
104
+ const timestamp = attrs.timestamp ?? Date.now();
105
+ if (!Number.isSafeInteger(timestamp) || !Number.isFinite(new Date(timestamp).getTime())) invalid('timestamp must be milliseconds since epoch');
106
+ const threadId = optionalId(attrs.thread_id, 'thread_id');
107
+ // Starts resolve the parent's lane even if a different lane was requested.
108
+ if (command === 'status' && threadId && !trace.threads.some(t => t.id === threadId)) notFound();
109
+ const filters = { thread_id: threadId,
110
+ active_only: bool(attrs.active_only, 'active_only'),
111
+ suspended_only: bool(attrs.suspended_only, 'suspended_only'),
112
+ include_unstarted: bool(attrs.include_unstarted, 'include_unstarted') };
113
+ if (filters.active_only && filters.suspended_only) invalid('active_only and suspended_only cannot both be true');
114
+ const result = { direction: null, reply: null, actions_applied: [], rules_fired: [], closed_descendants: [] };
115
+ if (command === 'status') {
116
+ const state = commandState(trace, filters);
117
+ if (filters.include_unstarted && !filters.active_only && !filters.suspended_only) {
118
+ for (const row of store.listUnstarted(traceId)) {
119
+ if (filters.thread_id && row.thread_id !== filters.thread_id) continue;
120
+ state.activities.push({
121
+ id: row.id, name: row.name, threadId: row.thread_id,
122
+ threadName: trace.threads.find(t => t.id === row.thread_id)?.name ?? null,
123
+ parentId: row.parent_id, agentId: row.agent_id, path: [row.name],
124
+ categoryIds: [], startedAt: null, latestEvent: null, status: 'unstarted',
125
+ scheduledStart: row.scheduled_start_ms, scheduledEnd: row.scheduled_end_ms,
126
+ availableActions: [{ operation: 'start', arguments: { trace_id: trace.id, activity_id: row.id, name: row.name } }],
127
+ });
128
+ }
129
+ }
130
+ return { ...result, state };
131
+ }
132
+ if (command === 'plan') {
133
+ const name = requiredText(attrs.name, 'name');
134
+ if (name.length > 255) invalid('name must be at most 255 characters');
135
+ const startAt = attrs.scheduled_start ?? null;
136
+ const endAt = attrs.scheduled_end ?? null;
137
+ if (startAt != null && (!Number.isSafeInteger(startAt))) invalid('scheduled_start must be milliseconds');
138
+ if (endAt != null && (!Number.isSafeInteger(endAt))) invalid('scheduled_end must be milliseconds');
139
+ if (startAt != null && endAt != null && endAt < startAt) invalid('scheduled_end must be at or after scheduled_start');
140
+ const created = store.createUnstarted(userId, {
141
+ traceId, name, description: attrs.description, weight: attrs.weight,
142
+ scheduledStart: startAt, scheduledEnd: endAt,
143
+ agent: headerAgent ?? (attrs.agent_id ? { agent_id: attrs.agent_id, name: attrs.agent_name } : null),
144
+ });
145
+ if (created.error) notFound();
146
+ result.activity_id = created.activity.id;
147
+ return { ...result, state: commandState(store.getTrace(userId, traceId), filters) };
148
+ }
149
+ if (command === 'message') {
150
+ requiredText(attrs.message, 'message');
151
+ if (attrs.activity_id != null && !byId.has(positiveId(attrs.activity_id, 'activity_id'))) notFound();
152
+ throw new CommandError(503, 'REDUCER_NOT_CONFIGURED',
153
+ 'Model review is unavailable in the self-contained runtime. Lifecycle commands remain available; no content was sent to a provider.');
154
+ }
155
+ if (attrs.message != null && typeof attrs.message !== 'string') invalid('message must be a string');
156
+ if (attrs.description != null && typeof attrs.description !== 'string') invalid('description must be a string');
157
+ const force = bool(attrs.force, 'force');
158
+ const agentId = headerAgent?.agent_id ?? attrs.agent_id;
159
+ if (agentId != null && (typeof agentId !== 'string' || !agentId.trim() || agentId.length > 200)) invalid('agent_id is invalid');
160
+
161
+ const ancestors = activity => {
162
+ const chain = [];
163
+ const seen = new Set();
164
+ while (activity && !seen.has(activity.id)) {
165
+ seen.add(activity.id);
166
+ chain.unshift(activity);
167
+ activity = byId.get(activity.parentId);
168
+ }
169
+ return chain;
170
+ };
171
+ const record = (id, phase, message) => {
172
+ const event = store.createEvent(userId, { traceId, activityId: id,
173
+ event: { phase, message, timestamp_integer: timestamp } });
174
+ if (event.error) notFound();
175
+ return event;
176
+ };
177
+ const annotate = (id, rule, applied) => {
178
+ result.rules_fired.push({ rule, applied });
179
+ record(id, 'reducer_decision', `rule=${rule} | applied=${JSON.stringify(applied)}`);
180
+ };
181
+
182
+ store.db.exec('BEGIN IMMEDIATE');
183
+ try {
184
+ const agent = headerAgent ?? (agentId ? store.identifyAgent(userId, agentId, attrs.agent_name, attrs.agent_platform) : null);
185
+ if (command === 'start') {
186
+ const name = requiredText(attrs.name, 'name');
187
+ if (name.length > 255) invalid('name must be at most 255 characters');
188
+ const categories = attrs.category_ids ?? [];
189
+ if (!Array.isArray(categories)) invalid('category_ids must be an array');
190
+ const categoryIds = [...new Set(categories.map(id => positiveId(id, 'category_id')))];
191
+ const explicitParent = Object.hasOwn(attrs, 'parent_id');
192
+ const requestedParent = optionalId(attrs.parent_id, 'parent_id');
193
+ if (requestedParent && !byId.has(requestedParent)) notFound();
194
+ const unstartedRows = store.listUnstarted(traceId);
195
+ const beginId = optionalId(attrs.activity_id, 'activity_id');
196
+ const namedLimbo = agentId && unstartedRows.find(row => row.proposed_by_agent_id === agentId
197
+ && row.name === name && row.scheduled_start_ms == null && row.scheduled_end_ms == null);
198
+ const explicit = beginId && unstartedRows.find(row => row.id === beginId);
199
+ if (beginId && !explicit) notFound();
200
+ const toBegin = explicit || namedLimbo;
201
+ if (toBegin) {
202
+ if (toBegin.parent_id) {
203
+ const parent = unstartedRows.find(row => row.id === toBegin.parent_id);
204
+ if (parent) invalid('parent is unstarted');
205
+ }
206
+ if (agentId) {
207
+ store.db.prepare('UPDATE activities SET agent_id = ?, agent_name = ? WHERE id = ?')
208
+ .run(agentId, headerAgent?.name ?? attrs.agent_name ?? agentId, toBegin.id);
209
+ }
210
+ const event = record(toBegin.id, 'B', 'Begun by reducer: a start matched this activity in limbo');
211
+ result.activity_id = toBegin.id;
212
+ result.event_id = event.id;
213
+ const action = { type: 'begin_existing', activity_id: toBegin.id };
214
+ result.actions_applied.push(action);
215
+ annotate(toBegin.id, 'begin_limbo', action);
216
+ } else {
217
+ const duplicate = agentId && [...before.activities].reverse().find(a => a.agentId === agentId
218
+ && a.name === name && !ENDED.has(a.latestEvent.phase));
219
+ if (duplicate) {
220
+ result.activity_id = duplicate.id;
221
+ const type = duplicate.latestEvent.phase === 'S' ? 'resume_existing' : 'no_op';
222
+ result.event_id = type === 'resume_existing'
223
+ ? record(duplicate.id, 'R', 'Resumed by reducer: duplicate start').id : duplicate.latestEvent.id;
224
+ const action = { type, activity_id: duplicate.id };
225
+ result.actions_applied.push(action);
226
+ annotate(duplicate.id, 'duplicate_open', action);
227
+ } else {
228
+ const parent = explicitParent ? byId.get(requestedParent) : [...before.activities].reverse()
229
+ .find(a => a.status === 'running' && (agentId ? a.agentId === agentId : a.threadId === (threadId ?? trace.threads[0]?.id)));
230
+ for (const ancestor of ancestors(parent)) {
231
+ if (ancestor.latestEvent.phase !== 'S') continue;
232
+ record(ancestor.id, 'R', 'Resumed by reducer: child started under suspended work');
233
+ result.actions_applied.push({ type: 'resume_ancestor', activity_id: ancestor.id });
234
+ }
235
+ const created = store.createActivity(userId, {
236
+ traceId, threadId, agent, reduce: true,
237
+ activity: { name, description: attrs.description, categories: categoryIds,
238
+ parent_id: parent?.id ?? null },
239
+ event: { phase: 'B', timestamp_integer: timestamp },
240
+ });
241
+ if (created.error) notFound();
242
+ result.activity_id = created.activity.id;
243
+ result.event_id = created.event.id;
244
+ result.reducer = { ...created.reducer,
245
+ parent_source: parent ? explicitParent ? 'explicit' : 'inferred' : 'root' };
246
+ if (result.actions_applied.length) annotate(result.activity_id, 'resume_ancestor',
247
+ result.actions_applied.map(a => a.activity_id));
248
+ if (!parent && agentId && before.activities.some(a => a.agentId === agentId && a.status === 'running')) {
249
+ annotate(result.activity_id, 'new_root_while_open', { type: 'skipped', reason: 'model_unavailable' });
250
+ } else if (parent) {
251
+ const ancestorTokens = new Set(ancestors(parent).flatMap(a => tokens(a.name)));
252
+ const childTokens = tokens(name);
253
+ if (childTokens.length && childTokens.every(t => !ancestorTokens.has(t))) {
254
+ annotate(result.activity_id, 'name_unfit', { type: 'skipped', reason: 'model_unavailable' });
255
+ }
256
+ }
257
+ }
258
+ }
259
+ } else {
260
+ const id = positiveId(attrs.activity_id, 'activity_id');
261
+ const activity = byId.get(id);
262
+ if (!activity) notFound();
263
+ const phase = command === 'end' ? attrs.phase ?? (attrs.message ? 'V' : 'E') : command === 'resume' ? 'R' : 'S';
264
+ if (command === 'end' && !ENDED.has(phase)) invalid('end phase must be E, J, or V');
265
+ const descendants = before.activities.filter(a => a.id !== id && RUNNING.has(a.latestEvent.phase)
266
+ && ancestors(a).some(p => p.id === id)).sort((a, b) => b.path.length - a.path.length || b.id - a.id);
267
+ if (command === 'end' && descendants.length && !force) {
268
+ throw new CommandError(409, 'OPEN_CHILDREN', 'End descendants first or use force: true', {
269
+ open_children: descendants.map(a => ({ activity_id: a.id, activity_name: a.name })),
270
+ });
271
+ }
272
+ if (command === 'end') {
273
+ for (const child of descendants) {
274
+ const event = record(child.id, 'E', `Ended by reducer: parent activity ${id} (${activity.name}) ended`);
275
+ result.closed_descendants.push({ activity_id: child.id, activity_name: child.name, event_id: event.id });
276
+ }
277
+ if (!descendants.length && activity.parentId != null) result.rules_fired.push({
278
+ rule: 'pop_to_parent', applied: { activity_id: activity.parentId, name: byId.get(activity.parentId)?.name },
279
+ });
280
+ }
281
+ result.activity_id = id;
282
+ result.event_id = record(id, phase, attrs.message).id;
283
+ }
284
+ result.state = commandState(store.getTrace(userId, traceId));
285
+ store.db.exec('COMMIT');
286
+ return result;
287
+ } catch (error) {
288
+ store.db.exec('ROLLBACK');
289
+ throw error;
290
+ }
291
+ }
@@ -1,5 +1,6 @@
1
1
  import { createReadStream, existsSync, statSync } from 'node:fs';
2
2
  import { extname, join, normalize, resolve } from 'node:path';
3
+ import { COMMANDS, CommandError, executeCommand } from './commands.mjs';
3
4
 
4
5
  function sendJson(res, status, body) {
5
6
  const payload = JSON.stringify(body);
@@ -143,6 +144,15 @@ async function dispatch(store, req, url, body, { auth, agent }) {
143
144
 
144
145
  const userId = auth?.user?.id;
145
146
 
147
+ if (path === '/api/agent-commands') {
148
+ if (method === 'GET') return { status: 200, json: { data: {
149
+ version: 1, commands: COMMANDS, runtime_mode: 'local_self_contained', model_review: false,
150
+ } } };
151
+ if (method === 'POST') return { status: 200, json: {
152
+ data: executeCommand(store, userId, body.command, body.arguments, agent),
153
+ } };
154
+ }
155
+
146
156
  if (method === 'GET' && path === '/api/agents/me') {
147
157
  if (!agent) {
148
158
  return { status: 400, json: { error: 'AGENT_ID_REQUIRED', detail: 'Send x-flambe-agent-id with a bearer token' } };
@@ -247,14 +257,29 @@ async function dispatch(store, req, url, body, { auth, agent }) {
247
257
  }
248
258
 
249
259
  if (method === 'POST' && path === '/api/activities') {
260
+ if (auth.tokenName !== null) {
261
+ const activity = body.activity ?? {};
262
+ const folded = executeCommand(store, userId, 'start', {
263
+ trace_id: body.trace_id, thread_id: body.thread_id, name: activity.name,
264
+ description: activity.description, category_ids: activity.categories,
265
+ ...(Object.hasOwn(activity, 'parent_id') ? { parent_id: activity.parent_id } : {}),
266
+ timestamp: body.event?.timestamp_integer,
267
+ }, agent);
268
+ return { status: 201, json: { data: {
269
+ activity: store.getActivity(userId, folded.activity_id),
270
+ event: { id: folded.event_id,
271
+ phase: folded.state.activities.find(a => a.id === folded.activity_id)?.latestEvent.phase },
272
+ reducer: folded.reducer ?? { actions_applied: folded.actions_applied },
273
+ direction: folded.direction, reply: folded.reply, rules_fired: folded.rules_fired,
274
+ actions_applied: folded.actions_applied,
275
+ } } };
276
+ }
250
277
  const result = store.createActivity(userId, {
251
278
  traceId: body.trace_id,
252
279
  threadId: body.thread_id,
253
280
  activity: body.activity ?? {},
254
281
  event: body.event ?? {},
255
282
  agent,
256
- // Bearer callers are agents proposing a start; the SPA (cookie) writes what it sent.
257
- reduce: auth.tokenName !== null,
258
283
  });
259
284
  if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
260
285
  return { status: 201, json: { data: result } };
@@ -269,7 +294,14 @@ async function dispatch(store, req, url, body, { auth, agent }) {
269
294
  return { status: 200, json: { data: activity } };
270
295
  }
271
296
  if (method === 'PUT') {
272
- const activity = store.updateActivity(userId, id, body.activity ?? {});
297
+ let attrs = body.activity ?? {};
298
+ // Same rule as Phoenix: bearer tokens cannot reassign identity; a session can.
299
+ if (auth.tokenName !== null) {
300
+ attrs = Object.fromEntries(
301
+ Object.entries(attrs).filter(([key]) => key !== 'agent_id' && key !== 'agent_name'),
302
+ );
303
+ }
304
+ const activity = store.updateActivity(userId, id, attrs);
273
305
  if (activity?.error === 'not_found' || !activity) return { status: 404, json: { error: 'NOT_FOUND' } };
274
306
  return { status: 200, json: { data: activity } };
275
307
  }
@@ -280,6 +312,21 @@ async function dispatch(store, req, url, body, { auth, agent }) {
280
312
  }
281
313
 
282
314
  if (method === 'POST' && path === '/api/events') {
315
+ const phase = body.event?.phase;
316
+ const command = { E: 'end', J: 'end', V: 'end', S: 'suspend', R: 'resume' }[phase];
317
+ if (auth.tokenName !== null && command) {
318
+ const folded = executeCommand(store, userId, command, {
319
+ trace_id: body.trace_id, activity_id: body.activity_id,
320
+ timestamp: body.event?.timestamp_integer, message: body.event?.message,
321
+ phase, force: body.force ?? true,
322
+ }, agent);
323
+ return { status: 201, json: { data: {
324
+ id: folded.event_id, phase,
325
+ reducer: { closed_descendants: folded.closed_descendants,
326
+ rules_fired: folded.rules_fired, actions_applied: folded.actions_applied,
327
+ direction: folded.direction, reply: folded.reply },
328
+ } } };
329
+ }
283
330
  const result = store.createEvent(userId, {
284
331
  traceId: body.trace_id,
285
332
  activityId: body.activity_id,
@@ -309,10 +356,6 @@ async function dispatch(store, req, url, body, { auth, agent }) {
309
356
  return { status: 200, json: { data: store.userDashboard(userId) } };
310
357
  }
311
358
 
312
- if (method === 'POST' && path === '/api/todos') {
313
- return { status: 201, json: { data: store.createTodo(userId, body.todo ?? {}) } };
314
- }
315
-
316
359
  if (method === 'POST' && path === '/api/mantras') {
317
360
  return { status: 201, json: { data: store.createMantra(userId, body.mantra ?? {}) } };
318
361
  }
@@ -354,7 +397,8 @@ function isoNow() {
354
397
  function tryStatic(staticDir, urlPath, res) {
355
398
  if (!staticDir) return false;
356
399
  const root = resolve(staticDir);
357
- const indexPath = join(root, 'assets', 'index.html');
400
+ const rootIndex = join(root, 'index.html');
401
+ const indexPath = existsSync(rootIndex) ? rootIndex : join(root, 'assets', 'index.html');
358
402
  const requested = urlPath === '/' || !urlPath.includes('.')
359
403
  ? indexPath
360
404
  : join(root, urlPath.replace(/^\//, ''));
@@ -376,6 +420,21 @@ function tryStatic(staticDir, urlPath, res) {
376
420
  export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
377
421
  const host = req.headers.host ?? '127.0.0.1';
378
422
  const url = new URL(req.url ?? '/', `http://${host}`);
423
+ // A locally served UI must not send trace content to a compiled-in remote API.
424
+ res.setHeader('content-security-policy', "connect-src 'self'; form-action 'self'; frame-ancestors 'none'");
425
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
426
+ sendJson(res, 403, { error: 'LOCAL_HOST_REQUIRED' });
427
+ return;
428
+ }
429
+ if (req.headers.origin) {
430
+ let origin;
431
+ try { origin = new URL(req.headers.origin); } catch { /* rejected below */ }
432
+ if (!origin || !['http:', 'https:'].includes(origin.protocol)
433
+ || !['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname)) {
434
+ sendJson(res, 403, { error: 'LOCAL_ORIGIN_REQUIRED' });
435
+ return;
436
+ }
437
+ }
379
438
 
380
439
  if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
381
440
  if (tryStatic(staticDir, url.pathname, res)) return;
@@ -383,8 +442,9 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
383
442
  const html = `<!doctype html><meta charset="utf-8"><title>Flambe local</title>
384
443
  <body style="font-family:sans-serif;max-width:40rem;margin:3rem auto;line-height:1.4">
385
444
  <h1>Flambe local</h1>
386
- <p>CLI API is running. Build the frontend (<code>cd frontend &amp;&amp; npm run build</code>) and restart with <code>--static</code> pointing at <code>backend/priv/static</code> to open the chart here. Vite can also proxy to this port.</p>
445
+ <p>CLI API is running. From the repository root, run <code>npm --prefix frontend run build:local</code>, then restart <code>flambe serve</code> to open the local chart here.</p>
387
446
  <pre>FLAMBE_URL=http://${host}
447
+ FLAMBE_RUNTIME_MODE=local_self_contained
388
448
  FLAMBE_API_TOKEN=${rawToken}
389
449
  FLAMBE_TRACE_ID=${traceId}</pre>
390
450
  </body>`;
@@ -403,7 +463,13 @@ FLAMBE_TRACE_ID=${traceId}</pre>
403
463
 
404
464
  const auth = authenticate(store, req);
405
465
  const agent = identifyAgent(store, req, auth);
406
- const result = await dispatch(store, req, url, body, { auth, agent });
466
+ let result;
467
+ try {
468
+ result = await dispatch(store, req, url, body, { auth, agent });
469
+ } catch (error) {
470
+ if (!(error instanceof CommandError)) throw error;
471
+ result = { status: error.status, json: error.body };
472
+ }
407
473
  const identity = agentHeaders(agent);
408
474
  if (result.status === 204) {
409
475
  if (result.headers) result.headers.apply(res, 204, identity);
@@ -12,7 +12,7 @@ export function defaultDbPath() {
12
12
  }
13
13
 
14
14
  export function defaultStaticDir() {
15
- return resolve(fileURLToPath(new URL('../../../../backend/priv/static', import.meta.url)));
15
+ return resolve(fileURLToPath(new URL('../../../backend/priv/static', import.meta.url)));
16
16
  }
17
17
 
18
18
  export function createLocalServer({ dbPath = defaultDbPath(), staticDir } = {}) {
@@ -56,6 +56,7 @@ export function printServeBanner({ host, port, store }, stdout = process.stdout)
56
56
  const origin = `http://${host}:${port}`;
57
57
  stdout.write(`Flambe local on ${origin} (SQLite, loopback only)\n`);
58
58
  stdout.write(`FLAMBE_URL=${origin}\n`);
59
+ stdout.write('FLAMBE_RUNTIME_MODE=local_self_contained\n');
59
60
  stdout.write(`FLAMBE_API_TOKEN=${rawToken}\n`);
60
61
  stdout.write(`FLAMBE_TRACE_ID=${traceId}\n`);
61
62
  }