@arcforgelabs/flow 2026.10.18 → 2026.10.19

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 (65) hide show
  1. package/README.md +3 -0
  2. package/SECURITY.md +4 -1
  3. package/npm-shrinkwrap.json +32 -3
  4. package/package.json +3 -2
  5. package/server/app.js +26 -3
  6. package/server/auth/routes.js +27 -12
  7. package/server/auth/session.js +6 -3
  8. package/server/auth/settings.js +26 -7
  9. package/server/auth/sign-in.html +17 -6
  10. package/server/core/invoices.js +6 -3
  11. package/server/core/ledger.js +420 -0
  12. package/server/db/migrations/0003_billing_ledger.sql +84 -0
  13. package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
  14. package/server/db/migrations/meta/0003_snapshot.json +1327 -0
  15. package/server/db/migrations/meta/0004_snapshot.json +1327 -0
  16. package/server/db/migrations/meta/_journal.json +14 -0
  17. package/server/db/schema.js +137 -0
  18. package/server/import/earlier-flow.js +230 -0
  19. package/server/import-earlier-flow.js +66 -0
  20. package/server/index.js +16 -4
  21. package/server/integrations/adapters.js +6 -0
  22. package/server/integrations/health.js +21 -0
  23. package/server/integrations/index.js +26 -0
  24. package/server/integrations/port.js +154 -6
  25. package/server/integrations/remove-tables.js +31 -0
  26. package/server/integrations/schema.js +62 -0
  27. package/server/integrations/seazona/client.js +196 -0
  28. package/server/integrations/seazona/drizzle.config.js +9 -0
  29. package/server/integrations/seazona/feed.js +104 -0
  30. package/server/integrations/seazona/governor.js +174 -0
  31. package/server/integrations/seazona/index.js +84 -0
  32. package/server/integrations/seazona/lanes.js +431 -0
  33. package/server/integrations/seazona/ledger.js +66 -0
  34. package/server/integrations/seazona/map.js +229 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
  37. package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
  38. package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
  40. package/server/integrations/seazona/replay.js +79 -0
  41. package/server/integrations/seazona/scheduler.js +78 -0
  42. package/server/integrations/seazona/schema.js +157 -0
  43. package/server/integrations/seazona/settings.js +161 -0
  44. package/server/integrations/seazona/store.js +219 -0
  45. package/server/integrations/seazona/sync-once.js +44 -0
  46. package/server/integrations/seazona/tables.js +17 -0
  47. package/server/integrations/xero/catalogue.js +184 -0
  48. package/server/integrations/xero/client.js +253 -0
  49. package/server/integrations/xero/connections.js +104 -0
  50. package/server/integrations/xero/drafts.js +525 -0
  51. package/server/integrations/xero/fixtures/replay.json +45 -0
  52. package/server/integrations/xero/governor.js +275 -0
  53. package/server/integrations/xero/index.js +56 -0
  54. package/server/integrations/xero/ledger.js +269 -0
  55. package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
  56. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
  57. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
  58. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +34 -0
  60. package/server/integrations/xero/oauth.js +143 -0
  61. package/server/integrations/xero/replay.js +83 -0
  62. package/server/integrations/xero/routes.js +234 -0
  63. package/server/integrations/xero/runtime.js +135 -0
  64. package/server/integrations/xero/settings.js +170 -0
  65. package/server/rate-limit.js +13 -19
@@ -0,0 +1,135 @@
1
+ import { createXeroClient, xeroError } from './client.js';
2
+ import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
3
+ import { createGovernor } from './governor.js';
4
+ import { createStateStore, refreshTokens } from './oauth.js';
5
+ import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from './replay.js';
6
+ import { resolveXeroSettings, writesAllowed } from './settings.js';
7
+
8
+ // The Xero adapter's state for one Flow process: settings, the rate-limit
9
+ // governor, the transport (real fetch, or replay), OAuth states, and the
10
+ // access tokens in memory. Access tokens live 30 minutes and are never
11
+ // stored; the refresh token is stored sealed (connections.js).
12
+
13
+ export function requireXeroSettings(env = process.env) {
14
+ const settings = resolveXeroSettings(env);
15
+ if (!settings) {
16
+ throw new Error('FLOW_INTEGRATIONS turns Xero on, so XERO_MODE must be "live", "sandbox" or "replay" (docs/XERO.md)');
17
+ }
18
+ return settings;
19
+ }
20
+
21
+ export function createXeroRuntime({ settings, fetch, now = Date.now, log = console } = {}) {
22
+ if (!settings) throw new TypeError('createXeroRuntime: settings are required');
23
+ const transport = fetch
24
+ ?? (settings.mode === 'replay' ? createReplayFetch(loadReplayFixtures(settings.replayFixtures ?? DEFAULT_REPLAY_FIXTURES)) : globalThis.fetch);
25
+ const governor = createGovernor({
26
+ config: { dayLimit: settings.dayLimit, usageWarningFraction: settings.usageWarningFraction },
27
+ now,
28
+ log,
29
+ });
30
+ const states = createStateStore({ now });
31
+ const accessTokens = new Map(); // organisation id -> { token, expiresAt }
32
+ const refreshing = new Map(); // organisation id -> promise
33
+ const queues = new Map(); // organisation id -> tail of its work queue
34
+
35
+ // Runs heavy Xero work for one organisation one at a time, in order: a
36
+ // sync and a batch of drafts draw on the same call budget, and running them
37
+ // together only buys 429s.
38
+ function serial(organisationId, fn) {
39
+ const run = (queues.get(organisationId) ?? Promise.resolve()).then(fn);
40
+ const tail = run.catch(() => {});
41
+ queues.set(organisationId, tail);
42
+ tail.then(() => {
43
+ if (queues.get(organisationId) === tail) queues.delete(organisationId);
44
+ });
45
+ return run;
46
+ }
47
+
48
+ function remember(organisationId, tokens) {
49
+ const seconds = Number(tokens.expires_in) || 1800;
50
+ accessTokens.set(organisationId, { token: tokens.access_token, expiresAt: now() + Math.max(5, seconds - 60) * 1000 });
51
+ }
52
+
53
+ // A stored connection made in another mode is not used: a sandbox
54
+ // connection never serves live, and the other way round.
55
+ function usable(connection) {
56
+ if (!connection) {
57
+ throw xeroError('xero_not_connected', 'Xero is not connected. An owner must connect Xero first.', { status: 409 });
58
+ }
59
+ if (connection.mode !== settings.mode) {
60
+ throw xeroError('xero_mode_changed', `Xero was connected in ${connection.mode} mode, and XERO_MODE is now ${settings.mode}. An owner must connect Xero again.`, { status: 409 });
61
+ }
62
+ if (settings.tenantId && connection.tenantId !== settings.tenantId) {
63
+ throw xeroError('xero_wrong_organisation', 'The connected Xero organisation is not the one XERO_TENANT_ID names. An owner must connect Xero again.', { status: 409 });
64
+ }
65
+ return connection;
66
+ }
67
+
68
+ // Refresh under a row lock, and store the rotated refresh token in the same
69
+ // transaction, so two refreshes never race and the newest token is kept.
70
+ async function refresh(db, organisationId) {
71
+ return db.transaction(async (tx) => {
72
+ const connection = usable(await readConnection(tx, organisationId, { lock: true }));
73
+ const refreshToken = decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
74
+ const tokens = await refreshTokens(settings, transport, refreshToken);
75
+ await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
76
+ remember(organisationId, tokens);
77
+ return tokens.access_token;
78
+ });
79
+ }
80
+
81
+ async function accessToken(db, organisationId) {
82
+ const cached = accessTokens.get(organisationId);
83
+ if (cached && cached.expiresAt > now()) return cached.token;
84
+ if (!refreshing.has(organisationId)) {
85
+ refreshing.set(organisationId, refresh(db, organisationId).finally(() => refreshing.delete(organisationId)));
86
+ }
87
+ return refreshing.get(organisationId);
88
+ }
89
+
90
+ // A client for one Flow organisation's connected Xero.
91
+ async function clientFor(db, organisationId) {
92
+ const connection = usable(await readConnection(db, organisationId));
93
+ const client = createXeroClient({
94
+ tenantId: connection.tenantId,
95
+ accessToken: () => accessToken(db, organisationId),
96
+ invalidateAccessToken: () => accessTokens.delete(organisationId),
97
+ fetch: transport,
98
+ governor,
99
+ apiBase: settings.endpoints.api,
100
+ log,
101
+ });
102
+ return { client, connection };
103
+ }
104
+
105
+ // A sandbox acts only on a Xero Demo Company: Flow proves it before it
106
+ // connects and before every write, and fails closed if it cannot.
107
+ async function assertEnvironment(client) {
108
+ if (settings.mode !== 'sandbox') return;
109
+ const organisation = await client.getOrganisation();
110
+ if (organisation?.Class !== 'DEMO') {
111
+ throw xeroError('xero_not_demo', 'XERO_MODE=sandbox acts only on a Xero Demo Company, and this organisation is not one.', { status: 409 });
112
+ }
113
+ }
114
+
115
+ function assertWritesAllowed() {
116
+ if (!writesAllowed(settings)) {
117
+ throw xeroError('xero_writes_off', 'XERO_MODE=live writes to Xero only when XERO_ALLOW_LIVE_WRITES=on.', { status: 409 });
118
+ }
119
+ }
120
+
121
+ return {
122
+ settings,
123
+ transport,
124
+ governor,
125
+ states,
126
+ remember,
127
+ forget: (organisationId) => accessTokens.delete(organisationId),
128
+ clientFor,
129
+ serial,
130
+ assertEnvironment,
131
+ assertWritesAllowed,
132
+ log,
133
+ now,
134
+ };
135
+ }
@@ -0,0 +1,170 @@
1
+ // Xero adapter settings, read from the environment once at boot. Errors name
2
+ // the variable, never its value: XERO_CLIENT_SECRET and XERO_TOKEN_KEY must
3
+ // never reach a log.
4
+ //
5
+ // XERO_MODE turns the adapter on and says which Xero it acts on (issue #10).
6
+ // It has no default: unset, the adapter is off.
7
+ // live the lab's own Xero organisation. Draft invoices are written only
8
+ // when XERO_ALLOW_LIVE_WRITES=on as well.
9
+ // sandbox a Xero Demo Company. Flow refuses any organisation whose class
10
+ // is not DEMO, so a sandbox can never write to a real ledger.
11
+ // replay no network: recorded, synthetic Xero responses
12
+ // (server/integrations/xero/fixtures). Development and CI.
13
+
14
+ export const XERO_MODES = Object.freeze(['live', 'sandbox', 'replay']);
15
+
16
+ // Xero's own public endpoints. They are the same for every installation.
17
+ export const XERO_ENDPOINTS = Object.freeze({
18
+ api: 'https://api.xero.com/api.xro/2.0',
19
+ authorize: 'https://login.xero.com/identity/connect/authorize',
20
+ token: 'https://identity.xero.com/connect/token',
21
+ revoke: 'https://identity.xero.com/connect/revocation',
22
+ connections: 'https://api.xero.com/connections',
23
+ // Deep links into the Xero web app, for a person to open a record.
24
+ web: 'https://go.xero.com',
25
+ });
26
+
27
+ export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
28
+ export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.settings.read';
29
+ export const DEFAULT_XERO_DAY_LIMIT = 1000;
30
+ export const DEFAULT_DUE_DAYS = 30;
31
+
32
+ function readEnv(env, name) {
33
+ const value = env?.[name];
34
+ return typeof value === 'string' ? value.trim() : '';
35
+ }
36
+
37
+ function readOnOff(env, name) {
38
+ const raw = readEnv(env, name).toLowerCase();
39
+ if (raw === '' || raw === 'off') return false;
40
+ if (raw === 'on') return true;
41
+ throw new Error(`${name} must be "on", "off" or unset`);
42
+ }
43
+
44
+ function readPositiveInteger(env, name, fallback) {
45
+ const raw = readEnv(env, name);
46
+ if (!raw) return fallback;
47
+ const value = Number(raw);
48
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive whole number`);
49
+ return value;
50
+ }
51
+
52
+ function readFraction(env, name, fallback) {
53
+ const raw = readEnv(env, name);
54
+ if (!raw) return fallback;
55
+ const value = Number(raw);
56
+ if (!Number.isFinite(value) || value <= 0 || value > 1) throw new Error(`${name} must be a number above 0 and at most 1`);
57
+ return value;
58
+ }
59
+
60
+ // A 32-byte key as 64 hex characters or base64. Returns null, never the input.
61
+ export function decodeTokenKey(raw) {
62
+ const value = String(raw ?? '').trim();
63
+ if (!value) return null;
64
+ if (/^[0-9a-f]{64}$/i.test(value)) return Buffer.from(value, 'hex');
65
+ if (/^[A-Za-z0-9+/]{43}=?$/.test(value)) {
66
+ const key = Buffer.from(value, 'base64');
67
+ if (key.length === 32) return key;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // The callback address Xero sends the owner back to. XERO_REDIRECT_URI wins;
73
+ // otherwise it is this installation's FLOW_PUBLIC_ORIGIN plus the callback
74
+ // path. It must match a redirect URI registered on the Xero app exactly.
75
+ export function resolveRedirectUri(env = process.env) {
76
+ const explicit = readEnv(env, 'XERO_REDIRECT_URI');
77
+ const origin = readEnv(env, 'FLOW_PUBLIC_ORIGIN');
78
+ const raw = explicit || (origin ? `${origin.replace(/\/+$/, '')}${XERO_CALLBACK_PATH}` : '');
79
+ if (!raw) return null;
80
+ const name = explicit ? 'XERO_REDIRECT_URI' : 'FLOW_PUBLIC_ORIGIN';
81
+ let url;
82
+ try {
83
+ url = new URL(raw);
84
+ } catch {
85
+ throw new Error(`${name} must be an absolute URL`);
86
+ }
87
+ const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
88
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
89
+ throw new Error(`${name} must be https (Xero accepts http only for localhost)`);
90
+ }
91
+ if (url.pathname !== XERO_CALLBACK_PATH || url.search || url.hash) {
92
+ throw new Error(`XERO_REDIRECT_URI must end in ${XERO_CALLBACK_PATH}, with no query`);
93
+ }
94
+ return url.toString();
95
+ }
96
+
97
+ function resolveTaxTypes(env) {
98
+ return {
99
+ GST: readEnv(env, 'XERO_TAX_TYPE_GST') || 'OUTPUT',
100
+ GST_FREE: readEnv(env, 'XERO_TAX_TYPE_GST_FREE') || 'EXEMPTOUTPUT',
101
+ };
102
+ }
103
+
104
+ function resolveTracking(env) {
105
+ const category = readEnv(env, 'XERO_TRACKING_CATEGORY');
106
+ const option = readEnv(env, 'XERO_TRACKING_OPTION');
107
+ if (!category && !option) return null;
108
+ if (!category || !option) throw new Error('XERO_TRACKING_CATEGORY and XERO_TRACKING_OPTION are set together');
109
+ return { category, option };
110
+ }
111
+
112
+ // null when the adapter is off. Otherwise the settings the adapter needs.
113
+ export function resolveXeroSettings(env = process.env) {
114
+ const mode = readEnv(env, 'XERO_MODE').toLowerCase();
115
+ if (!mode) return null;
116
+ if (!XERO_MODES.includes(mode)) throw new Error('XERO_MODE must be "live", "sandbox", "replay" or unset');
117
+
118
+ const clientId = readEnv(env, 'XERO_CLIENT_ID');
119
+ const clientSecret = readEnv(env, 'XERO_CLIENT_SECRET');
120
+ const tokenKey = decodeTokenKey(readEnv(env, 'XERO_TOKEN_KEY'));
121
+ if (mode !== 'replay') {
122
+ const missing = [];
123
+ if (!clientId) missing.push('XERO_CLIENT_ID');
124
+ if (!clientSecret) missing.push('XERO_CLIENT_SECRET');
125
+ if (missing.length) throw new Error(`XERO_MODE=${mode} needs ${missing.join(' and ')} (from the Xero app)`);
126
+ }
127
+ if (readEnv(env, 'XERO_TOKEN_KEY') && !tokenKey) {
128
+ throw new Error('XERO_TOKEN_KEY must be a 32-byte key, as 64 hex characters or base64');
129
+ }
130
+ if (mode !== 'replay' && !tokenKey) {
131
+ throw new Error(`XERO_MODE=${mode} needs XERO_TOKEN_KEY (a 32-byte key that encrypts the Xero refresh token)`);
132
+ }
133
+
134
+ const tenantId = readEnv(env, 'XERO_TENANT_ID') || null;
135
+ const tenantName = readEnv(env, 'XERO_TENANT_NAME') || null;
136
+ // A live connection must name the organisation it is for, so an owner who
137
+ // can see several Xero organisations cannot connect the wrong one.
138
+ if (mode === 'live' && !tenantId && !tenantName) {
139
+ throw new Error('XERO_MODE=live needs XERO_TENANT_ID or XERO_TENANT_NAME (the lab\'s Xero organisation)');
140
+ }
141
+
142
+ const invoiceNumber = (readEnv(env, 'XERO_INVOICE_NUMBER') || 'xero').toLowerCase();
143
+ if (!['xero', 'flow'].includes(invoiceNumber)) throw new Error('XERO_INVOICE_NUMBER must be "xero", "flow" or unset');
144
+
145
+ return {
146
+ mode,
147
+ clientId: clientId || (mode === 'replay' ? 'replay-client-id' : ''),
148
+ clientSecret: clientSecret || (mode === 'replay' ? 'replay-client-secret' : ''),
149
+ tokenKey,
150
+ redirectUri: resolveRedirectUri(env),
151
+ scope: readEnv(env, 'XERO_SCOPE') || DEFAULT_XERO_SCOPE,
152
+ tenantId,
153
+ tenantName,
154
+ liveWrites: readOnOff(env, 'XERO_ALLOW_LIVE_WRITES'),
155
+ dayLimit: readPositiveInteger(env, 'XERO_DAY_LIMIT', DEFAULT_XERO_DAY_LIMIT),
156
+ usageWarningFraction: readFraction(env, 'XERO_USAGE_WARNING_FRACTION', 0.7),
157
+ taxTypes: resolveTaxTypes(env),
158
+ tracking: resolveTracking(env),
159
+ defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
160
+ invoiceNumber,
161
+ replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
162
+ endpoints: XERO_ENDPOINTS,
163
+ };
164
+ }
165
+
166
+ // Whether this installation may write to Xero now. Reads always may.
167
+ export function writesAllowed(settings) {
168
+ if (!settings) return false;
169
+ return settings.mode !== 'live' || settings.liveWrites === true;
170
+ }
@@ -1,21 +1,15 @@
1
+ import expressRateLimit from 'express-rate-limit';
2
+
1
3
  // A fixed-window request limit per client address (req.ip, which honours
2
- // Express's `trust proxy`). In memory: one Flow process serves one lab.
3
- export function rateLimit({ limit, windowMs, now = Date.now }) {
4
- const windows = new Map();
5
- return (req, res, next) => {
6
- const at = now();
7
- if (windows.size > 10_000) {
8
- for (const [key, entry] of windows) if (entry.resetAt <= at) windows.delete(key);
9
- }
10
- const key = req.ip || 'unknown';
11
- let entry = windows.get(key);
12
- if (!entry || entry.resetAt <= at) {
13
- entry = { count: 0, resetAt: at + windowMs };
14
- windows.set(key, entry);
15
- }
16
- entry.count += 1;
17
- if (entry.count <= limit) return next();
18
- res.set('Retry-After', String(Math.ceil((entry.resetAt - at) / 1000)));
19
- return res.status(429).json({ ok: false, code: 'rate_limited', error: 'Too many requests. Wait and try again.' });
20
- };
4
+ // Express's `trust proxy`). In memory: one Flow process serves one lab. Each
5
+ // call makes its own counter. Built on express-rate-limit, which CodeQL
6
+ // recognises as rate limiting (js/missing-rate-limiting).
7
+ export function rateLimit({ limit, windowMs }) {
8
+ return expressRateLimit({
9
+ limit,
10
+ windowMs,
11
+ standardHeaders: 'draft-8', // RateLimit-Policy, RateLimit and Retry-After
12
+ legacyHeaders: false,
13
+ handler: (_req, res) => res.status(429).json({ ok: false, code: 'rate_limited', error: 'Too many requests. Wait and try again.' }),
14
+ });
21
15
  }