@arcforgelabs/flow 2026.10.21 → 2026.10.23

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 (35) hide show
  1. package/README.md +2 -1
  2. package/VISION.md +40 -9
  3. package/npm-shrinkwrap.json +2 -414
  4. package/package.json +6 -1
  5. package/server/app.js +7 -0
  6. package/server/core/links.js +166 -0
  7. package/server/core/outbox.js +129 -0
  8. package/server/core/queue.js +142 -0
  9. package/server/core/review.js +92 -0
  10. package/server/core/secrets.js +74 -0
  11. package/server/db/migrations/0005_data_spine.sql +99 -0
  12. package/server/db/migrations/0006_prior_links.sql +18 -0
  13. package/server/db/migrations/0007_integration_settings.sql +23 -0
  14. package/server/db/migrations/meta/0005_snapshot.json +2145 -0
  15. package/server/db/migrations/meta/0006_snapshot.json +2306 -0
  16. package/server/db/migrations/meta/0007_snapshot.json +2473 -0
  17. package/server/db/migrations/meta/_journal.json +21 -0
  18. package/server/db/schema.js +187 -1
  19. package/server/import/at-start.js +69 -1
  20. package/server/index.js +28 -8
  21. package/server/integrations/index.js +9 -3
  22. package/server/integrations/port.js +101 -9
  23. package/server/integrations/seazona/index.js +25 -3
  24. package/server/integrations/setup.js +234 -0
  25. package/server/integrations/xero/client.js +13 -0
  26. package/server/integrations/xero/import.js +116 -0
  27. package/server/integrations/xero/index.js +45 -5
  28. package/server/integrations/xero/migrations/0004_xero_mirror.sql +96 -0
  29. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  30. package/server/integrations/xero/mirror.js +295 -0
  31. package/server/integrations/xero/routes.js +26 -1
  32. package/server/integrations/xero/settings.js +8 -0
  33. package/server/ui/app-page.js +1 -1
  34. package/server/ui/app.js +142 -0
  35. package/server/ui/routes.js +9 -2
@@ -0,0 +1,166 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { externalLinks, LINK_ENTITIES, LINK_MATCHES, priorLinks } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+ import { openReview, resolveReviewFor } from './review.js';
5
+
6
+ // The identity map (issue 57). It records that a Flow record and a record in
7
+ // another system are the same thing, how Flow knows, and who confirmed it.
8
+ // Pushes, imports and the ledger read the link first; matching by name only
9
+ // ever proposes one.
10
+ //
11
+ // A link is exact when Flow made the external record, or both sides carry the
12
+ // same id. A name match is a guess: it stays `proposed` and opens a review item
13
+ // until a person confirms it. A link is never deleted; a replaced one is
14
+ // `superseded`.
15
+
16
+ const EXACT = new Set(['created_by_flow', 'exact_id', 'imported', 'person']);
17
+
18
+ function check({ entity, flowId, system, externalId, matchedBy }) {
19
+ if (!LINK_ENTITIES.includes(entity)) throw new FlowError('invalid', `unknown link entity: ${entity}`);
20
+ if (!LINK_MATCHES.includes(matchedBy)) throw new FlowError('invalid', `unknown match: ${matchedBy}`);
21
+ if (!flowId || !system || !externalId) throw new FlowError('invalid', 'flowId, system and externalId are required');
22
+ }
23
+
24
+ // Two callers that link the same external record, or the same Flow record, at
25
+ // the same time take turns: the decision below reads, then writes.
26
+ async function lockPair(tx, organisationId, { system, entity, externalId, flowId }) {
27
+ const keys = [
28
+ `link:${organisationId}:${system}:${entity}:ext:${externalId}`,
29
+ `link:${organisationId}:${system}:${entity}:flow:${flowId}`,
30
+ ].sort();
31
+ for (const key of keys) await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
32
+ }
33
+
34
+ async function activeBy(tx, organisationId, { system, entity }, column, value) {
35
+ const [row] = await tx.select().from(externalLinks).where(and(
36
+ eq(externalLinks.organisationId, organisationId),
37
+ eq(externalLinks.system, system),
38
+ eq(externalLinks.entity, entity),
39
+ eq(column, value),
40
+ eq(externalLinks.state, 'active'),
41
+ ));
42
+ return row ?? null;
43
+ }
44
+
45
+ // Records a link. Answers `{ outcome, link }`:
46
+ // - `linked`: a new active link;
47
+ // - `existing`: the same active link was already there;
48
+ // - `proposed`: a guess, waiting for a person (a review item is open);
49
+ // - `conflict`: one side already links elsewhere. Nothing changes; a review
50
+ // item holds both sides.
51
+ export async function linkRecord(db, organisationId, link) {
52
+ check(link);
53
+ const { entity, flowId, system, externalId, matchedBy } = link;
54
+ return db.transaction(async (tx) => {
55
+ await lockPair(tx, organisationId, link);
56
+ const byExternal = await activeBy(tx, organisationId, link, externalLinks.externalId, externalId);
57
+ if (byExternal?.flowId === flowId) return { outcome: 'existing', link: byExternal };
58
+ const byFlow = await activeBy(tx, organisationId, link, externalLinks.flowId, flowId);
59
+ if (byExternal || byFlow) {
60
+ await openReview(tx, organisationId, {
61
+ kind: 'link.conflict',
62
+ entity,
63
+ flowId,
64
+ systems: [system],
65
+ evidence: {
66
+ proposed: { system, externalId, matchedBy },
67
+ linkedExternal: byExternal && { flowId: byExternal.flowId, externalId: byExternal.externalId },
68
+ linkedFlow: byFlow && { flowId: byFlow.flowId, externalId: byFlow.externalId },
69
+ },
70
+ });
71
+ return { outcome: 'conflict', link: byExternal ?? byFlow };
72
+ }
73
+ const state = EXACT.has(matchedBy) ? 'active' : 'proposed';
74
+ const [waiting] = await tx.select().from(externalLinks).where(and(
75
+ eq(externalLinks.organisationId, organisationId),
76
+ eq(externalLinks.system, system),
77
+ eq(externalLinks.entity, entity),
78
+ eq(externalLinks.externalId, externalId),
79
+ eq(externalLinks.flowId, flowId),
80
+ eq(externalLinks.state, 'proposed'),
81
+ ));
82
+ if (waiting && state === 'proposed') return { outcome: 'proposed', link: waiting };
83
+ if (waiting) {
84
+ // Exact evidence for a pair a person was asked about settles the question.
85
+ await tx.update(externalLinks).set({ state: 'superseded', updatedAt: sql`now()` })
86
+ .where(eq(externalLinks.id, waiting.id));
87
+ await resolveReviewFor(tx, organisationId, {
88
+ kind: 'link.proposed', entity, flowId, resolution: `exact:${matchedBy}`, actor: 'system:links',
89
+ });
90
+ }
91
+ const [row] = await tx.insert(externalLinks)
92
+ .values({ organisationId, entity, flowId, system, externalId, matchedBy, state })
93
+ .returning();
94
+ if (state === 'proposed') {
95
+ await openReview(tx, organisationId, {
96
+ kind: 'link.proposed',
97
+ entity,
98
+ flowId,
99
+ systems: [system],
100
+ evidence: { linkId: row.id, system, externalId, matchedBy },
101
+ });
102
+ }
103
+ return { outcome: state === 'active' ? 'linked' : 'proposed', link: row };
104
+ });
105
+ }
106
+
107
+ // A person confirms a proposed link. It becomes active, unless either side
108
+ // gained an active link meanwhile.
109
+ export async function confirmLink(db, organisationId, linkId, { personId = null, actor }) {
110
+ if (!actor) throw new FlowError('invalid', 'actor is required');
111
+ return db.transaction(async (tx) => {
112
+ const [proposed] = await tx.select().from(externalLinks)
113
+ .where(and(eq(externalLinks.organisationId, organisationId), eq(externalLinks.id, linkId)));
114
+ if (!proposed) throw notFound('link');
115
+ await lockPair(tx, organisationId, proposed);
116
+ const [current] = await tx.select().from(externalLinks)
117
+ .where(eq(externalLinks.id, linkId)).for('update');
118
+ if (current.state !== 'proposed') throw new FlowError('conflict', 'link is not waiting for a person', { state: current.state });
119
+ const taken = await activeBy(tx, organisationId, current, externalLinks.externalId, current.externalId)
120
+ ?? await activeBy(tx, organisationId, current, externalLinks.flowId, current.flowId);
121
+ if (taken) throw new FlowError('conflict', 'one side already has an active link', { linkId: taken.id });
122
+ const [row] = await tx.update(externalLinks)
123
+ .set({ state: 'active', confirmedBy: personId, confirmedAt: sql`now()`, updatedAt: sql`now()` })
124
+ .where(eq(externalLinks.id, linkId))
125
+ .returning();
126
+ await resolveReviewFor(tx, organisationId, {
127
+ kind: 'link.proposed', entity: row.entity, flowId: row.flowId, resolution: 'confirmed', actor,
128
+ });
129
+ return row;
130
+ });
131
+ }
132
+
133
+ // Ends a link (for example when the external record was merged or deleted).
134
+ export async function supersedeLink(db, organisationId, linkId) {
135
+ const [row] = await db.update(externalLinks)
136
+ .set({ state: 'superseded', updatedAt: sql`now()` })
137
+ .where(and(eq(externalLinks.organisationId, organisationId), eq(externalLinks.id, linkId)))
138
+ .returning();
139
+ if (!row) throw notFound('link');
140
+ return row;
141
+ }
142
+
143
+ // The Flow record an external record is, through an active link, or null.
144
+ export async function flowIdFor(db, organisationId, { system, entity, externalId }) {
145
+ const row = await activeBy(db, organisationId, { system, entity }, externalLinks.externalId, externalId);
146
+ return row?.flowId ?? null;
147
+ }
148
+
149
+ // The external id of a Flow record in one system, through an active link, or null.
150
+ export async function externalIdFor(db, organisationId, { system, entity, flowId }) {
151
+ const row = await activeBy(db, organisationId, { system, entity }, externalLinks.flowId, flowId);
152
+ return row?.externalId ?? null;
153
+ }
154
+
155
+ // The link an earlier installation held for a work system's job, if any
156
+ // (`prior_links`). The pipeline reads it when the job first becomes an
157
+ // obligation (#58).
158
+ export async function priorLinkFor(db, organisationId, { sourceSystem, sourceJobRef, system }) {
159
+ const [row] = await db.select().from(priorLinks).where(and(
160
+ eq(priorLinks.organisationId, organisationId),
161
+ eq(priorLinks.sourceSystem, sourceSystem),
162
+ eq(priorLinks.sourceJobRef, sourceJobRef),
163
+ eq(priorLinks.system, system),
164
+ ));
165
+ return row ?? null;
166
+ }
@@ -0,0 +1,129 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { outbox } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+ import { linkRecord } from './links.js';
5
+ import { openReview } from './review.js';
6
+
7
+ // The outbox. Every write Flow makes to another system is a row here first,
8
+ // written in the same transaction as the Flow change it reports, with its
9
+ // idempotency key. An adapter sends it through its governor and records what
10
+ // came back. So a write is never lost, never made twice, and never made
11
+ // without a record of why.
12
+ //
13
+ // pending -> sending -> sent
14
+ // -> ambiguous (outcome unknown: settle by a lookup first)
15
+ // -> pending again (a retry) or failed (opens a review item)
16
+ // pending or ambiguous -> cancelled (a person)
17
+
18
+ // How long a send may take before Flow treats its outcome as unknown.
19
+ export const SEND_LEASE_MS = 10 * 60 * 1000;
20
+
21
+ // Queues a write. Pass the transaction that makes the Flow change. A repeat with
22
+ // the same key returns the first row; the same key for another record refuses.
23
+ export async function queueWrite(tx, organisationId, {
24
+ system, action, entity, flowId, idempotencyKey, request, actor,
25
+ }) {
26
+ if (!system || !action || !entity || !flowId) throw new FlowError('invalid', 'system, action, entity and flowId are required');
27
+ if (!idempotencyKey) throw new FlowError('invalid', 'idempotencyKey is required');
28
+ if (!actor) throw new FlowError('invalid', 'actor is required');
29
+ const [row] = await tx.insert(outbox)
30
+ .values({ organisationId, system, action, entity, flowId, idempotencyKey, request, createdBy: actor })
31
+ .onConflictDoNothing({ target: outbox.idempotencyKey })
32
+ .returning();
33
+ if (row) return { write: row, queued: true };
34
+ const [first] = await tx.select().from(outbox).where(eq(outbox.idempotencyKey, idempotencyKey));
35
+ if (first.organisationId !== organisationId || first.flowId !== flowId || first.action !== action) {
36
+ throw new FlowError('conflict', 'idempotency key already used for another write');
37
+ }
38
+ return { write: first, queued: false };
39
+ }
40
+
41
+ // Claims pending writes for one system. A write left `sending` past the lease
42
+ // (the process died mid-call) becomes `ambiguous`: the call may have left.
43
+ export async function claimWrites(db, { system, limit = 10, leaseMs = SEND_LEASE_MS }) {
44
+ return db.transaction(async (tx) => {
45
+ await tx.update(outbox)
46
+ .set({ state: 'ambiguous', lastError: 'send did not finish', updatedAt: sql`now()` })
47
+ .where(and(
48
+ eq(outbox.system, system),
49
+ eq(outbox.state, 'sending'),
50
+ sql`${outbox.updatedAt} < now() - make_interval(secs => ${leaseMs / 1000})`,
51
+ ));
52
+ const { rows } = await tx.execute(sql`
53
+ select id from outbox where system = ${system} and state = 'pending'
54
+ order by created_at for update skip locked limit ${limit}`);
55
+ const claimed = [];
56
+ for (const { id } of rows) {
57
+ const [write] = await tx.update(outbox)
58
+ .set({ state: 'sending', attempts: sql`${outbox.attempts} + 1`, updatedAt: sql`now()` })
59
+ .where(eq(outbox.id, id))
60
+ .returning();
61
+ claimed.push(write);
62
+ }
63
+ return claimed;
64
+ });
65
+ }
66
+
67
+ // Moves one write from the states in `from` to new fields. Refuses a move from
68
+ // any other state, so two workers can never both settle one write.
69
+ async function move(tx, organisationId, writeId, from, fields) {
70
+ const [row] = await tx.update(outbox)
71
+ .set({ ...fields, updatedAt: sql`now()` })
72
+ .where(and(
73
+ eq(outbox.organisationId, organisationId),
74
+ eq(outbox.id, writeId),
75
+ sql`${outbox.state} in (${sql.join(from.map((s) => sql`${s}`), sql`, `)})`,
76
+ ))
77
+ .returning();
78
+ if (row) return row;
79
+ const [existing] = await tx.select().from(outbox)
80
+ .where(and(eq(outbox.organisationId, organisationId), eq(outbox.id, writeId)));
81
+ if (!existing) throw notFound('write');
82
+ throw new FlowError('conflict', `write is ${existing.state}`, { state: existing.state });
83
+ }
84
+
85
+ // The system made the record. Flow links its own record to it in the same
86
+ // transaction, so the identity map always knows what Flow created.
87
+ export async function markSent(db, organisationId, writeId, { externalId }) {
88
+ if (!externalId) throw new FlowError('invalid', 'externalId is required');
89
+ return db.transaction(async (tx) => {
90
+ const row = await move(tx, organisationId, writeId, ['sending', 'ambiguous'], {
91
+ state: 'sent', externalId, sentAt: sql`now()`, lastError: null,
92
+ });
93
+ const link = await linkRecord(tx, organisationId, {
94
+ entity: row.entity, flowId: row.flowId, system: row.system, externalId, matchedBy: 'created_by_flow',
95
+ });
96
+ return { write: row, link };
97
+ });
98
+ }
99
+
100
+ // The outcome is unknown (a timeout, a 5xx after the request left). The adapter
101
+ // must look in the system before it sends again.
102
+ export async function markAmbiguous(db, organisationId, writeId, { error }) {
103
+ return move(db, organisationId, writeId, ['sending'], { state: 'ambiguous', lastError: String(error).slice(0, 2000) });
104
+ }
105
+
106
+ // The system refused the write, or a lookup found it was never made. With
107
+ // `retry` it goes back to pending; without, it fails and opens a review item.
108
+ export async function markFailed(db, organisationId, writeId, { error, retry = false }) {
109
+ const message = String(error).slice(0, 2000);
110
+ if (retry) return move(db, organisationId, writeId, ['sending', 'ambiguous'], { state: 'pending', lastError: message });
111
+ return db.transaction(async (tx) => {
112
+ const row = await move(tx, organisationId, writeId, ['sending', 'ambiguous'], { state: 'failed', lastError: message });
113
+ await openReview(tx, organisationId, {
114
+ kind: 'write.failed',
115
+ entity: row.entity,
116
+ flowId: row.flowId,
117
+ systems: [row.system],
118
+ evidence: { writeId: row.id, action: row.action, attempts: row.attempts, error: message },
119
+ });
120
+ return row;
121
+ });
122
+ }
123
+
124
+ export async function cancelWrite(db, organisationId, writeId, { actor }) {
125
+ if (!actor) throw new FlowError('invalid', 'actor is required');
126
+ return move(db, organisationId, writeId, ['pending', 'ambiguous', 'failed'], {
127
+ state: 'cancelled', lastError: `cancelled by ${actor}`,
128
+ });
129
+ }
@@ -0,0 +1,142 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { backgroundJobs } from '../db/schema.js';
3
+ import { FlowError } from './errors.js';
4
+ import { openReview } from './review.js';
5
+
6
+ // The background job queue (issue 6). Every sync and write step runs as a job
7
+ // here, so steps never overlap, a crash never loses one, and a person can see
8
+ // what is queued, running or stuck. It is PostgreSQL only: workers claim with
9
+ // FOR UPDATE SKIP LOCKED and hold a lease. A lease that runs out makes the job
10
+ // claimable again, so a dead process never holds a job for good.
11
+
12
+ export const DEFAULT_LEASE_MS = 5 * 60 * 1000;
13
+ const FIRST_RETRY_MS = 60 * 1000;
14
+ const LAST_RETRY_MS = 6 * 60 * 60 * 1000;
15
+
16
+ // The wait before attempt `attempts + 1`: 1 minute, doubling, at most 6 hours.
17
+ export function retryDelayMs(attempts) {
18
+ return Math.min(FIRST_RETRY_MS * 2 ** Math.max(0, attempts - 1), LAST_RETRY_MS);
19
+ }
20
+
21
+ // Queues a job. While a job with the same kind and dedupe key is queued or
22
+ // running, it is returned instead of a second one.
23
+ export async function enqueueJob(db, organisationId, {
24
+ kind, dedupeKey = '', payload = {}, runAfter = null, maxAttempts = 8,
25
+ }) {
26
+ if (!kind) throw new FlowError('invalid', 'kind is required');
27
+ const [row] = await db.insert(backgroundJobs)
28
+ .values({ organisationId, kind, dedupeKey, payload, maxAttempts, ...(runAfter ? { runAfter } : {}) })
29
+ .onConflictDoNothing()
30
+ .returning();
31
+ if (row) return { job: row, queued: true };
32
+ const [open] = await db.select().from(backgroundJobs).where(and(
33
+ eq(backgroundJobs.organisationId, organisationId),
34
+ eq(backgroundJobs.kind, kind),
35
+ eq(backgroundJobs.dedupeKey, dedupeKey),
36
+ sql`${backgroundJobs.state} in ('queued', 'running')`,
37
+ ));
38
+ return { job: open, queued: false };
39
+ }
40
+
41
+ async function giveUp(tx, job, error) {
42
+ const [row] = await tx.update(backgroundJobs)
43
+ .set({ state: 'dead', lockedBy: null, lockedUntil: null, lastError: error, updatedAt: sql`now()` })
44
+ .where(eq(backgroundJobs.id, job.id))
45
+ .returning();
46
+ await openReview(tx, job.organisationId, {
47
+ kind: 'job.dead',
48
+ entity: 'background_job',
49
+ flowId: job.id,
50
+ evidence: { kind: job.kind, dedupeKey: job.dedupeKey, attempts: job.attempts, error },
51
+ });
52
+ return row;
53
+ }
54
+
55
+ // Claims up to `limit` due jobs for one worker. A running job whose lease ran
56
+ // out is due again; one that has used every attempt is given up instead.
57
+ export async function claimJobs(db, { workerId, kinds = null, limit = 1, leaseMs = DEFAULT_LEASE_MS }) {
58
+ if (!workerId) throw new FlowError('invalid', 'workerId is required');
59
+ return db.transaction(async (tx) => {
60
+ const kindFilter = kinds?.length
61
+ ? sql`and kind in (${sql.join(kinds.map((k) => sql`${k}`), sql`, `)})`
62
+ : sql``;
63
+ const { rows: due } = await tx.execute(sql`
64
+ select id, attempts, max_attempts from background_jobs
65
+ where ((state = 'queued' and run_after <= now()) or (state = 'running' and locked_until < now()))
66
+ ${kindFilter}
67
+ order by run_after, created_at
68
+ for update skip locked
69
+ limit ${limit}`);
70
+ const claimed = [];
71
+ for (const candidate of due) {
72
+ if (candidate.attempts >= candidate.max_attempts) {
73
+ const [job] = await tx.select().from(backgroundJobs).where(eq(backgroundJobs.id, candidate.id));
74
+ await giveUp(tx, job, job.lastError ?? 'lease ran out on the last attempt');
75
+ continue;
76
+ }
77
+ const [job] = await tx.update(backgroundJobs)
78
+ .set({
79
+ state: 'running',
80
+ lockedBy: workerId,
81
+ lockedUntil: sql`now() + make_interval(secs => ${leaseMs / 1000})`,
82
+ attempts: sql`${backgroundJobs.attempts} + 1`,
83
+ updatedAt: sql`now()`,
84
+ })
85
+ .where(eq(backgroundJobs.id, candidate.id))
86
+ .returning();
87
+ claimed.push(job);
88
+ }
89
+ return claimed;
90
+ });
91
+ }
92
+
93
+ // Only the worker that holds the lease may finish a job. A worker whose lease
94
+ // ran out and was taken over finishes nothing and is told so.
95
+ async function finish(db, job, workerId, fields) {
96
+ const [row] = await db.update(backgroundJobs)
97
+ .set({ ...fields, lockedBy: null, lockedUntil: null, updatedAt: sql`now()` })
98
+ .where(and(
99
+ eq(backgroundJobs.id, job.id),
100
+ eq(backgroundJobs.state, 'running'),
101
+ eq(backgroundJobs.lockedBy, workerId),
102
+ eq(backgroundJobs.attempts, job.attempts),
103
+ ))
104
+ .returning();
105
+ if (!row) throw new FlowError('conflict', 'job lease was lost', { jobId: job.id });
106
+ return row;
107
+ }
108
+
109
+ export async function completeJob(db, job, { workerId, result = null }) {
110
+ return finish(db, job, workerId, { state: 'succeeded', result, lastError: null });
111
+ }
112
+
113
+ // A failed attempt waits and runs again, until its attempts run out or the
114
+ // error cannot be fixed by waiting (`retry: false`); then it opens a review item.
115
+ export async function failJob(db, job, { workerId, error, retry = true }) {
116
+ const message = String(error?.message ?? error ?? 'failed').slice(0, 2000);
117
+ if (!retry || job.attempts >= job.maxAttempts) {
118
+ return db.transaction(async (tx) => {
119
+ const row = await finish(tx, job, workerId, { state: 'running', lastError: message });
120
+ return giveUp(tx, { ...row, attempts: job.attempts }, message);
121
+ });
122
+ }
123
+ return finish(db, job, workerId, {
124
+ state: 'queued',
125
+ lastError: message,
126
+ runAfter: sql`now() + make_interval(secs => ${retryDelayMs(job.attempts) / 1000})`,
127
+ });
128
+ }
129
+
130
+ // Claims one due job and runs its handler. Answers what happened, or null when
131
+ // nothing was due. A handler throws to fail; `error.retry = false` gives up.
132
+ export async function runNextJob(db, { workerId, handlers, leaseMs }) {
133
+ const [job] = await claimJobs(db, { workerId, kinds: Object.keys(handlers), limit: 1, leaseMs });
134
+ if (!job) return null;
135
+ try {
136
+ const result = await handlers[job.kind](job);
137
+ return { job: await completeJob(db, job, { workerId, result: result ?? null }), outcome: 'succeeded' };
138
+ } catch (error) {
139
+ const row = await failJob(db, job, { workerId, error, retry: error?.retry !== false });
140
+ return { job: row, outcome: row.state };
141
+ }
142
+ }
@@ -0,0 +1,92 @@
1
+ import { and, asc, desc, eq, sql } from 'drizzle-orm';
2
+ import { reviewItems } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+
5
+ // The review queue (issue 59). Everything a person must decide is one row
6
+ // here: a link Flow could not prove, a write that failed for good, a job that
7
+ // gave up, a difference between two systems. Flow never settles a conflict by
8
+ // overwriting one side; it opens an item and waits for a person.
9
+
10
+ function requireActor(actor) {
11
+ if (!actor) throw new FlowError('invalid', 'actor is required');
12
+ }
13
+
14
+ // Opens an item, or updates the one open item for the same kind and record:
15
+ // a repeat finding refreshes the evidence and never makes a second item.
16
+ export async function openReview(db, organisationId, {
17
+ kind, entity, flowId, systems = [], evidence = {}, moneyAtRiskCents = 0,
18
+ }) {
19
+ if (!kind || !entity || !flowId) throw new FlowError('invalid', 'kind, entity and flowId are required');
20
+ if (!Number.isSafeInteger(moneyAtRiskCents) || moneyAtRiskCents < 0) {
21
+ throw new FlowError('invalid', 'moneyAtRiskCents must be whole, non-negative cents');
22
+ }
23
+ const [row] = await db.insert(reviewItems)
24
+ .values({ organisationId, kind, entity, flowId, systems, evidence, moneyAtRiskCents })
25
+ .onConflictDoUpdate({
26
+ target: [reviewItems.organisationId, reviewItems.kind, reviewItems.entity, reviewItems.flowId],
27
+ targetWhere: sql`state = 'open'`,
28
+ set: {
29
+ systems,
30
+ evidence,
31
+ moneyAtRiskCents,
32
+ seenCount: sql`${reviewItems.seenCount} + 1`,
33
+ updatedAt: sql`now()`,
34
+ },
35
+ })
36
+ .returning();
37
+ return row;
38
+ }
39
+
40
+ // Closes an open item with the choice a person made. `dismiss` records that
41
+ // nothing needed doing.
42
+ export async function resolveReview(db, organisationId, reviewId, { resolution, note = null, actor, dismiss = false }) {
43
+ requireActor(actor);
44
+ if (!resolution) throw new FlowError('invalid', 'resolution is required');
45
+ const [row] = await db.update(reviewItems)
46
+ .set({
47
+ state: dismiss ? 'dismissed' : 'resolved',
48
+ resolution,
49
+ note,
50
+ resolvedBy: actor,
51
+ resolvedAt: sql`now()`,
52
+ updatedAt: sql`now()`,
53
+ })
54
+ .where(and(
55
+ eq(reviewItems.organisationId, organisationId),
56
+ eq(reviewItems.id, reviewId),
57
+ eq(reviewItems.state, 'open'),
58
+ ))
59
+ .returning();
60
+ if (row) return row;
61
+ const [existing] = await db.select().from(reviewItems)
62
+ .where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.id, reviewId)));
63
+ if (!existing) throw notFound('review item');
64
+ throw new FlowError('conflict', 'review item is already closed', { state: existing.state });
65
+ }
66
+
67
+ // Closes the open item for one record and kind, when the cause went away on its
68
+ // own (for example a person confirmed a link from another screen).
69
+ export async function resolveReviewFor(db, organisationId, { kind, entity, flowId, resolution, actor }) {
70
+ requireActor(actor);
71
+ const rows = await db.update(reviewItems)
72
+ .set({ state: 'resolved', resolution, resolvedBy: actor, resolvedAt: sql`now()`, updatedAt: sql`now()` })
73
+ .where(and(
74
+ eq(reviewItems.organisationId, organisationId),
75
+ eq(reviewItems.kind, kind),
76
+ eq(reviewItems.entity, entity),
77
+ eq(reviewItems.flowId, flowId),
78
+ eq(reviewItems.state, 'open'),
79
+ ))
80
+ .returning();
81
+ return rows[0] ?? null;
82
+ }
83
+
84
+ // The queue a person works through: most money at risk first, then oldest.
85
+ export async function listReview(db, organisationId, { state = 'open', kind, limit = 100 } = {}) {
86
+ const filters = [eq(reviewItems.organisationId, organisationId), eq(reviewItems.state, state)];
87
+ if (kind) filters.push(eq(reviewItems.kind, kind));
88
+ return db.select().from(reviewItems)
89
+ .where(and(...filters))
90
+ .orderBy(desc(reviewItems.moneyAtRiskCents), asc(reviewItems.createdAt))
91
+ .limit(limit);
92
+ }
@@ -0,0 +1,74 @@
1
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from 'node:crypto';
2
+ import { FlowError } from './errors.js';
3
+
4
+ // Flow's one secrets key. It encrypts the integration credentials an owner
5
+ // enters in Settings (server/integrations/setup.js) before they reach
6
+ // PostgreSQL, and derives each integration's own token key. It never sits in
7
+ // the database, so a copy of the database alone reads no credential.
8
+ //
9
+ // FLOW_SECRETS_KEY is the key: 32 bytes, as 64 hex characters or base64.
10
+ // When it is unset, Flow derives the key from SESSION_SECRET, a long random
11
+ // secret many hosts already hold. Changing the source makes stored
12
+ // credentials unreadable: an owner enters them again.
13
+
14
+ const MIN_SESSION_SECRET = 32;
15
+
16
+ export function decodeKey(raw) {
17
+ const value = String(raw ?? '').trim();
18
+ if (!value) return null;
19
+ if (/^[0-9a-f]{64}$/i.test(value)) return Buffer.from(value, 'hex');
20
+ if (/^[A-Za-z0-9+/]{43}=?$/.test(value)) {
21
+ const key = Buffer.from(value, 'base64');
22
+ if (key.length === 32) return key;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ const keyId = (key) => createHash('sha256').update(key).digest('hex').slice(0, 12);
28
+
29
+ // Answers { key, id, source } or null when neither setting gives a key. A
30
+ // FLOW_SECRETS_KEY that is not a key stops boot, so a typo never falls back.
31
+ export function resolveSecretsKey(env = process.env) {
32
+ const raw = String(env.FLOW_SECRETS_KEY ?? '').trim();
33
+ if (raw) {
34
+ const key = decodeKey(raw);
35
+ if (!key) throw new Error('FLOW_SECRETS_KEY must be a 32-byte key, as 64 hex characters or base64');
36
+ return { key, id: keyId(key), source: 'FLOW_SECRETS_KEY' };
37
+ }
38
+ const session = String(env.SESSION_SECRET ?? '').trim();
39
+ if (session.length >= MIN_SESSION_SECRET) {
40
+ const key = Buffer.from(hkdfSync('sha256', session, 'flow', 'flow-secrets-key-v1', 32));
41
+ return { key, id: keyId(key), source: 'SESSION_SECRET' };
42
+ }
43
+ return null;
44
+ }
45
+
46
+ // A separate key for one purpose (for example `XERO_TOKEN_KEY`), so no two
47
+ // uses share a key.
48
+ export function deriveKey(secrets, purpose) {
49
+ return Buffer.from(hkdfSync('sha256', secrets.key, 'flow', `flow-derived-${purpose}`, 32));
50
+ }
51
+
52
+ // AES-256-GCM. `context` (the integration and the setting's name) is bound
53
+ // as associated data, so a value moved to another setting does not open.
54
+ export function sealSecret(secrets, plaintext, context) {
55
+ const iv = randomBytes(12);
56
+ const cipher = createCipheriv('aes-256-gcm', secrets.key, iv);
57
+ cipher.setAAD(Buffer.from(context));
58
+ const data = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
59
+ return { v: 1, key: secrets.id, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: data.toString('base64') };
60
+ }
61
+
62
+ export function openSecret(secrets, sealed, context) {
63
+ if (!secrets || !sealed || sealed.v !== 1 || sealed.key !== secrets.id) {
64
+ throw new FlowError('secret_unreadable', 'this credential was stored with another key; enter it again');
65
+ }
66
+ try {
67
+ const decipher = createDecipheriv('aes-256-gcm', secrets.key, Buffer.from(sealed.iv, 'base64'));
68
+ decipher.setAAD(Buffer.from(context));
69
+ decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
70
+ return Buffer.concat([decipher.update(Buffer.from(sealed.data, 'base64')), decipher.final()]).toString('utf8');
71
+ } catch {
72
+ throw new FlowError('secret_unreadable', 'this credential cannot be read; enter it again');
73
+ }
74
+ }