@arcforgelabs/flow 2026.10.35 → 2026.10.37

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
@@ -48,6 +48,7 @@ run a lab.
48
48
  | `docs/CUSTOM_FIELDS.md` | Custom fields: the lab's own fields for its jobs, kept in Flow only, and how a board move checks for conflicts |
49
49
  | `docs/JOB_BOARD_MOVES.md` | Moving jobs on the Jobs board: custom field moves stay in Flow; Seazona stage moves become a hand-off |
50
50
  | `docs/PRICE_LIST.md` | The price list: Flow's own codes and prices, their links to Xero items and Seazona products, and the price check |
51
+ | `docs/AI_BOTS.md` | The AI bots: ClawSweeper reviews and the Docs Agent, their ClawRouter budget and cost, and setup |
51
52
  | `docs/LOW_RISK_MERGE.md` | The low-risk merge lane: which pull requests merge themselves when green, with ClawSweeper's risk labels |
52
53
  | `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
53
54
  | `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.35",
3
+ "version": "2026.10.37",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.35",
9
+ "version": "2026.10.37",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "@clerk/backend": "3.23.2",
12
+ "@clerk/backend": "3.25.0",
13
13
  "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
14
14
  "@fontsource-variable/dm-sans": "5.3.0",
15
15
  "@fontsource-variable/geist": "5.3.0",
@@ -21,10 +21,11 @@
21
21
  "@fontsource-variable/playfair-display": "5.3.0",
22
22
  "@fontsource-variable/plus-jakarta-sans": "5.3.0",
23
23
  "@fontsource-variable/space-grotesk": "5.3.0",
24
- "@sentry/node": "11.4.0",
25
- "drizzle-orm": "0.45.3",
24
+ "@sentry/node": "11.8.0",
25
+ "drizzle-orm": "0.45.4",
26
26
  "express": "5.3.0",
27
- "express-rate-limit": "8.7.0",
27
+ "express-rate-limit": "8.7.1",
28
+ "lucide-static": "1.59.0",
28
29
  "pdfkit": "0.20.2",
29
30
  "pg": "8.23.1"
30
31
  },
@@ -39,9 +40,9 @@
39
40
  }
40
41
  },
41
42
  "node_modules/@clerk/backend": {
42
- "version": "3.23.2",
43
- "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.23.2.tgz",
44
- "integrity": "sha512-QW2qJPY7xQyn5SrNWJ+xO7o6va60X9/v4nX8y5zq8OJFS10X7GlEz8s5PDKTgo6W6SWb8L3APhrZH+LTp7yuNw==",
43
+ "version": "3.25.0",
44
+ "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.25.0.tgz",
45
+ "integrity": "sha512-hmJS0a61FO08PDaV2MtZ/hj6pE+cQBRmpxR8oUFGQTXewOQLLOAbcgob0rmFAnMNuHgXfsYf3NVxcQCUOWE+QA==",
45
46
  "license": "MIT",
46
47
  "dependencies": {
47
48
  "@clerk/shared": "^4.40.0",
@@ -809,6 +810,9 @@
809
810
  "cpu": [
810
811
  "arm64"
811
812
  ],
813
+ "libc": [
814
+ "glibc"
815
+ ],
812
816
  "license": "MIT",
813
817
  "optional": true,
814
818
  "os": [
@@ -825,6 +829,9 @@
825
829
  "cpu": [
826
830
  "arm64"
827
831
  ],
832
+ "libc": [
833
+ "musl"
834
+ ],
828
835
  "license": "MIT",
829
836
  "optional": true,
830
837
  "os": [
@@ -841,6 +848,9 @@
841
848
  "cpu": [
842
849
  "ppc64"
843
850
  ],
851
+ "libc": [
852
+ "glibc"
853
+ ],
844
854
  "license": "MIT",
845
855
  "optional": true,
846
856
  "os": [
@@ -857,6 +867,9 @@
857
867
  "cpu": [
858
868
  "riscv64"
859
869
  ],
870
+ "libc": [
871
+ "glibc"
872
+ ],
860
873
  "license": "MIT",
861
874
  "optional": true,
862
875
  "os": [
@@ -873,6 +886,9 @@
873
886
  "cpu": [
874
887
  "riscv64"
875
888
  ],
889
+ "libc": [
890
+ "musl"
891
+ ],
876
892
  "license": "MIT",
877
893
  "optional": true,
878
894
  "os": [
@@ -889,6 +905,9 @@
889
905
  "cpu": [
890
906
  "s390x"
891
907
  ],
908
+ "libc": [
909
+ "glibc"
910
+ ],
892
911
  "license": "MIT",
893
912
  "optional": true,
894
913
  "os": [
@@ -905,6 +924,9 @@
905
924
  "cpu": [
906
925
  "x64"
907
926
  ],
927
+ "libc": [
928
+ "glibc"
929
+ ],
908
930
  "license": "MIT",
909
931
  "optional": true,
910
932
  "os": [
@@ -921,6 +943,9 @@
921
943
  "cpu": [
922
944
  "x64"
923
945
  ],
946
+ "libc": [
947
+ "musl"
948
+ ],
924
949
  "license": "MIT",
925
950
  "optional": true,
926
951
  "os": [
@@ -1004,12 +1029,12 @@
1004
1029
  }
1005
1030
  },
1006
1031
  "node_modules/@sentry/bundler-plugins": {
1007
- "version": "11.4.0",
1008
- "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.4.0.tgz",
1009
- "integrity": "sha512-THgOMIaA0AMVF4TiCIbugK2jlLYCBIhRF0gclrj7Fte3RMZytkXeH8XeoaR6C6MJTyLCNkEOEccwX9Jj2xOc8g==",
1032
+ "version": "11.8.0",
1033
+ "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.8.0.tgz",
1034
+ "integrity": "sha512-rQrbL2lYPyiiO6ngdpbbfPKyEzVVQsz1FG+aE8jUiUYs72gvmDmZmRyl9VDCGLrHZfIEwaQMT1t6WQH5nopu5A==",
1010
1035
  "license": "MIT",
1011
1036
  "dependencies": {
1012
- "@sentry/core": "11.4.0",
1037
+ "@sentry/core": "11.8.0",
1013
1038
  "dotenv": "^17.4.2",
1014
1039
  "glob": "^13.0.6",
1015
1040
  "magic-string": "~0.30.8 || ^1.0.0",
@@ -1038,52 +1063,52 @@
1038
1063
  }
1039
1064
  },
1040
1065
  "node_modules/@sentry/conventions": {
1041
- "version": "0.25.0",
1042
- "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.25.0.tgz",
1043
- "integrity": "sha512-7SouCAX3qx+AzLsxb6gbl3dKVWNhp2wFjdYQ/T90sGwqPPQUoZVA9YwsWwvx07hqcCiSqFW9mUAzyUftASnuhQ==",
1066
+ "version": "0.27.0",
1067
+ "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.27.0.tgz",
1068
+ "integrity": "sha512-Mrqd/o7n/z7qWSYfQQA7x4fFTA0IqOG6JNsbBpnC97khPwZk7n6DPtOO7YgaYplSl8R8QLRbBz9Xv2lVhToSAg==",
1044
1069
  "license": "MIT",
1045
1070
  "engines": {
1046
1071
  "node": ">=14"
1047
1072
  }
1048
1073
  },
1049
1074
  "node_modules/@sentry/core": {
1050
- "version": "11.4.0",
1051
- "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.4.0.tgz",
1052
- "integrity": "sha512-kAhK8XVJuzu1IKx9uSu04OISxTU9BcIyDVOWbY/ATUCYr5Z6L7GRBtOGOlYCbxEuPtdZCw5TrbphnBsyO9fuTA==",
1075
+ "version": "11.8.0",
1076
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.8.0.tgz",
1077
+ "integrity": "sha512-4MVTthk6C/1oQFjbVIfygHyNkx2YGbwNLml0kJji0vjJXy6ZNls9zdIp0FTBTi4e3189IcSc5OXD4qcJdmEeKA==",
1053
1078
  "license": "MIT",
1054
1079
  "dependencies": {
1055
- "@sentry/conventions": "^0.25.0"
1080
+ "@sentry/conventions": "^0.27.0"
1056
1081
  },
1057
1082
  "engines": {
1058
1083
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
1059
1084
  }
1060
1085
  },
1061
1086
  "node_modules/@sentry/node": {
1062
- "version": "11.4.0",
1063
- "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.4.0.tgz",
1064
- "integrity": "sha512-fW587LsZnsuGt1V++59xFCtZ/7jGO3GQq1F+l/Jx5DS18QApXQ99vlfS9H5bxuWixfi5/22ea9M8XjFpkkuzQg==",
1087
+ "version": "11.8.0",
1088
+ "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.8.0.tgz",
1089
+ "integrity": "sha512-8vFPfCRYoUYmsno+OYdvtf5akIUUvd7FvcuIGcfrge8S/bSLFRKq58YtFmX25JFcrMNZ2oQD8r0jkgZfm/2iuA==",
1065
1090
  "license": "MIT",
1066
1091
  "dependencies": {
1067
1092
  "@opentelemetry/api": "^1.9.1",
1068
- "@sentry/bundler-plugins": "11.4.0",
1069
- "@sentry/conventions": "^0.25.0",
1070
- "@sentry/core": "11.4.0",
1071
- "@sentry/opentelemetry": "11.4.0",
1072
- "@sentry/server-runtime-injection": "11.4.0",
1073
- "@sentry/server-utils": "11.4.0"
1093
+ "@sentry/bundler-plugins": "11.8.0",
1094
+ "@sentry/conventions": "^0.27.0",
1095
+ "@sentry/core": "11.8.0",
1096
+ "@sentry/opentelemetry": "11.8.0",
1097
+ "@sentry/server-runtime-injection": "11.8.0",
1098
+ "@sentry/server-utils": "11.8.0"
1074
1099
  },
1075
1100
  "engines": {
1076
1101
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
1077
1102
  }
1078
1103
  },
1079
1104
  "node_modules/@sentry/opentelemetry": {
1080
- "version": "11.4.0",
1081
- "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.4.0.tgz",
1082
- "integrity": "sha512-GGDmV+VIGU1p3KGtgR32wrRJejSfEuafbEcSUK7Ww5oxDMYJyHXRSbFjVvGMoAG1ezTl0U6WtMUoWmalsfYHZg==",
1105
+ "version": "11.8.0",
1106
+ "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.8.0.tgz",
1107
+ "integrity": "sha512-wKuG2SbHHiGDT/fgT4X5n2usO4AjVkYfAj41002mZ18JZU3mNLeCq+qw/nnDIYbcsfIMOUm/eMh3ShKCE+DpXQ==",
1083
1108
  "license": "MIT",
1084
1109
  "dependencies": {
1085
- "@sentry/conventions": "^0.25.0",
1086
- "@sentry/core": "11.4.0"
1110
+ "@sentry/conventions": "^0.27.0",
1111
+ "@sentry/core": "11.8.0"
1087
1112
  },
1088
1113
  "engines": {
1089
1114
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
@@ -1093,27 +1118,27 @@
1093
1118
  }
1094
1119
  },
1095
1120
  "node_modules/@sentry/server-runtime-injection": {
1096
- "version": "11.4.0",
1097
- "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.4.0.tgz",
1098
- "integrity": "sha512-8IaiqIJuJySztjxdC0UXP3lHlBAr8HsI8kUMEDV7REYhgmz2+B4BmKyyvaZfeasJlAvzkvSOLZ/Xco7kPikZdQ==",
1121
+ "version": "11.8.0",
1122
+ "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.8.0.tgz",
1123
+ "integrity": "sha512-4uB4E8+9ni5NcJ463aYK/+n1hTMw/vSt4s+AkQ84CKigRDwv2FNNnD1AWpipWNj/xEQ6eQbUitGl7wg7/Ri8Pg==",
1099
1124
  "license": "MIT",
1100
1125
  "dependencies": {
1101
- "@sentry/core": "11.4.0",
1102
- "@sentry/server-utils": "11.4.0"
1126
+ "@sentry/core": "11.8.0",
1127
+ "@sentry/server-utils": "11.8.0"
1103
1128
  },
1104
1129
  "engines": {
1105
1130
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
1106
1131
  }
1107
1132
  },
1108
1133
  "node_modules/@sentry/server-utils": {
1109
- "version": "11.4.0",
1110
- "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.4.0.tgz",
1111
- "integrity": "sha512-GA4RvjRXyKFawD7Zi5AlgHPvm9Q8d9VeWer6g9qj4mDavi+VuNFhRmhgsv1qU1l3hlUeMYCpbKhpDOaOH/TcoQ==",
1134
+ "version": "11.8.0",
1135
+ "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.8.0.tgz",
1136
+ "integrity": "sha512-2MRFz1rXxnuDGafl6o/itwKHZeZn5hUZYeXNbQK012uO/a2pXhEc8Zh3FeO5L4tjGrIWHx419saAFnKwbVC2IQ==",
1112
1137
  "license": "MIT",
1113
1138
  "dependencies": {
1114
1139
  "@opentelemetry/api": "^1.9.1",
1115
- "@sentry/conventions": "^0.25.0",
1116
- "@sentry/core": "11.4.0"
1140
+ "@sentry/conventions": "^0.27.0",
1141
+ "@sentry/core": "11.8.0"
1117
1142
  },
1118
1143
  "engines": {
1119
1144
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
@@ -1399,9 +1424,9 @@
1399
1424
  }
1400
1425
  },
1401
1426
  "node_modules/drizzle-orm": {
1402
- "version": "0.45.3",
1403
- "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz",
1404
- "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==",
1427
+ "version": "0.45.4",
1428
+ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.4.tgz",
1429
+ "integrity": "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==",
1405
1430
  "license": "Apache-2.0",
1406
1431
  "peerDependencies": {
1407
1432
  "@aws-sdk/client-rds-data": ">=3",
@@ -1687,9 +1712,9 @@
1687
1712
  }
1688
1713
  },
1689
1714
  "node_modules/express-rate-limit": {
1690
- "version": "8.7.0",
1691
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
1692
- "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
1715
+ "version": "8.7.1",
1716
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
1717
+ "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
1693
1718
  "license": "MIT",
1694
1719
  "dependencies": {
1695
1720
  "debug": "^4.4.3",
@@ -2021,6 +2046,12 @@
2021
2046
  "node": "20 || >=22"
2022
2047
  }
2023
2048
  },
2049
+ "node_modules/lucide-static": {
2050
+ "version": "1.59.0",
2051
+ "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.59.0.tgz",
2052
+ "integrity": "sha512-MuHZYaXbHgzN2QO6D3OM19bj09A+8GJiFSdXIMlbnLLEGQQ9+NRH2KaCxuivevD3Y23vM74kV17XBifgtqhfEg==",
2053
+ "license": "ISC"
2054
+ },
2024
2055
  "node_modules/magic-string": {
2025
2056
  "version": "1.4.3",
2026
2057
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.35",
3
+ "version": "2026.10.37",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
38
38
  },
39
39
  "dependencies": {
40
- "@clerk/backend": "3.23.2",
40
+ "@clerk/backend": "3.25.0",
41
41
  "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
42
42
  "@fontsource-variable/dm-sans": "5.3.0",
43
43
  "@fontsource-variable/geist": "5.3.0",
@@ -49,10 +49,11 @@
49
49
  "@fontsource-variable/playfair-display": "5.3.0",
50
50
  "@fontsource-variable/plus-jakarta-sans": "5.3.0",
51
51
  "@fontsource-variable/space-grotesk": "5.3.0",
52
- "@sentry/node": "11.4.0",
53
- "drizzle-orm": "0.45.3",
52
+ "@sentry/node": "11.8.0",
53
+ "drizzle-orm": "0.45.4",
54
54
  "express": "5.3.0",
55
- "express-rate-limit": "8.7.0",
55
+ "express-rate-limit": "8.7.1",
56
+ "lucide-static": "1.59.0",
56
57
  "pdfkit": "0.20.2",
57
58
  "pg": "8.23.1"
58
59
  },
@@ -236,6 +236,22 @@ const jobOf = (o) => (o ? {
236
236
  id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
237
237
  } : null);
238
238
 
239
+ // A job with its work system's name and its link in that system's web app
240
+ // (jobLink: no live call), for the Linked column.
241
+ function withLink(job, systems) {
242
+ if (!job) return null;
243
+ const source = systems.find((a) => a.id === job.sourceSystem) ?? null;
244
+ const link = typeof source?.capabilities.jobLink === 'function' ? source.capabilities.jobLink({ sourceJobRef: job.sourceJobRef }) : null;
245
+ return { ...job, systemName: source?.name ?? job.sourceSystem, link: link ?? null };
246
+ }
247
+
248
+ // The connected systems an invoice can link to: work systems, then the
249
+ // accounting system.
250
+ const linkedSystems = (work, accounting) => [
251
+ ...work.map((a) => ({ id: a.id, name: a.name, kind: 'work' })),
252
+ ...(accounting ? [{ id: accounting.id, name: accounting.name, kind: 'accounting' }] : []),
253
+ ];
254
+
239
255
  async function jobsByInvoice(db, organisationId, invoiceIds) {
240
256
  if (!invoiceIds.length) return new Map();
241
257
  const rows = await db.select().from(billingObligations)
@@ -282,7 +298,9 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
282
298
  ? { mode: await sendModeFor(db, organisationId, invoice.contactId), otherCustomers: 0, ...(await accounting.capabilities.sendStatus({ organisationId })) }
283
299
  : null,
284
300
  contact: contact ?? null,
285
- job: jobs.get(invoiceId) ?? null,
301
+ job: withLink(jobs.get(invoiceId) ?? null, work),
302
+ // The Linked card: each connected system, and this invoice's link there.
303
+ linksTo: linkedSystems(work, accounting),
286
304
  versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
287
305
  };
288
306
  }
@@ -343,6 +361,7 @@ export function createInvoicingRouter({
343
361
  return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
344
362
  })();
345
363
  const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
364
+ const systems = work();
346
365
  const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
347
366
  for (const { status: s, n } of counted) counts[s] = n;
348
367
  counts.to_check = views.toCheck.length;
@@ -355,7 +374,7 @@ export function createInvoicingRouter({
355
374
  invoices: rows.map(({ invoice, contactName }) => ({
356
375
  ...invoice,
357
376
  contactName,
358
- job: jobs.get(invoice.id) ?? null,
377
+ job: withLink(jobs.get(invoice.id) ?? null, systems),
359
378
  view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
360
379
  : views.sending.has(invoice.id) ? 'sending' : null,
361
380
  problems: views.problems.get(invoice.id) ?? [],
@@ -363,6 +382,8 @@ export function createInvoicingRouter({
363
382
  counts,
364
383
  sendsTo: system?.name ?? null,
365
384
  sendMode: mode,
385
+ // The Linked column: one icon for each connected system.
386
+ linksTo: linkedSystems(systems, system),
366
387
  });
367
388
  }));
368
389
 
@@ -8,6 +8,9 @@ import { normaliseEmail } from './people.js';
8
8
  // getUser(clerkUserId) the Clerk user (emails, external accounts)
9
9
  // findUserIdByEmail(email) the Clerk user with this email, or null
10
10
  // createUser({ email }) a Clerk account with no password, for an invitee
11
+ // deleteUser(clerkUserId) removes a Clerk account (an unused invitee's only)
12
+ // inviteByEmail({ email, redirectUrl })
13
+ // Clerk emails an invitation link to `email`
11
14
  //
12
15
  // The real one is loaded only when FLOW_AUTH=clerk.
13
16
  export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, authorizedParties = [] }) {
@@ -36,9 +39,41 @@ export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, au
36
39
  const user = await client.users.createUser({ emailAddress: [email], skipPasswordRequirement: true });
37
40
  return user.id;
38
41
  },
42
+ async deleteUser(clerkUserId) {
43
+ await client.users.deleteUser(clerkUserId);
44
+ },
45
+ // Clerk sends the email. Its link opens `redirectUrl` with a sign-up
46
+ // ticket; a pending invitation for the same address is replaced.
47
+ async inviteByEmail({ email, redirectUrl }) {
48
+ await client.invitations.createInvitation({ emailAddress: email, redirectUrl, notify: true, ignoreExisting: true });
49
+ },
39
50
  };
40
51
  }
41
52
 
53
+ // A Clerk account nobody has used: never signed in, no password and no
54
+ // connected account. Flow made it for an invitee (ensureClerkAccount).
55
+ export function unusedClerkAccount(clerkUser) {
56
+ return Boolean(clerkUser)
57
+ && !clerkUser.lastSignInAt
58
+ && clerkUser.passwordEnabled === false
59
+ && !(Array.isArray(clerkUser.externalAccounts) && clerkUser.externalAccounts.length);
60
+ }
61
+
62
+ // Has Clerk email an invitation to `email`. Clerk does not invite an address
63
+ // that already has an account, so an unused account Flow made earlier is
64
+ // removed first; the invitee's account is made when they take up the
65
+ // invitation. An address with a used account gets no email: that person can
66
+ // already sign in. Returns { emailed }.
67
+ export async function sendClerkInvitation(clerk, email, redirectUrl) {
68
+ const existing = await clerk.findUserIdByEmail(email);
69
+ if (existing) {
70
+ if (!unusedClerkAccount(await clerk.getUser(existing))) return { emailed: false };
71
+ await clerk.deleteUser(existing);
72
+ }
73
+ await clerk.inviteByEmail({ email, redirectUrl });
74
+ return { emailed: true };
75
+ }
76
+
42
77
  // An invitee needs a Clerk account before their first sign-in, because with
43
78
  // public sign-up off Flow's sign-in page never creates one. The account has
44
79
  // the invitee's email (verified, since the backend made it) and no password;
@@ -1,9 +1,10 @@
1
1
  import express from 'express';
2
2
  import { FlowError } from '../core/errors.js';
3
3
  import { rateLimit } from '../rate-limit.js';
4
- import { ensureClerkAccount } from './clerk.js';
4
+ import { ensureClerkAccount, sendClerkInvitation } from './clerk.js';
5
5
  import {
6
6
  findPersonByEmail,
7
+ findPersonById,
7
8
  invitePerson,
8
9
  listPeople,
9
10
  normaliseEmail,
@@ -139,23 +140,61 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
139
140
  }
140
141
  });
141
142
 
142
- // Invite someone. Flow also makes sure they have a Clerk account, because
143
- // with public sign-up off the sign-in page never creates one.
143
+ // The invitation email's link opens this installation's sign-in page.
144
+ const signInUrl = (req) => `${req.protocol}://${req.get('host')}/sign-in`;
145
+
146
+ // Clerk emails the invitation. If Clerk cannot, Flow makes the invitee's
147
+ // Clerk account itself (with public sign-up off the sign-in page never
148
+ // creates one) and the owner sends the sign-in message by hand.
149
+ async function inviteWithClerk(req, email) {
150
+ try {
151
+ return await sendClerkInvitation(clerk, email, signInUrl(req));
152
+ } catch (error) {
153
+ log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
154
+ }
155
+ await ensureClerkAccount(clerk, email);
156
+ return { emailed: false };
157
+ }
158
+
159
+ // Invite someone.
144
160
  people.post('/', async (req, res) => {
145
161
  try {
146
162
  const email = normaliseEmail(req.body?.email);
147
163
  const role = req.body?.role;
148
164
  if (!isPlainEmail(email)) throw new FlowError('invalid', 'email must be a plain (ASCII) email address');
149
165
  if (await findPersonByEmail(db, email)) throw new FlowError('conflict', 'that email address is already on the people list');
166
+ let invite;
150
167
  try {
151
- await ensureClerkAccount(clerk, email);
168
+ invite = await inviteWithClerk(req, email);
152
169
  } catch (error) {
153
170
  log.error?.(`[people] Clerk account for an invitation failed: ${error.message}`);
154
171
  return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Flow could not prepare the sign-in account. Try again shortly.' });
155
172
  }
156
173
  const person = await invitePerson(db, { email, name: req.body?.name ?? null, role, invitedBy: req.person.id });
157
- log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role}`);
158
- res.status(201).json({ ok: true, person: publicPerson(person) });
174
+ log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role} (${invite.emailed ? 'emailed' : 'not emailed'})`);
175
+ res.status(201).json({ ok: true, person: publicPerson(person), invite });
176
+ } catch (error) {
177
+ sendError(res, error, log);
178
+ }
179
+ });
180
+
181
+ // Email an invited person's invitation again. Only for someone who has
182
+ // not signed in yet.
183
+ people.post('/:id/invite', async (req, res) => {
184
+ try {
185
+ const person = await findPersonById(db, req.params.id);
186
+ if (!person) throw new FlowError('not_found', 'person not found');
187
+ if (person.clerkUserId || person.status !== 'active') throw new FlowError('conflict', 'only an invited person who has not signed in yet gets an invitation email');
188
+ let invite;
189
+ try {
190
+ invite = await sendClerkInvitation(clerk, person.email, signInUrl(req));
191
+ } catch (error) {
192
+ log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
193
+ return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Clerk could not send the invitation email. Try again shortly.' });
194
+ }
195
+ if (!invite.emailed) throw new FlowError('conflict', 'they already have a sign-in account: they can sign in now');
196
+ log.info?.(`[people] ${req.person.id} emailed the invitation for ${person.id} again`);
197
+ res.json({ ok: true, invite });
159
198
  } catch (error) {
160
199
  sendError(res, error, log);
161
200
  }
@@ -0,0 +1,24 @@
1
+ // Where this copy of Flow runs. A Replit workspace (the editor's Run button)
2
+ // shares its secrets with the published app, so without a rule it would sync,
3
+ // draft and send next to the live copy: twice the calls to Xero and Seazona,
4
+ // and a second sender. FLOW_BACKGROUND_JOBS decides; unset, it is off in a
5
+ // Replit workspace and on everywhere else (docs/SELF_HOSTING.md).
6
+
7
+ const read = (env, name) => String(env?.[name] ?? '').trim();
8
+
9
+ // Replit sets REPL_ID in the workspace and REPLIT_DEPLOYMENT on the published
10
+ // app only.
11
+ export function isReplitWorkspace(env = process.env) {
12
+ return Boolean(read(env, 'REPL_ID')) && !read(env, 'REPLIT_DEPLOYMENT');
13
+ }
14
+
15
+ // Whether this copy runs the jobs that call or write to other systems: the
16
+ // Seazona sync, the Xero read, the pipeline, the daily reconcile and the send
17
+ // worker. Off, it also never writes to Xero.
18
+ export function backgroundJobsOn(env = process.env) {
19
+ const value = read(env, 'FLOW_BACKGROUND_JOBS').toLowerCase();
20
+ if (value === 'on') return true;
21
+ if (value === 'off') return false;
22
+ if (value) throw new Error('FLOW_BACKGROUND_JOBS must be "on", "off" or unset');
23
+ return !isReplitWorkspace(env);
24
+ }
package/server/index.js CHANGED
@@ -13,6 +13,7 @@ import { createMonitoredLog } from './core/monitoring.js';
13
13
  import { createTracing, traceQueries } from './core/tracing.js';
14
14
  import { createPipelineWorker } from './core/pipeline.js';
15
15
  import { createReconcileWorker } from './core/reconcile.js';
16
+ import { backgroundJobsOn } from './core/host.js';
16
17
  import { createSendWorker } from './core/sending.js';
17
18
  import { resolveSecretsKey } from './core/secrets.js';
18
19
  import { adapters as shippedAdapters } from './integrations/adapters.js';
@@ -96,20 +97,25 @@ try {
96
97
  const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => `${a.id}${a.state === 'ready' ? '' : ` (${a.state})`}`);
97
98
  console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets key ${secrets ? `from ${secrets.source}` : 'not set (FLOW_SECRETS_KEY)'}`);
98
99
 
100
+ // Background jobs run only where FLOW_BACKGROUND_JOBS allows: never in a
101
+ // Replit workspace, which shares the published app's secrets
102
+ // (server/core/host.js).
103
+ const jobsOn = backgroundJobsOn(process.env);
104
+ if (!jobsOn) console.log('[flow] background jobs off (FLOW_BACKGROUND_JOBS): no sync, pipeline, reconcile or sending in this copy');
99
105
  // The pipeline (server/core/pipeline.js) joins what the integrations copy into
100
106
  // Flow: jobs into the ledger, and each job to its accounting invoice. It runs
101
107
  // only while an integration that feeds it is on, and writes to no other
102
108
  // system. FLOW_PIPELINE=off stops it.
103
- if (process.env.FLOW_PIPELINE !== 'off') {
109
+ if (jobsOn && process.env.FLOW_PIPELINE !== 'off') {
104
110
  createPipelineWorker({ db: database.db, integrations, log }).start();
105
111
  }
106
112
  // The daily reconcile (server/core/reconcile.js) compares Flow's own copies
107
113
  // and opens a review item for a day that does not agree. It never calls or
108
114
  // writes to another system.
109
- createReconcileWorker({ db: database.db, integrations, log }).start();
115
+ if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start();
110
116
  // The send worker (server/core/sending.js) sends only what a person queued
111
117
  // with Send all ready, to the accounting integration that is on.
112
- createSendWorker({ db: database.db, integrations, log }).start();
118
+ if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
113
119
 
114
120
  let auth = null;
115
121
  if (settings.mode === 'clerk') {
@@ -111,7 +111,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
111
111
  const scheduler = settings.scheduled ? createSeazonaScheduler({ db, settings, run, now, log, timers }) : null;
112
112
  runtime = { settings, db, client, run, scheduler };
113
113
  scheduler?.start();
114
- log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
114
+ log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : settings.mode === 'live' ? 'off (FLOW_BACKGROUND_JOBS)' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
115
115
  },
116
116
 
117
117
  async stop() {
@@ -8,6 +8,8 @@
8
8
  // replay recorded, synthetic responses from a file; no network at all.
9
9
  // There is no default mode. Without one the adapter does nothing.
10
10
 
11
+ import { backgroundJobsOn } from '../../core/host.js';
12
+
11
13
  export const MODES = Object.freeze(['live', 'sandbox', 'replay']);
12
14
 
13
15
  // Seazona publishes 60 requests a minute per API client. Flow stays at half of
@@ -190,8 +192,9 @@ export function resolveSeazonaSettings(env = process.env) {
190
192
  credentials: null,
191
193
  replayFile: '',
192
194
  rate,
193
- // The scheduler starts only in live mode (issue #10).
194
- scheduled: mode === 'live',
195
+ // The scheduler starts only in live mode (issue #10), and only where
196
+ // background jobs run: never in a Replit workspace (server/core/host.js).
197
+ scheduled: mode === 'live' && backgroundJobsOn(env),
195
198
  // The address of one order in Seazona's own screens, with {ref} for the
196
199
  // order number, for "Open in Seazona" on a stage move. Optional; one
197
200
  // without {ref} is ignored.
@@ -1,4 +1,5 @@
1
1
  import * as SentryNode from '@sentry/node';
2
+ import { isReplitWorkspace } from '../../core/host.js';
2
3
  import { scrubSpan } from '../../ui/monitoring.js';
3
4
 
4
5
  // The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
@@ -40,7 +41,7 @@ export const SENTRY_SETUP = {
40
41
  },
41
42
  fields: [
42
43
  { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
43
- { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
44
+ { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
44
45
  { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
45
46
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
46
47
  ],
@@ -50,7 +51,9 @@ export function readSentrySettings(env) {
50
51
  const dsn = String(env.SENTRY_DSN ?? '').trim();
51
52
  if (!dsn) throw new Error('SENTRY_DSN is not set. Enter the Sentry project\'s DSN in Settings, Integrations.');
52
53
  if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
53
- const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
54
+ // A Replit workspace shares the published app's secrets; its errors are
55
+ // labelled apart so they never read as the live app's.
56
+ const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
54
57
  const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
55
58
  const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
56
59
  const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);