clearhand 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +146 -0
  3. package/SECURITY.md +77 -0
  4. package/bin/clearhand.js +230 -0
  5. package/dist/mcp/index.js +159 -0
  6. package/dist/server/connectors/crawl.js +52 -0
  7. package/dist/server/connectors/github.js +45 -0
  8. package/dist/server/connectors/mailbox.js +257 -0
  9. package/dist/server/connectors/stripe.js +252 -0
  10. package/dist/server/context.js +83 -0
  11. package/dist/server/db/index.js +324 -0
  12. package/dist/server/demo.js +194 -0
  13. package/dist/server/index.js +51 -0
  14. package/dist/server/routes/api.js +704 -0
  15. package/dist/server/secrets/redaction.js +62 -0
  16. package/dist/server/secrets/store.js +238 -0
  17. package/dist/server/services/approval.js +206 -0
  18. package/dist/server/services/connections.js +99 -0
  19. package/dist/server/services/executor.js +417 -0
  20. package/dist/server/services/lint.js +129 -0
  21. package/dist/server/services/onboarding.js +168 -0
  22. package/dist/server/services/overlay.js +65 -0
  23. package/dist/server/trust/egress.js +12 -0
  24. package/dist/shared/actions.js +136 -0
  25. package/dist/shared/config.js +33 -0
  26. package/dist/shared/paths.js +34 -0
  27. package/package.json +73 -0
  28. package/public/assets/index-BicJ8AKo.css +1 -0
  29. package/public/assets/index-DZSNZW8-.js +43 -0
  30. package/public/index.html +17 -0
  31. package/templates/CLEARHAND.md +79 -0
  32. package/templates/overlay/escalation.md +21 -0
  33. package/templates/overlay/policies.md +30 -0
  34. package/templates/overlay/products.md +25 -0
  35. package/templates/overlay/tone-lint.json +14 -0
  36. package/templates/overlay/voice.md +24 -0
  37. package/templates/skills/clearhand-cs/SKILL.md +75 -0
  38. package/templates/skills/clearhand-onboard/SKILL.md +126 -0
@@ -0,0 +1,704 @@
1
+ import { Router } from 'express';
2
+ import { audit, claimCaseIfNew, countPendingFinancialActions, now, softSupersedeProposals } from '../db/index.js';
3
+ import { enrichCaseFromStripe } from '../context.js';
4
+ import { approveAndExecuteActions, rejectAction, setCaseStatus, } from '../services/approval.js';
5
+ import { lintAndNormalizeAction, lintDraft } from '../services/lint.js';
6
+ import { overlayStatus, readOverlayFile } from '../services/overlay.js';
7
+ import { ACTION_TYPES, DEFAULT_EXECUTION_ORDER, isValidStripeId, } from '../../shared/actions.js';
8
+ import { verifyStripeKey, stripeBusinessSummary, stripeInventory, STRIPE_SECRET_NAME, } from '../connectors/stripe.js';
9
+ import { fetchSentSamples, gmailDefaults, mailboxInventory, verifyMailbox, MAILBOX_SECRET_NAME, } from '../connectors/mailbox.js';
10
+ import { appendEvent, applyStyleSelection, eventsAfter, getOnboardingState, ONBOARDING_STAGES, saveConfirmation, setStage, } from '../services/onboarding.js';
11
+ import { verifyGithub, GITHUB_SECRET_NAME } from '../connectors/github.js';
12
+ import { crawlPages } from '../connectors/crawl.js';
13
+ import { connectionsStatus, mailboxConfig, setConnection, writeConnectionsManifest, } from '../services/connections.js';
14
+ import { listEgress } from '../trust/egress.js';
15
+ import { makeMailer } from '../connectors/mailbox.js';
16
+ export function apiRouter(ctx) {
17
+ const r = Router();
18
+ const { db, secrets } = ctx;
19
+ const err = (res, status, error, extra = {}) => res.status(status).json({ error, ...extra });
20
+ /**
21
+ * The execute-token gate. Opt-in: when CLEARHAND_EXECUTE_TOKEN is set, the
22
+ * money path requires it as a bearer token.
23
+ */
24
+ const requireExecuteToken = (req, res) => {
25
+ const expected = process.env.CLEARHAND_EXECUTE_TOKEN;
26
+ if (!expected)
27
+ return true;
28
+ const got = req.headers.authorization?.replace(/^Bearer\s+/i, '') ?? req.headers['x-clearhand-token'];
29
+ if (got !== expected) {
30
+ err(res, 401, 'missing or invalid execute token');
31
+ return false;
32
+ }
33
+ return true;
34
+ };
35
+ // ------------------------------------------------------------- status --
36
+ r.get('/status', (_req, res) => {
37
+ const queue = db
38
+ .prepare(`SELECT
39
+ SUM(CASE WHEN status IN ('new','processed','reviewing') THEN 1 ELSE 0 END) AS open_cases,
40
+ (SELECT COUNT(*) FROM actions WHERE status = 'proposed') AS pending_proposals
41
+ FROM cases`)
42
+ .get();
43
+ res.json({
44
+ clearhand: '0.1.0',
45
+ demo_mode: ctx.demo,
46
+ secret_storage: secrets.backendName,
47
+ connections: connectionsStatus(db, secrets),
48
+ overlay: { files: overlayStatus() },
49
+ queue: {
50
+ open_cases: queue.open_cases ?? 0,
51
+ pending_proposals: queue.pending_proposals ?? 0,
52
+ },
53
+ });
54
+ writeConnectionsManifest(db, secrets);
55
+ });
56
+ // --------------------------------------------------------- onboarding --
57
+ r.get('/onboarding', (_req, res) => {
58
+ const state = getOnboardingState(db);
59
+ const last = db.prepare(`SELECT MAX(id) AS m FROM onboarding_events`).get();
60
+ res.json({ state, last_event_id: last.m ?? 0, demo_mode: ctx.demo });
61
+ });
62
+ r.patch('/onboarding/stage', (req, res) => {
63
+ const stage = String(req.body?.stage ?? '');
64
+ const status = String(req.body?.status ?? '');
65
+ if (!ONBOARDING_STAGES.includes(stage)) {
66
+ return err(res, 400, `unknown stage "${stage}"`);
67
+ }
68
+ if (!['pending', 'active', 'done', 'skipped'].includes(status)) {
69
+ return err(res, 400, `invalid status "${status}"`);
70
+ }
71
+ res.json({ state: setStage(db, stage, status) });
72
+ });
73
+ r.post('/onboarding/events', (req, res) => {
74
+ const { stage, kind, payload } = req.body ?? {};
75
+ if (typeof stage !== 'string' || typeof kind !== 'string') {
76
+ return err(res, 400, 'stage and kind required');
77
+ }
78
+ const id = appendEvent(db, stage, kind, payload);
79
+ res.json({ id });
80
+ });
81
+ r.get('/onboarding/events', (req, res) => {
82
+ const after = Number(req.query.after) || 0;
83
+ res.json({ events: eventsAfter(db, after) });
84
+ });
85
+ r.post('/onboarding/confirmations', (req, res) => {
86
+ const { key, value } = req.body ?? {};
87
+ if (typeof key !== 'string' || !key)
88
+ return err(res, 400, 'key required');
89
+ res.json({ state: saveConfirmation(db, key, value) });
90
+ });
91
+ r.post('/onboarding/style', (req, res) => {
92
+ const b = req.body ?? {};
93
+ if (!['by_the_book', 'track_record', 'generous'].includes(b.choice)) {
94
+ return err(res, 400, 'choice must be by_the_book | track_record | generous');
95
+ }
96
+ if (typeof b.contractText !== 'string' || b.contractText.trim().length < 20) {
97
+ return err(res, 400, 'contractText required — the prose IS the policy');
98
+ }
99
+ const sel = {
100
+ choice: b.choice,
101
+ escalation: {
102
+ legalOrChargeback: true,
103
+ repeatRefunders: b.escalation?.repeatRefunders !== false,
104
+ amountOverCents: typeof b.escalation?.amountOverCents === 'number'
105
+ ? Math.trunc(b.escalation.amountOverCents)
106
+ : null,
107
+ vipList: Array.isArray(b.escalation?.vipList) ? b.escalation.vipList.map(String) : [],
108
+ },
109
+ cancelDefault: b.cancelDefault === 'immediate' ? 'immediate' : 'end_of_period',
110
+ contractText: b.contractText,
111
+ };
112
+ applyStyleSelection(db, sel);
113
+ res.json({ ok: true, written: 'overlay/policies.md' });
114
+ });
115
+ // -------------------------------------------------------------- trust --
116
+ r.get('/trust', (_req, res) => {
117
+ res.json({
118
+ secret_storage: secrets.backendName,
119
+ secrets: secrets.list(), // metadata + masked hints only
120
+ secret_access_log: db
121
+ .prepare(`SELECT * FROM secret_access_log ORDER BY id DESC LIMIT 200`)
122
+ .all(),
123
+ egress: listEgress(db),
124
+ telemetry: 'none — ClearHand has no telemetry endpoint',
125
+ });
126
+ });
127
+ r.get('/audit', (req, res) => {
128
+ const caseId = req.query.case_id ? Number(req.query.case_id) : null;
129
+ const rows = caseId
130
+ ? db.prepare(`SELECT * FROM audit_log WHERE case_id = ? ORDER BY id DESC LIMIT 500`).all(caseId)
131
+ : db.prepare(`SELECT * FROM audit_log ORDER BY id DESC LIMIT 500`).all();
132
+ res.json({ audit: rows });
133
+ });
134
+ // -------------------------------------------------------- connections --
135
+ r.post('/connections/stripe', async (req, res) => {
136
+ const key = typeof req.body?.api_key === 'string' ? req.body.api_key.trim() : '';
137
+ if (!key)
138
+ return err(res, 400, 'api_key required');
139
+ if (ctx.demo) {
140
+ // Demo mode: simulated connect so the whole onboarding is walkable
141
+ // without real credentials. No secret is stored; nothing leaves the
142
+ // machine. Inventory matches the seeded data.
143
+ setConnection(db, 'stripe', 'connected', { keyType: 'demo', accountHint: '…demo' });
144
+ setStage(db, 'stripe', 'done');
145
+ return res.json({
146
+ ok: true,
147
+ key_type: 'demo',
148
+ warning: null,
149
+ inventory: { products: 3, prices: 7, charges: '1842', refunds: '41', disputes: '3' },
150
+ });
151
+ }
152
+ const check = await verifyStripeKey(db, key);
153
+ if (!check.ok) {
154
+ setConnection(db, 'stripe', 'error', null, check.error);
155
+ return err(res, 400, `Stripe key verification failed: ${check.error}`);
156
+ }
157
+ secrets.set(STRIPE_SECRET_NAME, key);
158
+ setConnection(db, 'stripe', 'connected', { keyType: check.keyType, accountHint: check.accountHint });
159
+ audit(db, 'human', 'connection_added', { detail: { id: 'stripe', keyType: check.keyType } });
160
+ writeConnectionsManifest(db, secrets);
161
+ setStage(db, 'stripe', 'done');
162
+ let inventory = null;
163
+ try {
164
+ inventory = await stripeInventory(db, secrets);
165
+ }
166
+ catch {
167
+ // counts are a nicety; connect already succeeded
168
+ }
169
+ res.json({ ok: true, key_type: check.keyType, warning: check.warning ?? null, inventory });
170
+ });
171
+ r.post('/connections/mailbox', async (req, res) => {
172
+ const { address, password } = req.body ?? {};
173
+ if (typeof address !== 'string' || !address.includes('@') || typeof password !== 'string' || !password) {
174
+ return err(res, 400, 'address and password required');
175
+ }
176
+ const cfg = {
177
+ ...gmailDefaults(address),
178
+ ...(req.body.imapHost ? { imapHost: String(req.body.imapHost) } : {}),
179
+ ...(req.body.imapPort ? { imapPort: Number(req.body.imapPort) } : {}),
180
+ ...(req.body.smtpHost ? { smtpHost: String(req.body.smtpHost) } : {}),
181
+ ...(req.body.smtpPort ? { smtpPort: Number(req.body.smtpPort) } : {}),
182
+ ...(req.body.displayName ? { displayName: String(req.body.displayName) } : {}),
183
+ };
184
+ if (ctx.demo) {
185
+ setConnection(db, 'mailbox', 'connected', {
186
+ ...cfg,
187
+ address: 'support@demo.app',
188
+ });
189
+ setStage(db, 'mailbox', 'done');
190
+ return res.json({
191
+ ok: true,
192
+ address: 'support@demo.app',
193
+ inventory: { inboxMessages: 1284, sentMessages: 312, sentFolder: '[Demo]/Sent' },
194
+ });
195
+ }
196
+ secrets.set(MAILBOX_SECRET_NAME, password);
197
+ const check = await verifyMailbox(db, secrets, cfg);
198
+ if (!check.ok) {
199
+ secrets.delete(MAILBOX_SECRET_NAME);
200
+ setConnection(db, 'mailbox', 'error', null, check.error);
201
+ return err(res, 400, `mailbox login failed: ${check.error}`);
202
+ }
203
+ setConnection(db, 'mailbox', 'connected', cfg);
204
+ audit(db, 'human', 'connection_added', { detail: { id: 'mailbox', address } });
205
+ writeConnectionsManifest(db, secrets);
206
+ setStage(db, 'mailbox', 'done');
207
+ let inventory = null;
208
+ try {
209
+ inventory = await mailboxInventory(db, secrets, cfg);
210
+ }
211
+ catch {
212
+ // counts are a nicety; connect already succeeded
213
+ }
214
+ res.json({ ok: true, address, inventory });
215
+ });
216
+ r.post('/connections/github', async (req, res) => {
217
+ const { token, repo } = req.body ?? {};
218
+ if (typeof token !== 'string' || !token || typeof repo !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
219
+ return err(res, 400, 'token and repo ("owner/name") required');
220
+ }
221
+ secrets.set(GITHUB_SECRET_NAME, token);
222
+ const check = await verifyGithub(db, secrets, { repo });
223
+ if (!check.ok) {
224
+ secrets.delete(GITHUB_SECRET_NAME);
225
+ setConnection(db, 'github', 'error', null, check.error);
226
+ return err(res, 400, `GitHub verification failed: ${check.error}`);
227
+ }
228
+ setConnection(db, 'github', 'connected', { repo });
229
+ audit(db, 'human', 'connection_added', { detail: { id: 'github', repo } });
230
+ writeConnectionsManifest(db, secrets);
231
+ res.json({ ok: true, repo });
232
+ });
233
+ r.delete('/connections/:id', (req, res) => {
234
+ const id = req.params.id;
235
+ const secretName = { stripe: STRIPE_SECRET_NAME, mailbox: MAILBOX_SECRET_NAME, github: GITHUB_SECRET_NAME }[id];
236
+ if (!secretName)
237
+ return err(res, 404, 'unknown connection');
238
+ secrets.delete(secretName);
239
+ setConnection(db, id, 'disconnected', null);
240
+ audit(db, 'human', 'connection_removed', { detail: { id } });
241
+ writeConnectionsManifest(db, secrets);
242
+ res.json({ ok: true });
243
+ });
244
+ // -------------------------------------------------------------- cases --
245
+ r.get('/cases', (req, res) => {
246
+ const clauses = [];
247
+ const params = [];
248
+ if (req.query.status) {
249
+ clauses.push(`c.status = ?`);
250
+ params.push(String(req.query.status));
251
+ }
252
+ if (req.query.category) {
253
+ clauses.push(`c.category = ?`);
254
+ params.push(String(req.query.category));
255
+ }
256
+ if (req.query.search) {
257
+ // Metadata AND message bodies — "find the customer who mentioned X"
258
+ // must work even when X never made it into a subject or summary.
259
+ clauses.push(`(c.subject LIKE ? OR cu.email LIKE ? OR c.summary LIKE ?
260
+ OR EXISTS (SELECT 1 FROM emails e WHERE e.case_id = c.id AND e.body_text LIKE ?))`);
261
+ const like = `%${String(req.query.search)}%`;
262
+ params.push(like, like, like, like);
263
+ }
264
+ if (req.query.date_from) {
265
+ clauses.push(`c.created_at >= ?`);
266
+ params.push(String(req.query.date_from));
267
+ }
268
+ if (req.query.date_to) {
269
+ clauses.push(`c.created_at <= ?`);
270
+ params.push(String(req.query.date_to));
271
+ }
272
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
273
+ // money_first: cases owing refunds/cancels surface first — this is what
274
+ // makes "the queue is sorted money-first" true.
275
+ const ORDERINGS = {
276
+ updated: 'c.updated_at DESC',
277
+ oldest: 'c.created_at ASC',
278
+ money_first: 'pending_financial DESC, proposed_actions DESC, c.updated_at DESC',
279
+ };
280
+ const orderBy = ORDERINGS[String(req.query.sort ?? 'updated')] ?? ORDERINGS.updated;
281
+ const limit = Math.min(Number(req.query.limit) || 50, 200);
282
+ const offset = Number(req.query.offset) || 0;
283
+ const cases = db
284
+ .prepare(`SELECT c.*, cu.email AS customer_email, cu.name AS customer_name,
285
+ (SELECT COUNT(*) FROM actions a WHERE a.case_id = c.id AND a.status = 'proposed') AS proposed_actions,
286
+ (SELECT COUNT(*) FROM actions a WHERE a.case_id = c.id AND a.action_type IN ('refund','cancel_subscription')
287
+ AND a.status IN ('proposed','approved','executing','failed')) AS pending_financial
288
+ FROM cases c LEFT JOIN customers cu ON cu.id = c.customer_id
289
+ ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
290
+ .all(...params, limit, offset);
291
+ const total = db
292
+ .prepare(`SELECT COUNT(*) AS n FROM cases c LEFT JOIN customers cu ON cu.id = c.customer_id ${where}`)
293
+ .get(...params).n;
294
+ res.json({ cases, total });
295
+ });
296
+ r.get('/cases/:id', (req, res) => {
297
+ const id = Number(req.params.id);
298
+ const row = db
299
+ .prepare(`SELECT c.*, cu.email AS customer_email, cu.name AS customer_name
300
+ FROM cases c LEFT JOIN customers cu ON cu.id = c.customer_id WHERE c.id = ?`)
301
+ .get(id);
302
+ if (!row)
303
+ return err(res, 404, 'case not found');
304
+ const emails = db.prepare(`SELECT * FROM emails WHERE case_id = ? ORDER BY received_at`).all(id);
305
+ const draft = db
306
+ .prepare(`SELECT * FROM drafts WHERE case_id = ? AND is_current = 1 ORDER BY version DESC LIMIT 1`)
307
+ .get(id);
308
+ const actions = db
309
+ .prepare(`SELECT * FROM actions WHERE case_id = ? ORDER BY execution_order, id`)
310
+ .all(id);
311
+ let prepared_context = null;
312
+ try {
313
+ prepared_context = row.prepared_context_json ? JSON.parse(String(row.prepared_context_json)) : null;
314
+ }
315
+ catch {
316
+ prepared_context = null;
317
+ }
318
+ res.json({ ...row, prepared_context, emails, draft: draft ?? null, actions });
319
+ });
320
+ r.post('/cases', (req, res) => {
321
+ const { email, subject, body } = req.body ?? {};
322
+ if (typeof email !== 'string' || !email.includes('@'))
323
+ return err(res, 400, 'email required');
324
+ db.prepare(`INSERT INTO customers (email, created_at, updated_at) VALUES (?, ?, ?)
325
+ ON CONFLICT(email) DO NOTHING`).run(email, now(), now());
326
+ const customerId = db.prepare(`SELECT id FROM customers WHERE email = ?`).get(email).id;
327
+ const caseId = Number(db
328
+ .prepare(`INSERT INTO cases (customer_id, source, conversation_key, subject, status, created_at, updated_at)
329
+ VALUES (?, 'manual', ?, ?, 'new', ?, ?)`)
330
+ .run(customerId, `manual-${Date.now()}`, subject ?? '(no subject)', now(), now()).lastInsertRowid);
331
+ if (typeof body === 'string' && body) {
332
+ db.prepare(`INSERT INTO emails (case_id, direction, from_addr, subject, body_text, received_at, created_at)
333
+ VALUES (?, 'inbound', ?, ?, ?, ?, ?)`).run(caseId, email, subject ?? null, body, now(), now());
334
+ }
335
+ audit(db, 'human', 'case_created', { caseId });
336
+ res.json({ id: caseId });
337
+ });
338
+ r.patch('/cases/:id', (req, res) => {
339
+ const id = Number(req.params.id);
340
+ const allowed = ['category', 'priority', 'summary', 'language'];
341
+ const sets = [];
342
+ const params = [];
343
+ for (const key of allowed) {
344
+ if (req.body?.[key] !== undefined) {
345
+ sets.push(`${key} = ?`);
346
+ params.push(req.body[key]);
347
+ }
348
+ }
349
+ if (!sets.length)
350
+ return err(res, 400, 'nothing to update');
351
+ const result = db
352
+ .prepare(`UPDATE cases SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`)
353
+ .run(...params, now(), id);
354
+ if (result.changes === 0)
355
+ return err(res, 404, 'case not found');
356
+ claimCaseIfNew(db, id);
357
+ res.json({ ok: true });
358
+ });
359
+ r.post('/cases/:id/status', (req, res) => {
360
+ const id = Number(req.params.id);
361
+ const status = String(req.body?.status ?? '');
362
+ if (!['new', 'processed', 'reviewing', 'pending', 'resolved', 'escalated', 'ignored'].includes(status)) {
363
+ return err(res, 400, `invalid status "${status}"`);
364
+ }
365
+ // The blocking resolve-guard, on every status-change path.
366
+ if (status === 'resolved' && !req.body?.allow_pending_financial) {
367
+ const pending = countPendingFinancialActions(db, id);
368
+ if (pending > 0) {
369
+ return err(res, 409, 'cannot resolve: financial actions not yet executed', {
370
+ code: 'pending_financial_actions',
371
+ pending_financial: pending,
372
+ });
373
+ }
374
+ }
375
+ setCaseStatus(db, id, status);
376
+ audit(db, 'human', 'case_status_changed', { caseId: id, detail: { status } });
377
+ res.json({ ok: true, status });
378
+ });
379
+ r.post('/cases/:id/enrich', async (req, res) => {
380
+ const id = Number(req.params.id);
381
+ if (ctx.demo)
382
+ return res.json({ ok: true, note: 'demo mode — context is pre-seeded' });
383
+ try {
384
+ await enrichCaseFromStripe(db, secrets, id);
385
+ res.json({ ok: true });
386
+ }
387
+ catch (e) {
388
+ err(res, 502, `enrichment failed: ${e instanceof Error ? e.message : String(e)}`);
389
+ }
390
+ });
391
+ r.patch('/cases/:id/context', (req, res) => {
392
+ const id = Number(req.params.id);
393
+ const row = db.prepare(`SELECT prepared_context_json FROM cases WHERE id = ?`).get(id);
394
+ if (!row)
395
+ return err(res, 404, 'case not found');
396
+ // Trust boundary: the `stripe` slice is server-owned (written only by the
397
+ // enricher). LLM-supplied money data is radioactive; agents may add
398
+ // qualitative slices only.
399
+ const allowed = ['customer_history', 'site_facts', 'notes', 'bug_repro'];
400
+ let ctxJson = {};
401
+ try {
402
+ ctxJson = row.prepared_context_json ? JSON.parse(row.prepared_context_json) : {};
403
+ }
404
+ catch {
405
+ ctxJson = {};
406
+ }
407
+ const rejected = [];
408
+ for (const [key, value] of Object.entries(req.body ?? {})) {
409
+ if (allowed.includes(key))
410
+ ctxJson[key] = value;
411
+ else
412
+ rejected.push(key);
413
+ }
414
+ db.prepare(`UPDATE cases SET prepared_context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(ctxJson), now(), id);
415
+ claimCaseIfNew(db, id);
416
+ res.json({ ok: true, rejected_keys: rejected });
417
+ });
418
+ r.post('/cases/:id/update-draft', (req, res) => {
419
+ const id = Number(req.params.id);
420
+ const { body_english, body_translated, target_language } = req.body ?? {};
421
+ if (typeof body_english !== 'string' || !body_english.trim()) {
422
+ return err(res, 400, 'body_english required');
423
+ }
424
+ const createdBy = req.body?.created_by === 'agent' ? 'agent' : 'human';
425
+ const prev = db
426
+ .prepare(`SELECT * FROM drafts WHERE case_id = ? AND is_current = 1 ORDER BY version DESC LIMIT 1`)
427
+ .get(id);
428
+ db.prepare(`UPDATE drafts SET is_current = 0 WHERE case_id = ?`).run(id);
429
+ db.prepare(`INSERT INTO drafts (case_id, version, body_english, body_translated, target_language, is_current, created_by, created_at)
430
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?)`).run(id, (prev?.version ?? 0) + 1, body_english, body_translated ?? null, target_language ?? null, createdBy, now());
431
+ claimCaseIfNew(db, id);
432
+ // The learning loop: a human replacing an agent draft is a labeled example.
433
+ if (createdBy === 'human' && prev?.created_by === 'agent' && prev.body_english !== body_english) {
434
+ db.prepare(`INSERT INTO feedback_events (case_id, event_type, original_json, modified_json, created_at)
435
+ VALUES (?, 'draft_edit', ?, ?, ?)`).run(id, JSON.stringify({ body: prev.body_english }), JSON.stringify({ body: body_english }), now());
436
+ }
437
+ res.json({ ok: true, warnings: lintDraft(body_english) });
438
+ });
439
+ // ------------------------------------------------------------- actions --
440
+ r.post('/cases/:id/actions', (req, res) => {
441
+ const id = Number(req.params.id);
442
+ const actionType = String(req.body?.action_type ?? '');
443
+ const rawProposal = req.body?.proposal;
444
+ if (!ACTION_TYPES.includes(actionType)) {
445
+ return err(res, 400, `unknown action_type "${actionType}"`);
446
+ }
447
+ if (typeof rawProposal !== 'object' || rawProposal === null) {
448
+ return err(res, 400, 'proposal object required');
449
+ }
450
+ if (!db.prepare(`SELECT 1 FROM cases WHERE id = ?`).get(id))
451
+ return err(res, 404, 'case not found');
452
+ // Anti-hallucination: strip invalid Stripe ids so the executor re-resolves
453
+ // from trusted data instead of acting on a made-up identifier.
454
+ const proposal = { ...rawProposal };
455
+ if (typeof proposal.subscription_id === 'string' && !isValidStripeId(proposal.subscription_id, 'sub_')) {
456
+ delete proposal.subscription_id;
457
+ }
458
+ if (typeof proposal.charge_id === 'string' &&
459
+ !isValidStripeId(proposal.charge_id, 'ch_') &&
460
+ !isValidStripeId(proposal.charge_id, 'py_') &&
461
+ !isValidStripeId(proposal.charge_id, 'sub_') // sub-in-charge rescue happens in the executor
462
+ ) {
463
+ delete proposal.charge_id;
464
+ }
465
+ // Single-active-subscription resolution from the server-owned context.
466
+ if (actionType === 'cancel_subscription' && !proposal.subscription_id) {
467
+ const ctxRow = db.prepare(`SELECT prepared_context_json FROM cases WHERE id = ?`).get(id);
468
+ try {
469
+ const pctx = ctxRow.prepared_context_json ? JSON.parse(ctxRow.prepared_context_json) : {};
470
+ const subs = (pctx.stripe?.customers ?? []).flatMap((c) => c.subscriptions ?? []);
471
+ const active = subs.filter((s) => ['active', 'trialing', 'past_due'].includes(s.status));
472
+ if (active.length === 1)
473
+ proposal.subscription_id = active[0].id;
474
+ }
475
+ catch {
476
+ // leave unresolved; lint/executor will handle
477
+ }
478
+ }
479
+ const lint = lintAndNormalizeAction(actionType, proposal);
480
+ if (lint.errors.length > 0) {
481
+ return err(res, 400, 'proposal failed lint', { details: lint.errors });
482
+ }
483
+ const order = typeof req.body?.execution_order === 'number'
484
+ ? req.body.execution_order
485
+ : DEFAULT_EXECUTION_ORDER[actionType];
486
+ const inserted = db
487
+ .prepare(`INSERT INTO actions (case_id, action_type, status, execution_order, proposal_json, confidence, policy_matched, created_at)
488
+ VALUES (?, ?, 'proposed', ?, ?, ?, ?, ?)`)
489
+ .run(id, actionType, order, JSON.stringify(lint.proposal), typeof req.body?.confidence === 'number' ? req.body.confidence : null, req.body?.policy_matched ?? null, now());
490
+ const actionId = Number(inserted.lastInsertRowid);
491
+ claimCaseIfNew(db, id);
492
+ const superseded = softSupersedeProposals(db, id, actionType, actionId, 'replaced by newer proposal');
493
+ audit(db, 'agent', 'action_proposed', { caseId: id, actionId, detail: { actionType, superseded } });
494
+ const action = db.prepare(`SELECT * FROM actions WHERE id = ?`).get(actionId);
495
+ res.json({ action, warnings: lint.warnings, superseded });
496
+ });
497
+ r.post('/cases/:id/actions/:actionId/reject', (req, res) => {
498
+ const ok = rejectAction(db, Number(req.params.actionId), req.body?.reason);
499
+ if (!ok)
500
+ return err(res, 404, 'action not found');
501
+ res.json({ ok: true });
502
+ });
503
+ // THE ONLY MONEY PATH.
504
+ r.post('/cases/:id/actions/execute', async (req, res) => {
505
+ if (!requireExecuteToken(req, res))
506
+ return;
507
+ const id = Number(req.params.id);
508
+ const actionIds = Array.isArray(req.body?.actionIds) ? req.body.actionIds.map(Number) : [];
509
+ if (actionIds.length === 0)
510
+ return err(res, 400, 'actionIds required');
511
+ const result = await approveAndExecuteActions(ctx.approval, id, actionIds, {
512
+ perActionParams: req.body?.perActionParams,
513
+ sendMode: req.body?.sendMode,
514
+ allowPendingFinancial: Boolean(req.body?.allow_pending_financial),
515
+ approvedBy: 'human',
516
+ });
517
+ const caseRow = db.prepare(`SELECT * FROM cases WHERE id = ?`).get(id);
518
+ res.json({
519
+ success: result.lintFailures.length === 0 && result.results.every((x) => x.success),
520
+ approved_action_ids: result.approvedIds,
521
+ lint_failures: result.lintFailures,
522
+ results: result.results,
523
+ case: caseRow,
524
+ });
525
+ });
526
+ r.post('/cases/:id/send-reply', async (req, res) => {
527
+ const id = Number(req.params.id);
528
+ const body = typeof req.body?.body === 'string' ? req.body.body : '';
529
+ if (!body.trim())
530
+ return err(res, 400, 'body required');
531
+ // Pre-send guard (one half of the Incident-C defense): a reply is exactly
532
+ // how a customer gets told "you're refunded" while nothing ran.
533
+ const pending = countPendingFinancialActions(db, id);
534
+ if (pending > 0 && !req.body?.allow_pending_financial) {
535
+ return err(res, 409, 'refusing to send: financial actions not yet executed', {
536
+ code: 'pending_financial_actions',
537
+ pending_financial: pending,
538
+ });
539
+ }
540
+ const caseRow = db
541
+ .prepare(`SELECT c.id, cu.email FROM cases c LEFT JOIN customers cu ON cu.id = c.customer_id WHERE c.id = ?`)
542
+ .get(id);
543
+ if (!caseRow?.email)
544
+ return err(res, 404, 'case not found or has no customer email');
545
+ try {
546
+ if (ctx.demo) {
547
+ db.prepare(`INSERT INTO emails (case_id, direction, to_addr, body_text, received_at, created_at)
548
+ VALUES (?, 'outbound', ?, ?, ?, ?)`).run(id, caseRow.email, body, now(), now());
549
+ }
550
+ else {
551
+ const cfg = mailboxConfig(db);
552
+ if (!cfg)
553
+ return err(res, 502, 'mailbox not connected', { stage: 'smtp_send' });
554
+ const sent = await makeMailer(db, secrets, cfg).sendReply({
555
+ caseId: id,
556
+ to: caseRow.email,
557
+ body,
558
+ ref: `manual-send:case:${id}`,
559
+ });
560
+ db.prepare(`INSERT INTO emails (case_id, direction, to_addr, body_text, provider_message_id, received_at, created_at)
561
+ VALUES (?, 'outbound', ?, ?, ?, ?, ?)`).run(id, caseRow.email, body, sent.providerMessageId, now(), now());
562
+ }
563
+ // Manual send marks pending send_reply proposals executed — but NEVER
564
+ // executes financial actions (the other half of the defense).
565
+ db.prepare(`UPDATE actions SET status = 'executed', executed_at = ?
566
+ WHERE case_id = ? AND action_type = 'send_reply' AND status IN ('proposed','approved')`).run(now(), id);
567
+ audit(db, 'human', 'manual_reply_sent', { caseId: id });
568
+ const stillPending = countPendingFinancialActions(db, id);
569
+ res.json({
570
+ success: true,
571
+ warning: stillPending > 0
572
+ ? `reply sent but ${stillPending} financial action(s) still pending on this case`
573
+ : null,
574
+ });
575
+ }
576
+ catch (e) {
577
+ err(res, 502, `send failed: ${e instanceof Error ? e.message : String(e)}`, { stage: 'smtp_send' });
578
+ }
579
+ });
580
+ // ------------------------------------------------------------ mailbox --
581
+ r.post('/sync', async (_req, res) => {
582
+ if (ctx.demo)
583
+ return res.json({ ok: true, note: 'demo mode — inbox is pre-seeded', scanned: 0, ingested: 0 });
584
+ const cfg = mailboxConfig(db);
585
+ if (!cfg)
586
+ return err(res, 400, 'mailbox not connected');
587
+ try {
588
+ const { syncInbox } = await import('../connectors/mailbox.js');
589
+ const result = await syncInbox(db, secrets, cfg);
590
+ res.json({ ok: true, ...result });
591
+ }
592
+ catch (e) {
593
+ err(res, 502, `sync failed: ${e instanceof Error ? e.message : String(e)}`);
594
+ }
595
+ });
596
+ // ------------------------------------------------- onboarding extract --
597
+ r.post('/extract/sent-samples', async (req, res) => {
598
+ if (ctx.demo)
599
+ return err(res, 400, 'not available in demo mode');
600
+ const cfg = mailboxConfig(db);
601
+ if (!cfg)
602
+ return err(res, 400, 'mailbox not connected');
603
+ try {
604
+ const samples = await fetchSentSamples(db, secrets, cfg, {
605
+ limit: Math.min(Number(req.body?.limit) || 200, 500),
606
+ sinceDays: Math.min(Number(req.body?.sinceDays) || 90, 365),
607
+ });
608
+ res.json({ count: samples.length, samples });
609
+ }
610
+ catch (e) {
611
+ err(res, 502, `extraction failed: ${e instanceof Error ? e.message : String(e)}`);
612
+ }
613
+ });
614
+ r.post('/extract/stripe-summary', async (_req, res) => {
615
+ if (ctx.demo)
616
+ return err(res, 400, 'not available in demo mode');
617
+ try {
618
+ res.json(await stripeBusinessSummary(db, secrets));
619
+ }
620
+ catch (e) {
621
+ err(res, 502, `extraction failed: ${e instanceof Error ? e.message : String(e)}`);
622
+ }
623
+ });
624
+ r.post('/extract/crawl', async (req, res) => {
625
+ const urls = Array.isArray(req.body?.urls) ? req.body.urls.map(String) : [];
626
+ if (urls.length === 0)
627
+ return err(res, 400, 'urls required');
628
+ res.json({ pages: await crawlPages(db, urls) });
629
+ });
630
+ // -------------------------------------------------------- calibration --
631
+ r.get('/calibrations', (_req, res) => {
632
+ res.json({ calibrations: db.prepare(`SELECT * FROM calibrations ORDER BY id`).all() });
633
+ });
634
+ r.post('/calibrations', (req, res) => {
635
+ const { customer_message, clearhand_draft, actual_reply, context_note } = req.body ?? {};
636
+ if (typeof customer_message !== 'string' || typeof clearhand_draft !== 'string') {
637
+ return err(res, 400, 'customer_message and clearhand_draft required');
638
+ }
639
+ const inserted = db
640
+ .prepare(`INSERT INTO calibrations (customer_message, context_note, clearhand_draft, actual_reply, created_at)
641
+ VALUES (?, ?, ?, ?, ?)`)
642
+ .run(customer_message, context_note ?? null, clearhand_draft, actual_reply ?? null, now());
643
+ res.json({ id: Number(inserted.lastInsertRowid) });
644
+ });
645
+ r.post('/calibrations/:id/review', (req, res) => {
646
+ const id = Number(req.params.id);
647
+ const verdict = String(req.body?.verdict ?? '');
648
+ if (!['fine', 'edited', 'wrong_policy', 'wrong_facts', 'wrong_tone'].includes(verdict)) {
649
+ return err(res, 400, 'verdict must be one of fine|edited|wrong_policy|wrong_facts|wrong_tone');
650
+ }
651
+ const result = db
652
+ .prepare(`UPDATE calibrations SET verdict = ?, tags = ?, note = ?, reviewed_at = ? WHERE id = ?`)
653
+ .run(verdict, JSON.stringify(req.body?.tags ?? []), req.body?.note ?? null, now(), id);
654
+ if (result.changes === 0)
655
+ return err(res, 404, 'calibration not found');
656
+ // Every calibration verdict is learning-loop input.
657
+ db.prepare(`INSERT INTO feedback_events (event_type, original_json, diff_summary, created_at)
658
+ VALUES ('calibration', ?, ?, ?)`).run(JSON.stringify({ calibration_id: id, verdict }), req.body?.note ?? null, now());
659
+ res.json({ ok: true });
660
+ });
661
+ // ------------------------------------------------- overlay & learning --
662
+ r.get('/overlay', (_req, res) => res.json({ files: overlayStatus() }));
663
+ r.get('/overlay/:name', (req, res) => {
664
+ const content = readOverlayFile(req.params.name);
665
+ if (content === null)
666
+ return err(res, 404, 'overlay file not found');
667
+ res.json({ name: req.params.name, content });
668
+ });
669
+ r.get('/feedback', (_req, res) => {
670
+ res.json({
671
+ events: db.prepare(`SELECT * FROM feedback_events ORDER BY id DESC LIMIT 200`).all(),
672
+ });
673
+ });
674
+ r.get('/learning-reviews', (_req, res) => {
675
+ res.json({ reviews: db.prepare(`SELECT * FROM learning_reviews ORDER BY id DESC LIMIT 100`).all() });
676
+ });
677
+ r.post('/learning-reviews', (req, res) => {
678
+ const { case_id, category, proposed_rule, target_file, target_section } = req.body ?? {};
679
+ if (typeof proposed_rule !== 'string' || typeof target_file !== 'string') {
680
+ return err(res, 400, 'proposed_rule and target_file required');
681
+ }
682
+ const inserted = db
683
+ .prepare(`INSERT INTO learning_reviews (case_id, category, proposed_rule, target_file, target_section, created_at)
684
+ VALUES (?, ?, ?, ?, ?, ?)`)
685
+ .run(case_id ?? null, category ?? null, proposed_rule, target_file, target_section ?? null, now());
686
+ res.json({ id: Number(inserted.lastInsertRowid) });
687
+ });
688
+ r.post('/learning-reviews/:id/resolve', (req, res) => {
689
+ const status = String(req.body?.status ?? '');
690
+ // Human gate: proposed -> approved|rejected (dashboard). Agent side:
691
+ // approved -> applied, only after the overlay edit actually landed.
692
+ if (!['approved', 'rejected', 'applied'].includes(status)) {
693
+ return err(res, 400, 'status must be approved|rejected|applied');
694
+ }
695
+ const from = status === 'applied' ? 'approved' : 'proposed';
696
+ const result = db
697
+ .prepare(`UPDATE learning_reviews SET status = ?, resolved_at = ? WHERE id = ? AND status = ?`)
698
+ .run(status, now(), Number(req.params.id), from);
699
+ if (result.changes === 0)
700
+ return err(res, 404, `review not found or not in '${from}' state`);
701
+ res.json({ ok: true });
702
+ });
703
+ return r;
704
+ }