@davvidbaker/flambe-cli 0.1.0 → 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);
@@ -10,8 +11,8 @@ function sendJson(res, status, body) {
10
11
  res.end(payload);
11
12
  }
12
13
 
13
- function sendEmpty(res, status) {
14
- res.writeHead(status);
14
+ function sendEmpty(res, status, headers = {}) {
15
+ res.writeHead(status, headers);
15
16
  res.end();
16
17
  }
17
18
 
@@ -88,12 +89,32 @@ function headerBag() {
88
89
  };
89
90
  }
90
91
 
91
- async function dispatch(store, req, url, body) {
92
- const method = req.method ?? 'GET';
93
- const path = url.pathname;
94
- const auth = authenticate(store, req);
92
+ // Same contract as FlambeNextWeb.Plugs.AgentIdentity (ADR-012).
93
+ function identifyAgent(store, req, auth) {
94
+ if (!auth) return null;
95
95
  const agentId = req.headers['x-flambe-agent-id'];
96
96
  const agentName = req.headers['x-flambe-agent-name'];
97
+ const agentPlatform = req.headers['x-flambe-agent-platform'];
98
+ if (typeof agentId !== 'string') return null;
99
+ return store.identifyAgent(
100
+ auth.user.id,
101
+ agentId,
102
+ typeof agentName === 'string' ? agentName : undefined,
103
+ typeof agentPlatform === 'string' ? agentPlatform : undefined,
104
+ );
105
+ }
106
+
107
+ function agentHeaders(agent) {
108
+ if (!agent) return {};
109
+ return {
110
+ 'x-flambe-agent-name': agent.name,
111
+ ...(agent.assigned ? { 'x-flambe-agent-name-assigned': 'true' } : {}),
112
+ };
113
+ }
114
+
115
+ async function dispatch(store, req, url, body, { auth, agent }) {
116
+ const method = req.method ?? 'GET';
117
+ const path = url.pathname;
97
118
 
98
119
  if (method === 'GET' && path === '/api/health') {
99
120
  return { status: 200, json: { status: 'ok' } };
@@ -123,6 +144,22 @@ async function dispatch(store, req, url, body) {
123
144
 
124
145
  const userId = auth?.user?.id;
125
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
+
156
+ if (method === 'GET' && path === '/api/agents/me') {
157
+ if (!agent) {
158
+ return { status: 400, json: { error: 'AGENT_ID_REQUIRED', detail: 'Send x-flambe-agent-id with a bearer token' } };
159
+ }
160
+ return { status: 200, json: { data: { agent_id: agent.agent_id, name: agent.name, platform: agent.platform ?? null, name_assigned: agent.assigned } } };
161
+ }
162
+
126
163
  if (method === 'GET' && path === '/api/export') {
127
164
  return { status: 200, json: store.exportBundle() };
128
165
  }
@@ -220,14 +257,29 @@ async function dispatch(store, req, url, body) {
220
257
  }
221
258
 
222
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
+ }
223
277
  const result = store.createActivity(userId, {
224
278
  traceId: body.trace_id,
225
279
  threadId: body.thread_id,
226
280
  activity: body.activity ?? {},
227
281
  event: body.event ?? {},
228
- agentId: typeof agentId === 'string' ? agentId : undefined,
229
- agentName: typeof agentName === 'string' ? agentName : undefined,
230
- tokenName: auth.tokenName,
282
+ agent,
231
283
  });
232
284
  if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
233
285
  return { status: 201, json: { data: result } };
@@ -242,7 +294,14 @@ async function dispatch(store, req, url, body) {
242
294
  return { status: 200, json: { data: activity } };
243
295
  }
244
296
  if (method === 'PUT') {
245
- 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);
246
305
  if (activity?.error === 'not_found' || !activity) return { status: 404, json: { error: 'NOT_FOUND' } };
247
306
  return { status: 200, json: { data: activity } };
248
307
  }
@@ -253,6 +312,21 @@ async function dispatch(store, req, url, body) {
253
312
  }
254
313
 
255
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
+ }
256
330
  const result = store.createEvent(userId, {
257
331
  traceId: body.trace_id,
258
332
  activityId: body.activity_id,
@@ -282,10 +356,6 @@ async function dispatch(store, req, url, body) {
282
356
  return { status: 200, json: { data: store.userDashboard(userId) } };
283
357
  }
284
358
 
285
- if (method === 'POST' && path === '/api/todos') {
286
- return { status: 201, json: { data: store.createTodo(userId, body.todo ?? {}) } };
287
- }
288
-
289
359
  if (method === 'POST' && path === '/api/mantras') {
290
360
  return { status: 201, json: { data: store.createMantra(userId, body.mantra ?? {}) } };
291
361
  }
@@ -327,7 +397,8 @@ function isoNow() {
327
397
  function tryStatic(staticDir, urlPath, res) {
328
398
  if (!staticDir) return false;
329
399
  const root = resolve(staticDir);
330
- 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');
331
402
  const requested = urlPath === '/' || !urlPath.includes('.')
332
403
  ? indexPath
333
404
  : join(root, urlPath.replace(/^\//, ''));
@@ -349,6 +420,21 @@ function tryStatic(staticDir, urlPath, res) {
349
420
  export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
350
421
  const host = req.headers.host ?? '127.0.0.1';
351
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
+ }
352
438
 
353
439
  if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
354
440
  if (tryStatic(staticDir, url.pathname, res)) return;
@@ -356,8 +442,9 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
356
442
  const html = `<!doctype html><meta charset="utf-8"><title>Flambe local</title>
357
443
  <body style="font-family:sans-serif;max-width:40rem;margin:3rem auto;line-height:1.4">
358
444
  <h1>Flambe local</h1>
359
- <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>
360
446
  <pre>FLAMBE_URL=http://${host}
447
+ FLAMBE_RUNTIME_MODE=local_self_contained
361
448
  FLAMBE_API_TOKEN=${rawToken}
362
449
  FLAMBE_TRACE_ID=${traceId}</pre>
363
450
  </body>`;
@@ -374,15 +461,24 @@ FLAMBE_TRACE_ID=${traceId}</pre>
374
461
  return;
375
462
  }
376
463
 
377
- const result = await dispatch(store, req, url, body);
464
+ const auth = authenticate(store, req);
465
+ const agent = identifyAgent(store, req, auth);
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
+ }
473
+ const identity = agentHeaders(agent);
378
474
  if (result.status === 204) {
379
- if (result.headers) result.headers.apply(res, 204);
380
- else sendEmpty(res, 204);
475
+ if (result.headers) result.headers.apply(res, 204, identity);
476
+ else sendEmpty(res, 204, identity);
381
477
  return;
382
478
  }
383
479
 
384
480
  const payload = JSON.stringify(result.json);
385
- const extra = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload) };
481
+ const extra = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload), ...identity };
386
482
  if (result.headers) result.headers.apply(res, result.status, extra);
387
483
  else res.writeHead(result.status, extra);
388
484
  res.end(payload);
@@ -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
  }