@arcforgelabs/flow 2026.10.18 → 2026.10.19-beta.1
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 +3 -0
- package/SECURITY.md +4 -1
- package/npm-shrinkwrap.json +32 -3
- package/package.json +3 -2
- package/server/app.js +26 -3
- package/server/auth/routes.js +27 -12
- package/server/auth/session.js +6 -3
- package/server/auth/settings.js +26 -7
- package/server/auth/sign-in.html +17 -6
- package/server/core/invoices.js +6 -3
- package/server/core/ledger.js +420 -0
- package/server/db/migrations/0003_billing_ledger.sql +84 -0
- package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
- package/server/db/migrations/meta/0003_snapshot.json +1327 -0
- package/server/db/migrations/meta/0004_snapshot.json +1327 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +137 -0
- package/server/import/earlier-flow.js +230 -0
- package/server/import-earlier-flow.js +66 -0
- package/server/index.js +16 -4
- package/server/integrations/adapters.js +6 -0
- package/server/integrations/health.js +21 -0
- package/server/integrations/index.js +26 -0
- package/server/integrations/port.js +154 -6
- package/server/integrations/remove-tables.js +31 -0
- package/server/integrations/schema.js +62 -0
- package/server/integrations/seazona/client.js +196 -0
- package/server/integrations/seazona/drizzle.config.js +9 -0
- package/server/integrations/seazona/feed.js +104 -0
- package/server/integrations/seazona/governor.js +174 -0
- package/server/integrations/seazona/index.js +84 -0
- package/server/integrations/seazona/lanes.js +431 -0
- package/server/integrations/seazona/ledger.js +66 -0
- package/server/integrations/seazona/map.js +229 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
- package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
- package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
- package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
- package/server/integrations/seazona/replay.js +79 -0
- package/server/integrations/seazona/scheduler.js +78 -0
- package/server/integrations/seazona/schema.js +157 -0
- package/server/integrations/seazona/settings.js +161 -0
- package/server/integrations/seazona/store.js +219 -0
- package/server/integrations/seazona/sync-once.js +44 -0
- package/server/integrations/seazona/tables.js +17 -0
- package/server/integrations/xero/catalogue.js +184 -0
- package/server/integrations/xero/client.js +253 -0
- package/server/integrations/xero/connections.js +104 -0
- package/server/integrations/xero/drafts.js +525 -0
- package/server/integrations/xero/fixtures/replay.json +45 -0
- package/server/integrations/xero/governor.js +275 -0
- package/server/integrations/xero/index.js +56 -0
- package/server/integrations/xero/ledger.js +269 -0
- package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
- package/server/integrations/xero/migrations/meta/_journal.json +34 -0
- package/server/integrations/xero/oauth.js +143 -0
- package/server/integrations/xero/replay.js +83 -0
- package/server/integrations/xero/routes.js +234 -0
- package/server/integrations/xero/runtime.js +135 -0
- package/server/integrations/xero/settings.js +170 -0
- 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.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.19-beta.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.19-beta.1",
|
|
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/
|
|
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.
|
|
3
|
+
"version": "2026.10.19-beta.1",
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
package/server/auth/routes.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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);
|
package/server/auth/session.js
CHANGED
|
@@ -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.
|
|
85
|
-
// nothing about them: they
|
|
86
|
-
|
|
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
|
}
|
package/server/auth/settings.js
CHANGED
|
@@ -64,24 +64,42 @@ export function parseOwnerMfaMaxAge(raw) {
|
|
|
64
64
|
return minutes;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
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(
|
|
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
|
|
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),
|
package/server/auth/sign-in.html
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
96
|
+
script.src = src;
|
|
85
97
|
script.crossOrigin = 'anonymous';
|
|
86
|
-
script
|
|
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(
|
|
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
|
}
|
package/server/core/invoices.js
CHANGED
|
@@ -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
|
-
|
|
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,
|