@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.
@@ -2380,5 +2380,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2380
2380
  landed since 1.19.951 with no explicit bump. run 35686469735. (task 1002620)
2381
2381
  1.19.953 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2382
2382
  landed since 1.19.952 with no explicit bump. run 35687192774. (task 1002620)
2383
+ 1.19.954 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2384
+ landed since 1.19.953 with no explicit bump. run 35687914716. (task 1002620)
2385
+ 1.19.955 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2386
+ landed since 1.19.954 with no explicit bump. run 35690089430. (task 1002620)
2383
2387
  ---------------------------------------------------------------------------
2384
2388
  ```
@@ -0,0 +1,215 @@
1
+ // modules/provisioning/env-manifest.js — the project ENV VAR MANIFEST: the NAMES a
2
+ // project's environment variables have and what each one is for (task 1004062,
3
+ // [ADR 0323](../../docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md)
4
+ // decision 6).
5
+ //
6
+ // **THE ONE RULE: THIS FILE NEVER HANDLES A VALUE.** Not stored, not accepted, not
7
+ // logged, not returned, not encrypted-at-rest — there is nothing to encrypt, because a
8
+ // variable NAME is not a secret (it appears in the source that reads it). The values live
9
+ // on the deployment platform the running application reads them from, which already
10
+ // encrypts, audits and rotates them; Bongos keeping a second copy would make this
11
+ // platform the custodian of other companies' credentials in exchange for nothing the
12
+ // manifest does not already deliver.
13
+ //
14
+ // The rule is enforced rather than stated. `rejectsValueField` is the boundary's answer
15
+ // to a caller that sends one — a LOUD 400, never a silent drop — and
16
+ // `../../tests/provisioning_env_manifest.mjs` asserts both that no row shape carries a
17
+ // value field and that sending one is refused. A silent drop would be worse than storing
18
+ // it: the caller would believe the platform had taken custody.
19
+ //
20
+ // What a manifest buys, and why the owner asked for it: "no project or code base works
21
+ // without this". A builder — human or agent — writing code has to know which variables
22
+ // exist and what each is for, or it invents a name the platform has never heard of and
23
+ // the setup document is incomplete in a way nothing detects until deploy.
24
+ //
25
+ // PURE except the four db functions at the bottom, which take the pool as an argument
26
+ // (the module's own convention — see provisioning.js). Reaches core only through
27
+ // src/module-api.js, and requires this module's own siblings, never another module's.
28
+
29
+ 'use strict';
30
+
31
+ // Where a variable can apply. Fixed and small on purpose: these are the three the
32
+ // deployment platforms in ADR 0323 decision 3 actually name, and an open-ended list
33
+ // would make "which environments does this project have" a second question nobody asked.
34
+ // Mirrors the CHECK in provisioning_023_env_manifest.sql.
35
+ const ENVIRONMENTS = Object.freeze(['production', 'preview', 'development']);
36
+
37
+ // The conventional POSIX-ish environment variable name: a letter or underscore, then
38
+ // letters, digits and underscores. UPPERCASE is not enforced — `npm_config_*` and
39
+ // `PORT` coexist in real projects, and refusing a lowercase name a project genuinely
40
+ // reads would make the manifest wrong rather than tidy. PURE.
41
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
+ const ENV_NAME_MAX = 128;
43
+
44
+ function isValidEnvName(name) {
45
+ const s = typeof name === 'string' ? name.trim() : '';
46
+ if (!s || s.length > ENV_NAME_MAX) return false;
47
+ return ENV_NAME_RE.test(s);
48
+ }
49
+
50
+ // Why a name is being refused, in the words a caller can act on. Returns null when the
51
+ // name is fine. Separate from isValidEnvName so the route's message and the predicate
52
+ // cannot drift. PURE.
53
+ function envNameRefusal(name) {
54
+ if (typeof name !== 'string' || !name.trim()) return 'A variable needs a name.';
55
+ const s = name.trim();
56
+ if (s.length > ENV_NAME_MAX) return `That name is ${s.length} characters; the limit is ${ENV_NAME_MAX}.`;
57
+ if (/\s/.test(s)) return `"${s}" contains a space — an environment variable name cannot.`;
58
+ if (/^[0-9]/.test(s)) return `"${s}" starts with a digit — an environment variable name cannot.`;
59
+ if (!ENV_NAME_RE.test(s)) return `"${s}" is not a valid environment variable name: letters, digits and underscores only, not starting with a digit.`;
60
+ return null;
61
+ }
62
+
63
+ // Coerce the caller's environment list to the known set. An ABSENT or empty list means
64
+ // "everywhere", which is stored as all three rather than as an empty array: a variable
65
+ // the code reads is read in every environment unless somebody says otherwise, and
66
+ // storing the explicit set means a later reader never has to know that an empty array
67
+ // was a special case. An unknown entry is dropped here and refused by the route — the
68
+ // drop is the backstop, not the answer. PURE.
69
+ function normalizeEnvironments(list) {
70
+ if (list === undefined || list === null) return [...ENVIRONMENTS];
71
+ const arr = Array.isArray(list) ? list : [list];
72
+ const kept = [];
73
+ for (const raw of arr) {
74
+ const s = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
75
+ if (ENVIRONMENTS.includes(s) && !kept.includes(s)) kept.push(s);
76
+ }
77
+ if (kept.length === 0) return [...ENVIRONMENTS];
78
+ // Stored in the canonical order, so two callers naming the same set produce equal rows
79
+ // and a diff of two manifests is about content rather than about ordering.
80
+ return ENVIRONMENTS.filter((e) => kept.includes(e));
81
+ }
82
+
83
+ // The environments a caller named that are not environments. The route refuses on this
84
+ // rather than silently keeping the remainder: dropping 'staging' would tell the owner
85
+ // their variable applies somewhere it does not. PURE.
86
+ function unknownEnvironments(list) {
87
+ if (list === undefined || list === null) return [];
88
+ const arr = Array.isArray(list) ? list : [list];
89
+ const bad = [];
90
+ for (const raw of arr) {
91
+ const s = typeof raw === 'string' ? raw.trim().toLowerCase() : String(raw);
92
+ if (!ENVIRONMENTS.includes(s) && !bad.includes(s)) bad.push(s);
93
+ }
94
+ return bad;
95
+ }
96
+
97
+ // The field names that would be a VALUE, in every spelling a caller might reach for.
98
+ // Checked as a group so the refusal can name what was sent.
99
+ const VALUE_FIELDS = Object.freeze(['value', 'values', 'secret', 'secret_value', 'val', 'default_value', 'example_value']);
100
+
101
+ // Refuse a body that carries a value, LOUDLY. Returns null when clean, else a
102
+ // { code, message } the route fails with.
103
+ //
104
+ // Loud and not a silent strip, and the distinction is the whole point: a caller who
105
+ // POSTs a secret and gets a 201 back has every reason to believe the platform now holds
106
+ // it — they may then delete it from the place that actually stores it. A 400 that says
107
+ // "this platform does not take values" leaves them in no doubt, and nothing is written.
108
+ // PURE.
109
+ function rejectsValueField(body) {
110
+ if (!body || typeof body !== 'object') return null;
111
+ const sent = VALUE_FIELDS.filter((f) => Object.prototype.hasOwnProperty.call(body, f));
112
+ if (sent.length === 0) return null;
113
+ return {
114
+ code: 'value_not_accepted',
115
+ message: `This platform stores variable NAMES, never values — ${sent.join(', ')} ${sent.length === 1 ? 'was' : 'were'} not saved and nothing was recorded. Keep the value where the running application reads it (your deployment platform), and declare the name here so builders know it exists.`,
116
+ };
117
+ }
118
+
119
+ // The row shape every reader gets. Built by NAMING each column rather than spreading the
120
+ // row, so a value column that somehow appeared in the table could still never leave
121
+ // through this function. The test asserts the absence on this projection.
122
+ function envVarRow(row) {
123
+ return {
124
+ id: String(row.id),
125
+ instance_id: String(row.instance_id),
126
+ name: row.name,
127
+ description: row.description ?? null,
128
+ environments: Array.isArray(row.environments) ? row.environments : [],
129
+ required: row.required !== false,
130
+ created_at: row.created_at,
131
+ updated_at: row.updated_at,
132
+ };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Storage (the pool is an argument — this module's convention)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ // The declared surface for one project, name-ordered so two reads of an unchanged
140
+ // manifest are byte-identical (a builder session diffs this).
141
+ async function listEnvVars(db, instanceId) {
142
+ const { rows } = await db.query(
143
+ `SELECT * FROM provisioning_env_vars WHERE instance_id = $1 ORDER BY name ASC`,
144
+ [instanceId]
145
+ );
146
+ return rows.map(envVarRow);
147
+ }
148
+
149
+ // Declare a variable, or EDIT the declaration that name already has. The UNIQUE index on
150
+ // (instance_id, name) makes a second POST an update rather than a duplicate: two rows
151
+ // disagreeing about DATABASE_URL is a manifest that cannot be read, and the reader would
152
+ // have no rule for choosing. Returns { row, created }.
153
+ async function declareEnvVar(db, { instanceId, name, description = null, environments, required = true }) {
154
+ const { rows } = await db.query(
155
+ `INSERT INTO provisioning_env_vars (instance_id, name, description, environments, required)
156
+ VALUES ($1, $2, $3, $4, $5)
157
+ ON CONFLICT (instance_id, name) DO UPDATE
158
+ SET description = EXCLUDED.description,
159
+ environments = EXCLUDED.environments,
160
+ required = EXCLUDED.required,
161
+ updated_at = now()
162
+ RETURNING *, (xmax = 0) AS inserted`,
163
+ [instanceId, String(name).trim(), description, normalizeEnvironments(environments), required !== false]
164
+ );
165
+ return { row: envVarRow(rows[0]), created: rows[0].inserted === true };
166
+ }
167
+
168
+ // Edit one declaration by id, scoped to its project so a guessed id cannot reach another
169
+ // owner's manifest. MERGE-patch: an omitted field is left alone, which is what lets the
170
+ // hall edit a description without restating the environments. Returns the row, or null
171
+ // when the id does not belong to that project.
172
+ async function updateEnvVar(db, instanceId, id, patch = {}) {
173
+ const sets = ['updated_at = now()'];
174
+ const params = [instanceId, id];
175
+ if (Object.prototype.hasOwnProperty.call(patch, 'description')) {
176
+ params.push(patch.description === null ? null : String(patch.description));
177
+ sets.push(`description = $${params.length}`);
178
+ }
179
+ if (Object.prototype.hasOwnProperty.call(patch, 'environments')) {
180
+ params.push(normalizeEnvironments(patch.environments));
181
+ sets.push(`environments = $${params.length}`);
182
+ }
183
+ if (Object.prototype.hasOwnProperty.call(patch, 'required')) {
184
+ params.push(patch.required !== false);
185
+ sets.push(`required = $${params.length}`);
186
+ }
187
+ if (Object.prototype.hasOwnProperty.call(patch, 'name')) {
188
+ params.push(String(patch.name).trim());
189
+ sets.push(`name = $${params.length}`);
190
+ }
191
+ const { rows } = await db.query(
192
+ `UPDATE provisioning_env_vars SET ${sets.join(', ')}
193
+ WHERE instance_id = $1 AND id = $2 RETURNING *`,
194
+ params
195
+ );
196
+ return rows[0] ? envVarRow(rows[0]) : null;
197
+ }
198
+
199
+ // Remove one declaration, scoped to its project for the same reason. Returns true when a
200
+ // row went, false when the id was not that project's — the route turns both into the
201
+ // same 404, so a guessed id never confirms another project's contents.
202
+ async function removeEnvVar(db, instanceId, id) {
203
+ const { rowCount } = await db.query(
204
+ `DELETE FROM provisioning_env_vars WHERE instance_id = $1 AND id = $2`,
205
+ [instanceId, id]
206
+ );
207
+ return rowCount > 0;
208
+ }
209
+
210
+ module.exports = {
211
+ ENVIRONMENTS, ENV_NAME_MAX, VALUE_FIELDS,
212
+ isValidEnvName, envNameRefusal, normalizeEnvironments, unknownEnvironments,
213
+ rejectsValueField, envVarRow,
214
+ listEnvVars, declareEnvVar, updateEnvVar, removeEnvVar,
215
+ };
@@ -0,0 +1,67 @@
1
+ -- provisioning_023 — the project ENV VAR MANIFEST (task 1004062, ADR 0323 decision 6).
2
+ --
3
+ -- **THERE IS NO VALUE COLUMN, AND ADDING ONE IS THE THING THIS TABLE EXISTS TO PREVENT.**
4
+ --
5
+ -- A project's environment variables have two halves. The VALUES live on the deployment
6
+ -- platform the running application reads them from — Vercel, Netlify, Render or the
7
+ -- owner's own machine — which already encrypts, audits and rotates them. Bongos storing
8
+ -- them too would make this platform the custodian of other companies' credentials for no
9
+ -- gain, which is the responsibility ADR 0323 spends its entire budget avoiding.
10
+ --
11
+ -- The half Bongos genuinely needs is the SURFACE: which variables exist and what each one
12
+ -- is for, so a builder — human or agent — writing code references the right names and a
13
+ -- setup document is complete. That is a manifest, not a vault, and a manifest is exactly
14
+ -- this: a name, a sentence, where it applies, and whether it is required.
15
+ --
16
+ -- Not encrypted-at-rest either, because there is nothing here to encrypt. A variable NAME
17
+ -- is not a secret: it appears in the source that reads it. Anyone who can read this table
18
+ -- can already read the repository.
19
+ --
20
+ -- For a `byo-host` project there is no platform to ask, so the manifest is simply
21
+ -- unverifiable — which keeps "Bongos holds no values" true everywhere, with no exception
22
+ -- to reason about later (ADR 0323 decision 6's own wording).
23
+ --
24
+ -- Additive + `provisioning_`-namespaced per ADR 0083 §5: CREATE TABLE IF NOT EXISTS only,
25
+ -- no core table touched, so an instance with this module disabled is unaffected.
26
+
27
+ CREATE TABLE IF NOT EXISTS provisioning_env_vars (
28
+ id bigserial PRIMARY KEY,
29
+
30
+ -- The project this declaration belongs to. CASCADE because a manifest is meaningless
31
+ -- without its project, and ADR 0290's disconnect already relies on the row going with
32
+ -- it (provisioning_instances cascades its intents, events and manifests the same way).
33
+ instance_id bigint NOT NULL REFERENCES provisioning_instances(id) ON DELETE CASCADE,
34
+
35
+ -- The variable's NAME, as the application reads it (e.g. 'DATABASE_URL'). Shape is
36
+ -- enforced in code (env-manifest.js's isValidEnvName) so the refusal can name the rule;
37
+ -- the length cap here is the backstop a bypassed caller still meets.
38
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 128),
39
+
40
+ -- What the variable is FOR, in a sentence. The whole point of the manifest: a name with
41
+ -- no purpose tells a builder the surface exists but not what to put behind it.
42
+ description text,
43
+
44
+ -- Where it applies. An array because one variable is usually read in several
45
+ -- environments, and a row per environment would make "rename this variable" a
46
+ -- multi-row edit. The CHECK is a containment test (<@), so it admits any subset —
47
+ -- including the empty array, which env-manifest.js reads as "everywhere" and never
48
+ -- writes.
49
+ environments text[] NOT NULL DEFAULT ARRAY['production','preview','development']::text[]
50
+ CHECK (environments <@ ARRAY['production','preview','development']::text[]),
51
+
52
+ -- Required vs optional. Defaults to required: a variable somebody bothered to declare
53
+ -- is more often one the application cannot start without, and the safe default for a
54
+ -- setup checklist is the one that makes an omission visible.
55
+ required boolean NOT NULL DEFAULT true,
56
+
57
+ created_at timestamptz NOT NULL DEFAULT now(),
58
+ updated_at timestamptz NOT NULL DEFAULT now()
59
+ );
60
+
61
+ -- One declaration per name per project. A second POST for the same name is an EDIT of
62
+ -- the same declaration, not a duplicate row: two rows saying different things about
63
+ -- DATABASE_URL is a manifest that cannot be read, and the reader would have no rule for
64
+ -- picking. Case-sensitive on purpose — environment variables are, and folding the case
65
+ -- here would silently merge `Path` and `PATH`.
66
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioning_env_vars_instance_name
67
+ ON provisioning_env_vars (instance_id, name);
@@ -1,19 +1,34 @@
1
1
  {
2
2
  "key": "provisioning",
3
3
  "title": "Instance provisioning",
4
- "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state — it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
4
+ "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state \u2014 it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
5
5
  "coreVersion": "^1.14.0",
6
6
  "default": false,
7
7
  "author": "Cloud Bongos",
8
8
  "origin": "Cloud Bongos core",
9
9
  "license": "AGPL-3.0",
10
10
  "maintainer": "Cloud Bongos maintainers",
11
- "maintenance": { "status": "core-maintained" },
11
+ "maintenance": {
12
+ "status": "core-maintained"
13
+ },
12
14
  "contributes": {
13
- "routes": ["provisioning", "disconnect", "core-upgrade"],
15
+ "routes": [
16
+ "provisioning",
17
+ "disconnect",
18
+ "core-upgrade",
19
+ "env-manifest"
20
+ ],
14
21
  "migrations": true,
15
- "pollers": ["liveness-sweep", "catalog-backfill"]
22
+ "pollers": [
23
+ "liveness-sweep",
24
+ "catalog-backfill"
25
+ ]
16
26
  },
17
- "provides": ["provisioning.instanceState", "provisioning.projectPlanets", "provisioning.projectOwnedBy", "provisioning.projectPublishIdentity"],
27
+ "provides": [
28
+ "provisioning.instanceState",
29
+ "provisioning.projectPlanets",
30
+ "provisioning.projectOwnedBy",
31
+ "provisioning.projectPublishIdentity"
32
+ ],
18
33
  "consumes": []
19
34
  }
@@ -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
+ };