@bongos/core 1.19.954 → 1.19.956

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.
@@ -0,0 +1,201 @@
1
+ // modules/provisioning/routes/env-manifest.js — the project ENV VAR MANIFEST's HTTP
2
+ // surface (task 1004062, ADR 0323 decision 6).
3
+ //
4
+ // own-scoped (requireBuilder — the caller's OWN project; archon may read/edit any):
5
+ // GET /provisioning/instances/:id/env-manifest — the declared surface
6
+ // POST /provisioning/instances/:id/env-manifest — declare (or re-declare) one
7
+ // PATCH /provisioning/instances/:id/env-manifest/:varId — edit one
8
+ // DELETE /provisioning/instances/:id/env-manifest/:varId — remove one
9
+ //
10
+ // **NO ROUTE HERE ACCEPTS, STORES, LOGS OR RETURNS A VALUE**, and the write routes REFUSE
11
+ // a body that carries one rather than dropping it (../env-manifest.js's
12
+ // `rejectsValueField`, and see that file's header for why loud beats silent). The values
13
+ // live on the deployment platform the running application reads them from.
14
+ //
15
+ // Its own routes file, declared in module.json, because routes/provisioning.js sits at
16
+ // the 1500-line fitness ratchet — the ./disconnect.js and ./core-upgrade.js precedent.
17
+ //
18
+ // A missing or foreign project is 404 for everyone but an archon, the same shape every
19
+ // own-scoped route in this module uses, so another owner's project id is never confirmed.
20
+ 'use strict';
21
+
22
+ const express = require('express');
23
+ const api = require('../../../src/module-api');
24
+ const { pool, validateOrRespond, parseId } = api;
25
+ const provisioning = require('../provisioning');
26
+ const envManifest = require('../env-manifest');
27
+
28
+ const log = api.logger('provisioning');
29
+
30
+ // Resolve the project and decide whether this caller may see it. Returns the instance, or
31
+ // null having already sent the 404 — the same own-or-archon rule as ./disconnect.js.
32
+ async function resolveOwnInstance(req, res) {
33
+ const id = parseId(req, res); // sends 400 bad_id + returns null on failure
34
+ if (!id) return null;
35
+ const inst = await provisioning.getInstanceById(pool, id);
36
+ const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
37
+ if (!inst || (!isOwner && req.builder.rank !== 'archon')) {
38
+ res.fail('instance_not_found', 404);
39
+ return null;
40
+ }
41
+ return inst;
42
+ }
43
+
44
+ // The `:varId` path segment. parseId reads `:id`, which is the PROJECT here, so the
45
+ // declaration's id is parsed separately. A non-numeric id is a 400 naming the field
46
+ // rather than a 404, because it is a malformed request and not a missing row.
47
+ function parseVarId(req, res) {
48
+ const raw = String(req.params.varId || '');
49
+ if (!/^[0-9]+$/.test(raw)) {
50
+ res.fail('bad_id', { status: 400, message: 'varId must be a numeric declaration id.' });
51
+ return null;
52
+ }
53
+ return raw;
54
+ }
55
+
56
+ // The VALUE refusal, and it runs BEFORE the kernel's strict body validator on purpose.
57
+ //
58
+ // `value` is not in either write route's field list, so `validateOrRespond` already
59
+ // refuses it — correctly, and nothing is stored either way. But it refuses it as a
60
+ // generic `validation_failed` naming an unknown field, and that is the wrong sentence for
61
+ // this particular mistake: a caller who has just sent this platform a credential needs to
62
+ // be told that it stores names and never values, and where the value should live instead.
63
+ // A caller who reads "unknown field: value" is as likely to conclude the field is spelled
64
+ // differently and try again.
65
+ //
66
+ // So this speaks first and the strict validator stays as the backstop behind it. Returns
67
+ // true when it has already failed the response.
68
+ function refusesValue(req, res) {
69
+ const valueSent = envManifest.rejectsValueField(req.body);
70
+ if (!valueSent) return false;
71
+ res.fail(valueSent.code, { status: 400, message: valueSent.message });
72
+ return true;
73
+ }
74
+
75
+ // The shared body checks for POST and PATCH, so the two can never disagree about what a
76
+ // valid declaration is. Runs AFTER validateOrRespond (types and lengths are its job) and
77
+ // answers the questions it cannot: is this a legal variable name, and is that a real
78
+ // environment. Returns true when it has already failed the response.
79
+ function badDeclaration(req, res, { nameRequired }) {
80
+ const hasName = req.body && Object.prototype.hasOwnProperty.call(req.body, 'name');
81
+ if (nameRequired || hasName) {
82
+ const refusal = envManifest.envNameRefusal(req.body && req.body.name);
83
+ if (refusal) {
84
+ res.fail('bad_env_name', { status: 400, message: refusal });
85
+ return true;
86
+ }
87
+ }
88
+ const unknown = envManifest.unknownEnvironments(req.body && req.body.environments);
89
+ if (unknown.length) {
90
+ // Refused, not quietly filtered: keeping the remainder would tell the owner their
91
+ // variable applies somewhere it does not.
92
+ res.fail('bad_environment', { status: 400,
93
+ message: `Unknown environment ${unknown.map((e) => `"${e}"`).join(', ')} — this platform knows ${envManifest.ENVIRONMENTS.join(', ')}.`,
94
+ details: { unknown, known: envManifest.ENVIRONMENTS } });
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ module.exports = function buildEnvManifestRouter() {
101
+ const router = express.Router();
102
+
103
+ // GET — the declared surface. This is the route a BUILDER SESSION reads to learn which
104
+ // variables a project has, which is the whole reason the manifest exists.
105
+ router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
106
+ try {
107
+ const inst = await resolveOwnInstance(req, res);
108
+ if (!inst) return;
109
+ const vars = await envManifest.listEnvVars(pool, inst.id);
110
+ res.json({
111
+ instance: { id: String(inst.id), slug: inst.slug, hosting_shape: inst.hosting_shape },
112
+ env_vars: vars,
113
+ environments: envManifest.ENVIRONMENTS,
114
+ // Said in the payload, not only in a doc: a reader of this list must not go
115
+ // looking for a value field and conclude the API is broken when there is none.
116
+ // For byo-host there is no platform to ask at all (ADR 0323 decision 6).
117
+ values_held: false,
118
+ note: 'Names and purposes only — this platform never holds a variable\'s value. Set values where the running application reads them.',
119
+ });
120
+ } catch (err) {
121
+ log.error('[provisioning] GET /provisioning/instances/:id/env-manifest', err);
122
+ res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
123
+ }
124
+ });
125
+
126
+ // POST — declare a variable, or re-declare one that exists (an edit, not a duplicate:
127
+ // see ../env-manifest.js's declareEnvVar).
128
+ router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
129
+ if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
130
+ if (validateOrRespond(req, res, {
131
+ name: { required: true, type: 'string', maxLength: envManifest.ENV_NAME_MAX },
132
+ description: { type: 'string', maxLength: 2000 },
133
+ environments: { type: 'array' },
134
+ required: { type: 'boolean' },
135
+ })) return;
136
+ try {
137
+ const inst = await resolveOwnInstance(req, res);
138
+ if (!inst) return;
139
+ if (badDeclaration(req, res, { nameRequired: true })) return;
140
+ const { row, created } = await envManifest.declareEnvVar(pool, {
141
+ instanceId: inst.id,
142
+ name: req.body.name,
143
+ description: req.body.description ?? null,
144
+ environments: req.body.environments,
145
+ required: req.body.required,
146
+ });
147
+ res.status(created ? 201 : 200).json({ ok: true, created, env_var: row, values_held: false });
148
+ } catch (err) {
149
+ log.error('[provisioning] POST /provisioning/instances/:id/env-manifest', err);
150
+ res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
151
+ }
152
+ });
153
+
154
+ // PATCH — edit one declaration. Merge-patch: an omitted field is left alone, so the
155
+ // hall can fix a description without restating the environments.
156
+ router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
157
+ if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
158
+ if (validateOrRespond(req, res, {
159
+ name: { type: 'string', maxLength: envManifest.ENV_NAME_MAX },
160
+ description: { type: 'string', nullable: true, maxLength: 2000 },
161
+ environments: { type: 'array' },
162
+ required: { type: 'boolean' },
163
+ })) return;
164
+ try {
165
+ const inst = await resolveOwnInstance(req, res);
166
+ if (!inst) return;
167
+ const varId = parseVarId(req, res);
168
+ if (!varId) return;
169
+ if (badDeclaration(req, res, { nameRequired: false })) return;
170
+ const row = await envManifest.updateEnvVar(pool, inst.id, varId, req.body || {});
171
+ // Not that project's declaration → the same 404 a missing one gets, so a guessed id
172
+ // never confirms what another project declares.
173
+ if (!row) return res.fail('env_var_not_found', 404);
174
+ res.json({ ok: true, env_var: row, values_held: false });
175
+ } catch (err) {
176
+ log.error('[provisioning] PATCH /provisioning/instances/:id/env-manifest/:varId', err);
177
+ res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
178
+ }
179
+ });
180
+
181
+ // DELETE — remove one declaration. Nothing outside this table is affected: the value,
182
+ // wherever it lives, is untouched, and the response says so rather than letting an
183
+ // owner believe removing the name removed the secret.
184
+ router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
185
+ try {
186
+ const inst = await resolveOwnInstance(req, res);
187
+ if (!inst) return;
188
+ const varId = parseVarId(req, res);
189
+ if (!varId) return;
190
+ const removed = await envManifest.removeEnvVar(pool, inst.id, varId);
191
+ if (!removed) return res.fail('env_var_not_found', 404);
192
+ res.json({ ok: true, removed: true,
193
+ message: 'Declaration removed. The variable\'s value, wherever you keep it, is untouched — this platform never had it.' });
194
+ } catch (err) {
195
+ log.error('[provisioning] DELETE /provisioning/instances/:id/env-manifest/:varId', err);
196
+ res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
197
+ }
198
+ });
199
+
200
+ return router;
201
+ };
@@ -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.954",
3
+ "version": "1.19.956",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.954",
9
+ "version": "1.19.956",
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.954",
3
+ "version": "1.19.956",
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.954'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.956'; // 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');