@bongos/core 1.19.82 → 1.19.84

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.
@@ -19,6 +19,7 @@ Scope: the three owner-facing project surfaces — the **create wizard** (`publi
19
19
 
20
20
  1. **Truth model: heartbeat + honest chips.** The platform re-checks liveness on a background cadence and the UI shows verification recency. "Live" becomes a verified claim.
21
21
  2. **Visual system: sunrise, tokenized — refined through design iterations.** Keep the sunrise landing brand; extract a shared token + component layer; the final look is nailed down through owner-review loops during implementation (§10), not invented in this doc.
22
+ > **Amended in Loop 1 (owner, 2026-08-12, during task 1002696):** the iteration loops did their job and moved the look — these surfaces go **all-in on the space backdrop** (the gate's deep-space sky, stars, project orbs), with a **light/dark mode** governing the content cards (light = cream cards on space, dark = deep-navy cards; OS preference honored, manual toggle persisted). The token layer ships as `public-landing/assets/cosmos.css`. Everything else in this doc (truth model, vocabulary, copy rules, flows) is unchanged.
22
23
  3. **First-run: designed now, delivered split.** This direction defines the shape-aware surfaces; task 1002696 implements the surface parts, and runner/infra hardening stays in its parallel lane (tasks 1002717–1002722).
23
24
  4. **Deliverable: this direction doc.** Owner sign-off on this doc is the task's done condition.
24
25
 
@@ -113,7 +114,7 @@ Beats stay **Name · Home · Address · Create** — the flow shape survives; th
113
114
  |---|---|---|
114
115
  | Done panel promises a runner self-hosts don't have (task 1002717) | Shape-aware plan rendering (§7) | **task 1002696** |
115
116
  | `FIRST_ADMIN` invisible — first user bounced to a nonexistent Owner (task 1002717) | A virgin instance's sign-in surface says plainly: "No one is admitted yet — the instance operator names the first admin," and points at the one-line fix (env var / branding field) | copy surface with task 1002696 where it lives in core UI; self-host docs + recipe with the **1002717 lane** |
116
- | Newborn instance greets its founder with "your art coverage has ended" (idea 1000701) | §5.6 welcome + nag suppression until first-run completes | **task 1002696** if it stays a small `needs`-gating change; otherwise spun out as its own task |
117
+ | Newborn instance greets its founder with "your art coverage has ended" (idea 1000701) | §5.6 welcome + nag suppression until first-run completes | **Spun out as task 1002739** (decided during task 1002696: it lives on the CHILD instance's hall + needs roster, not on the control-plane surfaces this task owns — the "otherwise" branch of the split below) |
117
118
  | `/create` + `/projects` unreachable on single-origin self-hosts (task 1002717) | Out of this direction's hands — a mounting/architecture question | **1002717 lane** |
118
119
  | Runner assumes the founder's box six ways; founder-identity hardcodes (tasks 1002718, 1002719) | Not a surface concern | **hardening lane** |
119
120
  | Refuse-unknown dispatch + runner-drift gate (task 1002722) | Not a surface concern | **hardening lane** |
@@ -133,7 +134,7 @@ Beats stay **Name · Home · Address · Create** — the flow shape survives; th
133
134
 
134
135
  ## 11. Scope boundaries
135
136
 
136
- **In (task 1002696 implements):** the three surfaces rebuilt on the shared token/component layer; heartbeat sweep + two columns + API exposure; chip overlay + per-flow strips; owner-voice copy pass incl. error categories + disclosures; address-step collapse (task 1002704); repo-visibility statements; Bring-it-back + Try-again actions; nav doors (gate, done panel); shape-aware done panel; newborn welcome/nag suppression (if small).
137
+ **In (task 1002696 implements):** the three surfaces rebuilt on the shared token/component layer; heartbeat sweep + two columns + API exposure; chip overlay + per-flow strips; owner-voice copy pass incl. error categories + disclosures; address-step collapse (task 1002704); repo-visibility statements; Bring-it-back + Try-again actions; nav doors (gate, done panel); shape-aware done panel. **Not** the newborn welcome — that landed as task 1002739 (see §9).
137
138
 
138
139
  **Parallel (hardening lane, already seeded):** provision-time healthz gating, no-hook attach honesty, teardown-vs-dropdb reality, manifest race + spent-code fix (task 1002720); self-host reachability + FIRST_ADMIN recipe (task 1002717); runner assumptions (task 1002718); founder-identity hardcodes (task 1002719); dispatch/drift gates (task 1002722).
139
140
 
@@ -492,6 +492,87 @@ async function getPublishStatus({ branch }, deps = {}) {
492
492
  };
493
493
  }
494
494
 
495
+ // probeDeployVisibility (task 1002733) — can this instance's publish credential READ
496
+ // the Actions API at all?
497
+ //
498
+ // WHY THIS EXISTS. getPublishStatus's deploy probe answers a question about ONE
499
+ // workflow, and on a private repo GitHub deliberately blurs "not there" and "not
500
+ // yours" — both can surface as 404. So a single call cannot distinguish "this repo
501
+ // has no deploy-prod.yml" (fine; the merge is the land, ADR 0108) from "this token
502
+ // cannot see Actions" (a silent misconfiguration that made task 1002695 strand at
503
+ // confirmed across 4 sweeps on 2026-08-12). Until task 1002724 there was no way to
504
+ // tell them apart without a shell on the host and a hand-run of the API.
505
+ //
506
+ // The fix is a CONTROL call: list the repo's workflows. If that succeeds we can read
507
+ // Actions, so a 404 on deploy-prod.yml genuinely means the file is absent. If the
508
+ // list itself is refused, the credential is blind and every deploy verdict it gives
509
+ // is meaningless.
510
+ //
511
+ // ADR 0055 specifies this credential as Contents rw + Pull requests rw + Actions:
512
+ // read. NOTE resolveToken precedence is deps.token > App installation token > PAT, so
513
+ // an instance runs on ONE of them and the other is irrelevant: a correctly-scoped PAT
514
+ // can be masked by a narrower App grant, and equally an App that was never configured
515
+ // means the PAT is live no matter what the ADR's history says. cloudbongos.com is the
516
+ // second case — checked 2026-08-12, web.env sets GITHUB_PUSH_TOKEN and no GITHUB_APP_*.
517
+ // The remediation string below resolves which one is live rather than guessing.
518
+ async function probeDeployVisibility(deps = {}) {
519
+ const token = await resolveToken(deps);
520
+ if (!token) {
521
+ return { state: 'no_credential', http: null, detail: 'no push credential configured (GITHUB_PUSH_TOKEN / App)', remediation: null };
522
+ }
523
+ const repoInfo = deps.repoInfo || loadRepoInfo();
524
+ if (!repoInfo || repoInfo.error) {
525
+ return { state: 'no_repo_info', http: null, detail: 'repo owner/name could not be resolved', remediation: null };
526
+ }
527
+ const { owner, repo } = repoInfo;
528
+ const doFetch = deps.fetchImpl || fetch;
529
+ const api = `https://api.github.com/repos/${owner}/${repo}`;
530
+ // Name the credential that is ACTUALLY live, so the remediation points at the right
531
+ // one. resolveToken() prefers the App and falls back to GITHUB_PUSH_TOKEN, so an
532
+ // instance can be on either — and cloudbongos.com is on the PAT despite ADR 0055's
533
+ // history saying the App replaced it (task 1002733).
534
+ const live = appConfigured() ? 'the GitHub App (GITHUB_APP_* are set — it wins over any PAT)' : 'the PAT in GITHUB_PUSH_TOKEN (no GITHUB_APP_* configured)';
535
+ const GRANT = `Grant 'Actions: read' on ${owner}/${repo} to ${live} (ADR 0055).`;
536
+
537
+ let listRes;
538
+ try {
539
+ listRes = await doFetch(`${api}/actions/workflows?per_page=1`, { headers: ghHeaders(token) });
540
+ } catch (e) {
541
+ return { state: 'unknown', http: null, detail: `workflows list threw: ${e && e.message}`, remediation: null };
542
+ }
543
+ if (!listRes.ok) {
544
+ return {
545
+ state: 'blind',
546
+ http: listRes.status,
547
+ detail: `cannot list workflows (HTTP ${listRes.status}) — every deploy verdict from this credential is unreliable`,
548
+ remediation: GRANT,
549
+ };
550
+ }
551
+
552
+ // Actions is readable. Now the specific workflow: present, or genuinely absent?
553
+ let runsRes;
554
+ try {
555
+ runsRes = await doFetch(`${api}/actions/workflows/deploy-prod.yml/runs?branch=main&per_page=1`, { headers: ghHeaders(token) });
556
+ } catch (e) {
557
+ return { state: 'unknown', http: null, detail: `workflow-runs threw: ${e && e.message}`, remediation: null };
558
+ }
559
+ if (runsRes.ok) {
560
+ return { state: 'readable', http: runsRes.status, detail: 'Actions readable; deploy-prod.yml present — the deploy gate is real', remediation: null };
561
+ }
562
+ if (runsRes.status === 404) {
563
+ return {
564
+ state: 'readable_no_workflow', http: 404,
565
+ detail: 'Actions readable and deploy-prod.yml is genuinely absent — the merge IS the land (ADR 0108 pin-bump publishing)',
566
+ remediation: null,
567
+ };
568
+ }
569
+ return {
570
+ state: 'blind', http: runsRes.status,
571
+ detail: `workflows list succeeded but deploy-prod.yml runs returned HTTP ${runsRes.status}`,
572
+ remediation: GRANT,
573
+ };
574
+ }
575
+
495
576
  // task 1547 — does the PR matched by getPublishStatus actually belong to THIS task?
496
577
  // The robust land proof is published-tip containment; this is the fallback used when
497
578
  // no published tip was recorded (a legacy row, or a publish in flight across the
@@ -1145,6 +1226,7 @@ module.exports = {
1145
1226
  GATE_OWNER_APPROVED_CONTEXT,
1146
1227
  landConfirmed,
1147
1228
  deployProbeBlind,
1229
+ probeDeployVisibility,
1148
1230
  landProvenForTask,
1149
1231
  prReferencesTask,
1150
1232
  findTaskPullRequest,
@@ -0,0 +1,30 @@
1
+ -- provisioning_006_liveness.sql — the VERIFICATION OVERLAY on an instance's
2
+ -- lifecycle (task 1002696; docs/design/projects-surfaces-redesign-direction.md §3).
3
+ --
4
+ -- WHY. `status` records what the RUNNER did, not what the instance actually does:
5
+ -- once a standup script finished, a row stays 'active' forever and no later
6
+ -- observation can ever correct it. The projects surfaces then assert "live" for an
7
+ -- instance nobody has spoken to since the day it was created — the walk's core
8
+ -- truthfulness failure. These two columns record what a probe actually SAW, so the
9
+ -- UI can carry recency ("live · checked 2m ago") and say "unreachable" when the
10
+ -- platform knows better.
11
+ --
12
+ -- OVERLAY, NOT A STATE. Verification never becomes a seventh lifecycle status: the
13
+ -- runner's six states are untouched, an unreachable instance is still 'active', and
14
+ -- nothing in the heartbeat path writes `status`. Both columns are NULLABLE with no
15
+ -- default on purpose — NULL means "never probed", which is a different claim from
16
+ -- last_verified_up=false ("probed, and it did not answer"), and the display contract
17
+ -- renders those differently (live? vs unreachable).
18
+ --
19
+ -- Adding nullable columns is ADDITIVE: no existing row changes, nothing is lost, and
20
+ -- every row correctly backfills to "never probed" (it never was). Safe under the
21
+ -- module-owned-migration rule (ADR 0083 §Decision #5). Idempotent: safe to re-run.
22
+
23
+ ALTER TABLE provisioning_instances
24
+ ADD COLUMN IF NOT EXISTS last_verified_at timestamptz;
25
+
26
+ ALTER TABLE provisioning_instances
27
+ ADD COLUMN IF NOT EXISTS last_verified_up boolean;
28
+
29
+ INSERT INTO schema_migrations (version) VALUES ('provisioning_006_liveness')
30
+ ON CONFLICT (version) DO NOTHING;
@@ -11,7 +11,8 @@
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
13
  "routes": ["provisioning"],
14
- "migrations": true
14
+ "migrations": true,
15
+ "pollers": ["liveness-sweep"]
15
16
  },
16
17
  "provides": ["provisioning.instanceState"],
17
18
  "consumes": []
@@ -21,19 +21,29 @@
21
21
  // hall address when the renderer knows the domain, and falls back to the `bongos dev`
22
22
  // local-hall one-liner when it doesn't — the hall is the point of creating a project,
23
23
  // so the sequence must end there, not at brand.
24
+ //
25
+ // `shapes` (task 1002696; direction §7 — a surface renders only steps somebody real
26
+ // will perform on THIS instance's shape). The control-plane shapes (standalone /
27
+ // co-tenant / dedicated) have the platform's runner performing every 'auto' leg;
28
+ // 'self-host' has no runner, so the runner-performed legs are absent from its plan —
29
+ // a self-host form must never promise them (QA walk finding, task 1002717). Steps
30
+ // with no `shapes` apply everywhere. Filter with stepsForShape().
31
+ const CONTROL_PLANE_SHAPES = ['standalone', 'co-tenant', 'dedicated'];
32
+ const ALL_SHAPES = [...CONTROL_PLANE_SHAPES, 'self-host'];
33
+
24
34
  function onboardPlan(mode = 'greenfield', opts = {}) {
25
35
  const adopt = mode === 'adopt';
26
36
  const domain = typeof opts.domain === 'string' && opts.domain.trim() ? opts.domain.trim() : null;
27
37
  return [
28
38
  { key: 'name-address', kind: 'auto', label: 'Decide name + address (interview)' },
29
39
  { key: 'github-repo', kind: 'human', label: adopt ? 'Use your EXISTING GitHub repository' : 'Create the empty GitHub repository' },
30
- { key: 'dns-preflight', kind: 'auto', label: 'DNS token preflight — before the address step (task 1980)' },
40
+ { key: 'dns-preflight', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'DNS token preflight — before the address step (task 1980)' },
31
41
  { key: 'init', kind: 'auto', label: adopt ? 'bongos init --adopt — LAYER onto the existing repo (pre-flight + accept)' : 'bongos init — scaffold the instance config' },
32
42
  { key: 'scaffold', kind: 'auto', label: adopt ? 'Layer config + pinned @bongos/core onto the existing repo (no clobber)' : 'Scaffold the standalone repo (own repo + pinned @bongos/core)' },
33
- { key: 'provision', kind: 'auto', label: 'Provision core + DB + address + service (standalone, task 1978)' },
43
+ { key: 'provision', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Provision core + DB + address + service (standalone, task 1978)' },
34
44
  { key: 'seed-version', kind: 'auto', label: 'Seed the first version (V1) so the instance can take tasks immediately (task 2119)' },
35
45
  { key: 'oauth-app', kind: 'human', label: 'Wire GitHub sign-in — one-click GitHub App (task 2080), or a manual OAuth app' },
36
- { key: 'oauth-place', kind: 'auto', label: 'Place the OAuth secret — auto via the Manifest flow, or verified manual (task 1979)' },
46
+ { key: 'oauth-place', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Place the OAuth secret — auto via the Manifest flow, or verified manual (task 1979)' },
37
47
  { key: 'first-sign-in', kind: 'human', label: 'First sign-in (seats the founding Archon)' },
38
48
  { key: 'verify', kind: 'auto', label: 'Verify /healthz auth_configured' },
39
49
  { key: 'brand', kind: 'auto', label: 'Brand the instance look' },
@@ -43,4 +53,10 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
43
53
  ];
44
54
  }
45
55
 
46
- module.exports = { onboardPlan };
56
+ // A step with no `shapes` occurs on every shape; otherwise the list is exhaustive.
57
+ function stepsForShape(steps, shape) {
58
+ if (!shape || !ALL_SHAPES.includes(shape)) return steps;
59
+ return steps.filter((s) => !s.shapes || s.shapes.includes(shape));
60
+ }
61
+
62
+ module.exports = { onboardPlan, stepsForShape, ALL_SHAPES, CONTROL_PLANE_SHAPES };
@@ -0,0 +1,266 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/pollers/liveness-sweep.js — the heartbeat behind the
4
+ // VERIFICATION OVERLAY (task 1002696; docs/design/projects-surfaces-redesign-
5
+ // direction.md §3). The loader's startModulePollers requires this file and calls
6
+ // start(deps) at boot / stop() at shutdown, for the ENABLED module only — the
7
+ // platform-identity code-sweep / lifecycle publish-reconciler poller shape.
8
+ //
9
+ // WHY. `status` records what the RUNNER did. Once its script finished, a row sits
10
+ // at 'active' forever and no later observation can correct it, so the projects
11
+ // surfaces assert "live" for instances nobody has spoken to since creation. This
12
+ // sweep knocks on every active instance's /healthz and records what it actually
13
+ // saw, so the UI can carry recency and say "unreachable" when the platform knows
14
+ // better.
15
+ //
16
+ // THE ONE RULE: it NEVER writes `status`. Verification is an overlay on lifecycle,
17
+ // not a new lifecycle state — an unreachable instance is still 'active' (its
18
+ // teardown/revive story is unchanged), and only the runner authors lifecycle. The
19
+ // narrowness is enforced downstream too: recordLivenessProbe touches exactly the
20
+ // two overlay columns.
21
+ //
22
+ // Best-effort throughout: a probe failure IS the signal (down), and a DB/network
23
+ // hiccup logs and waits for the next sweep. Timers are unref'd so the heartbeat
24
+ // never holds the process open.
25
+
26
+ const dns = require('node:dns').promises;
27
+ const { Agent: UndiciAgent } = require('undici');
28
+ const api = require('../../../src/module-api');
29
+ const provisioning = require('../provisioning');
30
+
31
+ // ~5-minute cadence — matches the display contract's "fresh ≤ 2 cadences" window
32
+ // and is cheap: one HTTP GET per active instance. Override for tests/ops.
33
+ const INTERVAL_MS = Number(process.env.PROVISIONING_LIVENESS_MS) || 5 * 60_000;
34
+ // Jitter so a fleet of control planes (and a restarted one) never lands its probes
35
+ // on the same wall-clock second forever.
36
+ const JITTER_MS = Number(process.env.PROVISIONING_LIVENESS_JITTER_MS) || 30_000;
37
+ // A first sweep a few seconds after boot: a freshly-restarted control plane should
38
+ // not show a 5-minute-stale overlay on every card.
39
+ const FIRST_SWEEP_DELAY_MS = Number(process.env.PROVISIONING_LIVENESS_FIRST_MS) || 8_000;
40
+ // Short: a healthy instance answers /healthz in milliseconds, and a slow one is
41
+ // indistinguishable from down to the owner staring at the card.
42
+ const PROBE_TIMEOUT_MS = Number(process.env.PROVISIONING_LIVENESS_TIMEOUT_MS) || 5_000;
43
+ // Runaway guard, not an expected limit.
44
+ const SWEEP_LIMIT = 200;
45
+ // Keep the one-line account readable on a big fleet.
46
+ const MAX_REASONS_SHOWN = 8;
47
+
48
+ let timer = null;
49
+ let started = false;
50
+
51
+ // Where the heartbeat knocks. The public address when the instance has one — that
52
+ // IS what an owner's "live" claim means — else the recorded loopback port on this
53
+ // box (a co-tenant/standalone instance not yet addressed). normalizeHostname keeps
54
+ // a junk domain from producing a bogus URL. null ⇒ unprobeable: the sweep leaves
55
+ // such a row's overlay untouched rather than recording a verdict it never observed.
56
+ function healthzUrl({ domain, port } = {}) {
57
+ const host = provisioning.normalizeHostname(domain);
58
+ if (host) return `https://${host}/healthz`;
59
+ const p = Number(port);
60
+ return Number.isInteger(p) && p > 0 && p < 65536 ? `http://127.0.0.1:${p}/healthz` : null;
61
+ }
62
+
63
+ // ── SSRF guard (grader blocker, 2026-08-12). `domain` is builder-settable with
64
+ // syntax-only validation, and this sweep fetches it from the control plane's
65
+ // network position on a cadence — without a gate, a builder could point their
66
+ // instance at 169.254.169.254 (cloud metadata), 127.0.0.1, or an internal host
67
+ // and read the live/unreachable chip as a blind SSRF confirmation oracle.
68
+ // The gate: IP-literal domains and hostnames that RESOLVE to a private/reserved
69
+ // range are never probed and get NO verdict (constant behavior ⇒ no oracle).
70
+ // An unresolvable hostname still probes — the fetch's own ENOTFOUND records an
71
+ // honest "down", the common real-world case (a domain whose DNS isn't set up).
72
+ // Rebinding-proof: the gate returns the address it vetted and the probe PINS its
73
+ // connection to it (undici connect.lookup) — a builder-run nameserver answering
74
+ // differently per query cannot re-aim the fetch at a private target.
75
+ function isIpLiteral(host) {
76
+ return /^\d+\.\d+\.\d+\.\d+$/.test(host) || String(host).includes(':');
77
+ }
78
+ // Loopback, RFC1918, link-local/metadata, CGNAT, benchmarking, unspecified —
79
+ // plus v6 loopback/link-local/ULA and v4-mapped forms. Junk parses as private.
80
+ function isPrivateAddress(addr) {
81
+ const a = String(addr || '').toLowerCase();
82
+ if (a.includes(':')) {
83
+ if (a === '::' || a === '::1') return true;
84
+ if (a.startsWith('fe80:') || a.startsWith('fc') || a.startsWith('fd')) return true;
85
+ const mapped = a.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
86
+ return mapped ? isPrivateAddress(mapped[1]) : false;
87
+ }
88
+ const p = a.split('.').map(Number);
89
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
90
+ if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true;
91
+ if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;
92
+ if (p[0] === 169 && p[1] === 254) return true;
93
+ if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
94
+ if (p[0] === 192 && p[1] === 168) return true;
95
+ if (p[0] === 198 && (p[1] === 18 || p[1] === 19)) return true;
96
+ return false;
97
+ }
98
+ // allowed:false ⇒ do not probe (and record nothing). Only ip-literal and
99
+ // private-resolving hosts are refused; resolution failure falls through to the
100
+ // probe so ENOTFOUND stays an honest "down".
101
+ async function domainProbeGate(host, { lookup } = {}) {
102
+ if (isIpLiteral(host)) return { allowed: false, reason: 'ip-literal' };
103
+ try {
104
+ const addrs = await (lookup || dns.lookup)(host, { all: true, verbatim: true });
105
+ const list = Array.isArray(addrs) ? addrs.filter((x) => x && x.address) : [];
106
+ if (list.some((x) => isPrivateAddress(x.address))) return { allowed: false, reason: 'private-address' };
107
+ // hand back the vetted address so the probe can pin to it (no re-resolution)
108
+ return list.length ? { allowed: true, pin: list[0] } : { allowed: true };
109
+ } catch (e) {
110
+ return { allowed: true }; // unresolvable — the probe itself records the honest down
111
+ }
112
+ }
113
+
114
+ // Pin the connection to the address the gate vetted. undici resolves via this
115
+ // lookup instead of the OS resolver, so the fetch cannot land anywhere else —
116
+ // closing the resolve-then-fetch rebinding window (grader round 2).
117
+ function pinnedDispatcher(pin, timeoutMs) {
118
+ const family = pin.family === 6 ? 6 : 4;
119
+ return new UndiciAgent({
120
+ connect: {
121
+ timeout: timeoutMs,
122
+ // undici calls this with { all: true }, which wants the array form;
123
+ // honor both so the shim is correct either way.
124
+ lookup: (_hostname, opts, cb) => (opts && opts.all
125
+ ? cb(null, [{ address: pin.address, family }])
126
+ : cb(null, pin.address, family)),
127
+ },
128
+ });
129
+ }
130
+
131
+ // The owner-facing shorthand for why a probe failed — a connection code
132
+ // ('ECONNREFUSED'), 'timeout', or the HTTP status. Only ever a log detail.
133
+ function probeReason(err) {
134
+ if (!err) return 'error';
135
+ if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout';
136
+ const code = (err.cause && err.cause.code) || err.code;
137
+ if (code) return String(code);
138
+ return err.message ? String(err.message).slice(0, 40) : 'error';
139
+ }
140
+
141
+ // Knock once. Any 2xx = up; everything else (non-2xx, refused, DNS, TLS, timeout)
142
+ // = down with a reason. Never throws — a probe result is the whole point.
143
+ async function probeHealthz(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl, pin } = {}) {
144
+ const doFetch = fetchImpl || globalThis.fetch;
145
+ const dispatcher = pin && !fetchImpl ? pinnedDispatcher(pin, timeoutMs) : null;
146
+ try {
147
+ const res = await doFetch(url, {
148
+ method: 'GET',
149
+ // manual: a redirect must never re-aim the probe (SSRF pivot) — and a
150
+ // healthz that redirects isn't answering anyway.
151
+ redirect: 'manual',
152
+ signal: AbortSignal.timeout(timeoutMs),
153
+ headers: { accept: 'application/json, text/plain;q=0.9, */*;q=0.8' },
154
+ ...(dispatcher ? { dispatcher } : {}),
155
+ });
156
+ if (res.status >= 200 && res.status < 300) return { up: true };
157
+ return { up: false, reason: `http ${res.status}` };
158
+ } catch (e) {
159
+ return { up: false, reason: probeReason(e) };
160
+ } finally {
161
+ if (dispatcher) dispatcher.close().catch(() => {});
162
+ }
163
+ }
164
+
165
+ // The per-sweep disposition line (the publish-reconciler's one-line account).
166
+ // PURE, so its wording is unit-tested without a sweep:
167
+ // [liveness] swept 6 active: 4 up · 2 down (mercury:ECONNREFUSED, swag:timeout)
168
+ function formatSweepLine({ swept = 0, up = 0, down = 0, unprobeable = 0, blocked = 0, blockedReasons = [], reasons = [] } = {}) {
169
+ const shown = reasons.slice(0, MAX_REASONS_SHOWN).join(', ');
170
+ const more = reasons.length > MAX_REASONS_SHOWN ? `, +${reasons.length - MAX_REASONS_SHOWN} more` : '';
171
+ const detail = reasons.length ? ` (${shown}${more})` : '';
172
+ const unaddressed = unprobeable ? ` · ${unprobeable} unaddressed` : '';
173
+ const blockedPart = blocked
174
+ ? ` · ${blocked} blocked${blockedReasons.length ? ` (${blockedReasons.slice(0, MAX_REASONS_SHOWN).join(', ')})` : ''}`
175
+ : '';
176
+ return `[liveness] swept ${swept} active: ${up} up · ${down} down${detail}${unaddressed}${blockedPart}`;
177
+ }
178
+
179
+ // One tick: probe every active instance and record what was seen. Exported +
180
+ // dependency-injected (pool, fetchImpl, timeoutMs, log) so it is driven directly
181
+ // by the tests — the timer is only a schedule.
182
+ async function sweepOnce(deps = {}) {
183
+ const db = deps.pool || api.pool;
184
+ const log = deps.log || console.log;
185
+ const rows = await provisioning.listActiveInstancesForLiveness(db, { limit: SWEEP_LIMIT });
186
+
187
+ const results = await Promise.all(rows.map(async (row) => {
188
+ const url = healthzUrl(row);
189
+ if (!url) return { row, probed: false };
190
+ // domain probes pass the SSRF gate first; a blocked row gets NO verdict —
191
+ // its overlay simply never advances (constant behavior, no oracle). The
192
+ // gate's vetted address is PINNED into the probe so DNS cannot change
193
+ // under us between the check and the connection.
194
+ let pin = null;
195
+ if (url.startsWith('https://')) {
196
+ const gate = await domainProbeGate(new URL(url).hostname, { lookup: deps.lookup });
197
+ if (!gate.allowed) return { row, probed: false, blocked: true, reason: gate.reason };
198
+ pin = gate.pin || null;
199
+ }
200
+ const probe = await probeHealthz(url, { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl, pin });
201
+ await provisioning.recordLivenessProbe(db, row.id, probe.up);
202
+ return { row, probed: true, ...probe };
203
+ }));
204
+
205
+ const probed = results.filter((r) => r.probed);
206
+ const downs = probed.filter((r) => !r.up);
207
+ const blocked = results.filter((r) => r.blocked);
208
+ const out = {
209
+ swept: rows.length,
210
+ up: probed.length - downs.length,
211
+ down: downs.length,
212
+ unprobeable: results.length - probed.length - blocked.length,
213
+ blocked: blocked.length,
214
+ blockedReasons: blocked.map((r) => `${r.row.slug}:${r.reason}`),
215
+ reasons: downs.map((r) => `${r.row.slug}:${r.reason || 'error'}`),
216
+ };
217
+ // Nothing active ⇒ nothing to say. A vanilla control plane would otherwise emit
218
+ // an empty account every five minutes forever.
219
+ if (rows.length) log(formatSweepLine(out));
220
+ return out;
221
+ }
222
+
223
+ // Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched by
224
+ // PROVISIONING_LIVENESS_DISABLED=1. Self-rescheduling rather than setInterval so
225
+ // each tick can carry its own jitter and a slow sweep never overlaps itself.
226
+ function start(deps = {}) {
227
+ if (process.env.PROVISIONING_LIVENESS_DISABLED === '1') {
228
+ console.log('[liveness] disabled (PROVISIONING_LIVENESS_DISABLED=1)');
229
+ return;
230
+ }
231
+ if (started) return;
232
+ started = true;
233
+ arm(deps, FIRST_SWEEP_DELAY_MS);
234
+ console.log(
235
+ `[liveness] started — verifying active instances every ~${Math.round(INTERVAL_MS / 1000)}s (${Math.round(PROBE_TIMEOUT_MS / 1000)}s probe timeout)`
236
+ );
237
+ }
238
+
239
+ function arm(deps, delay) {
240
+ timer = setTimeout(() => {
241
+ sweepOnce(deps)
242
+ .catch((e) => console.error(`[liveness] sweep crashed: ${e && e.message ? e.message : e}`))
243
+ .then(() => { if (started) arm(deps, INTERVAL_MS + Math.floor(Math.random() * JITTER_MS)); });
244
+ }, delay);
245
+ if (timer.unref) timer.unref();
246
+ }
247
+
248
+ function stop() {
249
+ started = false; // an in-flight sweep must not re-arm after this
250
+ if (timer) { clearTimeout(timer); timer = null; }
251
+ }
252
+
253
+ module.exports = {
254
+ start,
255
+ stop,
256
+ // exported for tests
257
+ sweepOnce,
258
+ probeHealthz,
259
+ healthzUrl,
260
+ formatSweepLine,
261
+ isPrivateAddress,
262
+ isIpLiteral,
263
+ domainProbeGate,
264
+ INTERVAL_MS,
265
+ PROBE_TIMEOUT_MS,
266
+ };
@@ -146,6 +146,12 @@ function publicInstance(row) {
146
146
  // thing you need when diagnosing a standup (task 1002562). Coalesced for rows read
147
147
  // before provisioning_004 added the column.
148
148
  onboard_mode: row.onboard_mode || 'greenfield',
149
+ // The verification overlay (task 1002696, redesign direction §3): what the
150
+ // heartbeat last OBSERVED, which is NOT what `status` claims. null = never
151
+ // probed — a different claim from last_verified_up=false, and the UI renders
152
+ // them differently ("live?" vs "unreachable"). Never collapse the two.
153
+ last_verified_at: row.last_verified_at || null,
154
+ last_verified_up: row.last_verified_up == null ? null : !!row.last_verified_up,
149
155
  cost_estimate_usd: Number(row.cost_estimate_usd || 0),
150
156
  error_note: row.error_note || null,
151
157
  created_at: row.created_at,
@@ -285,6 +291,41 @@ async function updateInstanceDomain(db, id, domain) {
285
291
  return rows[0] || null;
286
292
  }
287
293
 
294
+ // ── Verification overlay (task 1002696, redesign direction §3) ──────────────
295
+ // Liveness is an OVERLAY on the lifecycle, never a seventh state. These two
296
+ // functions are the whole DB surface of the heartbeat: read what is worth
297
+ // probing, record what the probe saw. NEITHER writes `status` — the runner stays
298
+ // the only author of lifecycle, and an unreachable instance is still 'active'.
299
+
300
+ // The instances a sweep probes: the ACTIVE ones. Anything earlier has nothing
301
+ // standing up yet and anything later is being (or has been) reclaimed, so a probe
302
+ // against it would only ever record a false "down". `limit` is a runaway guard.
303
+ async function listActiveInstancesForLiveness(db, { limit = 500 } = {}) {
304
+ const { rows } = await db.query(
305
+ `SELECT id, slug, domain, port FROM provisioning_instances
306
+ WHERE status = 'active'
307
+ ORDER BY id
308
+ LIMIT $1`,
309
+ [Math.min(Math.max(Number(limit) || 500, 1), 2000)]
310
+ );
311
+ return rows;
312
+ }
313
+
314
+ // Record ONE probe observation. Deliberately narrow: it writes the two overlay
315
+ // columns and nothing else — not `status`, and not `updated_at` either, so a
316
+ // 5-minute heartbeat can never reshuffle the owner's updated_at-ordered card list
317
+ // or fake activity on an instance nobody touched.
318
+ async function recordLivenessProbe(db, id, up) {
319
+ const { rows } = await db.query(
320
+ `UPDATE provisioning_instances
321
+ SET last_verified_at = now(), last_verified_up = $2
322
+ WHERE id = $1
323
+ RETURNING id, last_verified_at, last_verified_up`,
324
+ [id, !!up]
325
+ );
326
+ return rows[0] || null;
327
+ }
328
+
288
329
  // The caller's own instances, newest first.
289
330
  async function listInstancesForOwner(db, ownerBuilderId) {
290
331
  const { rows } = await db.query(
@@ -610,6 +651,8 @@ module.exports = {
610
651
  // data access
611
652
  isProvisionedHostname,
612
653
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
654
+ // data access — verification overlay (task 1002696)
655
+ listActiveInstancesForLiveness, recordLivenessProbe,
613
656
  createInstance, setInstanceStatus, updateInstanceDomain,
614
657
  enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
615
658
  recordEvent, instanceStateForSlug, costLedger,
@@ -32,7 +32,7 @@ const api = require('../../../src/module-api');
32
32
  const auth = api;
33
33
  const { pool, validateOrRespond, parseId } = api;
34
34
  const provisioning = require('../provisioning');
35
- const { onboardPlan } = require('../onboard-plan');
35
+ const { onboardPlan, stepsForShape } = require('../onboard-plan');
36
36
  const { makeAskRateLimiter } = require('../rate-limit');
37
37
 
38
38
  // How long a GitHub-App-Manifest `state` token is valid — the owner must click the
@@ -622,7 +622,11 @@ module.exports = function provisioningRoutes() {
622
622
  router.get('/provisioning/onboard-plan', (req, res) => {
623
623
  const mode = req.query.mode === 'adopt' ? 'adopt' : 'greenfield';
624
624
  const domain = provisioning.normalizeHostname(req.query.domain) || null;
625
- res.json({ mode, steps: onboardPlan(mode, { domain }) });
625
+ // ?shape= filters to the steps that actually occur on that hosting shape
626
+ // (direction §7 — a self-host form must never promise the runner's legs);
627
+ // unknown/absent shape returns the full canonical sequence unchanged.
628
+ const shape = typeof req.query.shape === 'string' ? req.query.shape : null;
629
+ res.json({ mode, steps: stepsForShape(onboardPlan(mode, { domain }), shape) });
626
630
  });
627
631
 
628
632
  // GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST