@arcforgelabs/flow 2026.10.19 → 2026.10.21

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/README.md CHANGED
@@ -16,7 +16,8 @@ from an earlier private codebase one module at a time (`CONTRIBUTING.md`,
16
16
  "Porting from the earlier codebase"). Today it holds the repository's rules,
17
17
  CI, the release track (npm, container image and GitHub Release, on a beta and
18
18
  a stable channel), the integration port, a health check, Clerk sign-in with
19
- roles kept in Flow, and the first part of the core: the PostgreSQL data model
19
+ roles kept in Flow, a People page for owners, the design systems Arc Forge
20
+ Labs' Field uses, and the first part of the core: the PostgreSQL data model
20
21
  for organisations, contacts, the price list and invoices. It is not ready to
21
22
  run a lab.
22
23
 
@@ -29,6 +30,7 @@ run a lab.
29
30
  | `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, merging |
30
31
  | `SECURITY.md` | How to report a security problem, the trust model, and the rules held hard |
31
32
  | `LICENSE` | MIT licence |
33
+ | `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
32
34
  | `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
33
35
  | `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
34
36
  | `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
package/SECURITY.md CHANGED
@@ -31,6 +31,10 @@ paste a credential, in full or in part.
31
31
  - **Integrations.** Seazona, Xero and every later adapter reach the core only
32
32
  through the integration port. Their credentials are stored per
33
33
  organisation, encrypted, and are write-only in Settings.
34
+ - **Records and files.** Every record is in the installation's PostgreSQL.
35
+ Files reach the core only through the object storage port; PostgreSQL
36
+ holds each file's key, owner, size and hash, and the hash is checked on
37
+ read. Download links are signed.
34
38
  - **Issued documents are records.** An invoice a customer has been sent is
35
39
  never changed in place; a change produces a new version.
36
40
 
@@ -53,6 +57,8 @@ paste a credential, in full or in part.
53
57
  - Session cookies are `Secure`, `HttpOnly` and `SameSite`.
54
58
  - Flow stores no clinical notes, images or scans, and keeps a patient
55
59
  reference only where an invoice must print one.
60
+ - Object keys and file names carry a case number at most, never a patient
61
+ name or other patient data.
56
62
  - CI runs gitleaks on every push and pull request
57
63
  (`.github/workflows/secret-scan.yml`).
58
64
 
package/VISION.md CHANGED
@@ -47,6 +47,15 @@ sensitive. Flow keeps the case number and the practice. It keeps a patient
47
47
  reference only when a practice needs one printed on its invoice, and it keeps
48
48
  no clinical notes or images.
49
49
 
50
+ ### Why does Flow keep its records in PostgreSQL only?
51
+
52
+ One database is less to run, back up and upgrade. Every record lives in
53
+ PostgreSQL, with `jsonb` for evidence and documents. Files (PDFs, payment
54
+ proof, exports) go through one object storage port, and PostgreSQL keeps only
55
+ each file's key, owner, size, type and hash. A need that seems to call for
56
+ another kind of database is raised as a decision first. The goal is an
57
+ installation that needs little maintenance (`docs/DATA_MODEL.md`, "Storage").
58
+
50
59
  ### Why does each lab get its own installation?
51
60
 
52
61
  Labs' customers, prices and credentials must never meet. Each installation has
@@ -1,15 +1,25 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.19",
3
+ "version": "2026.10.21",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.19",
9
+ "version": "2026.10.21",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.23.2",
13
+ "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
14
+ "@fontsource-variable/dm-sans": "5.3.0",
15
+ "@fontsource-variable/geist": "5.3.0",
16
+ "@fontsource-variable/ibm-plex-sans": "5.3.0",
17
+ "@fontsource-variable/instrument-sans": "5.3.0",
18
+ "@fontsource-variable/jetbrains-mono": "5.3.0",
19
+ "@fontsource-variable/lora": "5.3.0",
20
+ "@fontsource-variable/nunito-sans": "5.3.0",
21
+ "@fontsource-variable/playfair-display": "5.3.0",
22
+ "@fontsource-variable/space-grotesk": "5.3.0",
13
23
  "drizzle-orm": "0.45.3",
14
24
  "express": "5.3.0",
15
25
  "express-rate-limit": "8.7.0",
@@ -951,6 +961,96 @@
951
961
  "node": ">=18"
952
962
  }
953
963
  },
964
+ "node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
965
+ "version": "5.3.0",
966
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
967
+ "integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
968
+ "license": "OFL-1.1",
969
+ "funding": {
970
+ "url": "https://github.com/sponsors/ayuhito"
971
+ }
972
+ },
973
+ "node_modules/@fontsource-variable/dm-sans": {
974
+ "version": "5.3.0",
975
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.3.0.tgz",
976
+ "integrity": "sha512-BpUG5bqePiDFMBM4ZtLSlPdAIOM990uB1dlXzIf4aw21yQR7BmykedLXXXMqGWsR18aMLOsbWccwJNh3ztTQaA==",
977
+ "license": "OFL-1.1",
978
+ "funding": {
979
+ "url": "https://github.com/sponsors/ayuhito"
980
+ }
981
+ },
982
+ "node_modules/@fontsource-variable/geist": {
983
+ "version": "5.3.0",
984
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
985
+ "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
986
+ "license": "OFL-1.1",
987
+ "funding": {
988
+ "url": "https://github.com/sponsors/ayuhito"
989
+ }
990
+ },
991
+ "node_modules/@fontsource-variable/ibm-plex-sans": {
992
+ "version": "5.3.0",
993
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
994
+ "integrity": "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==",
995
+ "license": "OFL-1.1",
996
+ "funding": {
997
+ "url": "https://github.com/sponsors/ayuhito"
998
+ }
999
+ },
1000
+ "node_modules/@fontsource-variable/instrument-sans": {
1001
+ "version": "5.3.0",
1002
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/instrument-sans/-/instrument-sans-5.3.0.tgz",
1003
+ "integrity": "sha512-u4gKbDBTNFGkg997tfQn3eHOhHuquWUFTRT/rwzuKtrxX5P2ekfs2x+LgBPP4P32+cC+vUwF1Cr+IdRoPQbrGw==",
1004
+ "license": "OFL-1.1",
1005
+ "funding": {
1006
+ "url": "https://github.com/sponsors/ayuhito"
1007
+ }
1008
+ },
1009
+ "node_modules/@fontsource-variable/jetbrains-mono": {
1010
+ "version": "5.3.0",
1011
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
1012
+ "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
1013
+ "license": "OFL-1.1",
1014
+ "funding": {
1015
+ "url": "https://github.com/sponsors/ayuhito"
1016
+ }
1017
+ },
1018
+ "node_modules/@fontsource-variable/lora": {
1019
+ "version": "5.3.0",
1020
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/lora/-/lora-5.3.0.tgz",
1021
+ "integrity": "sha512-2ph8KiZ8Bn141hWuC3ErtpJ+Ui8p2MJQn8TJNRSRmLmnCIOCtHfiBM9R7WLRPeUG/q6hXyHOxWQ8DvRSX+ixvQ==",
1022
+ "license": "OFL-1.1",
1023
+ "funding": {
1024
+ "url": "https://github.com/sponsors/ayuhito"
1025
+ }
1026
+ },
1027
+ "node_modules/@fontsource-variable/nunito-sans": {
1028
+ "version": "5.3.0",
1029
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito-sans/-/nunito-sans-5.3.0.tgz",
1030
+ "integrity": "sha512-Vk1PSfckNDwgw2yGcsvJP3o/0zEW8Rp6KzbD8Ql/6iRHQshnhhAjt+cSIfAUyp5vQuSlleGHFW2LfYaaex0diw==",
1031
+ "license": "OFL-1.1",
1032
+ "funding": {
1033
+ "url": "https://github.com/sponsors/ayuhito"
1034
+ }
1035
+ },
1036
+ "node_modules/@fontsource-variable/playfair-display": {
1037
+ "version": "5.3.0",
1038
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/playfair-display/-/playfair-display-5.3.0.tgz",
1039
+ "integrity": "sha512-IHdzTvE8eGIXHteZql7KXoqdc3Gfm+Zs1u4XlSC3MeplkwviT3sx/P6gI0tLwg/xIYLF4ZJYveZ5V+bl9cnDwA==",
1040
+ "license": "OFL-1.1",
1041
+ "funding": {
1042
+ "url": "https://github.com/sponsors/ayuhito"
1043
+ }
1044
+ },
1045
+ "node_modules/@fontsource-variable/space-grotesk": {
1046
+ "version": "5.3.0",
1047
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz",
1048
+ "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==",
1049
+ "license": "OFL-1.1",
1050
+ "funding": {
1051
+ "url": "https://github.com/sponsors/ayuhito"
1052
+ }
1053
+ },
954
1054
  "node_modules/@stablelib/base64": {
955
1055
  "version": "1.0.1",
956
1056
  "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.19",
3
+ "version": "2026.10.21",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,6 +35,16 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@clerk/backend": "3.23.2",
38
+ "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
39
+ "@fontsource-variable/dm-sans": "5.3.0",
40
+ "@fontsource-variable/geist": "5.3.0",
41
+ "@fontsource-variable/ibm-plex-sans": "5.3.0",
42
+ "@fontsource-variable/instrument-sans": "5.3.0",
43
+ "@fontsource-variable/jetbrains-mono": "5.3.0",
44
+ "@fontsource-variable/lora": "5.3.0",
45
+ "@fontsource-variable/nunito-sans": "5.3.0",
46
+ "@fontsource-variable/playfair-display": "5.3.0",
47
+ "@fontsource-variable/space-grotesk": "5.3.0",
38
48
  "drizzle-orm": "0.45.3",
39
49
  "express": "5.3.0",
40
50
  "express-rate-limit": "8.7.0",
package/server/app.js CHANGED
@@ -4,6 +4,9 @@ import { createSignInRouter } from './auth/routes.js';
4
4
  import { deployedBuildStatus } from './deployed-build.js';
5
5
  import { integrationsHealth } from './integrations/health.js';
6
6
  import { rateLimit } from './rate-limit.js';
7
+ import { DEFAULT_FAMILY } from './theme/themes.js';
8
+ import { messagePage } from './ui/page.js';
9
+ import { createUiAssetsRouter } from './ui/routes.js';
7
10
 
8
11
  // The Flow HTTP app. Routes are added per module (invoices, bills, payments,
9
12
  // dashboard).
@@ -19,7 +22,8 @@ import { rateLimit } from './rate-limit.js';
19
22
  // (server/deployed-build.js); `updates` is a server/update-check.js checker.
20
23
  // `integrations` is the started registry (server/integrations/index.js); each
21
24
  // adapter's own routes are mounted under /api/<id>, and GET /health reports
22
- // each one (server/integrations/health.js).
25
+ // each one (server/integrations/health.js). `defaultFamily` is the design
26
+ // system every page starts on (FLOW_THEME, server/theme/themes.js).
23
27
  export function createApp({
24
28
  build = deployedBuildStatus(),
25
29
  database = null,
@@ -30,6 +34,7 @@ export function createApp({
30
34
  rateLimitPerMinute = 120,
31
35
  signInRateLimitPerMinute = 30,
32
36
  clerkProxyRateLimitPerMinute = 300,
37
+ defaultFamily = DEFAULT_FAMILY,
33
38
  log = console,
34
39
  } = {}) {
35
40
  const app = express();
@@ -59,6 +64,9 @@ export function createApp({
59
64
  app.use(auth.clerkProxy.path, rateLimit({ limit: clerkProxyRateLimitPerMinute, windowMs: 60_000 }), auth.clerkProxy.handler);
60
65
  }
61
66
 
67
+ // The design systems, Flow's components and the faces every page loads.
68
+ app.use(createUiAssetsRouter());
69
+
62
70
  app.use('/api', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
63
71
 
64
72
  // Machine access first, with its own authentication (issue #9). A Clerk
@@ -82,7 +90,10 @@ export function createApp({
82
90
  }
83
91
  }
84
92
 
85
- for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log }) ?? []) {
93
+ // An adapter's own pages (such as the end of connecting Xero) use
94
+ // `messagePage` so they look like the rest of Flow.
95
+ const page = (options) => messagePage({ ...options, defaultFamily });
96
+ for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
86
97
  app.use(path, handler);
87
98
  }
88
99
 
@@ -91,6 +102,7 @@ export function createApp({
91
102
  db: database?.db ?? null,
92
103
  clerk: auth?.clerk ?? null,
93
104
  personAuth: auth?.personAuth ?? null,
105
+ defaultFamily,
94
106
  rateLimitPerMinute: signInRateLimitPerMinute,
95
107
  log,
96
108
  }));
@@ -1,4 +1,3 @@
1
- import { fileURLToPath } from 'node:url';
2
1
  import express from 'express';
3
2
  import { FlowError } from '../core/errors.js';
4
3
  import { rateLimit } from '../rate-limit.js';
@@ -13,8 +12,7 @@ import {
13
12
  updatePerson,
14
13
  } from './people.js';
15
14
  import { isPlainEmail } from './settings.js';
16
-
17
- const signInPage = fileURLToPath(new URL('./sign-in.html', import.meta.url));
15
+ import { APP_PATHS, appPage } from '../ui/app-page.js';
18
16
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
17
  const ERROR_STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };
20
18
 
@@ -49,8 +47,10 @@ function sendError(res, error, log) {
49
47
  // off, person routes answer 503 and nothing else is mounted. The sign-in page
50
48
  // and its config have their own limit per client (`rateLimitPerMinute`);
51
49
  // /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 }) {
50
+ // `defaultFamily` is the installation's design system (FLOW_THEME).
51
+ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFamily, rateLimitPerMinute = 30, log = console }) {
53
52
  const router = express.Router();
53
+ const page = appPage({ defaultFamily });
54
54
  const signInOn = settings.mode === 'clerk';
55
55
  const signInLimit = rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 });
56
56
 
@@ -72,10 +72,11 @@ export function createSignInRouter({ settings, db, clerk, personAuth, rateLimitP
72
72
  });
73
73
 
74
74
  router.get('/', signInLimit, (_req, res) => res.redirect(302, '/sign-in'));
75
- router.get('/sign-in', signInLimit, (_req, res) => {
75
+ // Signed out, every one of these shows sign-in.
76
+ router.get(APP_PATHS, signInLimit, (_req, res) => {
76
77
  res.set('Cache-Control', 'no-store');
77
78
  res.set('X-Frame-Options', 'DENY');
78
- res.sendFile(signInPage);
79
+ res.type('html').send(page);
79
80
  });
80
81
 
81
82
  if (!signInOn) {
@@ -0,0 +1,52 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { organisations } from '../db/schema.js';
3
+ import { applyImport, planImport, readEarlierFlow, summarisePlan } from './earlier-flow.js';
4
+
5
+ // The import from the earlier codebase, run once by the server itself. On a
6
+ // host with no shell next to the app (a published Replit app) the operator
7
+ // moves the earlier tables into their own schema, then sets
8
+ // FLOW_IMPORT_EARLIER_SCHEMA and FLOW_IMPORT_ORGANISATION. While the database
9
+ // has no organisation, the next start reads that schema in a read-only
10
+ // transaction and writes the records (server/import/earlier-flow.js). Once an
11
+ // organisation exists, it does nothing.
12
+
13
+ const SCHEMA_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
14
+
15
+ export function resolveImportAtStart(env = process.env) {
16
+ const schema = (env.FLOW_IMPORT_EARLIER_SCHEMA ?? '').trim();
17
+ if (!schema) return null;
18
+ if (!SCHEMA_NAME.test(schema)) throw new Error('FLOW_IMPORT_EARLIER_SCHEMA must be a plain lower-case schema name, e.g. earlier');
19
+ const organisationName = (env.FLOW_IMPORT_ORGANISATION ?? '').trim();
20
+ if (!organisationName) throw new Error('FLOW_IMPORT_ORGANISATION (the lab\'s name) is required with FLOW_IMPORT_EARLIER_SCHEMA');
21
+ return { schema, organisationName };
22
+ }
23
+
24
+ // Returns the import's counts, or null when there was nothing to do.
25
+ export async function importEarlierAtStart({ db, pool, settings, log = console }) {
26
+ if (!settings) return null;
27
+ const [{ count }] = (await db.execute(sql`select count(*)::int as count from ${organisations}`)).rows;
28
+ if (count > 0) {
29
+ log.info?.('[import] skipped: this database already has an organisation (unset FLOW_IMPORT_EARLIER_SCHEMA)');
30
+ return null;
31
+ }
32
+
33
+ let plan;
34
+ const client = await pool.connect();
35
+ try {
36
+ await client.query('BEGIN TRANSACTION READ ONLY');
37
+ // The schema name was checked against SCHEMA_NAME, so it is safe to quote.
38
+ await client.query(`SET LOCAL search_path TO "${settings.schema}"`);
39
+ plan = planImport(await readEarlierFlow(client));
40
+ await client.query('COMMIT');
41
+ } catch (error) {
42
+ await client.query('ROLLBACK').catch(() => {});
43
+ throw new Error(`could not read schema "${settings.schema}": ${error.message}`);
44
+ } finally {
45
+ client.release();
46
+ }
47
+
48
+ log.info?.(`[import] plan from schema "${settings.schema}": ${JSON.stringify(summarisePlan(plan))}`);
49
+ const result = await applyImport(db, plan, { organisationName: settings.organisationName });
50
+ log.info?.(`[import] imported: ${JSON.stringify(result.counts)}`);
51
+ return result.counts;
52
+ }
@@ -45,6 +45,12 @@ function parseJson(text) {
45
45
  }
46
46
  }
47
47
 
48
+ // The earlier codebase stored some accounting payloads as the API sent them
49
+ // (`UnitPrice`) and some as its SDK returned them (`unitPrice`).
50
+ function field(object, name) {
51
+ return object[name] ?? object[name[0].toLowerCase() + name.slice(1)];
52
+ }
53
+
48
54
  function isTrue(value) {
49
55
  return value === true || value === 1 || value === '1' || value === 'true';
50
56
  }
@@ -82,8 +88,8 @@ function planContacts(rows, skipped) {
82
88
  skipped.push({ table: 'contacts', key, reason: 'unreadable_raw_json' });
83
89
  continue;
84
90
  }
85
- const customer = isTrue(raw.IsCustomer);
86
- const supplier = isTrue(raw.IsSupplier);
91
+ const customer = isTrue(field(raw, 'IsCustomer'));
92
+ const supplier = isTrue(field(raw, 'IsSupplier'));
87
93
  // A contact the accounting system has never billed or paid is most often
88
94
  // a new customer, so it comes across as one.
89
95
  const kinds = supplier && !customer ? ['supplier'] : supplier ? ['customer', 'supplier'] : ['customer'];
@@ -120,12 +126,12 @@ function planPriceItems(rows, skipped, defaulted) {
120
126
  skipped.push({ table: 'price_items', key, reason: 'unreadable_sales_details' });
121
127
  continue;
122
128
  }
123
- const unitAmountCents = toCents(sales.UnitPrice);
129
+ const unitAmountCents = toCents(field(sales, 'UnitPrice'));
124
130
  if (unitAmountCents === null) {
125
131
  skipped.push({ table: 'price_items', key, reason: 'no_sales_price' });
126
132
  continue;
127
133
  }
128
- const taxType = String(sales.TaxType ?? '').trim().toUpperCase();
134
+ const taxType = String(field(sales, 'TaxType') ?? '').trim().toUpperCase();
129
135
  if (!taxType) defaulted.push({ table: 'price_items', key, reason: 'no_tax_type_used_gst' });
130
136
  const taxCode = !taxType || GST_TAX_TYPES.has(taxType) ? 'GST' : 'GST_FREE';
131
137
  planned.push({ sourceKey: key, code, name, unitAmountCents, taxCode });
package/server/index.js CHANGED
@@ -5,7 +5,9 @@ import { seedFirstOwner } from './auth/people.js';
5
5
  import { createPersonAuth } from './auth/session.js';
6
6
  import { resolveSignInSettings } from './auth/settings.js';
7
7
  import { migrateDatabase, openDatabase } from './db/index.js';
8
+ import { importEarlierAtStart, resolveImportAtStart } from './import/at-start.js';
8
9
  import { createIntegrations } from './integrations/index.js';
10
+ import { resolveDefaultFamily } from './theme/themes.js';
9
11
  import { createUpdateChecker, UPDATE_CHECK_INTERVAL_MS } from './update-check.js';
10
12
 
11
13
  const port = Number(process.env.FLOW_PORT || process.env.PORT || 19300);
@@ -18,6 +20,13 @@ try {
18
20
  console.error(`[auth] ${error.message}`);
19
21
  process.exit(1);
20
22
  }
23
+ let defaultFamily;
24
+ try {
25
+ defaultFamily = resolveDefaultFamily();
26
+ } catch (error) {
27
+ console.error(`[ui] ${error.message}`);
28
+ process.exit(1);
29
+ }
21
30
  let integrations;
22
31
  try {
23
32
  integrations = createIntegrations();
@@ -26,11 +35,26 @@ try {
26
35
  process.exit(1);
27
36
  }
28
37
 
38
+ let importAtStart;
39
+ try {
40
+ importAtStart = resolveImportAtStart();
41
+ } catch (error) {
42
+ console.error(`[import] ${error.message}`);
43
+ process.exit(1);
44
+ }
45
+
29
46
  // Flow's records live in PostgreSQL. Migrations run before the server accepts
30
47
  // requests, so a release never serves an older schema. Then each enabled
31
48
  // integration's own tables, and no others (docs/INTEGRATIONS.md).
32
49
  const database = openDatabase();
33
50
  await migrateDatabase(database.db);
51
+ // A failed import stops the start, so Flow never opens with half its records.
52
+ try {
53
+ await importEarlierAtStart({ db: database.db, pool: database.pool, settings: importAtStart });
54
+ } catch (error) {
55
+ console.error(`[import] ${error.message}`);
56
+ process.exit(1);
57
+ }
34
58
  await integrations.start(database.db);
35
59
  const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => a.id);
36
60
  console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'} (FLOW_INTEGRATIONS)`);
@@ -76,8 +100,8 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
76
100
 
77
101
  const updates = createUpdateChecker();
78
102
 
79
- createApp({ database, auth, trustProxy, updates, integrations }).listen(port, () => {
80
- console.log(`[flow] listening on ${port}`);
103
+ createApp({ database, auth, trustProxy, updates, integrations, defaultFamily }).listen(port, () => {
104
+ console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
81
105
  });
82
106
 
83
107
  // The update notice: a release build logs when a newer release is out on its
@@ -102,7 +102,7 @@ export function defineAdapter(adapter) {
102
102
  // stop() ends that work (tests, shutdown).
103
103
  start: start ?? null,
104
104
  stop: stop ?? null,
105
- // Optional HTTP routes: routes({ db, personAuth, log }) returns a router
105
+ // Optional HTTP routes: routes({ db, personAuth, log, messagePage }) returns a router
106
106
  // mounted at /api/<id> (registry.routes).
107
107
  routes: routes ?? null,
108
108
  });
@@ -195,7 +195,7 @@ export function createRegistry({ log = console } = {}) {
195
195
  .filter((a) => ready(a) && typeof a.capabilities[capability] === 'function');
196
196
  },
197
197
  // The adapters' own HTTP routes, each under /api/<id>. `context` is
198
- // { db, personAuth, log }. An enabled, ready adapter's router is built
198
+ // { db, personAuth, log, messagePage }. An enabled, ready adapter's router is built
199
199
  // once, here; a request to an adapter that is off gets its turned-off
200
200
  // answer (503), so a turned-off adapter never reads its settings or
201
201
  // tables.
@@ -49,7 +49,7 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
49
49
  // cursor is the next `since`.
50
50
  pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
51
51
  },
52
- routes: ({ db, personAuth, log }) => createXeroRouter({ db, personAuth, runtime: current(), log }),
52
+ routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
53
53
  };
54
54
  }
55
55
 
@@ -9,6 +9,8 @@ import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
9
9
  import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
10
10
  import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
11
11
  import { writesAllowed } from './settings.js';
12
+ import { DEFAULT_FAMILY } from '../../theme/themes.js';
13
+ import { messagePage as themedMessagePage } from '../../ui/page.js';
12
14
 
13
15
  // The Xero adapter's routes, mounted at /api/xero (server/integrations/port.js).
14
16
  //
@@ -33,14 +35,6 @@ import { writesAllowed } from './settings.js';
33
35
 
34
36
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
35
37
 
36
- function escapeHtml(value) {
37
- return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
38
- }
39
-
40
- function page(title, text) {
41
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title></head>`
42
- + `<body style="font-family: system-ui, sans-serif; max-width: 36rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p></body></html>`;
43
- }
44
38
 
45
39
  export function sendError(res, error, log) {
46
40
  const status = error instanceof FlowError ? (error.status ?? 400) : 500;
@@ -75,7 +69,10 @@ export const handle = (log, fn) => async (req, res) => {
75
69
  }
76
70
  };
77
71
 
78
- export function createXeroRouter({ db, personAuth, runtime, log = console }) {
72
+ // `messagePage` draws the callback's answer in the installation's design
73
+ // system (server/ui/page.js); the server's app passes it.
74
+ export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
75
+ const page = (title, text, tone) => messagePage({ title, text, tone });
79
76
  const { settings } = runtime;
80
77
  const router = express.Router();
81
78
  router.use(express.json({ limit: '256kb' }));
@@ -116,10 +113,10 @@ export function createXeroRouter({ db, personAuth, runtime, log = console }) {
116
113
  res.set('Cache-Control', 'no-store');
117
114
  const entry = runtime.states.consume(req.query.state);
118
115
  if (!entry) {
119
- return res.status(400).type('html').send(page('Xero was not connected', 'This approval link has expired or was already used. Start again from Flow.'));
116
+ return res.status(400).type('html').send(page('Xero was not connected', 'This approval link has expired or was already used. Start again from Flow.', 'danger'));
120
117
  }
121
118
  if (req.query.error) {
122
- return res.status(400).type('html').send(page('Xero was not connected', 'The approval was cancelled in Xero. Start again from Flow if you meant to connect.'));
119
+ return res.status(400).type('html').send(page('Xero was not connected', 'The approval was cancelled in Xero. Start again from Flow if you meant to connect.', 'danger'));
123
120
  }
124
121
  try {
125
122
  const tokens = await exchangeCode(settings, runtime.transport, typeof req.query.code === 'string' ? req.query.code : '');
@@ -153,11 +150,11 @@ export function createXeroRouter({ db, personAuth, runtime, log = console }) {
153
150
  });
154
151
  runtime.remember(entry.organisationId, tokens);
155
152
  log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
156
- return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). You can close this tab.`));
153
+ return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). You can close this tab.`, 'success'));
157
154
  } catch (error) {
158
155
  log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
159
156
  const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
160
- return res.status(error.status && error.status < 500 ? error.status : 502).type('html').send(page('Xero was not connected', message));
157
+ return res.status(error.status && error.status < 500 ? error.status : 502).type('html').send(page('Xero was not connected', message, 'danger'));
161
158
  }
162
159
  });
163
160
 
@@ -0,0 +1,54 @@
1
+ // WCAG 2.1 contrast helpers for theme tokens (server/theme/themes.js and
2
+ // scripts/port-openclaw-themes.mjs). Colours are #rrggbb.
3
+
4
+ export const HEX = /^#[0-9a-f]{6}$/i;
5
+
6
+ function rgb(hex) {
7
+ return [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
8
+ }
9
+
10
+ function luminance(hex) {
11
+ const [r, g, b] = rgb(hex).map((c) => {
12
+ const s = c / 255;
13
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
14
+ });
15
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
16
+ }
17
+
18
+ export function contrast(a, b) {
19
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
20
+ return (hi + 0.05) / (lo + 0.05);
21
+ }
22
+
23
+ /** Black or white, whichever reads better on the fill. */
24
+ export function inkOn(fill) {
25
+ return luminance(fill) > 0.179 ? '#000000' : '#ffffff';
26
+ }
27
+
28
+ /** The colour, darkened or lightened just enough to read as text on `bg`. */
29
+ export function readableOn(color, bg, target = 4.5) {
30
+ if (!HEX.test(color) || !HEX.test(bg)) return color;
31
+ const toward = luminance(bg) > 0.179 ? [0, 0, 0] : [255, 255, 255];
32
+ const from = rgb(color);
33
+ for (let pct = 0; pct <= 100; pct += 2) {
34
+ const mixed = `#${from.map((c, i) => Math.round(c + (toward[i] - c) * pct / 100).toString(16).padStart(2, '0')).join('')}`;
35
+ if (contrast(mixed, bg) >= target) return mixed;
36
+ }
37
+ return toward[0] ? '#ffffff' : '#000000';
38
+ }
39
+
40
+ /**
41
+ * A boundary colour for controls on `bg` (an input's or an outlined button's
42
+ * edge): `bg` mixed toward `ink` just enough to stand out at `target`
43
+ * (WCAG 1.4.11 asks 3:1 for the parts that show where a control is).
44
+ */
45
+ export function boundaryOn(bg, ink, target = 3) {
46
+ if (!HEX.test(bg) || !HEX.test(ink)) return ink;
47
+ const from = rgb(bg);
48
+ const toward = rgb(ink);
49
+ for (let pct = 0; pct <= 100; pct += 2) {
50
+ const mixed = `#${from.map((c, i) => Math.round(c + (toward[i] - c) * pct / 100).toString(16).padStart(2, '0')).join('')}`;
51
+ if (contrast(mixed, bg) >= target) return mixed;
52
+ }
53
+ return ink;
54
+ }
@@ -0,0 +1,28 @@
1
+ // Each design system's faces, as the fontsource packages Flow serves under
2
+ // /assets/fonts/<package>/ (server/ui/routes.js). A page loads only the chosen
3
+ // family's faces, plus JetBrains Mono for codes. Shared by the server and the
4
+ // browser, so it imports nothing.
5
+ export const MONO_FONT = 'jetbrains-mono';
6
+
7
+ export const FAMILY_FONTS = {
8
+ harbour: ['nunito-sans', 'playfair-display'],
9
+ beacon: ['atkinson-hyperlegible-next'],
10
+ tide: ['ibm-plex-sans'],
11
+ claw: ['instrument-sans'],
12
+ knot: ['geist'],
13
+ dash: ['dm-sans'],
14
+ absolutely: ['space-grotesk'],
15
+ phosphor: [],
16
+ crt: [],
17
+ manuscript: ['lora'],
18
+ rose: ['dm-sans'],
19
+ miami: ['space-grotesk'],
20
+ };
21
+
22
+ /** Every package a family can ask for, so the server serves only these. */
23
+ export const FONT_PACKAGES = [...new Set([MONO_FONT, ...Object.values(FAMILY_FONTS).flat()])];
24
+
25
+ /** The stylesheet URLs a family needs, mono first. */
26
+ export function fontStylesheets(family) {
27
+ return [MONO_FONT, ...(FAMILY_FONTS[family] ?? [])].map((name) => `/assets/fonts/${name}/index.css`);
28
+ }