@bongos/core 1.19.419 → 1.19.420
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 +58 -43
- package/.claude/skills/new-project/SKILL.md +22 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +103 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +186 -174
- package/docs/copy-registry.json +348 -238
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/onboarding/routes/access-requests.js +16 -3
- package/modules/platform-identity/platform-identity.js +5 -5
- package/modules/platform-identity/tests/platform-identity.mjs +20 -0
- package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
- package/modules/provisioning/onboard-plan.js +13 -0
- package/modules/provisioning/provisioning.js +113 -5
- package/modules/provisioning/routes/provisioning.js +105 -0
- package/modules/provisioning/tests/provisioning.mjs +84 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +161 -42
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/onboard.js +41 -1
- package/scripts/gds/provision-net.js +7 -3
- package/src/bongos/middleware/rate-limit.js +68 -2
- package/src/bongos/routes/auth.js +19 -12
- package/src/module-api.js +13 -1
- package/tests/account_existence_rate_limit.mjs +375 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboard.mjs +56 -0
- package/tests/projects_hub.mjs +69 -0
- package/tests/projects_hub_pre_uat.mjs +27 -5
- package/tests/provision.mjs +20 -0
package/docs/file-map.md
CHANGED
|
@@ -349,6 +349,7 @@ tests/
|
|
|
349
349
|
├── sandbox_stage.mjs ← (#927, ADR 0046): sandbox-first review gate — box detection, BOX_PREVIEW_HOSTNAME parsing, previewable-surface matching, decideShipGate table, tree-stamp freshness (temp git repo), runShipGate composition with injected fs/exec/prompt.
|
|
350
350
|
├── session_start_freshness.mjs ← (#1277, ADR 0067): the SessionStart freshness decision (decideFreshness) — pull ONLY on a clean `main` full clone strictly behind origin/main; warn in every other behind case; skip when up to date.
|
|
351
351
|
├── own_scope_guard.mjs ← (task 1003343, goal 1000045 criterion own-scope-held-by-a-test): the OWN-SCOPE structural guard, generalised out of memory_isolation.mjs. An explicit inventory of own-scoped route files; inside them the route's declared PATH decides — a path naming no builder must never read a subject from the request (direct, optional-chained, destructured, aliased, computed-key or header), a path that DOES name one needs a per-route exemption whose gate the guard verifies is present. Repo-wide backstop: any file mounting a `/me` route must be in the inventory. Source-grepping, so it runs in the DB-free `unit` lane.
|
|
352
|
+
├── account_existence_rate_limit.mjs ← (task 1003339, goal 1000045, ADR 0209): the two PUBLIC account-existence probes (onboarding GET /access-requests/status + core GET /auth/web/admission-status) share ONE per-IP budget. Counts to the limit on each route, proves the budget is shared in both directions and when alternated, moves Date.now to prove the window slides on the real mounted route, and proves a refused probe issues no query. DB-free; mutation-checked three ways.
|
|
352
353
|
└── public_task_resolver.mjs ← (#604): the public `/public/tasks/:id` projection — title/value_summary exposed only for shipped|confirmed; withheld for in-flight so unshipped/security task names can't leak.
|
|
353
354
|
```
|
|
354
355
|
|
|
@@ -1275,5 +1275,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1275
1275
|
landed since 1.19.417 with no explicit bump. run 33264139544. (task 1002620)
|
|
1276
1276
|
1.19.419 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1277
1277
|
landed since 1.19.418 with no explicit bump. run 33265382684. (task 1002620)
|
|
1278
|
+
1.19.420 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1279
|
+
landed since 1.19.419 with no explicit bump. run 33266034679. (task 1002620)
|
|
1278
1280
|
---------------------------------------------------------------------------
|
|
1279
1281
|
```
|
|
@@ -640,6 +640,8 @@ function createClient(opts = {}) {
|
|
|
640
640
|
postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
|
|
641
641
|
// GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
|
|
642
642
|
getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
|
|
643
|
+
// PATCH /provisioning/instances/{id}/detail — rank: any-builder — PATCH /provisioning/instances/:id/detail
|
|
644
|
+
patchProvisioningInstancesIdDetail: (args) => request("PATCH", "/provisioning/instances/{id}/detail", { hasBody: true }, args),
|
|
643
645
|
// POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
|
|
644
646
|
postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
|
|
645
647
|
// POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
|
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
//
|
|
29
29
|
// The global IP rate-limiter (mounted in routes.js before every router) guards
|
|
30
30
|
// the public POST against spam. A partial unique index on lower(github_login)
|
|
31
|
-
// stops one username piling up duplicate open requests.
|
|
31
|
+
// stops one username piling up duplicate open requests. The global limiter
|
|
32
|
+
// meters WRITES only, so the public GET /access-requests/status carries its own
|
|
33
|
+
// per-IP read cap — api.accountExistenceReadRateLimit, shared with core's
|
|
34
|
+
// admission-status twin (task 1003339, ADR 0209).
|
|
32
35
|
|
|
33
36
|
const express = require('express');
|
|
34
37
|
// Carved into modules/onboarding/ (BV1.R81 / ADR 0093 §1). The auth gates, the
|
|
@@ -38,7 +41,7 @@ const express = require('express');
|
|
|
38
41
|
const api = require('../../../src/module-api');
|
|
39
42
|
const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requirePermission };
|
|
40
43
|
const { pool } = api;
|
|
41
|
-
const { validateOrRespond, parseId } = api;
|
|
44
|
+
const { validateOrRespond, parseId, accountExistenceReadRateLimit } = api;
|
|
42
45
|
const approvalBroadcast = require('../approval-broadcast');
|
|
43
46
|
const log = api.logger('onboarding');
|
|
44
47
|
|
|
@@ -137,7 +140,17 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
137
140
|
// rank: public — a would-be builder is by definition outside the system.
|
|
138
141
|
// Declared BEFORE `GET /access-requests` so the literal `/status` path is matched
|
|
139
142
|
// as its own route (not swallowed as a query on the list route).
|
|
140
|
-
|
|
143
|
+
//
|
|
144
|
+
// RATE-LIMITED (task 1003339 / ADR 0209). Public + unthrottled, this was a
|
|
145
|
+
// login→membership oracle an attacker could script at line rate to learn who
|
|
146
|
+
// builds here. accountExistenceReadRateLimit is the doorway's SHARED 120/60s
|
|
147
|
+
// per-IP budget: core's GET /auth/web/admission-status (the boolean twin of
|
|
148
|
+
// this answer) mounts the very same instance, so the two cannot be alternated
|
|
149
|
+
// for double the rate. The limiter runs before the queries, so a refused probe
|
|
150
|
+
// costs no DB work. Whether the ANSWER should also collapse — so a caller who
|
|
151
|
+
// is not the subject cannot tell pending/dismissed/none apart — is the owner's
|
|
152
|
+
// call and is filed as a blocker, not decided here.
|
|
153
|
+
router.get('/access-requests/status', accountExistenceReadRateLimit, async (req, res) => {
|
|
141
154
|
const login = normalizeLogin(req.query.github_login);
|
|
142
155
|
if (!GITHUB_LOGIN_RE.test(login)) {
|
|
143
156
|
return res.fail('bad_github_login', { status: 400, message: 'That does not look like a GitHub username.' });
|
|
@@ -201,7 +201,7 @@ async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
|
201
201
|
// (task #1002109) calls this with a freshly generated secret; the plaintext is
|
|
202
202
|
// hashed here and never stored. Upsert-on-client_id so re-provisioning rotates
|
|
203
203
|
// the secret + redirect_uris without a duplicate row.
|
|
204
|
-
async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId, type }, { pool = defaultPool } = {}) {
|
|
204
|
+
async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId, type, description }, { pool = defaultPool } = {}) {
|
|
205
205
|
const secretHash = sha256hex(secret);
|
|
206
206
|
const uris = Array.isArray(redirectUris) ? redirectUris : [];
|
|
207
207
|
const { rows } = await pool.query(
|
|
@@ -218,11 +218,11 @@ async function registerClient({ clientId, secret, name, redirectUris, origin, ow
|
|
|
218
218
|
RETURNING id, client_id, name, redirect_uris, origin, owner_github_id, status`,
|
|
219
219
|
[clientId, secretHash, name, uris, origin ?? null, ownerGithubId ?? null],
|
|
220
220
|
);
|
|
221
|
-
// Auto-catalogue the project (unfeatured) so it appears in the Archon curation
|
|
222
|
-
//
|
|
223
|
-
//
|
|
221
|
+
// Auto-catalogue the project (unfeatured) so it appears in the Archon curation list the
|
|
222
|
+
// moment it federates (ADR 0141 §5). Best-effort — a hiccup must never fail registration;
|
|
223
|
+
// only when an origin is known. `description` rides along like `type` (ADR 0182 D4, task 1002334).
|
|
224
224
|
if (origin) {
|
|
225
|
-
try { await upsertProject({ origin, name, ownerGithubId, type }, { pool }); }
|
|
225
|
+
try { await upsertProject({ origin, name, description, ownerGithubId, type }, { pool }); }
|
|
226
226
|
catch (err) { console.error('[platform-identity] auto-catalogue failed (non-fatal):', err.message); }
|
|
227
227
|
}
|
|
228
228
|
return rows[0];
|
|
@@ -406,6 +406,26 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
|
|
|
406
406
|
assert.match(pool.calls[1].text, /INSERT INTO platform_identity_projects/);
|
|
407
407
|
});
|
|
408
408
|
|
|
409
|
+
await ta('registerClient forwards the project-detail description into the catalog row (task 1002334)', async () => {
|
|
410
|
+
// Half the publish gate (ADR 0182 D4). It is given on the platform before this
|
|
411
|
+
// row exists, so if this hop drops it a project that DID describe itself sits
|
|
412
|
+
// in dark matter and the optional step is decorative.
|
|
413
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
|
|
414
|
+
await P.registerClient(
|
|
415
|
+
{ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p', ownerGithubId: 7, description: 'A weather sim.' },
|
|
416
|
+
{ pool },
|
|
417
|
+
);
|
|
418
|
+
assert.ok(pool.calls[1].params.includes('A weather sim.'), 'the description reaches the catalog upsert');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await ta('registerClient with a SKIPPED interview passes no description — and COALESCE nulls nothing', async () => {
|
|
422
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
|
|
423
|
+
await P.registerClient({ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p' }, { pool });
|
|
424
|
+
assert.equal(pool.calls[1].params[3], null, 'absent travels as null');
|
|
425
|
+
assert.match(pool.calls[1].text, /description = COALESCE\(EXCLUDED\.description/,
|
|
426
|
+
'so a re-registration can never erase a description the owner added later');
|
|
427
|
+
});
|
|
428
|
+
|
|
409
429
|
console.log('\nproject memberships (§3 check-in directory):');
|
|
410
430
|
|
|
411
431
|
await ta('recordFederatedSignin is the ONE path that creates a membership, and stamps the witness', async () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- provisioning_013_project_detail.sql — the optional project-detail interview's
|
|
2
|
+
-- storage (BV1.R15, task 1002334; goal 1000046 criterion `proj-detail-interview`).
|
|
3
|
+
--
|
|
4
|
+
-- The creation sequence gains ONE optional, skippable step — "tell us about your
|
|
5
|
+
-- project" — and its answers have to live somewhere real the moment they are
|
|
6
|
+
-- given. A standalone project never federates into the hub catalog at create
|
|
7
|
+
-- time, so this mirrors exactly what provisioning_007 did for the declared
|
|
8
|
+
-- `type`: the answer rides the provisioning request, and the control-plane
|
|
9
|
+
-- runner carries it into platform_identity_projects whenever a catalog row
|
|
10
|
+
-- materializes.
|
|
11
|
+
--
|
|
12
|
+
-- TWO columns, deliberately not one:
|
|
13
|
+
--
|
|
14
|
+
-- description a REAL column, because it is half of the publish gate (ADR 0182
|
|
15
|
+
-- D4: publishable iff name AND description are both present). It
|
|
16
|
+
-- mirrors platform_identity_projects.description, which
|
|
17
|
+
-- platform_identity_011 added for the same reason. NOT `tagline` —
|
|
18
|
+
-- ADR 0182 refuses to collapse them, since a description is the
|
|
19
|
+
-- publish bar and editing your card must never unpublish you.
|
|
20
|
+
--
|
|
21
|
+
-- detail jsonb, because the interview grows one answer at a time as R17
|
|
22
|
+
-- (rule-based recommendations), R19 (starter bundles) and R27
|
|
23
|
+
-- (invites) land, and a new question must not cost a migration
|
|
24
|
+
-- each time. Same contract as `settings` (provisioning_009): the
|
|
25
|
+
-- column is a dumb store, the known keys + their vocabularies are
|
|
26
|
+
-- enforced in code (provisioning.PROJECT_DETAIL_VOCAB +
|
|
27
|
+
-- normalizeProjectDetail), and an unknown or off-vocabulary stored
|
|
28
|
+
-- value is dropped at read time rather than leaking out.
|
|
29
|
+
--
|
|
30
|
+
-- SKIPPING IS THE DEFAULT, not a failure: both columns are nullable/empty, and an
|
|
31
|
+
-- empty description is precisely what leaves a project dark-matter until its
|
|
32
|
+
-- owner fills it in (ADR 0182 D4 — the bar exists to keep empty husks off the
|
|
33
|
+
-- map, not to enforce completeness). Nothing here changes live behaviour; R16
|
|
34
|
+
-- (task 1002335) is the surface that computes publishability and names what is
|
|
35
|
+
-- missing.
|
|
36
|
+
--
|
|
37
|
+
-- Additive + provisioning_-namespaced (ADR 0083 §5): ADD COLUMN only, so the
|
|
38
|
+
-- previous code release still runs against this schema. Idempotent: safe to re-run.
|
|
39
|
+
|
|
40
|
+
BEGIN;
|
|
41
|
+
|
|
42
|
+
ALTER TABLE provisioning_instances
|
|
43
|
+
ADD COLUMN IF NOT EXISTS description text;
|
|
44
|
+
|
|
45
|
+
ALTER TABLE provisioning_instances
|
|
46
|
+
ADD COLUMN IF NOT EXISTS detail jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
47
|
+
|
|
48
|
+
COMMENT ON COLUMN provisioning_instances.description IS
|
|
49
|
+
'What the owner said they are building — the answer to the optional project-detail step (BV1.R15). Half of the publish gate (ADR 0182 D4: publishable iff name AND description are present), mirrored into platform_identity_projects.description when a catalog row materializes. NULL = the step was skipped, which is a supported default, not a failure: the project stays dark-matter until it is filled in.';
|
|
50
|
+
|
|
51
|
+
COMMENT ON COLUMN provisioning_instances.detail IS
|
|
52
|
+
'The rest of the optional project-detail interview, as a dumb jsonb store (the `settings` contract from provisioning_009). Known keys + vocabularies live in provisioning.PROJECT_DETAIL_VOCAB and are enforced at the API layer; an unknown or off-vocabulary stored value is dropped at read time. jsonb so the interview can grow an answer at a time (R17 recommendations, R19 bundles, R27 invites) without a migration each time. {} = skipped.';
|
|
53
|
+
|
|
54
|
+
COMMIT;
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
//
|
|
10
10
|
// PURE — no I/O, no core imports. Safe to require from a script, a route, or a test.
|
|
11
11
|
//
|
|
12
|
+
// `optional: true` (task 1002334) marks a step every form must OFFER and every
|
|
13
|
+
// owner may SKIP — today just `project-detail`, the "tell us about your project"
|
|
14
|
+
// interview. It is a real step in the sequence rather than a wizard-only flourish
|
|
15
|
+
// because all four forms have to ask it the same way; skipping is a supported
|
|
16
|
+
// default that leaves the project dark-matter (off the map, un-joinable) until its
|
|
17
|
+
// description is filled in later, never a dead end (ADR 0182 D4). A form that can
|
|
18
|
+
// only render a linear checklist may treat an optional step as any other; one that
|
|
19
|
+
// takes answers must let the owner past it without one.
|
|
20
|
+
//
|
|
12
21
|
// Each step: { key, kind: 'auto' | 'human', label }. `kind: 'human'` marks the
|
|
13
22
|
// irreducibly-manual owner actions (create the GitHub repo, wire GitHub sign-in, first
|
|
14
23
|
// sign-in, enter the hall) that every form renders UI-first; `kind: 'auto'` steps the
|
|
@@ -36,6 +45,10 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
|
|
|
36
45
|
const domain = typeof opts.domain === 'string' && opts.domain.trim() ? opts.domain.trim() : null;
|
|
37
46
|
return [
|
|
38
47
|
{ key: 'name-address', kind: 'auto', label: 'Decide name + address (interview)' },
|
|
48
|
+
// 'auto' like name-address above, and for the same reason: the form itself
|
|
49
|
+
// collects it, so it is not one of the irreducibly-manual legs a done panel
|
|
50
|
+
// lists. `optional` is what makes it skippable — see the header.
|
|
51
|
+
{ key: 'project-detail', kind: 'auto', optional: true, label: 'Tell us about your project — optional, skip it and add it later' },
|
|
39
52
|
{ key: 'github-repo', kind: 'human', label: adopt ? 'Use your EXISTING GitHub repository' : 'Create the empty GitHub repository' },
|
|
40
53
|
{ key: 'dns-preflight', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'DNS token preflight — before the address step (task 1980)' },
|
|
41
54
|
{ key: 'init', kind: 'auto', label: adopt ? 'bongos init --adopt — LAYER onto the existing repo (pre-flight + accept)' : 'bongos init — scaffold the instance config' },
|
|
@@ -124,6 +124,34 @@ const SETTINGS_VOCAB = Object.freeze({
|
|
|
124
124
|
});
|
|
125
125
|
const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply' });
|
|
126
126
|
|
|
127
|
+
// The optional project-detail interview (BV1.R15, task 1002334) — the "tell us
|
|
128
|
+
// about your project" step every creation form offers and every owner may skip.
|
|
129
|
+
// The interview asks two things, and the vocabulary is the contract for the
|
|
130
|
+
// closed-answer half:
|
|
131
|
+
//
|
|
132
|
+
// team_shape who is going to build it — the input R17 (task 1002336) maps to a
|
|
133
|
+
// recommended governance preset and to invite suggestions.
|
|
134
|
+
//
|
|
135
|
+
// (The module bundle R19 recommends comes off the separately-declared `type`, so
|
|
136
|
+
// the interview deliberately does not ask it a second time.)
|
|
137
|
+
//
|
|
138
|
+
// The free-text half of the interview is NOT here: `description` is a real column
|
|
139
|
+
// (migration provisioning_013) because it is half of the publish gate (ADR 0182
|
|
140
|
+
// D4), while these are jsonb so a later question costs no migration — same dumb-
|
|
141
|
+
// store contract as SETTINGS_VOCAB above.
|
|
142
|
+
//
|
|
143
|
+
// There is deliberately NO default. Settings describe a policy the instance must
|
|
144
|
+
// obey, so an absent one has to resolve to today's behaviour; a detail answer is
|
|
145
|
+
// something the owner SAID, and inventing one they never said would feed R17 a
|
|
146
|
+
// recommendation nobody asked for. Absent stays absent, and skipped stays skipped.
|
|
147
|
+
const PROJECT_DETAIL_VOCAB = Object.freeze({
|
|
148
|
+
team_shape: ['solo', 'small-team', 'community'],
|
|
149
|
+
});
|
|
150
|
+
// The publish-gate half of the interview is prose, so it needs a ceiling. Long
|
|
151
|
+
// enough for a real paragraph, short enough that the column is never a document —
|
|
152
|
+
// the Explore card renders `tagline`, not this.
|
|
153
|
+
const DESCRIPTION_MAX = 600;
|
|
154
|
+
|
|
127
155
|
// Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
|
|
128
156
|
// (ADR 0111 §6), never a bill. A small local table so this module needs no
|
|
129
157
|
// sibling-module import; P2 (shared control-plane client extraction) is where a
|
|
@@ -257,6 +285,14 @@ function publicInstance(row) {
|
|
|
257
285
|
// instances so a declaration shows before the catalog carries it. Nullable:
|
|
258
286
|
// rows predating provisioning_007, or a declined answer, project null.
|
|
259
287
|
type: row.type || null,
|
|
288
|
+
// The optional project-detail interview (task 1002334): what the owner said
|
|
289
|
+
// they are building, and the answers that feed R17's recommendations. Both
|
|
290
|
+
// are legitimately empty — the step is skippable by design — and `description`
|
|
291
|
+
// being null is exactly what keeps the project dark-matter until it is filled
|
|
292
|
+
// in (ADR 0182 D4). Surfaced so a form can render "here's what's still
|
|
293
|
+
// missing" from a plain instance read, without a second endpoint.
|
|
294
|
+
description: row.description || null,
|
|
295
|
+
detail: projectDetail(row),
|
|
260
296
|
// The verification overlay (task 1002696, redesign direction §3): what the
|
|
261
297
|
// heartbeat last OBSERVED, which is NOT what `status` claims. null = never
|
|
262
298
|
// probed — a different claim from last_verified_up=false, and the UI renders
|
|
@@ -330,6 +366,40 @@ function effectiveSettings(row) {
|
|
|
330
366
|
return out;
|
|
331
367
|
}
|
|
332
368
|
|
|
369
|
+
// A description as it may be STORED: trimmed, collapsed of runaway whitespace,
|
|
370
|
+
// capped, and empty-means-absent. Returns null for anything that isn't real prose,
|
|
371
|
+
// because null is what leaves the project dark-matter — an empty string stored in a
|
|
372
|
+
// nullable column would look answered to `description IS NOT NULL` and publish an
|
|
373
|
+
// husk, which is the one thing the gate exists to prevent (ADR 0182 D4). PURE.
|
|
374
|
+
function normalizeDescription(input) {
|
|
375
|
+
if (typeof input !== 'string') return null;
|
|
376
|
+
const text = input.replace(/\s+/g, ' ').trim();
|
|
377
|
+
if (!text) return null;
|
|
378
|
+
return text.slice(0, DESCRIPTION_MAX);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The detail answers as they may be STORED: on-vocabulary keys only, everything
|
|
382
|
+
// else dropped. An off-vocabulary value is dropped rather than defaulted — a
|
|
383
|
+
// recommendation built on an answer the owner never gave is worse than no
|
|
384
|
+
// recommendation (see PROJECT_DETAIL_VOCAB). Always returns a plain object, so a
|
|
385
|
+
// skipped interview stores `{}` rather than null. PURE.
|
|
386
|
+
function normalizeProjectDetail(input) {
|
|
387
|
+
const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
388
|
+
const out = {};
|
|
389
|
+
for (const [key, vocab] of Object.entries(PROJECT_DETAIL_VOCAB)) {
|
|
390
|
+
if (vocab.includes(given[key])) out[key] = given[key];
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// What a row's detail answers READ as — the stored object filtered through the
|
|
396
|
+
// same vocabulary, so a value that was valid when written and has since left the
|
|
397
|
+
// vocabulary never leaks out of the read surface. Mirrors effectiveSettings, minus
|
|
398
|
+
// the defaults it deliberately has none of. PURE.
|
|
399
|
+
function projectDetail(row) {
|
|
400
|
+
return normalizeProjectDetail(row && row.detail);
|
|
401
|
+
}
|
|
402
|
+
|
|
333
403
|
// ---------------------------------------------------------------------------
|
|
334
404
|
// GitHub App Manifest flow — PURE builders (task 2080, ADR 0133).
|
|
335
405
|
// GitHub has no create-an-OAuth-App API, but the App *Manifest* flow lets the owner
|
|
@@ -479,6 +549,35 @@ async function updateInstanceSettings(db, id, patch) {
|
|
|
479
549
|
return rows[0] || null;
|
|
480
550
|
}
|
|
481
551
|
|
|
552
|
+
// Fill in (or change) the project-detail answers AFTER creation — the other half
|
|
553
|
+
// of "skippable" (task 1002334). Skipping the step at creation is a supported
|
|
554
|
+
// default, but it is only a default and not a dead end if there is a way back; this
|
|
555
|
+
// is that way back, and it is what turns a dark-matter project publishable once its
|
|
556
|
+
// owner writes a description (ADR 0182 D4).
|
|
557
|
+
//
|
|
558
|
+
// `detail` MERGES (the settings `||` precedent — a form may send one answer without
|
|
559
|
+
// resending the rest) while `description` REPLACES, because prose has no keys to
|
|
560
|
+
// merge. Passing description: null clears it — and un-publishes the project, which
|
|
561
|
+
// is the honest consequence of deleting half the identity gate, not a bug.
|
|
562
|
+
// Undefined leaves the column alone.
|
|
563
|
+
async function updateInstanceDetail(db, id, { description, detail } = {}) {
|
|
564
|
+
const sets = ['updated_at = now()'];
|
|
565
|
+
const params = [id];
|
|
566
|
+
if (description !== undefined) {
|
|
567
|
+
params.push(description);
|
|
568
|
+
sets.push(`description = $${params.length}`);
|
|
569
|
+
}
|
|
570
|
+
if (detail !== undefined) {
|
|
571
|
+
params.push(JSON.stringify(normalizeProjectDetail(detail)));
|
|
572
|
+
sets.push(`detail = detail || $${params.length}::jsonb`);
|
|
573
|
+
}
|
|
574
|
+
const { rows } = await db.query(
|
|
575
|
+
`UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
|
|
576
|
+
params
|
|
577
|
+
);
|
|
578
|
+
return rows[0] || null;
|
|
579
|
+
}
|
|
580
|
+
|
|
482
581
|
// Record an instance's domain — the ONLY columns this touches (task 1002692;
|
|
483
582
|
// `releasing` added by task 1003237). Status changes stay setInstanceStatus's job;
|
|
484
583
|
// keeping this narrow means the domain-attach route can never drift an instance's
|
|
@@ -630,7 +729,7 @@ async function listAllInstances(db, { status } = {}) {
|
|
|
630
729
|
// re-fetches the existing row and returns { created:false } so the ROUTE can
|
|
631
730
|
// decide (same owner → idempotent re-request; different owner → 409). Never
|
|
632
731
|
// silently reassigns ownership.
|
|
633
|
-
async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type }) {
|
|
732
|
+
async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail }) {
|
|
634
733
|
const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
|
|
635
734
|
// Unknown/absent → 'greenfield', matching the column default. Falling back to the
|
|
636
735
|
// SAFE mode matters: greenfield refuses to push over existing history (no --force
|
|
@@ -639,15 +738,22 @@ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShap
|
|
|
639
738
|
const mode = ONBOARD_MODES.includes(onboardMode) ? onboardMode : 'greenfield';
|
|
640
739
|
// Type is a declaration — off-vocabulary or absent stores null, never a guess.
|
|
641
740
|
const declaredType = PROJECT_TYPES.includes(type) ? type : null;
|
|
741
|
+
// The optional project-detail step (task 1002334). Skipping it is the SUPPORTED
|
|
742
|
+
// default, so both of these legitimately land empty — a null description is what
|
|
743
|
+
// leaves the project dark-matter until its owner fills it in (ADR 0182 D4), and
|
|
744
|
+
// updateInstanceDetail is the way back.
|
|
745
|
+
const told = normalizeDescription(description);
|
|
746
|
+
const answers = normalizeProjectDetail(detail);
|
|
642
747
|
const cost = costEstimateUsd({ hostingShape: shape, tier });
|
|
643
748
|
try {
|
|
644
749
|
const { rows } = await db.query(
|
|
645
750
|
`INSERT INTO provisioning_instances
|
|
646
|
-
(owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type)
|
|
647
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
751
|
+
(owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type, description, detail)
|
|
752
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
|
|
648
753
|
RETURNING *`,
|
|
649
754
|
[ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
|
|
650
|
-
dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType
|
|
755
|
+
dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType,
|
|
756
|
+
told, JSON.stringify(answers)]
|
|
651
757
|
);
|
|
652
758
|
return { instance: rows[0], created: true };
|
|
653
759
|
} catch (e) {
|
|
@@ -959,10 +1065,11 @@ module.exports = {
|
|
|
959
1065
|
SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
|
|
960
1066
|
RESTART_FROM, RESTART_SHAPES,
|
|
961
1067
|
TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
|
|
962
|
-
SETTINGS_VOCAB, DEFAULT_SETTINGS,
|
|
1068
|
+
SETTINGS_VOCAB, DEFAULT_SETTINGS, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
|
|
963
1069
|
// pure
|
|
964
1070
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
965
1071
|
defaultDomainForSlug, isReservedHostname, effectiveSettings,
|
|
1072
|
+
normalizeDescription, normalizeProjectDetail, projectDetail,
|
|
966
1073
|
// pure — GitHub App Manifest flow (task 2080)
|
|
967
1074
|
githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
|
|
968
1075
|
// data access
|
|
@@ -970,6 +1077,7 @@ module.exports = {
|
|
|
970
1077
|
getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
|
|
971
1078
|
// data access — verification overlay (task 1002696)
|
|
972
1079
|
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
1080
|
+
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
|
|
973
1081
|
createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
|
|
974
1082
|
enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
|
|
975
1083
|
recordEvent, instanceStateForSlug, costLedger,
|
|
@@ -86,6 +86,30 @@ function ownerFromTargetRef(ref) {
|
|
|
86
86
|
return m ? m[1] : null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// The project-detail answers, validated LOUDLY (task 1002334). `detail` arrives as
|
|
90
|
+
// a nested object, and the strict body validator only reaches top-level fields — so
|
|
91
|
+
// without this a typo'd answer key would be dropped by normalizeProjectDetail and
|
|
92
|
+
// the caller would be told nothing, which is exactly the silent-drop failure the
|
|
93
|
+
// strict validator exists to prevent (and that a re-request's type_ignored receipt
|
|
94
|
+
// was written to avoid). Returns null when clean, or a { code, message } to fail
|
|
95
|
+
// with. Both the create route and PATCH …/detail run it, so they cannot drift.
|
|
96
|
+
function badProjectDetail(detail) {
|
|
97
|
+
if (detail === undefined || detail === null) return null;
|
|
98
|
+
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
|
99
|
+
return { code: 'bad_detail', message: 'detail must be an object of answers.' };
|
|
100
|
+
}
|
|
101
|
+
const vocab = provisioning.PROJECT_DETAIL_VOCAB;
|
|
102
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
103
|
+
if (!Object.prototype.hasOwnProperty.call(vocab, key)) {
|
|
104
|
+
return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(vocab).join(', ')}.` };
|
|
105
|
+
}
|
|
106
|
+
if (!vocab[key].includes(value)) {
|
|
107
|
+
return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')}.` };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
// Minimal HTML escaper for the callback landing page (no template engine here).
|
|
90
114
|
function esc(s) {
|
|
91
115
|
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
@@ -388,8 +412,16 @@ module.exports = function provisioningRoutes() {
|
|
|
388
412
|
onboard_mode: { type: 'string', maxLength: 20 },
|
|
389
413
|
// the declared project Type (task 1002772) — optional, closed vocabulary
|
|
390
414
|
type: { type: 'string', enum: [...provisioning.PROJECT_TYPES] },
|
|
415
|
+
// The optional project-detail step (task 1002334) — "tell us about your
|
|
416
|
+
// project", skippable everywhere. Absent means SKIPPED, which is a supported
|
|
417
|
+
// default: the project simply stays dark-matter until a description exists
|
|
418
|
+
// (ADR 0182 D4), and PATCH …/detail is the way back.
|
|
419
|
+
description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
|
|
420
|
+
detail: { type: 'object' },
|
|
391
421
|
})) return;
|
|
392
422
|
const body = req.body || {};
|
|
423
|
+
const detailFault = badProjectDetail(body.detail);
|
|
424
|
+
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
393
425
|
const slug = String(body.slug || '').trim().toLowerCase();
|
|
394
426
|
if (!provisioning.isValidSlug(slug)) {
|
|
395
427
|
return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
|
|
@@ -472,6 +504,9 @@ module.exports = function provisioningRoutes() {
|
|
|
472
504
|
targetRef: body.target_ref, hostingShape: shape,
|
|
473
505
|
domain, tier: body.tier, onboardMode,
|
|
474
506
|
type: body.type,
|
|
507
|
+
// The optional detail step's answers (task 1002334) — absent means the
|
|
508
|
+
// owner skipped it, which is the supported default, not an error.
|
|
509
|
+
description: body.description, detail: body.detail,
|
|
475
510
|
});
|
|
476
511
|
// Slug already taken by ANOTHER builder — refuse (never reassign ownership).
|
|
477
512
|
if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
|
|
@@ -525,6 +560,16 @@ module.exports = function provisioningRoutes() {
|
|
|
525
560
|
hint: "a re-request never changes an existing instance's declared type",
|
|
526
561
|
};
|
|
527
562
|
}
|
|
563
|
+
// …and the same for the detail answers (task 1002334). Unlike type this one
|
|
564
|
+
// HAS a mutation route, so the receipt names it instead of just refusing —
|
|
565
|
+
// otherwise an owner who came back to fill in the description they skipped
|
|
566
|
+
// would be told nothing and left dark-matter with no idea why.
|
|
567
|
+
let detailIgnored;
|
|
568
|
+
if (!created && (body.description !== undefined || body.detail !== undefined)) {
|
|
569
|
+
detailIgnored = {
|
|
570
|
+
hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
528
573
|
res.status(created ? 201 : 200).json({
|
|
529
574
|
instance: provisioning.publicInstance(instance),
|
|
530
575
|
open_intent: intent ? { action: intent.action, state: intent.state } : null,
|
|
@@ -533,6 +578,7 @@ module.exports = function provisioningRoutes() {
|
|
|
533
578
|
...(domainIgnored ? { domain_ignored: domainIgnored } : {}),
|
|
534
579
|
...(noAddressIgnored ? { no_address_ignored: noAddressIgnored } : {}),
|
|
535
580
|
...(typeIgnored ? { type_ignored: typeIgnored } : {}),
|
|
581
|
+
...(detailIgnored ? { detail_ignored: detailIgnored } : {}),
|
|
536
582
|
});
|
|
537
583
|
} catch (err) {
|
|
538
584
|
log.error('[provisioning] POST /provisioning/instances', err);
|
|
@@ -644,6 +690,65 @@ module.exports = function provisioningRoutes() {
|
|
|
644
690
|
}
|
|
645
691
|
});
|
|
646
692
|
|
|
693
|
+
// ── The optional project detail (task 1002334, goal 1000046) ──────────────
|
|
694
|
+
// PATCH /provisioning/instances/:id/detail — answer (or re-answer) the
|
|
695
|
+
// "tell us about your project" step after creation. Body: any subset of
|
|
696
|
+
// { description, detail }. rank: any authenticated builder (own resource;
|
|
697
|
+
// archon may change any — same gate as settings).
|
|
698
|
+
//
|
|
699
|
+
// THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the
|
|
700
|
+
// project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is
|
|
701
|
+
// a supported default only because there is a way back. Without this route
|
|
702
|
+
// "optional" would mean "permanently unpublishable", i.e. a dead end, which is
|
|
703
|
+
// precisely what criterion `proj-detail-interview` refuses.
|
|
704
|
+
//
|
|
705
|
+
// Deliberately NOT a settings-apply: nothing here is a policy the running
|
|
706
|
+
// instance obeys, so there is no web.env to patch and no restart to queue. The
|
|
707
|
+
// answers live on the platform row until a catalog row materializes (the
|
|
708
|
+
// provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that
|
|
709
|
+
// reads `description` to compute publishability and name what is still missing.
|
|
710
|
+
router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
|
|
711
|
+
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
712
|
+
if (!id) return;
|
|
713
|
+
if (validateOrRespond(req, res, {
|
|
714
|
+
description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
|
|
715
|
+
detail: { type: 'object' },
|
|
716
|
+
})) return;
|
|
717
|
+
const body = req.body || {};
|
|
718
|
+
const detailFault = badProjectDetail(body.detail);
|
|
719
|
+
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
720
|
+
if (body.description === undefined && body.detail === undefined) {
|
|
721
|
+
return res.fail('bad_detail', { status: 400,
|
|
722
|
+
message: 'The body carries nothing to change — send description, detail, or both.' });
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
726
|
+
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
727
|
+
const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
728
|
+
// An empty description is CLEARING it, not a no-op string: normalize here so
|
|
729
|
+
// the column never holds '' — a blank string would read as answered to a
|
|
730
|
+
// `description IS NOT NULL` publish check and put an empty husk on the map.
|
|
731
|
+
const patch = {};
|
|
732
|
+
if (body.description !== undefined) patch.description = provisioning.normalizeDescription(body.description);
|
|
733
|
+
if (body.detail !== undefined) patch.detail = body.detail;
|
|
734
|
+
const updated = await provisioning.updateInstanceDetail(pool, id, patch);
|
|
735
|
+
await provisioning.recordEvent(pool, {
|
|
736
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'detail',
|
|
737
|
+
detail: `project detail changed: ${Object.keys(patch).join(', ')}`,
|
|
738
|
+
actor,
|
|
739
|
+
}).catch(() => {});
|
|
740
|
+
res.json({
|
|
741
|
+
ok: true,
|
|
742
|
+
description: updated.description || null,
|
|
743
|
+
detail: provisioning.projectDetail(updated),
|
|
744
|
+
options: provisioning.PROJECT_DETAIL_VOCAB,
|
|
745
|
+
});
|
|
746
|
+
} catch (err) {
|
|
747
|
+
log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
|
|
748
|
+
res.fail('detail_update_failed', { status: 500, message: 'internal error' });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
647
752
|
// ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
|
|
648
753
|
// The foundation the manage-page wave hangs off: effective settings are the
|
|
649
754
|
// DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
|
|
@@ -298,5 +298,89 @@ await ta('updateInstanceSettings: jsonb MERGE (||) + updated_at, parameterized,
|
|
|
298
298
|
assert.deepEqual(params, [18, JSON.stringify({ joinability: 'open' })]);
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
console.log('');
|
|
302
|
+
console.log('the optional project-detail interview (BV1.R15, task 1002334):');
|
|
303
|
+
|
|
304
|
+
t('normalizeDescription: collapses whitespace, caps, and empty means ABSENT (never an empty string)', () => {
|
|
305
|
+
const NL = String.fromCharCode(10);
|
|
306
|
+
assert.equal(P.normalizeDescription(' A co-op farming' + NL + ' game. '), 'A co-op farming game.');
|
|
307
|
+
// null, not '' — the publish gate reads `description IS NOT NULL`, so a blank
|
|
308
|
+
// string stored in a nullable column would publish an empty husk (ADR 0182 D4)
|
|
309
|
+
assert.equal(P.normalizeDescription(' '), null);
|
|
310
|
+
assert.equal(P.normalizeDescription(''), null);
|
|
311
|
+
assert.equal(P.normalizeDescription(null), null);
|
|
312
|
+
assert.equal(P.normalizeDescription(42), null);
|
|
313
|
+
assert.equal(P.normalizeDescription('x'.repeat(P.DESCRIPTION_MAX + 50)).length, P.DESCRIPTION_MAX);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
t('normalizeProjectDetail: on-vocabulary keys only — an unknown key or value is DROPPED, never defaulted', () => {
|
|
317
|
+
assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'community' }), { team_shape: 'community' });
|
|
318
|
+
assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'enormous' }), {}, 'an off-vocabulary answer is not stored');
|
|
319
|
+
assert.deepEqual(P.normalizeProjectDetail({ favourite_colour: 'blue' }), {}, 'an unknown question is not stored');
|
|
320
|
+
// skipping is the supported default, so every not-an-object shape reads as {}
|
|
321
|
+
for (const skipped of [undefined, null, 'solo', ['solo'], 7]) {
|
|
322
|
+
assert.deepEqual(P.normalizeProjectDetail(skipped), {}, 'a skipped interview stores {}');
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
t('projectDetail: a stored value that has since LEFT the vocabulary never leaks out of the read surface', () => {
|
|
327
|
+
assert.deepEqual(P.projectDetail({ detail: { team_shape: 'solo' } }), { team_shape: 'solo' });
|
|
328
|
+
assert.deepEqual(P.projectDetail({ detail: { team_shape: 'guild' } }), {});
|
|
329
|
+
assert.deepEqual(P.projectDetail({}), {}, 'a row predating the migration reads as skipped');
|
|
330
|
+
assert.deepEqual(P.projectDetail(null), {});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
t('PROJECT_DETAIL_VOCAB carries NO defaults — an unanswered question stays unanswered', () => {
|
|
334
|
+
// The deliberate asymmetry with SETTINGS_VOCAB: a setting is a policy the
|
|
335
|
+
// instance must obey, so it needs a default; a detail answer is something the
|
|
336
|
+
// owner SAID, and a default would feed R17 a recommendation nobody asked for.
|
|
337
|
+
assert.equal(P.DEFAULT_PROJECT_DETAIL, undefined, 'there is no defaults table to drift from');
|
|
338
|
+
for (const vocab of Object.values(P.PROJECT_DETAIL_VOCAB)) {
|
|
339
|
+
assert.ok(Array.isArray(vocab) && vocab.length, 'every question has a closed answer list');
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
t('publicInstance surfaces both halves, and both are legitimately empty when skipped', () => {
|
|
344
|
+
const answered = P.publicInstance({ id: 1, slug: 'mercury', description: 'A thing.', detail: { team_shape: 'solo' } });
|
|
345
|
+
assert.equal(answered.description, 'A thing.');
|
|
346
|
+
assert.deepEqual(answered.detail, { team_shape: 'solo' });
|
|
347
|
+
const skipped = P.publicInstance({ id: 2, slug: 'venus' });
|
|
348
|
+
assert.equal(skipped.description, null, 'no description is null — what keeps the project dark-matter');
|
|
349
|
+
assert.deepEqual(skipped.detail, {}, 'and a skipped interview reads as {}, never undefined');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await ta('createInstance: the answers ride the INSERT, and a skipped interview inserts null + {}', async () => {
|
|
353
|
+
let params = null;
|
|
354
|
+
const db = { query: async (q, p) => { params = p; return { rows: [{ id: 3 }] }; } };
|
|
355
|
+
await P.createInstance(db, {
|
|
356
|
+
ownerBuilderId: 1, slug: 'mercury', description: ' A co-op game. ',
|
|
357
|
+
detail: { team_shape: 'community', bogus: 'x' },
|
|
358
|
+
});
|
|
359
|
+
assert.equal(params[11], 'A co-op game.', 'the description is normalized before it is stored');
|
|
360
|
+
assert.equal(params[12], JSON.stringify({ team_shape: 'community' }), 'and off-vocabulary keys never reach the column');
|
|
361
|
+
await P.createInstance(db, { ownerBuilderId: 1, slug: 'venus' });
|
|
362
|
+
assert.equal(params[11], null, 'a skipped interview stores null — not an empty string');
|
|
363
|
+
assert.equal(params[12], '{}');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await ta('updateInstanceDetail: detail MERGES, description REPLACES, and neither is written unasked', async () => {
|
|
367
|
+
let sql = '', params = null;
|
|
368
|
+
const db = { query: async (q, p) => { sql = q; params = p; return { rows: [{ id: 18 }] }; } };
|
|
369
|
+
|
|
370
|
+
await P.updateInstanceDetail(db, 18, { detail: { team_shape: 'small-team' } });
|
|
371
|
+
assert.ok(sql.includes('detail = detail || $2::jsonb'), 'one answer does not erase the others');
|
|
372
|
+
assert.ok(!sql.includes('description ='), 'an unsent description is left alone, never nulled');
|
|
373
|
+
assert.deepEqual(params, [18, JSON.stringify({ team_shape: 'small-team' })]);
|
|
374
|
+
|
|
375
|
+
await P.updateInstanceDetail(db, 18, { description: 'Filled in later.' });
|
|
376
|
+
assert.ok(sql.includes('description = $2'), 'the way back for a skipped step');
|
|
377
|
+
assert.ok(!sql.includes('detail = detail'), 'and it touches no answers it was not given');
|
|
378
|
+
|
|
379
|
+
// clearing is allowed and un-publishes — the honest consequence of deleting
|
|
380
|
+
// half the identity gate, not a bug to guard against
|
|
381
|
+
await P.updateInstanceDetail(db, 18, { description: null });
|
|
382
|
+
assert.deepEqual(params, [18, null]);
|
|
383
|
+
});
|
|
384
|
+
|
|
301
385
|
console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
|
|
302
386
|
process.exit(failed ? 1 : 0);
|