@bongos/core 1.19.532 → 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 +113 -48
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +116 -0
- 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 +5 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +129 -124
- package/docs/copy-registry.json +205 -159
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +16 -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 +221 -2
- package/modules/public-landing/public/projects.states.json +2 -1
- 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 +28 -1
- package/tests/projects_hub_module_picker.mjs +207 -0
- 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
|
@@ -30,7 +30,8 @@ import http from 'node:http';
|
|
|
30
30
|
// ESM named imports off the CJS module on purpose — knip cannot trace a
|
|
31
31
|
// createRequire() import, so these would read as dead exports (the repo's knip
|
|
32
32
|
// learning; tests/provisioning_starter_bundles.mjs does the same).
|
|
33
|
-
import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules }
|
|
33
|
+
import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules, normalizeModuleSelection }
|
|
34
|
+
from '../modules/provisioning/starter-bundles.js';
|
|
34
35
|
|
|
35
36
|
const require = createRequire(import.meta.url);
|
|
36
37
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
@@ -178,6 +179,10 @@ provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
|
178
179
|
if (patch.detail !== undefined) {
|
|
179
180
|
lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
|
|
180
181
|
}
|
|
182
|
+
// `modules` REPLACES and is normalized on the way in — the real statement's
|
|
183
|
+
// `modules = $n::jsonb` over normalizeModuleSelection (BV1.R23, task 1002342).
|
|
184
|
+
// A stub that merged would hide the removal half of "add modules later".
|
|
185
|
+
if (patch.modules !== undefined) lastUpdatedRow.modules = normalizeModuleSelection(patch.modules);
|
|
181
186
|
return lastUpdatedRow;
|
|
182
187
|
};
|
|
183
188
|
|
|
@@ -286,8 +291,9 @@ await ta('POST /instances: modules must be an array of strings', async () => {
|
|
|
286
291
|
});
|
|
287
292
|
|
|
288
293
|
await ta('POST /instances: a re-request never changes an existing project\'s modules — and SAYS so', async () => {
|
|
289
|
-
// Same honesty as
|
|
290
|
-
//
|
|
294
|
+
// Same honesty as detail_ignored (task 1002334): since BV1.R23 (task 1002342)
|
|
295
|
+
// there IS a way back, so the receipt names it rather than leaving the owner
|
|
296
|
+
// to hunt for one.
|
|
291
297
|
existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
|
|
292
298
|
const res = await post('/provisioning/instances', {
|
|
293
299
|
slug: 'mercury', hosting_shape: 'standalone', modules: ['discord'],
|
|
@@ -295,6 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
|
|
|
295
301
|
assert.equal(res.status, 200, res.raw);
|
|
296
302
|
assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
|
|
297
303
|
assert.match(res.body.modules_ignored.hint, /never changes/);
|
|
304
|
+
assert.match(res.body.modules_ignored.hint, /\/detail/, 'and names the route that does change it');
|
|
298
305
|
assert.deepEqual(res.body.instance.modules.selected, ['agents'], 'and the stored answer is what reads back');
|
|
299
306
|
existingRow = null;
|
|
300
307
|
});
|
|
@@ -341,6 +348,160 @@ await ta('PATCH /detail: a description-only patch answers with modules too — t
|
|
|
341
348
|
detailRow = null; lastUpdatedRow = null;
|
|
342
349
|
});
|
|
343
350
|
|
|
351
|
+
// ── Adding (and removing) a module AFTER creation ──────────────────────────
|
|
352
|
+
// BV1.R23, task 1002342, ADR 0244. The picker is optional at creation, and an
|
|
353
|
+
// optional step is only a default rather than a dead end if there is a way back
|
|
354
|
+
// — the same argument that made PATCH …/detail exist for the About step beside
|
|
355
|
+
// it. So the way back is that route's `modules` key, not a new endpoint: the
|
|
356
|
+
// same platform row, the same ownership gate, the same receipt.
|
|
357
|
+
|
|
358
|
+
await ta('PATCH /detail: an owner who skipped the picker can add one module later', async () => {
|
|
359
|
+
// Arrange — a game project that skipped: the column is NULL and the read
|
|
360
|
+
// resolves the type's bundle.
|
|
361
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
362
|
+
lastUpdatedRow = null;
|
|
363
|
+
const before = effectiveModules(detailRow);
|
|
364
|
+
assert.equal(before.source, 'bundle', 'the fixture really is an unanswered project');
|
|
365
|
+
|
|
366
|
+
// Act — the owner adds `agents` on top of what the bundle gave them
|
|
367
|
+
const res = await patch('/provisioning/instances/41/detail', {
|
|
368
|
+
modules: before.selected.concat(['agents']),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Assert — stored, and the read-back is the answer rather than the bundle
|
|
372
|
+
assert.equal(res.status, 200, res.raw);
|
|
373
|
+
assert.ok(lastUpdatedRow, 'the route ran the update');
|
|
374
|
+
assert.deepEqual(lastUpdatedRow.modules, normalizeModuleSelection(before.selected.concat(['agents'])),
|
|
375
|
+
'in catalog order, exactly as the creation write stores it');
|
|
376
|
+
assert.ok(res.body.modules.selected.includes('agents'), 'the added module is there');
|
|
377
|
+
assert.equal(res.body.modules.source, 'chosen', 'an answered project is no longer reading the bundle');
|
|
378
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow), 'the reply is the POST-patch resolution');
|
|
379
|
+
detailRow = null; lastUpdatedRow = null;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await ta('PATCH /detail: a module can be REMOVED later too — the list replaces, it never merges', async () => {
|
|
383
|
+
// The half a merging write would make impossible, which is why
|
|
384
|
+
// updateInstanceDetail replaces `modules` and merges `detail`.
|
|
385
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['dev-box', 'discord'], detail: {} };
|
|
386
|
+
lastUpdatedRow = null;
|
|
387
|
+
|
|
388
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['discord'] });
|
|
389
|
+
|
|
390
|
+
assert.equal(res.status, 200, res.raw);
|
|
391
|
+
assert.deepEqual(lastUpdatedRow.modules, ['discord']);
|
|
392
|
+
assert.deepEqual(res.body.modules.selected, ['discord'], 'dev-box is gone, not still merged in');
|
|
393
|
+
assert.equal(res.body.modules.source, 'chosen');
|
|
394
|
+
detailRow = null; lastUpdatedRow = null;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await ta('PATCH /detail: switching them all off later is an answer, and it stays off', async () => {
|
|
398
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
|
|
399
|
+
lastUpdatedRow = null;
|
|
400
|
+
|
|
401
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: [] });
|
|
402
|
+
|
|
403
|
+
assert.equal(res.status, 200, res.raw);
|
|
404
|
+
assert.deepEqual(lastUpdatedRow.modules, [], 'an empty answer is stored as one, never as NULL');
|
|
405
|
+
assert.deepEqual(res.body.modules.selected, []);
|
|
406
|
+
assert.equal(res.body.modules.source, 'chosen', 'a project that switched the bundle off does not get it back');
|
|
407
|
+
detailRow = null; lastUpdatedRow = null;
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await ta('PATCH /detail: the always-on core cannot be removed — it was never on the list', async () => {
|
|
411
|
+
// Two guards, both pinned: the route REFUSES a core key by name, and the read
|
|
412
|
+
// re-adds the whole core regardless of what was stored.
|
|
413
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
|
|
414
|
+
lastUpdatedRow = null;
|
|
415
|
+
|
|
416
|
+
const refused = await patch('/provisioning/instances/41/detail', { modules: ['memory'] });
|
|
417
|
+
assert.equal(refused.status, 400, refused.raw);
|
|
418
|
+
assert.equal(refused.body.error.code, 'bad_modules');
|
|
419
|
+
assert.match(refused.body.error.message, /always on/i);
|
|
420
|
+
assert.equal(lastUpdatedRow, null, 'a refused write touches nothing');
|
|
421
|
+
|
|
422
|
+
const emptied = await patch('/provisioning/instances/41/detail', { modules: [] });
|
|
423
|
+
assert.deepEqual(emptied.body.modules.core, [...ALWAYS_ON_CORE], 'the core rides every read');
|
|
424
|
+
assert.deepEqual(emptied.body.modules.modules, [...ALWAYS_ON_CORE], 'and every project still has it');
|
|
425
|
+
detailRow = null; lastUpdatedRow = null;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await ta('PATCH /detail: an unknown module is refused and NAMED, exactly as at creation', async () => {
|
|
429
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
430
|
+
lastUpdatedRow = null;
|
|
431
|
+
|
|
432
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['discrod'] });
|
|
433
|
+
|
|
434
|
+
assert.equal(res.status, 400, res.raw);
|
|
435
|
+
assert.equal(res.body.error.code, 'bad_modules');
|
|
436
|
+
assert.match(res.body.error.message, /discrod/, 'the message names what was wrong');
|
|
437
|
+
assert.match(res.body.error.message, /agents/, 'and what the choices are');
|
|
438
|
+
assert.equal(lastUpdatedRow, null);
|
|
439
|
+
detailRow = null; lastUpdatedRow = null;
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await ta('PATCH /detail: modules must be an array of strings here too', async () => {
|
|
443
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
444
|
+
const notArray = await patch('/provisioning/instances/41/detail', { modules: 'discord' });
|
|
445
|
+
assert.equal(notArray.status, 400, notArray.raw);
|
|
446
|
+
assert.equal(notArray.body.error.code, 'validation_failed');
|
|
447
|
+
|
|
448
|
+
const notStrings = await patch('/provisioning/instances/41/detail', { modules: [7] });
|
|
449
|
+
assert.equal(notStrings.status, 400, notStrings.raw);
|
|
450
|
+
assert.equal(notStrings.body.error.code, 'validation_failed');
|
|
451
|
+
detailRow = null; lastUpdatedRow = null;
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await ta('PATCH /detail: a modules-only body is a real change, not an empty one', async () => {
|
|
455
|
+
// The "nothing to change" guard predates this key; a body carrying only
|
|
456
|
+
// modules must not be turned away by it.
|
|
457
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
458
|
+
lastUpdatedRow = null;
|
|
459
|
+
|
|
460
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['agents'] });
|
|
461
|
+
|
|
462
|
+
assert.equal(res.status, 200, res.raw);
|
|
463
|
+
assert.ok(lastUpdatedRow, 'the update ran off modules alone');
|
|
464
|
+
detailRow = null; lastUpdatedRow = null;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await ta('PATCH /detail: null modules cannot UN-ASK the question — the skip state is not writable', async () => {
|
|
468
|
+
// ADR 0240 §2's three-valued contract: NULL means "nobody answered". A later
|
|
469
|
+
// edit that could write NULL back would make a project that HAD answered
|
|
470
|
+
// indistinguishable from one that never was, permanently.
|
|
471
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
|
|
472
|
+
lastUpdatedRow = null;
|
|
473
|
+
|
|
474
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: null, description: 'Still here.' });
|
|
475
|
+
|
|
476
|
+
assert.equal(res.status, 200, res.raw);
|
|
477
|
+
assert.deepEqual(lastUpdatedRow.modules, ['discord'], 'the stored answer is untouched');
|
|
478
|
+
assert.equal(res.body.modules.source, 'chosen');
|
|
479
|
+
detailRow = null; lastUpdatedRow = null;
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await ta('PATCH /detail: a body with only a null modules key is still nothing to change', async () => {
|
|
483
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
484
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: null });
|
|
485
|
+
assert.equal(res.status, 400, res.raw);
|
|
486
|
+
assert.equal(res.body.error.code, 'bad_detail');
|
|
487
|
+
assert.match(res.body.error.message, /modules/, 'and the message names the third thing it accepts');
|
|
488
|
+
detailRow = null; lastUpdatedRow = null;
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
await ta('PATCH /detail: another builder’s project is a 404, not a module change', async () => {
|
|
492
|
+
// The ownership wall is instanceAccessDenied's, shared with every sibling
|
|
493
|
+
// route — pinned here because this key is new on it.
|
|
494
|
+
detailRow = { id: 41, owner_builder_id: 99, slug: 'not-mine', status: 'active', type: 'game', modules: null, detail: {} };
|
|
495
|
+
lastUpdatedRow = null;
|
|
496
|
+
|
|
497
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['agents'] });
|
|
498
|
+
|
|
499
|
+
assert.equal(res.status, 404, res.raw);
|
|
500
|
+
assert.equal(res.body.error.code, 'instance_not_found');
|
|
501
|
+
assert.equal(lastUpdatedRow, null, 'nothing was written');
|
|
502
|
+
detailRow = null; lastUpdatedRow = null;
|
|
503
|
+
});
|
|
504
|
+
|
|
344
505
|
server.close();
|
|
345
506
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
346
507
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -164,7 +164,7 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
164
164
|
reset();
|
|
165
165
|
const r = await get('/provisioning/instances/18/settings');
|
|
166
166
|
assert.equal(r.status, 200, r.raw);
|
|
167
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
167
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
|
|
168
168
|
// the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
|
|
169
169
|
assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
170
170
|
assert.deepEqual(r.body.options, {
|
|
@@ -172,6 +172,9 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
172
172
|
// the join door (task 1002324, ADR 0182 D1) — the third policy axis, offered
|
|
173
173
|
// to the form the same way as its two neighbours
|
|
174
174
|
visibility: ['public', 'private', 'stealth'],
|
|
175
|
+
// what a PUBLIC join grants (task 1002331, ADR 0247) — the fourth policy
|
|
176
|
+
// axis, offered to the form the same way
|
|
177
|
+
join_grant: ['view', 'apply', 'full'],
|
|
175
178
|
planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
176
179
|
// the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
|
|
177
180
|
// closed list, no default, no push — so it rides the same vocab
|
|
@@ -184,14 +187,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
|
|
|
184
187
|
const r = await get('/provisioning/instances/18/settings');
|
|
185
188
|
assert.equal(r.status, 200, r.raw);
|
|
186
189
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
|
|
187
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the planet is not a setting: the policy read is untouched');
|
|
190
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'the planet is not a setting: the policy read is untouched');
|
|
188
191
|
});
|
|
189
192
|
|
|
190
193
|
await ta('stored keys overlay the defaults', async () => {
|
|
191
194
|
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
|
|
192
195
|
const r = await get('/provisioning/instances/18/settings');
|
|
193
196
|
assert.equal(r.status, 200, r.raw);
|
|
194
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' });
|
|
197
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full' });
|
|
195
198
|
});
|
|
196
199
|
|
|
197
200
|
await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
|
|
@@ -219,7 +222,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
|
|
|
219
222
|
reset();
|
|
220
223
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
221
224
|
assert.equal(r.status, 200, r.raw);
|
|
222
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
225
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
223
226
|
assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
|
|
224
227
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
|
|
225
228
|
assert.equal(events.length, 1);
|
|
@@ -232,7 +235,7 @@ await ta('owner changes both keys in one call', async () => {
|
|
|
232
235
|
reset();
|
|
233
236
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
234
237
|
assert.equal(r.status, 200, r.raw);
|
|
235
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public' });
|
|
238
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full' });
|
|
236
239
|
});
|
|
237
240
|
|
|
238
241
|
await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
|
|
@@ -271,7 +274,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
|
|
|
271
274
|
reset();
|
|
272
275
|
const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
|
|
273
276
|
assert.equal(r.status, 200, r.raw);
|
|
274
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' });
|
|
277
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full' });
|
|
275
278
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
|
|
276
279
|
assert.equal(events[0].actor, 'api:self');
|
|
277
280
|
assert.match(events[0].detail, /visibility=stealth/);
|
|
@@ -331,7 +334,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
|
|
|
331
334
|
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
|
|
332
335
|
assert.equal(r.status, 200, r.raw);
|
|
333
336
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
|
|
334
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the policy half is untouched');
|
|
337
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'the policy half is untouched');
|
|
335
338
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
|
|
336
339
|
assert.equal(r.body.push.queued, false);
|
|
337
340
|
assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
|
|
@@ -212,7 +212,7 @@ await ta('ACTIVE standalone: the row is written FIRST, then a settings-apply int
|
|
|
212
212
|
reset();
|
|
213
213
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
214
214
|
assert.equal(r.status, 200, r.raw);
|
|
215
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
215
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
216
216
|
assert.equal(r.body.settings, undefined);
|
|
217
217
|
assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
|
|
218
218
|
assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
|
|
@@ -261,7 +261,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
|
|
|
261
261
|
enqueueThrows = new Error('db down');
|
|
262
262
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
263
263
|
assert.equal(r.status, 200, r.raw);
|
|
264
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' });
|
|
264
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full' });
|
|
265
265
|
assert.equal(updateCalls.length, 1, 'the write landed');
|
|
266
266
|
receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
|
|
267
267
|
assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
|
|
@@ -274,7 +274,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
|
|
|
274
274
|
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
|
|
275
275
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
|
|
276
276
|
assert.equal(r.status, 200, r.raw);
|
|
277
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public' });
|
|
277
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full' });
|
|
278
278
|
assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
|
|
279
279
|
assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
|
|
280
280
|
assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
|
|
@@ -57,7 +57,10 @@ t('a row with NO stored settings emits today\'s behavior EXPLICITLY, never nothi
|
|
|
57
57
|
assert.equal(v.CLOUDBONGOS_JOINABILITY, 'apply');
|
|
58
58
|
// the join door rides the same transport (task 1002324, ADR 0182 D1)
|
|
59
59
|
assert.equal(v.CLOUDBONGOS_VISIBILITY, 'public');
|
|
60
|
-
|
|
60
|
+
// what a public join grants rides it too (task 1002331, ADR 0182 D6)
|
|
61
|
+
assert.equal(v.CLOUDBONGOS_JOIN_GRANT, 'full');
|
|
62
|
+
assert.equal(linesOf(provisioning.settingsEnvLines({}, 'X')).length,
|
|
63
|
+
Object.keys(provisioning.SETTINGS_VOCAB).length, 'every policy key always present');
|
|
61
64
|
});
|
|
62
65
|
|
|
63
66
|
t('an off-vocabulary stored value falls back to the default, never reaches the instance', () => {
|
|
@@ -96,11 +99,11 @@ t('it names the live push by the intent the runner actually dispatches (task 100
|
|
|
96
99
|
});
|
|
97
100
|
|
|
98
101
|
t('settingsEnvVars is the single source both legs derive from — same keys, same values as the lines', () => {
|
|
99
|
-
const row = { settings: { platform_visibility: 'gated', joinability: 'open', visibility: 'stealth' } };
|
|
102
|
+
const row = { settings: { platform_visibility: 'gated', joinability: 'open', visibility: 'stealth', join_grant: 'view' } };
|
|
100
103
|
const vars = provisioning.settingsEnvVars(row, 'CLOUDBONGOS');
|
|
101
|
-
assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth' });
|
|
104
|
+
assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth', CLOUDBONGOS_JOIN_GRANT: 'view' });
|
|
102
105
|
assert.deepEqual(varsOf(provisioning.settingsEnvLines(row, 'CLOUDBONGOS')), vars, 'the standup lines ARE the vars');
|
|
103
|
-
assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public' }, 'defaults, explicitly');
|
|
106
|
+
assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full' }, 'defaults, explicitly');
|
|
104
107
|
});
|
|
105
108
|
|
|
106
109
|
console.log('\nwebEnvBody — the settings ride the real per-instance env file:');
|