@bongos/core 1.19.508 → 1.19.509
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 +35 -25
- package/.claude/skills/new-project/SKILL.md +2 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0182-project-visibility-two-axes.md +1 -0
- package/docs/api/openapi.json +5 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +210 -207
- package/docs/copy-registry.json +297 -269
- package/docs/module-api-changelog.md +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 +51 -5
- 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/publish_gate.mjs +167 -0
|
@@ -1453,5 +1453,7 @@ 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)
|
|
1456
1458
|
---------------------------------------------------------------------------
|
|
1457
1459
|
```
|
|
@@ -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):');
|
|
@@ -262,12 +262,48 @@ function registerProvisioningSeams() {
|
|
|
262
262
|
api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Is this project on the public map, and if not, what is it still missing?
|
|
266
|
+
// (R16, task 1002335 — the surface R15 pointed at by name.)
|
|
267
|
+
//
|
|
268
|
+
// The rule itself is ADR 0182 D4's and lives in `platform-identity`; this module
|
|
269
|
+
// reaches it through the kernel port `project.publishGate` rather than keeping a
|
|
270
|
+
// copy, because a second copy of a two-field rule is exactly the drift that
|
|
271
|
+
// module's single SQL definition exists to prevent (ADR 0083 forbids the import).
|
|
272
|
+
//
|
|
273
|
+
// resolveOptional, not resolve, and NOT declared in `consumes` (the lifecycle →
|
|
274
|
+
// reward precedent): platform-identity is hub-only (`default: false`), and the
|
|
275
|
+
// public map is a HUB concept — an instance running provisioning without it has no
|
|
276
|
+
// map to be on. The honest answer there is "no verdict" (null), never a locally
|
|
277
|
+
// invented one, and every surface below hides the section rather than guessing.
|
|
278
|
+
//
|
|
279
|
+
// The MAPPING is the one part this module owns: an instance's `slug` IS its
|
|
280
|
+
// project's name on this plane — there is no `name` column and a slug can never be
|
|
281
|
+
// empty — so the only identity field this plane can be missing is the description
|
|
282
|
+
// the optional About step (task 1002334) asks for.
|
|
283
|
+
function publishVerdict(row) {
|
|
284
|
+
const gate = api.resolveOptional('project.publishGate');
|
|
285
|
+
if (!gate || !row) return null;
|
|
286
|
+
const { publishable, missing } = gate.evaluate({ name: row.slug, description: row.description });
|
|
287
|
+
// The phrase travels with the verdict so the owner-facing surfaces and the hub's
|
|
288
|
+
// own refusal cannot word one rule two different ways.
|
|
289
|
+
return { publishable, missing, missing_phrase: gate.missingPhrase(missing) };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The owner-facing projection every instance read returns: the allow-listed row
|
|
293
|
+
// plus the derived publish verdict. One helper so a new read cannot ship the row
|
|
294
|
+
// without the verdict and leave an owner unable to see why they are dark matter.
|
|
295
|
+
function ownerInstance(row) {
|
|
296
|
+
return { ...provisioning.publicInstance(row), publish: publishVerdict(row) };
|
|
297
|
+
}
|
|
298
|
+
|
|
265
299
|
// Project an instance row for the fleet view — the owner identity + the same
|
|
266
|
-
//
|
|
300
|
+
// projection an owner sees, verdict included: "why is this project not on the map"
|
|
301
|
+
// is a support question, and an operator who has to re-derive the answer will
|
|
302
|
+
// eventually derive a different one.
|
|
267
303
|
function fleetInstance(row) {
|
|
268
304
|
return {
|
|
269
305
|
owner: { id: row.owner_builder_id, github_login: row.github_login, display_name: row.display_name, rank: row.rank },
|
|
270
|
-
...
|
|
306
|
+
...ownerInstance(row),
|
|
271
307
|
};
|
|
272
308
|
}
|
|
273
309
|
|
|
@@ -575,7 +611,7 @@ module.exports = function provisioningRoutes() {
|
|
|
575
611
|
};
|
|
576
612
|
}
|
|
577
613
|
res.status(created ? 201 : 200).json({
|
|
578
|
-
instance:
|
|
614
|
+
instance: ownerInstance(instance),
|
|
579
615
|
open_intent: intent ? { action: intent.action, state: intent.state } : null,
|
|
580
616
|
queued: intentCreated,
|
|
581
617
|
created,
|
|
@@ -647,7 +683,7 @@ module.exports = function provisioningRoutes() {
|
|
|
647
683
|
for (const row of rows) {
|
|
648
684
|
const open = await provisioning.getOpenIntent(pool, row.id);
|
|
649
685
|
instances.push({
|
|
650
|
-
...
|
|
686
|
+
...ownerInstance(row),
|
|
651
687
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
652
688
|
});
|
|
653
689
|
}
|
|
@@ -684,7 +720,7 @@ module.exports = function provisioningRoutes() {
|
|
|
684
720
|
// panel recovers it from server state after a reload — not from sessionStorage.
|
|
685
721
|
const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
|
|
686
722
|
res.json({
|
|
687
|
-
instance:
|
|
723
|
+
instance: ownerInstance(inst),
|
|
688
724
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
689
725
|
github_app: provisioning.publicGithubApp(manifest),
|
|
690
726
|
});
|
|
@@ -746,6 +782,11 @@ module.exports = function provisioningRoutes() {
|
|
|
746
782
|
description: updated.description || null,
|
|
747
783
|
detail: provisioning.projectDetail(updated),
|
|
748
784
|
options: provisioning.PROJECT_DETAIL_VOCAB,
|
|
785
|
+
// The whole point of the step, answered in the same breath (R16, task
|
|
786
|
+
// 1002335): an owner who just wrote a description learns HERE that the
|
|
787
|
+
// project is now on the map, and one who cleared it learns it just left.
|
|
788
|
+
// Re-deriving it from a follow-up GET would let the two answers disagree.
|
|
789
|
+
publish: publishVerdict(updated),
|
|
749
790
|
});
|
|
750
791
|
} catch (err) {
|
|
751
792
|
log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
|
|
@@ -1342,3 +1383,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1342
1383
|
|
|
1343
1384
|
// Re-export the seam registrar for tests that assert the port is registered.
|
|
1344
1385
|
module.exports.registerProvisioningSeams = registerProvisioningSeams;
|
|
1386
|
+
// …and the publish-gate projection (task 1002335), so a test can pin the one
|
|
1387
|
+
// thing this module owns about the gate — the slug-is-the-name mapping — and
|
|
1388
|
+
// prove it answers with the REAL port rather than a copy of the rule.
|
|
1389
|
+
module.exports.publishVerdict = publishVerdict;
|
|
1390
|
+
module.exports.ownerInstance = ownerInstance;
|
|
@@ -54,6 +54,22 @@
|
|
|
54
54
|
cosmos vocabulary, scoped to #view-manage there (task 1003345) ── */
|
|
55
55
|
#view-manage .empty{text-align:left;}
|
|
56
56
|
#view-manage .empty p{margin-bottom:0;}
|
|
57
|
+
/* the publish gate's one field (task 1002335). The description is prose, so it
|
|
58
|
+
is a textarea — and it wears a skin scoped to THIS view, because the wizard's
|
|
59
|
+
field rules are scoped to #view-new and borrowing them would couple the two.
|
|
60
|
+
Same grammar as those fields, though: one bottom hairline that takes ink on
|
|
61
|
+
focus, never a box — the manage page is cosmos vocabulary throughout. */
|
|
62
|
+
#view-manage textarea{
|
|
63
|
+
width:100%;font-family:var(--font-body);font-size:15.5px;font-weight:400;color:var(--ink);line-height:1.5;
|
|
64
|
+
background:none;border:0;border-bottom:1px solid var(--rule);border-radius:0;padding:8px 0;outline:none;
|
|
65
|
+
resize:vertical;min-height:64px;display:block;
|
|
66
|
+
transition:border-color .15s ease;
|
|
67
|
+
}
|
|
68
|
+
#view-manage textarea::placeholder{color:var(--ink-faint);font-weight:400;}
|
|
69
|
+
#view-manage textarea:focus{border-bottom-color:var(--ink);box-shadow:none;}
|
|
70
|
+
/* the verdict leads the card — it is the answer, not fine print */
|
|
71
|
+
#view-manage .pubVerdict{font-size:15px;font-weight:500;line-height:1.55;color:var(--ink-soft);margin-bottom:14px;}
|
|
72
|
+
#view-manage .pubVerdict b{font-weight:700;color:var(--ink);}
|
|
57
73
|
|
|
58
74
|
/* The bongo pair on the New-project view. Component-scoped custom properties,
|
|
59
75
|
never :root (C1). The drum head is SKIN — using --bg-card would turn it black
|
|
@@ -690,6 +706,29 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
690
706
|
<div class="actions" id="mActs" hidden></div>
|
|
691
707
|
</div>
|
|
692
708
|
|
|
709
|
+
<!-- live since task 1002335 (BV1.R16): the PUBLISH GATE. A project is on
|
|
710
|
+
the public map if and only if it has a name and a description — that
|
|
711
|
+
is the whole bar (ADR 0182 D4), identity only, deliberately not a
|
|
712
|
+
completeness check. Until it is met the project is DARK MATTER: off
|
|
713
|
+
the map and un-joinable. This card is the "name what is missing" half
|
|
714
|
+
of that decision, because a project that cannot be published and is
|
|
715
|
+
not told why is indistinguishable from a broken one.
|
|
716
|
+
The verdict rendered here is the SERVER's (instance.publish, derived
|
|
717
|
+
from the one gate definition platform-identity owns and hands over
|
|
718
|
+
the kernel seam) — never re-derived in the browser, which is how two
|
|
719
|
+
surfaces start telling one owner two different stories about one rule.
|
|
720
|
+
Hidden outright when the server sent no verdict: a platform with no
|
|
721
|
+
public map has nothing to say here, and inventing a local rule to
|
|
722
|
+
fill the silence is exactly what the seam exists to prevent. -->
|
|
723
|
+
<div class="card mSec" id="mPublish" hidden>
|
|
724
|
+
<h2>On the public map</h2>
|
|
725
|
+
<div id="mPub"><p class="finePrint">Reading the project…</p></div>
|
|
726
|
+
<details class="finePrint" id="mPubWhat">
|
|
727
|
+
<summary>What puts a project on the map</summary>
|
|
728
|
+
<p>Two things, and only two: a name — this project already has one — and a description of what it is. Modules, artwork, a finished setup interview: none of them are required, because the bar is there to keep empty shells off the map, not to make you finish anything. Until it is met the project is not listed publicly and nobody can ask to join it; the project itself runs exactly the same either way. Adding a description puts it on the map, and clearing one takes it back off.</p>
|
|
729
|
+
</details>
|
|
730
|
+
</div>
|
|
731
|
+
|
|
693
732
|
<!-- its address (task 1003355): the bring-your-own-domain control, moved
|
|
694
733
|
off the list row when a row shrank to view and manage. Its home is
|
|
695
734
|
here, under the ledger's address fact. The section hides itself where
|
|
@@ -4577,6 +4616,22 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4577
4616
|
: '<a href="?view=manage&id=' + esc(String(inst.id)) + '" data-manage="' + esc(String(inst.id)) + '">Give it an address on its own page</a>.');
|
|
4578
4617
|
}
|
|
4579
4618
|
|
|
4619
|
+
/* the publish gate on the LIST card (task 1002335, BV1.R16). Only the refusal
|
|
4620
|
+
earns a line here: being on the map is the expected state, and a card that
|
|
4621
|
+
announces it for every healthy project is noise — while a project that is
|
|
4622
|
+
invisible and never told why reads as broken (ADR 0182 D4). It names what is
|
|
4623
|
+
missing in the server's own words and points at the one page that fixes it.
|
|
4624
|
+
A torn-down project is silent: nothing about the public map is its problem
|
|
4625
|
+
while it is off. */
|
|
4626
|
+
function publishRow(inst) {
|
|
4627
|
+
var v = inst && inst.publish;
|
|
4628
|
+
if (!v || v.publishable) return '';
|
|
4629
|
+
if (inst.status === 'torn_down' || inst.status === 'tearing_down') return '';
|
|
4630
|
+
return 'Not on the public map yet — it has ' + esc(v.missing_phrase || 'no description') +
|
|
4631
|
+
'. <a href="?view=manage&id=' + esc(String(inst.id)) + '" data-manage="' + esc(String(inst.id)) +
|
|
4632
|
+
'">Say what it is</a> and it goes on.';
|
|
4633
|
+
}
|
|
4634
|
+
|
|
4580
4635
|
function renderCard(inst) {
|
|
4581
4636
|
/* iid is for MARKUP interpolation only (esc() = HTML escape); the DOM
|
|
4582
4637
|
lookup goes through liFor(), which CSS-escapes — the two escapes are
|
|
@@ -4598,6 +4653,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4598
4653
|
(meta.length ? '<div class="projMeta">' + meta.map(function (m) { return '<span>' + m + '</span>'; }).join('') + '</div>' : '');
|
|
4599
4654
|
var addr = addressRow(inst);
|
|
4600
4655
|
if (addr) body += '<div class="addr">' + addr + '</div>';
|
|
4656
|
+
var pub = publishRow(inst);
|
|
4657
|
+
if (pub) body += '<div class="projMsg">' + pub + '</div>';
|
|
4601
4658
|
if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
|
|
4602
4659
|
/* once a retry is queued the strip tells the story — the stale error
|
|
4603
4660
|
paragraph would just contradict it (owner walk, 2026-08-12). Also
|
|
@@ -5111,6 +5168,11 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5111
5168
|
the GitHub read: entry + tab return, never the in-flight tick */
|
|
5112
5169
|
if (!keepRepo) loadVisibility(inst);
|
|
5113
5170
|
if (!keepRepo) loadJoinability(inst);
|
|
5171
|
+
/* the publish gate (task 1002335) needs no second read — the verdict
|
|
5172
|
+
rode in on the instance itself. It sits behind the same keepRepo
|
|
5173
|
+
guard because its card holds a textarea the in-flight tick must
|
|
5174
|
+
never repaint out from under a typing owner. */
|
|
5175
|
+
if (!keepRepo) renderPublish(inst);
|
|
5114
5176
|
})
|
|
5115
5177
|
.catch(function (err) {
|
|
5116
5178
|
var m = String(err && err.message);
|
|
@@ -5256,6 +5318,101 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5256
5318
|
if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
|
|
5257
5319
|
});
|
|
5258
5320
|
|
|
5321
|
+
/* ── on the public map (task 1002335, BV1.R16): the publish gate ──────────
|
|
5322
|
+
The verdict is the SERVER's — instance.publish, derived from the ONE gate
|
|
5323
|
+
definition platform-identity owns and publishes over the kernel seam (ADR
|
|
5324
|
+
0182 D4). This page renders it and offers the one identity field that can
|
|
5325
|
+
be missing; it never re-derives the rule, because a browser-side copy is
|
|
5326
|
+
how the card and the server start disagreeing about whether a project is
|
|
5327
|
+
published. No verdict at all (an instance whose platform runs no public
|
|
5328
|
+
map) hides the card rather than guessing.
|
|
5329
|
+
|
|
5330
|
+
Rendered on ENTRY and after a save — deliberately NOT from renderManage,
|
|
5331
|
+
which the fetch-free minute tick and the in-flight watch both call: this
|
|
5332
|
+
card holds a textarea, and a repaint mid-sentence would eat what the owner
|
|
5333
|
+
is typing. Nothing the server does in the background changes the answer
|
|
5334
|
+
anyway; only this form does. ── */
|
|
5335
|
+
|
|
5336
|
+
function pubMsg(html) { $('mPub').innerHTML = html; }
|
|
5337
|
+
|
|
5338
|
+
function renderPublish(inst) {
|
|
5339
|
+
var v = inst && inst.publish;
|
|
5340
|
+
if (!v) { $('mPublish').hidden = true; return; }
|
|
5341
|
+
$('mPublish').hidden = false;
|
|
5342
|
+
var on = !!v.publishable;
|
|
5343
|
+
/* the refusal NAMES what is absent, in the server's own words (ADR 0182
|
|
5344
|
+
D4) — a project told only "no" is indistinguishable from a broken one */
|
|
5345
|
+
var verdict = on
|
|
5346
|
+
? '<b>' + esc(inst.slug) + ' is on the public map.</b> People can find it and ask to join.'
|
|
5347
|
+
: '<b>' + esc(inst.slug) + ' is not on the public map yet</b> — it has ' +
|
|
5348
|
+
esc(v.missing_phrase || 'no description') +
|
|
5349
|
+
'. Nobody can find it there or ask to join until it does.';
|
|
5350
|
+
pubMsg('<p class="pubVerdict">' + verdict + '</p>' +
|
|
5351
|
+
'<label class="wizField" for="mPubDesc"><span>What is this project?' +
|
|
5352
|
+
' <small>' + (on ? 'Change it any time.' : 'A sentence or two is plenty.') + '</small></span>' +
|
|
5353
|
+
'<textarea id="mPubDesc" maxlength="600" rows="3" placeholder="A weather simulator you can play with in the browser."></textarea></label>' +
|
|
5354
|
+
'<div class="actions"><button type="button" class="btn small" data-pub-save>' +
|
|
5355
|
+
(on ? 'Save' : 'Put it on the map') + '</button></div>' +
|
|
5356
|
+
'<div class="projMsg" id="mPubMsg" role="status" aria-live="polite"></div>');
|
|
5357
|
+
/* the owner's own prose is set as a PROPERTY, never interpolated into the
|
|
5358
|
+
markup above — what someone typed about their project is not HTML */
|
|
5359
|
+
$('mPubDesc').value = inst.description || '';
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5362
|
+
/* Saving sends the description and takes the server's verdict WHOLE. An empty
|
|
5363
|
+
box is a real answer, not a no-op: it clears the description and the project
|
|
5364
|
+
leaves the map — the honest consequence of deleting half the identity gate,
|
|
5365
|
+
which is why the receipt says so instead of just "Saved." */
|
|
5366
|
+
function doPubSave() {
|
|
5367
|
+
var ta = $('mPubDesc');
|
|
5368
|
+
if (!manageInst || !ta) return;
|
|
5369
|
+
var box = $('mPubMsg');
|
|
5370
|
+
var btn = document.querySelector('#mPub [data-pub-save]');
|
|
5371
|
+
if (btn) btn.disabled = true;
|
|
5372
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
5373
|
+
var asked = manageId; /* a stale answer must never repaint a newer project */
|
|
5374
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/detail', {
|
|
5375
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
5376
|
+
body: JSON.stringify({ description: ta.value })
|
|
5377
|
+
}).then(function (res) {
|
|
5378
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
5379
|
+
if (asked !== manageId) return;
|
|
5380
|
+
if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
|
|
5381
|
+
if (!res.ok) {
|
|
5382
|
+
var e = apiError(data, res.status);
|
|
5383
|
+
box.className = 'projMsg err';
|
|
5384
|
+
box.textContent = e.message || 'The description could not be saved — try again in a moment.';
|
|
5385
|
+
return;
|
|
5386
|
+
}
|
|
5387
|
+
manageInst.description = data.description || null;
|
|
5388
|
+
manageInst.publish = data.publish || manageInst.publish;
|
|
5389
|
+
renderPublish(manageInst);
|
|
5390
|
+
var m = $('mPubMsg'); /* the re-render replaced the box this closure held */
|
|
5391
|
+
m.className = 'projMsg ok';
|
|
5392
|
+
m.textContent = (manageInst.publish && manageInst.publish.publishable)
|
|
5393
|
+
? 'Saved — it’s on the public map.'
|
|
5394
|
+
: 'Saved. With no description it stays off the public map.';
|
|
5395
|
+
/* the list card carries the same verdict, and the owner may go back */
|
|
5396
|
+
if (byId[manageInst.id]) {
|
|
5397
|
+
byId[manageInst.id].description = manageInst.description;
|
|
5398
|
+
byId[manageInst.id].publish = manageInst.publish;
|
|
5399
|
+
}
|
|
5400
|
+
});
|
|
5401
|
+
}).catch(function () {
|
|
5402
|
+
if (asked !== manageId) return;
|
|
5403
|
+
var b = $('mPubMsg');
|
|
5404
|
+
if (b) { b.className = 'projMsg err'; b.textContent = 'Couldn’t reach the platform — check your connection and try again.'; }
|
|
5405
|
+
}).then(function () {
|
|
5406
|
+
var b = document.querySelector('#mPub [data-pub-save]');
|
|
5407
|
+
if (b) b.disabled = false;
|
|
5408
|
+
});
|
|
5409
|
+
}
|
|
5410
|
+
|
|
5411
|
+
$('mPub').addEventListener('click', function (ev) {
|
|
5412
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
5413
|
+
if (t && t.hasAttribute('data-pub-save')) doPubSave();
|
|
5414
|
+
});
|
|
5415
|
+
|
|
5259
5416
|
/* ── who can see it (task 1003043): the platform-visibility toggle. Two
|
|
5260
5417
|
reads, kept apart on purpose: the CHOICE comes from the platform's own
|
|
5261
5418
|
settings row (what the owner asked for), and the line under it reads what
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.509",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.509",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.509",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.509'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|