@bongos/core 1.19.429 → 1.19.431
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 +71 -51
- 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 +6 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +74 -3
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +162 -154
- package/docs/copy-registry.json +271 -198
- package/docs/design/mocks/hub/picker.html +421 -0
- package/docs/design/projects-hub-direction-v2.md +22 -0
- package/docs/file-map.md +1 -0
- 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/settings.html +1 -1
- package/modules/hall-ui/public/settings.js +17 -8
- package/modules/platform-identity/account-visibility.js +47 -0
- package/modules/platform-identity/platform-identity.js +4 -4
- package/modules/platform-identity/recruiter-sliver.js +197 -0
- package/modules/platform-identity/routes/projects.js +24 -0
- package/modules/platform-identity/routes/scouting.js +58 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +63 -2
- package/modules/provisioning/routes/provisioning.js +28 -6
- package/modules/provisioning/tests/provisioning.mjs +34 -0
- package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
- package/modules/ui-design/kit/fixtures/featured.json +1 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/modules/ui-design/kit/serve.js +28 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +37 -0
- package/public-landing/projects.html +164 -5
- package/public-landing/projects.probes.json +12 -0
- package/public-landing/projects.states.json +3 -2
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +115 -9
- package/tests/effective_visibility_predicate.mjs +58 -1
- package/tests/government_require_permission.mjs +22 -0
- package/tests/profile_route.mjs +6 -0
- package/tests/projects_hub.mjs +122 -7
- package/tests/provisioning_settings.mjs +81 -2
- package/tests/provisioning_settings_env.mjs +6 -0
- package/tests/recruiter_sliver_boundary.mjs +841 -0
- package/tests/scouting_owner_access_and_reach.mjs +4 -0
|
@@ -32,6 +32,12 @@ import { readFileSync } from 'node:fs';
|
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
33
|
import { dirname, join } from 'node:path';
|
|
34
34
|
|
|
35
|
+
// The sliver's own key lists — the copy guard below is written against THESE,
|
|
36
|
+
// not against a transcription of them, so a sixth granted field cannot be added
|
|
37
|
+
// without the Settings copy being made to name it (privacy spec D3: the toggle's
|
|
38
|
+
// copy "must state exactly this grant").
|
|
39
|
+
import { SLIVER_KEYS, SLIVER_KEYS_HIDDEN } from '../modules/platform-identity/recruiter-sliver.js';
|
|
40
|
+
|
|
35
41
|
const require = createRequire(import.meta.url);
|
|
36
42
|
const express = require('express');
|
|
37
43
|
|
|
@@ -173,6 +179,51 @@ test('setAccountPrivacy accepts an explicit null discoverable — the reset to "
|
|
|
173
179
|
assert.equal(calls[0].params[7], true, 'and it counts as sent');
|
|
174
180
|
});
|
|
175
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Resolve what the UPDATE branch would store in `recruiter_discoverable`, from
|
|
184
|
+
* the statement's OWN text — the WHERE-derived-fake principle applied to a CASE.
|
|
185
|
+
* Each arm is recognised, and an arm this reader cannot see simply is not
|
|
186
|
+
* modelled, so deleting one from the SQL genuinely changes the answer here.
|
|
187
|
+
*/
|
|
188
|
+
function resolveStoredReach(sql, params, stored) {
|
|
189
|
+
const from = sql.indexOf('recruiter_discoverable = CASE');
|
|
190
|
+
assert.ok(from >= 0, 'the reach column lost its CASE entirely');
|
|
191
|
+
const body = sql.slice(from, sql.indexOf('END', from));
|
|
192
|
+
const [visibilityWritten, reachWritten, setVisibility, setReach] = params.slice(4, 8);
|
|
193
|
+
assert.match(body, /ELSE platform_identity_accounts\.recruiter_discoverable/,
|
|
194
|
+
'the CASE must still fall through to the STORED value — without the ELSE every PATCH would clear the flag');
|
|
195
|
+
if (/WHEN \$8::boolean THEN EXCLUDED\.recruiter_discoverable/.test(body) && setReach) return reachWritten;
|
|
196
|
+
if (/WHEN \$7::boolean AND EXCLUDED\.account_visibility = 'private' THEN NULL/.test(body)
|
|
197
|
+
&& setVisibility && visibilityWritten === 'private') return null;
|
|
198
|
+
return stored;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test('GOING PRIVATE re-arms the reach default, so a consent given while public is not silently reused', async () => {
|
|
202
|
+
// THE DEFECT THIS CLOSES (found in adversarial review of task 1002968). A
|
|
203
|
+
// public builder toggles "Discoverable by recruiters" off and on again: the
|
|
204
|
+
// column is now an explicit `true`, not NULL. They later set the profile to
|
|
205
|
+
// Private, reading a row that promises the page "answers not found to every
|
|
206
|
+
// visitor". That stale `true` used to be INERT — every recruiting surface
|
|
207
|
+
// floored on the existence axis. The D3 sliver does not, by design, so it
|
|
208
|
+
// would have served them under a consent they gave to a different question.
|
|
209
|
+
//
|
|
210
|
+
// Each row is resolved from the statement's own CASE, so removing the new arm
|
|
211
|
+
// makes the first row answer `true` and this test red.
|
|
212
|
+
const cases = [
|
|
213
|
+
['private, reach unnamed → RESET', { account_visibility: 'private' }, true, null],
|
|
214
|
+
['private, reach named true → the owner re-consented, so it stands', { account_visibility: 'private', recruiter_discoverable: true }, true, true],
|
|
215
|
+
['private, reach named false → explicit off', { account_visibility: 'private', recruiter_discoverable: false }, true, false],
|
|
216
|
+
['public, reach unnamed → untouched', { account_visibility: 'public' }, true, true],
|
|
217
|
+
['reach alone, no visibility change → untouched', { recruiter_discoverable: false }, true, false],
|
|
218
|
+
['private on an account that never opted in → still the default', { account_visibility: 'private' }, null, null],
|
|
219
|
+
];
|
|
220
|
+
for (const [name, fields, stored, want] of cases) {
|
|
221
|
+
expect({ rows: [row({ recruiter_discoverable: want })] });
|
|
222
|
+
await pi.setAccountPrivacy({ githubId: SESSION.github_id, fields }, { pool });
|
|
223
|
+
assert.equal(resolveStoredReach(calls[0].text, calls[0].params, stored), want, name);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
176
227
|
test('setAccountPrivacy refuses a value outside the model, before any query', async () => {
|
|
177
228
|
expect();
|
|
178
229
|
assert.equal((await pi.setAccountPrivacy(
|
|
@@ -271,15 +322,45 @@ test('neither flag crosses a PUBLIC response — the /u/<handle> read stays a wh
|
|
|
271
322
|
|
|
272
323
|
// ---- 5. the settings copy states the grant ---------------------------------
|
|
273
324
|
|
|
274
|
-
|
|
325
|
+
// How each granted FIELD has to read to a person. Keyed by the module's export,
|
|
326
|
+
// so this map is the place a sixth key must be described before it can ship —
|
|
327
|
+
// and the "every key is described" assertion below is what makes that binding.
|
|
328
|
+
const GRANT_WORDS = {
|
|
329
|
+
handle: /your name/,
|
|
330
|
+
display_name: /your name/,
|
|
331
|
+
avatar_url: /your picture/,
|
|
332
|
+
disciplines: /your disciplines/,
|
|
333
|
+
active_recently: /active in the last 30 days/,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
test('the discoverable toggle names EXACTLY what it grants — checked against SLIVER_KEYS itself', () => {
|
|
275
337
|
const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
|
|
276
338
|
const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
|
|
277
|
-
|
|
278
|
-
|
|
339
|
+
// Privacy spec D3 makes this a MUST: "The discoverable toggle's settings copy
|
|
340
|
+
// must state exactly this grant." It was checked against three transcribed
|
|
341
|
+
// phrases, and the transcription had gone stale in the direction that matters:
|
|
342
|
+
// the copy said the hidden sliver was "your name and nothing else" while
|
|
343
|
+
// SLIVER_KEYS_HIDDEN also ships avatar_url, and the shown branch never named
|
|
344
|
+
// the picture at all. A user was being told something false about their own
|
|
345
|
+
// privacy. It is now checked against the shipped key lists.
|
|
346
|
+
const shown = /: 'They will see ([^']+)'/.exec(js);
|
|
347
|
+
const hidden = /\? 'Because your cross-project record is hidden, ([^']+)'/.exec(js);
|
|
348
|
+
assert.ok(shown && hidden, 'both branches of the grant string must still be findable');
|
|
349
|
+
|
|
350
|
+
for (const key of SLIVER_KEYS) {
|
|
351
|
+
assert.ok(GRANT_WORDS[key], `SLIVER_KEYS grew "${key}" and nothing here says how to describe it — spec D3 says the copy must state the grant, so a new granted field cannot ship undescribed`);
|
|
352
|
+
assert.match(shown[1], GRANT_WORDS[key], `the toggle must name "${key}" — it is in the sliver a recruiter receives`);
|
|
353
|
+
}
|
|
354
|
+
for (const key of SLIVER_KEYS_HIDDEN) {
|
|
355
|
+
assert.match(hidden[1], GRANT_WORDS[key], `hidden, the sliver still ships "${key}", so the name-only copy must still name it`);
|
|
356
|
+
}
|
|
357
|
+
for (const key of SLIVER_KEYS.filter((k) => !SLIVER_KEYS_HIDDEN.includes(k))) {
|
|
358
|
+
assert.doesNotMatch(hidden[1], GRANT_WORDS[key], `"${key}" drops under hide_stats, so the copy must not keep promising it`);
|
|
359
|
+
}
|
|
279
360
|
assert.match(js, /never your credits, works shipped, karma, or the projects you build on/,
|
|
280
361
|
'and the copy says what it does NOT grant');
|
|
281
|
-
assert.match(js, /
|
|
282
|
-
'
|
|
362
|
+
assert.match(js, /the one thing a recruiter can see while your profile page is private/,
|
|
363
|
+
'a private account is told the sliver is the exception to "not found to every visitor" — the surprising half of D2/D3');
|
|
283
364
|
assert.match(html, /<section[^>]*id="privacy-scroll"[^>]*hidden>/,
|
|
284
365
|
'the section ships hidden — it reveals only where the hub module answers');
|
|
285
366
|
});
|
|
@@ -304,13 +385,38 @@ test('each toggle claims exactly what is enforced — and BOTH are now in force'
|
|
|
304
385
|
'recruiting reach is enforced now (task 1002290), so the row promises it in the present tense');
|
|
305
386
|
assert.doesNotMatch(js, /will be able to find you/,
|
|
306
387
|
'the future tense outlived its truth the moment listScoutingRollup joined the reach predicate');
|
|
307
|
-
|
|
308
|
-
|
|
388
|
+
// WIDENED BY TASK 1002968, on the same rule that narrowed it. 1002290 cut this
|
|
389
|
+
// to "the People directory" because the D3 sliver was unbuilt and no guard here
|
|
390
|
+
// could hold a wider claim. The sliver is now built and gated, so BOTH
|
|
391
|
+
// recruiting reads join the reach axis and the row may name both — and the
|
|
392
|
+
// wider claim is held by the guard below rather than by this sentence.
|
|
393
|
+
assert.match(js, /removes you from both recruiting surfaces/,
|
|
394
|
+
'the row names both surfaces that read the flag today — a promise you can go and check');
|
|
309
395
|
assert.doesNotMatch(js, /every recruiting view/,
|
|
310
|
-
'never
|
|
311
|
-
+ '
|
|
396
|
+
'still never an OPEN-ENDED promise: "both" is a claim a guard can hold, "every" is not — a third '
|
|
397
|
+
+ 'recruiting read would make it false with nothing to notice');
|
|
312
398
|
assert.match(html, /profile page setting applies now[\s\S]*Recruiter discoverability applies now too/,
|
|
313
399
|
'the section note now says both are live, rather than splitting them');
|
|
314
400
|
assert.doesNotMatch(html, /starts applying as the recruiting surfaces land/,
|
|
315
401
|
'the "recorded and waiting" note must not outlive the surface that started reading it');
|
|
316
402
|
});
|
|
403
|
+
|
|
404
|
+
test('the recruiting surface really is exactly two reads, and each one joins REACH', () => {
|
|
405
|
+
// This is what makes the widened copy above checkable rather than a slogan.
|
|
406
|
+
// Both halves are enumerated from source: a third route on the recruiting
|
|
407
|
+
// surface, or either reader dropping the reach axis, reds here — which is the
|
|
408
|
+
// signal that the toggle's promise has stopped being true.
|
|
409
|
+
const route = readFileSync(join(ROOT, 'modules/platform-identity/routes/scouting.js'), 'utf8');
|
|
410
|
+
const mounted = route.match(/router\.get\(\s*\n?\s*'([^']+)'/g) || [];
|
|
411
|
+
assert.equal(mounted.length, 2,
|
|
412
|
+
`the recruiting surface mounts ${mounted.length} route(s), not 2 — the Settings copy promises "both recruiting surfaces" and can no longer name them`);
|
|
413
|
+
assert.match(route, /'\/scouting'/, 'the directory');
|
|
414
|
+
assert.match(route, /'\/scouting\/:handle'/, 'and the pre-accept sliver');
|
|
415
|
+
|
|
416
|
+
const av = readFileSync(join(ROOT, 'modules/platform-identity/account-visibility.js'), 'utf8');
|
|
417
|
+
for (const composed of ['scoutingListableSql', 'recruiterSliverVisibleSql']) {
|
|
418
|
+
const body = av.slice(av.indexOf(`function ${composed}(`));
|
|
419
|
+
assert.match(body.slice(0, body.indexOf('\n}')), /recruiterReachSql\(alias\)/,
|
|
420
|
+
`${composed} stopped composing the reach axis — opting out would no longer empty that surface`);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
@@ -79,6 +79,8 @@ import {
|
|
|
79
79
|
isRecruiterDiscoverable,
|
|
80
80
|
recruiterReachSql,
|
|
81
81
|
scoutingListableSql,
|
|
82
|
+
recruiterSliverVisibleSql,
|
|
83
|
+
isRecruiterSliverVisible,
|
|
82
84
|
} from '../modules/platform-identity/account-visibility.js';
|
|
83
85
|
|
|
84
86
|
const require = createRequire(import.meta.url);
|
|
@@ -119,6 +121,10 @@ const SPELLING_EXEMPTIONS = [
|
|
|
119
121
|
text: 'hide_stats = true',
|
|
120
122
|
count: 1,
|
|
121
123
|
why: "hiddenGithubIds asks the OPPOSITE question to visibility — 'which of these accounts have hidden themselves' — for the closeness-graph consent gate. It must stay fail-closed on hide alone (ADR 0171 D4), so it is not expressible as the shared predicate and is not a variant of it.",
|
|
124
|
+
}, {
|
|
125
|
+
text: "account_visibility = '",
|
|
126
|
+
count: 1,
|
|
127
|
+
why: "setAccountPrivacy's ON CONFLICT branch, and a WRITE rather than a read gate — it reads EXCLUDED, the value being written, to decide whether this PATCH re-arms recruiter_discoverable to its 'follow visibility' default. Going private without naming reach must clear an explicit true, because that true was set while PUBLIC and meant 'recruiters may see my public profile' (ADR 0215, task 1002968); the D3 sliver has no existence floor and would otherwise read that stale consent as fresh. It gates no read, so it cannot be expressed as the shared predicate and is not a variant of one — the SPELLINGS comment already excludes writes in principle, and this is the occurrence its regex cannot tell apart.",
|
|
122
128
|
}],
|
|
123
129
|
obligation: (code) => /require\(\s*'\.\/account-visibility'\s*\)/.test(code),
|
|
124
130
|
demands: 'still imports the shared predicate rather than re-deriving one',
|
|
@@ -341,8 +347,14 @@ function accountStatements(code) {
|
|
|
341
347
|
* recruiterReachSql inside the owner file, so a reader that joins it has adopted
|
|
342
348
|
* the shared rule — but it must be named here, or wiring the recruiting reach in
|
|
343
349
|
* would read as a reader that stopped adopting anything.
|
|
350
|
+
* recruiterSliverVisibleSql (task 1002968) is here on the same terms and one
|
|
351
|
+
* caveat: it is the ONE composed answer that deliberately omits the existence
|
|
352
|
+
* axis, because the private-but-discoverable account is the D3 sliver's subject.
|
|
353
|
+
* That is a decision recorded in the owner file and asserted above — not a
|
|
354
|
+
* reader quietly dropping a term, which is what this regex would otherwise let
|
|
355
|
+
* through unnoticed.
|
|
344
356
|
*/
|
|
345
|
-
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
|
|
357
|
+
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
|
|
346
358
|
|
|
347
359
|
// ===========================================================================
|
|
348
360
|
// Self-tests — the machinery is the guard, so it gets proved too.
|
|
@@ -513,6 +525,30 @@ t('the recruiter-reach predicate still lives here, and is still the only COALESC
|
|
|
513
525
|
'reach never buys addressability — that is what the D3 sliver is for');
|
|
514
526
|
});
|
|
515
527
|
|
|
528
|
+
t('recruiterSliverVisibleSql is COMPOSED from consent AND reach, and omits existence ON PURPOSE (task 1002968)', () => {
|
|
529
|
+
// The pre-accept sliver is the one effective answer that must NOT floor on the
|
|
530
|
+
// existence axis: the privacy spec's D2 "private · discoverable" row is the
|
|
531
|
+
// account it exists to serve, and ANDing accountVisibleSql would empty it of
|
|
532
|
+
// exactly that account while leaving every test above still green.
|
|
533
|
+
assert.ok(recruiterSliverVisibleSql('pa').includes(accountActiveSql('pa')),
|
|
534
|
+
'the consent floor, literally contained — a provisional account is never recruiter-visible (spec D3)');
|
|
535
|
+
assert.ok(recruiterSliverVisibleSql('pa').includes(recruiterReachSql('pa')),
|
|
536
|
+
'and the reach axis, literally contained — the opt-out empties this surface too');
|
|
537
|
+
assert.ok(!recruiterSliverVisibleSql('pa').includes(accountVisibleSql('pa')),
|
|
538
|
+
'and NOT the existence axis: dropping it is safe only because reach is an explicit opt-IN for a private account');
|
|
539
|
+
assert.doesNotMatch(recruiterSliverVisibleSql(), /hide_stats/,
|
|
540
|
+
'hide changes the SHAPE of the sliver (name-only), never who appears in it');
|
|
541
|
+
// The JS twin, over the same D2 matrix the SQL is judged on.
|
|
542
|
+
const accepted = { terms_accepted_at: 'T1' }; // the T&C half of the consent floor (ADR 0213)
|
|
543
|
+
assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), true);
|
|
544
|
+
assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: null }), false);
|
|
545
|
+
assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'provisional', account_visibility: 'public', recruiter_discoverable: true }), false);
|
|
546
|
+
assert.equal(isRecruiterSliverVisible({ profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), false,
|
|
547
|
+
'and the sliver inherits the T&C floor by COMPOSITION — an account that never accepted is not recruiter-visible');
|
|
548
|
+
assert.equal(isRecruiterSliverVisible(null), false, 'fail-closed on an absent account');
|
|
549
|
+
assert.throws(() => recruiterSliverVisibleSql('pa; DROP TABLE x --'), /bare SQL identifier/);
|
|
550
|
+
});
|
|
551
|
+
|
|
516
552
|
// ===========================================================================
|
|
517
553
|
// 2. The sole-owner rule — nothing else spells a variant
|
|
518
554
|
// ===========================================================================
|
|
@@ -640,6 +676,27 @@ t('the shared predicate has real adopters (the wiring did not silently unwire it
|
|
|
640
676
|
}
|
|
641
677
|
});
|
|
642
678
|
|
|
679
|
+
t('the EXISTENCE-LESS predicate has exactly one reader, and it is the sliver (task 1002968)', () => {
|
|
680
|
+
// recruiterSliverVisibleSql counts as adoption in ADOPTS above, which is what
|
|
681
|
+
// lets the sliver's own read pass the classification rule — and that is a hole
|
|
682
|
+
// unless the predicate is also PINNED to its one caller. It is the only shared
|
|
683
|
+
// answer that omits `account_visibility = 'public'`, so a future public read
|
|
684
|
+
// that joined it would look adopted while quietly serving private accounts.
|
|
685
|
+
// BOTH FORMS, because the same diff put both into ADOPTS. Pinning only the SQL
|
|
686
|
+
// one left the JS twin as an open door: a new module file doing
|
|
687
|
+
// `rows.filter(isRecruiterSliverVisible)` over an ungated
|
|
688
|
+
// `SELECT … FROM platform_identity_accounts WHERE handle ILIKE $1` passed this
|
|
689
|
+
// entire guard — that is the hub profile search this file's own header names
|
|
690
|
+
// as the read that must fail the build, sailing through on the one predicate
|
|
691
|
+
// that omits the existence axis (found in adversarial review, task 1002968).
|
|
692
|
+
// Enumerated from source, so a second reader fails here rather than in review.
|
|
693
|
+
const readers = scanned
|
|
694
|
+
.filter((f) => f.rel !== PREDICATE_OWNER && /\b(?:recruiterSliverVisibleSql|isRecruiterSliverVisible)\s*\(/.test(f.code))
|
|
695
|
+
.map((f) => f.rel);
|
|
696
|
+
assert.deepEqual(readers, ['modules/platform-identity/recruiter-sliver.js'],
|
|
697
|
+
'the pre-accept predicate is the recruiting sliver\'s alone — every other read must floor on the existence axis');
|
|
698
|
+
});
|
|
699
|
+
|
|
643
700
|
t('getPublicProfileExtras gates on the shared JS port (its SQL carries no predicate to join)', () => {
|
|
644
701
|
const pi = scanned.find((f) => f.rel === 'modules/platform-identity/platform-identity.js');
|
|
645
702
|
const span = functionSpans(pi.code).find((s) => s.name === 'getPublicProfileExtras');
|
|
@@ -352,6 +352,28 @@ test('every modules/*/routes gate is migrated, bar the one documented holdout',
|
|
|
352
352
|
// worth doing — it means minting an atom over the write — but it is the
|
|
353
353
|
// continuing R107/R108 migration's job, not this ADR's.
|
|
354
354
|
'security/routes/scans.js',
|
|
355
|
+
// Still requireRank after BV1.R105 — and for a reason the other holdouts do
|
|
356
|
+
// not have: the atom this gate WANTS is specified, named, and deliberately
|
|
357
|
+
// owned by another wave. `GET /scouting/:handle` is the privacy spec's D3
|
|
358
|
+
// pre-accept recruiter sliver (task 1002968, ADR 0215 D4), and D3 gates it on
|
|
359
|
+
// `project.recruit` — declared in spec D1 as FLOOR ARCHON, widenable per
|
|
360
|
+
// instance. That atom does not exist: ADR 0201 D3 and ADR 0210 both refuse to
|
|
361
|
+
// mint it outside the project-admin-console wave that defines it (spike
|
|
362
|
+
// 1002786 R3), because a third definition before any had shipped is the drift
|
|
363
|
+
// they were avoiding.
|
|
364
|
+
//
|
|
365
|
+
// The three ways out are all worse. Minting it here forks its definition.
|
|
366
|
+
// Borrowing a different archon atom (`access_request.review`, say) would name
|
|
367
|
+
// the wrong authority on a privacy surface. And reusing the sibling route's
|
|
368
|
+
// `project.curate`-or-project-owner door — the tidy option, and what the first
|
|
369
|
+
// draft did — is WIDER than the signed floor on both halves, on the first
|
|
370
|
+
// surface on this platform that discloses a PRIVATE account.
|
|
371
|
+
//
|
|
372
|
+
// So the floor is held by rank until the atom lands, which is the one spelling
|
|
373
|
+
// that neither invents authority nor widens it. It is temporary by
|
|
374
|
+
// construction: blocker 1000126 carries the audience question to the owner,
|
|
375
|
+
// and spike 1002786 R3 mints the atom that removes this entry.
|
|
376
|
+
'platform-identity/routes/scouting.js',
|
|
355
377
|
].sort();
|
|
356
378
|
|
|
357
379
|
const modulesDir = path.join(ROOT, 'modules');
|
package/tests/profile_route.mjs
CHANGED
|
@@ -159,6 +159,9 @@ require.cache[authPath] = {
|
|
|
159
159
|
requireBuilder: (_req, _res, next) => next(),
|
|
160
160
|
requireRank: () => (_req, _res, next) => next(),
|
|
161
161
|
requirePermission: () => (_req, _res, next) => next(),
|
|
162
|
+
// ADR 0215 D4: the sliver route beside the directory gates on RANK, so the
|
|
163
|
+
// partial doorway must answer requireRank too or the router will not load.
|
|
164
|
+
requireRank: () => (_req, _res, next) => next(),
|
|
162
165
|
},
|
|
163
166
|
};
|
|
164
167
|
|
|
@@ -434,6 +437,9 @@ require.cache[moduleApiPath] = {
|
|
|
434
437
|
pool: engine,
|
|
435
438
|
requireBuilder: (_req, _res, next) => next(),
|
|
436
439
|
requirePermission: () => (_req, _res, next) => next(),
|
|
440
|
+
// ADR 0215 D4: the sliver route beside the directory gates on RANK, so the
|
|
441
|
+
// partial doorway must answer requireRank too or the router will not load.
|
|
442
|
+
requireRank: () => (_req, _res, next) => next(),
|
|
437
443
|
parsePagination: () => ({ limit: 100, offset: 0 }),
|
|
438
444
|
corsPublicGet: () => (_req, _res, next) => next(),
|
|
439
445
|
},
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -187,8 +187,10 @@ test('the mine row draws the project\'s planet — the catalog\'s template when
|
|
|
187
187
|
assert.doesNotMatch(orb, /class="orb/, 'the sphere is gone');
|
|
188
188
|
const planet = (SCRIPT.match(/function planetOf\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
189
189
|
assert.match(planet, /hostOf\(catalog\[i\]\.origin\) === h/, 'matched by host, the same join the map makes');
|
|
190
|
-
assert.match(planet, /tpl:
|
|
191
|
-
assert.match(planet, /tpl:
|
|
190
|
+
assert.match(planet, /var own = \{ tpl: knownTemplate\(inst\.planet_template\), acc: accentVar\(inst\.planet_accent\) \};/, 'the owner\'s own choice is read off the row first (task 1003347)');
|
|
191
|
+
assert.match(planet, /tpl: own\.tpl \|\| templateOf\(c\), acc: own\.acc \|\| accentOf\(c\)/, 'a catalog match takes the catalog\'s template + accent under the owner\'s choice');
|
|
192
|
+
assert.match(planet, /tpl: own\.tpl \|\| slugTemplate\(inst\.slug\), acc: own\.acc \|\| ORB_ACCENTS\[slugAccent\(inst\.slug\)\]/, 'owned-only walks by slug under the owner\'s choice');
|
|
193
|
+
assert.match(planet, /chosen: !!\(own\.tpl \|\| own\.acc\)/, 'the row says whose pick it is');
|
|
192
194
|
assert.match(SCRIPT, /assignAccents\(\); applyMapFilter\(\); repaintPlanets\(\);/, 'a catalog that lands late repaints the rows');
|
|
193
195
|
assert.doesNotMatch(COSMOS, /\n\.orb\{/, 'cosmos no longer carries the sphere');
|
|
194
196
|
});
|
|
@@ -1531,8 +1533,9 @@ test('the planet templates: shape × material × style; the walk changes all thr
|
|
|
1531
1533
|
test('the walk assigns the template by canonical position beside the accent; a curated template wins; the platform is the pair in chrome', () => {
|
|
1532
1534
|
assert.match(SCRIPT, /function assignAccents[\s\S]*?p\._acc = ORB_ACCENTS\[i % ORB_ACCENTS\.length\];\s*p\._tpl = ORB_WALK\[i % ORB_WALK\.length\];/,
|
|
1533
1535
|
'one walk, two assignments');
|
|
1534
|
-
assert.match(SCRIPT, /function templateOf\(p\) \{\s*return ORB_TEMPLATES\.indexOf\(p\.template\) !== -1 \? p\.template : \(p\._tpl \|\| ORB_WALK\[0\]\);/,
|
|
1535
|
-
'a curated name
|
|
1536
|
+
assert.match(SCRIPT, /function templateOf\(p\) \{\s*if \(knownTemplate\(p\.planet_template\)\) return p\.planet_template;\s*return ORB_TEMPLATES\.indexOf\(p\.template\) !== -1 \? p\.template : \(p\._tpl \|\| ORB_WALK\[0\]\);/,
|
|
1537
|
+
'the owner\'s choice wins, then a curated name; an unknown name is not a choice (task 1003347)');
|
|
1538
|
+
assert.match(SCRIPT, /function accentOf\(p\) \{ return accentVar\(p\.planet_accent\) \|\| p\._acc \|\| ORB_ACCENTS\[0\]; \}/, 'the owner\'s colour by NAME, then the walk');
|
|
1536
1539
|
assert.match(SCRIPT, /_acc: ORB_ACCENTS\[slugAccent\(inst\.slug\)\], _tpl: slugTemplate\(inst\.slug\)/, 'an owned-only entry walks by its slug');
|
|
1537
1540
|
assert.match(SCRIPT, /function slugTemplate\(slug\) \{ return ORB_WALK\[slugHash\(slug\) % ORB_WALK\.length\]; \}/, 'by the walk, not the whole set');
|
|
1538
1541
|
assert.match(SCRIPT, /var tpl = templateOf\(p\), tplParts = tpl\.split\('-'\);\s*a\.setAttribute\('data-template', tpl\);\s*a\.setAttribute\('data-shape', tplParts\[0\]\);\s*a\.setAttribute\('data-material', tplParts\.length === 3 \? tplParts\[1\] : 'chrome'\);\s*a\.setAttribute\('data-style', tplParts\[tplParts\.length - 1\]\);/,
|
|
@@ -1647,13 +1650,125 @@ test('the kit proves the field path: the fixture carries a curated template and
|
|
|
1647
1650
|
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'featured.json'), 'utf8'));
|
|
1648
1651
|
const otb = fixture.projects.find((p) => p.origin === 'https://amazonprimea.com');
|
|
1649
1652
|
assert.equal(otb.template, 'bead-wash');
|
|
1650
|
-
|
|
1651
|
-
assert.
|
|
1653
|
+
const demo = fixture.projects.find((p) => p.origin === 'https://demo.cloudbongos.com');
|
|
1654
|
+
assert.equal(demo.template, 'pair-gold-wash', 'a material template rides the wire (task 1003337)');
|
|
1655
|
+
assert.equal(demo.planet_template, 'pair-obsidian-tint', 'the owner\'s choice rides the same wire (task 1003347)');
|
|
1656
|
+
assert.equal(demo.planet_accent, 'sea', 'a token NAME, never a colour');
|
|
1657
|
+
assert.ok(fixture.projects.every((p) => 'template' in p && 'planet_template' in p && 'planet_accent' in p), 'the wire never drops a key');
|
|
1652
1658
|
const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8'));
|
|
1653
1659
|
const attrs = states.states['in-map'].expect.attr.map((a) => a.join(' '));
|
|
1654
1660
|
assert.ok(attrs.includes('#selfOrb data-template pair-chrome'));
|
|
1655
1661
|
assert.ok(attrs.some((a) => a.includes('amazonprimea.com') && a.endsWith('data-template bead-wash')), 'the curated template reaches the orb');
|
|
1656
|
-
assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-material
|
|
1662
|
+
assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-material obsidian')), 'the material reaches the orb as its own attribute');
|
|
1663
|
+
assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-template pair-obsidian-tint')), 'the owner\'s choice outranks the curated template on the sky (task 1003347)');
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
/* ── the planet picker (task 1003347; direction record §16, v1 the bands): the owner's own
|
|
1667
|
+
choice, read first everywhere a planet is drawn; the "its planet" section on the manage
|
|
1668
|
+
page; a settings save that restarts nothing ── */
|
|
1669
|
+
test('the picker: the owner\'s choice is one of the sky\'s thirty-six names and one of the five accents BY NAME — the same lists the server validates (task 1003347)', () => {
|
|
1670
|
+
const req = createRequire(import.meta.url);
|
|
1671
|
+
const prov = req(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
|
|
1672
|
+
assert.match(SCRIPT, /var ORB_ACCENT_NAMES = \['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'\];/, 'the five by name, in the palette\'s order');
|
|
1673
|
+
assert.deepEqual([...prov.PLANET_ACCENTS], ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'], 'the server\'s allow-list is the same five');
|
|
1674
|
+
assert.match(SCRIPT, /function accentVar\(name\) \{ var i = ORB_ACCENT_NAMES\.indexOf\(name\); return i === -1 \? '' : ORB_ACCENTS\[i\]; \}/, 'a name maps to its var(); an unknown name is nothing');
|
|
1675
|
+
// the client mirror of PROJECT_TEMPLATES (pinned above) and the server's PLANET mirror agree
|
|
1676
|
+
const pi = req(path.join(ROOT, 'modules', 'platform-identity', 'platform-identity.js'));
|
|
1677
|
+
assert.deepEqual([...prov.PLANET_TEMPLATES], [...pi.PROJECT_TEMPLATES], 'provisioning validates the very names the catalog and the sky know');
|
|
1678
|
+
assert.match(SCRIPT, /function pickTemplate\(s\) \{\s*if \(s\.material === 'chrome'\) return s\.shape \+ '-' \+ \(s\.style === 'plain' \? 'chrome' : s\.style\);\s*return s\.shape \+ '-' \+ s\.material \+ '-' \+ s\.style;/, 'shape × material × style composes a stored name; the chrome material\'s plain is spelled chrome');
|
|
1679
|
+
assert.match(SCRIPT, /function pickFrom\(pl\) \{[\s\S]*?style: style === 'chrome' \? 'plain' : style,\s*accent: ORB_ACCENT_NAMES\[ORB_ACCENTS\.indexOf\(pl\.acc\)\] \|\| ORB_ACCENT_NAMES\[0\] \};/, 'and a drawn planet decomposes back into the four fields');
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
test('the picker is live markup on the manage page: the section, its body slot and a sibling fold (the .mSec shape), and the ledger row says whose pick it is', () => {
|
|
1683
|
+
assert.match(HUB, /<div class="card mSec" id="mPlanetPick">\s*<h2>Its planet<\/h2>\s*<div id="mPick">/, 'the section with its volatile slot');
|
|
1684
|
+
assert.match(HUB, /<details class="finePrint" id="mPickWhat">/, 'the fold is a SIBLING of the slot');
|
|
1685
|
+
const sec = (HUB.match(/<div class="card mSec" id="mPlanetPick">([\s\S]*?)<\/details>/) || [, ''])[1];
|
|
1686
|
+
assert.match(sec, /<b>shape<\/b>[\s\S]*<b>material<\/b>[\s\S]*<b>style<\/b>[\s\S]*<b>colour<\/b>/, 'the fold names the four choices');
|
|
1687
|
+
assert.match(sec, /no restart, nothing to apply/, 'and says the planet is the platform\'s to draw');
|
|
1688
|
+
assert.match(HUB, /id="mJoinability"[\s\S]*id="mPlanetPick"[\s\S]*id="mDelete"/, 'between how people join and the ender');
|
|
1689
|
+
const pm = (SCRIPT.match(/function paintManagePlanet\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1690
|
+
assert.match(pm, /pl\.chosen \? '<small>your choice\.<\/small>' : pl\.curated \? '' : '<small>chosen for it by the platform\.<\/small>'/, 'your choice · (curated, no clause) · chosen for it by the platform');
|
|
1691
|
+
const rm = (SCRIPT.match(/function renderManage\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1692
|
+
assert.match(rm, /renderPick\(inst\);/, 'renderManage paints the picker, fetch-free');
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
test('the picker\'s script: the preview planet and four radiogroups built from the sky\'s own names; a pick in progress survives the tick and a late catalog; the save is the settings PATCH and restarts nothing', () => {
|
|
1696
|
+
const rp = (SCRIPT.match(/function renderPick\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1697
|
+
assert.match(rp, /var pl = planetOf\(inst\);/, 'one resolution — the head, the row and the preview can never disagree');
|
|
1698
|
+
assert.match(rp, /<span class="worldOrb"' \+ planetAttrs\(preview\) \+ ' style="--acc:' \+ preview\.acc \+ ';--d:132px"/, 'the preview is the sky\'s object with the template attributes');
|
|
1699
|
+
assert.match(rp, /pickBand\('Shape', 'mPickShape', PICK_SHAPES, s\.shape\)[\s\S]*pickBand\('Material', 'mPickMaterial', PICK_MATERIALS, s\.material\)[\s\S]*pickBand\('Style', 'mPickStyle', PICK_STYLES, s\.style\)/, 'three bands of cells');
|
|
1700
|
+
assert.match(rp, /<div class="pickSwatches" role="radiogroup" aria-label="Colour">/, 'the colour as a radiogroup of swatches');
|
|
1701
|
+
assert.match(rp, /style="--sw:' \+ accentVar\(a\[0\]\) \+ '"/, 'a swatch is its token, never a literal');
|
|
1702
|
+
assert.match(rp, /pl\.chosen \? '<button type="button" class="btn small ghost" data-pick-reset>Let the platform choose again<\/button>' : ''/, 'the way back to the platform\'s pick, only once a choice is stored');
|
|
1703
|
+
assert.match(rp, /else if \(\$\('mPick'\)\.querySelector\('\.pick'\)\) return;/, 'the tick does not rebuild a pick in progress');
|
|
1704
|
+
assert.match(SCRIPT, /if \(manageInst && !mPickTouched && JSON\.stringify\(pickFrom\(planetOf\(manageInst\)\)\) !== mPickSeed\) \{ mPick = null; renderPick\(manageInst\); \}/, 'a late catalog reseeds only an untouched pick whose resolution changed');
|
|
1705
|
+
const save = (SCRIPT.match(/function doPickSave\(reset\) \{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1706
|
+
assert.match(save, /var body = reset \? \{ planet_template: null, planet_accent: null \} : \{ planet_template: pickTemplate\(mPick\), planet_accent: mPick\.accent \};/, 'null hands the pick back; a choice sends the name and the accent by name');
|
|
1707
|
+
assert.match(save, /\/settings', \{\s*method: 'PATCH'/, 'the settings PATCH the two sections above use');
|
|
1708
|
+
assert.match(save, /manageInst\.planet_template = planet\.planet_template \|\| null;\s*manageInst\.planet_accent = planet\.planet_accent \|\| null;/, 'the row now holds the choice');
|
|
1709
|
+
assert.match(save, /repaintPlanets\(\);/, 'the head, the ledger row and the list repaint from the row');
|
|
1710
|
+
assert.match(save, /loadCatalog\(\);/, 'and the sky re-reads the feed that carries it');
|
|
1711
|
+
assert.doesNotMatch(save, /loadManage\(/, 'no re-read for an open intent: a planet change queues none');
|
|
1712
|
+
assert.match(save, /\(data\.push && data\.push\.message\) \|\| 'Saved\.'/, 'the receipt is shown verbatim');
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
test('the picker wears the world by scope: the preview left and four bands right on hairlines, cells with the dot, swatches as their tokens; under 900 the cells are the list\'s pills; no box shadow, no literal colour', () => {
|
|
1716
|
+
const block = (COSMOS.match(/\/\* ── its planet \(task 1003347([\s\S]*?)\/\* this block stays HERE/) || [, ''])[1];
|
|
1717
|
+
assert.ok(block, 'the block sits in the manage page\'s scope');
|
|
1718
|
+
assert.match(block, /#view-manage \.pick\{display:grid;grid-template-columns:168px 1fr;gap:0 34px;align-items:start;\}/, 'the preview beside the bands');
|
|
1719
|
+
assert.match(block, /#view-manage \.pickBand\{display:grid;grid-template-columns:88px 1fr;[^}]*border-top:1px solid var\(--rule\);\}/, 'each band on a hairline, its label in a column');
|
|
1720
|
+
assert.match(block, /#view-manage \.pickCells\[role="radiogroup"\]\{display:grid;grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:1fr;border:0;\}/, 'the cells outrank the generic radiogroup rule');
|
|
1721
|
+
assert.match(block, /#view-manage \.pickCell::before\{content:"";width:7px;height:7px;border-radius:50%;/, 'the dot');
|
|
1722
|
+
assert.match(block, /#view-manage \.pickCell\.on::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid color-mix\(in srgb,var\(--accent\) 22%,transparent\);\}/, 'the chosen cell\'s dot on the accent with its halo');
|
|
1723
|
+
assert.match(block, /#view-manage \.pickSw\{[^}]*background:var\(--sw\);/, 'a swatch paints its token');
|
|
1724
|
+
assert.match(block, /#view-manage \.pickSw\.on\{outline:1px solid var\(--ink\);outline-offset:3px;\}/, 'the chosen swatch ringed by a hairline of the ink');
|
|
1725
|
+
assert.doesNotMatch(block, /box-shadow/, 'hairlines only');
|
|
1726
|
+
assert.doesNotMatch(block, /#[0-9a-fA-F]{3,8}\b/, 'no literal colour (the Fifteen Rule)');
|
|
1727
|
+
const fold = (COSMOS.match(/@media \(max-width:900px\)\{[\s\S]*?#view-manage \.pick\{grid-template-columns:1fr;gap:22px 0;\}([\s\S]*?)\n\}/) || [, ''])[1];
|
|
1728
|
+
assert.match(fold, /#view-manage \.pickCells\[role="radiogroup"\]\{display:flex;flex-wrap:wrap;gap:8px;padding:0 0 14px;\}/, 'the cells flow as pills');
|
|
1729
|
+
assert.match(fold, /#view-manage \.pickCell\.on\{background:var\(--ink\);color:var\(--bg\);border-color:var\(--ink\);\}/, 'the chosen one inverted to ink, the filter pills\' grammar');
|
|
1730
|
+
assert.match(fold, /#view-manage \.pickCell::before\{display:none;\}/, 'no dot on a pill');
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test('the kit proves the picker: a state renders it on the owner\'s feed-carried choice, three probes save / hand back / fold, and both fixtures carry the choice (task 1003347)', () => {
|
|
1734
|
+
const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8'));
|
|
1735
|
+
const st = states.states['in-manage-planet'];
|
|
1736
|
+
assert.ok(st && st.auth && st.url === '/projects?view=manage&id=101', 'the signed-in owner on Demo');
|
|
1737
|
+
assert.ok(st.expect.visible.includes('#mPlanetPick') && st.expect.visible.includes('#mPick [data-pick-reset]'));
|
|
1738
|
+
assert.deepEqual(st.expect.count.find((c) => c[0] === '#mPick .pickCell'), ['#mPick .pickCell', 10], 'three + four + three cells');
|
|
1739
|
+
assert.deepEqual(st.expect.count.find((c) => c[0] === '#mPick .pickSw'), ['#mPick .pickSw', 5]);
|
|
1740
|
+
assert.ok(st.expect.attr.some((a) => a[0] === '#mOrb .worldOrb' && a[2] === 'pair-obsidian-tint'), 'the head wears the owner\'s choice over the curated template');
|
|
1741
|
+
assert.ok(st.expect.text.some((t) => t[0] === '#mPlanet .v small' && t[1] === 'your choice.'));
|
|
1742
|
+
const mine = states.states['in-mine'].expect.count.find((c) => c[0] === '#projectsList .proj .worldOrb[data-template="blob-frost-tint"]');
|
|
1743
|
+
assert.deepEqual(mine, ['#projectsList .proj .worldOrb[data-template="blob-frost-tint"]', 1], 'an owned-only row draws the choice off its own row');
|
|
1744
|
+
const inst = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'provisioning-instances.json'), 'utf8'));
|
|
1745
|
+
const harbor = inst.instances.find((i) => i.slug === 'harbor');
|
|
1746
|
+
assert.equal(harbor.planet_template, 'blob-frost-tint');
|
|
1747
|
+
assert.equal(harbor.planet_accent, 'sun-lit');
|
|
1748
|
+
const probes = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.probes.json'), 'utf8'));
|
|
1749
|
+
const names = probes.probes.map((p) => p.name);
|
|
1750
|
+
assert.ok(names.some((n) => n.startsWith('manage flows: its planet (task 1003347)')), 'the save probe');
|
|
1751
|
+
assert.ok(names.some((n) => n.includes('handed back to the platform')), 'the reset probe');
|
|
1752
|
+
assert.ok(names.some((n) => n.includes('its planet at 390')), 'the fold probe');
|
|
1753
|
+
const saveProbe = probes.probes.find((p) => p.name.startsWith('manage flows: its planet (task 1003347)'));
|
|
1754
|
+
assert.ok(saveProbe.expect.text.some((t) => t[1] === 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.'), 'the route\'s no-restart receipt, verbatim');
|
|
1755
|
+
const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
1756
|
+
assert.match(stub, /const PLANETS = new Map\(\);/, 'the stub remembers a saved planet for the run');
|
|
1757
|
+
assert.match(stub, /planet: PLANETS\.get\(String\(id\)\) \|\| NO_PLANET/, 'and answers it on the settings read and the receipt');
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
test('the feed carries the owner\'s choice through a seam: platform-identity resolves provisioning.projectPlanets optionally and spreads both keys on every project (task 1003347)', () => {
|
|
1761
|
+
const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
|
|
1762
|
+
assert.match(route, /api\.resolveOptional\('provisioning\.projectPlanets', async \(\) => \(\{\}\)\)\(projects\.map\(\(p\) => hostOfOrigin\(p\.origin\)\)\)/, 'optional — an instance without provisioning has no choices to carry');
|
|
1763
|
+
assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null \};/, 'the wire always carries both keys');
|
|
1764
|
+
assert.match(route, /\.\.\.planetOf\(p\),/, 'spread beside the stats');
|
|
1765
|
+
assert.doesNotMatch(route, /require\(['"][^'"]*provisioning/, 'never an import across modules');
|
|
1766
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'module.json'), 'utf8'));
|
|
1767
|
+
assert.ok(manifest.provides.includes('provisioning.projectPlanets'), 'the port is declared');
|
|
1768
|
+
const pi = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'module.json'), 'utf8'));
|
|
1769
|
+
assert.ok(!(pi.consumes || []).includes('provisioning.projectPlanets'), 'not a required capability — the hub must boot without provisioning');
|
|
1770
|
+
const prov = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'), 'utf8');
|
|
1771
|
+
assert.match(prov, /api\.registerProvider\('provisioning\.projectPlanets', \(hosts\) => provisioning\.planetChoicesForHosts\(pool, hosts\)\);/, 'registered beside instanceState');
|
|
1657
1772
|
});
|
|
1658
1773
|
|
|
1659
1774
|
/* ── the table's columns on FIXED tracks (task 1003348; the owner, 2026-08-29: "the
|
|
@@ -44,7 +44,8 @@ provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
|
44
44
|
provisioning.getOpenIntent = async () => null;
|
|
45
45
|
// The live push (task 1003140) — stubbed so the reachable path is deterministic here;
|
|
46
46
|
// its own outcomes are covered in tests/provisioning_settings_apply.mjs.
|
|
47
|
-
|
|
47
|
+
let enqueueCalls = 0; // the live push — a planet change must never queue one (task 1003347)
|
|
48
|
+
provisioning.enqueueSettingsApply = async () => { enqueueCalls++; return { intent: { id: 9, action: 'settings-apply', state: 'pending' }, created: true }; };
|
|
48
49
|
let manifest = null; // the latest manifest attempt the read surfaces (task 1003235)
|
|
49
50
|
provisioning.getLatestOAuthManifestForInstance = async () => manifest;
|
|
50
51
|
|
|
@@ -100,6 +101,7 @@ function reset({ inst, as } = {}) {
|
|
|
100
101
|
builder = as || { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
101
102
|
updateCalls = [];
|
|
102
103
|
events = [];
|
|
104
|
+
enqueueCalls = 0;
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
console.log('GET /provisioning/instances/:id (the manage page\'s read — task 1003041):');
|
|
@@ -163,7 +165,20 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
163
165
|
const r = await get('/provisioning/instances/18/settings');
|
|
164
166
|
assert.equal(r.status, 200, r.raw);
|
|
165
167
|
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
|
|
166
|
-
|
|
168
|
+
// the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
|
|
169
|
+
assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null });
|
|
170
|
+
assert.deepEqual(r.body.options, {
|
|
171
|
+
platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'],
|
|
172
|
+
planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await ta('a stored planet reads back; a name that left the vocabulary reads null (task 1003347)', async () => {
|
|
177
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'purple' } } });
|
|
178
|
+
const r = await get('/provisioning/instances/18/settings');
|
|
179
|
+
assert.equal(r.status, 200, r.raw);
|
|
180
|
+
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null });
|
|
181
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' }, 'the planet is not a setting: the policy read is untouched');
|
|
167
182
|
});
|
|
168
183
|
|
|
169
184
|
await ta('stored keys overlay the defaults', async () => {
|
|
@@ -247,6 +262,70 @@ await ta('an unknown key → 400 (strict: a typo fails loudly, never silently dr
|
|
|
247
262
|
assert.equal(updateCalls.length, 0);
|
|
248
263
|
});
|
|
249
264
|
|
|
265
|
+
console.log('\nPATCH …/settings — the planet (task 1003347):');
|
|
266
|
+
|
|
267
|
+
await ta('the owner picks a planet: saved, echoed as `planet`, audited — and NO push, NO restart (the platform draws it)', async () => {
|
|
268
|
+
reset();
|
|
269
|
+
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
|
|
270
|
+
assert.equal(r.status, 200, r.raw);
|
|
271
|
+
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
|
|
272
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply' }, 'the policy half is untouched');
|
|
273
|
+
assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
|
|
274
|
+
assert.equal(r.body.push.queued, false);
|
|
275
|
+
assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
|
|
276
|
+
assert.match(r.body.push.message, /nothing on your project restarts/);
|
|
277
|
+
assert.equal(enqueueCalls, 0, 'no settings-apply intent for a planet change');
|
|
278
|
+
assert.equal(events.length, 1);
|
|
279
|
+
assert.match(events[0].detail, /planet_template=bead-obsidian-tint, planet_accent=sea/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await ta('null hands the pick back to the platform (stored as null, read back as null)', async () => {
|
|
283
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } } });
|
|
284
|
+
const r = await patch('/provisioning/instances/18/settings', { planet_template: null, planet_accent: null });
|
|
285
|
+
assert.equal(r.status, 200, r.raw);
|
|
286
|
+
assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null });
|
|
287
|
+
assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: null, planet_accent: null } }]);
|
|
288
|
+
assert.equal(enqueueCalls, 0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await ta('the same planet re-sent → "No change", nothing written', async () => {
|
|
292
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { planet_template: 'bead-obsidian-tint' } } });
|
|
293
|
+
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint' });
|
|
294
|
+
assert.equal(r.status, 200, r.raw);
|
|
295
|
+
assert.match(r.body.push.message, /^No change/);
|
|
296
|
+
assert.equal(updateCalls.length, 0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await ta('a planet re-sent over a retired push\'s error_note does NOT re-kick the push (only a policy key does)', async () => {
|
|
300
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', error_note: 'settings-apply failed', settings: { planet_template: 'bead-obsidian-tint' } } });
|
|
301
|
+
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint' });
|
|
302
|
+
assert.equal(r.status, 200, r.raw);
|
|
303
|
+
assert.equal(enqueueCalls, 0, 'the planet never restarts anything');
|
|
304
|
+
const r2 = await patch('/provisioning/instances/18/settings', { joinability: 'apply' });
|
|
305
|
+
assert.equal(r2.status, 200, r2.raw);
|
|
306
|
+
assert.equal(enqueueCalls, 1, 'the policy re-kick (the domain route\'s F19 shape) still works');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await ta('a policy change and a planet change in one save: the push queues for the policy, the planet rides along', async () => {
|
|
310
|
+
reset();
|
|
311
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open', planet_accent: 'gold-soft' });
|
|
312
|
+
assert.equal(r.status, 200, r.raw);
|
|
313
|
+
assert.equal(r.body.push.queued, true);
|
|
314
|
+
assert.equal(r.body.planet.planet_accent, 'gold-soft');
|
|
315
|
+
assert.equal(r.body.saved.joinability, 'open');
|
|
316
|
+
assert.equal(enqueueCalls, 1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await ta('an off-vocabulary planet → 400 validation_failed (a colour is a token NAME, never a hex)', async () => {
|
|
320
|
+
reset();
|
|
321
|
+
for (const body of [{ planet_accent: '#fb9569' }, { planet_accent: 'purple' }, { planet_template: 'cube-gold-wash' }]) {
|
|
322
|
+
const r = await patch('/provisioning/instances/18/settings', body);
|
|
323
|
+
assert.equal(r.status, 400, r.raw);
|
|
324
|
+
assert.equal(r.body.error.code, 'validation_failed');
|
|
325
|
+
}
|
|
326
|
+
assert.equal(updateCalls.length, 0);
|
|
327
|
+
});
|
|
328
|
+
|
|
250
329
|
await ta('an empty body → 400 bad_settings with a plain sentence naming the keys', async () => {
|
|
251
330
|
reset();
|
|
252
331
|
const r = await patch('/provisioning/instances/18/settings', {});
|
|
@@ -71,6 +71,12 @@ t('the prefix is the pack\'s, not a guess — a wrong prefix silently never fire
|
|
|
71
71
|
assert.ok(!('CLOUDBONGOS_PLATFORM_VISIBILITY' in v));
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
t('the planet never rides the env transport (task 1003347): it is the platform\'s to draw, not the instance\'s to run', () => {
|
|
75
|
+
const vars = provisioning.settingsEnvVars({ settings: { platform_visibility: 'gated', planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }, 'CB');
|
|
76
|
+
for (const k of Object.keys(vars)) assert.doesNotMatch(k, /PLANET/, k);
|
|
77
|
+
assert.doesNotMatch(provisioning.settingsEnvLines({ settings: { planet_template: 'bead-obsidian-tint' } }, 'CB'), /planet|PLANET/);
|
|
78
|
+
});
|
|
79
|
+
|
|
74
80
|
t('every emitted key is in SETTINGS_VOCAB — the two lists cannot drift', () => {
|
|
75
81
|
const keys = linesOf(provisioning.settingsEnvLines({}, 'X')).map((l) => l.split('=')[0].replace(/^X_/, '').toLowerCase());
|
|
76
82
|
assert.deepEqual(keys.sort(), Object.keys(provisioning.SETTINGS_VOCAB).sort(),
|