@bongos/core 1.19.511 → 1.19.513
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 +85 -50
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +40 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +158 -151
- package/docs/copy-registry.json +313 -229
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/provisioning/provisioning.js +27 -1
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/modules/provisioning/tests/provisioning.mjs +17 -14
- package/modules/public-landing/public/projects.html +125 -0
- package/modules/ui-design/kit/serve.js +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/currency_label.mjs +5 -4
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/project_door_ui.mjs +267 -0
- package/tests/projects_hub.mjs +73 -1
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provision_settings_apply.mjs +8 -7
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/provisioning_settings.mjs +65 -6
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +10 -8
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/watch_applications_queue.mjs +381 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// modules/provisioning/recommendations.js — the RULE ENGINE that turns what an
|
|
2
|
+
// owner said during project creation into a recommended starter bundle, a
|
|
3
|
+
// recommended governance preset, and an invite suggestion (BV1.R17, task
|
|
4
|
+
// 1002336; goal 1000046 criteria `proj-module-select`, `proj-governance-reco`,
|
|
5
|
+
// `proj-invite-builders`).
|
|
6
|
+
//
|
|
7
|
+
// WHY. Goal 1000046 pins the near term as RULE-BASED and defers the semantic
|
|
8
|
+
// layer: "near-term recommendations are rule-based and module search is lexical;
|
|
9
|
+
// the AI/embeddings layer … is a deferred follow-up captured in the idea inbox"
|
|
10
|
+
// (idea 1000552, task 1003500). So this file is a set of small declarative
|
|
11
|
+
// TABLES — condition -> effect -> a one-line `reason` in the owner's own words —
|
|
12
|
+
// and a resolver over them. Not a model, not a scoring function, and nothing
|
|
13
|
+
// that reads the free-text description: a rule an owner cannot be told the
|
|
14
|
+
// reason for is a recommendation they cannot audit.
|
|
15
|
+
//
|
|
16
|
+
// TWO INPUTS, AND ONLY TWO. `type` is the DECLARED project type (a column, the
|
|
17
|
+
// closed set task 1002772 fixed) and `team_shape` is the one closed-answer key
|
|
18
|
+
// the R15 interview collects (`PROJECT_DETAIL_VOCAB` in provisioning.js beside
|
|
19
|
+
// this file). The interview deliberately does not re-ask the type (ADR 0237 §2),
|
|
20
|
+
// so the two arrive separately and this is the file that puts them together.
|
|
21
|
+
//
|
|
22
|
+
// SKIPPING IS A SUPPORTED ANSWER, NOT A GAP. The interview is optional (ADR 0182
|
|
23
|
+
// D4), so every table has a stated DEFAULT for "nobody said" and the defaults are
|
|
24
|
+
// deliberately the narrowest option — the type's own bundle unadjusted, the
|
|
25
|
+
// governance preset that grants nobody anything, and no invites. An off-vocabulary
|
|
26
|
+
// team shape reads as no answer for the same reason `normalizeProjectDetail` drops
|
|
27
|
+
// one: inventing an answer nobody gave feeds the recommendation a lie. R18 (task
|
|
28
|
+
// 1002337) is the live proof of exactly that split.
|
|
29
|
+
//
|
|
30
|
+
// PURE — no I/O, no core import, no sibling-module import; requires only its own
|
|
31
|
+
// module's `starter-bundles.js` and `provisioning.js`, both of which are pure
|
|
32
|
+
// themselves. That is what lets a route, a script and a test read one answer.
|
|
33
|
+
// It is also why `invites.suggested` is always empty here: ranking builders means
|
|
34
|
+
// querying the leaderboard, which lives in a sibling module (`economy`) this one
|
|
35
|
+
// may not import (the one-way rule, ADR 0083). The engine states WHAT to look for
|
|
36
|
+
// in `invites.seeking`; R28 (task 1002347) owns the lookup that fills it.
|
|
37
|
+
//
|
|
38
|
+
// Decision record: ADR 0239.
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const { bundleForProjectType, ALWAYS_ON_CORE } = require('./starter-bundles');
|
|
43
|
+
const { PROJECT_DETAIL_VOCAB } = require('./provisioning');
|
|
44
|
+
|
|
45
|
+
// How the team shape adjusts the type's starter bundle, in application order.
|
|
46
|
+
// Each entry is one condition (`teamShape`) and one effect on ONE opt-in module,
|
|
47
|
+
// carrying the sentence a picker shows when it fires. Deliberately tiny: ADR 0237
|
|
48
|
+
// already made the per-type preset the opinion, and today's opt-in catalog is
|
|
49
|
+
// three modules wide (`agents`, `dev-box`, `discord`) — so this table's job is to
|
|
50
|
+
// correct a preset the owner's answer contradicts, not to re-derive it.
|
|
51
|
+
//
|
|
52
|
+
// A rule may only ever name an OPT-IN module. Adding or removing an always-on one
|
|
53
|
+
// would be a toggle that toggles nothing (ADR 0237 §3's argument, and
|
|
54
|
+
// `tests/provisioning_recommendations.mjs` fails the build over it).
|
|
55
|
+
const TEAM_SHAPE_RULES = Object.freeze([
|
|
56
|
+
Object.freeze({
|
|
57
|
+
id: 'a-community-needs-somewhere-to-gather',
|
|
58
|
+
teamShape: 'community',
|
|
59
|
+
effect: 'add',
|
|
60
|
+
module: 'discord',
|
|
61
|
+
reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
|
|
62
|
+
}),
|
|
63
|
+
Object.freeze({
|
|
64
|
+
id: 'a-team-shares-one-environment',
|
|
65
|
+
teamShape: 'small-team',
|
|
66
|
+
effect: 'add',
|
|
67
|
+
module: 'dev-box',
|
|
68
|
+
reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
|
|
69
|
+
}),
|
|
70
|
+
Object.freeze({
|
|
71
|
+
id: 'building-alone-needs-no-shared-environment',
|
|
72
|
+
teamShape: 'solo',
|
|
73
|
+
effect: 'remove',
|
|
74
|
+
module: 'dev-box',
|
|
75
|
+
reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// The governance presets, by key. A preset is a NAME plus the one sentence that
|
|
80
|
+
// says what living under it feels like — never a set of roles or permissions,
|
|
81
|
+
// because there is nowhere to put them: the governance module is planned (goal
|
|
82
|
+
// 1000030) and unbuilt, which criterion `proj-governance-reco` says out loud.
|
|
83
|
+
// R25 (task 1002344) is the spike that fits these to the real module when it
|
|
84
|
+
// exists; until then every recommendation carries `applied: false` so a form can
|
|
85
|
+
// label it a preview without hardcoding that fact in four places.
|
|
86
|
+
const GOVERNANCE_PRESETS = Object.freeze({
|
|
87
|
+
'owner-only': Object.freeze({
|
|
88
|
+
label: 'You decide',
|
|
89
|
+
preview: 'You hold every role. Nothing waits on anyone else, and nothing changes without you.',
|
|
90
|
+
}),
|
|
91
|
+
'trusted-team': Object.freeze({
|
|
92
|
+
label: 'A trusted team',
|
|
93
|
+
preview: 'Everyone you invite can build and ship. You keep the decisions that spend money or change the shape of the project.',
|
|
94
|
+
}),
|
|
95
|
+
'open-contribution': Object.freeze({
|
|
96
|
+
label: 'Open contribution',
|
|
97
|
+
preview: 'Anyone can turn up and propose work. A small circle you choose reviews what actually lands.',
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Team shape -> governance preset. One row per shape the interview can return;
|
|
102
|
+
// the integrity test fails the build if a shape in `PROJECT_DETAIL_VOCAB` has no
|
|
103
|
+
// row here, so a fourth answer can never be added and silently unhandled.
|
|
104
|
+
const GOVERNANCE_RULES = Object.freeze([
|
|
105
|
+
Object.freeze({
|
|
106
|
+
teamShape: 'solo',
|
|
107
|
+
preset: 'owner-only',
|
|
108
|
+
reason: 'You said you are building this alone.',
|
|
109
|
+
}),
|
|
110
|
+
Object.freeze({
|
|
111
|
+
teamShape: 'small-team',
|
|
112
|
+
preset: 'trusted-team',
|
|
113
|
+
reason: 'You said a small team is building this, so the people you invite can get on with it without asking first.',
|
|
114
|
+
}),
|
|
115
|
+
Object.freeze({
|
|
116
|
+
teamShape: 'community',
|
|
117
|
+
preset: 'open-contribution',
|
|
118
|
+
reason: 'You said an open community is building this, so people can arrive and propose work without an invite.',
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// What a skipped interview gets. The narrowest preset on purpose: widening later
|
|
123
|
+
// costs one setting, while a project that quietly granted strangers a role it was
|
|
124
|
+
// never asked to grant is not recoverable by changing the setting back.
|
|
125
|
+
const GOVERNANCE_DEFAULT = Object.freeze({
|
|
126
|
+
preset: 'owner-only',
|
|
127
|
+
reason: 'Nobody has said who is building this yet, so this is the narrowest setup — widen it whenever you like.',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Team shape -> who is worth suggesting, as a SEEK — the kind of builder to look
|
|
131
|
+
// for and how many. This engine is pure and cannot run the lookup (the
|
|
132
|
+
// leaderboard lives in the `economy` module, which this one may not import), so
|
|
133
|
+
// `seeking` is the seam R28 (task 1002347) reads and `suggested` stays empty.
|
|
134
|
+
// `seeking: null` means "suggest nobody", which is a real answer and not a
|
|
135
|
+
// missing one.
|
|
136
|
+
const INVITE_RULES = Object.freeze([
|
|
137
|
+
Object.freeze({
|
|
138
|
+
teamShape: 'solo',
|
|
139
|
+
seeking: null,
|
|
140
|
+
reason: 'You said you are building alone, so nobody is suggested. You can invite anyone later from the project settings.',
|
|
141
|
+
}),
|
|
142
|
+
Object.freeze({
|
|
143
|
+
teamShape: 'small-team',
|
|
144
|
+
seeking: Object.freeze({ kind: 'top-builders', limit: 3 }),
|
|
145
|
+
reason: 'A small team is the shape that fills fastest, so here is a short list of the builders with the strongest recent record.',
|
|
146
|
+
}),
|
|
147
|
+
Object.freeze({
|
|
148
|
+
teamShape: 'community',
|
|
149
|
+
seeking: Object.freeze({ kind: 'top-builders', limit: 5 }),
|
|
150
|
+
reason: 'An open community wants a wider first circle, so the list runs longer. Guilds join it once guilds can be invited.',
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
// What a skipped interview gets: nobody. Same reasoning as the governance
|
|
155
|
+
// default — an invite suggested on an answer nobody gave is a stranger the owner
|
|
156
|
+
// never asked for.
|
|
157
|
+
const INVITE_DEFAULT = Object.freeze({
|
|
158
|
+
seeking: null,
|
|
159
|
+
reason: 'Nobody has said who is building this yet, so nothing is suggested. Invites can be sent any time from the project settings.',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The interview's answer, or null. Anything outside `PROJECT_DETAIL_VOCAB` —
|
|
163
|
+
// absent, misspelled, or a value that was valid when it was stored and has since
|
|
164
|
+
// left the vocabulary — reads as NO ANSWER, exactly as `normalizeProjectDetail`
|
|
165
|
+
// drops it on the way in. PURE.
|
|
166
|
+
function normalizeTeamShape(value) {
|
|
167
|
+
return PROJECT_DETAIL_VOCAB.team_shape.includes(value) ? value : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
|
|
171
|
+
// list plus the adjustments that ACTUALLY changed it — a rule whose module the
|
|
172
|
+
// bundle already carries (or already lacks) records nothing, because a stated
|
|
173
|
+
// reason for a change that did not happen is a lie the owner cannot check.
|
|
174
|
+
// Never touches the always-on core. PURE.
|
|
175
|
+
function adjustBundle(bundle, teamShape) {
|
|
176
|
+
const modules = [...bundle];
|
|
177
|
+
const adjustments = [];
|
|
178
|
+
for (const rule of TEAM_SHAPE_RULES) {
|
|
179
|
+
if (rule.teamShape !== teamShape) continue;
|
|
180
|
+
const at = modules.indexOf(rule.module);
|
|
181
|
+
if (rule.effect === 'add' && at === -1) modules.push(rule.module);
|
|
182
|
+
else if (rule.effect === 'remove' && at !== -1) modules.splice(at, 1);
|
|
183
|
+
else continue;
|
|
184
|
+
adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
|
|
185
|
+
}
|
|
186
|
+
return { modules, adjustments };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The whole recommendation for one set of creation answers:
|
|
190
|
+
//
|
|
191
|
+
// {
|
|
192
|
+
// type, teamShape, // what was actually declared/answered (null = not)
|
|
193
|
+
// tailored, // did a DETAIL answer shape any of this?
|
|
194
|
+
// bundle: { type, label, summary, core, bundle, modules, adjustments },
|
|
195
|
+
// governance: { preset, label, preview, reason, applied },
|
|
196
|
+
// invites: { suggested, seeking, reason },
|
|
197
|
+
// }
|
|
198
|
+
//
|
|
199
|
+
// `bundle` is ADR 0237 §5's shape with one key added, so R20's picker and this
|
|
200
|
+
// engine can never name different modules; `adjustments` is the delta the rules
|
|
201
|
+
// made to it and is empty when nothing fired. `tailored` is false when the
|
|
202
|
+
// interview was skipped — the machine-readable half of criterion
|
|
203
|
+
// `proj-detail-interview`'s "completing it produces tailored recommendations;
|
|
204
|
+
// skipping produces sensible defaults", and what R18 (task 1002337) proves.
|
|
205
|
+
//
|
|
206
|
+
// Accepts a flat object because the two answers live in two places: `type` is a
|
|
207
|
+
// column and `team_shape` is a key of the `detail` jsonb, so a caller holding a
|
|
208
|
+
// row passes `{ type: row.type, ...projectDetail(row) }`. Never throws and never
|
|
209
|
+
// invents an answer. PURE — result arrays and objects are fresh, so a caller may
|
|
210
|
+
// sort or splice them without corrupting the frozen tables.
|
|
211
|
+
function recommendationsForDetails(answers) {
|
|
212
|
+
const given = answers && typeof answers === 'object' && !Array.isArray(answers) ? answers : {};
|
|
213
|
+
const teamShape = normalizeTeamShape(given.team_shape);
|
|
214
|
+
const preset = bundleForProjectType(given.type);
|
|
215
|
+
const { modules, adjustments } = adjustBundle(preset.bundle, teamShape);
|
|
216
|
+
|
|
217
|
+
const gov = GOVERNANCE_RULES.find((r) => r.teamShape === teamShape) || GOVERNANCE_DEFAULT;
|
|
218
|
+
const invite = INVITE_RULES.find((r) => r.teamShape === teamShape) || INVITE_DEFAULT;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
type: preset.type,
|
|
222
|
+
teamShape,
|
|
223
|
+
tailored: teamShape !== null,
|
|
224
|
+
bundle: {
|
|
225
|
+
type: preset.type,
|
|
226
|
+
label: preset.label,
|
|
227
|
+
summary: preset.summary,
|
|
228
|
+
core: [...ALWAYS_ON_CORE],
|
|
229
|
+
bundle: modules,
|
|
230
|
+
modules: [...ALWAYS_ON_CORE, ...modules],
|
|
231
|
+
adjustments,
|
|
232
|
+
},
|
|
233
|
+
governance: {
|
|
234
|
+
preset: gov.preset,
|
|
235
|
+
label: GOVERNANCE_PRESETS[gov.preset].label,
|
|
236
|
+
preview: GOVERNANCE_PRESETS[gov.preset].preview,
|
|
237
|
+
reason: gov.reason,
|
|
238
|
+
// Always false until the governance module ships (goal 1000030). A named
|
|
239
|
+
// recommendation, never an applied setting — criterion `proj-governance-reco`.
|
|
240
|
+
applied: false,
|
|
241
|
+
},
|
|
242
|
+
invites: {
|
|
243
|
+
// The seam, not an omission: ranking builders is a leaderboard query this
|
|
244
|
+
// pure file may not run. R28 (task 1002347) fills this from `seeking`.
|
|
245
|
+
suggested: [],
|
|
246
|
+
seeking: invite.seeking ? { ...invite.seeking } : null,
|
|
247
|
+
reason: invite.reason,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
TEAM_SHAPE_RULES,
|
|
254
|
+
GOVERNANCE_PRESETS,
|
|
255
|
+
GOVERNANCE_RULES,
|
|
256
|
+
GOVERNANCE_DEFAULT,
|
|
257
|
+
INVITE_RULES,
|
|
258
|
+
INVITE_DEFAULT,
|
|
259
|
+
recommendationsForDetails,
|
|
260
|
+
};
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// GET /provisioning/health — liveness + core version
|
|
24
24
|
// GET /provisioning/onboard-plan — the canonical onboarding step sequence
|
|
25
25
|
// GET /provisioning/starter-bundles — the curated module preset per project type
|
|
26
|
+
// GET /provisioning/recommendations — bundle/governance/invite recommendations from the answers
|
|
26
27
|
//
|
|
27
28
|
// Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
|
|
28
29
|
// 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
|
|
@@ -36,6 +37,7 @@ const { pool, validateOrRespond, parseId } = api;
|
|
|
36
37
|
const provisioning = require('../provisioning');
|
|
37
38
|
const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
38
39
|
const { ALWAYS_ON_CORE, allStarterBundles } = require('../starter-bundles');
|
|
40
|
+
const { recommendationsForDetails } = require('../recommendations');
|
|
39
41
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
40
42
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
41
43
|
const log = api.logger('provisioning');
|
|
@@ -1282,6 +1284,33 @@ module.exports = function provisioningRoutes() {
|
|
|
1282
1284
|
res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles() });
|
|
1283
1285
|
});
|
|
1284
1286
|
|
|
1287
|
+
// GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer>
|
|
1288
|
+
// — the RULE-BASED recommendation for one set of creation answers (BV1.R17, task
|
|
1289
|
+
// 1002336; goal 1000046 criteria `proj-module-select`, `proj-governance-reco`,
|
|
1290
|
+
// `proj-invite-builders`). Returns `{ recommendations: { type, teamShape, tailored,
|
|
1291
|
+
// bundle, governance, invites } }` — the starter bundle adjusted by the interview
|
|
1292
|
+
// answer with a `reason` per adjustment, a named governance preset with its preview
|
|
1293
|
+
// (`applied: false` until the governance module ships, goal 1000030), and the invite
|
|
1294
|
+
// SEEK that R28 (task 1002347) turns into names.
|
|
1295
|
+
//
|
|
1296
|
+
// The two answers arrive separately because they are stored separately: `type` is a
|
|
1297
|
+
// column and `team_shape` a key of the `detail` jsonb, so a caller passes back
|
|
1298
|
+
// exactly what it holds. Both are optional and neither is validated by rejection —
|
|
1299
|
+
// an absent or off-vocabulary value reads as NO ANSWER and yields the stated
|
|
1300
|
+
// defaults, which is the supported path (ADR 0182 D4: skipping the interview is a
|
|
1301
|
+
// real answer), never a 400.
|
|
1302
|
+
//
|
|
1303
|
+
// A pure read over modules/provisioning/recommendations.js — no database, no
|
|
1304
|
+
// per-request state. rank: public — the same absent gate as `starter-bundles` and
|
|
1305
|
+
// `onboard-plan` above: the signed-out hub wizard (/projects?view=new) asks while
|
|
1306
|
+
// the owner is still answering, before any row exists to be own-scoped against, and
|
|
1307
|
+
// the answer is a declaration that names nothing private.
|
|
1308
|
+
router.get('/provisioning/recommendations', (req, res) => {
|
|
1309
|
+
const type = typeof req.query.type === 'string' ? req.query.type : null;
|
|
1310
|
+
const teamShape = typeof req.query.team_shape === 'string' ? req.query.team_shape : null;
|
|
1311
|
+
res.json({ recommendations: recommendationsForDetails({ type, team_shape: teamShape }) });
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1285
1314
|
// GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST
|
|
1286
1315
|
// (ADR 0111 §4, task P4 #1945). Caddy's global `on_demand_tls { ask … }` calls
|
|
1287
1316
|
// this before fetching a Let's Encrypt cert for an SNI it doesn't already have:
|
|
@@ -263,21 +263,24 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
|
|
|
263
263
|
console.log('\nPer-project settings (task 1003040):');
|
|
264
264
|
|
|
265
265
|
t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
|
|
266
|
-
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply' });
|
|
267
|
-
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply' });
|
|
268
|
-
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply' });
|
|
266
|
+
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
267
|
+
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
268
|
+
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
269
269
|
assert.deepEqual(
|
|
270
270
|
P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
|
|
271
|
-
{ platform_visibility: 'gated', joinability: 'apply' },
|
|
271
|
+
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public' },
|
|
272
272
|
'a stored key overlays only itself'
|
|
273
273
|
);
|
|
274
274
|
assert.deepEqual(
|
|
275
|
-
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x' } }),
|
|
276
|
-
{ platform_visibility: 'public', joinability: 'invite_only' },
|
|
275
|
+
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted' } }),
|
|
276
|
+
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' },
|
|
277
277
|
'off-vocabulary values fall back to the default; unknown keys never leak out'
|
|
278
278
|
);
|
|
279
279
|
assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
|
|
280
|
-
{ platform_visibility: 'public', joinability: 'apply' }, 'a non-object settings value reads as defaults');
|
|
280
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'a non-object settings value reads as defaults');
|
|
281
|
+
// the join door reads back like any other policy key (task 1002324)
|
|
282
|
+
assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
|
|
283
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' }, 'a stored door overlays only itself');
|
|
281
284
|
});
|
|
282
285
|
|
|
283
286
|
t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
|
|
@@ -336,7 +339,7 @@ t('the planet (task 1003347): PLANET_TEMPLATES mirrors platform-identity\'s PROJ
|
|
|
336
339
|
assert.equal(P.PLANET_TEMPLATES.length, 36);
|
|
337
340
|
assert.deepEqual([...P.PLANET_ACCENTS], ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent']);
|
|
338
341
|
for (const a of P.PLANET_ACCENTS) assert.doesNotMatch(a, /^#|rgb|hsl/, 'a name, never a colour (the Fifteen Rule)');
|
|
339
|
-
assert.deepEqual(Object.keys(P.PLANET_VOCAB), ['planet_template', 'planet_accent']);
|
|
342
|
+
assert.deepEqual(Object.keys(P.PLANET_VOCAB), ['planet_template', 'planet_accent', 'card_backdrop']);
|
|
340
343
|
// the deliberate asymmetry: the planet is NOT a setting — no default, never in SETTINGS_VOCAB
|
|
341
344
|
for (const k of Object.keys(P.PLANET_VOCAB)) {
|
|
342
345
|
assert.ok(!(k in P.SETTINGS_VOCAB), `${k} is not a policy the instance obeys`);
|
|
@@ -345,12 +348,12 @@ t('the planet (task 1003347): PLANET_TEMPLATES mirrors platform-identity\'s PROJ
|
|
|
345
348
|
});
|
|
346
349
|
|
|
347
350
|
t('planetChoice: null for a bare row or an unset key; a stored on-vocabulary value reads back; junk reads null, never leaks', () => {
|
|
348
|
-
assert.deepEqual(P.planetChoice(null), { planet_template: null, planet_accent: null });
|
|
349
|
-
assert.deepEqual(P.planetChoice({}), { planet_template: null, planet_accent: null });
|
|
350
|
-
assert.deepEqual(P.planetChoice({ settings: { platform_visibility: 'gated' } }), { planet_template: null, planet_accent: null });
|
|
351
|
-
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' } }), { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' });
|
|
352
|
-
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'cube', planet_accent: '#fff' } }), { planet_template: null, planet_accent: null });
|
|
353
|
-
assert.deepEqual(P.planetChoice({ settings: ['nope'] }), { planet_template: null, planet_accent: null });
|
|
351
|
+
assert.deepEqual(P.planetChoice(null), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
352
|
+
assert.deepEqual(P.planetChoice({}), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
353
|
+
assert.deepEqual(P.planetChoice({ settings: { platform_visibility: 'gated' } }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
354
|
+
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' } }), { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit', card_backdrop: null });
|
|
355
|
+
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'cube', planet_accent: '#fff' } }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
356
|
+
assert.deepEqual(P.planetChoice({ settings: ['nope'] }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
354
357
|
});
|
|
355
358
|
|
|
356
359
|
t('publicInstance carries the planet — both keys, null when unset — so the list and the manage page read it off the row', () => {
|
|
@@ -792,6 +792,25 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
792
792
|
</details>
|
|
793
793
|
</div>
|
|
794
794
|
|
|
795
|
+
<!-- live since task 1002324: the join door (ADR 0182 D1) — how the project
|
|
796
|
+
stands on the platform's map. The third axis, deliberately its own
|
|
797
|
+
section: the two above are about the project's OWN pages and its OWN
|
|
798
|
+
membership; this one is about the map everyone else reads. Same two
|
|
799
|
+
reads, key for key, as its siblings: the choice shown is the platform's
|
|
800
|
+
REQUEST (its settings row) and the save's reply is a receipt, never a
|
|
801
|
+
claim the project already runs it. Plain labels only — Public, Private,
|
|
802
|
+
Stealth — because the space words are the drawn orb's, not the copy's
|
|
803
|
+
(the goal's interview decision). The fold is a SIBLING of the volatile
|
|
804
|
+
slot so no re-render can drop it. -->
|
|
805
|
+
<div class="card mSec" id="mDoorState">
|
|
806
|
+
<h2>On the map</h2>
|
|
807
|
+
<div id="mDoor"><p class="finePrint">Reading the project’s settings…</p></div>
|
|
808
|
+
<details class="finePrint" id="mDoorWhat">
|
|
809
|
+
<summary>What each choice changes</summary>
|
|
810
|
+
<p><b>Public</b> — the project is on the map under its name, and anyone who finds it can join. <b>Private</b> — it is on the map under its name, but nobody joins on their own: people ask, and you decide. <b>Stealth</b> — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.</p>
|
|
811
|
+
</details>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
795
814
|
<!-- the planet picker (task 1003347; direction record §16, v1 the bands): the
|
|
796
815
|
owner's own choice of how the platform draws this project — the sky,
|
|
797
816
|
the list, this page's head and the card read the same row. The pick is
|
|
@@ -5168,6 +5187,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5168
5187
|
the GitHub read: entry + tab return, never the in-flight tick */
|
|
5169
5188
|
if (!keepRepo) loadVisibility(inst);
|
|
5170
5189
|
if (!keepRepo) loadJoinability(inst);
|
|
5190
|
+
if (!keepRepo) loadDoor();
|
|
5171
5191
|
/* the publish gate (task 1002335) needs no second read — the verdict
|
|
5172
5192
|
rode in on the instance itself. It sits behind the same keepRepo
|
|
5173
5193
|
guard because its card holds a textarea the in-flight tick must
|
|
@@ -5953,6 +5973,111 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5953
5973
|
if (t && t.hasAttribute('data-join-save')) doJoinSave();
|
|
5954
5974
|
});
|
|
5955
5975
|
|
|
5976
|
+
/* ── on the map (task 1002324): the join door, ADR 0182 D1 — the third axis,
|
|
5977
|
+
beside "who can see it" (the project's own sign-in door) and "how people
|
|
5978
|
+
join" (its membership door). The two reads above, key for key: the CHOICE
|
|
5979
|
+
comes from the platform's settings row and the save's reply is a RECEIPT —
|
|
5980
|
+
its push.message is shown verbatim and never painted as the live state,
|
|
5981
|
+
because the value reaches a running project only by the restart the runner
|
|
5982
|
+
performs. Unlike its siblings this section asks the project's manifest
|
|
5983
|
+
nothing: an already-deployed project reports no door yet, and a line that
|
|
5984
|
+
could only ever say "your project doesn't report this" is worse than no
|
|
5985
|
+
line. The read-back arrives with the enforcement (BV1.R11/R12).
|
|
5986
|
+
Labels are PLAIN — Public / Private / Stealth — per the goal's interview:
|
|
5987
|
+
the space metaphor is the drawn orb's, never the copy's. ── */
|
|
5988
|
+
|
|
5989
|
+
var DOOR_OPTIONS = {
|
|
5990
|
+
public: { label: 'Public', blurb: 'The project is on the map under its name, and anyone who finds it can join.' },
|
|
5991
|
+
private: { label: 'Private', blurb: 'On the map under its name, but nobody joins on their own — people ask, and you decide.' },
|
|
5992
|
+
stealth: { label: 'Stealth', blurb: 'The map shows only that something is there. Its name, and everything else about it, stay yours — and nobody can ask to join.' }
|
|
5993
|
+
};
|
|
5994
|
+
var mDoorSettings = null; /* the platform row's last answer: { value, options } */
|
|
5995
|
+
|
|
5996
|
+
function doorMsg(html) { $('mDoor').innerHTML = html; }
|
|
5997
|
+
|
|
5998
|
+
function renderDoor() {
|
|
5999
|
+
var s = mDoorSettings;
|
|
6000
|
+
if (!s) return;
|
|
6001
|
+
var opts = (s.options || []).filter(function (v) { return !!DOOR_OPTIONS[v]; });
|
|
6002
|
+
doorMsg('<div role="radiogroup" aria-label="On the map">' + opts.map(function (v) {
|
|
6003
|
+
var o = DOOR_OPTIONS[v];
|
|
6004
|
+
return '<label class="visOpt"><input type="radio" name="mDoorPick" value="' + esc(v) + '"' +
|
|
6005
|
+
(v === s.value ? ' checked' : '') + '><span><b>' + esc(o.label) + '</b>' + esc(o.blurb) + '</span></label>';
|
|
6006
|
+
}).join('') + '</div>' +
|
|
6007
|
+
'<div class="actions"><button type="button" class="btn small" data-door-save>Save</button></div>' +
|
|
6008
|
+
'<div class="projMsg" id="mDoorMsg" role="status" aria-live="polite"></div>');
|
|
6009
|
+
}
|
|
6010
|
+
|
|
6011
|
+
function loadDoor() {
|
|
6012
|
+
mDoorSettings = null;
|
|
6013
|
+
doorMsg('<p class="finePrint">Reading the project’s settings…</p>');
|
|
6014
|
+
var asked = manageId; /* a stale answer must never describe a newer project */
|
|
6015
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
|
|
6016
|
+
.then(function (res) {
|
|
6017
|
+
if (asked !== manageId) throw new Error('stale');
|
|
6018
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6019
|
+
if (res.ok) {
|
|
6020
|
+
mDoorSettings = {
|
|
6021
|
+
value: (data.settings || {}).visibility,
|
|
6022
|
+
options: (data.options || {}).visibility || Object.keys(DOOR_OPTIONS)
|
|
6023
|
+
};
|
|
6024
|
+
renderDoor();
|
|
6025
|
+
return;
|
|
6026
|
+
}
|
|
6027
|
+
doorMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
|
|
6028
|
+
});
|
|
6029
|
+
})
|
|
6030
|
+
.catch(function (err) {
|
|
6031
|
+
if (String(err && err.message) === 'stale') return;
|
|
6032
|
+
doorMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
|
|
6033
|
+
});
|
|
6034
|
+
}
|
|
6035
|
+
|
|
6036
|
+
/* the save sends the CHOICE and shows the platform's receipt verbatim */
|
|
6037
|
+
function doDoorSave() {
|
|
6038
|
+
if (!manageInst || !mDoorSettings) return;
|
|
6039
|
+
var picked = document.querySelector('#mDoor input[name="mDoorPick"]:checked');
|
|
6040
|
+
if (!picked) return;
|
|
6041
|
+
var value = picked.value;
|
|
6042
|
+
var box = $('mDoorMsg');
|
|
6043
|
+
var btn = document.querySelector('#mDoor [data-door-save]');
|
|
6044
|
+
if (btn) btn.disabled = true;
|
|
6045
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
6046
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
|
|
6047
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
6048
|
+
body: JSON.stringify({ visibility: value })
|
|
6049
|
+
}).then(function (res) {
|
|
6050
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6051
|
+
if (res.ok) {
|
|
6052
|
+
/* the row now holds the choice — the radio follows it, and nothing
|
|
6053
|
+
here claims the project is already running it */
|
|
6054
|
+
mDoorSettings.value = (data.saved || {}).visibility || value;
|
|
6055
|
+
box.className = 'projMsg ok';
|
|
6056
|
+
box.textContent = (data.push && data.push.message) || 'Saved.';
|
|
6057
|
+
return;
|
|
6058
|
+
}
|
|
6059
|
+
var e = apiError(data, res.status);
|
|
6060
|
+
if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
|
|
6061
|
+
/* 409 intent_conflict: the change IS saved (recorded) — the server's
|
|
6062
|
+
sentence says so and names the run holding the slot; show it as is */
|
|
6063
|
+
box.className = e.code === 'intent_conflict' ? 'projMsg' : 'projMsg err';
|
|
6064
|
+
box.textContent = e.message || 'The change could not be saved — try again in a moment.';
|
|
6065
|
+
});
|
|
6066
|
+
}).catch(function () {
|
|
6067
|
+
box.className = 'projMsg err';
|
|
6068
|
+
box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
6069
|
+
}).then(function () {
|
|
6070
|
+
if (btn) btn.disabled = false;
|
|
6071
|
+
/* the strip + the sibling sections read open_intent — re-read it */
|
|
6072
|
+
loadManage(true, true);
|
|
6073
|
+
});
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
$('mDoor').addEventListener('click', function (ev) {
|
|
6077
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
6078
|
+
if (t && t.hasAttribute('data-door-save')) doDoorSave();
|
|
6079
|
+
});
|
|
6080
|
+
|
|
5956
6081
|
/* ── delete the project (task 1003042): the manage page's teardown control.
|
|
5957
6082
|
It renders from status + open_intent only — no second request — so the
|
|
5958
6083
|
minute tick can repaint it and a queued teardown appears without a reload.
|
|
@@ -134,7 +134,7 @@ const FLOW_RECEIPTS = {
|
|
|
134
134
|
domain: { recorded: true, queued: true, message: 'Address recorded.' },
|
|
135
135
|
// the wizard's filing (task 1003346): the receipt names the instance the page then polls — Harbor, the fixture's standing-up row — and the address the page promises is the one the server assigned
|
|
136
136
|
provision: { queued: true, message: 'Queued. The platform picks it up from here.', instance: { id: 102, domain: 'mercury.cloudbongos.com' } },
|
|
137
|
-
settings: { settings: { platform_visibility: 'public', joinability: 'open' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
|
|
137
|
+
settings: { settings: { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
|
|
138
138
|
};
|
|
139
139
|
// a teardown or restart ask is REMEMBERED for the run: the page re-reads the project right
|
|
140
140
|
// after a receipt (doDelete → loadManage), and what it must then see is the queued intent —
|
|
@@ -160,6 +160,7 @@ function readBody(req, cb) {
|
|
|
160
160
|
const STUB_VOCAB = {
|
|
161
161
|
platform_visibility: ['public', 'gated'],
|
|
162
162
|
joinability: ['open', 'apply', 'invite_only'],
|
|
163
|
+
visibility: ['public', 'private', 'stealth'],
|
|
163
164
|
planet_template: null, // the template set is long; shape is checked, not membership
|
|
164
165
|
planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
165
166
|
card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
|
|
@@ -178,7 +179,7 @@ function settingsPatch(res, id, body) {
|
|
|
178
179
|
const bad = settingsRejection(body);
|
|
179
180
|
if (bad) return json(res, 400, { error: { code: 'validation_failed', message: bad } });
|
|
180
181
|
if (PLANET_KEYS.some((k) => k in body)) PLANETS.set(String(id), Object.fromEntries(PLANET_KEYS.map((k) => [k, body[k] || null])));
|
|
181
|
-
const policy = ['platform_visibility', 'joinability'].some((k) => k in body);
|
|
182
|
+
const policy = ['platform_visibility', 'joinability', 'visibility'].some((k) => k in body);
|
|
182
183
|
const push = policy ? FLOW_RECEIPTS.settings.push
|
|
183
184
|
: { queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' };
|
|
184
185
|
return json(res, 200, { ok: true, saved: FLOW_RECEIPTS.settings.settings, planet: PLANETS.get(String(id)) || NO_PLANET, push });
|
|
@@ -209,7 +210,7 @@ function instances(res, id, settings) {
|
|
|
209
210
|
if (id === null) return json(res, 200, { instances: list });
|
|
210
211
|
const inst = list.find((i) => String(i.id) === String(id));
|
|
211
212
|
if (!inst) return json(res, 404, { error: { code: 'not_found', message: 'no such instance' } });
|
|
212
|
-
if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open' }, planet: PLANETS.get(String(id)) || NO_PLANET, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] } });
|
|
213
|
+
if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, planet: PLANETS.get(String(id)) || NO_PLANET, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'], visibility: ['public', 'private', 'stealth'] } });
|
|
213
214
|
return json(res, 200, { instance: inst, open_intent: inst.open_intent || null });
|
|
214
215
|
}
|
|
215
216
|
// A project's art in the featured fixture may be root-relative (the surface's
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.513",
|
|
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.513",
|
|
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.513",
|
|
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/branding.js
CHANGED
|
@@ -60,6 +60,11 @@ const ENV_OVERRIDES = [
|
|
|
60
60
|
// — which is exactly why applying a change RESTARTS the instance.
|
|
61
61
|
['PLATFORM_VISIBILITY', ['project', 'platformVisibility']],
|
|
62
62
|
['JOINABILITY', ['project', 'joinability']],
|
|
63
|
+
// The join door the owner set on the platform (ADR 0182 D1, task 1002324) —
|
|
64
|
+
// public | private | stealth. Distinct from PLATFORM_VISIBILITY above, which is
|
|
65
|
+
// this instance's own sign-in door; this one is how the project stands on the
|
|
66
|
+
// platform's map, and D6 makes it the ceiling JOINABILITY may only narrow.
|
|
67
|
+
['VISIBILITY', ['project', 'visibility']],
|
|
63
68
|
];
|
|
64
69
|
|
|
65
70
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -215,6 +220,10 @@ function clientBranding(b = branding()) {
|
|
|
215
220
|
project: {
|
|
216
221
|
platformVisibility: b.project?.platformVisibility || 'public',
|
|
217
222
|
joinability: b.project?.joinability || 'apply',
|
|
223
|
+
// The join door (task 1002324). Public by nature for the same reason as its
|
|
224
|
+
// two neighbours: it is how the project asks to be seen on the map, and it is
|
|
225
|
+
// the READ-BACK a manage page needs to tell a saved choice from a live one.
|
|
226
|
+
visibility: b.project?.visibility || 'public',
|
|
218
227
|
},
|
|
219
228
|
};
|
|
220
229
|
}
|
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.513'; // 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');
|
package/tests/currency_label.mjs
CHANGED
|
@@ -48,10 +48,11 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
|
|
51
|
-
|
|
51
|
+
// `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1)
|
|
52
|
+
assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
52
53
|
assert.deepEqual(
|
|
53
|
-
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only' } }).project,
|
|
54
|
-
{ platformVisibility: 'gated', joinability: 'invite_only' },
|
|
54
|
+
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' } }).project,
|
|
55
|
+
{ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' },
|
|
55
56
|
'what the instance actually resolved (env override included) is what it publishes');
|
|
56
57
|
});
|
|
57
58
|
|
|
@@ -70,7 +71,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
|
|
|
70
71
|
// `project` joined the allow-list in task 1003140: the per-project policy is public
|
|
71
72
|
// by nature and is the settings read-back a manage page needs (see clientBranding).
|
|
72
73
|
assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
|
|
73
|
-
assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility']);
|
|
74
|
+
assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility', 'visibility']);
|
|
74
75
|
for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
|
|
75
76
|
assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
|
|
76
77
|
}
|