@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.
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1792133499599,
9
+ "tag": "0000_core",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1792133500543,
16
+ "tag": "0001_records_are_append_only",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1792322911044,
23
+ "tag": "0002_people",
24
+ "breakpoints": true
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,169 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import {
3
+ check,
4
+ date,
5
+ foreignKey,
6
+ integer,
7
+ jsonb,
8
+ pgTable,
9
+ text,
10
+ timestamp,
11
+ unique,
12
+ uuid,
13
+ } from 'drizzle-orm/pg-core';
14
+
15
+ // Every table except `organisations` carries `organisation_id`. Child rows
16
+ // point at their parent through (organisation_id, id), so the database itself
17
+ // refuses a row that joins two organisations. Money is integer cents.
18
+
19
+ const id = () => uuid('id').primaryKey().defaultRandom();
20
+ const organisationId = () => uuid('organisation_id').notNull();
21
+ const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
22
+ const updatedAt = () => timestamp('updated_at', { withTimezone: true }).notNull().defaultNow();
23
+
24
+ export const TAX_CODES = ['GST', 'GST_FREE'];
25
+ export const CONTACT_KINDS = ['customer', 'supplier'];
26
+ export const INVOICE_STATUSES = ['draft', 'issued', 'void'];
27
+
28
+ export const organisations = pgTable('organisations', {
29
+ id: id(),
30
+ name: text('name').notNull(),
31
+ invoicePrefix: text('invoice_prefix').notNull().default('INV-'),
32
+ nextInvoiceNumber: integer('next_invoice_number').notNull().default(1),
33
+ createdAt: createdAt(),
34
+ }, (t) => [
35
+ check('organisations_next_invoice_number_positive', sql`${t.nextInvoiceNumber} > 0`),
36
+ ]);
37
+
38
+ export const contacts = pgTable('contacts', {
39
+ id: id(),
40
+ organisationId: organisationId().references(() => organisations.id),
41
+ kind: text('kind').notNull(),
42
+ name: text('name').notNull(),
43
+ email: text('email'),
44
+ createdAt: createdAt(),
45
+ }, (t) => [
46
+ unique('contacts_org_id').on(t.organisationId, t.id),
47
+ unique('contacts_org_kind_name').on(t.organisationId, t.kind, t.name),
48
+ check('contacts_kind_valid', sql`${t.kind} in ('customer', 'supplier')`),
49
+ ]);
50
+
51
+ export const priceItems = pgTable('price_items', {
52
+ id: id(),
53
+ organisationId: organisationId().references(() => organisations.id),
54
+ code: text('code').notNull(),
55
+ name: text('name').notNull(),
56
+ unitAmountCents: integer('unit_amount_cents').notNull(),
57
+ taxCode: text('tax_code').notNull().default('GST'),
58
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
59
+ createdAt: createdAt(),
60
+ }, (t) => [
61
+ unique('price_items_org_id').on(t.organisationId, t.id),
62
+ unique('price_items_org_code').on(t.organisationId, t.code),
63
+ check('price_items_tax_code_valid', sql`${t.taxCode} in ('GST', 'GST_FREE')`),
64
+ ]);
65
+
66
+ export const invoices = pgTable('invoices', {
67
+ id: id(),
68
+ organisationId: organisationId().references(() => organisations.id),
69
+ contactId: uuid('contact_id').notNull(),
70
+ // Assigned when the invoice is first issued; kept by every later version.
71
+ number: text('number'),
72
+ status: text('status').notNull().default('draft'),
73
+ // The last version a customer was sent; 0 while the invoice was never issued.
74
+ issuedVersion: integer('issued_version').notNull().default(0),
75
+ reference: text('reference'),
76
+ currency: text('currency').notNull().default('AUD'),
77
+ issueDate: date('issue_date'),
78
+ dueDate: date('due_date'),
79
+ subtotalCents: integer('subtotal_cents').notNull().default(0),
80
+ taxCents: integer('tax_cents').notNull().default(0),
81
+ totalCents: integer('total_cents').notNull().default(0),
82
+ createdAt: createdAt(),
83
+ updatedAt: updatedAt(),
84
+ }, (t) => [
85
+ unique('invoices_org_id').on(t.organisationId, t.id),
86
+ unique('invoices_org_number').on(t.organisationId, t.number),
87
+ foreignKey({
88
+ name: 'invoices_contact_same_org',
89
+ columns: [t.organisationId, t.contactId],
90
+ foreignColumns: [contacts.organisationId, contacts.id],
91
+ }),
92
+ check('invoices_status_valid', sql`${t.status} in ('draft', 'issued', 'void')`),
93
+ check('invoices_issued_has_number', sql`${t.status} = 'draft' or ${t.number} is not null`),
94
+ ]);
95
+
96
+ export const invoiceLines = pgTable('invoice_lines', {
97
+ id: id(),
98
+ organisationId: organisationId(),
99
+ invoiceId: uuid('invoice_id').notNull(),
100
+ position: integer('position').notNull(),
101
+ priceItemId: uuid('price_item_id'),
102
+ description: text('description').notNull(),
103
+ quantity: integer('quantity').notNull(),
104
+ unitAmountCents: integer('unit_amount_cents').notNull(),
105
+ taxCode: text('tax_code').notNull(),
106
+ lineAmountCents: integer('line_amount_cents').notNull(),
107
+ taxCents: integer('tax_cents').notNull(),
108
+ }, (t) => [
109
+ unique('invoice_lines_invoice_position').on(t.invoiceId, t.position),
110
+ foreignKey({
111
+ name: 'invoice_lines_invoice_same_org',
112
+ columns: [t.organisationId, t.invoiceId],
113
+ foreignColumns: [invoices.organisationId, invoices.id],
114
+ }).onDelete('cascade'),
115
+ foreignKey({
116
+ name: 'invoice_lines_price_item_same_org',
117
+ columns: [t.organisationId, t.priceItemId],
118
+ foreignColumns: [priceItems.organisationId, priceItems.id],
119
+ }),
120
+ check('invoice_lines_quantity_positive', sql`${t.quantity} > 0`),
121
+ check('invoice_lines_tax_code_valid', sql`${t.taxCode} in ('GST', 'GST_FREE')`),
122
+ ]);
123
+
124
+ // What the customer was sent, exactly. Rows are append-only: a trigger in
125
+ // the migrations refuses UPDATE and DELETE.
126
+ export const invoiceVersions = pgTable('invoice_versions', {
127
+ id: id(),
128
+ organisationId: organisationId(),
129
+ invoiceId: uuid('invoice_id').notNull(),
130
+ version: integer('version').notNull(),
131
+ issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
132
+ issuedBy: text('issued_by').notNull(),
133
+ document: jsonb('document').notNull(),
134
+ }, (t) => [
135
+ unique('invoice_versions_invoice_version').on(t.invoiceId, t.version),
136
+ foreignKey({
137
+ name: 'invoice_versions_invoice_same_org',
138
+ columns: [t.organisationId, t.invoiceId],
139
+ foreignColumns: [invoices.organisationId, invoices.id],
140
+ }),
141
+ check('invoice_versions_version_positive', sql`${t.version} > 0`),
142
+ ]);
143
+
144
+ export const PERSON_ROLES = ['owner', 'admin', 'staff'];
145
+ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
146
+
147
+ // Who may sign in to this installation, and with which role. Clerk proves who
148
+ // someone is; this table decides what they may do. One installation serves one
149
+ // lab, so people are installation-wide and carry no organisation_id. Emails
150
+ // are stored lower-case. `clerk_user_id` is set on the person's first verified
151
+ // sign-in; until then the row is an invitation.
152
+ export const people = pgTable('people', {
153
+ id: id(),
154
+ email: text('email').notNull(),
155
+ name: text('name'),
156
+ role: text('role').notNull(),
157
+ status: text('status').notNull().default('active'),
158
+ clerkUserId: text('clerk_user_id'),
159
+ invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
160
+ linkedAt: timestamp('linked_at', { withTimezone: true }),
161
+ createdAt: createdAt(),
162
+ updatedAt: updatedAt(),
163
+ }, (t) => [
164
+ unique('people_email').on(t.email),
165
+ unique('people_clerk_user_id').on(t.clerkUserId),
166
+ check('people_email_lower', sql`${t.email} = lower(${t.email})`),
167
+ check('people_role_valid', sql`${t.role} in ('owner', 'admin', 'staff')`),
168
+ check('people_status_valid', sql`${t.status} in ('active', 'pending', 'disabled')`),
169
+ ]);
@@ -0,0 +1,20 @@
1
+ // What build is running, for GET /health: the git commit (FLOW_REVISION),
2
+ // and on a Replit deployment the ref it was fetched as and where it came from
3
+ // ("github", or "workspace" for an emergency build), and whether its GitHub CI
4
+ // passed before it was built ("passed", or "unchecked" when an emergency
5
+ // override built it anyway), which scripts/replit-deploy.sh sets, and the
6
+ // release tag the build is (FLOW_DEPLOYED_RELEASE from that deploy, or
7
+ // FLOW_RELEASE, which server/flow-server.js sets for an installed npm
8
+ // package and the Dockerfile sets for a release image), when it is one. The
9
+ // repository is public, so none of these is a secret. Each is null when
10
+ // unknown.
11
+ export function deployedBuildStatus(env = process.env) {
12
+ const read = (value) => String(value ?? "").trim() || null;
13
+ return {
14
+ revision: read(env.FLOW_REVISION),
15
+ ref: read(env.FLOW_DEPLOYED_REF),
16
+ source: read(env.FLOW_DEPLOYED_FROM),
17
+ checks: read(env.FLOW_DEPLOYED_CHECKS),
18
+ release: read(env.FLOW_DEPLOYED_RELEASE) ?? read(env.FLOW_RELEASE),
19
+ };
20
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // Starts Flow from an installed @arcforgelabs/flow package (the `flow-server`
3
+ // command). A published package's version is its release (2026.10.18 or
4
+ // 2026.10.18-beta.2), so GET /health shows it as build.release. A checkout
5
+ // starts server/index.js (`npm start`) instead: its package.json holds the
6
+ // upcoming version, which is not a release yet.
7
+ import { readFileSync } from 'node:fs';
8
+ import { parseReleaseTag } from './release-channel.js';
9
+
10
+ if (!process.env.FLOW_RELEASE) {
11
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
12
+ const release = parseReleaseTag(version);
13
+ if (release) process.env.FLOW_RELEASE = release.tag;
14
+ }
15
+ await import('./index.js');
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { createApp } from './app.js';
3
+ import { createClerkProxy, ensureClerkAccount, loadClerkAdapter, resolveClerkProxyPath } from './auth/clerk.js';
4
+ import { seedFirstOwner } from './auth/people.js';
5
+ import { createPersonAuth } from './auth/session.js';
6
+ import { resolveSignInSettings } from './auth/settings.js';
7
+ import { migrateDatabase, openDatabase } from './db/index.js';
8
+ import { createUpdateChecker, UPDATE_CHECK_INTERVAL_MS } from './update-check.js';
9
+
10
+ const port = Number(process.env.FLOW_PORT || process.env.PORT || 19300);
11
+
12
+ // Sign-in settings first: a malformed setting stops boot before anything runs.
13
+ let settings;
14
+ try {
15
+ settings = resolveSignInSettings();
16
+ } catch (error) {
17
+ console.error(`[auth] ${error.message}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ // Flow's records live in PostgreSQL. Migrations run before the server accepts
22
+ // requests, so a release never serves an older schema.
23
+ const database = openDatabase();
24
+ await migrateDatabase(database.db);
25
+
26
+ let auth = null;
27
+ if (settings.mode === 'clerk') {
28
+ const { keys } = settings;
29
+ const clerk = await loadClerkAdapter({ ...keys, authorizedParties: settings.authorizedParties });
30
+ const personAuth = createPersonAuth({ db: database.db, clerk, ...settings });
31
+ let clerkProxy = null;
32
+ const proxyPath = resolveClerkProxyPath(keys.proxyUrl);
33
+ if (proxyPath) {
34
+ const { clerkFrontendApiProxy } = await import('@clerk/backend/proxy');
35
+ clerkProxy = { path: proxyPath, handler: createClerkProxy({ proxyPath, ...keys, proxy: clerkFrontendApiProxy }) };
36
+ }
37
+ auth = { settings, clerk, personAuth, clerkProxy };
38
+
39
+ if (settings.firstOwnerEmail) {
40
+ const seeded = await seedFirstOwner(database.db, settings.firstOwnerEmail);
41
+ if (seeded) {
42
+ console.log(`[auth] no owner yet: invited FLOW_FIRST_OWNER_EMAIL as owner (person ${seeded.id})`);
43
+ await ensureClerkAccount(clerk, seeded.email).catch((error) => {
44
+ console.error(`[auth] could not prepare the first owner's Clerk account: ${error.message}`);
45
+ });
46
+ }
47
+ }
48
+ const ownerPaths = [
49
+ ...(settings.ownerGoogleDomains.length ? [`Google Workspace sign-in on ${settings.ownerGoogleDomains.join(', ')} with no password`] : []),
50
+ `a Clerk second factor within ${settings.ownerMfaMaxAgeMinutes} min`,
51
+ ];
52
+ console.log(`[auth] Clerk sign-in on (CLERK_SECRET_KEY, ${keys.publishableSource}); public sign-up ${settings.publicSignUp ? 'on' : 'off'}; owner actions: ${ownerPaths.join(', or ')}${proxyPath ? `; Clerk proxied at ${proxyPath}` : ''}`);
53
+ // Replit's managed Clerk has no MFA, so there the Google Workspace path is
54
+ // the only way an owner can manage people.
55
+ if (!settings.ownerGoogleDomains.length && (process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT)) {
56
+ console.warn('[auth] FLOW_OWNER_GOOGLE_DOMAINS is unset: on Replit\'s managed Clerk no owner can manage people (docs/SELF_HOSTING.md, "Sign-in (Clerk)")');
57
+ }
58
+ } else {
59
+ console.log('[auth] sign-in off (FLOW_AUTH unset): person routes answer 503');
60
+ }
61
+
62
+ // Replit puts one proxy in front of the app.
63
+ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : false;
64
+
65
+ const updates = createUpdateChecker();
66
+
67
+ createApp({ database, auth, trustProxy, updates }).listen(port, () => {
68
+ console.log(`[flow] listening on ${port}`);
69
+ });
70
+
71
+ // The update notice: a release build logs when a newer release is out on its
72
+ // channel, at start and every six hours. A build that is not a release asks
73
+ // nobody.
74
+ async function noticeUpdate() {
75
+ try {
76
+ const status = await updates.status();
77
+ if (status.updateAvailable) {
78
+ console.log(`[flow] update available: ${status.latest} (running ${status.current}, channel ${status.channel}). Notes: ${status.releaseUrl}. How to update: ${status.upgradeUrl}`);
79
+ }
80
+ } catch (error) {
81
+ console.error(`[flow] update check failed: ${error?.message || error}`);
82
+ }
83
+ }
84
+ noticeUpdate();
85
+ setInterval(noticeUpdate, UPDATE_CHECK_INTERVAL_MS).unref();
@@ -0,0 +1,84 @@
1
+ // The integration port: the one contract between the Flow core and every
2
+ // adapter (Seazona, Xero, and later ones). The core calls only the
3
+ // capabilities an adapter declares, and never imports an adapter directly.
4
+ // VISION.md, "The core and its integrations".
5
+
6
+ export const CAPABILITIES = Object.freeze([
7
+ 'pullWorkItems', // billable work since a cursor (Seazona: completed cases)
8
+ 'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
9
+ 'pushBill', // send an approved bill; the Flow record id is the idempotency key
10
+ 'pullPayments', // payments since a cursor, matched to invoices and bills
11
+ 'health', // connection state for Settings
12
+ ]);
13
+
14
+ const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
15
+
16
+ export function defineAdapter(adapter) {
17
+ if (!adapter || typeof adapter !== 'object') {
18
+ throw new TypeError('An adapter must be an object');
19
+ }
20
+ const { id, name, bundled = false, capabilities } = adapter;
21
+ if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
22
+ throw new TypeError(`Adapter id must match ${ID_PATTERN}`);
23
+ }
24
+ if (typeof name !== 'string' || name.trim() === '') {
25
+ throw new TypeError(`Adapter ${id} needs a name`);
26
+ }
27
+ if (!capabilities || typeof capabilities !== 'object') {
28
+ throw new TypeError(`Adapter ${id} declares no capabilities`);
29
+ }
30
+ for (const [key, fn] of Object.entries(capabilities)) {
31
+ if (!CAPABILITIES.includes(key)) {
32
+ throw new TypeError(`Adapter ${id} declares unknown capability ${key}`);
33
+ }
34
+ if (typeof fn !== 'function') {
35
+ throw new TypeError(`Adapter ${id} capability ${key} must be a function`);
36
+ }
37
+ }
38
+ if (typeof capabilities.health !== 'function') {
39
+ throw new TypeError(`Adapter ${id} must declare health`);
40
+ }
41
+ return Object.freeze({ id, name, bundled: Boolean(bundled), capabilities: Object.freeze({ ...capabilities }) });
42
+ }
43
+
44
+ export function createRegistry() {
45
+ const adapters = new Map();
46
+ const enabled = new Set();
47
+
48
+ return {
49
+ register(adapter) {
50
+ const defined = defineAdapter(adapter);
51
+ if (adapters.has(defined.id)) {
52
+ throw new Error(`Adapter ${defined.id} is already registered`);
53
+ }
54
+ adapters.set(defined.id, defined);
55
+ return defined;
56
+ },
57
+ enable(id) {
58
+ if (!adapters.has(id)) throw new Error(`No adapter ${id}`);
59
+ enabled.add(id);
60
+ },
61
+ disable(id) {
62
+ enabled.delete(id);
63
+ },
64
+ list() {
65
+ return [...adapters.values()].map((a) => ({
66
+ id: a.id,
67
+ name: a.name,
68
+ bundled: a.bundled,
69
+ enabled: enabled.has(a.id),
70
+ capabilities: Object.keys(a.capabilities),
71
+ }));
72
+ },
73
+ // The enabled adapters that declare one capability. The core fans out to
74
+ // these and nothing else, so a disabled adapter changes nothing.
75
+ with(capability) {
76
+ if (!CAPABILITIES.includes(capability)) {
77
+ throw new TypeError(`Unknown capability ${capability}`);
78
+ }
79
+ return [...enabled]
80
+ .map((id) => adapters.get(id))
81
+ .filter((a) => typeof a.capabilities[capability] === 'function');
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,21 @@
1
+ // A fixed-window request limit per client address (req.ip, which honours
2
+ // Express's `trust proxy`). In memory: one Flow process serves one lab.
3
+ export function rateLimit({ limit, windowMs, now = Date.now }) {
4
+ const windows = new Map();
5
+ return (req, res, next) => {
6
+ const at = now();
7
+ if (windows.size > 10_000) {
8
+ for (const [key, entry] of windows) if (entry.resetAt <= at) windows.delete(key);
9
+ }
10
+ const key = req.ip || 'unknown';
11
+ let entry = windows.get(key);
12
+ if (!entry || entry.resetAt <= at) {
13
+ entry = { count: 0, resetAt: at + windowMs };
14
+ windows.set(key, entry);
15
+ }
16
+ entry.count += 1;
17
+ if (entry.count <= limit) return next();
18
+ res.set('Retry-After', String(Math.ceil((entry.resetAt - at) / 1000)));
19
+ return res.status(429).json({ ok: false, code: 'rate_limited', error: 'Too many requests. Wait and try again.' });
20
+ };
21
+ }
@@ -0,0 +1,146 @@
1
+ // Which published Flow release a channel points at, read anonymously from
2
+ // GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
3
+ // deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
4
+ //
5
+ // Release names follow docs/RELEASING.md: vYYYY.M.D is stable, and
6
+ // vYYYY.M.D-beta.N is a beta of the upcoming YYYY.M.D.
7
+ //
8
+ // stable GitHub's latest release (what Flow Release Promote points at, so
9
+ // a rollback is followed), else npm `latest`
10
+ // beta the newest release of all, betas and stable releases alike, so a
11
+ // beta installation is never behind stable; else npm `beta`
12
+ //
13
+ // Nothing here sends anything about the installation: each request is an
14
+ // anonymous GET of public data.
15
+
16
+ const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]|[12]\d|3[01])(?:-beta\.([1-9]\d*))?$/;
17
+ export const CHANNELS = ["stable", "beta"];
18
+ export const DEFAULT_GITHUB_API = "https://api.github.com";
19
+ export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
20
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
21
+ export const DEFAULT_PACKAGE = "@arcforgelabs/flow";
22
+ const ATTEMPTS = 3;
23
+ const TIMEOUT_MS = 20_000;
24
+
25
+ export class ReleaseUnavailable extends Error {}
26
+
27
+ // { tag, version, channel, parts } for a release tag (with or without its
28
+ // leading "v"), or null.
29
+ export function parseReleaseTag(value) {
30
+ const tag = String(value ?? "").startsWith("v") ? String(value) : `v${value}`;
31
+ const match = TAG.exec(tag);
32
+ if (!match) return null;
33
+ const beta = match[4] ? Number(match[4]) : null;
34
+ return {
35
+ tag,
36
+ version: tag.slice(1),
37
+ channel: beta ? "beta" : "stable",
38
+ // A stable release sorts after every beta of its own version.
39
+ parts: [Number(match[1]), Number(match[2]), Number(match[3]), beta ?? Number.POSITIVE_INFINITY],
40
+ };
41
+ }
42
+
43
+ // Negative when a is older than b, positive when newer, 0 when the same.
44
+ export function compareReleases(a, b) {
45
+ const [x, y] = [parseReleaseTag(a), parseReleaseTag(b)];
46
+ if (!x || !y) throw new ReleaseUnavailable(`cannot compare ${a} with ${b}`);
47
+ for (let index = 0; index < 4; index += 1) {
48
+ if (x.parts[index] !== y.parts[index]) return x.parts[index] < y.parts[index] ? -1 : 1;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ async function get(url, { fetchImpl, retryDelayMs, as = "json" }) {
54
+ let lastError;
55
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
56
+ let response;
57
+ try {
58
+ response = await fetchImpl(url, {
59
+ headers: { accept: as === "json" ? "application/json" : "*/*", "user-agent": "flow-release-channel" },
60
+ signal: AbortSignal.timeout(TIMEOUT_MS),
61
+ });
62
+ } catch (error) {
63
+ lastError = `${new URL(url).host} did not answer (${error?.cause?.code || error?.name || "network error"})`;
64
+ }
65
+ if (response) {
66
+ if (response.ok) return as === "json" ? response.json() : Buffer.from(await response.arrayBuffer());
67
+ if ((response.status === 403 || response.status === 429) && response.headers.get("x-ratelimit-remaining") === "0") {
68
+ throw new ReleaseUnavailable(`GitHub's rate limit for anonymous requests is used up (${url})`);
69
+ }
70
+ lastError = `${url} answered HTTP ${response.status}`;
71
+ if (response.status < 500 && response.status !== 429) break;
72
+ }
73
+ if (attempt < ATTEMPTS) await new Promise((wake) => setTimeout(wake, attempt * retryDelayMs));
74
+ }
75
+ throw new ReleaseUnavailable(lastError);
76
+ }
77
+
78
+ async function fromGitHub(channel, { slug, githubApi, fetchImpl, retryDelayMs }) {
79
+ if (channel === "stable") {
80
+ const latest = await get(`${githubApi}/repos/${slug}/releases/latest`, { fetchImpl, retryDelayMs });
81
+ const release = parseReleaseTag(latest?.tag_name);
82
+ if (!release || release.channel !== "stable" || latest.prerelease || latest.draft) {
83
+ throw new ReleaseUnavailable(`GitHub's latest release (${latest?.tag_name}) is not a stable Flow release`);
84
+ }
85
+ return release.tag;
86
+ }
87
+ const list = await get(`${githubApi}/repos/${slug}/releases?per_page=50`, { fetchImpl, retryDelayMs });
88
+ const tags = (Array.isArray(list) ? list : [])
89
+ .filter((release) => !release.draft && parseReleaseTag(release.tag_name))
90
+ .map((release) => release.tag_name)
91
+ .sort(compareReleases);
92
+ if (!tags.length) throw new ReleaseUnavailable(`${slug} has no published Flow release`);
93
+ return tags.at(-1);
94
+ }
95
+
96
+ async function fromNpm(channel, { npmRegistry, packageName, fetchImpl, retryDelayMs }) {
97
+ const metadata = await get(`${npmRegistry}/${packageName.replaceAll("/", "%2f")}`, { fetchImpl, retryDelayMs });
98
+ const distTag = channel === "stable" ? "latest" : "beta";
99
+ const version = metadata?.["dist-tags"]?.[distTag];
100
+ const release = parseReleaseTag(version);
101
+ if (!release || (channel === "stable" && release.channel !== "stable")) {
102
+ throw new ReleaseUnavailable(`npm ${distTag} (${version}) is not a ${channel} Flow release`);
103
+ }
104
+ return release.tag;
105
+ }
106
+
107
+ // { tag, source } for the release the channel points at, or a
108
+ // ReleaseUnavailable naming why neither GitHub nor npm could say.
109
+ export async function resolveChannelRelease({
110
+ channel,
111
+ slug,
112
+ githubApi = DEFAULT_GITHUB_API,
113
+ npmRegistry = DEFAULT_NPM_REGISTRY,
114
+ packageName = DEFAULT_PACKAGE,
115
+ fetchImpl = fetch,
116
+ retryDelayMs = 2000,
117
+ }) {
118
+ if (!CHANNELS.includes(channel)) throw new ReleaseUnavailable(`channel must be stable or beta, not "${channel}"`);
119
+ try {
120
+ return { tag: await fromGitHub(channel, { slug, githubApi, fetchImpl, retryDelayMs }), source: "github" };
121
+ } catch (github) {
122
+ if (!(github instanceof ReleaseUnavailable)) throw github;
123
+ try {
124
+ return { tag: await fromNpm(channel, { npmRegistry, packageName, fetchImpl, retryDelayMs }), source: "npm", githubError: github.message };
125
+ } catch (npm) {
126
+ if (!(npm instanceof ReleaseUnavailable)) throw npm;
127
+ throw new ReleaseUnavailable(`GitHub: ${github.message}; npm: ${npm.message}`);
128
+ }
129
+ }
130
+ }
131
+
132
+ // The release's manifest, checked against its .sha256: { commit, version,
133
+ // manifestSha256 }.
134
+ export async function verifyReleaseManifest({ slug, tag, githubDownloads = DEFAULT_GITHUB_DOWNLOADS, fetchImpl = fetch, retryDelayMs = 2000 }) {
135
+ const { createHash } = await import("node:crypto");
136
+ const base = `${githubDownloads}/${slug}/releases/download/${tag}`;
137
+ const bytes = await get(`${base}/release-manifest.json`, { fetchImpl, retryDelayMs, as: "bytes" });
138
+ const sidecar = (await get(`${base}/release-manifest.json.sha256`, { fetchImpl, retryDelayMs, as: "bytes" })).toString("utf8");
139
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
140
+ if (sidecar.trim().split(/\s+/)[0] !== sha256) throw new ReleaseUnavailable(`${tag}'s release-manifest.json does not match its .sha256`);
141
+ const manifest = JSON.parse(bytes.toString("utf8"));
142
+ if (manifest.tag !== tag || !/^[0-9a-f]{40}$/.test(String(manifest.commit))) {
143
+ throw new ReleaseUnavailable(`${tag}'s release-manifest.json does not describe ${tag}`);
144
+ }
145
+ return { commit: manifest.commit, version: manifest.version, manifestSha256: sha256 };
146
+ }
@@ -0,0 +1,84 @@
1
+ // Is a newer Flow release out? GET /api/updates answers, and the server logs
2
+ // a notice when one is (server/index.js).
3
+ //
4
+ // Flow knows its own release from GET /health's build.release
5
+ // (server/deployed-build.js): the tag a Replit deploy of a channel or a pinned
6
+ // release resolved to, a release image's FLOW_RELEASE, or an installed npm
7
+ // package's version (server/flow-server.js). A build that is not a release (a checkout of main, a CI
8
+ // image) has none, so it is never told to update and asks nobody.
9
+ //
10
+ // The channel is the one the installation follows: FLOW_UPDATE_CHANNEL when
11
+ // set, else FLOW_DEPLOYED_REF when that is stable or beta, else beta for a
12
+ // beta release, else stable. The newest release on that channel comes from
13
+ // server/release-channel.js: an anonymous GET of public data on GitHub, or
14
+ // npm. Nothing about the installation is sent. The answer, or the reason
15
+ // there is none, is cached and asked again at most every six hours.
16
+ // FLOW_UPDATE_CHECK=off turns the check off.
17
+ import { deployedBuildStatus } from "./deployed-build.js";
18
+ import { CHANNELS, compareReleases, parseReleaseTag, resolveChannelRelease } from "./release-channel.js";
19
+
20
+ export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
21
+ export const FLOW_REPOSITORY = "arcforgelabs/flow";
22
+
23
+ export function updateChannel(env = process.env, current = null) {
24
+ const pick = (value) => (CHANNELS.includes(String(value ?? "").trim()) ? String(value).trim() : null);
25
+ return pick(env.FLOW_UPDATE_CHANNEL) || pick(env.FLOW_DEPLOYED_REF) || (parseReleaseTag(current)?.channel === "beta" ? "beta" : "stable");
26
+ }
27
+
28
+ export function updateCheckEnabled(env = process.env) {
29
+ return !["off", "0", "false", "no"].includes(String(env.FLOW_UPDATE_CHECK ?? "").trim().toLowerCase());
30
+ }
31
+
32
+ // Where an operator reads how to move their installation to a release.
33
+ export function upgradeGuideUrl(env = process.env) {
34
+ const doc = env.FLOW_RUNTIME === "replit" ? "docs/REPLIT.md#following-stable-releases" : "docs/SELF_HOSTING.md";
35
+ return `https://github.com/${FLOW_REPOSITORY}/blob/main/${doc}`;
36
+ }
37
+
38
+ export function createUpdateChecker({
39
+ env = process.env,
40
+ fetchImpl = fetch,
41
+ now = () => Date.now(),
42
+ intervalMs = UPDATE_CHECK_INTERVAL_MS,
43
+ resolve = resolveChannelRelease,
44
+ } = {}) {
45
+ let cache = null; // { channel, checkedAt, latest, error }
46
+ let inflight = null;
47
+
48
+ async function refresh(channel) {
49
+ const checkedAt = now();
50
+ try {
51
+ const { tag } = await resolve({ channel, slug: FLOW_REPOSITORY, fetchImpl, retryDelayMs: 500 });
52
+ cache = { channel, checkedAt, latest: tag, error: null };
53
+ } catch (error) {
54
+ // A failed check waits out the interval too, so an unreachable GitHub
55
+ // is not asked on every page load.
56
+ cache = { channel, checkedAt, latest: null, error: error?.message || String(error) };
57
+ }
58
+ return cache;
59
+ }
60
+
61
+ return {
62
+ /** The update status; `enabled` false (or FLOW_UPDATE_CHECK=off) asks nobody. */
63
+ async status({ enabled = updateCheckEnabled(env) } = {}) {
64
+ const current = deployedBuildStatus(env).release;
65
+ const channel = updateChannel(env, current);
66
+ const base = { enabled, channel, current, latest: null, updateAvailable: false, checkedAt: null, error: null, releaseUrl: null, upgradeUrl: upgradeGuideUrl(env) };
67
+ if (!enabled || !parseReleaseTag(current)) return base;
68
+ if (!cache || cache.channel !== channel || now() - cache.checkedAt >= intervalMs) {
69
+ inflight ??= refresh(channel).finally(() => { inflight = null; });
70
+ await inflight;
71
+ }
72
+ const { latest, error, checkedAt } = cache;
73
+ const updateAvailable = Boolean(latest) && compareReleases(latest, current) > 0;
74
+ return {
75
+ ...base,
76
+ latest,
77
+ updateAvailable,
78
+ checkedAt: new Date(checkedAt).toISOString(),
79
+ error,
80
+ releaseUrl: updateAvailable ? `https://github.com/${FLOW_REPOSITORY}/releases/tag/${latest}` : null,
81
+ };
82
+ },
83
+ };
84
+ }