@bongos/core 1.19.953 → 1.19.955
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 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +80 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +334 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +156 -151
- package/docs/copy-registry.json +238 -192
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/env-manifest.js +215 -0
- package/modules/provisioning/migrations/provisioning_023_env_manifest.sql +67 -0
- package/modules/provisioning/module.json +20 -5
- package/modules/provisioning/routes/env-manifest.js +201 -0
- package/modules/public-landing/public/projects.html +157 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/provisioning_env_manifest.mjs +435 -0
|
@@ -871,6 +871,22 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
871
871
|
</details>
|
|
872
872
|
</div>
|
|
873
873
|
|
|
874
|
+
<!-- The project's ENVIRONMENT VARIABLE MANIFEST (task 1004062, ADR 0323 decision 6).
|
|
875
|
+
NAMES and purposes only. There is deliberately no value field anywhere in this
|
|
876
|
+
card, and the API refuses one — the values live where the running application
|
|
877
|
+
reads them, on the owner's deployment platform, which already encrypts, audits
|
|
878
|
+
and rotates them. The fold says so in the owner's words, because a form about
|
|
879
|
+
environment variables with no value box is otherwise read as unfinished. -->
|
|
880
|
+
<div class="card mSec" id="mEnvVars">
|
|
881
|
+
<h2>Its environment variables</h2>
|
|
882
|
+
<div id="mEnv"><p class="finePrint">Reading the project…</p></div>
|
|
883
|
+
<details class="finePrint" id="mEnvWhat">
|
|
884
|
+
<summary>Why there is nowhere to type the value</summary>
|
|
885
|
+
<p>This is a list of which variables your project needs and what each one is for — not the secrets themselves. The values belong wherever your project actually runs (Vercel, Netlify, Render, or your own machine): that is where the running app reads them, and it already keeps them encrypted, logs who touched them and lets you rotate them. Keeping a second copy here would make this platform the keeper of your credentials and buy you nothing.</p>
|
|
886
|
+
<p>What the list <i>does</i> buy you: anyone building on the project — you, a teammate, or an AI builder writing code — can see the exact names to use and what each is for, so nothing is invented and nothing is missed at setup.</p>
|
|
887
|
+
</details>
|
|
888
|
+
</div>
|
|
889
|
+
|
|
874
890
|
<!-- live since task 1002346 (BV1.R27): the way to invite a builder AFTER
|
|
875
891
|
creation. The wizard's invite step is skippable, and — the mModules
|
|
876
892
|
argument beside it — an optional step is a default rather than a dead end
|
|
@@ -5598,6 +5614,11 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5598
5614
|
answer the widget does not ask for itself. */
|
|
5599
5615
|
setInviteSlug('mInvite', inst.slug || '');
|
|
5600
5616
|
loadInviteSuggestions('mInvite', inst.slug ? inst.id : '');
|
|
5617
|
+
/* the env-var manifest (task 1004062) is the one section here that FETCHES, so it is
|
|
5618
|
+
guarded against the once-a-minute tick: a re-render mid-edit would throw away what
|
|
5619
|
+
the owner is typing, and the manifest cannot change under them without their doing
|
|
5620
|
+
it. Only a different project forces a reload. */
|
|
5621
|
+
loadEnvManifest(inst);
|
|
5601
5622
|
/* the page names its project — the heading holds #mName; and in the tab */
|
|
5602
5623
|
document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
|
|
5603
5624
|
/* the card's own story, on the project's own page (task 1003232): the
|
|
@@ -7160,6 +7181,142 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7160
7181
|
|
|
7161
7182
|
function discMsg(html) { $('mDisc').innerHTML = html; }
|
|
7162
7183
|
|
|
7184
|
+
/* ── Its environment variables (task 1004062, ADR 0323 decision 6) ──────────────
|
|
7185
|
+
NAMES and purposes; never a value. There is no value input in this card and the API
|
|
7186
|
+
refuses one, so there is nothing here that could carry a secret even by mistake. */
|
|
7187
|
+
var mEnvSlug = null; /* which project the loaded list belongs to */
|
|
7188
|
+
var mEnvVars = null; /* the declarations, or null before the first read */
|
|
7189
|
+
var mEnvAdding = false; /* the add form is open — the tick must not wipe it */
|
|
7190
|
+
var mEnvEditing = null; /* the id being edited, for the same reason */
|
|
7191
|
+
|
|
7192
|
+
function envMsg(html) { var el = $('mEnv'); if (el) el.innerHTML = html; }
|
|
7193
|
+
|
|
7194
|
+
function loadEnvManifest(inst) {
|
|
7195
|
+
if (!inst || !inst.id) return;
|
|
7196
|
+
/* Same project, already read, and the owner is mid-edit → leave the DOM alone. */
|
|
7197
|
+
if (mEnvSlug === inst.slug && mEnvVars && (mEnvAdding || mEnvEditing)) return;
|
|
7198
|
+
if (mEnvSlug === inst.slug && mEnvVars) { renderEnvManifest(); return; }
|
|
7199
|
+
mEnvSlug = inst.slug; mEnvVars = null; mEnvAdding = false; mEnvEditing = null;
|
|
7200
|
+
envMsg('<p class="finePrint">Reading the list…</p>');
|
|
7201
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/env-manifest', { credentials: 'same-origin' })
|
|
7202
|
+
.then(function (res) {
|
|
7203
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
7204
|
+
if (!res.ok) {
|
|
7205
|
+
envMsg('<div class="projMsg err">' + (res.status === 401 ? signInAgain()
|
|
7206
|
+
: esc(apiError(data, res.status).message || 'The list could not be read — try again.')) + '</div>');
|
|
7207
|
+
return;
|
|
7208
|
+
}
|
|
7209
|
+
mEnvVars = data.env_vars || [];
|
|
7210
|
+
renderEnvManifest();
|
|
7211
|
+
});
|
|
7212
|
+
})
|
|
7213
|
+
.catch(function () { envMsg('<div class="projMsg err">Couldn\u2019t reach the platform — check your connection and try again.</div>'); });
|
|
7214
|
+
}
|
|
7215
|
+
|
|
7216
|
+
function envWhere(list) {
|
|
7217
|
+
/* All three reads as "everywhere" rather than listing them: the list is the common
|
|
7218
|
+
case, and spelling it out three times per row buries the ones that differ. */
|
|
7219
|
+
if (!list || !list.length || list.length === 3) return 'everywhere';
|
|
7220
|
+
return list.join(', ');
|
|
7221
|
+
}
|
|
7222
|
+
|
|
7223
|
+
function envRowHtml(v) {
|
|
7224
|
+
if (mEnvEditing === v.id) {
|
|
7225
|
+
return '<div class="attach" data-env-row="' + esc(v.id) + '">' +
|
|
7226
|
+
'<input type="text" class="envDesc" value="' + esc(v.description || '') + '" maxlength="2000" ' +
|
|
7227
|
+
'aria-label="What ' + esc(v.name) + ' is for" placeholder="What is it for?">' +
|
|
7228
|
+
'<button type="button" class="btn small" data-env-save="' + esc(v.id) + '">Save</button>' +
|
|
7229
|
+
'<button type="button" class="linklike" data-env-cancel>Cancel</button></div>';
|
|
7230
|
+
}
|
|
7231
|
+
return '<div class="addr" data-env-row="' + esc(v.id) + '"><b>' + esc(v.name) + '</b>' +
|
|
7232
|
+
(v.required ? '' : ' <span class="finePrint">(optional)</span>') +
|
|
7233
|
+
' <span class="finePrint">' + esc(envWhere(v.environments)) + '</span><br>' +
|
|
7234
|
+
'<span class="plain">' + (v.description ? esc(v.description) : '<i>no purpose written yet</i>') + '</span> ' +
|
|
7235
|
+
'<button type="button" class="linklike" data-env-edit="' + esc(v.id) + '">Edit</button> ' +
|
|
7236
|
+
'<button type="button" class="linklike" data-env-del="' + esc(v.id) + '">Remove</button></div>';
|
|
7237
|
+
}
|
|
7238
|
+
|
|
7239
|
+
function renderEnvManifest() {
|
|
7240
|
+
var list = mEnvVars || [];
|
|
7241
|
+
var html = list.length
|
|
7242
|
+
? list.map(envRowHtml).join('')
|
|
7243
|
+
: '<p class="plain">No variables declared yet. Add the names your project reads, so anyone building on it knows what to set.</p>';
|
|
7244
|
+
if (mEnvAdding) {
|
|
7245
|
+
html += '<div class="attach" id="mEnvAdd">' +
|
|
7246
|
+
'<input type="text" id="mEnvName" autocomplete="off" spellcheck="false" maxlength="128" ' +
|
|
7247
|
+
'aria-label="Variable name" placeholder="DATABASE_URL">' +
|
|
7248
|
+
'<input type="text" id="mEnvNewDesc" maxlength="2000" aria-label="What it is for" placeholder="What is it for?">' +
|
|
7249
|
+
'<button type="button" class="btn small" data-env-add-save>Add it</button>' +
|
|
7250
|
+
'<button type="button" class="linklike" data-env-add-cancel>Cancel</button></div>' +
|
|
7251
|
+
'<p class="finePrint">The name only — not the value. Put the value where your project runs.</p>';
|
|
7252
|
+
} else {
|
|
7253
|
+
html += '<div class="actions"><button type="button" class="btn small" data-env-add>Add a variable</button></div>';
|
|
7254
|
+
}
|
|
7255
|
+
envMsg(html);
|
|
7256
|
+
if (mEnvAdding) { var f = $('mEnvName'); if (f) f.focus(); }
|
|
7257
|
+
}
|
|
7258
|
+
|
|
7259
|
+
function envErr(html) { var el = $('mEnv'); if (el) el.insertAdjacentHTML('beforeend', '<div class="projMsg err">' + html + '</div>'); }
|
|
7260
|
+
|
|
7261
|
+
/* One request shape for all three writes, so the error handling cannot drift between
|
|
7262
|
+
them. NOTHING here ever puts a value in the body — the API would refuse it, and this
|
|
7263
|
+
card has no field that could supply one. */
|
|
7264
|
+
function envWrite(method, path, body) {
|
|
7265
|
+
var inst = manageInst;
|
|
7266
|
+
if (!inst) return Promise.resolve(false);
|
|
7267
|
+
var opts = { method: method, credentials: 'same-origin' };
|
|
7268
|
+
if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
|
|
7269
|
+
return fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/env-manifest' + path, opts)
|
|
7270
|
+
.then(function (res) {
|
|
7271
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
7272
|
+
if (res.ok) return true;
|
|
7273
|
+
renderEnvManifest();
|
|
7274
|
+
envErr(res.status === 401 ? signInAgain()
|
|
7275
|
+
: esc(apiError(data, res.status).message || 'That could not be saved — try again.'));
|
|
7276
|
+
return false;
|
|
7277
|
+
});
|
|
7278
|
+
})
|
|
7279
|
+
.catch(function () {
|
|
7280
|
+
renderEnvManifest();
|
|
7281
|
+
envErr('Couldn\u2019t reach the platform — check your connection and try again.');
|
|
7282
|
+
return false;
|
|
7283
|
+
});
|
|
7284
|
+
}
|
|
7285
|
+
|
|
7286
|
+
function envReload() {
|
|
7287
|
+
mEnvAdding = false; mEnvEditing = null; mEnvSlug = null; mEnvVars = null;
|
|
7288
|
+
if (manageInst) loadEnvManifest(manageInst);
|
|
7289
|
+
}
|
|
7290
|
+
|
|
7291
|
+
$('mEnv').addEventListener('click', function (ev) {
|
|
7292
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
7293
|
+
if (!t) return;
|
|
7294
|
+
if (t.hasAttribute('data-env-add')) { mEnvAdding = true; mEnvEditing = null; renderEnvManifest(); return; }
|
|
7295
|
+
if (t.hasAttribute('data-env-add-cancel')) { mEnvAdding = false; renderEnvManifest(); return; }
|
|
7296
|
+
if (t.hasAttribute('data-env-cancel')) { mEnvEditing = null; renderEnvManifest(); return; }
|
|
7297
|
+
if (t.hasAttribute('data-env-edit')) { mEnvEditing = t.getAttribute('data-env-edit'); mEnvAdding = false; renderEnvManifest(); return; }
|
|
7298
|
+
if (t.hasAttribute('data-env-add-save')) {
|
|
7299
|
+
var name = ($('mEnvName') || {}).value || '';
|
|
7300
|
+
var desc = ($('mEnvNewDesc') || {}).value || '';
|
|
7301
|
+
if (!name.trim()) { envErr('Type the variable\u2019s name.'); return; }
|
|
7302
|
+
envWrite('POST', '', { name: name.trim(), description: desc.trim() || undefined }).then(function (ok) { if (ok) envReload(); });
|
|
7303
|
+
return;
|
|
7304
|
+
}
|
|
7305
|
+
var saveId = t.getAttribute('data-env-save');
|
|
7306
|
+
if (saveId) {
|
|
7307
|
+
var row = $('mEnv').querySelector('[data-env-row="' + saveId + '"] .envDesc');
|
|
7308
|
+
var text = row ? row.value.trim() : '';
|
|
7309
|
+
/* An emptied box is an ERASE, not an omission — null is the un-answer the API
|
|
7310
|
+
reads, the same merge-patch rule the project-detail answers use. */
|
|
7311
|
+
envWrite('PATCH', '/' + encodeURIComponent(saveId), { description: text || null }).then(function (ok) { if (ok) envReload(); });
|
|
7312
|
+
return;
|
|
7313
|
+
}
|
|
7314
|
+
var delId = t.getAttribute('data-env-del');
|
|
7315
|
+
if (delId) {
|
|
7316
|
+
envWrite('DELETE', '/' + encodeURIComponent(delId), null).then(function (ok) { if (ok) envReload(); });
|
|
7317
|
+
}
|
|
7318
|
+
});
|
|
7319
|
+
|
|
7163
7320
|
function renderDisconnect(inst) {
|
|
7164
7321
|
if (mDiscAsking && mDiscAsking === inst.slug) return;
|
|
7165
7322
|
var open = inst.open_intent;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.955",
|
|
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.955",
|
|
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.955",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.955'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// tests/provisioning_env_manifest.mjs
|
|
2
|
+
//
|
|
3
|
+
// The project ENV VAR MANIFEST — names and purposes, never values (task 1004062,
|
|
4
|
+
// ADR 0323 decision 6).
|
|
5
|
+
//
|
|
6
|
+
// THE LOAD-BEARING ASSERTION IS AN ABSENCE. The task's own done-when is "no route in this
|
|
7
|
+
// work accepts, stores, logs or returns a secret value, and a test asserts the value field
|
|
8
|
+
// does not exist", so this file proves the negative three ways rather than once: the
|
|
9
|
+
// migration declares no value column, the row projection carries no value key, and a
|
|
10
|
+
// caller who POSTs one is REFUSED — loudly, with nothing written. The third is the one
|
|
11
|
+
// that matters most: a silent strip would hand the caller a 201 and leave them believing
|
|
12
|
+
// this platform had taken custody of a secret, which is worse than storing it, because
|
|
13
|
+
// they may then delete it from the place that really holds it.
|
|
14
|
+
//
|
|
15
|
+
// The "secret" in these fixtures is a marker word, deliberately NOT credential-shaped:
|
|
16
|
+
// the repo's secret gate refuses a realistic connection string in any path, and a test
|
|
17
|
+
// that needs a real-looking one to be meaningful would be testing the wrong thing. What
|
|
18
|
+
// matters is that the marker never appears in a response body, and a marker proves that
|
|
19
|
+
// as well as a plausible URL would.
|
|
20
|
+
//
|
|
21
|
+
// Also pinned: the name rule with its reason-giving refusals, environment normalisation
|
|
22
|
+
// (absent means everywhere, and an unknown environment is refused rather than filtered),
|
|
23
|
+
// re-declaring a name as an EDIT not a duplicate, and the own-or-archon 404 that keeps a
|
|
24
|
+
// guessed id from confirming another project's manifest.
|
|
25
|
+
//
|
|
26
|
+
// DB-free: the pool is a fake, data access is stubbed on the cached module objects, auth
|
|
27
|
+
// is faked — the tests/provisioning_disconnect.mjs harness.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/provisioning_env_manifest.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
38
|
+
const envManifest = require('../modules/provisioning/env-manifest.js');
|
|
39
|
+
|
|
40
|
+
let passed = 0, failed = 0;
|
|
41
|
+
function t(name, fn) {
|
|
42
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
async function ta(name, fn) {
|
|
46
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
47
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The marker a "value" carries in these fixtures. If it ever appears in a response, a
|
|
51
|
+
// value reached a surface it must never reach.
|
|
52
|
+
const MARKER = 'VALUE-MUST-NOT-APPEAR';
|
|
53
|
+
|
|
54
|
+
const MIGRATION = readFileSync(new URL('../modules/provisioning/migrations/provisioning_023_env_manifest.sql', import.meta.url), 'utf8');
|
|
55
|
+
|
|
56
|
+
console.log('no value, anywhere — the schema:');
|
|
57
|
+
|
|
58
|
+
t('the migration declares NO value column, under any spelling', () => {
|
|
59
|
+
// Read the CREATE TABLE body only, so the header prose (which necessarily talks ABOUT
|
|
60
|
+
// values) cannot make this pass or fail. Comment lines are dropped for the same reason.
|
|
61
|
+
const body = /CREATE TABLE IF NOT EXISTS provisioning_env_vars \(([\s\S]*?)\n\);/.exec(MIGRATION);
|
|
62
|
+
assert.ok(body, 'the table is declared');
|
|
63
|
+
const columns = body[1]
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((l) => l.trim())
|
|
66
|
+
.filter((l) => l && !l.startsWith('--'))
|
|
67
|
+
.map((l) => l.split(/\s+/)[0]);
|
|
68
|
+
assert.ok(columns.includes('name'), `sanity: the columns were parsed (${columns.join(', ')})`);
|
|
69
|
+
for (const forbidden of envManifest.VALUE_FIELDS) {
|
|
70
|
+
assert.ok(!columns.includes(forbidden),
|
|
71
|
+
`provisioning_env_vars must not have a "${forbidden}" column — the deployment platform is the system of record for values (ADR 0323 D6). Columns: ${columns.join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
t('the migration self-registers and touches no core table', () => {
|
|
76
|
+
assert.match(MIGRATION, /CREATE TABLE IF NOT EXISTS provisioning_env_vars/, 'additive create');
|
|
77
|
+
assert.doesNotMatch(MIGRATION, /ALTER TABLE (?!provisioning_)/, 'no non-provisioning table is altered (ADR 0083 §5)');
|
|
78
|
+
assert.match(MIGRATION, /UNIQUE INDEX IF NOT EXISTS ux_provisioning_env_vars_instance_name/, 'one declaration per name per project');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
t('the environments CHECK admits exactly the known set, and the code agrees with it', () => {
|
|
82
|
+
const m = /CHECK \(environments <@ ARRAY\[([^\]]*)\]/.exec(MIGRATION);
|
|
83
|
+
assert.ok(m, 'the CHECK is a containment test, so any subset is legal');
|
|
84
|
+
const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
|
|
85
|
+
assert.deepEqual(inSql, [...envManifest.ENVIRONMENTS].sort(),
|
|
86
|
+
'the migration and ENVIRONMENTS carry the same set — a drift here silently refuses a legal row at write time');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
console.log('\nno value, anywhere — the projection:');
|
|
90
|
+
|
|
91
|
+
t('envVarRow returns no value key even when the row it is handed carries one', () => {
|
|
92
|
+
// The projection NAMES its columns rather than spreading the row, so a value column
|
|
93
|
+
// that somehow reached the table still could not leave through it. Handing it a poisoned
|
|
94
|
+
// row is how that is proved rather than asserted.
|
|
95
|
+
const out = envManifest.envVarRow({
|
|
96
|
+
id: 1, instance_id: 2, name: 'DATABASE_URL', description: 'the connection string',
|
|
97
|
+
environments: ['production'], required: true, created_at: 'now', updated_at: 'now',
|
|
98
|
+
value: MARKER, secret: MARKER,
|
|
99
|
+
});
|
|
100
|
+
for (const forbidden of envManifest.VALUE_FIELDS) {
|
|
101
|
+
assert.ok(!(forbidden in out), `the projection leaked "${forbidden}"`);
|
|
102
|
+
}
|
|
103
|
+
assert.equal(JSON.stringify(out).includes(MARKER), false, 'and no value rides along inside another field');
|
|
104
|
+
assert.deepEqual(Object.keys(out).sort(),
|
|
105
|
+
['created_at', 'description', 'environments', 'id', 'instance_id', 'name', 'required', 'updated_at']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
t('rejectsValueField catches every spelling, and is silent on a clean body', () => {
|
|
109
|
+
assert.equal(envManifest.rejectsValueField({ name: 'PORT', description: 'the port' }), null);
|
|
110
|
+
assert.equal(envManifest.rejectsValueField(null), null);
|
|
111
|
+
for (const f of envManifest.VALUE_FIELDS) {
|
|
112
|
+
const r = envManifest.rejectsValueField({ name: 'PORT', [f]: MARKER });
|
|
113
|
+
assert.ok(r, `"${f}" must be refused`);
|
|
114
|
+
assert.equal(r.code, 'value_not_accepted');
|
|
115
|
+
assert.match(r.message, /never values/i, 'the message says what the platform does instead');
|
|
116
|
+
assert.ok(!r.message.includes(MARKER), 'and the refusal does not echo the value back');
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log('\nthe name rule:');
|
|
121
|
+
|
|
122
|
+
t('valid names are accepted and the refusals say WHY', () => {
|
|
123
|
+
for (const ok of ['DATABASE_URL', 'PORT', '_INTERNAL', 'npm_config_registry', 'A1']) {
|
|
124
|
+
assert.ok(envManifest.isValidEnvName(ok), `${ok} is a legal name`);
|
|
125
|
+
assert.equal(envManifest.envNameRefusal(ok), null);
|
|
126
|
+
}
|
|
127
|
+
assert.match(envManifest.envNameRefusal(''), /needs a name/);
|
|
128
|
+
assert.match(envManifest.envNameRefusal('MY VAR'), /space/, 'a space is named as the problem');
|
|
129
|
+
assert.match(envManifest.envNameRefusal('1ST_RUN'), /starts with a digit/);
|
|
130
|
+
assert.match(envManifest.envNameRefusal('MY-VAR'), /letters, digits and underscores/);
|
|
131
|
+
assert.match(envManifest.envNameRefusal('X'.repeat(envManifest.ENV_NAME_MAX + 1)), /the limit is/);
|
|
132
|
+
for (const bad of ['MY VAR', '1ST_RUN', 'MY-VAR', '', 'a'.repeat(129)]) {
|
|
133
|
+
assert.ok(!envManifest.isValidEnvName(bad), `${JSON.stringify(bad)} is not a legal name`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
console.log('\nenvironments:');
|
|
138
|
+
|
|
139
|
+
t('absent or empty means EVERYWHERE, stored explicitly and in canonical order', () => {
|
|
140
|
+
assert.deepEqual(envManifest.normalizeEnvironments(undefined), ['production', 'preview', 'development']);
|
|
141
|
+
assert.deepEqual(envManifest.normalizeEnvironments(null), ['production', 'preview', 'development']);
|
|
142
|
+
assert.deepEqual(envManifest.normalizeEnvironments([]), ['production', 'preview', 'development']);
|
|
143
|
+
// Canonical order, so two callers naming the same set produce equal rows and a diff of
|
|
144
|
+
// two manifests is about content rather than ordering.
|
|
145
|
+
assert.deepEqual(envManifest.normalizeEnvironments(['development', 'production']), ['production', 'development']);
|
|
146
|
+
assert.deepEqual(envManifest.normalizeEnvironments(['PRODUCTION', ' production ']), ['production'], 'case and whitespace folded, duplicates collapsed');
|
|
147
|
+
assert.deepEqual(envManifest.normalizeEnvironments('preview'), ['preview'], 'a bare string is accepted as a one-element list');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
t('an unknown environment is REPORTED, not quietly filtered', () => {
|
|
151
|
+
assert.deepEqual(envManifest.unknownEnvironments(['production', 'staging']), ['staging']);
|
|
152
|
+
assert.deepEqual(envManifest.unknownEnvironments(['production']), []);
|
|
153
|
+
assert.deepEqual(envManifest.unknownEnvironments(undefined), []);
|
|
154
|
+
// The two functions disagree on purpose: normalize DROPS the unknown (the backstop) and
|
|
155
|
+
// the route refuses on unknownEnvironments (the answer). Keeping the remainder silently
|
|
156
|
+
// would tell an owner their variable applies somewhere it does not.
|
|
157
|
+
assert.deepEqual(envManifest.normalizeEnvironments(['production', 'staging']), ['production']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
console.log('\nstorage:');
|
|
161
|
+
|
|
162
|
+
// A fake pool that records the SQL and answers the shapes env-manifest.js expects.
|
|
163
|
+
function fakeDb({ rows = [], rowCount = 1, inserted = true } = {}) {
|
|
164
|
+
const calls = [];
|
|
165
|
+
return {
|
|
166
|
+
calls,
|
|
167
|
+
query: async (sql, params) => {
|
|
168
|
+
calls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params });
|
|
169
|
+
if (/^SELECT \* FROM provisioning_env_vars/.test(sql.trim())) return { rows };
|
|
170
|
+
if (/INSERT INTO provisioning_env_vars/.test(sql)) {
|
|
171
|
+
return { rows: [{ id: 5, instance_id: params[0], name: params[1], description: params[2], environments: params[3], required: params[4], created_at: 'c', updated_at: 'u', inserted }] };
|
|
172
|
+
}
|
|
173
|
+
if (/UPDATE provisioning_env_vars/.test(sql)) {
|
|
174
|
+
return { rows: rows.length ? [rows[0]] : [] };
|
|
175
|
+
}
|
|
176
|
+
if (/DELETE FROM provisioning_env_vars/.test(sql)) return { rowCount, rows: [] };
|
|
177
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await ta('declaring stores the name, the sentence, the environments and required — and nothing else', async () => {
|
|
183
|
+
const db = fakeDb();
|
|
184
|
+
const { row, created } = await envManifest.declareEnvVar(db, {
|
|
185
|
+
instanceId: 40, name: ' DATABASE_URL ', description: 'Postgres connection string', environments: ['production'],
|
|
186
|
+
});
|
|
187
|
+
assert.equal(created, true);
|
|
188
|
+
assert.equal(row.name, 'DATABASE_URL', 'the name is trimmed');
|
|
189
|
+
assert.equal(row.required, true, 'required is the default — an omission stays visible on a setup checklist');
|
|
190
|
+
assert.deepEqual(row.environments, ['production']);
|
|
191
|
+
assert.equal(db.calls.length, 1);
|
|
192
|
+
// Five parameters: instance, name, description, environments, required. A sixth would
|
|
193
|
+
// be the thing this table exists to not have.
|
|
194
|
+
assert.equal(db.calls[0].params.length, 5, `the insert binds exactly the manifest columns: ${JSON.stringify(db.calls[0].params)}`);
|
|
195
|
+
assert.match(db.calls[0].sql, /ON CONFLICT \(instance_id, name\) DO UPDATE/, 're-declaring is an edit, not a duplicate row');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await ta('re-declaring an existing name reports created:false — the same declaration, edited', async () => {
|
|
199
|
+
const db = fakeDb({ inserted: false });
|
|
200
|
+
const { created } = await envManifest.declareEnvVar(db, { instanceId: 40, name: 'PORT' });
|
|
201
|
+
assert.equal(created, false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await ta('a patch touches ONLY the fields it names, and is scoped to the project', async () => {
|
|
205
|
+
const db = fakeDb({ rows: [{ id: 5, instance_id: 40, name: 'PORT', description: 'new text', environments: ['production'], required: false, created_at: 'c', updated_at: 'u' }] });
|
|
206
|
+
const row = await envManifest.updateEnvVar(db, 40, '5', { description: 'new text' });
|
|
207
|
+
assert.equal(row.description, 'new text');
|
|
208
|
+
const sql = db.calls[0].sql;
|
|
209
|
+
assert.match(sql, /WHERE instance_id = \$1 AND id = \$2/, 'a guessed id cannot reach another project');
|
|
210
|
+
assert.match(sql, /SET updated_at = now\(\), description = \$3/, 'environments and required are left alone');
|
|
211
|
+
assert.doesNotMatch(sql, /required =/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await ta('an explicit null description erases it; an omitted one does not', async () => {
|
|
215
|
+
const db = fakeDb({ rows: [{ id: 5, instance_id: 40, name: 'PORT', description: null, environments: [], required: true }] });
|
|
216
|
+
await envManifest.updateEnvVar(db, 40, '5', { description: null });
|
|
217
|
+
assert.equal(db.calls[0].params[2], null, 'null is the un-answer, not a dropped field');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await ta('removing is scoped to the project too, and reports whether a row went', async () => {
|
|
221
|
+
const gone = fakeDb({ rowCount: 1 });
|
|
222
|
+
assert.equal(await envManifest.removeEnvVar(gone, 40, '5'), true);
|
|
223
|
+
assert.match(gone.calls[0].sql, /WHERE instance_id = \$1 AND id = \$2/);
|
|
224
|
+
const missing = fakeDb({ rowCount: 0 });
|
|
225
|
+
assert.equal(await envManifest.removeEnvVar(missing, 40, '999'), false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await ta('the list is name-ordered, so two reads of an unchanged manifest are identical', async () => {
|
|
229
|
+
const db = fakeDb({ rows: [] });
|
|
230
|
+
await envManifest.listEnvVars(db, 40);
|
|
231
|
+
assert.match(db.calls[0].sql, /ORDER BY name ASC/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
console.log('\nthe routes:');
|
|
235
|
+
|
|
236
|
+
let instance = null;
|
|
237
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
238
|
+
|
|
239
|
+
const api = require('../src/module-api.js');
|
|
240
|
+
let builder = { id: 7, rank: 'xenos', github_login: 'owner' };
|
|
241
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
242
|
+
|
|
243
|
+
let store = [];
|
|
244
|
+
envManifest.listEnvVars = async () => store;
|
|
245
|
+
envManifest.declareEnvVar = async (_db, d) => {
|
|
246
|
+
const row = envManifest.envVarRow({
|
|
247
|
+
id: 5, instance_id: 40, name: String(d.name).trim(), description: d.description ?? null,
|
|
248
|
+
environments: envManifest.normalizeEnvironments(d.environments), required: d.required !== false,
|
|
249
|
+
created_at: 'c', updated_at: 'u',
|
|
250
|
+
});
|
|
251
|
+
store = [row];
|
|
252
|
+
return { row, created: true };
|
|
253
|
+
};
|
|
254
|
+
envManifest.updateEnvVar = async (_db, _inst, id) => (id === '5' ? store[0] || null : null);
|
|
255
|
+
envManifest.removeEnvVar = async (_db, _inst, id) => id === '5';
|
|
256
|
+
|
|
257
|
+
const express = require('express');
|
|
258
|
+
const envRoutes = require('../modules/provisioning/routes/env-manifest.js');
|
|
259
|
+
const app = express();
|
|
260
|
+
app.use(express.json());
|
|
261
|
+
app.use((_req, res, next) => {
|
|
262
|
+
res.fail = (code, opts, details) => {
|
|
263
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
264
|
+
const extra = typeof opts === 'object' ? opts : {};
|
|
265
|
+
res.status(status).json({ error: { code, message: extra.message || code, details: details || extra.details } });
|
|
266
|
+
};
|
|
267
|
+
next();
|
|
268
|
+
});
|
|
269
|
+
app.use('/api/bongos', envRoutes());
|
|
270
|
+
const server = app.listen(0);
|
|
271
|
+
await new Promise((r) => server.once('listening', r));
|
|
272
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
273
|
+
|
|
274
|
+
function call(method, path, body) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
277
|
+
const headers = payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {};
|
|
278
|
+
const req = http.request(`${base}${path}`, { method, headers }, (res) => {
|
|
279
|
+
let d = '';
|
|
280
|
+
res.on('data', (c) => { d += c; });
|
|
281
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
282
|
+
});
|
|
283
|
+
req.on('error', reject);
|
|
284
|
+
if (payload) req.write(payload);
|
|
285
|
+
req.end();
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const URL_ = '/provisioning/instances/40/env-manifest';
|
|
290
|
+
function reset({ inst = { owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'byo-host' }, who = { id: 7, rank: 'xenos', github_login: 'owner' } } = {}) {
|
|
291
|
+
instance = inst; builder = who; store = [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await ta('the owner declares a variable and reads it back, values_held:false on both', async () => {
|
|
295
|
+
reset();
|
|
296
|
+
const created = await call('POST', URL_, { name: 'DATABASE_URL', description: 'Postgres connection string', environments: ['production'] });
|
|
297
|
+
assert.equal(created.status, 201, created.raw);
|
|
298
|
+
assert.equal(created.body.env_var.name, 'DATABASE_URL');
|
|
299
|
+
assert.equal(created.body.values_held, false);
|
|
300
|
+
const read = await call('GET', URL_);
|
|
301
|
+
assert.equal(read.status, 200, read.raw);
|
|
302
|
+
assert.equal(read.body.env_vars.length, 1);
|
|
303
|
+
assert.equal(read.body.values_held, false, 'a reader must not go looking for a value field and think the API is broken');
|
|
304
|
+
assert.deepEqual(read.body.environments, envManifest.ENVIRONMENTS, 'the vocabulary ships with the list');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// THE done-when assertion, at the boundary.
|
|
308
|
+
for (const field of ['value', 'secret', 'secret_value']) {
|
|
309
|
+
await ta(`POSTing a "${field}" is REFUSED with nothing written, and the value is not echoed back`, async () => {
|
|
310
|
+
reset();
|
|
311
|
+
const res = await call('POST', URL_, { name: 'DATABASE_URL', [field]: MARKER });
|
|
312
|
+
assert.equal(res.status, 400, res.raw);
|
|
313
|
+
assert.equal(res.body.error.code, 'value_not_accepted');
|
|
314
|
+
assert.ok(!res.raw.includes(MARKER), 'the refusal does not repeat the value into a response body or a log');
|
|
315
|
+
assert.deepEqual(store, [], 'and nothing was stored — a 201 here would have the caller believe we hold it');
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await ta('a value sent alongside a BAD name is still answered as the value problem first', async () => {
|
|
320
|
+
reset();
|
|
321
|
+
const res = await call('POST', URL_, { name: '1BAD NAME', value: MARKER });
|
|
322
|
+
assert.equal(res.status, 400, res.raw);
|
|
323
|
+
assert.equal(res.body.error.code, 'value_not_accepted', 'the value is the urgent half of that mistake');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await ta('PATCHing a value is refused the same way', async () => {
|
|
327
|
+
reset();
|
|
328
|
+
const res = await call('PATCH', `${URL_}/5`, { value: MARKER });
|
|
329
|
+
assert.equal(res.status, 400, res.raw);
|
|
330
|
+
assert.equal(res.body.error.code, 'value_not_accepted');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await ta('an unknown environment is refused and NAMES both sets', async () => {
|
|
334
|
+
reset();
|
|
335
|
+
const res = await call('POST', URL_, { name: 'PORT', environments: ['production', 'staging'] });
|
|
336
|
+
assert.equal(res.status, 400, res.raw);
|
|
337
|
+
assert.equal(res.body.error.code, 'bad_environment');
|
|
338
|
+
assert.deepEqual(res.body.error.details.unknown, ['staging']);
|
|
339
|
+
assert.deepEqual(res.body.error.details.known, envManifest.ENVIRONMENTS);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await ta('a malformed name is refused with the rule, not a generic 400', async () => {
|
|
343
|
+
reset();
|
|
344
|
+
const res = await call('POST', URL_, { name: 'MY VAR' });
|
|
345
|
+
assert.equal(res.status, 400, res.raw);
|
|
346
|
+
assert.equal(res.body.error.code, 'bad_env_name');
|
|
347
|
+
assert.match(res.body.error.message, /space/);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await ta("another builder's project is a 404, not a 403 — a guessed id confirms nothing", async () => {
|
|
351
|
+
reset({ who: { id: 99, rank: 'xenos', github_login: 'stranger' } });
|
|
352
|
+
for (const [m, p] of [['GET', URL_], ['POST', URL_], ['PATCH', `${URL_}/5`], ['DELETE', `${URL_}/5`]]) {
|
|
353
|
+
const res = await call(m, p, m === 'GET' || m === 'DELETE' ? undefined : { name: 'PORT' });
|
|
354
|
+
assert.equal(res.status, 404, `${m} ${p} → ${res.raw}`);
|
|
355
|
+
assert.equal(res.body.error.code, 'instance_not_found');
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await ta('an archon may read and edit any project (fleet support)', async () => {
|
|
360
|
+
reset({ who: { id: 99, rank: 'archon', github_login: 'archon' } });
|
|
361
|
+
const res = await call('GET', URL_);
|
|
362
|
+
assert.equal(res.status, 200, res.raw);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await ta('a missing project is the same 404', async () => {
|
|
366
|
+
reset({ inst: null });
|
|
367
|
+
const res = await call('GET', URL_);
|
|
368
|
+
assert.equal(res.status, 404, res.raw);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await ta("a declaration id that is not this project's is a 404, and removing says the value was never ours", async () => {
|
|
372
|
+
reset();
|
|
373
|
+
const miss = await call('DELETE', `${URL_}/999`);
|
|
374
|
+
assert.equal(miss.status, 404, miss.raw);
|
|
375
|
+
assert.equal(miss.body.error.code, 'env_var_not_found');
|
|
376
|
+
const hit = await call('DELETE', `${URL_}/5`);
|
|
377
|
+
assert.equal(hit.status, 200, hit.raw);
|
|
378
|
+
assert.match(hit.body.message, /untouched/, 'removing a name must not read as removing the secret');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await ta('a non-numeric declaration id is a 400 naming the field, not a 404', async () => {
|
|
382
|
+
reset();
|
|
383
|
+
const res = await call('DELETE', `${URL_}/abc`);
|
|
384
|
+
assert.equal(res.status, 400, res.raw);
|
|
385
|
+
assert.equal(res.body.error.code, 'bad_id');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
console.log('\nthe owner-facing card:');
|
|
389
|
+
|
|
390
|
+
t('the manage card has NO value input, and says why there is nowhere to type one', () => {
|
|
391
|
+
// The UI is the other place a value field could appear, so the absence is asserted here
|
|
392
|
+
// too rather than left to the API's refusal. A form about environment variables with no
|
|
393
|
+
// value box reads as unfinished unless it explains itself, which is why the copy check
|
|
394
|
+
// is part of the same assertion: removing the explanation to "tidy up" would leave a
|
|
395
|
+
// card that invites the very question the API refuses.
|
|
396
|
+
const page = readFileSync(new URL('../modules/public-landing/public/projects.html', import.meta.url), 'utf8');
|
|
397
|
+
const card = /<div class="card mSec" id="mEnvVars">([\s\S]*?)<\/details>/.exec(page);
|
|
398
|
+
assert.ok(card, 'the env-var card is on the manage page');
|
|
399
|
+
assert.match(card[1], /Why there is nowhere to type the value/, 'the fold answers the question the missing field raises');
|
|
400
|
+
assert.match(card[1], /Vercel|Netlify|Render|your own machine/, 'and names where the value does belong');
|
|
401
|
+
|
|
402
|
+
// The card's own client code: no input whose purpose is a value, and no request body
|
|
403
|
+
// field that could carry one.
|
|
404
|
+
const code = /Its environment variables \(task 1004062([\s\S]*?)function renderDisconnect/.exec(page);
|
|
405
|
+
assert.ok(code, 'the card\'s client code is on the page');
|
|
406
|
+
const inputIds = [...code[1].matchAll(/id="(mEnv[A-Za-z]*)"/g)].map((m) => m[1]);
|
|
407
|
+
assert.deepEqual(inputIds.sort(), ['mEnvAdd', 'mEnvName', 'mEnvNewDesc'],
|
|
408
|
+
`the card's only fields are the name and the purpose: ${inputIds.join(', ')}`);
|
|
409
|
+
for (const forbidden of envManifest.VALUE_FIELDS) {
|
|
410
|
+
assert.ok(!new RegExp(`\\b${forbidden}:`).test(code[1]),
|
|
411
|
+
`the card must not put "${forbidden}" in a request body`);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
t('the card is guarded against the once-a-minute manage tick', () => {
|
|
416
|
+
// renderManage runs on a timer. Every other section there is fetch-free and so is safe
|
|
417
|
+
// to re-render; this one fetches AND holds a form, so a blind re-render would throw away
|
|
418
|
+
// what the owner is typing. The guard is the reason loadEnvManifest is called rather
|
|
419
|
+
// than a renderX, and this pins it.
|
|
420
|
+
const page = readFileSync(new URL('../modules/public-landing/public/projects.html', import.meta.url), 'utf8');
|
|
421
|
+
assert.match(page, /loadEnvManifest\(inst\);/, 'renderManage calls the loader');
|
|
422
|
+
assert.match(page, /if \(mEnvSlug === inst\.slug && mEnvVars && \(mEnvAdding \|\| mEnvEditing\)\) return;/,
|
|
423
|
+
'a tick during an edit leaves the DOM alone');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await ta('the routes file is DECLARED in module.json, or none of the above is reachable in production', async () => {
|
|
427
|
+
const mod = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
|
|
428
|
+
assert.ok((mod.contributes.routes || []).includes('env-manifest'),
|
|
429
|
+
'a routes file the manifest does not list is a silent no-op (ADR 0289)');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
server.close();
|
|
433
|
+
|
|
434
|
+
console.log(`\nprovisioning_env_manifest: ${passed} passed, ${failed} failed`);
|
|
435
|
+
if (failed) process.exit(1);
|