@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,253 @@
1
+ import { FlowError } from '../../core/errors.js';
2
+ import { XERO_ENDPOINTS } from './settings.js';
3
+
4
+ // The Xero Accounting API client. Every call goes through the rate-limit
5
+ // governor (governor.js). A 429 is retried after the wait Xero asks for,
6
+ // except a DayLimit 429, which opens the circuit and fails fast. Tokens are
7
+ // sent only in the Authorization header and never logged or put in errors.
8
+
9
+ export const DEFAULT_TIMEOUT_MS = 15_000;
10
+ export const DEFAULT_ATTEMPTS = 2;
11
+ export const XERO_PAGE_SIZE = 100;
12
+
13
+ export function xeroError(code, message, { status = 502, ...extra } = {}) {
14
+ const error = new FlowError(code, message, extra.details);
15
+ error.status = status;
16
+ for (const [key, value] of Object.entries(extra)) if (key !== 'details') error[key] = value;
17
+ return error;
18
+ }
19
+
20
+ function parseHeaderCount(value) {
21
+ if (value === null || value === undefined || value === '') return null;
22
+ const number = Number(value);
23
+ return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
24
+ }
25
+
26
+ export function readRateLimitHeaders(response) {
27
+ const headers = response?.headers;
28
+ if (typeof headers?.get !== 'function') return null;
29
+ const info = {
30
+ appMinuteRemaining: parseHeaderCount(headers.get('x-appminlimit-remaining')),
31
+ minuteRemaining: parseHeaderCount(headers.get('x-minlimit-remaining')),
32
+ dayRemaining: parseHeaderCount(headers.get('x-daylimit-remaining')),
33
+ };
34
+ return Object.values(info).every((value) => value === null) ? null : info;
35
+ }
36
+
37
+ function retryAfterMsOf(response, now = Date.now()) {
38
+ const raw = response?.headers?.get?.('retry-after');
39
+ if (!raw) return null;
40
+ const seconds = Number(raw);
41
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
42
+ const at = Date.parse(raw);
43
+ return Number.isNaN(at) ? null : Math.max(0, at - now);
44
+ }
45
+
46
+ function parseJson(text) {
47
+ try {
48
+ return text ? JSON.parse(text) : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // Validation messages from a Xero ValidationException or a per-element
55
+ // ValidationErrors list.
56
+ export function validationMessages(payload) {
57
+ const messages = [];
58
+ const collect = (entity) => {
59
+ for (const item of entity?.ValidationErrors ?? []) if (item?.Message) messages.push(String(item.Message));
60
+ };
61
+ collect(payload);
62
+ for (const element of payload?.Elements ?? []) collect(element);
63
+ return [...new Set(messages)];
64
+ }
65
+
66
+ // The message comes only from Xero's structured error fields, never from a
67
+ // raw body, so nothing unexpected in a body reaches a log.
68
+ export function apiError(response, payload) {
69
+ const messages = validationMessages(payload);
70
+ const message = messages.length
71
+ ? messages.join(' ')
72
+ : payload?.Message || payload?.Detail || payload?.Title || `Xero answered ${response.status}.`;
73
+ return xeroError(response.status === 401 ? 'xero_unauthorized' : 'xero_api_error', message, {
74
+ status: response.status,
75
+ retryAfterMs: retryAfterMsOf(response),
76
+ rateLimitProblem: response.headers?.get?.('x-rate-limit-problem')?.trim() || null,
77
+ details: messages.length ? { validationErrors: messages } : undefined,
78
+ });
79
+ }
80
+
81
+ // A where-clause string literal for Xero's filter syntax.
82
+ function quote(value) {
83
+ return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
84
+ }
85
+
86
+ const first = (list) => (Array.isArray(list) && list.length ? list[0] : null);
87
+
88
+ // `accessToken()` returns a current access token; `invalidateAccessToken()`
89
+ // drops a cached one after Xero answers 401, and the request is tried once
90
+ // more with a fresh token.
91
+ export function createXeroClient({
92
+ tenantId,
93
+ accessToken,
94
+ invalidateAccessToken = () => {},
95
+ fetch: fetchImpl = globalThis.fetch,
96
+ governor,
97
+ apiBase = XERO_ENDPOINTS.api,
98
+ timeoutMs = DEFAULT_TIMEOUT_MS,
99
+ attempts = DEFAULT_ATTEMPTS,
100
+ log = console,
101
+ }) {
102
+ if (!tenantId) throw new TypeError('createXeroClient: tenantId is required');
103
+ if (typeof accessToken !== 'function') throw new TypeError('createXeroClient: accessToken() is required');
104
+ if (!governor) throw new TypeError('createXeroClient: a governor is required');
105
+ const base = apiBase.endsWith('/') ? apiBase : `${apiBase}/`;
106
+ let lastRateLimit = null;
107
+
108
+ async function send(method, url, headers, bodyText) {
109
+ await governor.acquire(tenantId);
110
+ const controller = new AbortController();
111
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
112
+ try {
113
+ let response;
114
+ try {
115
+ response = await fetchImpl(url, { method, headers, body: bodyText, signal: controller.signal });
116
+ } catch (error) {
117
+ if (error?.code === 'xero_replay_no_match') throw error;
118
+ const timedOut = error?.name === 'AbortError';
119
+ throw xeroError(timedOut ? 'xero_timeout' : 'xero_unreachable',
120
+ timedOut ? `Xero did not answer within ${timeoutMs} ms.` : 'Flow could not reach Xero.',
121
+ { status: timedOut ? 504 : 503 });
122
+ }
123
+ const text = await response.text().catch(() => '');
124
+ lastRateLimit = readRateLimitHeaders(response);
125
+ governor.observe(tenantId, lastRateLimit);
126
+ return { response, text };
127
+ } finally {
128
+ clearTimeout(timer);
129
+ governor.release(tenantId);
130
+ }
131
+ }
132
+
133
+ async function request(method, path, { query, body, headers: extra = {} } = {}) {
134
+ const url = new URL(String(path).replace(/^\/+/, ''), base);
135
+ for (const [key, value] of Object.entries(query ?? {})) {
136
+ if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
137
+ }
138
+ // Serialised once, so a retry sends the same bytes under the same
139
+ // Idempotency-Key.
140
+ const bodyText = body === undefined ? undefined : JSON.stringify(body);
141
+ let refreshed = false;
142
+ for (let attempt = 1; ; attempt += 1) {
143
+ const headers = {
144
+ ...extra,
145
+ Authorization: `Bearer ${await accessToken()}`,
146
+ Accept: 'application/json',
147
+ 'xero-tenant-id': tenantId,
148
+ ...(bodyText === undefined ? {} : { 'Content-Type': 'application/json' }),
149
+ };
150
+ const { response, text } = await send(method, url, headers, bodyText);
151
+ const payload = parseJson(text);
152
+ if (response.ok) return payload ?? {};
153
+
154
+ const error = apiError(response, payload);
155
+ if (response.status === 401 && !refreshed) {
156
+ refreshed = true;
157
+ invalidateAccessToken();
158
+ attempt -= 1;
159
+ continue;
160
+ }
161
+ if (response.status === 429) {
162
+ const problem = error.rateLimitProblem || '';
163
+ if (/DayLimit/i.test(problem)) {
164
+ const ms = error.retryAfterMs > 0 ? error.retryAfterMs : governor.config.dayMs;
165
+ governor.tripCircuit(tenantId, 'DayLimit', ms);
166
+ log.warn?.(`[xero] daily call limit reached on ${method} ${url.pathname}; calls stop until ${new Date(Date.now() + ms).toISOString()}`);
167
+ error.code = 'xero_rate_limited';
168
+ throw error;
169
+ }
170
+ if (attempt < attempts) {
171
+ const waitMs = governor.retryDelayMs(problem, error.retryAfterMs);
172
+ log.info?.(`[xero] 429 ${problem || 'rate limit'} on ${method} ${url.pathname}; retry ${attempt + 1} of ${attempts} in ${waitMs} ms`);
173
+ await governor.sleep(waitMs);
174
+ continue;
175
+ }
176
+ error.code = 'xero_rate_limited';
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ async function collectPages(fetchPage, { maxPages = 100 } = {}) {
183
+ const rows = [];
184
+ for (let page = 1; page <= maxPages; page += 1) {
185
+ const batch = await fetchPage(page);
186
+ if (!Array.isArray(batch) || !batch.length) break;
187
+ rows.push(...batch);
188
+ if (batch.length < XERO_PAGE_SIZE) break;
189
+ }
190
+ return rows;
191
+ }
192
+
193
+ return {
194
+ tenantId,
195
+ request,
196
+ lastRateLimit: () => lastRateLimit,
197
+ status: () => governor.status(tenantId),
198
+
199
+ async getOrganisation() {
200
+ return first((await request('GET', 'Organisation')).Organisations);
201
+ },
202
+ async listContacts({ maxPages } = {}) {
203
+ return collectPages(async (page) => (await request('GET', 'Contacts', { query: { page } })).Contacts, { maxPages });
204
+ },
205
+ async findContactsByName(name) {
206
+ return (await request('GET', 'Contacts', { query: { where: `Name==${quote(name)}` } })).Contacts ?? [];
207
+ },
208
+ async listItems() {
209
+ return (await request('GET', 'Items')).Items ?? [];
210
+ },
211
+ async getTrackingCategories() {
212
+ return (await request('GET', 'TrackingCategories')).TrackingCategories ?? [];
213
+ },
214
+ async getInvoice(invoiceId) {
215
+ return first((await request('GET', `Invoices/${encodeURIComponent(invoiceId)}`)).Invoices);
216
+ },
217
+ async findInvoicesByReference(reference) {
218
+ return collectPages(async (page) => (await request('GET', 'Invoices', {
219
+ query: { page, where: `Type=="ACCREC" AND Reference==${quote(reference)}` },
220
+ })).Invoices, { maxPages: 10 });
221
+ },
222
+ async findInvoicesByNumber(number) {
223
+ return (await request('GET', 'Invoices', { query: { InvoiceNumbers: number } })).Invoices ?? [];
224
+ },
225
+ // Sales invoice payments created or changed since `since` (a Date), page
226
+ // by page. Xero's If-Modified-Since narrows Payments to changed records.
227
+ async listPaymentsSince(since, { maxPages } = {}) {
228
+ return collectPages(async (page) => (await request('GET', 'Payments', {
229
+ query: { page, where: 'PaymentType=="ACCRECPAYMENT"' },
230
+ headers: { 'If-Modified-Since': since.toISOString().replace(/\.\d{3}Z$/, '') },
231
+ })).Payments, { maxPages });
232
+ },
233
+ // One invoice. `idempotencyKey` makes a retry within Xero's window
234
+ // return the first result instead of a second invoice.
235
+ async createInvoice(invoice, { idempotencyKey }) {
236
+ if (!idempotencyKey) throw new TypeError('createInvoice: idempotencyKey is required');
237
+ const payload = await request('POST', 'Invoices', {
238
+ query: { summarizeErrors: 'false' },
239
+ body: { Invoices: [invoice] },
240
+ headers: { 'Idempotency-Key': idempotencyKey },
241
+ });
242
+ const created = first(payload.Invoices);
243
+ const messages = validationMessages(created);
244
+ if (created?.HasErrors || created?.StatusAttributeString === 'ERROR' || messages.length) {
245
+ throw xeroError('xero_validation_failed', messages.join(' ') || 'Xero refused the invoice.', {
246
+ status: 422,
247
+ details: { validationErrors: messages },
248
+ });
249
+ }
250
+ return created;
251
+ },
252
+ };
253
+ }
@@ -0,0 +1,104 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
3
+ import { xeroError } from './client.js';
4
+
5
+ // The Xero connection of each Flow organisation: which Xero organisation it
6
+ // is, and the refresh token. Xero rotates the refresh token on every use, so
7
+ // the newest one must be stored durably. It is stored encrypted with
8
+ // XERO_TOKEN_KEY (AES-256-GCM), bound to the Flow organisation so a stored
9
+ // token cannot be copied to another row. The client id and secret come from
10
+ // settings and are never stored. Tokens are never logged.
11
+
12
+ const FORMAT = 'v1';
13
+
14
+ function aad(organisationId) {
15
+ return Buffer.from(`flow:xero:refresh-token:${organisationId}`);
16
+ }
17
+
18
+ export function encryptToken(token, key, organisationId) {
19
+ if (!key) throw xeroError('xero_token_key_missing', 'XERO_TOKEN_KEY is not set, so Flow cannot store the Xero refresh token.', { status: 503 });
20
+ const iv = randomBytes(12);
21
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
22
+ cipher.setAAD(aad(organisationId));
23
+ const sealed = Buffer.concat([cipher.update(String(token), 'utf8'), cipher.final()]);
24
+ return [FORMAT, iv.toString('base64'), cipher.getAuthTag().toString('base64'), sealed.toString('base64')].join(':');
25
+ }
26
+
27
+ export function decryptToken(stored, key, organisationId) {
28
+ const [format, iv, tag, sealed] = String(stored ?? '').split(':');
29
+ if (!key || format !== FORMAT || !iv || !tag || !sealed) {
30
+ throw xeroError('xero_token_unreadable', 'The stored Xero token cannot be read with XERO_TOKEN_KEY. Connect Xero again.', { status: 409 });
31
+ }
32
+ try {
33
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
34
+ decipher.setAAD(aad(organisationId));
35
+ decipher.setAuthTag(Buffer.from(tag, 'base64'));
36
+ return Buffer.concat([decipher.update(Buffer.from(sealed, 'base64')), decipher.final()]).toString('utf8');
37
+ } catch {
38
+ throw xeroError('xero_token_unreadable', 'The stored Xero token cannot be read with XERO_TOKEN_KEY. Connect Xero again.', { status: 409 });
39
+ }
40
+ }
41
+
42
+ const COLUMNS = sql`organisation_id, mode, tenant_id, tenant_name, scope, refresh_token_enc, connected_by, connected_at, refreshed_at`;
43
+
44
+ function mapRow(row) {
45
+ if (!row) return null;
46
+ return {
47
+ organisationId: row.organisation_id,
48
+ mode: row.mode,
49
+ tenantId: row.tenant_id,
50
+ tenantName: row.tenant_name,
51
+ scope: row.scope,
52
+ refreshTokenEnc: row.refresh_token_enc,
53
+ connectedBy: row.connected_by,
54
+ connectedAt: row.connected_at,
55
+ refreshedAt: row.refreshed_at,
56
+ };
57
+ }
58
+
59
+ // The connection with its sealed token, or null. `lock` holds the row for
60
+ // the transaction, so two token refreshes never race.
61
+ export async function readConnection(db, organisationId, { lock = false } = {}) {
62
+ const { rows } = await db.execute(sql`select ${COLUMNS} from xero_connections where organisation_id = ${organisationId} ${lock ? sql`for update` : sql``}`);
63
+ return mapRow(rows[0]);
64
+ }
65
+
66
+ export async function saveConnection(db, { organisationId, mode, tenantId, tenantName, scope, refreshToken, connectedBy, key }) {
67
+ const sealed = encryptToken(refreshToken, key, organisationId);
68
+ const { rows } = await db.execute(sql`
69
+ insert into xero_connections (organisation_id, mode, tenant_id, tenant_name, scope, refresh_token_enc, connected_by)
70
+ values (${organisationId}, ${mode}, ${tenantId}, ${tenantName}, ${scope}, ${sealed}, ${connectedBy})
71
+ on conflict (organisation_id) do update set
72
+ mode = excluded.mode, tenant_id = excluded.tenant_id, tenant_name = excluded.tenant_name,
73
+ scope = excluded.scope, refresh_token_enc = excluded.refresh_token_enc,
74
+ connected_by = excluded.connected_by, connected_at = now(), refreshed_at = null, updated_at = now()
75
+ returning ${COLUMNS}`);
76
+ return mapRow(rows[0]);
77
+ }
78
+
79
+ export async function storeRotatedToken(db, organisationId, { refreshToken, scope, key }) {
80
+ const sealed = encryptToken(refreshToken, key, organisationId);
81
+ await db.execute(sql`
82
+ update xero_connections set refresh_token_enc = ${sealed}, scope = coalesce(${scope ?? null}, scope),
83
+ refreshed_at = now(), updated_at = now()
84
+ where organisation_id = ${organisationId}`);
85
+ }
86
+
87
+ export async function deleteConnection(db, organisationId) {
88
+ const { rows } = await db.execute(sql`delete from xero_connections where organisation_id = ${organisationId} returning ${COLUMNS}`);
89
+ return mapRow(rows[0]);
90
+ }
91
+
92
+ // What may be shown about a connection: never the token.
93
+ export function describeConnection(connection) {
94
+ if (!connection) return null;
95
+ return {
96
+ mode: connection.mode,
97
+ tenantId: connection.tenantId,
98
+ tenantName: connection.tenantName,
99
+ scopes: String(connection.scope ?? '').split(/\s+/).filter(Boolean),
100
+ connectedBy: connection.connectedBy,
101
+ connectedAt: connection.connectedAt,
102
+ refreshedAt: connection.refreshedAt,
103
+ };
104
+ }