@arcforgelabs/flow 0.0.0-stage → 2026.10.18-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/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2026.10.18-beta.1",
4
+ "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/arcforgelabs/flow.git"
9
+ },
10
+ "homepage": "https://github.com/arcforgelabs/flow#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/arcforgelabs/flow/issues"
13
+ },
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=24"
17
+ },
18
+ "bin": {
19
+ "flow-server": "./server/flow-server.js"
20
+ },
21
+ "files": [
22
+ "server/",
23
+ "SECURITY.md",
24
+ "VISION.md",
25
+ "npm-shrinkwrap.json"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "scripts": {
32
+ "start": "node server/index.js",
33
+ "test": "node --test test/*.test.js",
34
+ "db:generate": "drizzle-kit generate"
35
+ },
36
+ "dependencies": {
37
+ "@clerk/backend": "3.23.2",
38
+ "drizzle-orm": "0.45.3",
39
+ "express": "5.3.0",
40
+ "pg": "8.23.1"
41
+ },
42
+ "devDependencies": {
43
+ "drizzle-kit": "0.31.11"
44
+ }
45
+ }
@@ -0,0 +1,17 @@
1
+ import express from 'express';
2
+
3
+ // The Flow API (`/api/flow/v1`, issue #9) is for scripts, the `flow` CLI and
4
+ // outside agents. Callers authenticate with a signed connector key, never a
5
+ // Clerk session: sign-in and machine access stay separate. Nothing here reads
6
+ // a Clerk session. Until connector keys exist, every request is refused.
7
+ export function createConnectorApi() {
8
+ const router = express.Router();
9
+ router.use((_req, res) => {
10
+ res.status(401).json({
11
+ ok: false,
12
+ code: 'connector_signature_required',
13
+ error: 'The Flow API needs a signed connector request. Connector keys are not available yet.',
14
+ });
15
+ });
16
+ return router;
17
+ }
package/server/app.js ADDED
@@ -0,0 +1,76 @@
1
+ import express from 'express';
2
+ import { createConnectorApi } from './api/connector.js';
3
+ import { createSignInRouter } from './auth/routes.js';
4
+ import { deployedBuildStatus } from './deployed-build.js';
5
+ import { rateLimit } from './rate-limit.js';
6
+
7
+ // The Flow HTTP app. Routes are added per module (invoices, bills, payments,
8
+ // dashboard).
9
+ //
10
+ // `auth` is { settings, clerk, personAuth, clerkProxy } from server/index.js
11
+ // (or a test). Without it sign-in is off and every person route answers 503.
12
+ // `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
+ // (server/deployed-build.js); `updates` is a server/update-check.js checker.
15
+ export function createApp({
16
+ build = deployedBuildStatus(),
17
+ database = null,
18
+ auth = null,
19
+ updates = null,
20
+ trustProxy = false,
21
+ rateLimitPerMinute = 120,
22
+ log = console,
23
+ } = {}) {
24
+ const app = express();
25
+ app.disable('x-powered-by');
26
+ app.set('trust proxy', trustProxy);
27
+
28
+ app.get('/health', async (_req, res) => {
29
+ let databaseOk = null;
30
+ if (database) {
31
+ databaseOk = await database.pool.query('SELECT 1').then(() => true, () => false);
32
+ }
33
+ const ok = databaseOk !== false;
34
+ // The running build (commit, deploy ref, source, CI result, release), so
35
+ // an operator can confirm what a publish deployed. The repository is
36
+ // public, so none of it is a secret.
37
+ res.status(ok ? 200 : 503).json({ ok, service: 'flow', database: databaseOk, build });
38
+ });
39
+
40
+ // Clerk's Frontend API through Flow's origin (CLERK_PROXY_URL). Before any
41
+ // body parser, so request bodies reach Clerk intact.
42
+ if (auth?.clerkProxy) app.use(auth.clerkProxy.path, auth.clerkProxy.handler);
43
+
44
+ app.use('/api', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
45
+
46
+ // Machine access first, with its own authentication (issue #9). A Clerk
47
+ // session never opens it.
48
+ app.use('/api/flow/v1', createConnectorApi());
49
+
50
+ // Is a newer Flow release out on this installation's channel? Owners only,
51
+ // as in Field. It only reads, so an owner session is enough; it needs no
52
+ // second factor. With sign-in off it answers 503 like the person routes,
53
+ // and the server log still carries the notice (server/index.js).
54
+ if (updates) {
55
+ const updateStatus = async (_req, res) => res.json({ ok: true, ...(await updates.status()) });
56
+ if (auth?.personAuth) {
57
+ app.get('/api/updates', auth.personAuth.requireSession, auth.personAuth.requireRole('owner'), updateStatus);
58
+ } else {
59
+ app.get('/api/updates', (_req, res) => res.status(503).json({
60
+ ok: false,
61
+ code: 'sign_in_not_configured',
62
+ error: 'Sign-in is not configured on this installation (FLOW_AUTH is unset).',
63
+ }));
64
+ }
65
+ }
66
+
67
+ app.use(createSignInRouter({
68
+ settings: auth?.settings ?? { mode: 'off' },
69
+ db: database?.db ?? null,
70
+ clerk: auth?.clerk ?? null,
71
+ personAuth: auth?.personAuth ?? null,
72
+ log,
73
+ }));
74
+
75
+ return app;
76
+ }
@@ -0,0 +1,139 @@
1
+ import { Readable } from 'node:stream';
2
+ import { normaliseEmail } from './people.js';
3
+
4
+ // Everything Flow asks of Clerk goes through one small adapter, so tests pass
5
+ // a fake with the same members and never need keys or the network:
6
+ //
7
+ // authenticate(req) the verified session on this request, or null
8
+ // getUser(clerkUserId) the Clerk user (emails, external accounts)
9
+ // findUserIdByEmail(email) the Clerk user with this email, or null
10
+ // createUser({ email }) a Clerk account with no password, for an invitee
11
+ //
12
+ // The real one is loaded only when FLOW_AUTH=clerk.
13
+ export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, authorizedParties = [] }) {
14
+ const { createClerkClient } = await import('@clerk/backend');
15
+ // No SDK telemetry: Flow sends Clerk only what sign-in needs.
16
+ const client = createClerkClient({ secretKey, publishableKey, ...(proxyUrl ? { proxyUrl } : {}), telemetry: { disabled: true } });
17
+ return {
18
+ async authenticate(req) {
19
+ // Only the Authorization header is read. Flow never accepts the session
20
+ // cookie, so another site cannot make a browser act with it.
21
+ const headers = new Headers();
22
+ if (typeof req.headers.authorization === 'string') headers.set('authorization', req.headers.authorization);
23
+ const request = new Request(`${req.protocol}://${req.get('host')}${req.originalUrl}`, { method: req.method, headers });
24
+ const state = await client.authenticateRequest(request, {
25
+ acceptsToken: 'session_token',
26
+ ...(authorizedParties.length ? { authorizedParties } : {}),
27
+ });
28
+ return state.isAuthenticated ? state.toAuth() : null;
29
+ },
30
+ getUser: (clerkUserId) => client.users.getUser(clerkUserId),
31
+ async findUserIdByEmail(email) {
32
+ const { data } = await client.users.getUserList({ emailAddress: [email], limit: 1 });
33
+ return data[0]?.id ?? null;
34
+ },
35
+ async createUser({ email }) {
36
+ const user = await client.users.createUser({ emailAddress: [email], skipPasswordRequirement: true });
37
+ return user.id;
38
+ },
39
+ };
40
+ }
41
+
42
+ // An invitee needs a Clerk account before their first sign-in, because with
43
+ // public sign-up off Flow's sign-in page never creates one. The account has
44
+ // the invitee's email (verified, since the backend made it) and no password;
45
+ // their first Google sign-in on that address joins it.
46
+ export async function ensureClerkAccount(clerk, email) {
47
+ const existing = await clerk.findUserIdByEmail(email);
48
+ if (existing) return { clerkUserId: existing, created: false };
49
+ return { clerkUserId: await clerk.createUser({ email }), created: true };
50
+ }
51
+
52
+ // The Clerk user's primary email and whether Clerk has verified it. Flow
53
+ // links people only by a verified primary address.
54
+ export function primaryEmailOf(clerkUser) {
55
+ const addresses = Array.isArray(clerkUser?.emailAddresses) ? clerkUser.emailAddresses : [];
56
+ const primary = addresses.find((address) => address?.id === clerkUser?.primaryEmailAddressId) ?? null;
57
+ if (!primary) return { email: null, verified: false };
58
+ return { email: normaliseEmail(primary.emailAddress), verified: primary.verification?.status === 'verified' };
59
+ }
60
+
61
+ export function displayNameOf(clerkUser) {
62
+ const name = [clerkUser?.firstName, clerkUser?.lastName].map((part) => String(part || '').trim()).filter(Boolean).join(' ');
63
+ return name || null;
64
+ }
65
+
66
+ // Clerk's `fva` claim is [minutes since first factor, minutes since second
67
+ // factor]; the second is -1 when the session never passed a second factor.
68
+ export function secondFactorVerified(auth, maxAgeMinutes) {
69
+ const fva = Array.isArray(auth?.factorVerificationAge) ? auth.factorVerificationAge
70
+ : Array.isArray(auth?.sessionClaims?.fva) ? auth.sessionClaims.fva : null;
71
+ const age = fva?.[1];
72
+ return typeof age === 'number' && Number.isFinite(age) && age >= 0 && age <= maxAgeMinutes;
73
+ }
74
+
75
+ // The Google Workspace owner path. The Clerk user has a verified Google
76
+ // account whose email is the owner's Flow email, on an allowed domain, and
77
+ // no password, so Google (and the 2-Step Verification its Workspace
78
+ // enforces) is the only way into that account.
79
+ export function googleWorkspaceOwner(clerkUser, flowEmail, allowedDomains) {
80
+ if (!clerkUser || !allowedDomains?.length) return false;
81
+ if (clerkUser.passwordEnabled !== false) return false;
82
+ const email = normaliseEmail(flowEmail);
83
+ const domain = email.slice(email.lastIndexOf('@') + 1);
84
+ if (!email.includes('@') || !allowedDomains.includes(domain)) return false;
85
+ const accounts = Array.isArray(clerkUser.externalAccounts) ? clerkUser.externalAccounts : [];
86
+ return accounts.some((account) => ['oauth_google', 'google'].includes(String(account?.provider).toLowerCase())
87
+ && account?.verification?.status === 'verified'
88
+ && normaliseEmail(account?.emailAddress) === email);
89
+ }
90
+
91
+ // The path part of CLERK_PROXY_URL with no trailing slash, or null.
92
+ export function resolveClerkProxyPath(proxyUrl) {
93
+ if (!proxyUrl) return null;
94
+ let pathname;
95
+ try {
96
+ pathname = new URL(proxyUrl, 'http://flow.invalid').pathname;
97
+ } catch {
98
+ throw new Error('CLERK_PROXY_URL is not a valid URL or path');
99
+ }
100
+ return pathname.replace(/\/+$/, '') || null;
101
+ }
102
+
103
+ // Replit's managed Clerk publishes the app with CLERK_PROXY_URL, so the
104
+ // browser talks to Clerk's Frontend API through Flow's own origin. Clerk's
105
+ // SDK does the forwarding; this turns Express's request into a web Request
106
+ // and streams the answer back. Mount it before any body parser.
107
+ export function createClerkProxy({ proxyPath, publishableKey, secretKey, proxy }) {
108
+ return async function clerkProxy(req, res, next) {
109
+ try {
110
+ const headers = new Headers();
111
+ for (const [name, value] of Object.entries(req.headers)) {
112
+ if (value === undefined) continue;
113
+ if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
114
+ else headers.set(name, value);
115
+ }
116
+ // The client address as Express resolved it, so a client cannot hand
117
+ // Clerk a forged X-Forwarded-For.
118
+ headers.delete('x-forwarded-for');
119
+ if (req.ip) headers.set('x-forwarded-for', req.ip);
120
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
121
+ const request = new Request(`${req.protocol}://${req.get('host')}${req.originalUrl}`, {
122
+ method: req.method,
123
+ headers,
124
+ ...(hasBody ? { body: Readable.toWeb(req), duplex: 'half' } : {}),
125
+ });
126
+ const response = await proxy(request, { proxyPath, publishableKey, secretKey });
127
+ res.status(response.status);
128
+ for (const [name, value] of response.headers) {
129
+ if (name !== 'set-cookie') res.setHeader(name, value);
130
+ }
131
+ const cookies = response.headers.getSetCookie?.() ?? [];
132
+ if (cookies.length) res.setHeader('set-cookie', cookies);
133
+ if (!response.body || req.method === 'HEAD') return res.end();
134
+ Readable.fromWeb(response.body).on('error', next).pipe(res);
135
+ } catch (error) {
136
+ next(error);
137
+ }
138
+ };
139
+ }
@@ -0,0 +1,138 @@
1
+ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
2
+ import { people, PERSON_ROLES } from '../db/schema.js';
3
+ import { FlowError } from '../core/errors.js';
4
+ import { isPlainEmail } from './settings.js';
5
+
6
+ // The people who may sign in, and their roles. Only an owner changes this
7
+ // table (server/auth/routes.js); sign-in only reads it and links a row to its
8
+ // Clerk user on the first verified sign-in.
9
+
10
+ export function normaliseEmail(email) {
11
+ return String(email ?? '').trim().toLowerCase();
12
+ }
13
+
14
+ export function publicPerson(row) {
15
+ return {
16
+ id: row.id,
17
+ email: row.email,
18
+ name: row.name,
19
+ role: row.role,
20
+ status: row.status,
21
+ // An invitation the person has not taken up yet.
22
+ invited: !row.clerkUserId,
23
+ createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
24
+ };
25
+ }
26
+
27
+ export async function findPersonById(db, id) {
28
+ const [row] = await db.select().from(people).where(eq(people.id, id));
29
+ return row ?? null;
30
+ }
31
+
32
+ export async function findPersonByClerkId(db, clerkUserId) {
33
+ const [row] = await db.select().from(people).where(eq(people.clerkUserId, clerkUserId));
34
+ return row ?? null;
35
+ }
36
+
37
+ export async function findPersonByEmail(db, email) {
38
+ const [row] = await db.select().from(people).where(eq(people.email, normaliseEmail(email)));
39
+ return row ?? null;
40
+ }
41
+
42
+ export function listPeople(db) {
43
+ return db.select().from(people).orderBy(asc(people.status), asc(people.email));
44
+ }
45
+
46
+ function checkRole(role) {
47
+ if (!PERSON_ROLES.includes(role)) throw new FlowError('invalid', `role must be one of ${PERSON_ROLES.join(', ')}`);
48
+ }
49
+
50
+ // An owner invites someone by email. They get in on their first sign-in with
51
+ // a verified Clerk account on that address.
52
+ export async function invitePerson(db, { email, name = null, role, invitedBy = null }) {
53
+ const address = normaliseEmail(email);
54
+ if (!isPlainEmail(address)) throw new FlowError('invalid', 'email must be a plain (ASCII) email address');
55
+ checkRole(role);
56
+ const [row] = await db.insert(people)
57
+ .values({ email: address, name: name?.trim() || null, role, status: 'active', invitedBy })
58
+ .onConflictDoNothing({ target: people.email })
59
+ .returning();
60
+ if (!row) throw new FlowError('conflict', 'that email address is already on the people list');
61
+ return row;
62
+ }
63
+
64
+ // Public sign-up only: someone Flow does not know, waiting for an owner.
65
+ export async function recordPendingPerson(db, { email, name, clerkUserId }) {
66
+ const [row] = await db.insert(people)
67
+ .values({ email: normaliseEmail(email), name: name || null, role: 'staff', status: 'pending', clerkUserId, linkedAt: new Date() })
68
+ .onConflictDoNothing()
69
+ .returning();
70
+ return row ?? null;
71
+ }
72
+
73
+ // Links an invitation to the Clerk user who signed in with its verified
74
+ // email. Only an unlinked row links, so a concurrent sign-in cannot take it.
75
+ export async function linkPerson(db, { id, clerkUserId }) {
76
+ const [row] = await db.update(people)
77
+ .set({ clerkUserId, linkedAt: new Date(), updatedAt: new Date() })
78
+ .where(and(eq(people.id, id), isNull(people.clerkUserId)))
79
+ .returning();
80
+ return row ?? null;
81
+ }
82
+
83
+ // While the installation has no owner, invite `email` as its owner (or make
84
+ // that person the owner if they are already listed). Returns the row, or null
85
+ // when an owner already exists.
86
+ export async function seedFirstOwner(db, email) {
87
+ return db.transaction(async (tx) => {
88
+ await tx.execute(sql`lock table ${people} in share row exclusive mode`);
89
+ const [owner] = await tx.select({ id: people.id }).from(people).where(eq(people.role, 'owner')).limit(1);
90
+ if (owner) return null;
91
+ const address = normaliseEmail(email);
92
+ const [row] = await tx.insert(people)
93
+ .values({ email: address, role: 'owner', status: 'active' })
94
+ .onConflictDoUpdate({ target: people.email, set: { role: 'owner', status: 'active', updatedAt: new Date() } })
95
+ .returning();
96
+ return row;
97
+ });
98
+ }
99
+
100
+ // Changes made by an owner. Flow always keeps one active owner, and an owner
101
+ // never changes or removes themself (so nobody locks themself out by mistake).
102
+ export async function updatePerson(db, { id, actorId, role, status }) {
103
+ if (role !== undefined) checkRole(role);
104
+ if (status !== undefined && !['active', 'disabled'].includes(status)) {
105
+ throw new FlowError('invalid', 'status must be active or disabled');
106
+ }
107
+ if (id === actorId) throw new FlowError('forbidden', 'you cannot change your own role or access');
108
+ return db.transaction(async (tx) => {
109
+ await tx.execute(sql`lock table ${people} in share row exclusive mode`);
110
+ const current = await findPersonById(tx, id);
111
+ if (!current) throw new FlowError('not_found', 'person not found');
112
+ const next = { role: role ?? current.role, status: status ?? current.status };
113
+ if (current.role === 'owner' && current.status === 'active' && (next.role !== 'owner' || next.status !== 'active')) {
114
+ await assertAnotherOwner(tx, id);
115
+ }
116
+ const [row] = await tx.update(people).set({ ...next, updatedAt: new Date() }).where(eq(people.id, id)).returning();
117
+ return row;
118
+ });
119
+ }
120
+
121
+ export async function removePerson(db, { id, actorId }) {
122
+ if (id === actorId) throw new FlowError('forbidden', 'you cannot remove yourself');
123
+ return db.transaction(async (tx) => {
124
+ await tx.execute(sql`lock table ${people} in share row exclusive mode`);
125
+ const current = await findPersonById(tx, id);
126
+ if (!current) throw new FlowError('not_found', 'person not found');
127
+ if (current.role === 'owner' && current.status === 'active') await assertAnotherOwner(tx, id);
128
+ await tx.delete(people).where(eq(people.id, id));
129
+ return current;
130
+ });
131
+ }
132
+
133
+ async function assertAnotherOwner(tx, id) {
134
+ const [other] = await tx.select({ id: people.id }).from(people)
135
+ .where(and(eq(people.role, 'owner'), eq(people.status, 'active'), ne(people.id, id)))
136
+ .limit(1);
137
+ if (!other) throw new FlowError('conflict', 'Flow needs at least one active owner');
138
+ }
@@ -0,0 +1,143 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import express from 'express';
3
+ import { FlowError } from '../core/errors.js';
4
+ import { ensureClerkAccount } from './clerk.js';
5
+ import {
6
+ findPersonByEmail,
7
+ invitePerson,
8
+ listPeople,
9
+ normaliseEmail,
10
+ publicPerson,
11
+ removePerson,
12
+ updatePerson,
13
+ } from './people.js';
14
+ import { isPlainEmail } from './settings.js';
15
+
16
+ const signInPage = fileURLToPath(new URL('./sign-in.html', import.meta.url));
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const ERROR_STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };
19
+
20
+ // The Clerk script the sign-in page loads: through Flow's own origin when
21
+ // 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
25
+ ? proxyUrl.replace(/\/+$/, '')
26
+ : `https://${Buffer.from(publishableKey.split('_')[2] ?? '', 'base64').toString('utf8').replace(/\$$/, '')}`;
27
+ return `${base}/npm/@clerk/clerk-js@6/dist/clerk.browser.js`;
28
+ }
29
+
30
+ function sendError(res, error, log) {
31
+ if (error instanceof FlowError) {
32
+ return res.status(ERROR_STATUS[error.code] ?? 400).json({ ok: false, code: error.code, error: error.message });
33
+ }
34
+ log.error?.(`[people] ${error.message}`);
35
+ return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
36
+ }
37
+
38
+ // 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 }) {
41
+ const router = express.Router();
42
+ const signInOn = settings.mode === 'clerk';
43
+
44
+ // Public: what the sign-in page needs. The publishable key is meant for
45
+ // browsers. Served at runtime, so one build serves Replit's development
46
+ // and production Clerk instances.
47
+ router.get('/api/auth/config', (_req, res) => {
48
+ res.set('Cache-Control', 'no-store');
49
+ if (!signInOn) return res.json({ ok: true, mode: 'off' });
50
+ const { publishableKey, proxyUrl } = settings.keys;
51
+ return res.json({
52
+ ok: true,
53
+ mode: 'clerk',
54
+ publicSignUp: settings.publicSignUp,
55
+ clerk: { publishableKey, proxyUrl, scriptUrl: clerkScriptUrl(settings.keys) },
56
+ });
57
+ });
58
+
59
+ router.get('/', (_req, res) => res.redirect(302, '/sign-in'));
60
+ router.get('/sign-in', (_req, res) => {
61
+ res.set('Cache-Control', 'no-store');
62
+ res.set('X-Frame-Options', 'DENY');
63
+ res.sendFile(signInPage);
64
+ });
65
+
66
+ if (!signInOn) {
67
+ router.use(['/api/me', '/api/people'], (_req, res) => res.status(503).json({
68
+ ok: false,
69
+ code: 'sign_in_not_configured',
70
+ error: 'Sign-in is not configured on this installation (FLOW_AUTH is unset).',
71
+ }));
72
+ return router;
73
+ }
74
+
75
+ const { requireSession, requireOwner, ownerVerification } = personAuth;
76
+
77
+ router.get('/api/me', requireSession, async (req, res) => {
78
+ const body = { ok: true, person: publicPerson(req.person) };
79
+ if (req.person.role === 'owner') {
80
+ const check = await ownerVerification(req.person, req.clerkAuth);
81
+ body.ownerActions = { allowed: Boolean(check.verified), by: check.by ?? null };
82
+ }
83
+ res.json(body);
84
+ });
85
+
86
+ // Only an owner manages people.
87
+ const people = express.Router();
88
+ people.use(requireSession, requireOwner, express.json({ limit: '16kb' }));
89
+ people.param('id', (_req, res, next, id) => (UUID.test(id) ? next() : res.status(404).json({ ok: false, code: 'not_found', error: 'person not found' })));
90
+
91
+ people.get('/', async (_req, res) => {
92
+ try {
93
+ res.json({ ok: true, people: (await listPeople(db)).map(publicPerson) });
94
+ } catch (error) {
95
+ sendError(res, error, log);
96
+ }
97
+ });
98
+
99
+ // Invite someone. Flow also makes sure they have a Clerk account, because
100
+ // with public sign-up off the sign-in page never creates one.
101
+ people.post('/', async (req, res) => {
102
+ try {
103
+ const email = normaliseEmail(req.body?.email);
104
+ const role = req.body?.role;
105
+ if (!isPlainEmail(email)) throw new FlowError('invalid', 'email must be a plain (ASCII) email address');
106
+ if (await findPersonByEmail(db, email)) throw new FlowError('conflict', 'that email address is already on the people list');
107
+ try {
108
+ await ensureClerkAccount(clerk, email);
109
+ } catch (error) {
110
+ log.error?.(`[people] Clerk account for an invitation failed: ${error.message}`);
111
+ return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Flow could not prepare the sign-in account. Try again shortly.' });
112
+ }
113
+ const person = await invitePerson(db, { email, name: req.body?.name ?? null, role, invitedBy: req.person.id });
114
+ log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role}`);
115
+ res.status(201).json({ ok: true, person: publicPerson(person) });
116
+ } catch (error) {
117
+ sendError(res, error, log);
118
+ }
119
+ });
120
+
121
+ people.patch('/:id', async (req, res) => {
122
+ try {
123
+ const person = await updatePerson(db, { id: req.params.id, actorId: req.person.id, role: req.body?.role, status: req.body?.status });
124
+ log.info?.(`[people] ${req.person.id} set ${person.id} to ${person.role}, ${person.status}`);
125
+ res.json({ ok: true, person: publicPerson(person) });
126
+ } catch (error) {
127
+ sendError(res, error, log);
128
+ }
129
+ });
130
+
131
+ people.delete('/:id', async (req, res) => {
132
+ try {
133
+ const person = await removePerson(db, { id: req.params.id, actorId: req.person.id });
134
+ log.info?.(`[people] ${req.person.id} removed ${person.id}`);
135
+ res.json({ ok: true });
136
+ } catch (error) {
137
+ sendError(res, error, log);
138
+ }
139
+ });
140
+
141
+ router.use('/api/people', people);
142
+ return router;
143
+ }