@arcforgelabs/flow 2026.11.2 → 2026.11.4

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 (49) hide show
  1. package/SECURITY.md +12 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +119 -3
  6. package/server/app.js +3 -2
  7. package/server/auth/routes.js +20 -0
  8. package/server/auth/session.js +44 -1
  9. package/server/auth/settings.js +27 -0
  10. package/server/core/alerts.js +38 -3
  11. package/server/core/automations.js +8 -3
  12. package/server/core/invoice-rules.js +158 -8
  13. package/server/core/invoices.js +9 -1
  14. package/server/core/invoicing-rules.js +31 -6
  15. package/server/core/job-board.js +16 -1
  16. package/server/core/job-match.js +6 -4
  17. package/server/core/ledger.js +67 -8
  18. package/server/core/pipeline.js +6 -2
  19. package/server/core/reconcile.js +11 -3
  20. package/server/core/sending.js +30 -3
  21. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  22. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  23. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  24. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +21 -0
  27. package/server/integrations/seazona/feed.js +10 -3
  28. package/server/integrations/seazona/index.js +6 -1
  29. package/server/integrations/seazona/routes.js +65 -2
  30. package/server/integrations/seazona/settings.js +3 -0
  31. package/server/integrations/xero/drafts.js +1 -1
  32. package/server/integrations/xero/index.js +1 -1
  33. package/server/integrations/xero/ledger.js +17 -8
  34. package/server/integrations/xero/links.js +39 -1
  35. package/server/integrations/xero/lookup.js +3 -1
  36. package/server/integrations/xero/routes.js +18 -1
  37. package/server/ui/app-page.js +1 -1
  38. package/server/ui/app.js +7 -4
  39. package/server/ui/flow.css +69 -1
  40. package/server/ui/home.js +120 -0
  41. package/server/ui/invoice-rules.js +11 -3
  42. package/server/ui/invoicing.js +81 -6
  43. package/server/ui/job-board.js +8 -3
  44. package/server/ui/jobs.js +14 -2
  45. package/server/ui/nav-pins.js +5 -3
  46. package/server/ui/reconcile.js +4 -2
  47. package/server/ui/routes.js +1 -1
  48. package/skills/flow/SKILL.md +306 -0
  49. package/skills/flow/flow.sh +121 -0
package/SECURITY.md CHANGED
@@ -31,6 +31,18 @@ paste a credential, in full or in part.
31
31
  person/client/organisation read grant. The first slice exposes four bounded read tools
32
32
  (`docs/AGENT_ACCESS.md`). The reserved `/api/flow/v1` stub still refuses all
33
33
  requests. It does not implement connector keys.
34
+ - **AI assistant key.** Also off by default. When the host sets
35
+ `FLOW_AGENT_KEY_SHA256` and `FLOW_AGENT_EMAIL` (and at most one more pair,
36
+ `FLOW_AGENT_KEY_SHA256_2` and `FLOW_AGENT_EMAIL_2`), each bearer key acts as its own
37
+ Flow person, inside that person's role, on `/api/me`, `/api/invoicing/…`,
38
+ `/api/xero/…` (never connect or disconnect), `GET /api/seazona/api/…` and the Seazona history import `/api/seazona/history…` only (`docs/AGENT_ACCESS.md`, "AI assistant key").
39
+ With the admin role that includes invoice writes: rules, void, delete draft
40
+ and send to the accounting system. Flow stores only the key's SHA-256; the key
41
+ stays with the assistant. Owner actions always refuse the key. Turning the
42
+ person off stops the key on the next request and stops sends it queued. The
43
+ Seazona read answers what Seazona gives, patient fields included; the owner
44
+ can turn on "Hide patient details" to drop them. Use an assistant whose model
45
+ runs locally or with zero data retention.
34
46
  - **Integrations.** Seazona, Xero and every later adapter reach the core only
35
47
  through the integration port. Their credentials are stored per
36
48
  organisation, encrypted, and are write-only in Settings.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.2",
3
+ "version": "2026.11.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.11.2",
9
+ "version": "2026.11.4",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.2",
3
+ "version": "2026.11.4",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "files": [
22
22
  "server/",
23
+ "skills/",
23
24
  "SECURITY.md",
24
25
  "VISION.md",
25
26
  "npm-shrinkwrap.json"
@@ -6,7 +6,21 @@ import { agentLimits } from './settings.js';
6
6
  export function createAgentGate({ pool, verify, resource, limits: input, log = console }) {
7
7
  const limits = agentLimits(input);
8
8
  let active = 0, pruneAfter = 0;
9
- const running = new Map();
9
+ const running = new Map(), waiting = new Set();
10
+ const capacity = () => Math.min(limits.fleet, Math.max(0,(pool.options?.max ?? 10)-1));
11
+ // A lease stays held through the audit after a response. A call that comes
12
+ // while a lease is only auditing waits up to a second for it, then is refused.
13
+ const lease = async () => {
14
+ const until = Date.now() + 1000;
15
+ while (active >= capacity()) {
16
+ const left = until - Date.now();
17
+ if (left <= 0) return false;
18
+ await new Promise(resolve => { const timer = setTimeout(done, left); function done() { clearTimeout(timer); waiting.delete(done); resolve(); } waiting.add(done); });
19
+ }
20
+ active++;
21
+ return true;
22
+ };
23
+ const unlease = () => { active--; for (const wake of [...waiting]) wake(); };
10
24
  const fail = (res, status, code) => {
11
25
  if (res.writableEnded || res.destroyed) return;
12
26
  if ([429,503].includes(status) && !res.hasHeader('Retry-After')) res.set('Retry-After', '60');
@@ -35,8 +49,7 @@ export function createAgentGate({ pool, verify, resource, limits: input, log = c
35
49
  }
36
50
  }
37
51
  const handle = handler => async (req,res) => {
38
- if (active >= Math.min(limits.fleet, Math.max(0,(pool.options?.max ?? 10)-1))) return fail(res,429,'agent_capacity');
39
- active++;
52
+ if (!await lease()) return fail(res,429,'agent_capacity');
40
53
  const started = Date.now(), controller = new AbortController();
41
54
  let client, acquiring, grant, ownConcurrency = false, status = 200, count = 0, tool = 'protocol', expired = false;
42
55
  // pg.Pool has no per-acquisition cancellation. Bound only the MCP wait,
@@ -104,6 +117,14 @@ export function createAgentGate({ pool, verify, resource, limits: input, log = c
104
117
  check();
105
118
  await handler(req,res);
106
119
  })();
120
+ // The grant's slot covers its work, not the audit after the response: a
121
+ // client that sends its next call at once must not meet its own last call.
122
+ const freed = () => {
123
+ if (!ownConcurrency) return;
124
+ ownConcurrency = false;
125
+ const n = running.get(grant.id) - 1; n ? running.set(grant.id,n) : running.delete(grant.id);
126
+ };
127
+ work.then(freed, freed);
107
128
  try { await Promise.race([work,deadline]); }
108
129
  catch { if (!expired) { status=503; fail(res,503,'agent_unavailable'); } }
109
130
  finally {
@@ -122,8 +143,7 @@ export function createAgentGate({ pool, verify, resource, limits: input, log = c
122
143
  }
123
144
  } catch { if (client) { client.release(true); client=undefined; } log.warn?.('[mcp] audit unavailable'); }
124
145
  await Promise.allSettled([acquiring]);
125
- if (ownConcurrency) { const n=running.get(grant.id)-1; n ? running.set(grant.id,n) : running.delete(grant.id); }
126
- active--;
146
+ unlease();
127
147
  }
128
148
  };
129
149
  return { handle, fail, limits };
@@ -4,13 +4,14 @@ import { invoiceKindOf, invoicePage, INVOICE_SORTS, normaliseInvoicesView } from
4
4
  import { fieldIdOf, filterRows } from '../ui/job-view.js';
5
5
  import { listFields, listJobFieldValues } from '../core/custom-fields.js';
6
6
  import { FlowError } from '../core/errors.js';
7
+ import { listReview } from '../core/review.js';
7
8
  import { createActionGuard } from '../integrations/call-guard.js';
8
9
  import {
9
10
  createDraftInvoice, deleteDraft, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
10
11
  } from '../core/invoices.js';
11
12
  import { listAutomations, saveAutomation, workStatuses } from '../core/automations.js';
12
13
  import {
13
- ACTIONS, createRule, deleteRule, FIELDS, listRules, TESTS, updateRule,
14
+ ACTIONS, addRuleItem, createRule, deleteRule, exceptDraft, exceptJob, FIELDS, jobRuleExceptions, listRuleExceptions, listRules, removeRuleException, TESTS, updateRule,
14
15
  } from '../core/invoice-rules.js';
15
16
  import {
16
17
  createMapping, deleteMapping, listMappings, STEP_LABELS, STEPS, updateMapping,
@@ -27,7 +28,7 @@ import {
27
28
  acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, linkPriceItems, MAX_LINKS_AT_ONCE, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
28
29
  } from '../core/price-list.js';
29
30
  import {
30
- confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
31
+ confirmNoInvoice, draftInvoiceForObligation, freeVoidedJob, setHold, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
31
32
  setReady, settleJobChange, useSourceLines,
32
33
  } from '../core/ledger.js';
33
34
  import { isHistoryHeld } from '../core/history.js';
@@ -99,6 +100,8 @@ import {
99
100
  // PUT /contacts/:id/billing owner or admin: change them (null takes the organisation's)
100
101
  // POST /jobs/:id/ready { ready }: mark a job ready to invoice, or back in work
101
102
  // GET /jobs/:id/activity what happened to a job, newest first
103
+ // POST /jobs/:id/hold owner or admin: { state: none|held|review, reason }
104
+ // POST /jobs/:id/free owner or admin: { reason }: let go of a void invoice, then hold
102
105
  // POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
103
106
  // check that the accounting system holds none
104
107
  //
@@ -380,12 +383,34 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
380
383
  : null,
381
384
  contact: contact ?? null,
382
385
  job: withLink(jobs.get(invoiceId) ?? null, work),
386
+ // Lines this invoice has and its job in the work system does not (a
387
+ // rule's added shipping, for example). Flow cannot write lines to the
388
+ // work system, so the page shows the gap until the work system has them.
389
+ sourceGap: await sourceGap(db, organisationId, invoice),
390
+ // The exceptions people gave this invoice's job, current or ended.
391
+ ruleExceptions: await exceptionsOfInvoice(db, organisationId, invoice),
383
392
  // The Linked card: each connected system, and this invoice's link there.
384
393
  linksTo: linkedSystems(work, accounting),
385
394
  versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
386
395
  };
387
396
  }
388
397
 
398
+ async function exceptionsOfInvoice(db, organisationId, invoice) {
399
+ const [job] = await db.select({ id: billingObligations.id }).from(billingObligations)
400
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)));
401
+ return job ? jobRuleExceptions(db, organisationId, job.id) : [];
402
+ }
403
+
404
+ async function sourceGap(db, organisationId, invoice) {
405
+ if (invoice.status === 'void') return [];
406
+ const [job] = await db.select({ lines: billingObligations.lines }).from(billingObligations)
407
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)));
408
+ if (!job) return [];
409
+ const inSource = new Set((job.lines ?? []).map((l) => l.priceItemId).filter(Boolean));
410
+ return invoice.lines.filter((l) => l.priceItemId && !inSource.has(l.priceItemId))
411
+ .map((l) => ({ description: l.description, quantity: l.quantity, unitAmountCents: l.unitAmountCents }));
412
+ }
413
+
389
414
  // A person's Recheck reads a job from its work system at most once in this
390
415
  // time; a second click inside it answers from Flow's copy.
391
416
  const RECHECK_GAP_MS = 10 * 1000;
@@ -825,6 +850,23 @@ export function createInvoicingRouter({
825
850
  res.json({ ok: true, ...await listReconcile(db, organisationId) });
826
851
  }));
827
852
 
853
+ // The open review queue (core/review.js): daily reconcile days, job
854
+ // changes, links Flow could not prove, failed writes. Read only: each item
855
+ // closes when its cause is fixed (or by the screen that owns it).
856
+ router.get('/review', ...signedIn, handle(log, async (req, res) => {
857
+ const organisationId = await organisationOf(req);
858
+ const state = text(req.query.state) || 'open';
859
+ if (!['open', 'resolved', 'dismissed'].includes(state)) throw new FlowError('invalid', 'state must be open, resolved or dismissed');
860
+ const kind = text(req.query.kind) || undefined;
861
+ const limit = Math.min(Math.max(Number.parseInt(text(req.query.limit) || '100', 10) || 100, 1), 500);
862
+ const items = await listReview(db, organisationId, { state, kind, limit });
863
+ res.json({ ok: true, items: items.map((r) => ({
864
+ id: r.id, kind: r.kind, entity: r.entity, flowId: r.flowId, systems: r.systems, evidence: r.evidence,
865
+ moneyAtRiskCents: r.moneyAtRiskCents, seenCount: r.seenCount, state: r.state, resolution: r.resolution,
866
+ createdAt: r.createdAt, updatedAt: r.updatedAt,
867
+ })) });
868
+ }));
869
+
828
870
  router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
829
871
  const obligationId = pathId(req.params.id, 'job');
830
872
  const organisationId = await organisationOf(req);
@@ -848,10 +890,39 @@ export function createInvoicingRouter({
848
890
  router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
849
891
  const obligationId = pathId(req.params.id, 'job');
850
892
  const organisationId = await organisationOf(req);
851
- const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
893
+ const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}`, system: accounting()?.id ?? null });
852
894
  res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
853
895
  }));
854
896
 
897
+ // Holds a job, sends it to review, or releases it (state "none"). Every
898
+ // change needs a reason, kept in the job's activity.
899
+ router.post('/jobs/:id/hold', ...manager, handle(log, async (req, res) => {
900
+ const obligationId = pathId(req.params.id, 'job');
901
+ const organisationId = await organisationOf(req);
902
+ const state = text(req.body?.state);
903
+ const reason = text(req.body?.reason);
904
+ if (!['none', 'held', 'review'].includes(state)) throw invalid('state must be none, held or review');
905
+ if (!reason) throw invalid('a reason is required');
906
+ const [current] = await db.select({ holdState: billingObligations.holdState, holdReason: billingObligations.holdReason }).from(billingObligations)
907
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
908
+ if (state === 'none' && isHistoryHeld(current)) {
909
+ throw invalid('this is a history-import hold: settle it with the history import decisions');
910
+ }
911
+ const job = await setHold(db, organisationId, obligationId, { holdState: state, reason, actor: `person:${req.person.id}` });
912
+ res.json({ ok: true, job: { id: job.id, holdState: job.holdState, holdReason: job.holdReason } });
913
+ }));
914
+
915
+ // Frees a job stuck on a void Flow invoice, or on an accounting invoice that
916
+ // was voided or deleted there. The job goes on hold until a person releases it.
917
+ router.post('/jobs/:id/free', ...manager, handle(log, async (req, res) => {
918
+ const obligationId = pathId(req.params.id, 'job');
919
+ const organisationId = await organisationOf(req);
920
+ const reason = text(req.body?.reason);
921
+ if (!reason) throw invalid('a reason is required');
922
+ const job = await freeVoidedJob(db, organisationId, obligationId, { actor: `person:${req.person.id}`, reason, system: accounting()?.id ?? null });
923
+ res.json({ ok: true, job: { id: job.id, invoiceId: job.invoiceId, accountingState: job.accountingState, holdState: job.holdState, holdReason: job.holdReason } });
924
+ }));
925
+
855
926
  // Links a job to a Flow invoice that already exists.
856
927
  router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
857
928
  const obligationId = pathId(req.params.id, 'job');
@@ -971,6 +1042,51 @@ export function createInvoicingRouter({
971
1042
  res.json({ ok: true });
972
1043
  }));
973
1044
 
1045
+ // Exceptions (docs/INVOICE_RULES.md): each job a person let through although
1046
+ // the rule matched, with who, when and why.
1047
+ router.get('/rules/:id/exceptions', ...signedIn, handle(log, async (req, res) => {
1048
+ const ruleId = pathId(req.params.id, 'rule');
1049
+ const organisationId = await organisationOf(req);
1050
+ res.json({ ok: true, exceptions: await listRuleExceptions(db, organisationId, ruleId) });
1051
+ }));
1052
+
1053
+ router.delete('/rules/:id/exceptions/:exceptionId', ...manager, handle(log, async (req, res) => {
1054
+ const ruleId = pathId(req.params.id, 'rule');
1055
+ const exceptionId = pathId(req.params.exceptionId, 'exception');
1056
+ const organisationId = await organisationOf(req);
1057
+ await removeRuleException(db, organisationId, ruleId, exceptionId, { actor: `person:${req.person.id}` });
1058
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} removed exception ${exceptionId} from rule ${ruleId}`);
1059
+ res.json({ ok: true });
1060
+ }));
1061
+
1062
+ // A Pending draft's rule fix: add the rule's item, or skip with a reason.
1063
+ router.post('/invoices/:id/rules/:ruleId/add-item', ...manager, handle(log, async (req, res) => {
1064
+ const invoiceId = pathId(req.params.id, 'invoice');
1065
+ const ruleId = pathId(req.params.ruleId, 'rule');
1066
+ const organisationId = await organisationOf(req);
1067
+ await addRuleItem(db, organisationId, invoiceId, ruleId);
1068
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added rule ${ruleId}'s item to invoice ${invoiceId}`);
1069
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
1070
+ }));
1071
+
1072
+ router.post('/invoices/:id/rules/:ruleId/exception', ...manager, handle(log, async (req, res) => {
1073
+ const invoiceId = pathId(req.params.id, 'invoice');
1074
+ const ruleId = pathId(req.params.ruleId, 'rule');
1075
+ const organisationId = await organisationOf(req);
1076
+ await exceptDraft(db, organisationId, invoiceId, ruleId, { reason: req.body?.reason, actor: `person:${req.person.id}` });
1077
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made an exception to rule ${ruleId} for invoice ${invoiceId}`);
1078
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
1079
+ }));
1080
+
1081
+ router.post('/jobs/:id/rules/:ruleId/exception', ...manager, handle(log, async (req, res) => {
1082
+ const obligationId = pathId(req.params.id, 'job');
1083
+ const ruleId = pathId(req.params.ruleId, 'rule');
1084
+ const organisationId = await organisationOf(req);
1085
+ const exception = await exceptJob(db, organisationId, obligationId, ruleId, { reason: req.body?.reason, actor: `person:${req.person.id}` });
1086
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made an exception to rule ${ruleId} for job ${obligationId}`);
1087
+ res.json({ ok: true, exception });
1088
+ }));
1089
+
974
1090
  router.get('/line-mappings', ...signedIn, handle(log, async (req, res) => {
975
1091
  const organisationId = await organisationOf(req);
976
1092
  res.json({ ok: true, mappings: await listMappings(db, organisationId), steps: STEPS.map((key) => ({ key, label: STEP_LABELS[key] })) });
package/server/app.js CHANGED
@@ -8,7 +8,7 @@ import { createWorkMovesRouter } from './api/work-moves.js';
8
8
  import { createConnectorApi } from './api/connector.js';
9
9
  import { createInvoicingRouter } from './api/invoicing.js';
10
10
  import { createSignInRouter } from './auth/routes.js';
11
- import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
11
+ import { integrationAlertsSource, invoiceProblemsSource, jobGapsSource, priceDifferencesSource } from './core/alerts.js';
12
12
  import { boardJobs } from './core/job-board.js';
13
13
  import { reconcileHealth } from './core/reconcile.js';
14
14
  import { appliedMigrationCounts } from './db/doctor.js';
@@ -154,7 +154,7 @@ export function createApp({
154
154
  if (database && auth?.personAuth) {
155
155
  const liveCheckFor = (organisationId) => {
156
156
  const [adapter] = integrations?.with('checkInvoice') ?? [];
157
- return adapter ? (obligation) => adapter.capabilities.checkInvoice({ organisationId, obligation }) : null;
157
+ return adapter ? (obligation, { reference = null } = {}) => adapter.capabilities.checkInvoice({ organisationId, obligation, reference }) : null;
158
158
  };
159
159
  // Send all ready (server/core/sending.js): the first enabled integration
160
160
  // that sends invoices (Xero).
@@ -184,6 +184,7 @@ export function createApp({
184
184
  personAuth: auth.personAuth,
185
185
  log,
186
186
  sources: {
187
+ jobs: [jobGapsSource(database.db)],
187
188
  invoices: [invoiceProblemsSource(database.db, { accounting })],
188
189
  'price-list': [priceDifferencesSource(database.db)],
189
190
  integrations: [integrationAlertsSource(() => integrations?.with('alerts') ?? [])],
@@ -1,3 +1,4 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import express from 'express';
2
3
  import { FlowError } from '../core/errors.js';
3
4
  import { rateLimit } from '../rate-limit.js';
@@ -18,6 +19,9 @@ import {
18
19
  updatePerson,
19
20
  } from './people.js';
20
21
  import { isPlainEmail } from './settings.js';
22
+
23
+ // The files of skills/flow that GET /api/agent-skill/flow/<file> serves.
24
+ const SKILL_FILES = { 'SKILL.md': 'text/markdown', 'flow.sh': 'text/x-shellscript' };
21
25
  import { APP_PATHS, appPage } from '../ui/app-page.js';
22
26
  import { navPinsFromViews } from '../ui/nav-pins.js';
23
27
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -110,6 +114,22 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
110
114
  res.json(body);
111
115
  });
112
116
 
117
+ // The AI assistant's own skill, from the Flow it talks to, so the skill
118
+ // always matches this Flow's routes (`flow.sh update`). Only the two skill
119
+ // files; the release that runs here is the one they come from.
120
+ router.get('/api/agent-skill/flow/:file', requireSession, async (req, res) => {
121
+ const file = SKILL_FILES[req.params.file];
122
+ if (!file) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such skill file.' });
123
+ try {
124
+ const text = await readFile(new URL(`../../skills/flow/${req.params.file}`, import.meta.url), 'utf8');
125
+ res.set('Cache-Control', 'no-store');
126
+ res.type(file).send(text);
127
+ } catch (error) {
128
+ log.error?.(`[auth] skill file ${req.params.file} unreadable: ${error.message}`);
129
+ res.status(503).json({ ok: false, code: 'skill_unavailable', error: 'This Flow has no skill files.' });
130
+ }
131
+ });
132
+
113
133
  // Each person sets their own appearance; it follows their account.
114
134
  router.put('/api/me/appearance', requireSession, express.json({ limit: '4kb' }), async (req, res) => {
115
135
  try {
@@ -1,3 +1,4 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
1
2
  import { displayNameOf, googleWorkspaceOwner, primaryEmailOf, secondFactorVerified } from './clerk.js';
2
3
  import { fillPersonName, findPersonByClerkId, findPersonByEmail, linkPerson, recordPendingPerson } from './people.js';
3
4
  import { DEFAULT_OWNER_MFA_MAX_AGE_MINUTES, isPlainEmail } from './settings.js';
@@ -52,6 +53,8 @@ export function createPersonAuth({
52
53
  log = console,
53
54
  now = Date.now,
54
55
  clerkUserCacheMs = 60 * 1000,
56
+ agentKey = null,
57
+ agentKeys = agentKey ? [agentKey] : [],
55
58
  }) {
56
59
  if (!db) throw new Error('createPersonAuth: db is required');
57
60
  if (!clerk?.authenticate || !clerk?.getUser) throw new Error('createPersonAuth: a Clerk adapter is required');
@@ -118,7 +121,10 @@ export function createPersonAuth({
118
121
  // Every person route starts here. The session token must be in the
119
122
  // Authorization header; Clerk verifies it, and Flow checks its expiry too.
120
123
  async function requireSession(req, res, next) {
121
- if (!bearerToken(req)) return deny(res, 'signed_out');
124
+ const token = bearerToken(req);
125
+ if (!token) return deny(res, 'signed_out');
126
+ const agent = agentKeys.length ? agentKeyFor(token) : null;
127
+ if (agent) return agentSession(req, res, next, agent);
122
128
  let auth;
123
129
  try {
124
130
  auth = await clerk.authenticate(req);
@@ -145,6 +151,41 @@ export function createPersonAuth({
145
151
  return next();
146
152
  }
147
153
 
154
+ // The AI assistant keys (FLOW_AGENT_KEY_SHA256, and _2). Each acts as its own Flow person, and
155
+ // only inside that person's role. Owner actions stay with people: an agent
156
+ // request is never a verified owner sign-in.
157
+ // Keys shorter than 32 characters are refused, so a digest of a short
158
+ // guess never matches.
159
+ const agentDigests = agentKeys.map((key) => ({ email: key.email, digest: Buffer.from(key.digest, 'hex') }));
160
+ function agentKeyFor(token) {
161
+ if (token.length < 32) return null;
162
+ const hash = createHash('sha256').update(token).digest();
163
+ let found = null;
164
+ for (const key of agentDigests) if (timingSafeEqual(hash, key.digest)) found = key;
165
+ return found;
166
+ }
167
+ // The key opens only these routes: invoicing, Xero (owner routes refuse it
168
+ // below), the Seazona read, the Seazona history import, /api/me and its own
169
+ // skill files.
170
+ const AGENT_PATHS = /^\/api\/(?:me$|invoicing\/|xero\/|seazona\/(?:api|history)(?:\/|$)|agent-skill\/flow\/)/;
171
+ async function agentSession(req, res, next, agent) {
172
+ if (!AGENT_PATHS.test(`${req.baseUrl}${req.path}`)) return deny(res, 'role_required');
173
+ let person;
174
+ try {
175
+ person = await findPersonByEmail(db, agent.email);
176
+ } catch (error) {
177
+ log.error?.(`[auth] Flow agent person lookup failed: ${error.message}`);
178
+ return deny(res, 'sign_in_unavailable');
179
+ }
180
+ if (!person) return deny(res, 'not_invited');
181
+ if (person.status !== 'active') return deny(res, 'access_disabled');
182
+ req.person = person;
183
+ req.clerkAuth = null;
184
+ req.agent = true;
185
+ log.info?.(`[auth] agent key: ${req.method} ${req.baseUrl}${req.path} as Flow person ${person.id} (${person.role})`);
186
+ return next();
187
+ }
188
+
148
189
  function requireRole(...roles) {
149
190
  return (req, res, next) => (roles.includes(req.person?.role) ? next() : deny(res, 'role_required'));
150
191
  }
@@ -165,6 +206,7 @@ export function createPersonAuth({
165
206
  // (Replit's managed Clerk, which has no MFA).
166
207
  // A Clerk error fails closed. Returns { verified, by } or { unavailable }.
167
208
  async function ownerVerification(person, auth) {
209
+ if (!auth) return { verified: false, by: null };
168
210
  if (secondFactorVerified(auth, ownerMfaMaxAgeMinutes)) return { verified: true, by: 'second_factor' };
169
211
  if (!ownerGoogleDomains.length) return { verified: false, by: null };
170
212
  try {
@@ -182,6 +224,7 @@ export function createPersonAuth({
182
224
  // requireSession.
183
225
  async function requireOwner(req, res, next) {
184
226
  if (req.person?.role !== 'owner') return deny(res, 'owner_required');
227
+ if (req.agent) return deny(res, 'owner_verification_required', ownerMessage);
185
228
  const check = await ownerVerification(req.person, req.clerkAuth);
186
229
  if (check.unavailable) return deny(res, 'sign_in_unavailable');
187
230
  if (!check.verified) return deny(res, 'owner_verification_required', ownerMessage);
@@ -127,6 +127,32 @@ export function isPlainEmail(email) {
127
127
  return typeof email === 'string' && /^[\x21-\x3f\x41-\x7e]+@[a-z0-9.-]+\.[a-z0-9-]+$/i.test(email);
128
128
  }
129
129
 
130
+ // FLOW_AGENT_KEY_SHA256 and FLOW_AGENT_EMAIL: one key for one AI assistant
131
+ // (an OpenClaw Gateway). A request with `Authorization: Bearer <key>`, where
132
+ // the key's SHA-256 is FLOW_AGENT_KEY_SHA256, acts as the Flow person with
133
+ // FLOW_AGENT_EMAIL, inside that person's role. Flow holds only the digest, so
134
+ // the key itself never leaves the assistant's machine. Both or neither.
135
+ export function resolveAgentKey(env = process.env, suffix = '') {
136
+ const digestName = `FLOW_AGENT_KEY_SHA256${suffix}`;
137
+ const emailName = `FLOW_AGENT_EMAIL${suffix}`;
138
+ const digest = readEnv(env, digestName).toLowerCase();
139
+ const email = readEnv(env, emailName).toLowerCase();
140
+ if (!digest && !email) return null;
141
+ if (!digest || !email) throw new Error(`${digestName} and ${emailName} must be set together`);
142
+ if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error(`${digestName} must be 64 hex characters (the SHA-256 of the key)`);
143
+ if (!isPlainEmail(email)) throw new Error(`${emailName} must be a plain (ASCII) email address`);
144
+ return { digest, email };
145
+ }
146
+
147
+ // Up to two assistant keys: the first pair, and a second pair with the
148
+ // suffix _2. Each key acts as its own Flow person.
149
+ export function resolveAgentKeys(env = process.env) {
150
+ const keys = [resolveAgentKey(env), resolveAgentKey(env, '_2')].filter(Boolean);
151
+ if (keys.length === 2 && keys[0].digest === keys[1].digest) throw new Error('FLOW_AGENT_KEY_SHA256_2 must differ from FLOW_AGENT_KEY_SHA256');
152
+ if (keys.length === 2 && keys[0].email === keys[1].email) throw new Error('FLOW_AGENT_EMAIL_2 must differ from FLOW_AGENT_EMAIL');
153
+ return keys;
154
+ }
155
+
130
156
  export function resolveSignInSettings(env = process.env) {
131
157
  const mode = resolveAuthMode(env);
132
158
  if (mode === 'off') return { mode };
@@ -139,5 +165,6 @@ export function resolveSignInSettings(env = process.env) {
139
165
  ownerGoogleDomains: parseOwnerGoogleDomains(readEnv(env, 'FLOW_OWNER_GOOGLE_DOMAINS')),
140
166
  authorizedParties: resolveAuthorizedParties(env),
141
167
  firstOwnerEmail: resolveFirstOwnerEmail(env),
168
+ agentKeys: resolveAgentKeys(env),
142
169
  };
143
170
  }
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { and, eq, inArray, sql } from 'drizzle-orm';
3
- import { alertDismissals, invoices } from '../db/schema.js';
2
+ import { and, asc, eq, inArray, isNull, lt, sql } from 'drizzle-orm';
3
+ import { alertDismissals, billingObligations, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
+ import { READY_GRACE_DAYS } from './reconcile.js';
5
6
  import { listReview } from './review.js';
6
7
  import { invoiceViews } from './sending.js';
7
8
 
@@ -19,7 +20,7 @@ import { invoiceViews } from './sending.js';
19
20
  // they never leave the server. `roles` are who may see it. `action`, when
20
21
  // there is one, is { label, href } or { label, tab } for a tab on the page.
21
22
 
22
- export const ALERT_PAGES = Object.freeze(['invoices', 'price-list', 'integrations']);
23
+ export const ALERT_PAGES = Object.freeze(['jobs', 'invoices', 'price-list', 'integrations']);
23
24
  const ALL_ROLES = Object.freeze(['owner', 'admin', 'staff']);
24
25
  const MANAGERS = Object.freeze(['owner', 'admin']);
25
26
  const TONES = new Set(['warn', 'danger']);
@@ -41,6 +42,40 @@ function checkAlert(alert) {
41
42
 
42
43
  // ── Sources ────────────────────────────────────────────────────────────────
43
44
 
45
+ // Jobs: finished jobs that nothing is billing. A job ready longer than the
46
+ // daily reconcile's grace, with no Flow invoice, no accounting invoice and
47
+ // no hold, opens Reconcile, where a person links its invoice or makes one.
48
+ // A hold is how a person says "do not bill".
49
+ export function jobGapsSource(db, { now = () => Date.now() } = {}) {
50
+ return async (organisationId) => {
51
+ const before = new Date(now() - READY_GRACE_DAYS * 86_400_000);
52
+ const unbilled = await db.select({ id: billingObligations.id, totalCents: billingObligations.totalCents })
53
+ .from(billingObligations)
54
+ .where(and(
55
+ eq(billingObligations.organisationId, organisationId),
56
+ isNull(billingObligations.invoiceId),
57
+ isNull(billingObligations.accountingRef),
58
+ eq(billingObligations.holdState, 'none'),
59
+ lt(billingObligations.readyAt, before),
60
+ ))
61
+ .orderBy(asc(billingObligations.readyAt))
62
+ .limit(MAX_MEMBERS);
63
+ const alerts = [];
64
+ if (unbilled.length) {
65
+ alerts.push({
66
+ key: 'jobs:not-invoiced',
67
+ page: 'jobs',
68
+ tone: 'warn',
69
+ title: `${plural(unbilled.length, 'finished job has', 'finished jobs have')} no invoice after ${READY_GRACE_DAYS} days.`,
70
+ action: { label: 'Open Reconcile', href: '/reconcile' },
71
+ moneyAtRiskCents: unbilled.reduce((sum, o) => sum + Number(o.totalCents ?? 0), 0),
72
+ members: unbilled.map((o) => o.id),
73
+ });
74
+ }
75
+ return alerts;
76
+ };
77
+ }
78
+
44
79
  // Invoices: drafts a check stopped and issued invoices the accounting system
45
80
  // refused (server/core/sending.js). One alert for all of them.
46
81
  export function invoiceProblemsSource(db, { accounting = () => null } = {}) {
@@ -1,4 +1,4 @@
1
- import { and, eq, sql } from 'drizzle-orm';
1
+ import { and, asc, eq, sql } from 'drizzle-orm';
2
2
  import { automations, billingObligations, invoices } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
4
  import { autoDraftJobs, readInvoicingSettings } from './invoicing-rules.js';
@@ -176,14 +176,19 @@ const actorOf = (key) => `automation:${key}`;
176
176
  // that job to people from then on.
177
177
  async function markReadyFromWorkStatus(db, organisationId, { statuses }) {
178
178
  const actor = actorOf('mark_ready_from_work_status');
179
+ const wanted = [...new Set(statuses.map(norm).filter(Boolean))];
180
+ if (!wanted.length) return { marked: 0 };
181
+ // The status test runs in the query, with the same normalising as
182
+ // statusMatches, so the batch holds only jobs to mark, oldest first: jobs
183
+ // still in work never fill it and push a shipped job out.
179
184
  const jobs = await db.select().from(billingObligations).where(and(
180
185
  eq(billingObligations.organisationId, organisationId),
181
186
  sql`${billingObligations.readyAt} is null`,
182
187
  sql`${billingObligations.invoiceId} is null`,
183
- sql`${billingObligations.sourceStatus} is not null`,
188
+ sql`lower(regexp_replace(btrim(${billingObligations.sourceStatus}), '\\s+', ' ', 'g')) in (${sql.join(wanted.map((w) => sql`${w}`), sql`, `)})`,
184
189
  sql`not exists (select 1 from obligation_events e where e.obligation_id = ${billingObligations.id}
185
190
  and e.kind = 'in_work' and e.actor not like 'automation:%')`,
186
- )).limit(BATCH);
191
+ )).orderBy(asc(billingObligations.createdAt)).limit(BATCH);
187
192
  let marked = 0;
188
193
  for (const job of jobs) {
189
194
  if (!statusMatches(job.sourceStatus, statuses)) continue;