@bongos/core 1.19.83 → 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.
- package/.bongos-core.json +49 -19
- package/docs/design/mocks/component-sheet.html +428 -0
- package/docs/design/mocks/projects-page-mock.html +167 -0
- package/docs/design/projects-surfaces-redesign-direction.md +3 -2
- package/modules/provisioning/migrations/provisioning_006_liveness.sql +30 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/onboard-plan.js +20 -4
- package/modules/provisioning/pollers/liveness-sweep.js +266 -0
- package/modules/provisioning/provisioning.js +43 -0
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +420 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +311 -0
- package/public-landing/create.html +253 -125
- package/public-landing/gate/index.html +3 -0
- package/public-landing/gate/space.css +11 -1
- package/public-landing/projects.html +298 -213
- package/src/module-api.js +3 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>/projects redesigned — all-in space, light/dark (task 1002696)</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=Quicksand:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap">
|
|
10
|
+
<!-- This mock links the REAL token/component layer the live pages use, so what
|
|
11
|
+
you review here is what ships. Toggle ☾/☀ in the header to see both modes. -->
|
|
12
|
+
<link rel="stylesheet" href="../../../public-landing/assets/cosmos.css">
|
|
13
|
+
<script>
|
|
14
|
+
try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')document.documentElement.setAttribute('data-mode',_m);}catch(e){}
|
|
15
|
+
</script>
|
|
16
|
+
<style>
|
|
17
|
+
.wrap{position:relative;z-index:1;max-width:880px;margin:0 auto;padding:0 clamp(16px,4vw,40px) 80px;}
|
|
18
|
+
.card{margin-bottom:26px;}
|
|
19
|
+
.mockNote{background:rgba(13,15,40,0.55);border:1.5px solid rgba(255,217,138,0.35);border-radius:16px;padding:12px 18px;font-size:13px;font-weight:600;color:var(--gold);line-height:1.5;margin-bottom:20px;backdrop-filter:blur(6px);}
|
|
20
|
+
h2.sub{font-family:var(--font-display);font-weight:700;font-size:19px;margin:0 0 6px;}
|
|
21
|
+
.subnote{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.55;margin-bottom:18px;max-width:70ch;}
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<div class="sky" aria-hidden="true">
|
|
26
|
+
<span class="star" style="top:2%;left:12%;width:2px;height:2px;"></span>
|
|
27
|
+
<span class="star" style="top:5%;left:28%;width:1.5px;height:1.5px;animation-delay:.7s;"></span>
|
|
28
|
+
<span class="star" style="top:3%;left:47%;width:2px;height:2px;animation-delay:1.4s;"></span>
|
|
29
|
+
<span class="star" style="top:8%;left:63%;width:1.5px;height:1.5px;animation-delay:.3s;"></span>
|
|
30
|
+
<span class="star" style="top:4%;left:78%;width:2.5px;height:2.5px;animation-delay:2s;"></span>
|
|
31
|
+
<span class="star" style="top:10%;left:88%;width:1.5px;height:1.5px;animation-delay:1s;"></span>
|
|
32
|
+
<span class="star" style="top:14%;left:20%;width:1.5px;height:1.5px;animation-delay:1.8s;"></span>
|
|
33
|
+
<span class="star" style="top:22%;left:55%;width:2px;height:2px;animation-delay:.5s;"></span>
|
|
34
|
+
<span class="star" style="top:34%;left:36%;width:1.5px;height:1.5px;animation-delay:2.4s;"></span>
|
|
35
|
+
<span class="star" style="top:30%;left:78%;width:1.5px;height:1.5px;animation-delay:1.2s;"></span>
|
|
36
|
+
<span class="star" style="top:48%;left:8%;width:2px;height:2px;animation-delay:.9s;"></span>
|
|
37
|
+
<span class="star" style="top:55%;left:90%;width:1.5px;height:1.5px;animation-delay:1.6s;"></span>
|
|
38
|
+
<span class="star" style="top:68%;left:24%;width:1.5px;height:1.5px;animation-delay:2.1s;"></span>
|
|
39
|
+
<span class="star" style="top:76%;left:64%;width:2px;height:2px;animation-delay:.4s;"></span>
|
|
40
|
+
<span class="star" style="top:88%;left:42%;width:1.5px;height:1.5px;animation-delay:1.9s;"></span>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<header class="top">
|
|
44
|
+
<a class="mark" href="#">cloud <span>bongos</span></a>
|
|
45
|
+
<div class="top-right">
|
|
46
|
+
<a class="btn-space" href="#">+ New project</a>
|
|
47
|
+
<button type="button" class="mode-toggle" id="modeToggle" title="Switch light / dark">☾</button>
|
|
48
|
+
<div class="who"><span>jaxri</span></div>
|
|
49
|
+
</div>
|
|
50
|
+
</header>
|
|
51
|
+
|
|
52
|
+
<div class="hero">
|
|
53
|
+
<h1>Your projects</h1>
|
|
54
|
+
<p class="lede">The same sky as the front door — every project you own is an <b>orb</b> here. Each card says what the platform last verified, and when.</p>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="wrap">
|
|
58
|
+
|
|
59
|
+
<div class="mockNote">Mock of the REAL page (linked to the live cosmos.css layer) — all-in space backdrop, light/dark via the ☾/☀ toggle or your OS setting. Fleet data faithful to Aug 12; raw notes paraphrased.</div>
|
|
60
|
+
|
|
61
|
+
<main class="card">
|
|
62
|
+
<ul class="projects">
|
|
63
|
+
|
|
64
|
+
<li class="proj">
|
|
65
|
+
<span class="orb" style="--acc:#fb923c" title="Open Spike Harbor"></span>
|
|
66
|
+
<div class="projBody">
|
|
67
|
+
<div class="projHead"><span class="projName">spike-harbor</span><span class="chip live"><span class="cdot"></span>live <span class="rec">· checked 2m ago</span></span></div>
|
|
68
|
+
<div class="projMeta"><span>repo <code>spike-harbor</code></span><span>its own server</span><span>back live since Aug 12</span></div>
|
|
69
|
+
<div class="addr">Answering at <a href="#">spike-harbor.cloudbongos.com</a></div>
|
|
70
|
+
<div class="actions"><button class="btn small ghost">Change address</button><button class="btn small danger">Tear it down</button></div>
|
|
71
|
+
</div>
|
|
72
|
+
</li>
|
|
73
|
+
|
|
74
|
+
<li class="proj">
|
|
75
|
+
<span class="orb ashen" style="--acc:#fb7185" title="swag"></span>
|
|
76
|
+
<div class="projBody">
|
|
77
|
+
<div class="projHead"><span class="projName">swag</span><span class="chip bad"><span class="cdot"></span>snagged <span class="rec">· since Aug 7</span></span></div>
|
|
78
|
+
<div class="projMeta"><span>repo <code>swag</code></span><span>its own server</span></div>
|
|
79
|
+
<div class="projMsg err">We couldn’t push the starter code to the repository — it wasn’t found on GitHub. We stopped trying on Aug 7 — nothing more will happen until you try again.
|
|
80
|
+
<details><summary>Technical details</summary><pre>Command failed: git -C /srv/cloudbongos/swag push -u origin main
|
|
81
|
+
ERROR: Repository not found.</pre></details>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="actions"><button class="btn small">Try again</button><button class="btn small danger">Tear it down</button></div>
|
|
84
|
+
</div>
|
|
85
|
+
</li>
|
|
86
|
+
|
|
87
|
+
<li class="proj gone">
|
|
88
|
+
<span class="orb ghost" style="--acc:#38bdf8" title="jaxri-spike"></span>
|
|
89
|
+
<div class="projBody">
|
|
90
|
+
<div class="projHead"><span class="projName">jaxri-spike</span><span class="chip calm"><span class="cdot"></span>torn down <span class="rec">· Aug 11</span></span></div>
|
|
91
|
+
<div class="projMeta"><span>repo <code>jaxri-spike</code></span><span>shared server</span></div>
|
|
92
|
+
<div class="projMsg">Torn down Aug 11. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="#">start another</a>.</div>
|
|
93
|
+
<div class="actions"><button class="btn small">Bring it back</button></div>
|
|
94
|
+
</div>
|
|
95
|
+
</li>
|
|
96
|
+
|
|
97
|
+
</ul>
|
|
98
|
+
|
|
99
|
+
<p class="finePrint">Buttons here ask the platform to act — a runner does the real work, and each card reports only what was verified, with a timestamp. Standups keep running even with this tab closed.</p>
|
|
100
|
+
</main>
|
|
101
|
+
|
|
102
|
+
<main class="card">
|
|
103
|
+
<h2 class="sub">Also in today's fleet — states the design must absorb</h2>
|
|
104
|
+
<p class="subnote">These exist in the live system right now under other accounts.</p>
|
|
105
|
+
|
|
106
|
+
<ul class="projects">
|
|
107
|
+
|
|
108
|
+
<li class="proj">
|
|
109
|
+
<span class="orb dim" style="--acc:#f6c976" title="charter"></span>
|
|
110
|
+
<div class="projBody">
|
|
111
|
+
<div class="projHead"><span class="projName">charter</span><span class="chip maybe"><span class="cdot"></span>live? <span class="rec">· last seen Jul 25</span></span></div>
|
|
112
|
+
<div class="projMeta"><span>repo <code>charter</code></span><span>its own server</span></div>
|
|
113
|
+
<div class="addr"><span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one.</div>
|
|
114
|
+
<div class="attach"><input type="text" placeholder="myproject.example.com"><button class="btn small">Give it an address</button></div>
|
|
115
|
+
<div class="actions"><button class="btn small danger">Tear it down</button></div>
|
|
116
|
+
</div>
|
|
117
|
+
</li>
|
|
118
|
+
|
|
119
|
+
<li class="proj">
|
|
120
|
+
<span class="orb" style="--acc:#38bdf8;--glow:rgba(56,189,248,.5)" title="Open hermeslines-marketing"></span>
|
|
121
|
+
<div class="projBody">
|
|
122
|
+
<div class="projHead"><span class="projName">hermeslines-marketing</span><span class="chip live"><span class="cdot"></span>live <span class="rec">· checked 4m ago</span></span></div>
|
|
123
|
+
<div class="projMeta"><span>repo <code>hermeslines-marketing</code></span><span>its own server</span><span>adopted repo</span></div>
|
|
124
|
+
<div class="addr">Answering at <a href="#">hermeslines-marketing.cloudbongos.com</a></div>
|
|
125
|
+
<div class="actions"><button class="btn small ghost">Change address</button><button class="btn small danger">Tear it down</button></div>
|
|
126
|
+
</div>
|
|
127
|
+
</li>
|
|
128
|
+
|
|
129
|
+
<li class="proj">
|
|
130
|
+
<span class="orb ashen" style="--acc:#8b7ec8" title="trial"></span>
|
|
131
|
+
<div class="projBody">
|
|
132
|
+
<div class="projHead"><span class="projName">trial</span><span class="chip bad"><span class="cdot"></span>snagged <span class="rec">· since Jul 17</span></span></div>
|
|
133
|
+
<div class="projMeta"><span>repo <code>trial</code></span><span>its own server</span></div>
|
|
134
|
+
<div class="projMsg err">We couldn’t push the starter code — the account that set it up doesn’t have permission. We stopped trying on Jul 17 — nothing more will happen until you try again.
|
|
135
|
+
<details><summary>Technical details</summary><pre>Command failed: git -C /srv/cloudbongos/trial push -u origin main
|
|
136
|
+
ERROR: Permission denied to the provisioning account.</pre></details>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="actions"><button class="btn small">Try again</button><button class="btn small danger">Tear it down</button></div>
|
|
139
|
+
</div>
|
|
140
|
+
</li>
|
|
141
|
+
|
|
142
|
+
</ul>
|
|
143
|
+
</main>
|
|
144
|
+
|
|
145
|
+
<main class="card">
|
|
146
|
+
<h2 class="sub">Empty state — a first-timer's landing</h2>
|
|
147
|
+
<div class="empty">
|
|
148
|
+
<p>You don't own any projects yet. Creating one takes about five minutes — name it, pick a home for its code, and it gets an address of its own in this sky.</p>
|
|
149
|
+
<a class="btn" href="#">Create your first project</a>
|
|
150
|
+
</div>
|
|
151
|
+
</main>
|
|
152
|
+
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div class="foot">cloud bongos · your projects live at addresses like <code>name.cloudbongos.com</code></div>
|
|
156
|
+
|
|
157
|
+
<script>
|
|
158
|
+
(function(){
|
|
159
|
+
function resolved(){var m=document.documentElement.getAttribute('data-mode');if(m==='light'||m==='dark')return m;return (window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';}
|
|
160
|
+
var t=document.getElementById('modeToggle');
|
|
161
|
+
function paint(){t.textContent=resolved()==='dark'?'☀':'☾';}
|
|
162
|
+
t.addEventListener('click',function(){var next=resolved()==='dark'?'light':'dark';document.documentElement.setAttribute('data-mode',next);try{localStorage.setItem('cb-color-mode',next);}catch(e){}paint();});
|
|
163
|
+
paint();
|
|
164
|
+
})();
|
|
165
|
+
</script>
|
|
166
|
+
</body>
|
|
167
|
+
</html>
|
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|