@bongos/core 1.19.260 → 1.19.261
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 +33 -23
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +63 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +94 -91
- package/docs/copy-registry.json +179 -143
- package/modules/provisioning/provisioning.js +15 -4
- package/modules/provisioning/routes/provisioning.js +23 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +6 -0
- package/public-landing/projects.html +144 -6
- package/scripts/gds/copy-inventory.js +9 -5
- package/scripts/gds/provision.js +4 -4
- package/src/module-api.js +3 -1
- package/tests/projects_hub.mjs +67 -0
- package/tests/provisioning_teardown_states.mjs +176 -0
- package/tests/public_landing_projects.mjs +22 -0
|
@@ -43,10 +43,21 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
43
43
|
// domain-dependent standup legs for an already-ACTIVE instance that was just
|
|
44
44
|
// given its address via POST /provisioning/instances/:id/domain.
|
|
45
45
|
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach'];
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
46
|
+
// Every state a teardown can be asked for — i.e. everything except the one
|
|
47
|
+
// state where there is provably nothing left to release. DERIVED from
|
|
48
|
+
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
49
|
+
// whitelist ['provisioning','active','tearing_down'] silently excluded
|
|
50
|
+
// 'requested' and 'error', and since force-teardown reuses this same set there
|
|
51
|
+
// was NO path — owner or archon — to remove such a row; errored instances piled
|
|
52
|
+
// up permanently (instance 15 'swag' sat there from 2026-08-07). The premise
|
|
53
|
+
// that an errored row has "nothing to reclaim" was simply wrong: a provision
|
|
54
|
+
// that fails mid-run lands at 'error' AFTER it may have created the database,
|
|
55
|
+
// taken a port, written the systemd unit and the Caddy block, and UPSERTed the
|
|
56
|
+
// DNS record. teardownInstance's steps are individually allowFail, so it
|
|
57
|
+
// reclaims whatever actually exists and settles the row either way.
|
|
58
|
+
// Deriving it means a future 7th state is teardownable by default — the safe
|
|
59
|
+
// direction, since the failure mode being fixed here was a row with no exit.
|
|
60
|
+
const TEARDOWN_FROM = new Set(INSTANCE_STATES.filter((s) => s !== 'torn_down'));
|
|
50
61
|
// The states from which (re)enqueueing a 'provision' intent is meaningful. An
|
|
51
62
|
// 'active' instance needs no standup — blindly re-enqueueing one on a slug
|
|
52
63
|
// re-request left a stale open provision intent that then blocked the owner's
|
|
@@ -290,20 +290,37 @@ module.exports = function provisioningRoutes() {
|
|
|
290
290
|
}
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
+
// The ONE teardown-state refusal, shared by the owner route and the archon
|
|
294
|
+
// force route. It was duplicated, and the copies drifted: the owner's carried a
|
|
295
|
+
// plain sentence while force-teardown's used the positional res.fail form with
|
|
296
|
+
// no message at all, so its body returned the bare code "not_teardownable" as
|
|
297
|
+
// the human text (walk finding N5, §5.1's plain-sentence rule). One helper, one
|
|
298
|
+
// wording, no way to drift again. Returns true when it has answered.
|
|
299
|
+
function refusedAsTornDown(inst, res) {
|
|
300
|
+
if (provisioning.TEARDOWN_FROM.has(inst.status)) return false;
|
|
301
|
+
// res.fail's return value is not part of its contract — answer, then say so.
|
|
302
|
+
res.fail('not_teardownable', {
|
|
303
|
+
status: 409,
|
|
304
|
+
message: 'This project has already been torn down — its server is stopped and there is nothing left to release.',
|
|
305
|
+
details: { status: inst.status },
|
|
306
|
+
});
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
293
310
|
// POST /provisioning/instances/:id/force-teardown — Archon tears down ANY
|
|
294
311
|
// instance. Trust-safe by the same mechanism as the owner's own teardown: it
|
|
295
312
|
// ENQUEUES a 'teardown' intent for the control-plane runner; the web tier never
|
|
296
|
-
// holds cloud tokens.
|
|
297
|
-
//
|
|
313
|
+
// holds cloud tokens. Gated by 'provisioning.fleet.manage', whose floor is
|
|
314
|
+
// metic (ADR 0157 lowered it from archon) — "force" means ACROSS OWNERSHIP, not
|
|
315
|
+
// past the state machine: the state gate below is the same one the owner route
|
|
316
|
+
// uses. Declared among the archon routes (archon-first).
|
|
298
317
|
router.post('/provisioning/instances/:id/force-teardown', auth.requireBuilder, auth.requirePermission('provisioning.fleet.manage'), async (req, res) => {
|
|
299
318
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
300
319
|
if (!id) return;
|
|
301
320
|
try {
|
|
302
321
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
303
322
|
if (!inst) return res.fail('instance_not_found', 404);
|
|
304
|
-
if (
|
|
305
|
-
return res.fail('not_teardownable', 409, { status: inst.status });
|
|
306
|
-
}
|
|
323
|
+
if (refusedAsTornDown(inst, res)) return;
|
|
307
324
|
// Same truthful outcome mapping as the owner's teardown (task 1002691).
|
|
308
325
|
const r = await provisioning.enqueueTeardown(pool, id, `api:archon:${req.builder.id}`);
|
|
309
326
|
if (r.conflict) {
|
|
@@ -670,9 +687,7 @@ module.exports = function provisioningRoutes() {
|
|
|
670
687
|
if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
|
|
671
688
|
return res.fail('instance_not_found', 404);
|
|
672
689
|
}
|
|
673
|
-
if (
|
|
674
|
-
return res.fail('not_teardownable', { status: 409, message: `Instance is '${inst.status}' — nothing to tear down right now.`, details: { status: inst.status } });
|
|
675
|
-
}
|
|
690
|
+
if (refusedAsTornDown(inst, res)) return;
|
|
676
691
|
// enqueueTeardown supersedes a stale pending provision intent and reports each
|
|
677
692
|
// outcome distinctly, so "already queued" is only ever said when it is TRUE
|
|
678
693
|
// (task 1002691 — the old blind enqueue lied here and the instance leaked).
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.261",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.261",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.261",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -251,6 +251,12 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
|
|
|
251
251
|
.btn.small{font-size:13.5px;padding:8px 16px;}
|
|
252
252
|
.btn.danger{background:transparent;color:var(--err);box-shadow:none;border:2px solid color-mix(in srgb,var(--err) 35%,transparent);}
|
|
253
253
|
.btn.danger:hover{background:var(--err-soft);border-color:var(--err);}
|
|
254
|
+
/* a disabled danger button must LOOK disabled. .btn.danger is declared AFTER
|
|
255
|
+
.btn:disabled at equal specificity, so without these it keeps full-strength
|
|
256
|
+
--err and only the cursor says the control is inert — on a destructive button
|
|
257
|
+
that reads as "click me", which is the one thing it must not (task 1003042). */
|
|
258
|
+
.btn.danger:disabled{background:transparent;color:color-mix(in srgb,var(--ink) 40%,var(--bg-deep));border-color:color-mix(in srgb,var(--ink) 18%,transparent);box-shadow:none;cursor:not-allowed;transform:none;}
|
|
259
|
+
.btn.danger:disabled:hover{background:transparent;border-color:color-mix(in srgb,var(--ink) 18%,transparent);}
|
|
254
260
|
.btn .gh{width:17px;height:17px;fill:currentColor;}
|
|
255
261
|
.linklike{background:none;border:none;cursor:pointer;font-family:var(--font-body);font-weight:700;font-size:13.5px;color:var(--link-ink);text-decoration:underline;padding:6px 4px;}
|
|
256
262
|
|
|
@@ -636,9 +636,25 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
636
636
|
</details>
|
|
637
637
|
</div>
|
|
638
638
|
|
|
639
|
+
<!-- live since task 1003042: the teardown control, for a project in ANY
|
|
640
|
+
state that still holds something. The server used to refuse a
|
|
641
|
+
'requested' or an 'error' project outright — and force-teardown reused
|
|
642
|
+
the same guard — so a snagged project had no way out of its owner's
|
|
643
|
+
list at all (walk finding N5). This section is built for that case as
|
|
644
|
+
much as for a healthy one, which is why it asks for the project's own
|
|
645
|
+
name typed back: it is the only control on this page that ends
|
|
646
|
+
something, and the card's one-click confirm is too light for it. -->
|
|
639
647
|
<div class="card" id="mDelete">
|
|
640
648
|
<h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">Delete the project</h2>
|
|
641
|
-
<div
|
|
649
|
+
<div id="mDel"><p class="finePrint">Reading the project…</p></div>
|
|
650
|
+
<!-- the teardown-vs-delete distinction, stated once as a SIBLING of the
|
|
651
|
+
volatile slot (the #mRepoVisWhy shape) so no re-render can drop it.
|
|
652
|
+
Nothing on this page erases a project, and the copy says so rather
|
|
653
|
+
than letting the word "Delete" imply it. -->
|
|
654
|
+
<details class="finePrint" id="mDelWhat">
|
|
655
|
+
<summary>What this removes — and what it keeps</summary>
|
|
656
|
+
<p>Tearing down stops the project’s server and its nightly backups. It does <b>not</b> erase the project: it stays in your list marked <b>torn down</b> so you can bring it back, your repository on GitHub is untouched, and its database and existing backups are kept on purpose. There is no permanent-delete button — clearing a project’s database for good, and freeing its web address for another project, are done by hand today.</p>
|
|
657
|
+
</details>
|
|
642
658
|
</div>
|
|
643
659
|
</section>
|
|
644
660
|
</div>
|
|
@@ -3522,11 +3538,15 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3522
3538
|
return chipMarkup('', inst.status);
|
|
3523
3539
|
}
|
|
3524
3540
|
|
|
3525
|
-
/* client mirror of provisioning.TEARDOWN_FROM
|
|
3526
|
-
|
|
3541
|
+
/* client mirror of provisioning.TEARDOWN_FROM — every state EXCEPT torn_down
|
|
3542
|
+
(nothing left to release), minus tearing_down (already going) and minus a
|
|
3543
|
+
teardown that is already queued. It used to list only provisioning+active,
|
|
3544
|
+
which is how a snagged project ended up with no way out of the owner's list
|
|
3545
|
+
at all: its only button was "Try again" (walk finding N5, task 1003042). */
|
|
3527
3546
|
function canTeardown(inst) {
|
|
3528
3547
|
if (inst.open_intent && inst.open_intent.action === 'teardown') return false;
|
|
3529
|
-
|
|
3548
|
+
if (inst.status === 'tearing_down' || inst.status === 'torn_down') return false;
|
|
3549
|
+
return true;
|
|
3530
3550
|
}
|
|
3531
3551
|
/* client mirror of provisioning.DOMAIN_ATTACH_FROM */
|
|
3532
3552
|
/* the platform-assigned <slug>.<apex> for this instance, from the server-stamped
|
|
@@ -3651,9 +3671,16 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3651
3671
|
retention is deliberate on shared boxes; a dedicated machine takes its
|
|
3652
3672
|
database with it). Copy follows the runner, never the other way around. */
|
|
3653
3673
|
function teardownTruth(inst) {
|
|
3674
|
+
/* the address line is NOT decoration: teardown has no DNS or Caddy leg, so
|
|
3675
|
+
the hostname keeps resolving and serves an error page rather than going
|
|
3676
|
+
quiet. The old copy promised the address goes silent, which was simply
|
|
3677
|
+
untrue — and the rule above is that copy follows the runner. Releasing the
|
|
3678
|
+
address is tracked separately (task 1002897); until then "reserved" is
|
|
3679
|
+
both the honest word and the accurate one, since a revive re-uses exactly
|
|
3680
|
+
this hostname. */
|
|
3654
3681
|
return inst && inst.hosting_shape === 'dedicated'
|
|
3655
|
-
? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched.'
|
|
3656
|
-
: 'The server goes away
|
|
3682
|
+
? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched. Its web address stays reserved for this project and shows an error page until you bring it back.'
|
|
3683
|
+
: 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project and shows an error page until you bring it back.';
|
|
3657
3684
|
}
|
|
3658
3685
|
|
|
3659
3686
|
function addressRow(inst) {
|
|
@@ -3924,6 +3951,10 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3924
3951
|
/* a fact with no answer hides — never a dash (the card rule) */
|
|
3925
3952
|
$('mCreated').textContent = inst.created_at ? 'Created ' + listedDate(inst.created_at) : '';
|
|
3926
3953
|
$('mCreated').hidden = !inst.created_at;
|
|
3954
|
+
/* fetch-free like everything else here, so the once-a-minute tick may call
|
|
3955
|
+
it: the delete section reads only status + open_intent, both already in
|
|
3956
|
+
hand, and a queued teardown must show up without a reload */
|
|
3957
|
+
renderDelete(inst);
|
|
3927
3958
|
}
|
|
3928
3959
|
|
|
3929
3960
|
function loadManage(quiet) {
|
|
@@ -4093,6 +4124,113 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
4093
4124
|
if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
|
|
4094
4125
|
});
|
|
4095
4126
|
|
|
4127
|
+
/* ── delete the project (task 1003042): the manage page's teardown control.
|
|
4128
|
+
It renders from status + open_intent only — no second request — so the
|
|
4129
|
+
minute tick can repaint it and a queued teardown appears without a reload.
|
|
4130
|
+
Every outcome the server can answer is SAID here, refusals included: the
|
|
4131
|
+
1002691/1002692 rule, and the reason this section exists at all. ── */
|
|
4132
|
+
|
|
4133
|
+
/* the SLUG whose confirmation is open, or null — not a bare boolean: a flag
|
|
4134
|
+
would also suppress the repaint after navigating to a DIFFERENT project,
|
|
4135
|
+
leaving one project's confirm prompt on screen while the act below is bound
|
|
4136
|
+
to another. Keyed by slug, a different project always repaints. */
|
|
4137
|
+
var mDelAsking = null;
|
|
4138
|
+
|
|
4139
|
+
function delMsg(html) { $('mDel').innerHTML = html; }
|
|
4140
|
+
|
|
4141
|
+
function renderDelete(inst) {
|
|
4142
|
+
if (mDelAsking && mDelAsking === inst.slug) return; /* keep THIS project's open confirmation */
|
|
4143
|
+
if (inst.status === 'torn_down') {
|
|
4144
|
+
delMsg('<div class="addr">This project is already torn down — its server is stopped.</div>' +
|
|
4145
|
+
'<p class="finePrint">Its repository, its database and its web address are all still here. ' +
|
|
4146
|
+
'Bring it back any time from <a href="?view=mine" data-nav-to="mine">your projects</a>.</p>');
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
if (inst.status === 'tearing_down') {
|
|
4150
|
+
delMsg('<div class="addr">This project is being torn down right now.</div>' +
|
|
4151
|
+
'<p class="finePrint">This page updates when it lands.</p>');
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
4154
|
+
if (inst.open_intent && inst.open_intent.action === 'teardown') {
|
|
4155
|
+
delMsg('<div class="addr">A teardown is queued for this project.</div>' +
|
|
4156
|
+
'<p class="finePrint">This page updates when it starts.</p>');
|
|
4157
|
+
return;
|
|
4158
|
+
}
|
|
4159
|
+
/* a project that never finished standing up still reaches here — that is
|
|
4160
|
+
the whole point of the widened gate; the consequence copy is the same */
|
|
4161
|
+
delMsg('<p class="plain">' + teardownTruth(inst) + '</p>' +
|
|
4162
|
+
'<div class="actions"><button type="button" class="btn small danger" data-del-ask>Tear it down</button></div>');
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
/* the project's own name, typed back — a second click is the card's bar, and
|
|
4166
|
+
this control ends a whole project rather than one setting */
|
|
4167
|
+
function askDelete() {
|
|
4168
|
+
var inst = manageInst;
|
|
4169
|
+
if (!inst) return;
|
|
4170
|
+
mDelAsking = inst.slug;
|
|
4171
|
+
delMsg('<div class="confirmRow"><span class="plain">Tear down <b>' + esc(inst.slug) + '</b>? ' +
|
|
4172
|
+
teardownTruth(inst) + ' Type the project’s name to confirm.</span></div>' +
|
|
4173
|
+
'<div class="attach">' +
|
|
4174
|
+
'<input type="text" id="mDelName" autocomplete="off" spellcheck="false" maxlength="40" ' +
|
|
4175
|
+
'aria-label="Type the project name to confirm" placeholder="' + esc(inst.slug) + '">' +
|
|
4176
|
+
'<button type="button" class="btn small danger" data-del-yes disabled>Yes, tear it down</button>' +
|
|
4177
|
+
'<button type="button" class="linklike" data-del-no>Keep it</button></div>');
|
|
4178
|
+
var field = $('mDelName');
|
|
4179
|
+
if (field) field.focus();
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
function delTypedMatches() {
|
|
4183
|
+
var field = $('mDelName');
|
|
4184
|
+
return !!(field && manageInst && field.value.trim() === manageInst.slug);
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
function doDelete() {
|
|
4188
|
+
var inst = manageInst;
|
|
4189
|
+
/* the typed name is this page's own gate, never the server's — so a click
|
|
4190
|
+
on a button that only LOOKS enabled cannot act */
|
|
4191
|
+
if (!inst || !delTypedMatches()) return;
|
|
4192
|
+
mDelAsking = null;
|
|
4193
|
+
delMsg('<p class="finePrint">Asking…</p>');
|
|
4194
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/teardown', {
|
|
4195
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}'
|
|
4196
|
+
}).then(function (res) {
|
|
4197
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
4198
|
+
if (res.ok) {
|
|
4199
|
+
var note = esc(data.message || 'Teardown requested.');
|
|
4200
|
+
if (data.superseded) note += ' (A stale standup request was cleared to make room.)';
|
|
4201
|
+
delMsg('<div class="projMsg ok">' + note + '</div>');
|
|
4202
|
+
loadManage(true); /* the status and intent this page renders just changed */
|
|
4203
|
+
return;
|
|
4204
|
+
}
|
|
4205
|
+
/* the still-true state first, then exactly what the server said */
|
|
4206
|
+
renderDelete(inst);
|
|
4207
|
+
$('mDel').insertAdjacentHTML('beforeend',
|
|
4208
|
+
res.status === 401
|
|
4209
|
+
? '<div class="projMsg err">' + signInAgain() + '</div>'
|
|
4210
|
+
: '<div class="projMsg err">' + esc(apiError(data, res.status).message ||
|
|
4211
|
+
'The teardown could not be queued — try again in a moment.') + '</div>');
|
|
4212
|
+
});
|
|
4213
|
+
}).catch(function () {
|
|
4214
|
+
renderDelete(inst);
|
|
4215
|
+
$('mDel').insertAdjacentHTML('beforeend',
|
|
4216
|
+
'<div class="projMsg err">Couldn’t reach the platform — check your connection and try again.</div>');
|
|
4217
|
+
});
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
$('mDel').addEventListener('input', function (ev) {
|
|
4221
|
+
if (!ev.target || ev.target.id !== 'mDelName') return;
|
|
4222
|
+
var yes = $('mDel').querySelector('[data-del-yes]');
|
|
4223
|
+
if (yes) yes.disabled = !delTypedMatches();
|
|
4224
|
+
});
|
|
4225
|
+
|
|
4226
|
+
$('mDel').addEventListener('click', function (ev) {
|
|
4227
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
4228
|
+
if (!t) return;
|
|
4229
|
+
if (t.hasAttribute('data-del-ask')) return askDelete();
|
|
4230
|
+
if (t.hasAttribute('data-del-yes')) return doDelete();
|
|
4231
|
+
if (t.hasAttribute('data-del-no')) { mDelAsking = null; if (manageInst) renderDelete(manageInst); }
|
|
4232
|
+
});
|
|
4233
|
+
|
|
4096
4234
|
/* ── teardown: ask, confirm (the truth, itemized — direction §5.4), then
|
|
4097
4235
|
SAY exactly what the server said ── */
|
|
4098
4236
|
|
|
@@ -40,14 +40,18 @@ const path = require('node:path');
|
|
|
40
40
|
const crypto = require('node:crypto');
|
|
41
41
|
|
|
42
42
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Repo-relative paths that end up INSIDE the checked-in artifacts are joined with
|
|
44
|
+
// '/', never path.join: CI compares the committed bytes, so a Windows run baking
|
|
45
|
+
// 'modules\hall-ui\public' in would rewrite ~1600 lines and never match again.
|
|
46
|
+
// (fs still resolves forward slashes on Windows, so reads are unaffected.)
|
|
47
|
+
const REGISTRY_REL = 'docs/copy-registry.json';
|
|
48
|
+
const REPORT_REL = 'docs/copy-inventory.md';
|
|
45
49
|
|
|
46
50
|
// The surfaces this tool knows how to read. `id` is the contract-shaped surface
|
|
47
51
|
// name; `at` is where it REALLY lives. Adding a surface is a data edit — the point.
|
|
48
52
|
const SURFACES = [
|
|
49
|
-
{ id: 'builders-hall', kind: 'dir', at:
|
|
50
|
-
{ id: 'status-dashboard', kind: 'dir', at:
|
|
53
|
+
{ id: 'builders-hall', kind: 'dir', at: 'modules/hall-ui/public', exts: ['.html', '.js'] },
|
|
54
|
+
{ id: 'status-dashboard', kind: 'dir', at: 'modules/status-ui/public', exts: ['.html', '.js'] },
|
|
51
55
|
// THE LANDING SURFACE IS public-landing/, NOT src/platform-server.js. The first
|
|
52
56
|
// cut pointed here at the server file, which only fs.readFileSync's these
|
|
53
57
|
// templates — so its JS patterns matched a handful of incidental literals and the
|
|
@@ -255,7 +259,7 @@ function filesFor(surface, { fsImpl = fs } = {}) {
|
|
|
255
259
|
return fsImpl.readdirSync(abs)
|
|
256
260
|
.filter((n) => surface.exts.includes(path.extname(n)))
|
|
257
261
|
.sort()
|
|
258
|
-
.map((n) =>
|
|
262
|
+
.map((n) => `${surface.at}/${n}`);
|
|
259
263
|
}
|
|
260
264
|
|
|
261
265
|
function buildRegistry() {
|
package/scripts/gds/provision.js
CHANGED
|
@@ -2049,10 +2049,10 @@ async function teardownInstance(inst, deps) {
|
|
|
2049
2049
|
boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
|
|
2050
2050
|
boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
|
|
2051
2051
|
boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
|
|
2052
|
-
//
|
|
2053
|
-
//
|
|
2054
|
-
|
|
2055
|
-
log(` (left for the operator:
|
|
2052
|
+
// Left for an operator (data is precious), naming ONLY what this row created —
|
|
2053
|
+
// teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
|
|
2054
|
+
const dumps = inst.db_name ? [`dropdb ${dbName(inst)}`, `prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz`] : [];
|
|
2055
|
+
log(` (left for the operator: ${dumps.concat([`remove the Caddy block for ${inst.slug} if one was written`]).join(', ')})`);
|
|
2056
2056
|
} else if (apply && deps.doApi && inst.droplet_id) {
|
|
2057
2057
|
await deps.doApi.deleteDroplet(inst.droplet_id);
|
|
2058
2058
|
}
|
package/src/module-api.js
CHANGED
|
@@ -947,8 +947,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
947
947
|
// landed since 1.19.258 with no explicit bump. run 32286866098. (task 1002620)
|
|
948
948
|
// 1.19.260 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
949
949
|
// landed since 1.19.259 with no explicit bump. run 32289043893. (task 1002620)
|
|
950
|
+
// 1.19.261 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
951
|
+
// landed since 1.19.260 with no explicit bump. run 32295699354. (task 1002620)
|
|
950
952
|
// ---------------------------------------------------------------------------
|
|
951
|
-
const CORE_VERSION = '1.19.
|
|
953
|
+
const CORE_VERSION = '1.19.261'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
952
954
|
|
|
953
955
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
954
956
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1052,3 +1052,70 @@ test('making a repo public asks first — exposure is never one accidental click
|
|
|
1052
1052
|
assert.match(SCRIPT, /data-repo-public-yes/, 'the confirm is a distinct second click');
|
|
1053
1053
|
assert.match(SCRIPT, /data-repo-public-no/, 'and declining is offered, not implied');
|
|
1054
1054
|
});
|
|
1055
|
+
|
|
1056
|
+
/* ── the delete section (task 1003042, walk finding N5) ─────────────────── */
|
|
1057
|
+
|
|
1058
|
+
test('the delete section is live markup with its body slot (C7)', () => {
|
|
1059
|
+
assert.match(HUB, /id="mDel"/, 'the volatile slot is real markup, not JS-built');
|
|
1060
|
+
assert.match(HUB, /id="mDelWhat"/, 'the teardown-vs-delete fold ships with the page');
|
|
1061
|
+
// the fold is a SIBLING of the volatile slot, so no re-render can drop it
|
|
1062
|
+
const card = (HUB.match(/<div class="card" id="mDelete">[\s\S]*?\n <\/div>/) || [''])[0];
|
|
1063
|
+
assert.ok(card, 'the delete card must be extractable');
|
|
1064
|
+
const slotAt = card.indexOf('id="mDel"');
|
|
1065
|
+
const foldAt = card.indexOf('id="mDelWhat"');
|
|
1066
|
+
assert.ok(slotAt > 0 && foldAt > slotAt, 'the fold sits outside the slot it must survive');
|
|
1067
|
+
assert.doesNotMatch(card, /isn’t wired up/, 'the placeholder copy is gone — the section is real now');
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test('"delete" never claims to erase — the fold states what is kept', () => {
|
|
1071
|
+
const fold = (HUB.match(/<details class="finePrint" id="mDelWhat">[\s\S]*?<\/details>/) || [''])[0];
|
|
1072
|
+
assert.ok(fold, 'the fold must be extractable');
|
|
1073
|
+
assert.match(fold, /does <b>not<\/b> erase the project/i, 'the distinction is stated, not implied');
|
|
1074
|
+
assert.match(fold, /torn down/, 'it names the state the project actually lands in');
|
|
1075
|
+
assert.match(fold, /repository on GitHub is untouched/, 'and what survives');
|
|
1076
|
+
assert.match(fold, /no permanent-delete button/i, 'the absent capability is admitted, not faked');
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
test('tearing a project down asks for its NAME — the page\'s heaviest control', () => {
|
|
1080
|
+
const fn = (SCRIPT.match(/function askDelete\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1081
|
+
assert.ok(fn, 'askDelete() must be extractable');
|
|
1082
|
+
assert.match(fn, /Type the project’s name to confirm/, 'the ask is stated in plain words');
|
|
1083
|
+
assert.match(fn, /teardownTruth\(inst\)/, 'and carries the consequence, from the one truth site');
|
|
1084
|
+
assert.match(fn, /id="mDelName"/, 'a typed confirmation, not just a second click');
|
|
1085
|
+
assert.match(fn, /data-del-yes disabled/, 'the act is unreachable until the name matches');
|
|
1086
|
+
assert.match(SCRIPT, /data-del-no/, 'declining is offered, not implied');
|
|
1087
|
+
// the typed value is re-checked at act time — an enabled-LOOKING button cannot act
|
|
1088
|
+
const doFn = (SCRIPT.match(/function doDelete\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1089
|
+
assert.match(doFn, /if \(!inst \|\| !delTypedMatches\(\)\) return;/,
|
|
1090
|
+
'the guard is re-evaluated on the click, never trusted from the disabled attribute');
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test('the delete section mirrors the server gate — every state but torn_down', () => {
|
|
1094
|
+
const fn = (SCRIPT.match(/function canTeardown\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1095
|
+
assert.ok(fn, 'canTeardown() must be extractable');
|
|
1096
|
+
// the old whitelist is what made a snagged project a dead end (N5)
|
|
1097
|
+
assert.doesNotMatch(fn, /inst\.status === 'provisioning' \|\| inst\.status === 'active'/,
|
|
1098
|
+
'the two-state whitelist is gone');
|
|
1099
|
+
assert.match(fn, /torn_down/, 'torn_down is the only lifecycle exclusion');
|
|
1100
|
+
const render = (SCRIPT.match(/function renderDelete\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1101
|
+
assert.ok(render, 'renderDelete() must be extractable');
|
|
1102
|
+
// keyed by PROJECT, not a bare flag: a boolean would also suppress the repaint
|
|
1103
|
+
// after navigating to a DIFFERENT project, leaving one project's confirm prompt
|
|
1104
|
+
// on screen while the act is bound to another
|
|
1105
|
+
assert.match(render, /if \(mDelAsking && mDelAsking === inst\.slug\) return;/,
|
|
1106
|
+
'the minute tick must never repaint over a half-typed confirmation, and the guard is project-scoped');
|
|
1107
|
+
for (const s of ['torn_down', 'tearing_down']) {
|
|
1108
|
+
assert.match(render, new RegExp(s), `${s} gets its own honest state, not the action`);
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test('a teardown does not silence the address — the copy says what the runner does', () => {
|
|
1113
|
+
const fn = (SCRIPT.match(/function teardownTruth\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1114
|
+
assert.ok(fn, 'teardownTruth() must be extractable');
|
|
1115
|
+
// the runner has NO DNS or Caddy leg, so the hostname keeps resolving and 502s
|
|
1116
|
+
assert.doesNotMatch(fn, /the address stops answering/,
|
|
1117
|
+
'the old claim was untrue — teardown releases neither the DNS record nor the Caddy block');
|
|
1118
|
+
assert.match(fn, /stays reserved for this project/,
|
|
1119
|
+
'the honest word, and the accurate one — a revive re-uses exactly this hostname');
|
|
1120
|
+
assert.match(fn, /repository is untouched/, 'what survives is still stated in both shapes');
|
|
1121
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// tests/provisioning_teardown_states.mjs
|
|
2
|
+
//
|
|
3
|
+
// The teardown STATE gate (task 1003042, walk finding N5). Sibling of
|
|
4
|
+
// provisioning_teardown_intent.mjs, which covers the intent-collision mapping;
|
|
5
|
+
// this file covers which STATES may be torn down at all, and how the one
|
|
6
|
+
// remaining refusal reads.
|
|
7
|
+
//
|
|
8
|
+
// The bug: TEARDOWN_FROM was a hand-kept whitelist, ['provisioning','active',
|
|
9
|
+
// 'tearing_down'], that silently omitted 'requested' and 'error'. Because the
|
|
10
|
+
// archon force-teardown route reuses the SAME set, there was no path — owner or
|
|
11
|
+
// archon — to remove a snagged project, so errored rows accumulated forever
|
|
12
|
+
// (instance 15 'swag' from 2026-08-07). Two things are pinned here:
|
|
13
|
+
//
|
|
14
|
+
// • the set is DERIVED from INSTANCE_STATES minus 'torn_down', so a future
|
|
15
|
+
// state is teardownable by default and cannot be silently excluded again;
|
|
16
|
+
// • the surviving refusal is a plain SENTENCE on BOTH routes. force-teardown
|
|
17
|
+
// used the positional res.fail(code, 409, details) form with no message,
|
|
18
|
+
// and envelope()'s `message || code` fallback then put the bare string
|
|
19
|
+
// "not_teardownable" on the wire as the human text (§5.1's plain-sentence
|
|
20
|
+
// rule). This test mounts the REAL attachFail, not a shim, so that exact
|
|
21
|
+
// regression is caught on the wire and not merely in the call shape.
|
|
22
|
+
//
|
|
23
|
+
// DB-free: the pool is never queried — the data-access fns the routes need are
|
|
24
|
+
// stubbed on the cached module object (the provisioning_teardown_intent.mjs
|
|
25
|
+
// pattern). No network beyond a loopback listener.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/provisioning_teardown_states.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import http from 'node:http';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
35
|
+
|
|
36
|
+
let passed = 0, failed = 0;
|
|
37
|
+
function t(name, fn) {
|
|
38
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
39
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
40
|
+
}
|
|
41
|
+
async function ta(name, fn) {
|
|
42
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── the constant itself ──────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
console.log('TEARDOWN_FROM — every state except the one with nothing left:');
|
|
49
|
+
|
|
50
|
+
t('every INSTANCE_STATE except torn_down may be torn down', () => {
|
|
51
|
+
for (const s of provisioning.INSTANCE_STATES) {
|
|
52
|
+
assert.equal(provisioning.TEARDOWN_FROM.has(s), s !== 'torn_down',
|
|
53
|
+
`${s} should ${s === 'torn_down' ? 'NOT ' : ''}be teardownable`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
t("'requested' and 'error' are IN — the two the old whitelist dropped (N5)", () => {
|
|
58
|
+
assert.equal(provisioning.TEARDOWN_FROM.has('requested'), true);
|
|
59
|
+
assert.equal(provisioning.TEARDOWN_FROM.has('error'), true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
t('the set is derived, not hand-listed — size tracks INSTANCE_STATES', () => {
|
|
63
|
+
assert.equal(provisioning.TEARDOWN_FROM.size, provisioning.INSTANCE_STATES.length - 1,
|
|
64
|
+
'a state added to INSTANCE_STATES must be teardownable by default, never silently excluded');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── the routes, over a real mounted router + the REAL error envelope ─────────
|
|
68
|
+
|
|
69
|
+
let currentStatus = 'active';
|
|
70
|
+
let teardownCalls = [];
|
|
71
|
+
provisioning.getInstanceById = async (_db, id) =>
|
|
72
|
+
({ id, owner_builder_id: 7, slug: 'swag', status: currentStatus });
|
|
73
|
+
provisioning.enqueueTeardown = async (_db, id, requestedBy) => {
|
|
74
|
+
teardownCalls.push({ id, requestedBy, status: currentStatus });
|
|
75
|
+
return { intent: { action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
76
|
+
};
|
|
77
|
+
provisioning.recordEvent = async () => {};
|
|
78
|
+
|
|
79
|
+
const api = require('../src/module-api.js');
|
|
80
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner', rank: 'archon' }; next(); };
|
|
81
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
82
|
+
|
|
83
|
+
const express = require('express');
|
|
84
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
85
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
86
|
+
const app = express();
|
|
87
|
+
app.use(express.json());
|
|
88
|
+
app.use(attachFail); // the REAL envelope — a shim would hide the bare-code bug
|
|
89
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
90
|
+
|
|
91
|
+
const server = app.listen(0);
|
|
92
|
+
await new Promise((r) => server.once('listening', r));
|
|
93
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
94
|
+
|
|
95
|
+
function post(path) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const req = http.request(`${base}${path}`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
100
|
+
}, (res) => {
|
|
101
|
+
let d = '';
|
|
102
|
+
res.on('data', (c) => { d += c; });
|
|
103
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
104
|
+
});
|
|
105
|
+
req.on('error', reject);
|
|
106
|
+
req.end('{}');
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const OWNER = '/provisioning/instances/15/teardown';
|
|
111
|
+
const FORCE = '/provisioning/instances/15/force-teardown';
|
|
112
|
+
|
|
113
|
+
console.log('\nEvery non-torn-down state is accepted — on BOTH routes:');
|
|
114
|
+
|
|
115
|
+
for (const state of ['requested', 'provisioning', 'active', 'tearing_down', 'error']) {
|
|
116
|
+
await ta(`owner teardown queues from '${state}'`, async () => {
|
|
117
|
+
currentStatus = state; teardownCalls = [];
|
|
118
|
+
const res = await post(OWNER);
|
|
119
|
+
assert.equal(res.status, 200, res.raw);
|
|
120
|
+
assert.equal(res.body.queued, true);
|
|
121
|
+
assert.equal(teardownCalls.length, 1, 'the intent was actually enqueued');
|
|
122
|
+
});
|
|
123
|
+
await ta(`force-teardown queues from '${state}'`, async () => {
|
|
124
|
+
currentStatus = state; teardownCalls = [];
|
|
125
|
+
const res = await post(FORCE);
|
|
126
|
+
assert.equal(res.status, 200, res.raw);
|
|
127
|
+
assert.equal(res.body.queued, true);
|
|
128
|
+
assert.equal(teardownCalls.length, 1);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await ta("the live repro: instance 15 'swag' sat at 'error' and could not be removed by ANYONE", async () => {
|
|
133
|
+
currentStatus = 'error';
|
|
134
|
+
teardownCalls = [];
|
|
135
|
+
const owner = await post(OWNER);
|
|
136
|
+
const force = await post(FORCE);
|
|
137
|
+
assert.equal(owner.status, 200, `owner teardown: ${owner.raw}`);
|
|
138
|
+
assert.equal(force.status, 200, `force-teardown: ${force.raw}`);
|
|
139
|
+
assert.equal(teardownCalls.length, 2, 'both roles now have a real path out');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.log("\nOnly 'torn_down' still refuses — and it refuses in a sentence:");
|
|
143
|
+
|
|
144
|
+
for (const [label, path] of [['owner teardown', OWNER], ['force-teardown', FORCE]]) {
|
|
145
|
+
await ta(`${label} refuses a torn-down project with 409 not_teardownable`, async () => {
|
|
146
|
+
currentStatus = 'torn_down'; teardownCalls = [];
|
|
147
|
+
const res = await post(path);
|
|
148
|
+
assert.equal(res.status, 409, res.raw);
|
|
149
|
+
assert.equal(res.body.error.code, 'not_teardownable');
|
|
150
|
+
assert.equal(res.body.error.details.status, 'torn_down', 'the real state is named');
|
|
151
|
+
assert.equal(teardownCalls.length, 0, 'nothing was enqueued');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await ta(`${label}'s message is a plain sentence, never the bare code (the N5 §5.1 violation)`, async () => {
|
|
155
|
+
currentStatus = 'torn_down';
|
|
156
|
+
const res = await post(path);
|
|
157
|
+
const m = String(res.body.error.message || '');
|
|
158
|
+
assert.notEqual(m, 'not_teardownable', 'the bare code must never be the human text');
|
|
159
|
+
assert.doesNotMatch(m, /_/, 'no snake_case identifier leaks into owner-facing copy');
|
|
160
|
+
assert.ok(/\s/.test(m) && /\.$/.test(m), `a real sentence, got: ${JSON.stringify(m)}`);
|
|
161
|
+
assert.match(m, /torn down/i, 'it says what is actually true of the project');
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await ta('both routes give the SAME refusal — the copies cannot drift again', async () => {
|
|
166
|
+
currentStatus = 'torn_down';
|
|
167
|
+
const owner = await post(OWNER);
|
|
168
|
+
const force = await post(FORCE);
|
|
169
|
+
assert.equal(owner.body.error.message, force.body.error.message);
|
|
170
|
+
assert.equal(owner.status, force.status);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
server.close();
|
|
174
|
+
|
|
175
|
+
console.log(`\nprovisioning_teardown_states: ${passed} passed, ${failed} failed`);
|
|
176
|
+
if (failed) process.exit(1);
|