@bongos/core 1.20.83 → 1.21.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/.bongos-core.json +151 -61
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +153 -4
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +12 -12
- package/docs/design/project-startup-direction.md +63 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +6 -0
- package/docs/page-inventory.json +8 -3
- package/docs/page-readings.json +382 -373
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- package/modules/hall-ui/public/genesis-home.js +17 -3
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- package/modules/lifecycle/workflow-dispatch.js +21 -11
- package/modules/npm-release/release.js +10 -1
- package/modules/onboarding/founding-birth.js +116 -0
- package/modules/onboarding/genesis-home.js +72 -5
- package/modules/onboarding/port.js +9 -2
- package/modules/onboarding/routes/onboarding.js +8 -1
- package/modules/provisioning/birth.js +64 -0
- package/modules/provisioning/dns-resolves.js +54 -0
- package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
- package/modules/provisioning/pollers/liveness-sweep.js +5 -0
- package/modules/provisioning/provisioning.js +7 -6
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +48 -0
- package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
- package/modules/public-landing/public/projects.html +40 -15
- package/modules/sessions/db.js +58 -0
- package/modules/sessions/routes/sessions.js +97 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +35 -0
- package/scripts/gds/provision.js +77 -53
- package/scripts/gds/session-digest.js +11 -3
- package/scripts/gds/session-transcript-build.js +155 -0
- package/scripts/gds/session-transcript-upload.js +29 -0
- package/scripts/gds/ship-session-upload.js +6 -1
- package/scripts/gds/upgrade-commit-identity.js +32 -0
- package/scripts/gds/upgrade.js +1 -1
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/hub_map_orb_states.mjs +5 -1
- package/tests/npm_release_release.mjs +37 -2
- package/tests/projects_hub.mjs +5 -3
- package/tests/projects_hub_dns_ready.mjs +153 -0
- package/tests/projects_hub_pre_uat.mjs +9 -5
- package/tests/provision_dns_first.mjs +171 -0
- package/tests/provision_settings_apply.mjs +6 -1
- package/tests/provisioning_settings_env.mjs +49 -0
- package/tests/session_transcript.mjs +273 -0
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/upgrade_commit_identity.mjs +89 -0
- package/tests/wizard_demo.mjs +29 -3
|
@@ -57,7 +57,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
|
|
|
57
57
|
|
|
58
58
|
**Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check and waits for the `integration` lane's verdict on the commit it releases (task 1004290), auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
|
|
59
59
|
|
|
60
|
-
**Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub
|
|
60
|
+
**Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub credential needs **Actions: write** to start the job: the `GITHUB_PUSH_TOKEN` access token (what cloudbongos.com uses), or the GitHub App where one is configured. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
|
|
61
61
|
|
|
62
62
|
The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
|
|
63
63
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- core_269_session_transcripts.sql — the FULL, secret-scrubbed transcript of every
|
|
2
|
+
-- session (ADR 0051, task 1001004).
|
|
3
|
+
--
|
|
4
|
+
-- WHY A SEPARATE TABLE and not a widened session_records.detail / a new jsonb
|
|
5
|
+
-- column on it: session_records is the BFG's hot, searched, paged table
|
|
6
|
+
-- (searchSessions, summarizeSessions, /mine all SELECT from it, and its rows are
|
|
7
|
+
-- KB by design — ADR 0027). A transcript is MB. Putting it on the same row would
|
|
8
|
+
-- make every list query drag (or have to remember to exclude) a fat jsonb, and
|
|
9
|
+
-- would make a TOASTed-column slip on one query a performance incident. One row
|
|
10
|
+
-- per session in each table, joined on session_id, keeps the evaluator's read
|
|
11
|
+
-- path exactly as cheap as it is today. ADR 0051 §1 allowed either shape
|
|
12
|
+
-- ("new transcript jsonb column, or a widened detail"); this is the third
|
|
13
|
+
-- option that satisfies its intent without the cost.
|
|
14
|
+
--
|
|
15
|
+
-- The content is scrubbed ON THE BUILDER'S BOX before upload (modules/security/
|
|
16
|
+
-- secret-scrub.js, the task-1003 scrubber); this table never holds raw text.
|
|
17
|
+
--
|
|
18
|
+
-- builder_id is denormalised from session_records ON PURPOSE: the read route's
|
|
19
|
+
-- ownership check (own transcript vs Archon-only cross-builder) must not depend
|
|
20
|
+
-- on a join succeeding, and the upload's ownership guard needs it at write time.
|
|
21
|
+
--
|
|
22
|
+
-- Retention: keep forever (ADR 0051 §4, Lars 2026-06-13). Storage-cost work is a
|
|
23
|
+
-- deliberate future task, not designed in here.
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS session_transcripts (
|
|
26
|
+
session_id text PRIMARY KEY, -- Claude Code session id, == session_records.session_id
|
|
27
|
+
builder_id bigint NOT NULL REFERENCES builders(id),
|
|
28
|
+
turn_count integer NOT NULL DEFAULT 0,
|
|
29
|
+
byte_size integer NOT NULL DEFAULT 0, -- serialized size of `turns`, for corpus accounting
|
|
30
|
+
truncated boolean NOT NULL DEFAULT false, -- true when the uploader hit its own size ceiling
|
|
31
|
+
turns jsonb NOT NULL DEFAULT '[]'::jsonb, -- per-turn scrubbed content (prompts, assistant text, tool calls + results)
|
|
32
|
+
uploaded_at timestamptz NOT NULL DEFAULT now()
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_session_transcripts_builder ON session_transcripts (builder_id);
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
var now = g.stages.find(function (s) { return s.state === 'now'; });
|
|
80
80
|
var sub = g.deciding
|
|
81
81
|
? 'Deciding the plan' + (g.day ? ' · day ' + g.day : '')
|
|
82
|
-
: 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.day ? ' · day ' + g.day : '');
|
|
82
|
+
: 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.demo ? ' · demo' : g.day ? ' · day ' + g.day : '');
|
|
83
83
|
return '<div class="genesis__head"><h2 id="genesis-h" class="scroll__h">Genesis</h2>'
|
|
84
84
|
+ '<p class="genesis__sub">' + esc(sub) + '</p></div>' + stepperHtml(g);
|
|
85
85
|
}
|
|
@@ -157,7 +157,10 @@
|
|
|
157
157
|
function stagesHtml(g) {
|
|
158
158
|
return '<ol class="genesis__stages">' + g.stages.map(function (s) {
|
|
159
159
|
var wakes = (s.wakes || []).map(function (id) { return ROOM[id] || id; });
|
|
160
|
-
|
|
160
|
+
// A demo's stage is minutes long (task 1004506): say how long, and due at a time.
|
|
161
|
+
var due = s.minutes
|
|
162
|
+
? ' · ' + s.minutes + ' min' + (s.state === 'now' && s.due ? ', due ' + new Date(s.due).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : '')
|
|
163
|
+
: s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
|
|
161
164
|
return '<li class="genesis__stage is-' + s.state + '">'
|
|
162
165
|
+ '<span class="genesis__stage-n">0' + s.number + '</span>'
|
|
163
166
|
+ '<span class="genesis__stage-t"><b>' + esc(s.label) + '</b>'
|
|
@@ -225,12 +228,23 @@
|
|
|
225
228
|
return document.documentElement.getAttribute('data-founding') === 'on';
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// A demo's genesis is expedited (task 1004506): its plan was settled from the demo's
|
|
232
|
+
// own picks before anyone saw it, so it never shows the decide form, only this line.
|
|
233
|
+
function demoHtml(g) {
|
|
234
|
+
var d = g.demo;
|
|
235
|
+
if (!d) return '';
|
|
236
|
+
var total = g.stages.reduce(function (n, s) { return n + (s.minutes || 0); }, 0);
|
|
237
|
+
return '<p class="scroll__lede">A demo’s genesis is short: about ' + esc(String(total)) + ' minutes, then the rest of your time is for building. '
|
|
238
|
+
+ 'The plan is already set from your demo: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
|
|
239
|
+
+ esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it to move on.</p>';
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
function render(host, state) {
|
|
229
243
|
var g = state.genesis;
|
|
230
244
|
if (!g) { host.hidden = true; return; }
|
|
231
245
|
host.innerHTML = headHtml(g) + (g.deciding
|
|
232
246
|
? decideHtml(g, state.gov)
|
|
233
|
-
: lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
|
|
247
|
+
: demoHtml(g) + lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
|
|
234
248
|
host.hidden = false;
|
|
235
249
|
}
|
|
236
250
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The genesis home of a DEMO, born into genesis (task 1004506): 3 people, 4 hours each. The plan was settled at birth from the demo's picks, so there is no decide form: one line names the plan, and each stage reads in minutes (scope in progress, due at a time). /me says founding at scope (kit fixture me-founding-demo.json) and /founding/genesis answers founding-genesis-demo.json, built from the real server shaper. A real project's genesis is index-genesis.states.json and index-genesis-decide.states.json; a hall that is not founding is index.states.json.",
|
|
3
|
+
"page": "/builders/",
|
|
4
|
+
"surface": "hall-ui",
|
|
5
|
+
"stub": { "prefix": "/builders/", "fixtures": { "me": "me-founding-demo", "founding-genesis": "founding-genesis-demo" } },
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard", "/api/(gds|bongos)/removal-proposals", "/api/(gds|bongos)/blockers"],
|
|
8
|
+
"_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s.",
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
10
|
+
"states": {
|
|
11
|
+
"demo": { "auth": true, "actions": [["wait", 800]], "expect": { "visible": ["#genesis-home", "#founding-band"] } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -5,16 +5,21 @@
|
|
|
5
5
|
// Kept beside github-push.js rather than in it: it shares that file's credential and headers,
|
|
6
6
|
// and nothing else.
|
|
7
7
|
|
|
8
|
-
const {
|
|
8
|
+
const { getInstallationToken, ghHeaders, isSafeBranch } = require('./github-push');
|
|
9
9
|
const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
|
|
10
10
|
|
|
11
11
|
// dispatchWorkflow — start a GitHub Actions workflow on this repo (task 1004298, ADR 0361). The
|
|
12
12
|
// Release button on /deploy uses it to run publish.yml's release job, so the npm credential never
|
|
13
|
-
// leaves GitHub: the server
|
|
14
|
-
// Actions: write for this one call. Returns { ok:true } on GitHub's 204, otherwise
|
|
15
|
-
// { ok:false, code, status? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
|
|
16
|
-
// (GitHub said no: 403 = the
|
|
17
|
-
// 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
|
|
13
|
+
// leaves GitHub: the server uses only the GitHub credential it already holds, which needs
|
|
14
|
+
// Actions: write for this one call. Returns { ok:true, credential } on GitHub's 204, otherwise
|
|
15
|
+
// { ok:false, code, status?, credential? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
|
|
16
|
+
// REFUSED (GitHub said no: 403 = the credential lacks Actions: write, 404 = no such workflow or no
|
|
17
|
+
// access, 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
|
|
18
|
+
//
|
|
19
|
+
// `credential` says WHICH one was used — 'app' (a GitHub App installation token) or 'token' (the
|
|
20
|
+
// GITHUB_PUSH_TOKEN access token) — so a refusal can name the thing the owner must edit
|
|
21
|
+
// (task 1004518: cloudbongos.com uses the access token, and a message naming "the App" sent the
|
|
22
|
+
// owner to the wrong settings page). Resolved in resolveToken's order: App first, then the token.
|
|
18
23
|
//
|
|
19
24
|
// ONLY THE WORKFLOWS NAMED HERE can be started. The port lends the server's GitHub credential to
|
|
20
25
|
// another module; an allowlist keeps a buggy or future borrower from running any other workflow in
|
|
@@ -22,8 +27,13 @@ const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
|
|
|
22
27
|
const DISPATCHABLE_WORKFLOWS = Object.freeze(['publish.yml']);
|
|
23
28
|
async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps = {}) {
|
|
24
29
|
if (!DISPATCHABLE_WORKFLOWS.includes(String(workflow || '')) || !isSafeBranch(ref)) return { ok: false, code: 'BAD_INPUT' };
|
|
25
|
-
let token;
|
|
26
|
-
|
|
30
|
+
let token = deps.token || null;
|
|
31
|
+
let credential = token ? (deps.credential || 'token') : null;
|
|
32
|
+
if (!token) {
|
|
33
|
+
try { token = await (deps.appToken || getInstallationToken)(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED', credential: 'app' }; }
|
|
34
|
+
if (token) credential = 'app';
|
|
35
|
+
}
|
|
36
|
+
if (!token && process.env.GITHUB_PUSH_TOKEN) { token = process.env.GITHUB_PUSH_TOKEN; credential = 'token'; }
|
|
27
37
|
if (!token) return { ok: false, code: 'UNCONFIGURED' };
|
|
28
38
|
const repoInfo = deps.repoInfo || loadRepoInfo();
|
|
29
39
|
if (!repoInfo || repoInfo.error) return { ok: false, code: 'NO_REPO_INFO' };
|
|
@@ -35,10 +45,10 @@ async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps =
|
|
|
35
45
|
headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
|
|
36
46
|
body: JSON.stringify({ ref, inputs }),
|
|
37
47
|
});
|
|
38
|
-
if (res.status === 204 || res.ok) return { ok: true };
|
|
39
|
-
return { ok: false, code: 'REFUSED', status: res.status };
|
|
48
|
+
if (res.status === 204 || res.ok) return { ok: true, credential };
|
|
49
|
+
return { ok: false, code: 'REFUSED', status: res.status, credential };
|
|
40
50
|
} catch (_) {
|
|
41
|
-
return { ok: false, code: 'UNREACHABLE' };
|
|
51
|
+
return { ok: false, code: 'UNREACHABLE', credential };
|
|
42
52
|
}
|
|
43
53
|
}
|
|
44
54
|
|
|
@@ -95,6 +95,15 @@ async function readReleaseLedger(pool) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// The credential a refusal names, in the words of the settings page the owner must open: GitHub
|
|
99
|
+
// lists an App under Developer settings → GitHub Apps, and an access token under Developer
|
|
100
|
+
// settings → Personal access tokens (task 1004518).
|
|
101
|
+
function credentialName(credential) {
|
|
102
|
+
if (credential === 'app') return "the server's GitHub App";
|
|
103
|
+
if (credential === 'token') return "the server's GitHub access token (GITHUB_PUSH_TOKEN, under Developer settings → Personal access tokens)";
|
|
104
|
+
return "the server's GitHub credential";
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
/**
|
|
99
108
|
* What the Release button does. Returns { status, body } for the route to send.
|
|
100
109
|
*
|
|
@@ -118,7 +127,7 @@ async function requestRelease({ version, registry, dispatch } = {}) {
|
|
|
118
127
|
}
|
|
119
128
|
const why = !r ? 'no answer'
|
|
120
129
|
: r.code === 'UNCONFIGURED' ? 'this server has no GitHub credential'
|
|
121
|
-
: r.code === 'REFUSED' && r.status === 403 ?
|
|
130
|
+
: r.code === 'REFUSED' && r.status === 403 ? `GitHub refused: ${credentialName(r.credential)} needs the Actions: write permission on this repository`
|
|
122
131
|
: r.code === 'REFUSED' && r.status === 404 ? 'GitHub could not find publish.yml, or the App cannot see this repository'
|
|
123
132
|
: r.code === 'REFUSED' ? `GitHub refused the request (${r.status})`
|
|
124
133
|
: r.code === 'UNREACHABLE' ? 'GitHub could not be reached'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/onboarding/founding-birth.js — A NEW PROJECT IS BORN INTO GENESIS (task
|
|
4
|
+
// 1004506 / BV2.PS18, goal 1000121; spec docs/specs/bongos-v2-project-startup.md step 5,
|
|
5
|
+
// D4, D8, D9).
|
|
6
|
+
//
|
|
7
|
+
// THE PROBLEM. POST /founding/start existed, but nothing called it, so a project made
|
|
8
|
+
// through the startup flow opened as an ordinary hall. The create request lands on the
|
|
9
|
+
// HUB, while founding mode lives in the NEW INSTANCE's own project_settings, and the hub
|
|
10
|
+
// holds no way into that database. So the hub only says so, and the instance acts:
|
|
11
|
+
//
|
|
12
|
+
// 1. The hub's create route stamps a reserved key on the new row
|
|
13
|
+
// (modules/provisioning/birth.js) — never on a row that already exists.
|
|
14
|
+
// 2. The runner writes it into the instance's web.env with the other settings
|
|
15
|
+
// companions: <PREFIX>_FOUNDING_BIRTH, plus <PREFIX>_DEMO_PEOPLE / _DEMO_HOURS
|
|
16
|
+
// when the row is a demo. src/branding.js maps them onto `founding.*`, which is
|
|
17
|
+
// server-only (clientBranding never publishes it).
|
|
18
|
+
// 3. Here, the instance reads that marker and, once, starts founding — and for a
|
|
19
|
+
// demo, also settles the expedited plan, so its decide stage needs no choices.
|
|
20
|
+
//
|
|
21
|
+
// WHEN IT RUNS: lazily, on the first founding read (GET /me through the port, and
|
|
22
|
+
// GET /founding/genesis), not at boot. The first /me is the hall's first paint after the
|
|
23
|
+
// founder's first sign-in, so the hall opens already in genesis; and by then the
|
|
24
|
+
// founder is seated and the kickoff seed has run, so the board the demo's stages are
|
|
25
|
+
// filed on exists. At boot it would race the module load order (the lifecycle port the
|
|
26
|
+
// plan files tasks through may not be registered yet) and a database that is still
|
|
27
|
+
// coming up.
|
|
28
|
+
//
|
|
29
|
+
// WHY IT CANNOT RACE OR TOUCH AN OLD PROJECT.
|
|
30
|
+
// * No marker, no run: a project created before this shipped has none (D8), and a
|
|
31
|
+
// marker-less read resolves without touching the database.
|
|
32
|
+
// * Once per process: one shared promise, so two concurrent first reads share one run.
|
|
33
|
+
// * Once for good: founding is started only when it was never started and never
|
|
34
|
+
// ended (founding.started_at / ended_at), so a restart, a later settings push that
|
|
35
|
+
// rewrites the env, or an owner who already started by hand changes nothing. A demo
|
|
36
|
+
// carried over keeps its state for the same reason: genesis happened once.
|
|
37
|
+
// * The demo's plan is saved only while the project is still deciding with no plan,
|
|
38
|
+
// and savePlan itself refuses outside 'decide'; the stage tasks are filed by a seeder
|
|
39
|
+
// that skips a step already filed.
|
|
40
|
+
|
|
41
|
+
const fm = require('./founding-mode');
|
|
42
|
+
const genesis = require('./genesis-home');
|
|
43
|
+
|
|
44
|
+
// The demo's limits and the bounds check are genesis-home.js's own, never retyped here.
|
|
45
|
+
const { DEMO_PEOPLE, DEMO_HOURS, intIn } = genesis;
|
|
46
|
+
|
|
47
|
+
// birthMarker(founding) — PURE. The resolved branding pack's `founding` block → whether
|
|
48
|
+
// this project was born through the startup flow, and its demo time box if a demo. A
|
|
49
|
+
// malformed demo value reads as no demo (the project still founds, with the normal plan).
|
|
50
|
+
function birthMarker(founding) {
|
|
51
|
+
const f = founding && typeof founding === 'object' ? founding : {};
|
|
52
|
+
const born = typeof f.birth === 'string' && Number.isFinite(new Date(f.birth).getTime());
|
|
53
|
+
if (!born) return { born: false, demo: null };
|
|
54
|
+
const people = Number(f.demoPeople);
|
|
55
|
+
const hours = Number(f.demoHours);
|
|
56
|
+
const demo = intIn(people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) && intIn(hours, DEMO_HOURS[0], DEMO_HOURS[1]) ? { people, hours_each: hours } : null;
|
|
57
|
+
return { born: true, demo };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function markerFrom(deps) {
|
|
61
|
+
if (deps.marker) return deps.marker;
|
|
62
|
+
let b;
|
|
63
|
+
try { b = require('../../src/module-api').branding(); } catch (_) { return { born: false, demo: null }; }
|
|
64
|
+
return birthMarker(b && b.founding);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// bornFounding(deps) — the guarded birth. Returns what it did:
|
|
68
|
+
// { ran: false, reason } nothing to do (no marker, already, alive)
|
|
69
|
+
// { ran: true, started, plan, demo } it started founding and/or saved the plan
|
|
70
|
+
// { ran: false, reason, retry: true } the demo plan could not be saved yet
|
|
71
|
+
async function bornFounding(deps = {}) {
|
|
72
|
+
const marker = markerFrom(deps);
|
|
73
|
+
if (!marker.born) return { ran: false, reason: 'no_marker' };
|
|
74
|
+
const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
|
|
75
|
+
const [started, ended] = await Promise.all([get(fm.KEYS.STARTED_AT), get(fm.KEYS.ENDED_AT)]);
|
|
76
|
+
if (ended && ended.value) return { ran: false, reason: 'already_alive' };
|
|
77
|
+
let didStart = false;
|
|
78
|
+
if (!(started && started.value)) {
|
|
79
|
+
const r = await genesis.startFounding(null, deps);
|
|
80
|
+
if (!r.ok) return { ran: false, reason: r.reason };
|
|
81
|
+
didStart = true;
|
|
82
|
+
}
|
|
83
|
+
if (!marker.demo) return didStart ? { ran: true, started: true, plan: false, demo: false } : { ran: false, reason: 'already_started' };
|
|
84
|
+
// The demo's expedited plan: only while still deciding, and only if none is saved.
|
|
85
|
+
const mode = await fm.foundingModeFor(genesis.modeDepsFor({ ...deps, getSetting: get }));
|
|
86
|
+
const planRow = await get(genesis.PLAN_KEY);
|
|
87
|
+
if (!mode || mode.stage !== 'decide' || (planRow && planRow.value)) {
|
|
88
|
+
return didStart ? { ran: true, started: true, plan: false, demo: true } : { ran: false, reason: 'already_started' };
|
|
89
|
+
}
|
|
90
|
+
const saved = await genesis.savePlan(genesis.demoPlan(marker.demo), null, deps);
|
|
91
|
+
if (!saved.ok) return { ran: false, reason: saved.reason, retry: true };
|
|
92
|
+
return { ran: true, started: didStart, plan: true, demo: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ensureBirth(deps) — the once-per-process door the reads call. Shares one run between
|
|
96
|
+
// concurrent callers; forgets a run that failed or asked to retry, so a later read tries
|
|
97
|
+
// again. Never throws: a birth hiccup must never fail the read that triggered it.
|
|
98
|
+
let once = null;
|
|
99
|
+
function ensureBirth(deps = {}) {
|
|
100
|
+
if (once) return once;
|
|
101
|
+
const p = bornFounding(deps).then((r) => {
|
|
102
|
+
if (r && r.retry) once = null;
|
|
103
|
+
return r;
|
|
104
|
+
}, (err) => {
|
|
105
|
+
once = null;
|
|
106
|
+
try { require('../../src/module-api').logger('onboarding').error('[onboarding] founding birth failed', err); } catch (_) { /* no logger: nothing to do */ }
|
|
107
|
+
return { ran: false, reason: 'error' };
|
|
108
|
+
});
|
|
109
|
+
once = p;
|
|
110
|
+
return p;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Test seam: forget the per-process run.
|
|
114
|
+
function resetBirthForTests() { once = null; }
|
|
115
|
+
|
|
116
|
+
module.exports = { birthMarker, bornFounding, ensureBirth, resetBirthForTests };
|
|
@@ -75,7 +75,65 @@ function parsePlan(body = {}) {
|
|
|
75
75
|
if (government !== null && !/^[a-z][a-z-]{1,39}$/.test(government)) return { ok: false, reason: 'bad_government' };
|
|
76
76
|
const expected = b.expected_builders == null || b.expected_builders === '' ? null : Number(b.expected_builders);
|
|
77
77
|
if (expected !== null && !intIn(expected, 1, 100000)) return { ok: false, reason: 'bad_expected_builders' };
|
|
78
|
-
|
|
78
|
+
const plan = { days, passes, advisors, government, expected_builders: expected, existing: b.existing === true };
|
|
79
|
+
// A DEMO's plan (task 1004506) also carries its time box and each stage's length in
|
|
80
|
+
// minutes. Only the birth path writes these (demoPlan below): the plan route's schema
|
|
81
|
+
// does not name them, so a client cannot send one.
|
|
82
|
+
if (b.demo != null) {
|
|
83
|
+
const d = b.demo;
|
|
84
|
+
if (!d || !intIn(d.people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) || !intIn(d.hours_each, DEMO_HOURS[0], DEMO_HOURS[1])) return { ok: false, reason: 'bad_demo' };
|
|
85
|
+
plan.demo = { people: d.people, hours_each: d.hours_each };
|
|
86
|
+
}
|
|
87
|
+
if (b.minutes != null) {
|
|
88
|
+
plan.minutes = {};
|
|
89
|
+
for (const s of PROPER) {
|
|
90
|
+
const m = b.minutes[s];
|
|
91
|
+
if (!intIn(m, 1, MAX_DEMO_MINUTES)) return { ok: false, reason: 'bad_minutes', stage: s };
|
|
92
|
+
plan.minutes[s] = m;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, plan };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── the demo's expedited genesis (task 1004506) ──────────────────────────────
|
|
99
|
+
// Owner ruling, 2026-10-01: a demo DOES go through genesis, but expedited, because the
|
|
100
|
+
// point of a demo is that the whole thing is fast. So a demo's plan is settled before
|
|
101
|
+
// the founder ever sees it, from what the demo already picked: Monarchy / BDFL (the
|
|
102
|
+
// founder's one yes closes each stage, spec D4's fast-track), one pass, no advisors,
|
|
103
|
+
// the demo's people as the headcount. Its stages are minutes, not days: genesis gets a
|
|
104
|
+
// fifth of the hours each person has (at least ten minutes, at most two hours), split
|
|
105
|
+
// by how much each stage asks, so a small team reaches "come alive" with most of the
|
|
106
|
+
// time box left for building.
|
|
107
|
+
const DEMO_PEOPLE = Object.freeze([1, 12]); // modules/provisioning/demo.js DEMO_LIMITS
|
|
108
|
+
const DEMO_HOURS = Object.freeze([1, 40]);
|
|
109
|
+
const MAX_DEMO_MINUTES = 240;
|
|
110
|
+
const DEMO_SHARE = 0.2;
|
|
111
|
+
const DEMO_GENESIS_MINUTES = Object.freeze([10, 120]);
|
|
112
|
+
const DEMO_WEIGHTS = Object.freeze({ scope: 3, strategize: 2, charter: 2, legalize: 1, legislation: 2 });
|
|
113
|
+
|
|
114
|
+
// demoMinutes(hoursEach) — PURE. Each stage's length in minutes, from the time box.
|
|
115
|
+
function demoMinutes(hoursEach) {
|
|
116
|
+
const [lo, hi] = DEMO_GENESIS_MINUTES;
|
|
117
|
+
const budget = Math.min(hi, Math.max(lo, Math.round(hoursEach * 60 * DEMO_SHARE)));
|
|
118
|
+
const total = Object.values(DEMO_WEIGHTS).reduce((a, b) => a + b, 0);
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const s of PROPER) out[s] = Math.max(1, Math.round((budget * DEMO_WEIGHTS[s]) / total));
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// demoPlan({ people, hours_each }) — PURE. The plan body a demo is born with; it goes
|
|
125
|
+
// through parsePlan like any other, so it is held to the same ranges.
|
|
126
|
+
function demoPlan({ people, hours_each: hoursEach }) {
|
|
127
|
+
return {
|
|
128
|
+
days: Object.fromEntries(PROPER.map((s) => [s, 1])),
|
|
129
|
+
minutes: demoMinutes(hoursEach),
|
|
130
|
+
passes: 1,
|
|
131
|
+
advisors: 'none',
|
|
132
|
+
government: 'monarchy',
|
|
133
|
+
expected_builders: people,
|
|
134
|
+
existing: false,
|
|
135
|
+
demo: { people, hours_each: hoursEach },
|
|
136
|
+
};
|
|
79
137
|
}
|
|
80
138
|
|
|
81
139
|
function readPlanValue(raw) {
|
|
@@ -99,8 +157,11 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
|
99
157
|
const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
|
|
100
158
|
const b = board[key] || null;
|
|
101
159
|
const days = plan && plan.days && plan.days[key] ? plan.days[key] : info.days;
|
|
160
|
+
// A demo's stage is minutes long (task 1004506); its due time is counted in them.
|
|
161
|
+
const minutes = plan && plan.minutes && plan.minutes[key] ? plan.minutes[key] : null;
|
|
102
162
|
const began = stageAt[key] ? new Date(stageAt[key]) : null;
|
|
103
|
-
const
|
|
163
|
+
const span = minutes ? minutes * 60000 : days * 86400000;
|
|
164
|
+
const due = began && Number.isFinite(began.getTime()) ? new Date(began.getTime() + span).toISOString() : null;
|
|
104
165
|
return {
|
|
105
166
|
key,
|
|
106
167
|
number: idx,
|
|
@@ -110,6 +171,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
|
110
171
|
done_when: info.done_when,
|
|
111
172
|
wakes: fm.WAKES[key] || [],
|
|
112
173
|
days,
|
|
174
|
+
minutes,
|
|
113
175
|
due,
|
|
114
176
|
state: idx < at ? 'done' : idx === at ? 'now' : 'later',
|
|
115
177
|
tasks: list,
|
|
@@ -131,6 +193,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
|
131
193
|
day: mode.day,
|
|
132
194
|
deciding: mode.stage === 'decide',
|
|
133
195
|
plan: plan || null,
|
|
196
|
+
// The demo's time box when this genesis is a demo's (task 1004506), else null.
|
|
197
|
+
demo: plan && plan.demo ? plan.demo : null,
|
|
134
198
|
stages,
|
|
135
199
|
// The close door: every task of the current stage finished, and not already sitting.
|
|
136
200
|
can_close: !!(now && now.total > 0 && now.finished === now.total && !sitting && !passed),
|
|
@@ -193,8 +257,9 @@ async function readGenesis(deps = {}) {
|
|
|
193
257
|
return shapeGenesis({ mode, plan, tasks, board, stageAt });
|
|
194
258
|
}
|
|
195
259
|
|
|
196
|
-
// startFounding — a brand-new project enters genesis (
|
|
197
|
-
//
|
|
260
|
+
// startFounding — a brand-new project enters genesis (founding-birth.js calls this once
|
|
261
|
+
// on a project born through the startup flow, task 1004506). Refused once the project
|
|
262
|
+
// has come alive: genesis happens once (spec D8).
|
|
198
263
|
async function startFounding(builderId, deps = {}) {
|
|
199
264
|
const s = seams(deps);
|
|
200
265
|
const ended = await s.get(fm.KEYS.ENDED_AT);
|
|
@@ -269,6 +334,8 @@ async function onStagePassed(deps = {}) {
|
|
|
269
334
|
}
|
|
270
335
|
|
|
271
336
|
module.exports = {
|
|
272
|
-
PLAN_KEY, STAGE_INFO, PROPER, parsePlan, shapeGenesis,
|
|
337
|
+
PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
|
|
273
338
|
readGenesis, startFounding, savePlan, closeStage, comeAlive, onStagePassed,
|
|
339
|
+
// the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
|
|
340
|
+
modeDepsFor: (deps) => modeDeps(seams(deps)),
|
|
274
341
|
};
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
const onboardingState = require('./onboarding-state');
|
|
42
42
|
const newcomerRestock = require('./newcomer-restock');
|
|
43
43
|
const foundingMode = require('./founding-mode');
|
|
44
|
+
const foundingBirth = require('./founding-birth');
|
|
44
45
|
|
|
45
46
|
module.exports = {
|
|
46
47
|
// --- the newcomer onboarding state machine (onboarding-state.js) ---------
|
|
@@ -56,8 +57,14 @@ module.exports = {
|
|
|
56
57
|
|
|
57
58
|
// --- project founding mode (founding-mode.js, task 1004422) ---------------
|
|
58
59
|
// me.js reads it for the hall shell (null = not founding = the hall as today);
|
|
59
|
-
// the genesis home (task 1004423) drives the write.
|
|
60
|
-
|
|
60
|
+
// the genesis home (task 1004423) drives the write. A project born through the
|
|
61
|
+
// startup flow enters genesis on this, its first read (founding-birth.js, task
|
|
62
|
+
// 1004506): once, guarded, and a no-op without the birth marker. A caller passing
|
|
63
|
+
// its own deps (a test) skips the birth.
|
|
64
|
+
foundingModeFor: async (...a) => {
|
|
65
|
+
if (!a.length) await foundingBirth.ensureBirth();
|
|
66
|
+
return foundingMode.foundingModeFor(...a);
|
|
67
|
+
},
|
|
61
68
|
setFoundingMode: (...a) => foundingMode.setFoundingMode(...a),
|
|
62
69
|
|
|
63
70
|
// --- the evergreen newcomer-chore restock (newcomer-restock.js) ----------
|
|
@@ -36,6 +36,7 @@ const onboardingPort = require('../port');
|
|
|
36
36
|
const buildAccessRequestsRouter = require('./access-requests');
|
|
37
37
|
const foundingBuilders = require('../founding-builders');
|
|
38
38
|
const genesis = require('../genesis-home');
|
|
39
|
+
const foundingBirth = require('../founding-birth');
|
|
39
40
|
|
|
40
41
|
// Register the `onboarding` kernel PORT. See the header + port.js for the full
|
|
41
42
|
// consumer list and the graceful-degradation contract.
|
|
@@ -76,6 +77,7 @@ const GENESIS_REFUSALS = {
|
|
|
76
77
|
not_at_legislation: 409, legislation_not_passed: 409, stage_tasks_unfinished: 409,
|
|
77
78
|
no_stage_tasks: 409, unknown_stage: 400, no_board: 503,
|
|
78
79
|
bad_days: 400, bad_passes: 400, bad_advisors: 400, bad_government: 400, bad_expected_builders: 400,
|
|
80
|
+
bad_demo: 400, bad_minutes: 400,
|
|
79
81
|
};
|
|
80
82
|
let genesisListening = false;
|
|
81
83
|
|
|
@@ -101,7 +103,12 @@ function mountGenesisRoutes(router) {
|
|
|
101
103
|
res.status(500).json({ error: { code: 'genesis_failed', message: 'internal error' } });
|
|
102
104
|
}
|
|
103
105
|
};
|
|
104
|
-
|
|
106
|
+
// A project born through the startup flow enters genesis on its first founding read
|
|
107
|
+
// (founding-birth.js, task 1004506) — here and on GET /me through the port.
|
|
108
|
+
router.get('/founding/genesis', api.requireBuilder, handle(async (req) => {
|
|
109
|
+
await foundingBirth.ensureBirth();
|
|
110
|
+
return { genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) };
|
|
111
|
+
}));
|
|
105
112
|
router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.startFounding(req.builder.id)));
|
|
106
113
|
// The plan's ranges and vocabularies are genesis-home.js parsePlan's (one source);
|
|
107
114
|
// this schema only names the fields so an unknown one is refused.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/birth.js — the BIRTH MARKER: this project was made through the
|
|
4
|
+
// startup flow, so it is born into genesis (task 1004506 / BV2.PS18; spec
|
|
5
|
+
// docs/specs/bongos-v2-project-startup.md step 5 + D8).
|
|
6
|
+
//
|
|
7
|
+
// Founding mode lives in the NEW INSTANCE's own project_settings, and this process (the
|
|
8
|
+
// hub's web tier) has no way into that database — the one rule of this module. So the
|
|
9
|
+
// hub only says so, and the instance acts on it (modules/onboarding/founding-birth.js):
|
|
10
|
+
//
|
|
11
|
+
// * the create route stamps `founding_birth` (a reserved settings-jsonb key, like
|
|
12
|
+
// push_owed and look_accent: platform bookkeeping, out of SETTINGS_VOCAB, so the
|
|
13
|
+
// settings PATCH cannot write it and effectiveSettings never projects it) on the
|
|
14
|
+
// row it JUST created — never on one that already existed, so a project made before
|
|
15
|
+
// this shipped is never born into anything (D8);
|
|
16
|
+
// * the companions below carry it, and a demo's time box, into the instance's
|
|
17
|
+
// web.env beside the other settings, at standup and on every settings-apply push.
|
|
18
|
+
//
|
|
19
|
+
// Always emitted, empty meaning "not born through the flow" / "not a demo", for the
|
|
20
|
+
// same reason as every companion: the runner patches these lines in place, and a key
|
|
21
|
+
// that came and went would leave a stale line behind.
|
|
22
|
+
|
|
23
|
+
const BIRTH_KEY = 'founding_birth';
|
|
24
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
25
|
+
|
|
26
|
+
const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
|
|
27
|
+
|
|
28
|
+
// bornAt(row) — PURE. The birth stamp, or '' for a row that has none (or a mangled one).
|
|
29
|
+
function bornAt(row) {
|
|
30
|
+
const v = String(storedOf(row)[BIRTH_KEY] || '');
|
|
31
|
+
return ISO.test(v) && Number.isFinite(new Date(v).getTime()) ? v : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// demoBox(row) — PURE. A born demo's { people, hours_each }, or null. Only a row that is
|
|
35
|
+
// both born and a demo carries one: an older demo has no marker and founds nothing.
|
|
36
|
+
function demoBox(row) {
|
|
37
|
+
if (!bornAt(row)) return null;
|
|
38
|
+
const d = row && row.demo;
|
|
39
|
+
if (!d || typeof d !== 'object') return null;
|
|
40
|
+
const people = Number(d.people);
|
|
41
|
+
const hours = Number(d.hours_each);
|
|
42
|
+
if (!Number.isInteger(people) || !Number.isInteger(hours) || people < 1 || hours < 1) return null;
|
|
43
|
+
return { people, hours_each: hours };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The companion env (SETTINGS_COMPANION_ENV). src/branding.js maps them onto founding.*.
|
|
47
|
+
const BIRTH_COMPANION_ENV = Object.freeze({
|
|
48
|
+
FOUNDING_BIRTH: bornAt,
|
|
49
|
+
DEMO_PEOPLE: (row) => { const d = demoBox(row); return d ? String(d.people) : ''; },
|
|
50
|
+
DEMO_HOURS: (row) => { const d = demoBox(row); return d ? String(d.hours_each) : ''; },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// stampBirth(db, instanceId, now) — the create route's one write, on the row it just
|
|
54
|
+
// made. Write-once: a stamp already there is kept (the WHERE says so).
|
|
55
|
+
async function stampBirth(db, instanceId, now = new Date()) {
|
|
56
|
+
await db.query(
|
|
57
|
+
`UPDATE provisioning_instances
|
|
58
|
+
SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, $3::text)
|
|
59
|
+
WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
|
|
60
|
+
[instanceId, BIRTH_KEY, now.toISOString()],
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { BIRTH_KEY, BIRTH_COMPANION_ENV, stampBirth };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/dns-resolves.js — has the platform's own lookup seen this
|
|
4
|
+
// project's address answer? (task 1004505, BV2.PS17; migration provisioning_036)
|
|
5
|
+
//
|
|
6
|
+
// WHY. A visitor's resolver caches a "not found" answer for the zone's negative TTL
|
|
7
|
+
// (cloudbongos.com's SOA minimum is 1800s). The hub used to link an address as soon as
|
|
8
|
+
// the row read 'active', so a founder who clicked before the name answered anywhere
|
|
9
|
+
// could not reach their own project from their home network for up to half an hour.
|
|
10
|
+
// The fix has two halves: the runner creates the DNS record FIRST (scripts/gds/
|
|
11
|
+
// provision.js), and the hub holds every link to the address until `dns_resolves` is
|
|
12
|
+
// true — a fact only a lookup the PLATFORM made can establish, never the status alone.
|
|
13
|
+
//
|
|
14
|
+
// Who writes it: the runner, at the end of a standup (minutes after the record was
|
|
15
|
+
// created, so its own lookup cannot race the record into a cached miss), and the
|
|
16
|
+
// liveness sweep, whose SSRF gate already resolves every active address. Sticky once
|
|
17
|
+
// seen; a changed domain clears it (updateInstanceDomain in ./provisioning.js).
|
|
18
|
+
// Its own file because ./provisioning.js sits at the 1500-line size ratchet.
|
|
19
|
+
|
|
20
|
+
const dns = require('node:dns').promises;
|
|
21
|
+
|
|
22
|
+
// The projection publicInstance carries. PURE. No domain ⇒ nothing to resolve.
|
|
23
|
+
function dnsResolves(row) {
|
|
24
|
+
return !!(row && row.domain && row.dns_resolved_at);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Record that a lookup answered. Narrow like recordDnsNote: no `status`, no
|
|
28
|
+
// `updated_at`, and only the FIRST sighting writes, so a sweep never rewrites it.
|
|
29
|
+
async function recordDnsResolved(db, id) {
|
|
30
|
+
const { rows } = await db.query(
|
|
31
|
+
`UPDATE provisioning_instances SET dns_resolved_at = now()
|
|
32
|
+
WHERE id = $1 AND dns_resolved_at IS NULL RETURNING id, dns_resolved_at`,
|
|
33
|
+
[id]
|
|
34
|
+
);
|
|
35
|
+
return rows[0] || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One bounded lookup: true when the name resolves to at least one address, false on
|
|
39
|
+
// any failure or after `timeoutMs`. Never throws. `lookup` is injectable for tests.
|
|
40
|
+
async function lookupAnswers(host, { lookup, timeoutMs = 5000 } = {}) {
|
|
41
|
+
if (!host) return false;
|
|
42
|
+
let timer = null;
|
|
43
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); });
|
|
44
|
+
try {
|
|
45
|
+
const ask = Promise.resolve((lookup || dns.lookup)(host, { all: true }))
|
|
46
|
+
.then((addrs) => Array.isArray(addrs) ? addrs.some((a) => a && a.address) : !!(addrs && addrs.address))
|
|
47
|
+
.catch(() => false);
|
|
48
|
+
return await Promise.race([ask, timeout]);
|
|
49
|
+
} finally {
|
|
50
|
+
if (timer) clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { dnsResolves, recordDnsResolved, lookupAnswers };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- provisioning_036_dns_resolved.sql — when the platform's own lookup first saw a
|
|
2
|
+
-- project's address answer (task 1004505, BV2.PS17).
|
|
3
|
+
--
|
|
4
|
+
-- WHY THIS EXISTS. A visitor's resolver caches a "not found" answer for the zone's
|
|
5
|
+
-- negative TTL (30 minutes on cloudbongos.com). The hub used to link a project's
|
|
6
|
+
-- address as soon as the row said 'active', and the founder's first click, made
|
|
7
|
+
-- before the name answered anywhere, poisoned their own home network for half an
|
|
8
|
+
-- hour. The hub now holds every link to the address until this column is set.
|
|
9
|
+
--
|
|
10
|
+
-- WHAT IT ADDS.
|
|
11
|
+
-- provisioning_instances.dns_resolved_at — NULL until a lookup made by the
|
|
12
|
+
-- platform (the runner at the end of a standup, or the liveness sweep's gate)
|
|
13
|
+
-- returned an address for the row's domain. Projected as `dns_resolves`.
|
|
14
|
+
-- A changed domain clears it (updateInstanceDomain), like the liveness overlay.
|
|
15
|
+
--
|
|
16
|
+
-- OVERLAY, NOT A STATE (the provisioning_006 rule): nullable, no default, never
|
|
17
|
+
-- written by the lifecycle. The backfill marks every address the sweep has
|
|
18
|
+
-- already seen answer, so no live project loses its link when this lands.
|
|
19
|
+
--
|
|
20
|
+
-- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
|
|
21
|
+
|
|
22
|
+
BEGIN;
|
|
23
|
+
|
|
24
|
+
ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS dns_resolved_at timestamptz;
|
|
25
|
+
|
|
26
|
+
UPDATE provisioning_instances
|
|
27
|
+
SET dns_resolved_at = last_verified_at
|
|
28
|
+
WHERE dns_resolved_at IS NULL AND last_verified_up IS TRUE AND domain IS NOT NULL;
|
|
29
|
+
|
|
30
|
+
COMMIT;
|