@bongos/core 1.19.533 → 1.19.534
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 +106 -46
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
- package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
- package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
- package/docs/adr/README.md +4 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +129 -126
- package/docs/copy-registry.json +189 -161
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +14 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +21 -6
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-catalog.js +202 -0
- package/modules/platform-identity/routes/my-projects.js +7 -2
- package/modules/platform-identity/routes/projects.js +6 -0
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/provisioning/catalog-bridge.js +105 -0
- package/modules/provisioning/provisioning.js +28 -2
- package/modules/provisioning/routes/provisioning.js +45 -11
- package/modules/provisioning/tests/provisioning.mjs +11 -8
- package/modules/public-landing/public/assets/cosmos.css +15 -0
- package/modules/public-landing/public/projects.html +162 -3
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/provision-net.js +7 -0
- package/src/bongos/project-door.js +138 -0
- package/src/branding.js +8 -0
- package/src/module-api.js +12 -1
- package/tests/access_requests_invite.mjs +2 -2
- package/tests/application_lifecycle.mjs +1 -0
- package/tests/catalog_only_client.mjs +242 -0
- package/tests/currency_label.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/project_catalog_bridge.mjs +355 -0
- package/tests/project_catalog_routes.mjs +213 -0
- package/tests/project_join_door.mjs +166 -0
- package/tests/project_join_door_route.mjs +206 -0
- package/tests/project_modules_ui.mjs +318 -0
- package/tests/projects_hub.mjs +6 -1
- package/tests/projects_hub_pre_uat.mjs +2 -2
- package/tests/provision_settings_apply.mjs +6 -5
- package/tests/provisioning_module_selection.mjs +164 -3
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +7 -4
|
@@ -42,6 +42,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
|
42
42
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
|
|
43
43
|
const { recommendationsForDetails } = require('../recommendations');
|
|
44
44
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
45
|
+
const catalogBridge = require('../catalog-bridge');
|
|
45
46
|
const { callbackPage } = require('./callback-page');
|
|
46
47
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
47
48
|
const log = api.logger('provisioning');
|
|
@@ -479,6 +480,12 @@ module.exports = function provisioningRoutes() {
|
|
|
479
480
|
actor: 'api:self',
|
|
480
481
|
}).catch(() => {});
|
|
481
482
|
}
|
|
483
|
+
// The project joins the hub catalog the moment it EXISTS, not when (or whether) it
|
|
484
|
+
// later federates as a co-tenant (task 1003576, ADR 0246). It is dark matter until a
|
|
485
|
+
// description exists — the publish gate alone decides that — but the row has to be
|
|
486
|
+
// there for the gate to have anything to decide about, and for the wizard's invite
|
|
487
|
+
// step to have a project to invite into while standup is still running.
|
|
488
|
+
await catalogBridge.publishInstanceToCatalog(pool, instance).catch(() => {});
|
|
482
489
|
// A re-request cannot change an existing instance's domain — SAY so instead of
|
|
483
490
|
// silently dropping it (task 1002692); the domain route is the real path.
|
|
484
491
|
let domainIgnored;
|
|
@@ -520,14 +527,13 @@ module.exports = function provisioningRoutes() {
|
|
|
520
527
|
hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
|
|
521
528
|
};
|
|
522
529
|
}
|
|
523
|
-
// And the same for the picker's module set (task 1002339). Like
|
|
524
|
-
// and unlike
|
|
525
|
-
//
|
|
526
|
-
// than naming a way back that does not exist.
|
|
530
|
+
// And the same for the picker's module set (task 1002339). Like detail —
|
|
531
|
+
// and unlike `type` — this one HAS a way back since BV1.R23 (task 1002342),
|
|
532
|
+
// so the receipt names it instead of leaving the owner to hunt for one.
|
|
527
533
|
let modulesIgnored;
|
|
528
534
|
if (!created && body.modules !== undefined) {
|
|
529
535
|
modulesIgnored = {
|
|
530
|
-
hint:
|
|
536
|
+
hint: `a re-request never changes an existing instance's module selection — PATCH /provisioning/instances/${instance.id}/detail with { modules } instead`,
|
|
531
537
|
};
|
|
532
538
|
}
|
|
533
539
|
res.status(created ? 201 : 200).json({
|
|
@@ -653,9 +659,9 @@ module.exports = function provisioningRoutes() {
|
|
|
653
659
|
|
|
654
660
|
// ── The optional project detail (task 1002334, goal 1000046) ──────────────
|
|
655
661
|
// PATCH /provisioning/instances/:id/detail — answer (or re-answer) the
|
|
656
|
-
//
|
|
657
|
-
// { description, detail }. rank: any authenticated builder (own
|
|
658
|
-
// archon may change any — same gate as settings).
|
|
662
|
+
// wizard's OPTIONAL creation steps after creation. Body: any subset of
|
|
663
|
+
// { description, detail, modules }. rank: any authenticated builder (own
|
|
664
|
+
// resource; archon may change any — same gate as settings).
|
|
659
665
|
//
|
|
660
666
|
// THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the
|
|
661
667
|
// project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is
|
|
@@ -668,19 +674,31 @@ module.exports = function provisioningRoutes() {
|
|
|
668
674
|
// answers live on the platform row until a catalog row materializes (the
|
|
669
675
|
// provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that
|
|
670
676
|
// reads `description` to compute publishability and name what is still missing.
|
|
677
|
+
//
|
|
678
|
+
// `modules` rides the SAME route for the same reason (BV1.R23, task 1002342,
|
|
679
|
+
// ADR 0245): step 4 of the wizard is skippable exactly as step 3 is, so it needs
|
|
680
|
+
// the same way back, and it lands on the same platform row under the same
|
|
681
|
+
// ownership gate. Only an ARRAY is a change — an omitted or null key leaves the
|
|
682
|
+
// column alone, so the "never asked" NULL (ADR 0240 §2) can never be written by
|
|
683
|
+
// a later edit. The always-on core stays unremovable by construction:
|
|
684
|
+
// badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.
|
|
671
685
|
router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
|
|
672
686
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
673
687
|
if (!id) return;
|
|
674
688
|
if (validateOrRespond(req, res, {
|
|
675
689
|
description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
|
|
676
690
|
detail: { type: 'object' },
|
|
691
|
+
modules: { type: 'array', itemsType: 'string', maxItems: 50 },
|
|
677
692
|
})) return;
|
|
678
693
|
const body = req.body || {};
|
|
679
694
|
const detailFault = badProjectDetail(body.detail);
|
|
680
695
|
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
681
|
-
|
|
696
|
+
const modulesFault = badModuleSelection(body.modules);
|
|
697
|
+
if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
|
|
698
|
+
const wantsModules = Array.isArray(body.modules);
|
|
699
|
+
if (body.description === undefined && body.detail === undefined && !wantsModules) {
|
|
682
700
|
return res.fail('bad_detail', { status: 400,
|
|
683
|
-
message: 'The body carries nothing to change — send description, detail, or
|
|
701
|
+
message: 'The body carries nothing to change — send description, detail, modules, or any mix.' });
|
|
684
702
|
}
|
|
685
703
|
try {
|
|
686
704
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
@@ -692,12 +710,18 @@ module.exports = function provisioningRoutes() {
|
|
|
692
710
|
const patch = {};
|
|
693
711
|
if (body.description !== undefined) patch.description = provisioning.normalizeDescription(body.description);
|
|
694
712
|
if (body.detail !== undefined) patch.detail = body.detail;
|
|
713
|
+
if (wantsModules) patch.modules = body.modules;
|
|
695
714
|
const updated = await provisioning.updateInstanceDetail(pool, id, patch);
|
|
696
715
|
await provisioning.recordEvent(pool, {
|
|
697
716
|
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'detail',
|
|
698
717
|
detail: `project detail changed: ${Object.keys(patch).join(', ')}`,
|
|
699
718
|
actor,
|
|
700
719
|
}).catch(() => {});
|
|
720
|
+
// Re-take the catalog projection (task 1003576, ADR 0246) — this is THE write the
|
|
721
|
+
// R18 proof caught going nowhere. A description added here has to reach the map, and
|
|
722
|
+
// one CLEARED here has to leave it, which is why the hub assigns `description`
|
|
723
|
+
// rather than COALESCEing it.
|
|
724
|
+
await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
|
|
701
725
|
res.json({
|
|
702
726
|
ok: true,
|
|
703
727
|
description: updated.description || null,
|
|
@@ -809,6 +833,11 @@ module.exports = function provisioningRoutes() {
|
|
|
809
833
|
detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
|
810
834
|
actor,
|
|
811
835
|
}).catch(() => {});
|
|
836
|
+
// The door the owner just set is what the map draws this project's orb from
|
|
837
|
+
// (planet / star / black hole), so the catalog projection is re-taken here as well
|
|
838
|
+
// as on the detail write (task 1003576, ADR 0246). Unlike the push below this
|
|
839
|
+
// needs no restart and no reachable shape — it is the PLATFORM's own row.
|
|
840
|
+
await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
|
|
812
841
|
}
|
|
813
842
|
// The push (task 1003140). The row is now the platform's truth, but a
|
|
814
843
|
// RUNNING instance reads its settings from web.env through a pack memoized
|
|
@@ -1054,7 +1083,12 @@ module.exports = function provisioningRoutes() {
|
|
|
1054
1083
|
// Record BEFORE enqueueing: the runner reads the row's domain, so the intent
|
|
1055
1084
|
// must never race ahead of the column it wires. The release queue rides the
|
|
1056
1085
|
// SAME statement — see updateInstanceDomain.
|
|
1057
|
-
await provisioning.updateInstanceDomain(pool, id, domain, releasing);
|
|
1086
|
+
const rewired = await provisioning.updateInstanceDomain(pool, id, domain, releasing);
|
|
1087
|
+
// The catalog is keyed on ORIGIN, so an address change moves the project's row (task
|
|
1088
|
+
// 1003576, ADR 0246). `retireOrigin` takes the abandoned one off the map in the same
|
|
1089
|
+
// call — a row left `active` at a hostname the project no longer answers on is the
|
|
1090
|
+
// ADR 0181 §2 leak, in the catalog instead of in DNS.
|
|
1091
|
+
await catalogBridge.publishInstanceToCatalog(pool, rewired, { retireOrigin: current }).catch(() => {});
|
|
1058
1092
|
let queued = false;
|
|
1059
1093
|
if (inst.status === 'active') {
|
|
1060
1094
|
const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
|
|
@@ -263,24 +263,27 @@ 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', 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' });
|
|
266
|
+
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
|
|
267
|
+
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
|
|
268
|
+
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
|
|
269
269
|
assert.deepEqual(
|
|
270
270
|
P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
|
|
271
|
-
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public' },
|
|
271
|
+
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full' },
|
|
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', visibility: 'unlisted' } }),
|
|
276
|
-
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' },
|
|
275
|
+
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted', join_grant: 'everything' } }),
|
|
276
|
+
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full' },
|
|
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', visibility: 'public' }, 'a non-object settings value reads as defaults');
|
|
280
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'a non-object settings value reads as defaults');
|
|
281
281
|
// the join door reads back like any other policy key (task 1002324)
|
|
282
282
|
assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
|
|
283
|
-
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' }, 'a stored door overlays only itself');
|
|
283
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full' }, 'a stored door overlays only itself');
|
|
284
|
+
// what a public join grants is a policy key like any other (task 1002331)
|
|
285
|
+
assert.deepEqual(P.effectiveSettings({ settings: { join_grant: 'view' } }),
|
|
286
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view' }, 'the grant overlays only itself');
|
|
284
287
|
});
|
|
285
288
|
|
|
286
289
|
t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
|
|
@@ -1368,6 +1368,21 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1368
1368
|
#view-manage .visOpt:has(input:checked) b::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
|
|
1369
1369
|
#view-manage .visOpt:focus-within{outline:2px solid var(--accent);outline-offset:3px;}
|
|
1370
1370
|
#view-manage #mVisRuns b,#view-manage #mJoinRuns b{color:var(--ok);font-weight:500;}
|
|
1371
|
+
/* the project's extras (task 1002342): the creation picker's own band, asked
|
|
1372
|
+
again after the project exists. Stacked full width rather than side by side
|
|
1373
|
+
like the choice bands above, because each row carries a sentence saying what
|
|
1374
|
+
the part is for — and because these are independent facts, not one choice. */
|
|
1375
|
+
#view-manage .modBand{display:grid;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);}
|
|
1376
|
+
#view-manage .modOpt{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:0;padding:16px 0 18px;border:0;border-top:1px solid var(--rule-soft);border-radius:0;background:none;text-align:left;cursor:pointer;}
|
|
1377
|
+
#view-manage .modOpt:first-child{border-top:0;}
|
|
1378
|
+
#view-manage .modOpt::before{content:"";grid-column:1;grid-row:1;width:7px;height:7px;margin-top:6px;border-radius:50%;border:1px solid var(--rule);background:var(--bg);}
|
|
1379
|
+
#view-manage .modOpt>b,#view-manage .modOpt>small{grid-column:2;min-width:0;}
|
|
1380
|
+
#view-manage .modOpt[aria-pressed="true"]::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
|
|
1381
|
+
#view-manage .modOpt b{font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
|
|
1382
|
+
#view-manage .modOpt small{font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);max-width:62ch;}
|
|
1383
|
+
#view-manage .modOpt:hover::before{border-color:var(--ink-faint);}
|
|
1384
|
+
#view-manage .modOpt[disabled]{cursor:default;opacity:.55;}
|
|
1385
|
+
#view-manage .modOpt:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
|
|
1371
1386
|
/* ── its planet (task 1003347; direction record §16, v1 the bands): the preview
|
|
1372
1387
|
planet left; four bands right — shape · material · style as hairline cells of
|
|
1373
1388
|
one band, a word each, the chosen one carrying the dot; the colour as five
|
|
@@ -829,6 +829,26 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
829
829
|
</details>
|
|
830
830
|
</div>
|
|
831
831
|
|
|
832
|
+
<!-- live since task 1002342 (BV1.R23): the way back into the creation
|
|
833
|
+
wizard's module picker. Step 4 of the wizard is skippable, and an
|
|
834
|
+
optional step is a default rather than a dead end only if there is a
|
|
835
|
+
way back — the same argument the About step's PATCH …/detail was built
|
|
836
|
+
on, which is why this section saves through THAT route and not the
|
|
837
|
+
settings channel beside it: a module set is a declaration on the
|
|
838
|
+
platform row, not a policy a running instance obeys (ADR 0240 §3,
|
|
839
|
+
ADR 0245). So, unlike its three siblings above, nothing is pushed to
|
|
840
|
+
the project and nothing restarts — and there is no "what your project
|
|
841
|
+
runs" read-back line, because there is nothing to read back. The fold
|
|
842
|
+
is a SIBLING of the volatile slot so no re-render can drop it. -->
|
|
843
|
+
<div class="card mSec" id="mModules">
|
|
844
|
+
<h2>Its extras</h2>
|
|
845
|
+
<div id="mMods"><p class="finePrint">Reading the project…</p></div>
|
|
846
|
+
<details class="finePrint" id="mModsWhat">
|
|
847
|
+
<summary>What an extra is — and what is always on</summary>
|
|
848
|
+
<p>Extras are the optional parts a project can run — a shared dev box, a Discord mirror, and the rest. You picked a set when you created the project, or took the suggested set by skipping that step; this is where you change your mind. Switching one on or off saves straight away and changes nothing that is already running: nothing restarts, and no work is lost either way. The parts every project runs — the platform itself, the way it tracks work, the builders’ hall — were never part of the choice, so they are not listed here and cannot be switched off.</p>
|
|
849
|
+
</details>
|
|
850
|
+
</div>
|
|
851
|
+
|
|
832
852
|
<!-- the planet picker (task 1003347; direction record §16, v1 the bands): the
|
|
833
853
|
owner's own choice of how the platform draws this project — the sky,
|
|
834
854
|
the list, this page's head and the card read the same row. The pick is
|
|
@@ -1033,7 +1053,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1033
1053
|
new class and no icon. It filters the band below and NOTHING
|
|
1034
1054
|
else — what is switched on is unaffected by what is on
|
|
1035
1055
|
screen, and the line under it counts the ones it is hiding
|
|
1036
|
-
(ADR
|
|
1056
|
+
(ADR 0245). -->
|
|
1037
1057
|
<input type="text" id="modQ" placeholder="Search extras…" autocomplete="off" aria-label="Search the extras by name" aria-describedby="modFilterNote">
|
|
1038
1058
|
<div class="hint" id="modFilterNote" aria-live="polite"></div>
|
|
1039
1059
|
<div class="typeCards" id="modCards" role="group" aria-labelledby="modLbl"></div>
|
|
@@ -1185,6 +1205,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1185
1205
|
<div id="liveProgress" role="status" aria-live="polite"></div>
|
|
1186
1206
|
<h2 class="stepsHead">Your steps — the parts only you can play</h2>
|
|
1187
1207
|
<ol class="humanSteps" id="humanSteps"></ol>
|
|
1208
|
+
<!-- the modules step was optional (task 1002342, BV1.R23) — one line
|
|
1209
|
+
saying where it lives afterwards, so a skip is never a dead end -->
|
|
1210
|
+
<p class="samePath" id="doneModules"></p>
|
|
1188
1211
|
<p class="samePath">These are the same steps however a project is created — nothing here is special to this page.</p>
|
|
1189
1212
|
</section>
|
|
1190
1213
|
</div>
|
|
@@ -3275,7 +3298,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3275
3298
|
see. Search by MEANING ("somewhere to chat") is a different feature and is
|
|
3276
3299
|
deferred (idea 1000552), so nothing here pretends to understand a query.
|
|
3277
3300
|
|
|
3278
|
-
It is a VIEW over the rows and never an edit to the answer (ADR
|
|
3301
|
+
It is a VIEW over the rows and never an edit to the answer (ADR 0245):
|
|
3279
3302
|
filtering paints fewer rows, and state.modules does not move. That is what
|
|
3280
3303
|
lets a switched-on module be off screen without being switched off — the
|
|
3281
3304
|
count under the field says so rather than leaving it to be discovered. */
|
|
@@ -4071,6 +4094,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4071
4094
|
$('doneLede').innerHTML = 'Provisioning is requested for <strong>' + esc(state.name || state.slug) + '</strong>' +
|
|
4072
4095
|
(adopt ? ' — adopting your existing repo' : '') +
|
|
4073
4096
|
'. The platform stands up the server, database, and address on its own — watch it right here, or any time on <a href="?view=mine">your projects page</a>.';
|
|
4097
|
+
/* the picker's later half (task 1002342, BV1.R23). The modules step is
|
|
4098
|
+
skippable, so this panel names where it lives afterwards rather than
|
|
4099
|
+
leaving an owner to go looking for it. */
|
|
4100
|
+
$('doneModules').innerHTML = 'Add or remove modules later in ' + projectPageLink('the project’s settings') + '.';
|
|
4074
4101
|
renderSteps();
|
|
4075
4102
|
}
|
|
4076
4103
|
|
|
@@ -4084,7 +4111,14 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4084
4111
|
var a = ev.target.closest && ev.target.closest('a[href^="?view="]');
|
|
4085
4112
|
if (!a || !root.contains(a)) return;
|
|
4086
4113
|
ev.preventDefault();
|
|
4087
|
-
|
|
4114
|
+
var href = a.getAttribute('href');
|
|
4115
|
+
var to = (href.match(/view=(\w+)/) || [])[1];
|
|
4116
|
+
/* a manage link names its project in the query, and setView reads manageId
|
|
4117
|
+
rather than the href — so the id has to be handed over or every link on
|
|
4118
|
+
this panel lands on a manage page with no project (task 1002342) */
|
|
4119
|
+
var mid = (href.match(/[?&]id=([^&]*)/) || [])[1];
|
|
4120
|
+
if (to === 'manage' && mid) return goToManage(decodeURIComponent(mid));
|
|
4121
|
+
setView(to, true);
|
|
4088
4122
|
});
|
|
4089
4123
|
|
|
4090
4124
|
/* One delegated listener — renderSteps swaps innerHTML, so handlers live on the list. */
|
|
@@ -5499,6 +5533,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5499
5533
|
guard because its card holds a textarea the in-flight tick must
|
|
5500
5534
|
never repaint out from under a typing owner. */
|
|
5501
5535
|
if (!keepRepo) renderPublish(inst);
|
|
5536
|
+
/* the extras band (task 1002342) rides the same guard: its own toggles
|
|
5537
|
+
are the only thing that should ever repaint it, never the tick */
|
|
5538
|
+
if (!keepRepo) renderMods(inst);
|
|
5502
5539
|
})
|
|
5503
5540
|
.catch(function (err) {
|
|
5504
5541
|
var m = String(err && err.message);
|
|
@@ -6279,6 +6316,128 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6279
6316
|
if (t && t.hasAttribute('data-join-save')) doJoinSave();
|
|
6280
6317
|
});
|
|
6281
6318
|
|
|
6319
|
+
/* ── the project's extras (task 1002342, BV1.R23) ──
|
|
6320
|
+
The way back into the creation wizard's module picker. Step 4 is skippable,
|
|
6321
|
+
and an optional step is a default rather than a dead end only if there is a
|
|
6322
|
+
way back — the argument PATCH …/detail was built on for the About step
|
|
6323
|
+
beside it, which is why this saves through THAT route rather than the
|
|
6324
|
+
settings channel its three neighbours use: a module set is a declaration on
|
|
6325
|
+
the platform row, not a policy a running instance obeys (ADR 0240 §3, ADR
|
|
6326
|
+
0245). Nothing is pushed and nothing restarts, so this section has no "what
|
|
6327
|
+
your project runs" line — there is nothing to read back.
|
|
6328
|
+
|
|
6329
|
+
Two reads, and only one is new. The CURRENT selection rides in on the
|
|
6330
|
+
instance itself (publicInstance resolves it, so a project that skipped the
|
|
6331
|
+
picker reads as its own starter bundle and this page never re-derives that
|
|
6332
|
+
rule); the CATALOG — what may be chosen, and the owner-facing line for each
|
|
6333
|
+
— is the same public GET /provisioning/starter-bundles the wizard reads.
|
|
6334
|
+
|
|
6335
|
+
A toggle IS the save. The band is a list of independent facts rather than
|
|
6336
|
+
one choice, so a Save button would only add a way to lose one. The write
|
|
6337
|
+
sends the WHOLE list because that is what the column stores, and the band
|
|
6338
|
+
repaints from the server's own answer — never from the list we sent — so it
|
|
6339
|
+
can never show a set the platform did not accept. ── */
|
|
6340
|
+
|
|
6341
|
+
var modsCatalog = null; /* { core, optional } — the public catalog, read once */
|
|
6342
|
+
var modsPromise = null;
|
|
6343
|
+
var modsFailed = false; /* asked and did not get it; distinct from not yet asked */
|
|
6344
|
+
var modsBusy = false; /* a write is in flight — the band is inert until it answers */
|
|
6345
|
+
|
|
6346
|
+
function modsSlot(html) { $('mMods').innerHTML = html; }
|
|
6347
|
+
|
|
6348
|
+
/* Lazy and memoised, the wizard picker's rule: /projects serves four views
|
|
6349
|
+
from one file and the map has no business paying for this read. */
|
|
6350
|
+
function ensureModsCatalog() {
|
|
6351
|
+
if (modsPromise) return modsPromise;
|
|
6352
|
+
modsPromise = fetch(API + '/provisioning/starter-bundles', { credentials: 'same-origin' })
|
|
6353
|
+
.then(function (res) { return res.ok ? res.json() : null; })
|
|
6354
|
+
.then(function (data) {
|
|
6355
|
+
if (data && data.optional) modsCatalog = data; else modsFailed = true;
|
|
6356
|
+
if (manageInst) renderMods(manageInst);
|
|
6357
|
+
})
|
|
6358
|
+
.catch(function () { modsFailed = true; if (manageInst) renderMods(manageInst); });
|
|
6359
|
+
return modsPromise;
|
|
6360
|
+
}
|
|
6361
|
+
|
|
6362
|
+
/* What this project runs today, straight off the instance read — never
|
|
6363
|
+
re-derived here, or a skipped picker would read differently on two pages. */
|
|
6364
|
+
function modsSelected(inst) {
|
|
6365
|
+
return ((inst && inst.modules && inst.modules.selected) || []).slice();
|
|
6366
|
+
}
|
|
6367
|
+
|
|
6368
|
+
function renderMods(inst) {
|
|
6369
|
+
ensureModsCatalog();
|
|
6370
|
+
if (!modsCatalog) {
|
|
6371
|
+
/* a read still in flight is a quiet slot; only one that ANSWERED badly
|
|
6372
|
+
gets the clause, so a slow network never reports a break that isn't */
|
|
6373
|
+
modsSlot(modsFailed
|
|
6374
|
+
? '<p class="finePrint">We couldn’t load the list of extras just now — refresh in a moment. Nothing about your project has changed.</p>'
|
|
6375
|
+
: '<p class="finePrint">Reading the project…</p>');
|
|
6376
|
+
return;
|
|
6377
|
+
}
|
|
6378
|
+
var on = modsSelected(inst);
|
|
6379
|
+
var core = (modsCatalog.core || []).length;
|
|
6380
|
+
modsSlot('<div class="modBand" id="mModBand" role="group" aria-label="Extras">' +
|
|
6381
|
+
(modsCatalog.optional || []).map(function (m) {
|
|
6382
|
+
return '<button type="button" class="modOpt" data-mod="' + esc(m.key) + '" aria-pressed="' +
|
|
6383
|
+
(on.indexOf(m.key) === -1 ? 'false' : 'true') + '"' + (modsBusy ? ' disabled' : '') +
|
|
6384
|
+
'><b>' + esc(m.label) + '</b><small>' + esc(m.summary) + '</small></button>';
|
|
6385
|
+
}).join('') + '</div>' +
|
|
6386
|
+
(core ? '<p class="finePrint">Every project also starts with the same ' + core +
|
|
6387
|
+
' core parts — the platform itself, the way it tracks work, and the builders’ hall. They are always on, so they are not part of this choice.</p>' : '') +
|
|
6388
|
+
'<div class="projMsg" id="mModMsg" role="status" aria-live="polite"></div>');
|
|
6389
|
+
}
|
|
6390
|
+
|
|
6391
|
+
/* One toggle, sent as the whole list: the column stores a list, so a delta
|
|
6392
|
+
would have to be re-assembled somewhere and the two copies would drift. */
|
|
6393
|
+
function doModToggle(key) {
|
|
6394
|
+
if (!manageInst || !modsCatalog || modsBusy) return;
|
|
6395
|
+
var on = modsSelected(manageInst);
|
|
6396
|
+
var want = on.indexOf(key) === -1 ? on.concat([key]) : on.filter(function (k) { return k !== key; });
|
|
6397
|
+
/* CATALOG order — the order the picker paints and the server stores, so a
|
|
6398
|
+
re-read of what we just wrote can never look like a change */
|
|
6399
|
+
var next = (modsCatalog.optional || []).map(function (m) { return m.key; })
|
|
6400
|
+
.filter(function (k) { return want.indexOf(k) !== -1; });
|
|
6401
|
+
modsBusy = true;
|
|
6402
|
+
renderMods(manageInst);
|
|
6403
|
+
var box = $('mModMsg');
|
|
6404
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
6405
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/detail', {
|
|
6406
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
6407
|
+
body: JSON.stringify({ modules: next })
|
|
6408
|
+
}).then(function (res) {
|
|
6409
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6410
|
+
modsBusy = false;
|
|
6411
|
+
if (res.ok) {
|
|
6412
|
+
/* the platform's own answer, never the list we sent */
|
|
6413
|
+
manageInst.modules = data.modules || manageInst.modules;
|
|
6414
|
+
renderMods(manageInst);
|
|
6415
|
+
var ok = $('mModMsg');
|
|
6416
|
+
ok.className = 'projMsg ok';
|
|
6417
|
+
ok.textContent = 'Saved. Nothing on the project itself changes — there is nothing to restart.';
|
|
6418
|
+
return;
|
|
6419
|
+
}
|
|
6420
|
+
renderMods(manageInst); /* the band goes back to what the row still says */
|
|
6421
|
+
var e = apiError(data, res.status);
|
|
6422
|
+
var m = $('mModMsg');
|
|
6423
|
+
if (res.status === 401) { m.className = 'projMsg err'; m.innerHTML = signInAgain(); return; }
|
|
6424
|
+
m.className = 'projMsg err';
|
|
6425
|
+
m.textContent = e.message || 'The change could not be saved — try again in a moment.';
|
|
6426
|
+
});
|
|
6427
|
+
}).catch(function () {
|
|
6428
|
+
modsBusy = false;
|
|
6429
|
+
renderMods(manageInst);
|
|
6430
|
+
var m = $('mModMsg');
|
|
6431
|
+
m.className = 'projMsg err';
|
|
6432
|
+
m.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
6433
|
+
});
|
|
6434
|
+
}
|
|
6435
|
+
|
|
6436
|
+
$('mMods').addEventListener('click', function (ev) {
|
|
6437
|
+
var t = ev.target.closest && ev.target.closest('.modOpt[data-mod]');
|
|
6438
|
+
if (t && !t.disabled) doModToggle(t.getAttribute('data-mod'));
|
|
6439
|
+
});
|
|
6440
|
+
|
|
6282
6441
|
/* ── on the map (task 1002324): the join door, ADR 0182 D1 — the third axis,
|
|
6283
6442
|
beside "who can see it" (the project's own sign-in door) and "how people
|
|
6284
6443
|
join" (its membership door). The two reads above, key for key: the CHOICE
|
|
@@ -187,6 +187,7 @@ const STUB_VOCAB = {
|
|
|
187
187
|
platform_visibility: ['public', 'gated'],
|
|
188
188
|
joinability: ['open', 'apply', 'invite_only'],
|
|
189
189
|
visibility: ['public', 'private', 'stealth'],
|
|
190
|
+
join_grant: ['view', 'apply', 'full'],
|
|
190
191
|
planet_template: null, // the template set is long; shape is checked, not membership
|
|
191
192
|
planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
192
193
|
card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.534",
|
|
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.534",
|
|
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.534",
|
|
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/scripts/gds/fitness.js
CHANGED
|
@@ -529,6 +529,15 @@ const KERNEL_FILES = [
|
|
|
529
529
|
// KERNEL (already in the module-scope-map kernel key) and imports only the
|
|
530
530
|
// kernel (instance-config) + node built-ins, so it joins the roster cleanly.
|
|
531
531
|
'src/bongos/repo-info.js',
|
|
532
|
+
// task 1002331 (ADR 0182 D6 / ADR 0247): the project's composed join/apply door —
|
|
533
|
+
// a deterministic classifier over three policy values, the same category as
|
|
534
|
+
// route-rank-check.js and goal-scope-check.js above (a trust decision the doorway
|
|
535
|
+
// hands to modules so no module re-derives it). The doorway re-exports it, so
|
|
536
|
+
// leaving it off this roster reads as a kernel->non-kernel leak; it imports only
|
|
537
|
+
// branding.js and auth-admission.js — both on this roster — so joining costs
|
|
538
|
+
// nothing and buys the import-direction guarantee on the file that decides who
|
|
539
|
+
// may knock.
|
|
540
|
+
'src/bongos/project-door.js',
|
|
532
541
|
];
|
|
533
542
|
|
|
534
543
|
// Kernel files exempt from the no-module-keys scan (Check 10) because naming a
|
|
@@ -22,6 +22,13 @@ function federationHubOrigin() {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// Whether provisioning should federate this instance's sign-in through the hub.
|
|
25
|
+
//
|
|
26
|
+
// THIS GATE DECIDES A CREDENTIAL, NOT CATALOG MEMBERSHIP (task 1003576, ADR 0246). It used
|
|
27
|
+
// to decide both by accident — federateInstance was the only writer of the hub catalog — so
|
|
28
|
+
// a `standalone` project could never appear on the public map. That hop now runs at every
|
|
29
|
+
// provisioning write (modules/provisioning/catalog-bridge.js → the `project.catalog` port),
|
|
30
|
+
// for every hosting shape. What survives here is the narrow question this gate is actually
|
|
31
|
+
// about: whose web.env does the platform write, and therefore who can hold a client secret.
|
|
25
32
|
// Dormant-by-default: true ONLY when the control plane is hub-configured (the
|
|
26
33
|
// platform-identity module is present, a signing key is loadable, AND a hub origin
|
|
27
34
|
// resolves), the instance is a CO-TENANT (the mothership-hosted shape whose web.env is
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/bongos/project-door.js — THE project's join/apply door, composed from every
|
|
2
|
+
// knob that can close it (BV1.R12, task 1002331; ADR 0182 D6, ADR 0247).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT ANSWERS: "a stranger has arrived at this project's door — what happens?"
|
|
5
|
+
// One value, so no two surfaces can answer it differently:
|
|
6
|
+
//
|
|
7
|
+
// open no request needed; signing in is the way in
|
|
8
|
+
// apply an application is accepted and queued for the owner
|
|
9
|
+
// invite_only no application is taken; an invite is the only way in
|
|
10
|
+
// locked nothing — the project is not published, so there is no door
|
|
11
|
+
//
|
|
12
|
+
// WHY IT EXISTS. Three separate owner-set knobs each narrow the same door, and
|
|
13
|
+
// before this file only one of them (`joinability`) was read at the door — so an
|
|
14
|
+
// owner could set their project `private` or `stealth` on the platform's map and
|
|
15
|
+
// still have its public application endpoint behave exactly as before. ADR 0182 D6
|
|
16
|
+
// wrote the composition rule down ("visibility sets the ceiling; join policy may
|
|
17
|
+
// only narrow it; the stricter of the two always wins, and neither can widen the
|
|
18
|
+
// other") and left it unimplemented. This is that rule, as code, in one place.
|
|
19
|
+
//
|
|
20
|
+
// THE THREE KNOBS (all owner-set on the platform, pushed into this instance's
|
|
21
|
+
// branding pack by the provisioning runner, applied by a restart):
|
|
22
|
+
//
|
|
23
|
+
// visibility public | private | stealth how the project stands on the map
|
|
24
|
+
// joinGrant view | apply | full what a PUBLIC join grants (R12)
|
|
25
|
+
// joinability open | apply | invite_only how someone becomes a member (ADR 0194)
|
|
26
|
+
//
|
|
27
|
+
// plus `publishable`, which is DERIVED and never stored (ADR 0182 D1/D4: a project
|
|
28
|
+
// with no name or no description is "dark matter" — off the map and un-joinable).
|
|
29
|
+
//
|
|
30
|
+
// THE REFUSAL NEVER NAMES THE STATE. `stealth`, `public`+`view`, and an explicit
|
|
31
|
+
// `invite_only` all answer with the SAME door, so a refusal can never be read
|
|
32
|
+
// backwards to learn which state a project is in. That is not a nicety: ADR 0182
|
|
33
|
+
// D2a makes stealth's anonymity a privacy primitive, and a door that said "this
|
|
34
|
+
// project is stealth" would hand back exactly what the feed redaction protects.
|
|
35
|
+
// A refused application is also never STORED, so a closed project accumulates no
|
|
36
|
+
// queue to leak (D6).
|
|
37
|
+
//
|
|
38
|
+
// FAIL CLOSED. An unreadable value — a truncated env write, a fourth enum value a
|
|
39
|
+
// future release adds, a pack this core cannot parse — is never the wider door.
|
|
40
|
+
// Same rule ADR 0194 D2 applies to `joinability` alone, and the same direction R06
|
|
41
|
+
// took for the feed (an unrecognised visibility draws as a black hole, not a planet).
|
|
42
|
+
|
|
43
|
+
'use strict';
|
|
44
|
+
|
|
45
|
+
// The door vocabulary, ORDERED widest → strictest. The order IS the composition
|
|
46
|
+
// rule: two knobs never negotiate, the higher index simply wins.
|
|
47
|
+
const JOIN_DOORS = Object.freeze(['open', 'apply', 'invite_only', 'locked']);
|
|
48
|
+
const DOOR_RANK = Object.freeze(Object.fromEntries(JOIN_DOORS.map((d, i) => [d, i])));
|
|
49
|
+
|
|
50
|
+
// What a join of a PUBLIC project grants — the owner's own words from the project
|
|
51
|
+
// interview (BV1.R12). It is a CEILING on the visibility axis, not a second
|
|
52
|
+
// admission policy: `full` means "this axis does not narrow anything", which is
|
|
53
|
+
// why it is the default (DEFAULT_SETTINGS must be today's behaviour).
|
|
54
|
+
const PROJECT_JOIN_GRANTS = Object.freeze(['view', 'apply', 'full']);
|
|
55
|
+
const DOOR_BY_GRANT = Object.freeze({ full: 'open', apply: 'apply', view: 'invite_only' });
|
|
56
|
+
|
|
57
|
+
// The ceiling each stored visibility state sets (ADR 0182 D6's table). `public` is
|
|
58
|
+
// absent on purpose — it is the one state that defers to the grant.
|
|
59
|
+
const DOOR_BY_VISIBILITY = Object.freeze({ private: 'apply', stealth: 'invite_only' });
|
|
60
|
+
|
|
61
|
+
// The joinability policies this core can read (ADR 0194). Its own reader is already
|
|
62
|
+
// fail-closed, so this is the guard against a caller that bypassed it.
|
|
63
|
+
const JOINABILITY_DOOR = Object.freeze({ open: 'open', apply: 'apply', invite_only: 'invite_only' });
|
|
64
|
+
|
|
65
|
+
// The strictest of two doors. PURE. An unknown name is treated as the strictest
|
|
66
|
+
// door there is rather than ignored, so a caller can never widen this by passing
|
|
67
|
+
// a value the table does not carry.
|
|
68
|
+
function strictestDoor(a, b) {
|
|
69
|
+
const ra = DOOR_RANK[a] === undefined ? DOOR_RANK.locked : DOOR_RANK[a];
|
|
70
|
+
const rb = DOOR_RANK[b] === undefined ? DOOR_RANK.locked : DOOR_RANK[b];
|
|
71
|
+
return JOIN_DOORS[Math.max(ra, rb)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The ceiling the project's place on the map sets, before `joinability` narrows it
|
|
75
|
+
// (ADR 0182 D6). PURE. `public` hands the answer to the grant; every other value —
|
|
76
|
+
// including one this core does not recognise — is at most `invite_only`.
|
|
77
|
+
function visibilityCeiling(visibility, joinGrant) {
|
|
78
|
+
if (visibility === 'public') {
|
|
79
|
+
return DOOR_BY_GRANT[joinGrant] || 'invite_only';
|
|
80
|
+
}
|
|
81
|
+
return DOOR_BY_VISIBILITY[visibility] || 'invite_only';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// projectJoinDoor — the whole decision, PURE, from the four inputs a door surface
|
|
85
|
+
// holds. This is the function every join/apply surface must call instead of reading
|
|
86
|
+
// any single knob, so the public application endpoint, the hall's landing form, the
|
|
87
|
+
// CLI and the hub's join relay cannot disagree about which door this project runs.
|
|
88
|
+
//
|
|
89
|
+
// `publishable` is a TRI-STATE and the third value matters: `false` locks the door
|
|
90
|
+
// (dark matter — ADR 0182 D6: "refused — there is no door to knock on"), `true`
|
|
91
|
+
// does not open anything on its own, and `null`/undefined means NO VERDICT — the
|
|
92
|
+
// caller does not hold a publish gate and must not invent one (the same word
|
|
93
|
+
// `provisioning.publishVerdict` uses when the `project.publishGate` port is absent).
|
|
94
|
+
// A caller with no verdict is answered on the three stored knobs alone.
|
|
95
|
+
function projectJoinDoor({ visibility, joinGrant, joinability, publishable = null } = {}) {
|
|
96
|
+
if (publishable === false) return 'locked';
|
|
97
|
+
const ceiling = visibilityCeiling(visibility, joinGrant);
|
|
98
|
+
// An unreadable `joinability` closes to `invite_only`, not to `locked`: its own
|
|
99
|
+
// reader already chose that value for this failure (ADR 0194 D2), and `locked`
|
|
100
|
+
// is a specific CLAIM — "this project is not published" — that an unknown policy
|
|
101
|
+
// is no evidence for. Refusing honestly matters more than refusing hardest.
|
|
102
|
+
return strictestDoor(ceiling, JOINABILITY_DOOR[joinability] || 'invite_only');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// effectiveJoinDoor — this project's LIVE door: projectJoinDoor() over the policy
|
|
106
|
+
// this instance actually resolved. The one call a door surface makes; published
|
|
107
|
+
// through the module doorway as `api.projectJoinDoor()` so a module never
|
|
108
|
+
// re-derives the composition (the drift `joinabilityMode` exists to prevent).
|
|
109
|
+
//
|
|
110
|
+
// The requires are deliberately lazy: this file's pure half must stay importable by
|
|
111
|
+
// a test with no branding pack, no env and no database behind it.
|
|
112
|
+
//
|
|
113
|
+
// A pack that will not load leaves BOTH visibility and the grant unknown, and
|
|
114
|
+
// `visibilityCeiling` answers that with `invite_only` — the same fail-closed door
|
|
115
|
+
// ADR 0194 D2 chose for the same failure, so this composition never reopens
|
|
116
|
+
// something that reader had already closed.
|
|
117
|
+
function effectiveJoinDoor(deps = {}) {
|
|
118
|
+
const readBranding = deps.branding || require('../branding').branding; // eslint-disable-line global-require
|
|
119
|
+
const readJoinability = deps.joinabilityMode || require('./auth-admission').joinabilityMode; // eslint-disable-line global-require
|
|
120
|
+
let project = {};
|
|
121
|
+
try { project = readBranding().project || {}; } catch { project = {}; }
|
|
122
|
+
return projectJoinDoor({
|
|
123
|
+
visibility: project.visibility,
|
|
124
|
+
joinGrant: project.joinGrant,
|
|
125
|
+
joinability: readJoinability(),
|
|
126
|
+
publishable: deps.publishable === undefined ? null : deps.publishable,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
JOIN_DOORS,
|
|
132
|
+
DOOR_RANK,
|
|
133
|
+
PROJECT_JOIN_GRANTS,
|
|
134
|
+
strictestDoor,
|
|
135
|
+
visibilityCeiling,
|
|
136
|
+
projectJoinDoor,
|
|
137
|
+
effectiveJoinDoor,
|
|
138
|
+
};
|