@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
package/README.md CHANGED
@@ -33,6 +33,9 @@ run a lab.
33
33
  | `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
34
34
  | `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
35
35
  | `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
36
+ | `docs/INTEGRATIONS.md` | How an integration adapter is built, turned on and off, and owns its tables |
37
+ | `docs/XERO.md` | The Xero adapter: modes, settings, rate limits, connecting Xero and draft invoices |
38
+ | `docs/SEAZONA.md` | The Seazona adapter: modes, settings, the rate governor, replay files and its tables |
36
39
  | `server/integrations/port.js` | The integration port every adapter implements |
37
40
 
38
41
  ## Getting started
package/SECURITY.md CHANGED
@@ -46,7 +46,10 @@ paste a credential, in full or in part.
46
46
  Secrets), never in the repository, a log, an issue or a pull request. Name
47
47
  the variable, never its value.
48
48
  - No account has a built-in or fallback password, in any environment.
49
- - Sign-in, API and integration routes are rate limited.
49
+ - Sign-in, API and integration routes are rate limited per client
50
+ (`server/rate-limit.js`): the sign-in page and its config 30 requests a
51
+ minute, `/api` 120, and the Clerk proxy 300. A client over the limit gets
52
+ `429` with `Retry-After`.
50
53
  - Session cookies are `Secure`, `HttpOnly` and `SameSite`.
51
54
  - Flow stores no clinical notes, images or scans, and keeps a patient
52
55
  reference only where an invoice must print one.
@@ -1,21 +1,22 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.18",
3
+ "version": "2026.10.19",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.18",
9
+ "version": "2026.10.19",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.23.2",
13
13
  "drizzle-orm": "0.45.3",
14
14
  "express": "5.3.0",
15
+ "express-rate-limit": "8.7.0",
15
16
  "pg": "8.23.1"
16
17
  },
17
18
  "bin": {
18
- "flow-server": "server/index.js"
19
+ "flow-server": "server/flow-server.js"
19
20
  },
20
21
  "devDependencies": {
21
22
  "drizzle-kit": "0.31.11"
@@ -1431,6 +1432,25 @@
1431
1432
  "url": "https://opencollective.com/express"
1432
1433
  }
1433
1434
  },
1435
+ "node_modules/express-rate-limit": {
1436
+ "version": "8.7.0",
1437
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
1438
+ "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
1439
+ "license": "MIT",
1440
+ "dependencies": {
1441
+ "debug": "^4.4.3",
1442
+ "ip-address": "^10.2.0"
1443
+ },
1444
+ "engines": {
1445
+ "node": ">= 16"
1446
+ },
1447
+ "funding": {
1448
+ "url": "https://github.com/sponsors/express-rate-limit"
1449
+ },
1450
+ "peerDependencies": {
1451
+ "express": ">= 4.11"
1452
+ }
1453
+ },
1434
1454
  "node_modules/fast-sha256": {
1435
1455
  "version": "1.3.0",
1436
1456
  "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
@@ -1634,6 +1654,15 @@
1634
1654
  "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1635
1655
  "license": "ISC"
1636
1656
  },
1657
+ "node_modules/ip-address": {
1658
+ "version": "10.7.3",
1659
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
1660
+ "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
1661
+ "license": "MIT",
1662
+ "engines": {
1663
+ "node": ">= 12"
1664
+ }
1665
+ },
1637
1666
  "node_modules/ipaddr.js": {
1638
1667
  "version": "1.9.1",
1639
1668
  "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.18",
3
+ "version": "2026.10.19",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,13 +30,14 @@
30
30
  },
31
31
  "scripts": {
32
32
  "start": "node server/index.js",
33
- "test": "node --test test/*.test.js",
33
+ "test": "node --test --test-concurrency=1 test/*.test.js",
34
34
  "db:generate": "drizzle-kit generate"
35
35
  },
36
36
  "dependencies": {
37
37
  "@clerk/backend": "3.23.2",
38
38
  "drizzle-orm": "0.45.3",
39
39
  "express": "5.3.0",
40
+ "express-rate-limit": "8.7.0",
40
41
  "pg": "8.23.1"
41
42
  },
42
43
  "devDependencies": {
package/server/app.js CHANGED
@@ -2,6 +2,7 @@ import express from 'express';
2
2
  import { createConnectorApi } from './api/connector.js';
3
3
  import { createSignInRouter } from './auth/routes.js';
4
4
  import { deployedBuildStatus } from './deployed-build.js';
5
+ import { integrationsHealth } from './integrations/health.js';
5
6
  import { rateLimit } from './rate-limit.js';
6
7
 
7
8
  // The Flow HTTP app. Routes are added per module (invoices, bills, payments,
@@ -10,15 +11,25 @@ import { rateLimit } from './rate-limit.js';
10
11
  // `auth` is { settings, clerk, personAuth, clerkProxy } from server/index.js
11
12
  // (or a test). Without it sign-in is off and every person route answers 503.
12
13
  // `trustProxy` is Express's `trust proxy`: 1 hop on Replit, so rate limits
13
- // count per client rather than per proxy. `build` is what GET /health reports
14
+ // count per client rather than per proxy. The limits are requests per minute
15
+ // per client, sized for a lab of a few people: `rateLimitPerMinute` for /api,
16
+ // `signInRateLimitPerMinute` for the sign-in page and its config, and
17
+ // `clerkProxyRateLimitPerMinute` for Clerk's Frontend API, which a signed-in
18
+ // browser calls often. `build` is what GET /health reports
14
19
  // (server/deployed-build.js); `updates` is a server/update-check.js checker.
20
+ // `integrations` is the started registry (server/integrations/index.js); each
21
+ // adapter's own routes are mounted under /api/<id>, and GET /health reports
22
+ // each one (server/integrations/health.js).
15
23
  export function createApp({
16
24
  build = deployedBuildStatus(),
17
25
  database = null,
18
26
  auth = null,
19
27
  updates = null,
28
+ integrations = null,
20
29
  trustProxy = false,
21
30
  rateLimitPerMinute = 120,
31
+ signInRateLimitPerMinute = 30,
32
+ clerkProxyRateLimitPerMinute = 300,
22
33
  log = console,
23
34
  } = {}) {
24
35
  const app = express();
@@ -34,12 +45,19 @@ export function createApp({
34
45
  // The running build (commit, deploy ref, source, CI result, release), so
35
46
  // an operator can confirm what a publish deployed. The repository is
36
47
  // public, so none of it is a secret.
37
- res.status(ok ? 200 : 503).json({ ok, service: 'flow', database: databaseOk, build });
48
+ // Each integration's state and its own health (mode, freshness, failing
49
+ // records). An integration problem does not fail the health check: Flow's
50
+ // core works with no integration connected (VISION.md).
51
+ const body = { ok, service: 'flow', database: databaseOk, build };
52
+ if (integrations) body.integrations = await integrationsHealth(integrations);
53
+ res.status(ok ? 200 : 503).json(body);
38
54
  });
39
55
 
40
56
  // Clerk's Frontend API through Flow's origin (CLERK_PROXY_URL). Before any
41
57
  // body parser, so request bodies reach Clerk intact.
42
- if (auth?.clerkProxy) app.use(auth.clerkProxy.path, auth.clerkProxy.handler);
58
+ if (auth?.clerkProxy) {
59
+ app.use(auth.clerkProxy.path, rateLimit({ limit: clerkProxyRateLimitPerMinute, windowMs: 60_000 }), auth.clerkProxy.handler);
60
+ }
43
61
 
44
62
  app.use('/api', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
45
63
 
@@ -64,11 +82,16 @@ export function createApp({
64
82
  }
65
83
  }
66
84
 
85
+ for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log }) ?? []) {
86
+ app.use(path, handler);
87
+ }
88
+
67
89
  app.use(createSignInRouter({
68
90
  settings: auth?.settings ?? { mode: 'off' },
69
91
  db: database?.db ?? null,
70
92
  clerk: auth?.clerk ?? null,
71
93
  personAuth: auth?.personAuth ?? null,
94
+ rateLimitPerMinute: signInRateLimitPerMinute,
72
95
  log,
73
96
  }));
74
97
 
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import express from 'express';
3
3
  import { FlowError } from '../core/errors.js';
4
+ import { rateLimit } from '../rate-limit.js';
4
5
  import { ensureClerkAccount } from './clerk.js';
5
6
  import {
6
7
  findPersonByEmail,
@@ -17,14 +18,23 @@ const signInPage = fileURLToPath(new URL('./sign-in.html', import.meta.url));
17
18
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
19
  const ERROR_STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };
19
20
 
20
- // The Clerk script the sign-in page loads: through Flow's own origin when
21
+ // The Clerk scripts the sign-in page loads: through Flow's own origin when
21
22
  // CLERK_PROXY_URL is set, otherwise from the Clerk instance's Frontend API
22
- // (its host is encoded in the publishable key).
23
- export function clerkScriptUrl({ publishableKey, proxyUrl }) {
24
- const base = proxyUrl
23
+ // (its host is encoded in the publishable key). clerk-js 6 ships without its
24
+ // prebuilt components; the sign-in widget comes from @clerk/ui, passed to
25
+ // Clerk.load().
26
+ function clerkScriptBase({ publishableKey, proxyUrl }) {
27
+ return proxyUrl
25
28
  ? proxyUrl.replace(/\/+$/, '')
26
29
  : `https://${Buffer.from(publishableKey.split('_')[2] ?? '', 'base64').toString('utf8').replace(/\$$/, '')}`;
27
- return `${base}/npm/@clerk/clerk-js@6/dist/clerk.browser.js`;
30
+ }
31
+
32
+ export function clerkScriptUrl(keys) {
33
+ return `${clerkScriptBase(keys)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js`;
34
+ }
35
+
36
+ export function clerkUiScriptUrl(keys) {
37
+ return `${clerkScriptBase(keys)}/npm/@clerk/ui@1/dist/ui.browser.js`;
28
38
  }
29
39
 
30
40
  function sendError(res, error, log) {
@@ -36,28 +46,33 @@ function sendError(res, error, log) {
36
46
  }
37
47
 
38
48
  // Sign-in routes. `settings` comes from resolveSignInSettings; with sign-in
39
- // off, person routes answer 503 and nothing else is mounted.
40
- export function createSignInRouter({ settings, db, clerk, personAuth, log = console }) {
49
+ // off, person routes answer 503 and nothing else is mounted. The sign-in page
50
+ // and its config have their own limit per client (`rateLimitPerMinute`);
51
+ // /api/me and /api/people sit under the app's /api limit as well.
52
+ export function createSignInRouter({ settings, db, clerk, personAuth, rateLimitPerMinute = 30, log = console }) {
41
53
  const router = express.Router();
42
54
  const signInOn = settings.mode === 'clerk';
55
+ const signInLimit = rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 });
43
56
 
44
57
  // Public: what the sign-in page needs. The publishable key is meant for
45
58
  // browsers. Served at runtime, so one build serves Replit's development
46
59
  // and production Clerk instances.
47
- router.get('/api/auth/config', (_req, res) => {
60
+ router.get('/api/auth/config', signInLimit, (_req, res) => {
48
61
  res.set('Cache-Control', 'no-store');
49
62
  if (!signInOn) return res.json({ ok: true, mode: 'off' });
50
63
  const { publishableKey, proxyUrl } = settings.keys;
51
64
  return res.json({
52
65
  ok: true,
53
66
  mode: 'clerk',
54
- publicSignUp: settings.publicSignUp,
55
- clerk: { publishableKey, proxyUrl, scriptUrl: clerkScriptUrl(settings.keys) },
67
+ // The widget creates Clerk accounts only when someone could ask for
68
+ // access; Flow still refuses an email outside FLOW_SIGN_UP_DOMAINS.
69
+ publicSignUp: settings.publicSignUp || Boolean(settings.signUpDomains?.length),
70
+ clerk: { publishableKey, proxyUrl, scriptUrl: clerkScriptUrl(settings.keys), uiScriptUrl: clerkUiScriptUrl(settings.keys) },
56
71
  });
57
72
  });
58
73
 
59
- router.get('/', (_req, res) => res.redirect(302, '/sign-in'));
60
- router.get('/sign-in', (_req, res) => {
74
+ router.get('/', signInLimit, (_req, res) => res.redirect(302, '/sign-in'));
75
+ router.get('/sign-in', signInLimit, (_req, res) => {
61
76
  res.set('Cache-Control', 'no-store');
62
77
  res.set('X-Frame-Options', 'DENY');
63
78
  res.sendFile(signInPage);
@@ -46,6 +46,7 @@ export function createPersonAuth({
46
46
  db,
47
47
  clerk,
48
48
  publicSignUp = false,
49
+ signUpDomains = [],
49
50
  ownerGoogleDomains = [],
50
51
  ownerMfaMaxAgeMinutes = DEFAULT_OWNER_MFA_MAX_AGE_MINUTES,
51
52
  log = console,
@@ -81,9 +82,11 @@ export function createPersonAuth({
81
82
  return again ? { person: again } : { denial: 'email_linked_elsewhere' };
82
83
  }
83
84
 
84
- // Someone Flow does not know. With public sign-up off Flow records
85
- // nothing about them: they simply have no access.
86
- if (!publicSignUp) {
85
+ // Someone Flow does not know. Unless public sign-up is on or their
86
+ // email's domain may sign up, Flow records nothing about them: they
87
+ // simply have no access.
88
+ const domain = email.slice(email.lastIndexOf('@') + 1);
89
+ if (!publicSignUp && !signUpDomains.includes(domain)) {
87
90
  log.warn?.(`[auth] Clerk user ${clerkUserId} refused: not invited`);
88
91
  return { denial: 'not_invited' };
89
92
  }
@@ -64,24 +64,42 @@ export function parseOwnerMfaMaxAge(raw) {
64
64
  return minutes;
65
65
  }
66
66
 
67
- // FLOW_OWNER_GOOGLE_DOMAINS: comma-separated email domains whose Google
68
- // Workspace sign-in counts as an owner's verified sign-in (that Workspace
69
- // enforces 2-Step Verification). No default: unset turns that path off.
70
- export function parseOwnerGoogleDomains(raw) {
67
+ function parseDomains(raw, name) {
71
68
  const domains = String(raw ?? '').split(',').map((part) => part.trim().toLowerCase()).filter(Boolean);
72
69
  for (const domain of domains) {
73
70
  if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/.test(domain)) {
74
- throw new Error('FLOW_OWNER_GOOGLE_DOMAINS must be a comma-separated list of email domains (e.g. example.com)');
71
+ throw new Error(`${name} must be a comma-separated list of email domains (e.g. example.com)`);
75
72
  }
76
73
  }
77
74
  return [...new Set(domains)];
78
75
  }
79
76
 
77
+ // FLOW_OWNER_GOOGLE_DOMAINS: comma-separated email domains whose Google
78
+ // Workspace sign-in counts as an owner's verified sign-in (that Workspace
79
+ // enforces 2-Step Verification). No default: unset turns that path off.
80
+ export function parseOwnerGoogleDomains(raw) {
81
+ return parseDomains(raw, 'FLOW_OWNER_GOOGLE_DOMAINS');
82
+ }
83
+
84
+ // FLOW_SIGN_UP_DOMAINS: comma-separated email domains whose people may ask
85
+ // for access without an invitation. They wait as `pending` until an owner
86
+ // approves them; everyone else still needs an invitation. Unset: none.
87
+ export function parseSignUpDomains(raw) {
88
+ return parseDomains(raw, 'FLOW_SIGN_UP_DOMAINS');
89
+ }
90
+
80
91
  // FLOW_PUBLIC_ORIGIN: the published app's origin. Clerk then accepts only
81
- // session tokens minted for it. Unset: Clerk's default.
92
+ // session tokens minted for it. Unset: Clerk's default. A Replit workspace
93
+ // shares its secrets with the published app, so there its own development
94
+ // address (REPLIT_DEV_DOMAIN, never set on the published app) is accepted too.
82
95
  export function resolveAuthorizedParties(env = process.env) {
83
96
  const raw = readEnv(env, 'FLOW_PUBLIC_ORIGIN');
84
97
  if (!raw) return [];
98
+ const devDomain = readEnv(env, 'REPLIT_DEPLOYMENT') ? '' : readEnv(env, 'REPLIT_DEV_DOMAIN');
99
+ return [publicOrigin(raw), ...(devDomain ? [`https://${devDomain}`] : [])];
100
+ }
101
+
102
+ function publicOrigin(raw) {
85
103
  let url;
86
104
  try {
87
105
  url = new URL(raw);
@@ -91,7 +109,7 @@ export function resolveAuthorizedParties(env = process.env) {
91
109
  if (url.protocol !== 'https:' && url.protocol !== 'http:') {
92
110
  throw new Error('FLOW_PUBLIC_ORIGIN must be an http(s) URL');
93
111
  }
94
- return [url.origin];
112
+ return url.origin;
95
113
  }
96
114
 
97
115
  // FLOW_FIRST_OWNER_EMAIL: while the installation has no owner, Flow invites
@@ -116,6 +134,7 @@ export function resolveSignInSettings(env = process.env) {
116
134
  mode,
117
135
  keys: resolveClerkKeys(env),
118
136
  publicSignUp: resolvePublicSignUp(env),
137
+ signUpDomains: parseSignUpDomains(readEnv(env, 'FLOW_SIGN_UP_DOMAINS')),
119
138
  ownerMfaMaxAgeMinutes: parseOwnerMfaMaxAge(readEnv(env, 'FLOW_OWNER_MFA_MAX_AGE')),
120
139
  ownerGoogleDomains: parseOwnerGoogleDomains(readEnv(env, 'FLOW_OWNER_GOOGLE_DOMAINS')),
121
140
  authorizedParties: resolveAuthorizedParties(env),
@@ -60,7 +60,7 @@ if (!config || config.mode !== 'clerk') {
60
60
  } else {
61
61
  await loadClerk(config.clerk);
62
62
  const Clerk = window.Clerk;
63
- await Clerk.load();
63
+ await Clerk.load({ ui: { ClerkUI: window.__internal_ClerkUICtor } });
64
64
  Clerk.addListener(({ user }) => { if (user && $('signed-in').hidden) showSignedIn(); });
65
65
  if (Clerk.user) {
66
66
  await showSignedIn();
@@ -78,15 +78,26 @@ if (!config || config.mode !== 'clerk') {
78
78
  }
79
79
  }
80
80
 
81
- function loadClerk({ publishableKey, proxyUrl, scriptUrl }) {
81
+ // clerk-js, then its prebuilt components (@clerk/ui), which set
82
+ // window.__internal_ClerkUICtor for Clerk.load().
83
+ function loadClerk({ publishableKey, proxyUrl, scriptUrl, uiScriptUrl }) {
84
+ return Promise.all([
85
+ loadScript(scriptUrl, (script) => {
86
+ script.dataset.clerkPublishableKey = publishableKey;
87
+ if (proxyUrl) script.dataset.clerkProxyUrl = proxyUrl;
88
+ }),
89
+ loadScript(uiScriptUrl),
90
+ ]);
91
+ }
92
+
93
+ function loadScript(src, prepare = () => {}) {
82
94
  return new Promise((resolve, reject) => {
83
95
  const script = document.createElement('script');
84
- script.src = scriptUrl;
96
+ script.src = src;
85
97
  script.crossOrigin = 'anonymous';
86
- script.dataset.clerkPublishableKey = publishableKey;
87
- if (proxyUrl) script.dataset.clerkProxyUrl = proxyUrl;
98
+ prepare(script);
88
99
  script.onload = resolve;
89
- script.onerror = () => { status('Could not load sign-in. Try again shortly.', true); reject(new Error('clerk-js')); };
100
+ script.onerror = () => { status('Could not load sign-in. Try again shortly.', true); reject(new Error(src)); };
90
101
  document.head.append(script);
91
102
  });
92
103
  }
@@ -107,7 +107,10 @@ export async function issueInvoice(db, organisationId, invoiceId, { issuedBy, is
107
107
  return db.transaction(async (tx) => {
108
108
  const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
109
109
  if (invoice.status !== 'draft') throw new FlowError('not_draft', 'only a draft invoice can be issued');
110
- const lines = await tx.select().from(invoiceLines)
110
+ // Each line with its price item's code, so the document says which item
111
+ // was sold even if the price list changes later.
112
+ const lines = await tx.select({ line: invoiceLines, itemCode: priceItems.code }).from(invoiceLines)
113
+ .leftJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
111
114
  .where(eq(invoiceLines.invoiceId, invoice.id)).orderBy(asc(invoiceLines.position));
112
115
  if (!lines.length) throw new FlowError('invalid', 'an invoice needs at least one line');
113
116
 
@@ -129,8 +132,8 @@ export async function issueInvoice(db, organisationId, invoiceId, { issuedBy, is
129
132
  reference: invoice.reference,
130
133
  currency: invoice.currency,
131
134
  contact: { id: contact.id, name: contact.name, email: contact.email },
132
- lines: lines.map(({ position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }) => (
133
- { position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }
135
+ lines: lines.map(({ line: { position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }, itemCode }) => (
136
+ { position, itemCode: itemCode ?? null, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }
134
137
  )),
135
138
  subtotalCents: invoice.subtotalCents,
136
139
  taxCents: invoice.taxCents,