@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
|
@@ -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
|
|
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": {
|
|
11
|
+
"maintenance": {
|
|
12
|
+
"status": "core-maintained"
|
|
13
|
+
},
|
|
12
14
|
"contributes": {
|
|
13
|
-
"routes": [
|
|
15
|
+
"routes": [
|
|
16
|
+
"provisioning",
|
|
17
|
+
"disconnect",
|
|
18
|
+
"core-upgrade",
|
|
19
|
+
"env-manifest"
|
|
20
|
+
],
|
|
14
21
|
"migrations": true,
|
|
15
|
-
"pollers": [
|
|
22
|
+
"pollers": [
|
|
23
|
+
"liveness-sweep",
|
|
24
|
+
"catalog-backfill"
|
|
25
|
+
]
|
|
16
26
|
},
|
|
17
|
-
"provides": [
|
|
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
|
+
};
|