@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.
- package/SECURITY.md +12 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/agents/gate.js +25 -5
- package/server/api/invoicing.js +119 -3
- package/server/app.js +3 -2
- package/server/auth/routes.js +20 -0
- package/server/auth/session.js +44 -1
- package/server/auth/settings.js +27 -0
- package/server/core/alerts.js +38 -3
- package/server/core/automations.js +8 -3
- package/server/core/invoice-rules.js +158 -8
- package/server/core/invoices.js +9 -1
- package/server/core/invoicing-rules.js +31 -6
- package/server/core/job-board.js +16 -1
- package/server/core/job-match.js +6 -4
- package/server/core/ledger.js +67 -8
- package/server/core/pipeline.js +6 -2
- package/server/core/reconcile.js +11 -3
- package/server/core/sending.js +30 -3
- package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
- package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
- package/server/db/migrations/meta/0027_snapshot.json +5050 -0
- package/server/db/migrations/meta/0028_snapshot.json +5062 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +21 -0
- package/server/integrations/seazona/feed.js +10 -3
- package/server/integrations/seazona/index.js +6 -1
- package/server/integrations/seazona/routes.js +65 -2
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/xero/drafts.js +1 -1
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/ledger.js +17 -8
- package/server/integrations/xero/links.js +39 -1
- package/server/integrations/xero/lookup.js +3 -1
- package/server/integrations/xero/routes.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +7 -4
- package/server/ui/flow.css +69 -1
- package/server/ui/home.js +120 -0
- package/server/ui/invoice-rules.js +11 -3
- package/server/ui/invoicing.js +81 -6
- package/server/ui/job-board.js +8 -3
- package/server/ui/jobs.js +14 -2
- package/server/ui/nav-pins.js +5 -3
- package/server/ui/reconcile.js +4 -2
- package/server/ui/routes.js +1 -1
- package/skills/flow/SKILL.md +306 -0
- 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.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.11.
|
|
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.
|
|
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.
|
|
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"
|
package/server/agents/gate.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
126
|
-
active--;
|
|
146
|
+
unlease();
|
|
127
147
|
}
|
|
128
148
|
};
|
|
129
149
|
return { handle, fail, limits };
|
package/server/api/invoicing.js
CHANGED
|
@@ -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') ?? [])],
|
package/server/auth/routes.js
CHANGED
|
@@ -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 {
|
package/server/auth/session.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/server/auth/settings.js
CHANGED
|
@@ -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
|
}
|
package/server/core/alerts.js
CHANGED
|
@@ -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
|
|
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;
|