@bongos/core 1.25.21 → 1.25.23

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.
Files changed (51) hide show
  1. package/.bongos-core.json +89 -49
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  8. package/docs/api/openapi.json +198 -3
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +415 -410
  11. package/docs/copy-registry.json +551 -506
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-readings.json +382 -376
  15. package/modules/hall-ui/public/palette.js +11 -4
  16. package/modules/hall-ui/records/founding-mode.md +6 -0
  17. package/modules/lifecycle/kickoff-checklist.js +1 -1
  18. package/modules/lifecycle/routes/pay-type-gates.js +7 -2
  19. package/modules/onboarding/project-facts.js +318 -0
  20. package/modules/onboarding/routes/genesis-work.js +16 -0
  21. package/modules/provisioning/project-facts.js +55 -0
  22. package/modules/provisioning/provisioning.js +4 -1
  23. package/modules/provisioning/render-sandbox.js +6 -0
  24. package/modules/public-landing/public/projects.html +191 -31
  25. package/package-lock.json +2 -2
  26. package/package.json +2 -1
  27. package/release-notes.json +20 -0
  28. package/scripts/bongos/genesis.js +38 -2
  29. package/scripts/bongos/init.js +13 -0
  30. package/scripts/bongos/package-core.js +2 -0
  31. package/scripts/bongos/provision-render-sandbox.js +5 -1
  32. package/scripts/bongos/render-payload.js +5 -2
  33. package/scripts/hall-preview/README.md +1 -0
  34. package/scripts/hall-preview/server.js +32 -1
  35. package/src/bongos/folder-upload.js +243 -0
  36. package/src/bongos/routes/github.js +109 -16
  37. package/src/bongos/routes.js +3 -0
  38. package/src/branding.js +4 -0
  39. package/src/module-api.js +1 -1
  40. package/src/sandbox/gate.js +164 -0
  41. package/src/sandbox/index.js +144 -0
  42. package/tests/folder_upload_project.mjs +396 -0
  43. package/tests/genesis_work_routes.mjs +25 -1
  44. package/tests/hall_palette.mjs +59 -2
  45. package/tests/project_facts.mjs +158 -0
  46. package/tests/projects_hub.mjs +1 -1
  47. package/tests/provision_settings_apply.mjs +2 -1
  48. package/tests/provisioning_settings_env.mjs +2 -2
  49. package/tests/public_landing_projects.mjs +27 -5
  50. package/tests/render_sandbox.mjs +7 -4
  51. package/tests/sandbox_gate.mjs +182 -0
@@ -35,10 +35,13 @@ const SHELL_SRC = read('shell.js');
35
35
  // SAME stub every time — real enough to fire DOM events at and read innerHTML
36
36
  // back from, which the pure-compute-only harness never needed before task 1003460
37
37
  // wired a real fetch() into the input handler.
38
- function bootPalette({ navItems = [], visibleIds = ['nav-profile', 'nav-work', 'shell-user'], modules = null, fetchImpl = undefined } = {}) {
38
+ // `asleepIds`: nav ids the shell has marked asleep (.nav-item--asleep) while founding.
39
+ function bootPalette({ navItems = [], visibleIds = ['nav-profile', 'nav-work', 'shell-user'], asleepIds = [], modules = null, fetchImpl = undefined } = {}) {
39
40
  const registry = {};
41
+ const asleepClass = (id) => ({ contains: (c) => c === 'nav-item--asleep' && asleepIds.includes(id) });
40
42
  const makeNav = (it) => ({
41
43
  hidden: !!it.hidden,
44
+ classList: asleepClass(`nav-${it.id}`),
42
45
  target: it.external ? '_blank' : '',
43
46
  getAttribute: (n) => (n === 'href' ? it.href : null),
44
47
  querySelector: (sel) => (sel === '.nav-item__label' ? { textContent: it.label } : null),
@@ -69,7 +72,7 @@ function bootPalette({ navItems = [], visibleIds = ['nav-profile', 'nav-work', '
69
72
  documentElement: { classList: { add() {}, remove() {} } },
70
73
  getElementById(id) {
71
74
  if (id === 'topbar-jump' || id === 'topbar-jump-kbd') return stubEl();
72
- return visibleIds.includes(id) ? { hidden: false } : null;
75
+ return visibleIds.includes(id) ? { hidden: false, classList: asleepClass(id) } : null;
73
76
  },
74
77
  querySelectorAll(sel) {
75
78
  if (sel === '#app-sidebar .nav-item') return navItems.map(makeNav);
@@ -429,3 +432,57 @@ test('closing and reopening clears the cache — a fresh open may see new DB sta
429
432
 
430
433
  assert.equal(calls.length, 2, 'a fresh open must not trust a memo carried over from the previous one');
431
434
  });
435
+
436
+ // ---- founding: a sleeping room is not a destination (task 1005285) ----------
437
+ // While a project is founded the shell marks each room the server says sleeps
438
+ // (/me.founding_mode.asleep, keyed by nav id) with .nav-item--asleep and hides it;
439
+ // the server refuses its page too. The jump list must not offer it either, or the
440
+ // founder is listed a room that is not open. Which rooms sleep is the server's own
441
+ // shapeFoundingMode, never a list typed here.
442
+ const require = (await import('node:module')).createRequire(import.meta.url);
443
+ const foundingMode = require('../modules/onboarding/founding-mode.js');
444
+ const ROOMS = [
445
+ { id: 'home', label: 'Home', href: '/' },
446
+ { id: 'work', label: 'Tasks', href: '/work' },
447
+ { id: 'goals', label: 'Goals', href: '/goals' },
448
+ { id: 'goal-map', label: 'Sky', href: '/goal-map' },
449
+ { id: 'board-room', label: 'Board Room', href: '/board-room' },
450
+ { id: 'collab', label: 'Collab', href: '/collab' },
451
+ { id: 'roster', label: 'Builders', href: '/roster' },
452
+ { id: 'profile', label: 'Profile', href: '/profile' },
453
+ { id: 'primer', label: 'Primer', href: '/primer' },
454
+ ];
455
+ const asleepAt = (stage) => Object.keys(foundingMode.shapeFoundingMode({ active: 'true', stage }).asleep).map((id) => `nav-${id}`);
456
+ const navIds = ROOMS.map((r) => `nav-${r.id}`).concat('shell-user');
457
+ const pages = (P) => P.compute('').filter((r) => r.group === 'Pages').map((r) => r.label);
458
+
459
+ test('founding: a room the server says sleeps is neither a page nor a section in the jump list (task 1005285)', () => {
460
+ const asleepIds = asleepAt('scope');
461
+ assert.deepEqual([...asleepIds].sort(), ['nav-collab', 'nav-goal-map', 'nav-goals', 'nav-roster', 'nav-work'],
462
+ 'at scope the server puts Tasks, Goals, Sky, Collab and Builders to sleep');
463
+ const P = bootPalette({ navItems: ROOMS.map((r) => ({ ...r })), visibleIds: navIds, asleepIds });
464
+ assert.deepEqual(pages(P), ['Home', 'Board Room', 'Profile', 'Primer'], 'only the open rooms are listed');
465
+ const all = P.compute('');
466
+ assert.ok(!all.some((r) => r.group === 'Tasks'), 'no Tasks tab is offered while Tasks sleeps');
467
+ assert.ok(all.some((r) => r.group === 'Profile') && all.some((r) => r.group === 'Primer'), 'an open room keeps its sections');
468
+ assert.ok(!P.compute('tas').some((r) => r.href === '/work' || r.href.indexOf('/work#') === 0), 'searching for it finds nothing that goes there');
469
+ });
470
+
471
+ test('founding: a room opens in the jump list the moment its stage is behind (task 1005285)', () => {
472
+ const asleepIds = asleepAt('charter'); // scope and strategize are done: Goals, Sky and Tasks woke
473
+ const P = bootPalette({ navItems: ROOMS.map((r) => ({ ...r })), visibleIds: navIds, asleepIds });
474
+ assert.deepEqual(pages(P), ['Home', 'Tasks', 'Goals', 'Sky', 'Board Room', 'Profile', 'Primer']);
475
+ assert.ok(P.compute('').some((r) => r.group === 'Tasks'), 'the woken room brings its tabs');
476
+ });
477
+
478
+ test('a project that is not founding: the jump list is exactly what it was (task 1005285)', () => {
479
+ const P = bootPalette({ navItems: ROOMS.map((r) => ({ ...r })), visibleIds: navIds });
480
+ assert.deepEqual(pages(P), ROOMS.map((r) => r.label));
481
+ assert.ok(P.compute('').some((r) => r.group === 'Tasks'));
482
+ });
483
+
484
+ test('the jump list reads the sleeping mark, the one shell.js stamps from /me (task 1005285)', () => {
485
+ assert.match(SHELL_SRC, /classList\.add\('nav-item--asleep'\)/, 'shell.js marks a sleeping room with this class');
486
+ assert.match(PALETTE_SRC, /contains\('nav-item--asleep'\)/, 'and the palette skips exactly that class');
487
+ assert.doesNotMatch(PALETTE_SRC.replace(/\/\/.*$/gm, ''), /founding|asleepRooms|WAKES/, 'the palette never works out which rooms sleep on its own');
488
+ });
@@ -0,0 +1,158 @@
1
+ // tests/project_facts.mjs — the project's own facts, carried from the hub, and each reward
2
+ // option's real implications at the point of choice (task 1005129).
3
+ //
4
+ // Everything RUNS: the hub's real companion line (provisioning.js SETTINGS_COMPANION_ENV →
5
+ // project-facts.js) decoded by the project's real reader, the real soft-limit table carried
6
+ // as data, and the project's facts + options over an in-memory project_settings store and
7
+ // a fake `lifecycle.payTypeGates` port built from the REAL gate table.
8
+ //
9
+ // Run: node --test tests/project_facts.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const provisioning = require('../modules/provisioning/provisioning.js');
17
+ const hub = require('../modules/provisioning/project-facts.js');
18
+ const pf = require('../modules/onboarding/project-facts.js');
19
+ const gatesLib = require('../modules/lifecycle/pay-type-gates.js');
20
+ const { PAY_TYPE_LABELS } = require('../modules/lifecycle/pay-terms.js');
21
+
22
+ // The real gate verdicts with nothing on file (or with the given pay types cleared).
23
+ function payGates(cleared = []) {
24
+ return {
25
+ labels: PAY_TYPE_LABELS,
26
+ readGates: async () => {
27
+ const types = [...Object.keys(gatesLib.REQUIREMENTS), 'eu_labour'];
28
+ return types.map((p) => gatesLib.withoutEvidence(gatesLib.gateVerdict(p, cleared.includes(p)
29
+ ? gatesLib.requirementIds(p).map((requirement) => ({ pay_type: p, requirement, state: 'cleared' })) : [])));
30
+ },
31
+ };
32
+ }
33
+
34
+ function project({ detail, gates = payGates() } = {}) {
35
+ const store = new Map();
36
+ const line = detail ? provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({ detail }) : '';
37
+ return {
38
+ store,
39
+ deps: () => ({
40
+ getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
41
+ setSetting: async (k, v) => { store.set(k, String(v)); },
42
+ factsMarker: pf.factsMarker({ projectFacts: line }),
43
+ payGates: gates,
44
+ now: () => new Date('2026-11-13T12:00:00Z'),
45
+ }),
46
+ };
47
+ }
48
+
49
+ test('the hub carries only the facts a stage reads, and the reward rules as data', () => {
50
+ const line = provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({ detail: {
51
+ home_country: 'DE', builder_geography: 'global', entity: 'nonprofit', minors_on_board: false,
52
+ reward_intents: ['cash', 'equity'], framing: 'managed', project_state: 'new',
53
+ } });
54
+ const m = JSON.parse(Buffer.from(line, 'base64url').toString('utf8'));
55
+ assert.deepEqual(Object.keys(m.facts).sort(), ['builder_geography', 'entity', 'home_country', 'minors_on_board', 'reward_intents']);
56
+ assert.ok(!('framing' in m.facts), 'nothing a stage does not read crosses');
57
+ assert.ok(m.rules.length > 0 && m.rules.every((r) => r.options.some((o) => o.startsWith('reward_intents'))), 'only reward rules ride along');
58
+ assert.deepEqual(m.rules.map((r) => r.id), hub.REWARD_RULES.map((r) => r.id), 'read from the soft-limit table, never rewritten');
59
+ assert.equal(provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({ detail: {} }), '', 'nothing answered: an empty line, not a guess');
60
+ assert.equal(provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({}), '');
61
+ });
62
+
63
+ test('the project seeds its own copy once, as carried over; the founder\'s edit wins after', async () => {
64
+ const p = project({ detail: { home_country: 'US', home_region: 'US-CA', builder_geography: 'us-only' } });
65
+ let r = await pf.readFacts(p.deps());
66
+ const geo = r.facts.find((f) => f.key === 'builder_geography');
67
+ assert.equal(geo.value, 'us-only');
68
+ assert.equal(geo.origin, 'carried_over');
69
+ assert.deepEqual(r.unanswered, ['minors_on_board', 'entity', 'reward_intents'], 'never answered reads as not answered');
70
+ const w = await pf.setFacts({ builder_geography: 'global' }, 1, p.deps());
71
+ assert.equal(w.ok, true);
72
+ r = await pf.readFacts(p.deps());
73
+ assert.equal(r.facts.find((f) => f.key === 'builder_geography').value, 'global', 'the copy is editable, not a frozen snapshot');
74
+ assert.equal(r.facts.find((f) => f.key === 'builder_geography').origin, 'founder');
75
+ assert.equal(r.facts.find((f) => f.key === 'home_country').origin, 'carried_over');
76
+ // Un-answering goes back to "not answered".
77
+ await pf.setFacts({ home_region: null }, 1, p.deps());
78
+ assert.ok((await pf.readFacts(p.deps())).unanswered.includes('home_region'));
79
+ });
80
+
81
+ test('a project not made through the hub has every fact "not answered" and is not refused', async () => {
82
+ const p = project({});
83
+ const r = await pf.readOptions(p.deps());
84
+ assert.equal(r.unanswered.length, 6);
85
+ assert.equal(r.facts.every((f) => f.value === null), true);
86
+ assert.ok(r.options.length > 0, 'the options are still stated, from the enforced table');
87
+ assert.ok(r.options.every((o) => o.facts.length === 0), 'no fact note fires on an answer nobody gave');
88
+ });
89
+
90
+ test('every option states what the server enforces and the facts beside it, kept apart', async () => {
91
+ const p = project({ detail: { home_country: 'US', builder_geography: 'us-eu', entity: 'nonprofit', minors_on_board: true } });
92
+ const r = await pf.readOptions(p.deps());
93
+ const cash = r.options.find((o) => o.pay_type === 'cash');
94
+ assert.equal(cash.status, 'needs_setup');
95
+ assert.equal(cash.enforced.cleared, false);
96
+ assert.deepEqual(cash.enforced.missing, ['Processor onboarding done', 'Tax details collected', 'Age check passed', 'Sanctions screening done', 'Signed pay rule on file'],
97
+ 'the five things cash needs, from the gate table');
98
+ assert.ok(cash.facts.some((n) => /EU aren't available yet/.test(n.reason)), 'builders in the EU: a fact about cash');
99
+ assert.ok(cash.facts.some((n) => /13 to 15/.test(n.reason)), 'minors on board: a fact about cash');
100
+ assert.ok(cash.facts.every((n) => n.enforced === false), 'a soft limit is never shown as enforced');
101
+ const equity = r.options.find((o) => o.pay_type === 'equity');
102
+ assert.ok(equity.facts.some((n) => n.effect === 'grey' && /nonprofit/i.test(n.reason)));
103
+ assert.equal(equity.advised_against, true);
104
+ // A blocked option is SHOWN, with its reason.
105
+ const eu = r.options.find((o) => o.pay_type === 'eu_labour');
106
+ assert.ok(eu, 'EU labour is listed, not hidden');
107
+ assert.equal(eu.status, 'closed');
108
+ assert.equal(eu.enforced.never, true);
109
+ assert.match(eu.enforced.reason, /refuses/);
110
+ assert.equal(eu.label, 'Paying builders in the EU');
111
+ // A cleared gate reads as allowed.
112
+ const q = project({ gates: payGates(['self_managed']) });
113
+ assert.equal((await pf.readOptions(q.deps())).options.find((o) => o.pay_type === 'self_managed').status, 'open');
114
+ });
115
+
116
+ test('changing a fact re-checks every option and says what it changed', async () => {
117
+ const p = project({ detail: { home_country: 'US', builder_geography: 'us-only' } });
118
+ let r = await pf.readOptions(p.deps());
119
+ assert.equal(r.options.find((o) => o.pay_type === 'cash').facts.length, 0);
120
+ const w = await pf.setFacts({ builder_geography: 'global' }, 1, p.deps());
121
+ const c = w.changed.find((x) => x.pay_type === 'cash');
122
+ assert.ok(c, 'cash is named as changed');
123
+ assert.ok(c.new_notes.some((n) => /EU/.test(n)));
124
+ r = await pf.readOptions(p.deps());
125
+ assert.ok(r.options.find((o) => o.pay_type === 'cash').facts.length > 0, 'the read reflects the new fact at once');
126
+ const back = await pf.setFacts({ builder_geography: 'us-only' }, 1, p.deps());
127
+ assert.ok(back.changed.find((x) => x.pay_type === 'cash').gone_notes.length > 0, 'and says when a note no longer applies');
128
+ });
129
+
130
+ test('a bad fact is refused with its reason; nothing is written', async () => {
131
+ const p = project({ detail: { home_country: 'US' } });
132
+ await pf.readFacts(p.deps());
133
+ const before = p.store.get(pf.FACTS_KEY);
134
+ assert.equal((await pf.setFacts({ nope: 1 }, 1, p.deps())).reason, 'unknown_fact');
135
+ assert.equal((await pf.setFacts({ builder_geography: 'mars' }, 1, p.deps())).reason, 'bad_fact');
136
+ assert.equal((await pf.setFacts({ home_region: 'CA-ON' }, 1, p.deps())).reason, 'region_not_in_country');
137
+ assert.equal((await pf.setFacts({ reward_intents: ['gold'] }, 1, p.deps())).reason, 'bad_fact');
138
+ assert.equal(p.store.get(pf.FACTS_KEY), before);
139
+ });
140
+
141
+ test('with no pay-rules module nothing is claimed as enforced', async () => {
142
+ const p = project({ detail: { entity: 'nonprofit' }, gates: null });
143
+ const r = await pf.readOptions(p.deps());
144
+ assert.equal(r.rules_module, false);
145
+ assert.ok(r.options.every((o) => o.enforced === null && o.status === 'no_rules_module'));
146
+ assert.ok(r.options.find((o) => o.pay_type === 'equity').facts.length > 0, 'the facts still speak');
147
+ assert.match(pf.optionsMarkdown(r), /nothing below is enforced by the server/);
148
+ });
149
+
150
+ test('the session read explains the rules and tells it never to add one', async () => {
151
+ const p = project({ detail: { home_country: 'US', builder_geography: 'global' } });
152
+ const md = pf.optionsMarkdown(await pf.readOptions(p.deps()));
153
+ assert.match(md, /never add a rule of your own/);
154
+ assert.match(md, /Closed \(enforced\)/);
155
+ assert.match(md, /Fact, not a rule: /);
156
+ assert.match(md, /Where its builders are: global \(from project creation, not yet confirmed\)/);
157
+ assert.match(md, /not answered/);
158
+ });
@@ -2240,7 +2240,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2240
2240
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
2241
2241
  assert.match(SCRIPT, /hosting_shape: 'cloud-host',/, 'the shape is unchanged');
2242
2242
  assert.match(SCRIPT, /target_ref: state\.owner \+ '\/' \+ state\.repo,/, 'the repo ref is unchanged');
2243
- assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
2243
+ assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \|\| state\.folderUploaded \? 'adopt' : 'greenfield'/, 'the onboard mode: adopt, or a repo made from an uploaded folder (task 1005282)');
2244
2244
  // the kit walks every step and files against the stub
2245
2245
  const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
2246
2246
  for (const s of ['named', 'ready', 'demo', 'checks-filled', 'row-code', 'row-extras', 'look', 'create-blocked', 'done']) assert.ok(states['in-new-' + s], `the ${s} state has a kit state`);
@@ -82,6 +82,7 @@ ${NEUTRAL_PREFIX}_DEMO_HOURS=
82
82
  ${NEUTRAL_PREFIX}_SHIP_MODE=
83
83
  ${NEUTRAL_PREFIX}_TEST_PROJECT=
84
84
  ${NEUTRAL_PREFIX}_GUIDE_HANDOFF=
85
+ ${NEUTRAL_PREFIX}_PROJECT_FACTS=
85
86
  ${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
86
87
  `;
87
88
 
@@ -153,7 +154,7 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
153
154
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
154
155
  [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_FOUNDING_GENESIS`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
155
156
  // the wizard's ship mode (task 1004629), empty when none was picked
156
- [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '', [`${NEUTRAL_PREFIX}_GUIDE_HANDOFF`]: '',
157
+ [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '', [`${NEUTRAL_PREFIX}_GUIDE_HANDOFF`]: '', [`${NEUTRAL_PREFIX}_PROJECT_FACTS`]: '',
157
158
  // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
158
159
  [`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
159
160
  });
@@ -128,7 +128,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
128
128
  // route never stamped, which is every row made before it shipped (spec D8).
129
129
  CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_FOUNDING_GENESIS: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
130
130
  // The wizard's ship mode (task 1004629): empty when none was picked.
131
- CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '', CLOUDBONGOS_GUIDE_HANDOFF: '',
131
+ CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '', CLOUDBONGOS_GUIDE_HANDOFF: '', CLOUDBONGOS_PROJECT_FACTS: '',
132
132
  });
133
133
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
134
134
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -137,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
137
137
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
138
138
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
139
139
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
140
- X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '', X_GUIDE_HANDOFF: '',
140
+ X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '', X_GUIDE_HANDOFF: '', X_PROJECT_FACTS: '',
141
141
  }, 'defaults, explicitly');
142
142
  });
143
143
 
@@ -252,12 +252,34 @@ test('the repo is named as text from the server read, never a guessed link (task
252
252
  });
253
253
 
254
254
  test('the create-repo response keeps the html_url it already returned', async () => {
255
+ // EXECUTED, not read (task 1005282 moved the response handling into repoMade(), shared
256
+ // with the folder upload): the served page's repo create runs in a vm against a stub
257
+ // fetch, and the URL GitHub returned must land on the draft and be saved.
255
258
  const { body } = await request('/projects', 'game.test.local');
256
- const fn = (body.match(/function ensureFreshRepo\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
257
- assert.ok(fn, 'ensureFreshRepo() must be extractable');
258
- assert.match(fn, /data\.repo && data\.repo\.html_url/, 'the URL is read off the response, not dropped');
259
- assert.match(fn, /state\.repoUrl = made/, 'and stored on the draft so a resume keeps it');
260
- assert.match(fn, /saveDraft\(\)/, 'the draft is persisted after the URL lands on state');
259
+ const src = body.replace(/\r\n/g, '\n');
260
+ const s = src.indexOf(' function ensureFreshRepo() {');
261
+ const e = s < 0 ? -1 : src.indexOf(" $('doCreate').addEventListener('click'", s);
262
+ assert.ok(s > 0 && e > s, 'ensureFreshRepo() and its response handler must be extractable');
263
+ const vm = (await import('node:vm')).default;
264
+ let saves = 0;
265
+ const sandbox = {
266
+ API: '/api/bongos', JSON, String, Promise, Error,
267
+ state: { mode: 'greenfield', repo: 'mercury', owner: 'founder' },
268
+ repoAccess: true, hopForRepoAccess: () => false, syncHome() {}, paintFolder() {},
269
+ saveDraft() { saves++; sandbox.savedUrl = sandbox.state.repoUrl; },
270
+ fetch: async () => ({ ok: true, status: 201, json: async () => ({ repo: { full_name: 'founder/mercury', html_url: 'https://github.com/founder/mercury' } }) }),
271
+ };
272
+ vm.createContext(sandbox);
273
+ vm.runInContext(src.slice(s, e), sandbox);
274
+ await sandbox.ensureFreshRepo();
275
+ assert.equal(sandbox.state.repoUrl, 'https://github.com/founder/mercury', 'the URL is read off the response, not dropped');
276
+ assert.equal(sandbox.savedUrl, 'https://github.com/founder/mercury', 'the draft is persisted after the URL lands on state');
277
+ assert.ok(saves >= 1);
278
+ // a URL that is not GitHub's own is never kept
279
+ sandbox.state = { mode: 'greenfield', repo: 'x', owner: 'founder' };
280
+ sandbox.fetch = async () => ({ ok: true, status: 201, json: async () => ({ repo: { full_name: 'founder/x', html_url: 'https://evil.example/x' } }) });
281
+ await sandbox.ensureFreshRepo();
282
+ assert.equal(sandbox.state.repoUrl, undefined);
261
283
  });
262
284
 
263
285
  /* ── The address step's three choices (task 1002898 N2 / N8 / N10) ───────────
@@ -132,10 +132,13 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
132
132
  assert.equal(sb.branch, 'main');
133
133
  assert.equal(sb.autoDeploy, 'yes', 'the sandbox follows every merge');
134
134
  assert.equal(sb.repo, 'https://github.com/example-owner/demo');
135
- assert.deepEqual(sb.envVars, [
135
+ const pageKey = sb.envVars.find((v) => v.key === 'BONGOS_SANDBOX_PAGE_KEY');
136
+ assert.match(pageKey && pageKey.value, /^[0-9a-f]{48}$/, 'its own page key (task 1005291)');
137
+ assert.equal(h.inst.app_host.sandbox.pageKey, pageKey.value, 'kept on the row for the owner’s link');
138
+ assert.deepEqual(sb.envVars.filter((v) => v.key !== 'BONGOS_SANDBOX_PAGE_KEY'), [
136
139
  { key: 'BONGOS_SANDBOX', value: '1' },
137
140
  { key: 'DATABASE_URL', value: 'postgresql://u:p@dpg-new-a/demo_sandbox' },
138
- ], 'its own database on the same server — never the live one');
141
+ ], 'its own database on the same server — never the live one, and no other key');
139
142
  // the second database is made with SQL on the app's server, existence checked first
140
143
  assert.equal(h.sqlRan.length, 2);
141
144
  assert.match(h.sqlRan[0].statements[0], /FROM pg_database WHERE datname = \$1/);
@@ -151,7 +154,7 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
151
154
  await ta('an app with no database: the sandbox has none either, and says so', async () => {
152
155
  const h = harness({ host: { database: false } });
153
156
  assert.equal((await h.run()).ok, true);
154
- assert.deepEqual(created(h, 'demo-sandbox').body.envVars, [{ key: 'BONGOS_SANDBOX', value: '1' }]);
157
+ assert.deepEqual(created(h, 'demo-sandbox').body.envVars.filter((v) => v.key !== 'BONGOS_SANDBOX_PAGE_KEY'), [{ key: 'BONGOS_SANDBOX', value: '1' }]);
155
158
  assert.equal(h.sqlRan.length, 0);
156
159
  assert.equal(h.inst.app_host.sandbox.database, 'none');
157
160
  assert.match(h.inst.app_host.sandbox.dbNote, /no database, so its sandbox has none/);
@@ -173,7 +176,7 @@ await ta('the database stage fails: the sandbox still runs, with no database, an
173
176
  const h = harness({ sql: async () => { throw new Error('permission denied to create database'); } });
174
177
  const out = await h.run();
175
178
  assert.equal(out.ok, true);
176
- assert.deepEqual(created(h, 'demo-sandbox').body.envVars, [{ key: 'BONGOS_SANDBOX', value: '1' }], 'no DATABASE_URL at all — never the live one');
179
+ assert.deepEqual(created(h, 'demo-sandbox').body.envVars.filter((v) => v.key !== 'BONGOS_SANDBOX_PAGE_KEY'), [{ key: 'BONGOS_SANDBOX', value: '1' }], 'no DATABASE_URL at all — never the live one');
177
180
  const s = h.inst.app_host.sandbox;
178
181
  assert.equal(s.state, 'deploying');
179
182
  assert.equal(s.database, 'failed');
@@ -0,0 +1,182 @@
1
+ // tests/sandbox_gate.mjs — the sandbox's outbound gate and band (task 1005291; ADR 0406 §5).
2
+ //
3
+ // src/sandbox/ driven with fake fetch and fake http/https modules: no network, no provider,
4
+ // no spend. The task's DONE WHEN, as written: with real keys lent, a read succeeds, and a
5
+ // send, a charge and a write are each refused and logged. Plus: nothing changes outside a
6
+ // sandbox; a sandbox page is visibly marked; the owner can see what was refused and which
7
+ // real keys are lent (by name, never value); and the page says plainly what it cannot catch.
8
+ //
9
+ // Run: node tests/sandbox_gate.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import { EventEmitter } from 'node:events';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const { decide } = require('../src/sandbox/gate.js');
17
+ const { install, isSandbox, pageHtml, PAGE } = require('../src/sandbox/index.js');
18
+
19
+ let passed = 0, failed = 0;
20
+ async function ta(name, fn) {
21
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
22
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
23
+ }
24
+
25
+ const LIVE = 'sk_live_51Habcdefghijklmnopqrstuv';
26
+ const auth = (k) => ({ authorization: `Bearer ${k}` });
27
+
28
+ console.log('the policy (decide):');
29
+ await ta('a read to a known provider passes; a charge, a send and a write are refused, named by what they would do', () => {
30
+ assert.equal(decide({ method: 'GET', host: 'api.stripe.com', path: '/v1/customers', headers: auth(LIVE) }).allow, true);
31
+ const charge = decide({ method: 'POST', host: 'api.stripe.com', path: '/v1/payment_intents', headers: auth(LIVE) });
32
+ assert.deepEqual([charge.allow, charge.provider, charge.kind], [false, 'Stripe', 'charge']);
33
+ assert.match(charge.reason, /take or move a payment for real/);
34
+ const send = decide({ method: 'POST', host: 'api.sendgrid.com', path: '/v3/mail/send' });
35
+ assert.deepEqual([send.allow, send.kind], [false, 'send']);
36
+ const write = decide({ method: 'PATCH', host: 'api.github.com', path: '/repos/o/r/issues/1' });
37
+ assert.deepEqual([write.allow, write.kind], [false, 'write']);
38
+ });
39
+ await ta('a test-mode Stripe key is let through: it cannot move real money', () => {
40
+ assert.equal(decide({ method: 'POST', host: 'api.stripe.com', path: '/v1/charges', headers: auth('sk_test_abc') }).allow, true);
41
+ assert.equal(decide({ method: 'POST', host: 'api.stripe.com', path: '/v1/charges', headers: auth('rk_live_abc') }).allow, false, 'a restricted LIVE key is still refused writes');
42
+ });
43
+ await ta('a read some providers make by POST passes; their other writes do not', () => {
44
+ assert.equal(decide({ method: 'POST', host: 'api.notion.com', path: '/v1/databases/abc/query' }).allow, true);
45
+ assert.equal(decide({ method: 'POST', host: 'api.notion.com', path: '/v1/pages' }).allow, false);
46
+ });
47
+ await ta('a host the gate does not know passes — the gate covers what it knows, and the page says so', () => {
48
+ assert.equal(decide({ method: 'POST', host: 'example.org', path: '/x' }).allow, true);
49
+ });
50
+
51
+ console.log('\noutside a sandbox:');
52
+ await ta('install() changes nothing unless BONGOS_SANDBOX=1', async () => {
53
+ const realFetch = async () => 'real';
54
+ const host = { fetch: realFetch };
55
+ const http = { request: () => 'r', get: () => 'g' };
56
+ assert.equal(isSandbox({}), false);
57
+ assert.equal(isSandbox({ BONGOS_SANDBOX: '1' }), true);
58
+ const out = install({ env: {}, fetchHost: host, http, https: http });
59
+ assert.equal(out.active, false);
60
+ assert.equal(host.fetch, realFetch, 'fetch untouched');
61
+ assert.equal(http.request(), 'r', 'http untouched');
62
+ let nexted = false;
63
+ out.middleware({}, {}, () => { nexted = true; });
64
+ assert.equal(nexted, true, 'the middleware passes straight through');
65
+ });
66
+
67
+ console.log('\nin a sandbox, with a real key lent:');
68
+ const sent = [], lines = [];
69
+ const fetchHost = { fetch: async (input, init = {}) => { sent.push({ via: 'fetch', url: String(input), method: init.method || 'GET' }); return { status: 200, ok: true }; } };
70
+ function fakeHttp(name) {
71
+ return {
72
+ request(...args) { sent.push({ via: name, args }); const r = new EventEmitter(); r.end = () => r; return r; },
73
+ get(...args) { sent.push({ via: `${name}.get`, args }); const r = new EventEmitter(); r.end = () => r; return r; },
74
+ };
75
+ }
76
+ const http = fakeHttp('http'), https = fakeHttp('https');
77
+ const env = { BONGOS_SANDBOX: '1', STRIPE_SECRET_KEY: LIVE, STRIPE_READ_KEY: 'rk_live_51Hrestrictedabcdefg', SENDGRID_API_KEY: 'SG.abcdefghijklmnop.qrstuvwxyz', PORT: '3000' };
78
+ const sb = install({ env, fetchHost, http, https, write: (l) => lines.push(l) });
79
+
80
+ await ta('a read succeeds', async () => {
81
+ sent.length = 0;
82
+ const r = await fetchHost.fetch('https://api.stripe.com/v1/customers?limit=3', { headers: auth(LIVE) });
83
+ assert.equal(r.status, 200);
84
+ assert.equal(sent.length, 1, 'the read went out');
85
+ });
86
+ await ta('a charge is refused (403, never sent) and logged', async () => {
87
+ sent.length = 0; lines.length = 0;
88
+ const r = await fetchHost.fetch('https://api.stripe.com/v1/payment_intents', { method: 'POST', headers: auth(LIVE), body: 'amount=500' });
89
+ assert.equal(r.status, 403);
90
+ const body = await r.json();
91
+ assert.equal(body.error.type, 'bongos_sandbox_refused');
92
+ assert.equal(sent.length, 0, 'nothing left the sandbox');
93
+ assert.match(lines[0], /^\[bongos-sandbox\] refused charge to Stripe: POST api\.stripe\.com\/v1\/payment_intents$/);
94
+ });
95
+ await ta('a send through an SDK’s https.request is refused with an error, never connects, and is logged', async () => {
96
+ sent.length = 0; lines.length = 0;
97
+ const req = https.request({ hostname: 'api.sendgrid.com', method: 'POST', path: '/v3/mail/send', headers: { authorization: 'Bearer SG.x' } });
98
+ const err = await new Promise((resolve) => { req.on('error', resolve); req.end('{}'); });
99
+ assert.equal(err.code, 'BONGOS_SANDBOX_REFUSED');
100
+ assert.equal(err.kind, 'send');
101
+ assert.equal(sent.length, 0, 'the real request was never made');
102
+ assert.match(lines[0], /refused send to SendGrid/);
103
+ });
104
+ await ta('a write is refused and logged; https.get (a read) still goes out', async () => {
105
+ sent.length = 0; lines.length = 0;
106
+ const r = await fetchHost.fetch(new URL('https://api.github.com/repos/o/r/issues'), { method: 'POST' });
107
+ assert.equal(r.status, 403);
108
+ assert.match(lines[0], /refused write to GitHub/);
109
+ https.get('https://api.github.com/repos/o/r');
110
+ assert.deepEqual(sent.map((s) => s.via), ['https.get']);
111
+ });
112
+ await ta('every refusal is kept, newest first, for the owner to see', () => {
113
+ assert.deepEqual(sb.refusals.slice(0, 3).map((r) => r.kind), ['write', 'send', 'charge']);
114
+ });
115
+
116
+ console.log('\nthe band and the page:');
117
+ function fakeRes() {
118
+ const headers = { 'content-type': 'text/html; charset=utf-8' };
119
+ const res = { statusCode: 200, out: '', getHeader: (k) => headers[k.toLowerCase()], setHeader: (k, v) => { headers[k.toLowerCase()] = v; }, removeHeader: (k) => { delete headers[k.toLowerCase()]; } };
120
+ res.write = (c) => { res.out += c; return true; };
121
+ res.end = (c) => { if (c) res.out += c; res.ended = true; };
122
+ return { res, headers };
123
+ }
124
+ await ta('every HTML page carries the "Sandbox" band, with the count refused and a link', () => {
125
+ const { res } = fakeRes();
126
+ sb.middleware({ method: 'GET', url: '/' }, res, () => {});
127
+ res.end('<html><body><h1>Shop</h1></body></html>');
128
+ assert.match(res.out, /<body><div id="bongos-sandbox-band"/);
129
+ assert.match(res.out, /Sandbox — a test copy of this site\. Nothing here reaches real customers\./);
130
+ assert.doesNotMatch(res.out, /refused/, 'no count for passers-by');
131
+ assert.match(res.out, /href="\/__bongos\/sandbox"/);
132
+ });
133
+ await ta('a non-HTML answer is left alone', () => {
134
+ const { res, headers } = fakeRes();
135
+ headers['content-type'] = 'application/json';
136
+ sb.middleware({ method: 'GET', url: '/api' }, res, () => {});
137
+ res.end('{"a":1}');
138
+ assert.equal(res.out, '{"a":1}');
139
+ });
140
+ await ta('the sandbox page, for the owner: what was refused, which real keys are lent (names only), and what it cannot catch', () => {
141
+ const html = pageHtml({ items: sb.refusals }, env, { owner: true });
142
+ assert.match(html, /would take or move a payment/);
143
+ assert.match(html, /<code>STRIPE_SECRET_KEY<\/code>: Stripe — a full live secret key\. Prefer a restricted key with read access only\./);
144
+ assert.match(html, /<code>STRIPE_READ_KEY<\/code>: Stripe — a restricted live key/);
145
+ assert.ok(!html.includes(LIVE) && !html.includes('SG.abcdefghijklmnop'), 'never a key value');
146
+ assert.match(html, /Code that goes around them, or a provider it does not know, is not stopped\. A read-only key is the real protection/);
147
+ });
148
+ await ta('without the owner’s page key a visitor sees what a sandbox is — never the refusals or the lent keys', () => {
149
+ const KEY = 'a'.repeat(48);
150
+ env.BONGOS_SANDBOX_PAGE_KEY = KEY; // the env install() was given — the platform sets it on the sandbox
151
+ try {
152
+ for (const url of ['/__bongos/sandbox', '/__bongos/sandbox?key=wrong', `/__bongos/sandbox?key=${'b'.repeat(48)}`]) {
153
+ const { res } = fakeRes();
154
+ let nexted = false;
155
+ sb.middleware({ method: 'GET', url }, res, () => { nexted = true; });
156
+ assert.equal(nexted, false);
157
+ assert.match(res.out, /<h1>This is a sandbox<\/h1>/);
158
+ assert.match(res.out, /is not stopped/, 'the limits are public');
159
+ assert.doesNotMatch(res.out, /Refused calls|STRIPE_SECRET_KEY|payment_intents/, `${url} shows nothing private`);
160
+ }
161
+ const { res } = fakeRes();
162
+ sb.middleware({ method: 'GET', url: `/__bongos/sandbox?key=${KEY}` }, res, () => {});
163
+ assert.match(res.out, /Refused calls/);
164
+ assert.match(res.out, /STRIPE_SECRET_KEY/);
165
+ } finally { delete env.BONGOS_SANDBOX_PAGE_KEY; }
166
+ });
167
+ await ta('the owner page path is spelled the same in core and in the provisioning module', () => {
168
+ assert.equal(PAGE, '/__bongos/sandbox');
169
+ });
170
+
171
+ console.log('\nthe owner’s project page:');
172
+ await ta('the owner’s sandbox status links to the sandbox’s own page with its page key', () => {
173
+ const rs = require('../modules/provisioning/render-sandbox.js');
174
+ const KEY = 'c'.repeat(48);
175
+ const host = (sb) => ({ id: 1, status: 'active', app_host: { platform: 'render', state: 'live', serviceId: 'srv-live0001', sandboxWanted: true,
176
+ sandbox: { state: 'live', serviceId: 'srv-sbx00001', serviceUrl: 'https://demo-sandbox.onrender.com', ...sb } } });
177
+ assert.equal(rs.sandboxStatus(host({ pageKey: KEY })).safety_page, `https://demo-sandbox.onrender.com${PAGE}?key=${KEY}`);
178
+ assert.equal(rs.sandboxStatus(host({})).safety_page, `https://demo-sandbox.onrender.com${PAGE}`, 'an older sandbox has no key: the public page');
179
+ });
180
+
181
+ console.log(`\nsandbox_gate: ${passed} passed, ${failed} failed`);
182
+ process.exit(failed ? 1 : 0);