@bongos/core 1.19.508 → 1.19.510
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 +57 -32
- package/.claude/skills/new-project/SKILL.md +2 -0
- 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 +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0182-project-visibility-two-axes.md +1 -0
- package/docs/adr/0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md +137 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +47 -6
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +210 -207
- package/docs/copy-registry.json +297 -269
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +30 -36
- package/modules/platform-identity/publish-gate.js +103 -0
- package/modules/platform-identity/routes/projects.js +31 -4
- package/modules/platform-identity/tests/platform-identity.mjs +7 -2
- package/modules/provisioning/routes/provisioning.js +70 -5
- package/modules/provisioning/starter-bundles.js +159 -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/projects_hub.mjs +62 -0
- package/tests/provisioning_starter_bundles.mjs +268 -0
- package/tests/publish_gate.mjs +167 -0
|
@@ -1453,5 +1453,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1453
1453
|
landed since 1.19.506 with no explicit bump. run 33788726591. (task 1002620)
|
|
1454
1454
|
1.19.508 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1455
1455
|
landed since 1.19.507 with no explicit bump. run 33790206682. (task 1002620)
|
|
1456
|
+
1.19.509 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1457
|
+
landed since 1.19.508 with no explicit bump. run 33790542026. (task 1002620)
|
|
1458
|
+
1.19.510 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1459
|
+
landed since 1.19.509 with no explicit bump. run 33791237400. (task 1002620)
|
|
1456
1460
|
---------------------------------------------------------------------------
|
|
1457
1461
|
```
|
|
@@ -670,6 +670,8 @@ function createClient(opts = {}) {
|
|
|
670
670
|
getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
|
|
671
671
|
// GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
|
|
672
672
|
getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
|
|
673
|
+
// GET /provisioning/starter-bundles — rank: public — GET /provisioning/starter-bundles
|
|
674
|
+
getProvisioningStarterBundles: (args) => request("GET", "/provisioning/starter-bundles", { hasBody: false }, args),
|
|
673
675
|
// GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
|
|
674
676
|
getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
|
|
675
677
|
},
|
|
@@ -300,38 +300,18 @@ const PROJECT_TEMPLATE_WALK = ['blob-tint', 'pair-gold-wash', 'bead-obsidian-pla
|
|
|
300
300
|
// `private` and let two sources of truth for one question drift apart.
|
|
301
301
|
const PROJECT_VISIBILITIES = ['public', 'private', 'stealth'];
|
|
302
302
|
|
|
303
|
-
// The publish gate
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// The boolean alone, for predicates that do not care which field is absent.
|
|
318
|
-
function projectIsPublishable(project) {
|
|
319
|
-
return projectPublishability(project).publishable;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// The SAME gate as a SQL fragment, so the feed predicate (R06) and the owner-facing
|
|
323
|
-
// refusal (R16) cannot drift from projectPublishability() or from each other. `alias`
|
|
324
|
-
// qualifies the columns for a joined query (e.g. publishableSql('p')).
|
|
325
|
-
function publishableSql(alias) {
|
|
326
|
-
// The alias is INTERPOLATED, so it is the one thing here that could carry SQL. Callers
|
|
327
|
-
// pass a literal, but a helper that builds SQL should refuse anything that is not a
|
|
328
|
-
// bare identifier rather than trust every future caller to remember that.
|
|
329
|
-
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
330
|
-
throw new Error('publishableSql: alias must be a bare SQL identifier');
|
|
331
|
-
}
|
|
332
|
-
const q = alias ? `${alias}.` : '';
|
|
333
|
-
return `(btrim(coalesce(${q}name, '')) <> '' AND btrim(coalesce(${q}description, '')) <> '')`;
|
|
334
|
-
}
|
|
303
|
+
// The publish gate — the derived "is this project on the map at all" bar — lives in
|
|
304
|
+
// its own file (ADR 0182 D4; the account-visibility.js precedent). Every symbol is
|
|
305
|
+
// re-exported below, so this module's public surface is exactly what it was before
|
|
306
|
+
// the split and no caller has to know about it.
|
|
307
|
+
const {
|
|
308
|
+
PUBLISH_GATE_FIELDS,
|
|
309
|
+
projectPublishability,
|
|
310
|
+
projectIsPublishable,
|
|
311
|
+
publishableSql,
|
|
312
|
+
missingPhrase,
|
|
313
|
+
publishGatePort,
|
|
314
|
+
} = require('./publish-gate');
|
|
335
315
|
|
|
336
316
|
// Upsert a catalog row (keyed by origin). Called when a client registers (so every
|
|
337
317
|
// federated project is catalogued, unfeatured) and by the Archon create/curate route.
|
|
@@ -412,14 +392,26 @@ async function listAllProjects({ pool = defaultPool } = {}) {
|
|
|
412
392
|
return rows.map((r) => ({ ...r, missing: projectPublishability(r).missing }));
|
|
413
393
|
}
|
|
414
394
|
|
|
415
|
-
// Archon-only: flip the Explore feature flag
|
|
395
|
+
// Archon-only: flip the Explore feature flag — BOUNDED BY THE PUBLISH GATE (ADR 0182
|
|
396
|
+
// D3, task 1002335). Featuring may rank among projects ALREADY on the map, never put
|
|
397
|
+
// one there. The bound rides the UPDATE's own WHERE, not a read-then-write, so a
|
|
398
|
+
// description cleared mid-flight cannot slip a husk on; un-featuring is never blocked,
|
|
399
|
+
// since an already-featured husk must stay removable. Answers a VERDICT rather than a
|
|
400
|
+
// row — { project } | { blocked, missing } | { notFound } — because a bare null
|
|
401
|
+
// collapsed "the gate refused" into "no such row", and a curator is owed each.
|
|
416
402
|
async function setProjectFeatured(id, featured, { pool = defaultPool } = {}) {
|
|
403
|
+
const on = !!featured;
|
|
417
404
|
const { rows } = await pool.query(
|
|
418
405
|
`UPDATE platform_identity_projects SET featured = $2, updated_at = now()
|
|
419
|
-
WHERE id = $1
|
|
420
|
-
|
|
406
|
+
WHERE id = $1 AND ($2 = false OR ${publishableSql()})
|
|
407
|
+
RETURNING id, origin, name, featured, status`,
|
|
408
|
+
[id, on],
|
|
421
409
|
);
|
|
422
|
-
return rows[0]
|
|
410
|
+
if (rows[0]) return { project: rows[0] };
|
|
411
|
+
// Re-read only to tell the two failures apart — and, when it was the gate, to name
|
|
412
|
+
// the identity field that is still missing (D4: never merely refuse).
|
|
413
|
+
const project = await getProject(id, { pool });
|
|
414
|
+
return project ? { blocked: true, missing: project.missing } : { notFound: true };
|
|
423
415
|
}
|
|
424
416
|
|
|
425
417
|
// -------------------------------------------------------------------------
|
|
@@ -1435,6 +1427,8 @@ module.exports = {
|
|
|
1435
1427
|
projectPublishability,
|
|
1436
1428
|
projectIsPublishable,
|
|
1437
1429
|
publishableSql,
|
|
1430
|
+
missingPhrase,
|
|
1431
|
+
publishGatePort,
|
|
1438
1432
|
upsertProject,
|
|
1439
1433
|
PROJECT_TYPES,
|
|
1440
1434
|
PROJECT_TEMPLATES,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// modules/platform-identity/publish-gate.js — THE project publish gate, published
|
|
2
|
+
// in all three forms it is asked in, so no two of them can drift (ADR 0182 D4; the
|
|
3
|
+
// account-visibility.js precedent, which took this file's shape for the same reason).
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT ANSWERS: "is this project on the public map at all?" — and, when the answer
|
|
6
|
+
// is no, WHICH identity field is absent. The bar is IDENTITY ONLY: a `name` and a
|
|
7
|
+
// `description`. Not tagline, art, modules, governance, or a completed interview. It
|
|
8
|
+
// exists to keep empty husks off the map, never to enforce completeness — a higher bar
|
|
9
|
+
// would make skipping the optional project-detail step (task 1002334) a dead end
|
|
10
|
+
// rather than the supported default the goal explicitly wants.
|
|
11
|
+
//
|
|
12
|
+
// "Dark matter" — off the map and un-joinable — is the ABSENCE of a met bar, derived
|
|
13
|
+
// here and never stored. A stored fourth visibility value would compete with `private`
|
|
14
|
+
// and give one question two sources of truth that can drift apart (ADR 0182 D1).
|
|
15
|
+
//
|
|
16
|
+
// THE THREE FORMS, and why they live together:
|
|
17
|
+
// • projectPublishability(row) — the verdict + `missing`, for JS callers
|
|
18
|
+
// • publishableSql(alias) — the identical predicate as SQL, for the feed
|
|
19
|
+
// • publishGatePort — the kernel seam port, for another module
|
|
20
|
+
// A caller that reached only one of these could not have drifted; a caller that
|
|
21
|
+
// re-implements a two-field rule inevitably does. That is the whole point of the file.
|
|
22
|
+
//
|
|
23
|
+
// WHY ITS OWN FILE. Same reason account-visibility.js is: platform-identity.js sits at
|
|
24
|
+
// the file-size ratchet (scripts/gds/fitness-ratchets.js), and R16 (task 1002335) added
|
|
25
|
+
// the port + the phrase. platform-identity.js re-exports every symbol here, so the
|
|
26
|
+
// module's public surface is unchanged and no caller has to know about the split.
|
|
27
|
+
//
|
|
28
|
+
// WHAT IT IS NOT. Not the join door: `visibility ∈ (public|private|stealth)` is a
|
|
29
|
+
// stored, owner-set policy and all three of its values are VISIBLE (ADR 0182 D2). Not
|
|
30
|
+
// curation: `featured` may rank among projects already on the map, never put one there
|
|
31
|
+
// (D3) — setProjectFeatured enforces that bound with publishableSql(). Not authority.
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
// The two fields, and only these two (ADR 0182 D4, owner interview on R16).
|
|
35
|
+
const PUBLISH_GATE_FIELDS = Object.freeze(['name', 'description']);
|
|
36
|
+
|
|
37
|
+
// Is this project on the map at all? Returns the verdict AND what is missing, because
|
|
38
|
+
// ADR 0182 D4 requires the gate to NAME what is missing rather than merely refuse: a
|
|
39
|
+
// project that cannot be published and is not told why is indistinguishable from a
|
|
40
|
+
// broken one. R16 (task 1002335) renders `missing`; R06 (task 1002325) reads `publishable`.
|
|
41
|
+
function projectPublishability(project) {
|
|
42
|
+
const missing = PUBLISH_GATE_FIELDS.filter((f) => !String(project?.[f] ?? '').trim());
|
|
43
|
+
return { publishable: missing.length === 0, missing };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The boolean alone, for predicates that do not care which field is absent.
|
|
47
|
+
function projectIsPublishable(project) {
|
|
48
|
+
return projectPublishability(project).publishable;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The SAME gate as a SQL fragment, so the feed predicate (R06) and the owner-facing
|
|
52
|
+
// refusal (R16) cannot drift from projectPublishability() or from each other. `alias`
|
|
53
|
+
// qualifies the columns for a joined query (e.g. publishableSql('p')).
|
|
54
|
+
function publishableSql(alias) {
|
|
55
|
+
// The alias is INTERPOLATED, so it is the one thing here that could carry SQL. Callers
|
|
56
|
+
// pass a literal, but a helper that builds SQL should refuse anything that is not a
|
|
57
|
+
// bare identifier rather than trust every future caller to remember that.
|
|
58
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
59
|
+
throw new Error('publishableSql: alias must be a bare SQL identifier');
|
|
60
|
+
}
|
|
61
|
+
const q = alias ? `${alias}.` : '';
|
|
62
|
+
return `(btrim(coalesce(${q}name, '')) <> '' AND btrim(coalesce(${q}description, '')) <> '')`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// What is missing, as a PHRASE, so every surface that refuses says it the same way
|
|
66
|
+
// (task 1002335). ADR 0182 D4 requires the gate to NAME what is absent, and a list
|
|
67
|
+
// rendered ad hoc at each call site is how two surfaces end up telling one owner two
|
|
68
|
+
// different stories about one rule. ['description'] → "no description";
|
|
69
|
+
// ['name','description'] → "no name and no description". PURE.
|
|
70
|
+
function missingPhrase(missing) {
|
|
71
|
+
const list = (missing || []).filter(Boolean);
|
|
72
|
+
if (!list.length) return '';
|
|
73
|
+
if (list.length === 1) return `no ${list[0]}`;
|
|
74
|
+
return `no ${list.slice(0, -1).join(', no ')} and no ${list[list.length - 1]}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The gate as a KERNEL SEAM PORT — `project.publishGate` (R16, task 1002335),
|
|
78
|
+
// registered by routes/projects.js and declared in module.json.
|
|
79
|
+
//
|
|
80
|
+
// The module one-way rule (ADR 0083) forbids `provisioning` from importing this file,
|
|
81
|
+
// and provisioning is the plane an owner actually stands on BEFORE their project is
|
|
82
|
+
// ever catalogued here — so without a port it would have to keep its own copy of the
|
|
83
|
+
// rule, which is exactly the drift publishableSql() exists to prevent. One definition,
|
|
84
|
+
// reached through the kernel switchboard.
|
|
85
|
+
//
|
|
86
|
+
// `evaluate` is deliberately tolerant of a FOREIGN row shape: a consumer maps its own
|
|
87
|
+
// columns onto { name, description } — provisioning's `slug` IS its project's name —
|
|
88
|
+
// so the gate never learns another module's schema. `fields` is published so a consumer
|
|
89
|
+
// can render the bar without hardcoding it.
|
|
90
|
+
const publishGatePort = Object.freeze({
|
|
91
|
+
fields: PUBLISH_GATE_FIELDS,
|
|
92
|
+
evaluate: (project) => projectPublishability(project),
|
|
93
|
+
missingPhrase,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
PUBLISH_GATE_FIELDS,
|
|
98
|
+
projectPublishability,
|
|
99
|
+
projectIsPublishable,
|
|
100
|
+
publishableSql,
|
|
101
|
+
missingPhrase,
|
|
102
|
+
publishGatePort,
|
|
103
|
+
};
|
|
@@ -24,7 +24,23 @@ function hostOfOrigin(origin) {
|
|
|
24
24
|
try { return new URL(String(origin)).hostname.replace(/^www\./, '').toLowerCase(); } catch { return ''; }
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Register this module's publish-gate seam (R16, task 1002335; the
|
|
28
|
+
// registerProvisioningSeams precedent). Idempotent — the loader calls the factory
|
|
29
|
+
// once at boot, but tests instantiate the router repeatedly, and a port is
|
|
30
|
+
// single-provider so a second registration throws.
|
|
31
|
+
//
|
|
32
|
+
// • PROVIDES port `project.publishGate` → { fields, evaluate(project) }.
|
|
33
|
+
// Lets provisioning — the plane an owner stands on before their project is
|
|
34
|
+
// catalogued here — ask THIS module's gate whether a project is on the map,
|
|
35
|
+
// without importing it (the ADR 0083 one-way rule) and without keeping a
|
|
36
|
+
// second copy of the rule that could drift from publishableSql().
|
|
37
|
+
function registerPublishGateSeam() {
|
|
38
|
+
if (api.hasProvider('project.publishGate')) return;
|
|
39
|
+
api.registerProvider('project.publishGate', pi.publishGatePort);
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
module.exports = function projectsRoutes() {
|
|
43
|
+
registerPublishGateSeam();
|
|
28
44
|
const router = express.Router();
|
|
29
45
|
|
|
30
46
|
// rank: metic+archon — the full curation list (includes unfeatured + owner attribution).
|
|
@@ -99,15 +115,26 @@ module.exports = function projectsRoutes() {
|
|
|
99
115
|
}
|
|
100
116
|
});
|
|
101
117
|
|
|
102
|
-
// rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate)
|
|
118
|
+
// rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate),
|
|
119
|
+
// BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). Featuring ranks among
|
|
120
|
+
// projects already on the map; it is not a way ONTO it, so featuring a dark-matter
|
|
121
|
+
// row is refused — and the refusal names the identity field that is missing rather
|
|
122
|
+
// than merely saying no (D4). Un-featuring is never refused.
|
|
103
123
|
router.patch('/projects/:id/featured', requireBuilder, requirePermission('project.curate'), async (req, res) => {
|
|
104
124
|
const id = parseId(req, res, { code: 'bad_project_id', positiveInt: true });
|
|
105
125
|
if (id === null) return;
|
|
106
126
|
if (validateOrRespond(req, res, { featured: { required: true, type: 'boolean' } })) return;
|
|
107
127
|
try {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
128
|
+
const out = await pi.setProjectFeatured(id, req.body.featured, { pool });
|
|
129
|
+
if (out.notFound) return res.fail('project_not_found', 404);
|
|
130
|
+
if (out.blocked) {
|
|
131
|
+
return res.fail('not_publishable', {
|
|
132
|
+
status: 409,
|
|
133
|
+
message: `This project is not on the public map yet, so it cannot be featured — it still has ${pi.missingPhrase(out.missing)}. Featuring promotes a project that is already published; it cannot publish one.`,
|
|
134
|
+
details: { missing: out.missing },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
res.json({ project: out.project });
|
|
111
138
|
} catch (err) {
|
|
112
139
|
console.error('[platform-identity] PATCH /projects/:id/featured', err);
|
|
113
140
|
res.fail('feature_toggle_failed', { status: 500, message: 'internal error' });
|
|
@@ -286,12 +286,17 @@ await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only
|
|
|
286
286
|
assert.doesNotMatch(sel, /updated_at/, 'updated_at is curation noise — never projected');
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
|
|
289
|
+
await ta('setProjectFeatured flips the flag (Archon curation) — and answers with a VERDICT', async () => {
|
|
290
290
|
const pool = mockPool([{ rows: [{ id: '1', featured: true }] }]);
|
|
291
291
|
const out = await P.setProjectFeatured(1, true, { pool });
|
|
292
|
-
assert.equal(out.featured, true);
|
|
292
|
+
assert.equal(out.project.featured, true);
|
|
293
293
|
assert.match(pool.calls[0].text, /UPDATE platform_identity_projects SET featured = \$2/);
|
|
294
294
|
assert.deepEqual(pool.calls[0].params, [1, true]);
|
|
295
|
+
// Bounded by the publish gate since task 1002335 (ADR 0182 D3): featuring may
|
|
296
|
+
// promote among projects already on the map, never put one there. The full
|
|
297
|
+
// refusal contract — blocked vs notFound, and the un-feature escape — lives in
|
|
298
|
+
// tests/publish_gate.mjs.
|
|
299
|
+
assert.ok(pool.calls[0].text.includes(P.publishableSql()), 'the gate rides in the WHERE');
|
|
295
300
|
});
|
|
296
301
|
|
|
297
302
|
console.log('\nproject visibility: the stored door + the DERIVED publish gate (BV1.R04, ADR 0182):');
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// POST /provisioning/instances/:id/force-teardown — tear down ANY instance
|
|
22
22
|
// public:
|
|
23
23
|
// GET /provisioning/health — liveness + core version
|
|
24
|
+
// GET /provisioning/onboard-plan — the canonical onboarding step sequence
|
|
25
|
+
// GET /provisioning/starter-bundles — the curated module preset per project type
|
|
24
26
|
//
|
|
25
27
|
// Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
|
|
26
28
|
// 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
|
|
@@ -33,6 +35,7 @@ const auth = api;
|
|
|
33
35
|
const { pool, validateOrRespond, parseId } = api;
|
|
34
36
|
const provisioning = require('../provisioning');
|
|
35
37
|
const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
38
|
+
const { ALWAYS_ON_CORE, allStarterBundles } = require('../starter-bundles');
|
|
36
39
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
37
40
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
38
41
|
const log = api.logger('provisioning');
|
|
@@ -262,12 +265,48 @@ function registerProvisioningSeams() {
|
|
|
262
265
|
api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
|
|
263
266
|
}
|
|
264
267
|
|
|
268
|
+
// Is this project on the public map, and if not, what is it still missing?
|
|
269
|
+
// (R16, task 1002335 — the surface R15 pointed at by name.)
|
|
270
|
+
//
|
|
271
|
+
// The rule itself is ADR 0182 D4's and lives in `platform-identity`; this module
|
|
272
|
+
// reaches it through the kernel port `project.publishGate` rather than keeping a
|
|
273
|
+
// copy, because a second copy of a two-field rule is exactly the drift that
|
|
274
|
+
// module's single SQL definition exists to prevent (ADR 0083 forbids the import).
|
|
275
|
+
//
|
|
276
|
+
// resolveOptional, not resolve, and NOT declared in `consumes` (the lifecycle →
|
|
277
|
+
// reward precedent): platform-identity is hub-only (`default: false`), and the
|
|
278
|
+
// public map is a HUB concept — an instance running provisioning without it has no
|
|
279
|
+
// map to be on. The honest answer there is "no verdict" (null), never a locally
|
|
280
|
+
// invented one, and every surface below hides the section rather than guessing.
|
|
281
|
+
//
|
|
282
|
+
// The MAPPING is the one part this module owns: an instance's `slug` IS its
|
|
283
|
+
// project's name on this plane — there is no `name` column and a slug can never be
|
|
284
|
+
// empty — so the only identity field this plane can be missing is the description
|
|
285
|
+
// the optional About step (task 1002334) asks for.
|
|
286
|
+
function publishVerdict(row) {
|
|
287
|
+
const gate = api.resolveOptional('project.publishGate');
|
|
288
|
+
if (!gate || !row) return null;
|
|
289
|
+
const { publishable, missing } = gate.evaluate({ name: row.slug, description: row.description });
|
|
290
|
+
// The phrase travels with the verdict so the owner-facing surfaces and the hub's
|
|
291
|
+
// own refusal cannot word one rule two different ways.
|
|
292
|
+
return { publishable, missing, missing_phrase: gate.missingPhrase(missing) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The owner-facing projection every instance read returns: the allow-listed row
|
|
296
|
+
// plus the derived publish verdict. One helper so a new read cannot ship the row
|
|
297
|
+
// without the verdict and leave an owner unable to see why they are dark matter.
|
|
298
|
+
function ownerInstance(row) {
|
|
299
|
+
return { ...provisioning.publicInstance(row), publish: publishVerdict(row) };
|
|
300
|
+
}
|
|
301
|
+
|
|
265
302
|
// Project an instance row for the fleet view — the owner identity + the same
|
|
266
|
-
//
|
|
303
|
+
// projection an owner sees, verdict included: "why is this project not on the map"
|
|
304
|
+
// is a support question, and an operator who has to re-derive the answer will
|
|
305
|
+
// eventually derive a different one.
|
|
267
306
|
function fleetInstance(row) {
|
|
268
307
|
return {
|
|
269
308
|
owner: { id: row.owner_builder_id, github_login: row.github_login, display_name: row.display_name, rank: row.rank },
|
|
270
|
-
...
|
|
309
|
+
...ownerInstance(row),
|
|
271
310
|
};
|
|
272
311
|
}
|
|
273
312
|
|
|
@@ -575,7 +614,7 @@ module.exports = function provisioningRoutes() {
|
|
|
575
614
|
};
|
|
576
615
|
}
|
|
577
616
|
res.status(created ? 201 : 200).json({
|
|
578
|
-
instance:
|
|
617
|
+
instance: ownerInstance(instance),
|
|
579
618
|
open_intent: intent ? { action: intent.action, state: intent.state } : null,
|
|
580
619
|
queued: intentCreated,
|
|
581
620
|
created,
|
|
@@ -647,7 +686,7 @@ module.exports = function provisioningRoutes() {
|
|
|
647
686
|
for (const row of rows) {
|
|
648
687
|
const open = await provisioning.getOpenIntent(pool, row.id);
|
|
649
688
|
instances.push({
|
|
650
|
-
...
|
|
689
|
+
...ownerInstance(row),
|
|
651
690
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
652
691
|
});
|
|
653
692
|
}
|
|
@@ -684,7 +723,7 @@ module.exports = function provisioningRoutes() {
|
|
|
684
723
|
// panel recovers it from server state after a reload — not from sessionStorage.
|
|
685
724
|
const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
|
|
686
725
|
res.json({
|
|
687
|
-
instance:
|
|
726
|
+
instance: ownerInstance(inst),
|
|
688
727
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
689
728
|
github_app: provisioning.publicGithubApp(manifest),
|
|
690
729
|
});
|
|
@@ -746,6 +785,11 @@ module.exports = function provisioningRoutes() {
|
|
|
746
785
|
description: updated.description || null,
|
|
747
786
|
detail: provisioning.projectDetail(updated),
|
|
748
787
|
options: provisioning.PROJECT_DETAIL_VOCAB,
|
|
788
|
+
// The whole point of the step, answered in the same breath (R16, task
|
|
789
|
+
// 1002335): an owner who just wrote a description learns HERE that the
|
|
790
|
+
// project is now on the map, and one who cleared it learns it just left.
|
|
791
|
+
// Re-deriving it from a follow-up GET would let the two answers disagree.
|
|
792
|
+
publish: publishVerdict(updated),
|
|
749
793
|
});
|
|
750
794
|
} catch (err) {
|
|
751
795
|
log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
|
|
@@ -1222,6 +1266,22 @@ module.exports = function provisioningRoutes() {
|
|
|
1222
1266
|
res.json({ mode, steps: stepsForShape(onboardPlan(mode, { domain }), shape) });
|
|
1223
1267
|
});
|
|
1224
1268
|
|
|
1269
|
+
// GET /provisioning/starter-bundles — the curated module preset for each declared
|
|
1270
|
+
// project type (BV1.R19, task 1002338; goal 1000046 criterion `proj-module-select`).
|
|
1271
|
+
// Returns the always-on core once, then one resolved bundle per type:
|
|
1272
|
+
// `{ type, label, summary, core, bundle, modules }`. The whole table in one read,
|
|
1273
|
+
// because the creation picker (R20, task 1002339) re-preselects as the owner flips
|
|
1274
|
+
// the type question and must not refetch to do it; R17's rule engine reads the same
|
|
1275
|
+
// shape so a recommendation and a preselect can never name different modules.
|
|
1276
|
+
// A DECLARATION, not a query — modules/provisioning/starter-bundles.js is pure, so
|
|
1277
|
+
// this route touches no database and no per-request state.
|
|
1278
|
+
// rank: public — the signed-out hub wizard (/projects?view=new) reads it while
|
|
1279
|
+
// choosing a type, exactly like onboard-plan above; no write, no secret, and the
|
|
1280
|
+
// module catalog it names is already public at GET /public/modules.
|
|
1281
|
+
router.get('/provisioning/starter-bundles', (req, res) => {
|
|
1282
|
+
res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles() });
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1225
1285
|
// GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST
|
|
1226
1286
|
// (ADR 0111 §4, task P4 #1945). Caddy's global `on_demand_tls { ask … }` calls
|
|
1227
1287
|
// this before fetching a Let's Encrypt cert for an SNI it doesn't already have:
|
|
@@ -1342,3 +1402,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1342
1402
|
|
|
1343
1403
|
// Re-export the seam registrar for tests that assert the port is registered.
|
|
1344
1404
|
module.exports.registerProvisioningSeams = registerProvisioningSeams;
|
|
1405
|
+
// …and the publish-gate projection (task 1002335), so a test can pin the one
|
|
1406
|
+
// thing this module owns about the gate — the slug-is-the-name mapping — and
|
|
1407
|
+
// prove it answers with the REAL port rather than a copy of the rule.
|
|
1408
|
+
module.exports.publishVerdict = publishVerdict;
|
|
1409
|
+
module.exports.ownerInstance = ownerInstance;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// modules/provisioning/starter-bundles.js — the curated STARTER BUNDLE for each
|
|
2
|
+
// declared project type (BV1.R19, task 1002338; goal 1000046 criterion
|
|
3
|
+
// `proj-module-select`).
|
|
4
|
+
//
|
|
5
|
+
// WHY. Every Cloud Bongos instance boots with the same always-on core — the
|
|
6
|
+
// methodology modules a project cannot sensibly run without. What differs between
|
|
7
|
+
// a game, a research project and a business is the handful of OPT-IN modules on
|
|
8
|
+
// top, and an owner creating their first project has no way to know which of
|
|
9
|
+
// those they want before they have used any of them. A bundle is that opinion,
|
|
10
|
+
// written down once: a project type maps to a short list of opt-in module keys,
|
|
11
|
+
// PRESELECTED for the owner and freely toggled off in the creation picker (R20,
|
|
12
|
+
// task 1002339). It is the answer the flow STARTS from, never a decision it makes
|
|
13
|
+
// on the owner's behalf, and never a thing an owner is stuck with — modules are
|
|
14
|
+
// added and removed after creation too (R23, task 1002342).
|
|
15
|
+
//
|
|
16
|
+
// THE ARROW RUNS TYPE -> MODULES, NEVER BACK. Task 1002772 rejected inferring a
|
|
17
|
+
// project's type from its enabled module set: flags flip all through setup, so a
|
|
18
|
+
// half-configured project would show a confidently wrong type. The type is
|
|
19
|
+
// DECLARED at creation and everything downstream reads off that declaration. R15
|
|
20
|
+
// wrote the same rule into the interview's vocabulary — "the module bundle R19
|
|
21
|
+
// recommends comes off the separately-declared `type`, so the interview
|
|
22
|
+
// deliberately does not ask it a second time" (PROJECT_DETAIL_VOCAB in
|
|
23
|
+
// provisioning.js beside this file).
|
|
24
|
+
//
|
|
25
|
+
// A BUNDLE IS NOT A PUBLISH PRECONDITION. ADR 0182 D4 fixes the publish bar at
|
|
26
|
+
// identity alone — a name and a description, "not tagline, art, modules,
|
|
27
|
+
// governance, or a completed interview" — so taking or declining a bundle must
|
|
28
|
+
// never change whether a project is on the map.
|
|
29
|
+
//
|
|
30
|
+
// PURE — no I/O, no core imports, no sibling-module imports; safe to require from
|
|
31
|
+
// a route, a script or a test, exactly like onboard-plan.js beside it. That purity
|
|
32
|
+
// is why the module keys below are DECLARED here rather than read off the live
|
|
33
|
+
// catalog (src/module-loader/catalog.js, which a module may not import — the
|
|
34
|
+
// one-way rule, ADR 0083). tests/provisioning_starter_bundles.mjs is the pin that
|
|
35
|
+
// keeps the declaration true: it reads every modules/*/module.json off disk and
|
|
36
|
+
// fails the build if a bundle names a module that does not exist, if a bundle
|
|
37
|
+
// names an always-on module (a preselect that toggles nothing), or if
|
|
38
|
+
// ALWAYS_ON_CORE drifts from the manifests' `default: true` set.
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
// The modules every instance gets whichever type it declares — the manifests'
|
|
43
|
+
// `default: true` set (docs/modules-contract.md: "core-domain modules that are
|
|
44
|
+
// `default: true` (always on unless disabled)"). They are the FLOOR a bundle
|
|
45
|
+
// layers on, not part of any bundle, and they are listed here so a creation form
|
|
46
|
+
// can show an owner what they already have without a second round trip.
|
|
47
|
+
const ALWAYS_ON_CORE = Object.freeze([
|
|
48
|
+
'autonomy',
|
|
49
|
+
'builder-settings',
|
|
50
|
+
'copy-desk',
|
|
51
|
+
'economy',
|
|
52
|
+
'government',
|
|
53
|
+
'grading',
|
|
54
|
+
'hall-ui',
|
|
55
|
+
'ideas',
|
|
56
|
+
'lifecycle',
|
|
57
|
+
'memory',
|
|
58
|
+
'onboarding',
|
|
59
|
+
'public-landing',
|
|
60
|
+
'security',
|
|
61
|
+
'sessions',
|
|
62
|
+
'status-ui',
|
|
63
|
+
'ui-design',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// The control plane's own modules. `platform-identity` is the hub identity
|
|
67
|
+
// provider ("enabled ONLY on the hub instance") and `provisioning` is the surface
|
|
68
|
+
// that stands other instances up — a project created THROUGH the hub is a
|
|
69
|
+
// consumer of both, never a second copy of them. Named so the integrity test can
|
|
70
|
+
// refuse them by construction rather than by anyone remembering.
|
|
71
|
+
const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning']);
|
|
72
|
+
|
|
73
|
+
// The curated presets, keyed by the declared project type (the closed vocabulary
|
|
74
|
+
// owner-decided 2026-08-13 and mirrored in provisioning.PROJECT_TYPES + the
|
|
75
|
+
// provisioning_007 CHECK). `label` and `summary` are the owner-facing copy the
|
|
76
|
+
// picker renders — plain language, no module jargon, because the owner choosing
|
|
77
|
+
// here has not met any of these modules yet.
|
|
78
|
+
//
|
|
79
|
+
// The lists are SHORT on purpose. A preselect an owner has to audit is worse than
|
|
80
|
+
// none, and today's opt-in catalog is genuinely small (`agents`, `dev-box`,
|
|
81
|
+
// `discord`); a bundle grows as the catalog does, which is exactly why this is a
|
|
82
|
+
// declaration and not a rule engine. Enabling `dev-box` opens the door to a cloud
|
|
83
|
+
// dev environment — it provisions nothing and bills nothing until a builder asks
|
|
84
|
+
// for a box — so preselecting it costs the owner nothing but a toggle.
|
|
85
|
+
const STARTER_BUNDLES = Object.freeze({
|
|
86
|
+
game: Object.freeze({
|
|
87
|
+
label: 'Game starter',
|
|
88
|
+
summary: 'Somewhere for players to gather, and a place to walk a change through before anyone plays it.',
|
|
89
|
+
modules: Object.freeze(['dev-box', 'discord']),
|
|
90
|
+
}),
|
|
91
|
+
research: Object.freeze({
|
|
92
|
+
label: 'Research starter',
|
|
93
|
+
summary: 'Extra agents to run the reading and the experiments the question needs.',
|
|
94
|
+
modules: Object.freeze(['agents']),
|
|
95
|
+
}),
|
|
96
|
+
business: Object.freeze({
|
|
97
|
+
label: 'Business starter',
|
|
98
|
+
summary: 'A place to try a change before customers see it, and agents to look it over first.',
|
|
99
|
+
modules: Object.freeze(['agents', 'dev-box']),
|
|
100
|
+
}),
|
|
101
|
+
'non-profit': Object.freeze({
|
|
102
|
+
label: 'Non-profit starter',
|
|
103
|
+
summary: 'Somewhere the community around the cause can gather and be heard.',
|
|
104
|
+
modules: Object.freeze(['discord']),
|
|
105
|
+
}),
|
|
106
|
+
'not-sure': Object.freeze({
|
|
107
|
+
label: 'Core only',
|
|
108
|
+
summary: 'Just the core, and nothing else to decide today. Add modules whenever you know what you need.',
|
|
109
|
+
modules: Object.freeze([]),
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// The copy an undeclared project gets. Deliberately NOT one of the bundles above:
|
|
114
|
+
// `type` is nullable because every row predates the field and must never be
|
|
115
|
+
// back-filled with a guess (migration provisioning_007), so "nobody said" has to
|
|
116
|
+
// read differently from "the owner said not sure".
|
|
117
|
+
const UNDECLARED = Object.freeze({
|
|
118
|
+
label: 'Core only',
|
|
119
|
+
summary: 'No project type yet, so nothing is preselected — every project starts with the core.',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Resolve one project type to what the creation flow should preselect:
|
|
123
|
+
// `{ type, label, summary, core, bundle, modules }`, where `bundle` is the type's
|
|
124
|
+
// opt-in additions, `core` the always-on floor, and `modules` the union in that
|
|
125
|
+
// order — what the owner ends up with if they change nothing. An unknown or
|
|
126
|
+
// absent type resolves to the core with `type: null`; this never throws and never
|
|
127
|
+
// invents a type, because a recommendation built on an answer nobody gave is
|
|
128
|
+
// worse than no recommendation. Arrays are fresh copies, so a caller may sort or
|
|
129
|
+
// splice its result without corrupting the frozen declaration.
|
|
130
|
+
function bundleForProjectType(type) {
|
|
131
|
+
const declared = typeof type === 'string' && Object.prototype.hasOwnProperty.call(STARTER_BUNDLES, type)
|
|
132
|
+
? type
|
|
133
|
+
: null;
|
|
134
|
+
const preset = declared ? STARTER_BUNDLES[declared] : UNDECLARED;
|
|
135
|
+
const bundle = declared ? [...STARTER_BUNDLES[declared].modules] : [];
|
|
136
|
+
return {
|
|
137
|
+
type: declared,
|
|
138
|
+
label: preset.label,
|
|
139
|
+
summary: preset.summary,
|
|
140
|
+
core: [...ALWAYS_ON_CORE],
|
|
141
|
+
bundle,
|
|
142
|
+
modules: [...ALWAYS_ON_CORE, ...bundle],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Every curated bundle, resolved, in declaration order — the whole table in one
|
|
147
|
+
// read. The creation picker wants all of them up front so flipping the type
|
|
148
|
+
// question re-preselects without another round trip.
|
|
149
|
+
function allStarterBundles() {
|
|
150
|
+
return Object.keys(STARTER_BUNDLES).map(bundleForProjectType);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
ALWAYS_ON_CORE,
|
|
155
|
+
HUB_ONLY_MODULES,
|
|
156
|
+
STARTER_BUNDLES,
|
|
157
|
+
bundleForProjectType,
|
|
158
|
+
allStarterBundles,
|
|
159
|
+
};
|