@davvidbaker/flambe-cli 0.1.1 → 0.1.3

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,41 @@ async function dispatch(store, req, url, body, { auth, agent }) {
247
257
  }
248
258
 
249
259
  if (method === 'POST' && path === '/api/activities') {
260
+ if (!Object.hasOwn(body, 'event') && auth.tokenName !== null) {
261
+ const activity = body.activity ?? {};
262
+ const planned = executeCommand(store, userId, 'plan', {
263
+ trace_id: body.trace_id, name: activity.name, description: activity.description,
264
+ weight: activity.weight, category_ids: activity.categories,
265
+ scheduled_start: activity.scheduled_start_integer,
266
+ scheduled_end: activity.scheduled_end_integer,
267
+ }, agent);
268
+ return { status: 201, json: { data: {
269
+ activity: store.getActivity(userId, planned.activity_id), event: null,
270
+ } } };
271
+ }
272
+ if (auth.tokenName !== null) {
273
+ const activity = body.activity ?? {};
274
+ const folded = executeCommand(store, userId, 'start', {
275
+ trace_id: body.trace_id, thread_id: body.thread_id, name: activity.name,
276
+ description: activity.description, category_ids: activity.categories,
277
+ ...(Object.hasOwn(activity, 'parent_id') ? { parent_id: activity.parent_id } : {}),
278
+ timestamp: body.event?.timestamp_integer,
279
+ }, agent);
280
+ return { status: 201, json: { data: {
281
+ activity: store.getActivity(userId, folded.activity_id),
282
+ event: { id: folded.event_id,
283
+ phase: folded.state.activities.find(a => a.id === folded.activity_id)?.latestEvent.phase },
284
+ reducer: folded.reducer ?? { actions_applied: folded.actions_applied },
285
+ direction: folded.direction, reply: folded.reply, rules_fired: folded.rules_fired,
286
+ actions_applied: folded.actions_applied,
287
+ } } };
288
+ }
250
289
  const result = store.createActivity(userId, {
251
290
  traceId: body.trace_id,
252
291
  threadId: body.thread_id,
253
292
  activity: body.activity ?? {},
254
- event: body.event ?? {},
293
+ event: body.event,
255
294
  agent,
256
- // Bearer callers are agents proposing a start; the SPA (cookie) writes what it sent.
257
- reduce: auth.tokenName !== null,
258
295
  });
259
296
  if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
260
297
  return { status: 201, json: { data: result } };
@@ -269,7 +306,14 @@ async function dispatch(store, req, url, body, { auth, agent }) {
269
306
  return { status: 200, json: { data: activity } };
270
307
  }
271
308
  if (method === 'PUT') {
272
- const activity = store.updateActivity(userId, id, body.activity ?? {});
309
+ let attrs = body.activity ?? {};
310
+ // Same rule as Phoenix: bearer tokens cannot reassign identity; a session can.
311
+ if (auth.tokenName !== null) {
312
+ attrs = Object.fromEntries(
313
+ Object.entries(attrs).filter(([key]) => key !== 'agent_id' && key !== 'agent_name'),
314
+ );
315
+ }
316
+ const activity = store.updateActivity(userId, id, attrs);
273
317
  if (activity?.error === 'not_found' || !activity) return { status: 404, json: { error: 'NOT_FOUND' } };
274
318
  return { status: 200, json: { data: activity } };
275
319
  }
@@ -280,6 +324,21 @@ async function dispatch(store, req, url, body, { auth, agent }) {
280
324
  }
281
325
 
282
326
  if (method === 'POST' && path === '/api/events') {
327
+ const phase = body.event?.phase;
328
+ const command = { E: 'end', J: 'end', V: 'end', S: 'suspend', R: 'resume' }[phase];
329
+ if (auth.tokenName !== null && command) {
330
+ const folded = executeCommand(store, userId, command, {
331
+ trace_id: body.trace_id, activity_id: body.activity_id,
332
+ timestamp: body.event?.timestamp_integer, message: body.event?.message,
333
+ phase, force: body.force ?? true,
334
+ }, agent);
335
+ return { status: 201, json: { data: {
336
+ id: folded.event_id, phase,
337
+ reducer: { closed_descendants: folded.closed_descendants,
338
+ rules_fired: folded.rules_fired, actions_applied: folded.actions_applied,
339
+ direction: folded.direction, reply: folded.reply },
340
+ } } };
341
+ }
283
342
  const result = store.createEvent(userId, {
284
343
  traceId: body.trace_id,
285
344
  activityId: body.activity_id,
@@ -309,10 +368,6 @@ async function dispatch(store, req, url, body, { auth, agent }) {
309
368
  return { status: 200, json: { data: store.userDashboard(userId) } };
310
369
  }
311
370
 
312
- if (method === 'POST' && path === '/api/todos') {
313
- return { status: 201, json: { data: store.createTodo(userId, body.todo ?? {}) } };
314
- }
315
-
316
371
  if (method === 'POST' && path === '/api/mantras') {
317
372
  return { status: 201, json: { data: store.createMantra(userId, body.mantra ?? {}) } };
318
373
  }
@@ -354,7 +409,8 @@ function isoNow() {
354
409
  function tryStatic(staticDir, urlPath, res) {
355
410
  if (!staticDir) return false;
356
411
  const root = resolve(staticDir);
357
- const indexPath = join(root, 'assets', 'index.html');
412
+ const rootIndex = join(root, 'index.html');
413
+ const indexPath = existsSync(rootIndex) ? rootIndex : join(root, 'assets', 'index.html');
358
414
  const requested = urlPath === '/' || !urlPath.includes('.')
359
415
  ? indexPath
360
416
  : join(root, urlPath.replace(/^\//, ''));
@@ -376,6 +432,21 @@ function tryStatic(staticDir, urlPath, res) {
376
432
  export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
377
433
  const host = req.headers.host ?? '127.0.0.1';
378
434
  const url = new URL(req.url ?? '/', `http://${host}`);
435
+ // A locally served UI must not send trace content to a compiled-in remote API.
436
+ res.setHeader('content-security-policy', "connect-src 'self'; form-action 'self'; frame-ancestors 'none'");
437
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
438
+ sendJson(res, 403, { error: 'LOCAL_HOST_REQUIRED' });
439
+ return;
440
+ }
441
+ if (req.headers.origin) {
442
+ let origin;
443
+ try { origin = new URL(req.headers.origin); } catch { /* rejected below */ }
444
+ if (!origin || !['http:', 'https:'].includes(origin.protocol)
445
+ || !['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname)) {
446
+ sendJson(res, 403, { error: 'LOCAL_ORIGIN_REQUIRED' });
447
+ return;
448
+ }
449
+ }
379
450
 
380
451
  if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
381
452
  if (tryStatic(staticDir, url.pathname, res)) return;
@@ -383,8 +454,9 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
383
454
  const html = `<!doctype html><meta charset="utf-8"><title>Flambe local</title>
384
455
  <body style="font-family:sans-serif;max-width:40rem;margin:3rem auto;line-height:1.4">
385
456
  <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>
457
+ <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
458
  <pre>FLAMBE_URL=http://${host}
459
+ FLAMBE_RUNTIME_MODE=local_self_contained
388
460
  FLAMBE_API_TOKEN=${rawToken}
389
461
  FLAMBE_TRACE_ID=${traceId}</pre>
390
462
  </body>`;
@@ -403,7 +475,13 @@ FLAMBE_TRACE_ID=${traceId}</pre>
403
475
 
404
476
  const auth = authenticate(store, req);
405
477
  const agent = identifyAgent(store, req, auth);
406
- const result = await dispatch(store, req, url, body, { auth, agent });
478
+ let result;
479
+ try {
480
+ result = await dispatch(store, req, url, body, { auth, agent });
481
+ } catch (error) {
482
+ if (!(error instanceof CommandError)) throw error;
483
+ result = { status: error.status, json: error.body };
484
+ }
407
485
  const identity = agentHeaders(agent);
408
486
  if (result.status === 204) {
409
487
  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
  }