@arcforgelabs/flow 0.0.0-stage → 2026.10.18
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/LICENSE +21 -0
- package/README.md +45 -2
- package/SECURITY.md +61 -0
- package/VISION.md +65 -0
- package/npm-shrinkwrap.json +2754 -0
- package/package.json +43 -4
- package/server/api/connector.js +17 -0
- package/server/app.js +76 -0
- package/server/auth/clerk.js +139 -0
- package/server/auth/people.js +138 -0
- package/server/auth/routes.js +143 -0
- package/server/auth/session.js +170 -0
- package/server/auth/settings.js +124 -0
- package/server/auth/sign-in.html +184 -0
- package/server/core/directory.js +39 -0
- package/server/core/errors.js +11 -0
- package/server/core/invoices.js +188 -0
- package/server/core/money.js +38 -0
- package/server/db/index.js +21 -0
- package/server/db/migrations/0000_core.sql +93 -0
- package/server/db/migrations/0001_records_are_append_only.sql +65 -0
- package/server/db/migrations/0002_people.sql +19 -0
- package/server/db/migrations/meta/0000_snapshot.json +642 -0
- package/server/db/migrations/meta/0001_snapshot.json +642 -0
- package/server/db/migrations/meta/0002_snapshot.json +761 -0
- package/server/db/migrations/meta/_journal.json +27 -0
- package/server/db/schema.js +169 -0
- package/server/deployed-build.js +20 -0
- package/server/flow-server.js +15 -0
- package/server/index.js +85 -0
- package/server/integrations/port.js +84 -0
- package/server/rate-limit.js +21 -0
- package/server/release-channel.js +146 -0
- package/server/update-check.js +84 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { displayNameOf, googleWorkspaceOwner, primaryEmailOf, secondFactorVerified } from './clerk.js';
|
|
2
|
+
import { findPersonByClerkId, findPersonByEmail, linkPerson, recordPendingPerson } from './people.js';
|
|
3
|
+
import { DEFAULT_OWNER_MFA_MAX_AGE_MINUTES, isPlainEmail } from './settings.js';
|
|
4
|
+
|
|
5
|
+
// Checks the Clerk session on every person request, then maps the Clerk user
|
|
6
|
+
// to a Flow person by verified email. Clerk proves who someone is; the
|
|
7
|
+
// `people` table decides whether they get in and what they may do. A Clerk
|
|
8
|
+
// account with no Flow person gets no access.
|
|
9
|
+
|
|
10
|
+
const DENIALS = {
|
|
11
|
+
signed_out: [401, 'Sign in to Flow.'],
|
|
12
|
+
session_expired: [401, 'Your sign-in has expired. Sign in again.'],
|
|
13
|
+
email_unverified: [403, 'Flow links your sign-in by your verified email address. Verify your primary email, then sign in again.'],
|
|
14
|
+
email_not_plain: [403, 'Flow only accepts plain (ASCII) email addresses.'],
|
|
15
|
+
email_linked_elsewhere: [403, 'That email address is already linked to a different sign-in. Ask a Flow owner.'],
|
|
16
|
+
not_invited: [403, 'This installation is invitation only. Ask a Flow owner to invite you.'],
|
|
17
|
+
access_pending: [403, "You're signed in, but a Flow owner still needs to approve your access."],
|
|
18
|
+
access_disabled: [403, 'Your Flow access has been turned off. Ask a Flow owner if this is a mistake.'],
|
|
19
|
+
role_required: [403, 'Your Flow role does not allow this.'],
|
|
20
|
+
owner_required: [403, 'Only a Flow owner can do this.'],
|
|
21
|
+
owner_verification_required: [403, null],
|
|
22
|
+
sign_in_unavailable: [503, 'Sign-in is unavailable right now. Try again shortly.'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function deny(res, code, error) {
|
|
26
|
+
const [status, message] = DENIALS[code] ?? DENIALS.signed_out;
|
|
27
|
+
return res.status(status).json({ ok: false, code, error: error ?? message });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bearerToken(req) {
|
|
31
|
+
const header = req.headers.authorization;
|
|
32
|
+
if (typeof header !== 'string' || !header.startsWith('Bearer ')) return null;
|
|
33
|
+
return header.slice('Bearer '.length).trim() || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ownerVerificationMessage({ ownerGoogleDomains, ownerMfaMaxAgeMinutes }) {
|
|
37
|
+
const ways = [];
|
|
38
|
+
if (ownerGoogleDomains.length) {
|
|
39
|
+
ways.push(`sign in with Google using your work account (${ownerGoogleDomains.map((domain) => `@${domain}`).join(' or ')}), with no password set on your sign-in`);
|
|
40
|
+
}
|
|
41
|
+
ways.push(`sign in with a second factor (it counts for ${ownerMfaMaxAgeMinutes} minutes)`);
|
|
42
|
+
return `Owner actions need a verified owner sign-in. Sign out, then ${ways.join(', or ')}.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createPersonAuth({
|
|
46
|
+
db,
|
|
47
|
+
clerk,
|
|
48
|
+
publicSignUp = false,
|
|
49
|
+
ownerGoogleDomains = [],
|
|
50
|
+
ownerMfaMaxAgeMinutes = DEFAULT_OWNER_MFA_MAX_AGE_MINUTES,
|
|
51
|
+
log = console,
|
|
52
|
+
now = Date.now,
|
|
53
|
+
clerkUserCacheMs = 60 * 1000,
|
|
54
|
+
}) {
|
|
55
|
+
if (!db) throw new Error('createPersonAuth: db is required');
|
|
56
|
+
if (!clerk?.authenticate || !clerk?.getUser) throw new Error('createPersonAuth: a Clerk adapter is required');
|
|
57
|
+
const ownerMessage = ownerVerificationMessage({ ownerGoogleDomains, ownerMfaMaxAgeMinutes });
|
|
58
|
+
// Clerk users fetched for the owner check, cached briefly so a burst of
|
|
59
|
+
// owner requests costs one Clerk call. Failures are never cached.
|
|
60
|
+
const clerkUsers = new Map();
|
|
61
|
+
|
|
62
|
+
// { person } or { denial }.
|
|
63
|
+
async function personFor(clerkUserId) {
|
|
64
|
+
const linked = await findPersonByClerkId(db, clerkUserId);
|
|
65
|
+
if (linked) return { person: linked };
|
|
66
|
+
|
|
67
|
+
const clerkUser = await clerk.getUser(clerkUserId);
|
|
68
|
+
const { email, verified } = primaryEmailOf(clerkUser);
|
|
69
|
+
if (!email || !verified) return { denial: 'email_unverified' };
|
|
70
|
+
if (!isPlainEmail(email)) return { denial: 'email_not_plain' };
|
|
71
|
+
|
|
72
|
+
const invited = await findPersonByEmail(db, email);
|
|
73
|
+
if (invited) {
|
|
74
|
+
if (invited.clerkUserId) return { denial: 'email_linked_elsewhere' };
|
|
75
|
+
const person = await linkPerson(db, { id: invited.id, clerkUserId });
|
|
76
|
+
if (person) {
|
|
77
|
+
log.info?.(`[auth] linked Clerk user ${clerkUserId} to Flow person ${person.id} (${person.role})`);
|
|
78
|
+
return { person };
|
|
79
|
+
}
|
|
80
|
+
const again = await findPersonByClerkId(db, clerkUserId);
|
|
81
|
+
return again ? { person: again } : { denial: 'email_linked_elsewhere' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Someone Flow does not know. With public sign-up off Flow records
|
|
85
|
+
// nothing about them: they simply have no access.
|
|
86
|
+
if (!publicSignUp) {
|
|
87
|
+
log.warn?.(`[auth] Clerk user ${clerkUserId} refused: not invited`);
|
|
88
|
+
return { denial: 'not_invited' };
|
|
89
|
+
}
|
|
90
|
+
const person = await recordPendingPerson(db, { email, name: displayNameOf(clerkUser), clerkUserId });
|
|
91
|
+
if (!person) return { denial: 'access_pending' };
|
|
92
|
+
log.info?.(`[auth] Clerk user ${clerkUserId} asked for access; pending Flow person ${person.id}`);
|
|
93
|
+
return { person };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Every person route starts here. The session token must be in the
|
|
97
|
+
// Authorization header; Clerk verifies it, and Flow checks its expiry too.
|
|
98
|
+
async function requireSession(req, res, next) {
|
|
99
|
+
if (!bearerToken(req)) return deny(res, 'signed_out');
|
|
100
|
+
let auth;
|
|
101
|
+
try {
|
|
102
|
+
auth = await clerk.authenticate(req);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
log.error?.(`[auth] Clerk session check failed: ${error.message}`);
|
|
105
|
+
return deny(res, 'sign_in_unavailable');
|
|
106
|
+
}
|
|
107
|
+
if (!auth?.userId) return deny(res, 'signed_out');
|
|
108
|
+
const exp = auth.sessionClaims?.exp;
|
|
109
|
+
if (typeof exp === 'number' && exp * 1000 <= now()) return deny(res, 'session_expired');
|
|
110
|
+
|
|
111
|
+
let resolved;
|
|
112
|
+
try {
|
|
113
|
+
resolved = await personFor(auth.userId);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
log.error?.(`[auth] Flow person lookup failed: ${error.message}`);
|
|
116
|
+
return deny(res, 'sign_in_unavailable');
|
|
117
|
+
}
|
|
118
|
+
if (resolved.denial) return deny(res, resolved.denial);
|
|
119
|
+
if (resolved.person.status === 'pending') return deny(res, 'access_pending');
|
|
120
|
+
if (resolved.person.status !== 'active') return deny(res, 'access_disabled');
|
|
121
|
+
req.person = resolved.person;
|
|
122
|
+
req.clerkAuth = auth;
|
|
123
|
+
return next();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requireRole(...roles) {
|
|
127
|
+
return (req, res, next) => (roles.includes(req.person?.role) ? next() : deny(res, 'role_required'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function cachedClerkUser(clerkUserId) {
|
|
131
|
+
const cached = clerkUsers.get(clerkUserId);
|
|
132
|
+
if (cached && cached.expiresAt > now()) return cached.user;
|
|
133
|
+
const user = await clerk.getUser(clerkUserId);
|
|
134
|
+
if (clerkUsers.size > 100) clerkUsers.clear();
|
|
135
|
+
clerkUsers.set(clerkUserId, { user, expiresAt: now() + clerkUserCacheMs });
|
|
136
|
+
return user;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Is this owner's sign-in strong enough for owner actions?
|
|
140
|
+
// (a) the session passed a second factor within ownerMfaMaxAgeMinutes
|
|
141
|
+
// (an installation with its own Clerk application and MFA on), or
|
|
142
|
+
// (b) Google Workspace sign-in on an allowed domain with no password
|
|
143
|
+
// (Replit's managed Clerk, which has no MFA).
|
|
144
|
+
// A Clerk error fails closed. Returns { verified, by } or { unavailable }.
|
|
145
|
+
async function ownerVerification(person, auth) {
|
|
146
|
+
if (secondFactorVerified(auth, ownerMfaMaxAgeMinutes)) return { verified: true, by: 'second_factor' };
|
|
147
|
+
if (!ownerGoogleDomains.length) return { verified: false, by: null };
|
|
148
|
+
try {
|
|
149
|
+
const clerkUser = await cachedClerkUser(auth.userId);
|
|
150
|
+
return googleWorkspaceOwner(clerkUser, person.email, ownerGoogleDomains)
|
|
151
|
+
? { verified: true, by: 'google_workspace' }
|
|
152
|
+
: { verified: false, by: null };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
log.error?.(`[auth] owner sign-in check failed: ${error.message}`);
|
|
155
|
+
return { unavailable: true };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Owner-only routes: managing people, and later settings. Use after
|
|
160
|
+
// requireSession.
|
|
161
|
+
async function requireOwner(req, res, next) {
|
|
162
|
+
if (req.person?.role !== 'owner') return deny(res, 'owner_required');
|
|
163
|
+
const check = await ownerVerification(req.person, req.clerkAuth);
|
|
164
|
+
if (check.unavailable) return deny(res, 'sign_in_unavailable');
|
|
165
|
+
if (!check.verified) return deny(res, 'owner_verification_required', ownerMessage);
|
|
166
|
+
return next();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { requireSession, requireRole, requireOwner, ownerVerification };
|
|
170
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Sign-in settings, read from the environment once at boot. Errors name the
|
|
2
|
+
// variable, never its value: CLERK_SECRET_KEY must never reach a log.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_OWNER_MFA_MAX_AGE_MINUTES = 12 * 60;
|
|
5
|
+
|
|
6
|
+
function readEnv(env, name) {
|
|
7
|
+
const value = env?.[name];
|
|
8
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// FLOW_AUTH: "clerk" signs people in with Clerk. Unset: sign-in is off and
|
|
12
|
+
// every person route answers 503, so Flow never runs open. Any other value
|
|
13
|
+
// stops boot, so a typo cannot quietly turn sign-in off.
|
|
14
|
+
export function resolveAuthMode(env = process.env) {
|
|
15
|
+
const raw = readEnv(env, 'FLOW_AUTH').toLowerCase();
|
|
16
|
+
if (raw === '') return 'off';
|
|
17
|
+
if (raw === 'clerk') return 'clerk';
|
|
18
|
+
throw new Error('FLOW_AUTH must be "clerk" or unset');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Replit's Clerk integration sets CLERK_SECRET_KEY and CLERK_PUBLISHABLE_KEY
|
|
22
|
+
// (some templates name the second VITE_CLERK_PUBLISHABLE_KEY; either works).
|
|
23
|
+
// CLERK_PROXY_URL is set on a published Replit app.
|
|
24
|
+
export function resolveClerkKeys(env = process.env) {
|
|
25
|
+
const secretKey = readEnv(env, 'CLERK_SECRET_KEY');
|
|
26
|
+
const publishableSource = readEnv(env, 'CLERK_PUBLISHABLE_KEY') ? 'CLERK_PUBLISHABLE_KEY'
|
|
27
|
+
: readEnv(env, 'VITE_CLERK_PUBLISHABLE_KEY') ? 'VITE_CLERK_PUBLISHABLE_KEY' : null;
|
|
28
|
+
const publishableKey = publishableSource ? readEnv(env, publishableSource) : '';
|
|
29
|
+
const missing = [];
|
|
30
|
+
if (!secretKey) missing.push('CLERK_SECRET_KEY');
|
|
31
|
+
if (!publishableKey) missing.push('CLERK_PUBLISHABLE_KEY');
|
|
32
|
+
if (missing.length) {
|
|
33
|
+
throw new Error(`FLOW_AUTH=clerk needs ${missing.join(' and ')} (Replit: Tools > Users & Auth sets them)`);
|
|
34
|
+
}
|
|
35
|
+
if (!/^sk_(test|live)_\S+$/.test(secretKey)) {
|
|
36
|
+
throw new Error('CLERK_SECRET_KEY is not a Clerk secret key (expected sk_test_... or sk_live_...)');
|
|
37
|
+
}
|
|
38
|
+
if (!/^pk_(test|live)_\S+$/.test(publishableKey)) {
|
|
39
|
+
throw new Error(`${publishableSource} is not a Clerk publishable key (expected pk_test_... or pk_live_...)`);
|
|
40
|
+
}
|
|
41
|
+
return { secretKey, publishableKey, publishableSource, proxyUrl: readEnv(env, 'CLERK_PROXY_URL') || null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// FLOW_PUBLIC_SIGN_UP: "on" lets anyone with a verified email ask for access
|
|
45
|
+
// (they wait as `pending` until an owner approves them). Unset or "off": only
|
|
46
|
+
// people an owner invited get in, and Flow's sign-in page creates no account.
|
|
47
|
+
export function resolvePublicSignUp(env = process.env) {
|
|
48
|
+
const raw = readEnv(env, 'FLOW_PUBLIC_SIGN_UP').toLowerCase();
|
|
49
|
+
if (raw === '' || raw === 'off') return false;
|
|
50
|
+
if (raw === 'on') return true;
|
|
51
|
+
throw new Error('FLOW_PUBLIC_SIGN_UP must be "on", "off" or unset');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// FLOW_OWNER_MFA_MAX_AGE: how long a Clerk second factor counts for owner
|
|
55
|
+
// actions. Minutes, or with an m/h suffix ("90m", "12h"). Default 12h.
|
|
56
|
+
export function parseOwnerMfaMaxAge(raw) {
|
|
57
|
+
const value = String(raw ?? '').trim().toLowerCase();
|
|
58
|
+
if (!value) return DEFAULT_OWNER_MFA_MAX_AGE_MINUTES;
|
|
59
|
+
const match = /^(\d+)\s*(m|h)?$/.exec(value);
|
|
60
|
+
const minutes = match ? Number(match[1]) * (match[2] === 'h' ? 60 : 1) : NaN;
|
|
61
|
+
if (!Number.isSafeInteger(minutes) || minutes <= 0) {
|
|
62
|
+
throw new Error('FLOW_OWNER_MFA_MAX_AGE must be a positive number of minutes, or hours with "h" (e.g. 12h)');
|
|
63
|
+
}
|
|
64
|
+
return minutes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// FLOW_OWNER_GOOGLE_DOMAINS: comma-separated email domains whose Google
|
|
68
|
+
// Workspace sign-in counts as an owner's verified sign-in (that Workspace
|
|
69
|
+
// enforces 2-Step Verification). No default: unset turns that path off.
|
|
70
|
+
export function parseOwnerGoogleDomains(raw) {
|
|
71
|
+
const domains = String(raw ?? '').split(',').map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
72
|
+
for (const domain of domains) {
|
|
73
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/.test(domain)) {
|
|
74
|
+
throw new Error('FLOW_OWNER_GOOGLE_DOMAINS must be a comma-separated list of email domains (e.g. example.com)');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...new Set(domains)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// FLOW_PUBLIC_ORIGIN: the published app's origin. Clerk then accepts only
|
|
81
|
+
// session tokens minted for it. Unset: Clerk's default.
|
|
82
|
+
export function resolveAuthorizedParties(env = process.env) {
|
|
83
|
+
const raw = readEnv(env, 'FLOW_PUBLIC_ORIGIN');
|
|
84
|
+
if (!raw) return [];
|
|
85
|
+
let url;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(raw);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error('FLOW_PUBLIC_ORIGIN must be an absolute URL (e.g. https://flow.example.com)');
|
|
90
|
+
}
|
|
91
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
92
|
+
throw new Error('FLOW_PUBLIC_ORIGIN must be an http(s) URL');
|
|
93
|
+
}
|
|
94
|
+
return [url.origin];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// FLOW_FIRST_OWNER_EMAIL: while the installation has no owner, Flow invites
|
|
98
|
+
// this address as its owner at boot. Ignored once an owner exists.
|
|
99
|
+
export function resolveFirstOwnerEmail(env = process.env) {
|
|
100
|
+
const email = readEnv(env, 'FLOW_FIRST_OWNER_EMAIL').toLowerCase();
|
|
101
|
+
if (!email) return null;
|
|
102
|
+
if (!isPlainEmail(email)) throw new Error('FLOW_FIRST_OWNER_EMAIL must be a plain (ASCII) email address');
|
|
103
|
+
return email;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Flow emails are plain ASCII, so a look-alike character cannot pose as
|
|
107
|
+
// someone else's address.
|
|
108
|
+
export function isPlainEmail(email) {
|
|
109
|
+
return typeof email === 'string' && /^[\x21-\x3f\x41-\x7e]+@[a-z0-9.-]+\.[a-z0-9-]+$/i.test(email);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveSignInSettings(env = process.env) {
|
|
113
|
+
const mode = resolveAuthMode(env);
|
|
114
|
+
if (mode === 'off') return { mode };
|
|
115
|
+
return {
|
|
116
|
+
mode,
|
|
117
|
+
keys: resolveClerkKeys(env),
|
|
118
|
+
publicSignUp: resolvePublicSignUp(env),
|
|
119
|
+
ownerMfaMaxAgeMinutes: parseOwnerMfaMaxAge(readEnv(env, 'FLOW_OWNER_MFA_MAX_AGE')),
|
|
120
|
+
ownerGoogleDomains: parseOwnerGoogleDomains(readEnv(env, 'FLOW_OWNER_GOOGLE_DOMAINS')),
|
|
121
|
+
authorizedParties: resolveAuthorizedParties(env),
|
|
122
|
+
firstOwnerEmail: resolveFirstOwnerEmail(env),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<title>Sign in to Flow</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: light dark; --fg: #1d1d1f; --bg: #f7f7f8; --muted: #66666e; --line: #d9d9de; --accent: #2f5bd3; }
|
|
10
|
+
@media (prefers-color-scheme: dark) { :root { --fg: #ececf0; --bg: #16161a; --muted: #a0a0aa; --line: #34343c; --accent: #7d9cf0; } }
|
|
11
|
+
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); }
|
|
12
|
+
main { max-width: 760px; margin: 0 auto; padding: 32px 16px; }
|
|
13
|
+
h1 { font-size: 1.4rem; margin: 0 0 16px; }
|
|
14
|
+
h2 { font-size: 1.1rem; margin: 32px 0 8px; }
|
|
15
|
+
p.note { color: var(--muted); }
|
|
16
|
+
.error { color: #c62828; }
|
|
17
|
+
table { width: 100%; border-collapse: collapse; }
|
|
18
|
+
th, td { text-align: left; padding: 6px 4px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
|
19
|
+
form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
20
|
+
input, select, button { font: inherit; padding: 6px 8px; }
|
|
21
|
+
button { cursor: pointer; }
|
|
22
|
+
[hidden] { display: none !important; }
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<main>
|
|
27
|
+
<h1>Flow</h1>
|
|
28
|
+
<p id="status" class="note">Loading sign-in…</p>
|
|
29
|
+
<div id="sign-in"></div>
|
|
30
|
+
<section id="signed-in" hidden>
|
|
31
|
+
<p>Signed in as <strong id="me-email"></strong> (<span id="me-role"></span>). <button id="sign-out" type="button">Sign out</button></p>
|
|
32
|
+
<p id="owner-note" class="note" hidden></p>
|
|
33
|
+
<section id="people-section" hidden>
|
|
34
|
+
<h2>People</h2>
|
|
35
|
+
<table>
|
|
36
|
+
<thead><tr><th>Email</th><th>Role</th><th>Access</th><th></th></tr></thead>
|
|
37
|
+
<tbody id="people"></tbody>
|
|
38
|
+
</table>
|
|
39
|
+
<h2>Invite someone</h2>
|
|
40
|
+
<form id="invite">
|
|
41
|
+
<input name="email" type="email" required placeholder="name@example.com" autocomplete="off">
|
|
42
|
+
<select name="role"><option value="staff">staff</option><option value="admin">admin</option><option value="owner">owner</option></select>
|
|
43
|
+
<button type="submit">Invite</button>
|
|
44
|
+
</form>
|
|
45
|
+
<p id="people-error" class="error" role="alert"></p>
|
|
46
|
+
</section>
|
|
47
|
+
</section>
|
|
48
|
+
</main>
|
|
49
|
+
<script type="module">
|
|
50
|
+
// Flow's sign-in page. Clerk's sign-in widget signs people in; Flow then
|
|
51
|
+
// decides what they may do (GET /api/me). Every Flow request carries the
|
|
52
|
+
// session token in the Authorization header. Text from the server is only
|
|
53
|
+
// ever set with textContent.
|
|
54
|
+
const $ = (id) => document.getElementById(id);
|
|
55
|
+
const status = (text, isError = false) => { $('status').textContent = text; $('status').className = isError ? 'error' : 'note'; };
|
|
56
|
+
|
|
57
|
+
const config = await fetch('/api/auth/config').then((res) => res.json()).catch(() => null);
|
|
58
|
+
if (!config || config.mode !== 'clerk') {
|
|
59
|
+
status('Sign-in is not configured on this installation. The operator sets FLOW_AUTH=clerk (docs/SELF_HOSTING.md).', true);
|
|
60
|
+
} else {
|
|
61
|
+
await loadClerk(config.clerk);
|
|
62
|
+
const Clerk = window.Clerk;
|
|
63
|
+
await Clerk.load();
|
|
64
|
+
Clerk.addListener(({ user }) => { if (user && $('signed-in').hidden) showSignedIn(); });
|
|
65
|
+
if (Clerk.user) {
|
|
66
|
+
await showSignedIn();
|
|
67
|
+
} else {
|
|
68
|
+
status(config.publicSignUp ? 'Sign in, or ask for access.' : 'Sign in with the account you were invited with.');
|
|
69
|
+
// With public sign-up off the widget never creates a Clerk account: no
|
|
70
|
+
// sign-up link, and an OAuth sign-in for an unknown email is not turned
|
|
71
|
+
// into a sign-up (transferable: false).
|
|
72
|
+
Clerk.mountSignIn($('sign-in'), {
|
|
73
|
+
forceRedirectUrl: '/sign-in',
|
|
74
|
+
withSignUp: config.publicSignUp,
|
|
75
|
+
transferable: config.publicSignUp,
|
|
76
|
+
...(config.publicSignUp ? {} : { appearance: { elements: { footerAction: { display: 'none' } } } }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadClerk({ publishableKey, proxyUrl, scriptUrl }) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const script = document.createElement('script');
|
|
84
|
+
script.src = scriptUrl;
|
|
85
|
+
script.crossOrigin = 'anonymous';
|
|
86
|
+
script.dataset.clerkPublishableKey = publishableKey;
|
|
87
|
+
if (proxyUrl) script.dataset.clerkProxyUrl = proxyUrl;
|
|
88
|
+
script.onload = resolve;
|
|
89
|
+
script.onerror = () => { status('Could not load sign-in. Try again shortly.', true); reject(new Error('clerk-js')); };
|
|
90
|
+
document.head.append(script);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function flow(path, options = {}) {
|
|
95
|
+
const token = await window.Clerk.session?.getToken();
|
|
96
|
+
const res = await fetch(path, {
|
|
97
|
+
...options,
|
|
98
|
+
headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
99
|
+
});
|
|
100
|
+
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
101
|
+
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
|
102
|
+
return body;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function showSignedIn() {
|
|
106
|
+
$('sign-in').replaceChildren();
|
|
107
|
+
$('sign-out').onclick = () => window.Clerk.signOut({ redirectUrl: '/sign-in' });
|
|
108
|
+
let me;
|
|
109
|
+
try {
|
|
110
|
+
me = await flow('/api/me');
|
|
111
|
+
} catch (error) {
|
|
112
|
+
status(error.message, true);
|
|
113
|
+
$('signed-in').hidden = false;
|
|
114
|
+
$('me-email').textContent = window.Clerk.user?.primaryEmailAddress?.emailAddress ?? '';
|
|
115
|
+
$('me-role').textContent = 'no Flow access';
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
status('');
|
|
119
|
+
$('signed-in').hidden = false;
|
|
120
|
+
$('me-email').textContent = me.person.email;
|
|
121
|
+
$('me-role').textContent = me.person.role;
|
|
122
|
+
if (me.person.role !== 'owner') return;
|
|
123
|
+
if (!me.ownerActions?.allowed) {
|
|
124
|
+
$('owner-note').hidden = false;
|
|
125
|
+
$('owner-note').textContent = 'Managing people needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.';
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
$('people-section').hidden = false;
|
|
129
|
+
$('invite').onsubmit = async (event) => {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
const form = new FormData(event.target);
|
|
132
|
+
await act(() => flow('/api/people', { method: 'POST', body: JSON.stringify({ email: form.get('email'), role: form.get('role') }) }));
|
|
133
|
+
event.target.reset();
|
|
134
|
+
};
|
|
135
|
+
await renderPeople(me.person.id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function act(fn) {
|
|
139
|
+
$('people-error').textContent = '';
|
|
140
|
+
try {
|
|
141
|
+
await fn();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
$('people-error').textContent = error.message;
|
|
144
|
+
}
|
|
145
|
+
const me = await flow('/api/me');
|
|
146
|
+
await renderPeople(me.person.id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function renderPeople(selfId) {
|
|
150
|
+
const { people } = await flow('/api/people');
|
|
151
|
+
const rows = people.map((person) => {
|
|
152
|
+
const tr = document.createElement('tr');
|
|
153
|
+
const email = document.createElement('td');
|
|
154
|
+
email.textContent = person.email + (person.invited ? ' (invited)' : '');
|
|
155
|
+
const role = document.createElement('td');
|
|
156
|
+
const access = document.createElement('td');
|
|
157
|
+
const actions = document.createElement('td');
|
|
158
|
+
if (person.id === selfId) {
|
|
159
|
+
role.textContent = person.role;
|
|
160
|
+
access.textContent = person.status;
|
|
161
|
+
} else {
|
|
162
|
+
const select = document.createElement('select');
|
|
163
|
+
for (const value of ['owner', 'admin', 'staff']) select.add(new Option(value, value, false, value === person.role));
|
|
164
|
+
select.onchange = () => act(() => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify({ role: select.value }) }));
|
|
165
|
+
role.append(select);
|
|
166
|
+
const toggle = document.createElement('button');
|
|
167
|
+
toggle.type = 'button';
|
|
168
|
+
toggle.textContent = person.status === 'active' ? 'Turn off' : person.status === 'pending' ? 'Approve' : 'Turn on';
|
|
169
|
+
toggle.onclick = () => act(() => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify({ status: person.status === 'active' ? 'disabled' : 'active' }) }));
|
|
170
|
+
access.append(`${person.status} `, toggle);
|
|
171
|
+
const remove = document.createElement('button');
|
|
172
|
+
remove.type = 'button';
|
|
173
|
+
remove.textContent = 'Remove';
|
|
174
|
+
remove.onclick = () => { if (confirm(`Remove ${person.email}?`)) act(() => flow(`/api/people/${person.id}`, { method: 'DELETE' })); };
|
|
175
|
+
actions.append(remove);
|
|
176
|
+
}
|
|
177
|
+
tr.append(email, role, access, actions);
|
|
178
|
+
return tr;
|
|
179
|
+
});
|
|
180
|
+
$('people').replaceChildren(...rows);
|
|
181
|
+
}
|
|
182
|
+
</script>
|
|
183
|
+
</body>
|
|
184
|
+
</html>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
2
|
+
import { contacts, organisations, priceItems, CONTACT_KINDS, TAX_CODES } from '../db/schema.js';
|
|
3
|
+
import { FlowError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// Organisations, contacts and the price list: the records invoices and bills
|
|
6
|
+
// refer to. Every read and write is scoped to one organisation.
|
|
7
|
+
|
|
8
|
+
export async function createOrganisation(db, { name, invoicePrefix }) {
|
|
9
|
+
if (!name?.trim()) throw new FlowError('invalid', 'organisation name is required');
|
|
10
|
+
const [row] = await db.insert(organisations)
|
|
11
|
+
.values({ name: name.trim(), ...(invoicePrefix ? { invoicePrefix } : {}) })
|
|
12
|
+
.returning();
|
|
13
|
+
return row;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function createContact(db, organisationId, { kind, name, email = null }) {
|
|
17
|
+
if (!CONTACT_KINDS.includes(kind)) throw new FlowError('invalid', `unknown contact kind: ${kind}`);
|
|
18
|
+
if (!name?.trim()) throw new FlowError('invalid', 'contact name is required');
|
|
19
|
+
const [row] = await db.insert(contacts)
|
|
20
|
+
.values({ organisationId, kind, name: name.trim(), email })
|
|
21
|
+
.returning();
|
|
22
|
+
return row;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function createPriceItem(db, organisationId, { code, name, unitAmountCents, taxCode = 'GST' }) {
|
|
26
|
+
if (!code?.trim() || !name?.trim()) throw new FlowError('invalid', 'price item code and name are required');
|
|
27
|
+
if (!Number.isSafeInteger(unitAmountCents)) throw new FlowError('invalid', 'unitAmountCents must be whole cents');
|
|
28
|
+
if (!TAX_CODES.includes(taxCode)) throw new FlowError('invalid', `unknown tax code: ${taxCode}`);
|
|
29
|
+
const [row] = await db.insert(priceItems)
|
|
30
|
+
.values({ organisationId, code: code.trim(), name: name.trim(), unitAmountCents, taxCode })
|
|
31
|
+
.returning();
|
|
32
|
+
return row;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function listPriceItems(db, organisationId) {
|
|
36
|
+
return db.select().from(priceItems)
|
|
37
|
+
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
|
|
38
|
+
.orderBy(priceItems.code);
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A failure the caller can act on. `code` is stable; the API returns it.
|
|
2
|
+
export class FlowError extends Error {
|
|
3
|
+
constructor(code, message, details) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'FlowError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
if (details !== undefined) this.details = details;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const notFound = (what) => new FlowError('not_found', `${what} not found`);
|