@arcforgelabs/flow 2026.10.22 → 2026.10.24

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.22",
3
+ "version": "2026.10.24",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.22",
9
+ "version": "2026.10.24",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.23.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.22",
3
+ "version": "2026.10.24",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/server/app.js CHANGED
@@ -3,6 +3,7 @@ import { createConnectorApi } from './api/connector.js';
3
3
  import { createSignInRouter } from './auth/routes.js';
4
4
  import { deployedBuildStatus } from './deployed-build.js';
5
5
  import { integrationsHealth } from './integrations/health.js';
6
+ import { createSetupRouter } from './integrations/setup.js';
6
7
  import { rateLimit } from './rate-limit.js';
7
8
  import { DEFAULT_FAMILY } from './theme/themes.js';
8
9
  import { messagePage } from './ui/page.js';
@@ -30,6 +31,7 @@ export function createApp({
30
31
  auth = null,
31
32
  updates = null,
32
33
  integrations = null,
34
+ secrets = null,
33
35
  trustProxy = false,
34
36
  rateLimitPerMinute = 120,
35
37
  signInRateLimitPerMinute = 30,
@@ -93,6 +95,11 @@ export function createApp({
93
95
  // An adapter's own pages (such as the end of connecting Xero) use
94
96
  // `messagePage` so they look like the rest of Flow.
95
97
  const page = (options) => messagePage({ ...options, defaultFamily });
98
+ // Settings, Integrations: an owner turns integrations on and enters their
99
+ // settings (server/integrations/setup.js).
100
+ if (integrations && database && auth?.personAuth) {
101
+ app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log }));
102
+ }
96
103
  for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
97
104
  app.use(path, handler);
98
105
  }
@@ -1,5 +1,5 @@
1
1
  import { and, eq, sql } from 'drizzle-orm';
2
- import { externalLinks, LINK_ENTITIES, LINK_MATCHES } from '../db/schema.js';
2
+ import { externalLinks, LINK_ENTITIES, LINK_MATCHES, priorLinks } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
4
  import { openReview, resolveReviewFor } from './review.js';
5
5
 
@@ -151,3 +151,16 @@ export async function externalIdFor(db, organisationId, { system, entity, flowId
151
151
  const row = await activeBy(db, organisationId, { system, entity }, externalLinks.flowId, flowId);
152
152
  return row?.externalId ?? null;
153
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,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
+ }
@@ -0,0 +1,18 @@
1
+ CREATE TABLE "prior_links" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "source_system" text NOT NULL,
5
+ "source_job_ref" text NOT NULL,
6
+ "system" text NOT NULL,
7
+ "external_id" text NOT NULL,
8
+ "external_number" text,
9
+ "matched_by" text NOT NULL,
10
+ "matched_at" timestamp with time zone,
11
+ "origin" text NOT NULL,
12
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
13
+ CONSTRAINT "prior_links_matched_by_valid" CHECK (matched_by in ('person', 'imported'))
14
+ );
15
+ --> statement-breakpoint
16
+ ALTER TABLE "prior_links" ADD CONSTRAINT "prior_links_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
17
+ CREATE UNIQUE INDEX "prior_links_job" ON "prior_links" USING btree ("organisation_id","source_system","source_job_ref","system");--> statement-breakpoint
18
+ CREATE INDEX "prior_links_external" ON "prior_links" USING btree ("organisation_id","system","external_id");
@@ -0,0 +1,23 @@
1
+ CREATE TABLE "integration_setting_events" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "adapter_id" text NOT NULL,
4
+ "kind" text NOT NULL,
5
+ "settings" text[] DEFAULT '{}'::text[] NOT NULL,
6
+ "actor" uuid,
7
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
8
+ CONSTRAINT "integration_setting_events_kind_valid" CHECK (kind in ('turned_on', 'turned_off', 'setting_changed', 'secret_set', 'secret_removed'))
9
+ );
10
+ --> statement-breakpoint
11
+ CREATE TABLE "integration_settings" (
12
+ "adapter_id" text PRIMARY KEY NOT NULL,
13
+ "enabled" boolean DEFAULT false NOT NULL,
14
+ "config" jsonb DEFAULT '{}'::jsonb NOT NULL,
15
+ "secrets" jsonb DEFAULT '{}'::jsonb NOT NULL,
16
+ "updated_by" uuid,
17
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
18
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
19
+ );
20
+ --> statement-breakpoint
21
+ ALTER TABLE "integration_setting_events" ADD CONSTRAINT "integration_setting_events_actor_people_id_fk" FOREIGN KEY ("actor") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
22
+ ALTER TABLE "integration_settings" ADD CONSTRAINT "integration_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
23
+ CREATE INDEX "integration_setting_events_adapter" ON "integration_setting_events" USING btree ("adapter_id","created_at");