@arcforgelabs/flow 2026.10.22 → 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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/app.js +7 -0
- package/server/core/links.js +14 -1
- package/server/core/secrets.js +74 -0
- package/server/db/migrations/0006_prior_links.sql +18 -0
- package/server/db/migrations/0007_integration_settings.sql +23 -0
- package/server/db/migrations/meta/0006_snapshot.json +2306 -0
- package/server/db/migrations/meta/0007_snapshot.json +2473 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +57 -1
- package/server/import/at-start.js +69 -1
- package/server/index.js +28 -8
- package/server/integrations/index.js +9 -3
- package/server/integrations/port.js +101 -9
- package/server/integrations/seazona/index.js +25 -3
- package/server/integrations/setup.js +234 -0
- package/server/integrations/xero/client.js +13 -0
- package/server/integrations/xero/import.js +116 -0
- package/server/integrations/xero/index.js +45 -5
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +96 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +295 -0
- package/server/integrations/xero/routes.js +26 -1
- package/server/integrations/xero/settings.js +8 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +142 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.23",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.23",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.23.2",
|
package/package.json
CHANGED
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
|
}
|
package/server/core/links.js
CHANGED
|
@@ -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");
|