@bongos/core 1.25.28 → 1.25.30

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.
@@ -0,0 +1,54 @@
1
+ # Cloud Bongos's own sandbox — sandbox.cloudbongos.com
2
+
3
+ > Task [task 1005292](https://cloudbongos.com/builders#/task/1005292), under [ADR 0406](../adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md) §7. Stood up 2026-11-14.
4
+
5
+ The same rule every project follows, one level up: **every published core release reaches the sandbox first; the live hub moves only when a person promotes.** For Cloud Bongos, "main" is the published release (ADR 0161), so the sandbox follows releases and the hub holds a pin.
6
+
7
+ ## Where it runs (owner's choices, 2026-11-14)
8
+
9
+ | | Sandbox | Live hub |
10
+ |---|---|---|
11
+ | Address | `https://sandbox.cloudbongos.com` (one host: projects page and hall) | `cloudbongos.com`, `builders.`, `status.` |
12
+ | Box | DigitalOcean `cloudbongos-sandbox` (IP in the DigitalOcean console; reached with the control-plane key `cloudbongos_ed25519`), `s-1vcpu-2gb`, **NYC1**, $12/mo, tag `cloudbongos-sandbox` (NYC3 had no `s-1vcpu-2gb` left) | `cloudbongos`, NYC3 |
13
+ | Takes releases | **every one, as published** — `cloudbongos-sandbox-update.timer`, every 15 min, roster entry `follow: candidates`, `channel: minor`, `pinMode: local-commit` | **never by itself** — its row's update rule is `pinned` (set 2026-11-14) |
14
+ | Moves when | a release is published | a person presses the move on **Hall → Deploy** (`/deploy`) |
15
+ | Data | its own empty `cloudbongos` database | the real one |
16
+
17
+ The owner chose a separate server over the hub's box (which every project hall shares and which sits near its memory limit), and chose that patch releases stop going live by themselves once the sandbox exists.
18
+
19
+ ## What makes it a sandbox
20
+
21
+ - `BONGOS_SANDBOX=1` in `/etc/cloudbongos/web.env`. The hub server installs `src/sandbox` (`src/platform-server.js`), so every page carries the yellow band and sends, charges and writes to known providers (Discord, GitHub's API, Stripe, mail) are refused and logged as `[bongos-sandbox] refused …` in `journalctl -u cloudbongos-sandbox`. The band appears once a release carrying task 1005292 is installed.
22
+ - **One named exception:** `BONGOS_SANDBOX_ALLOW=github-repo-create`, so a test project's creation can make its (free) GitHub repository. Every other GitHub change stays refused (owner's choice, 2026-11-14; `src/sandbox/gate.js` `ALLOWANCES`).
23
+ - **No outside credentials in its environment:** no push token, no box-source token, no Discord, no Cloudflare or DigitalOcean token. Its `web.env` copies only the hub's look and behaviour settings.
24
+ - Its own GitHub sign-in app ("Cloud Bongos sandbox", callback `https://sandbox.cloudbongos.com/api/gds/auth/web/callback`). Session cookies are host-only, and `cookieDomain` is `sandbox.cloudbongos.com`, so signing in on one site never touches the other. `firstAdmin` is `willHermesLines` (Will O'Brien, who started the goal): his first sign-in seats the sandbox's owner. The creation walk is task 1005314.
25
+ - `config/branding.json` addresses all point at `sandbox.cloudbongos.com` (committed locally in the sandbox checkout; it has no remote, so nothing reaches the hub's instance repo).
26
+
27
+ ## Test projects
28
+
29
+ Projects created on the sandbox live at `<slug>.sandbox.cloudbongos.com`. The wildcard `*.sandbox.cloudbongos.com` is one grey-cloud Cloudflare record pointing at the sandbox, so the sandbox needs no Cloudflare token (`BOX_DNS_HOOK` unset, the runner skips the DNS step). Caddy gives each one a certificate on demand, through the same `on_demand_tls` ask as the hub. A test project (`modules/provisioning/test-project.js`) is archon-only and never shows on the public map. Created projects are built by `sandbox-provision-intent-runner.timer` (every 2 minutes; the live hub's twin is `selfhost-provision-intent-runner`), which carries no `box.env`, so it holds no Cloudflare or DigitalOcean token.
30
+
31
+ ## Box layout
32
+
33
+ - Checkout `/home/lars/cloudbongos-instance` (a copy of the hub's instance repo with the `origin` remote removed). Service `cloudbongos-sandbox.service`, port 3002, user `lars`.
34
+ - Caddy `/etc/caddy/Caddyfile`: the hub's global block (Cloudflare trusted proxies, `on_demand_tls`), `import /etc/caddy/sites/*.caddy`, and the `sandbox.cloudbongos.com` site on the `*.cloudbongos.com` origin cert (proxied, orange cloud).
35
+ - 2 GB swap, `vm.swappiness=10`, ufw allowing 22/80/443.
36
+ - `/usr/local/sbin/sandbox-set-github` stores the sign-in app's id and secret (one per line on stdin) and restarts the service.
37
+
38
+ ## Checks
39
+
40
+ ```bash
41
+ curl -s https://sandbox.cloudbongos.com/version
42
+ ```
43
+
44
+ ```bash
45
+ ssh -i ~/.ssh/cloudbongos_ed25519 root@<sandbox droplet IP> 'systemctl list-timers | grep sandbox; journalctl -u cloudbongos-sandbox-update -n 20 --no-pager'
46
+ ```
47
+
48
+ The hub's own sweep must keep printing `cloudbongos: pinned — skipped`.
49
+
50
+ ## Gotchas found standing it up
51
+
52
+ - The hub's Cloudflare token is locked to the hub's IPv4 address: call the API with `curl -4`, or it answers 9109 "Cannot use the access token from location".
53
+ - `@bongos/core` is public on npm, so the sandbox needs no registry token (`docs/recipes/core-release-pipeline.md` still describes an `NPM_TOKEN` file on the hub, which no longer exists there).
54
+ - The Claude terminal panel shows what is typed even at a hidden (`read -s`) prompt. Never have a person paste a secret into it; read it from the clipboard (`pbpaste`) without reading the keyboard at all.
@@ -265,6 +265,10 @@
265
265
  return false;
266
266
  }
267
267
  function homeHref(mode) {
268
+ // In game mode the street is not a place (shell-game.js would send it back
269
+ // home), so the patron's home there is Home itself (task 1005307).
270
+ var g = window.OTBShellGame;
271
+ if (g && typeof g.on === 'function' && g.on() === true) return '/';
268
272
  var street = mode === 'patron' && window.GovCity && window.GovCity.STREET_HREF;
269
273
  return street || '/';
270
274
  }
@@ -355,9 +355,17 @@
355
355
  }
356
356
 
357
357
  // ---- the governor's Home is the street; tests swap the hop for a recorder ----
358
+ // Classic view only (task 1005307): in game mode the street is not a place, so
359
+ // shell-game.js sends it straight back here — hopping from Home made an endless
360
+ // reload loop. The game's patron stays on the city and walks on from the visor.
361
+ // shell-game.js is the one answer: a page without it is never in game mode.
362
+ function gameOn() {
363
+ const g = window.OTBShellGame;
364
+ return !!(g && typeof g.on === 'function' && g.on() === true);
365
+ }
358
366
  function streetHop() {
359
367
  const href = window.GovCity && window.GovCity.STREET_HREF;
360
- if (document.documentElement.dataset.craft !== 'patron' || !href) return false;
368
+ if (document.documentElement.dataset.craft !== 'patron' || !href || gameOn()) return false;
361
369
  (window.OTBHomeStreetHop || ((h) => window.location.assign(h)))(href);
362
370
  return true;
363
371
  }
@@ -179,7 +179,8 @@
179
179
  if (!p) return null;
180
180
  const el = d.documentElement;
181
181
  Object.keys(p).forEach((name) => el.style.setProperty('--sky-now-' + name, String(p[name])));
182
- el.setAttribute('data-sky-hour', String(Math.round(h * 10) / 10));
182
+ // rounded to a tenth, 23:57 to 23:59 would say 24, which is no hour: wrap after rounding (task 1005299)
183
+ el.setAttribute('data-sky-hour', String(wrap(Math.round(h * 10) / 10)));
183
184
  lastHour = h;
184
185
  return p;
185
186
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.28",
3
+ "version": "1.25.30",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.28",
9
+ "version": "1.25.30",
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.25.28",
3
+ "version": "1.25.30",
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",
@@ -11940,5 +11940,20 @@
11940
11940
  "id": "1005305",
11941
11941
  "text": "In game mode the builder's Home is now just the city: no scrolling, you move around with the arrow keys, by dragging, or by resting the cursor at a screen edge, and you rewind and replay the city's history from the visor's top"
11942
11942
  }
11943
+ ],
11944
+ "1.25.29": [
11945
+ {
11946
+ "text": "Cloud Bongos now has a test copy at sandbox.cloudbongos.com that gets every new release first, and the main site only updates when a person presses promote."
11947
+ },
11948
+ {
11949
+ "id": "1005299",
11950
+ "text": "The city's sky clock no longer says \"hour 24\" in the last three minutes before midnight, and its check no longer fails at random late at night."
11951
+ }
11952
+ ],
11953
+ "1.25.30": [
11954
+ {
11955
+ "id": "1005307",
11956
+ "text": "Choosing Patron in game mode no longer reloads the hall endlessly; it settles on the city."
11957
+ }
11943
11958
  ]
11944
11959
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.28'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.30'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -225,6 +225,14 @@ function buildPlatformApp() {
225
225
  // same reqId. Mounted before any surface so nothing logs uncorrelated.
226
226
  app.use(requestIdMiddleware);
227
227
 
228
+ // THE PLATFORM'S OWN SANDBOX (task 1005292, ADR 0406 §7). Cloud Bongos runs a second
229
+ // copy of itself that takes every release first; that copy runs with BONGOS_SANDBOX=1
230
+ // and nothing else marks it. install() is the same one a project's app uses: every
231
+ // page carries the "Sandbox" band, and sends/charges/writes to known providers
232
+ // (Discord, GitHub's API, Stripe, mail) are refused and logged. On the live hub, where
233
+ // the variable is absent, it returns a pass-through and changes nothing.
234
+ app.use(require('./sandbox').install().middleware);
235
+
228
236
  // THE request.divert SEAM (task 1004300, ADR 0349). A module may register a provider that
229
237
  // takes a request BEFORE any surface answers and either handles it or passes it on —
230
238
  // today the npm-release module's version preview, which hands a browser holding its
@@ -56,18 +56,32 @@ function credentialOf(headers) {
56
56
  return '';
57
57
  }
58
58
 
59
+ // NAMED EXCEPTIONS a sandbox may switch on with BONGOS_SANDBOX_ALLOW (comma-separated
60
+ // names), each one write and nothing near it (task 1005292). Cloud Bongos's own sandbox
61
+ // switches on `github-repo-create`, so a test project's creation walk can make its (free)
62
+ // repository, owner's choice 2026-11-14; every other GitHub change stays refused. An
63
+ // unknown name allows nothing.
64
+ const ALLOWANCES = {
65
+ 'github-repo-create': { host: /^api\.github\.com$/i, method: 'POST', path: /^\/(user|orgs\/[A-Za-z0-9-]+)\/repos$/ },
66
+ };
67
+ function parseAllowances(raw) {
68
+ return String(raw || '').split(',').map((x) => x.trim()).filter((x) => Object.prototype.hasOwnProperty.call(ALLOWANCES, x));
69
+ }
70
+
59
71
  /**
60
- * The gate's whole policy. `{ method, host, path, headers }` → `{ allow: true }` or
61
- * `{ allow: false, provider, kind, reason }`. A host in no provider's row passes: the
72
+ * The gate's whole policy. `{ method, host, path, headers, allowances }` → `{ allow: true }`
73
+ * or `{ allow: false, provider, kind, reason }`. A host in no provider's row passes: the
62
74
  * gate covers the providers it knows, and says so.
63
75
  */
64
- function decide({ method, host, path, headers }) {
76
+ function decide({ method, host, path, headers, allowances = [] }) {
65
77
  const m = String(method || 'GET').toUpperCase();
66
78
  const h = String(host || '').toLowerCase().replace(/:\d+$/, '');
67
79
  const p = PROVIDERS.find((x) => x.host.test(h));
68
80
  if (!p) return { allow: true };
69
81
  if (READ_METHODS.has(m)) return { allow: true, provider: p.name };
70
82
  const pathOnly = String(path || '/').split('?')[0];
83
+ const named = allowances.find((n) => { const a = ALLOWANCES[n]; return a && a.method === m && a.host.test(h) && a.path.test(pathOnly); });
84
+ if (named) return { allow: true, provider: p.name, allowance: named };
71
85
  if (m === 'POST' && (p.postReads || []).some((re) => re.test(pathOnly))) return { allow: true, provider: p.name };
72
86
  if (p.testKey && p.testKey.test(credentialOf(headers))) return { allow: true, provider: p.name, testMode: true };
73
87
  return {
@@ -97,13 +111,13 @@ class SandboxRefused extends Error {
97
111
  }
98
112
 
99
113
  /** Wrap a fetch: a refused call answers 403 with a body that says why, and never leaves. */
100
- function wrapFetch(fetchImpl, log) {
114
+ function wrapFetch(fetchImpl, log, allowances = []) {
101
115
  if (!fetchImpl || fetchImpl.__bongosSandbox) return fetchImpl;
102
116
  const wrapped = async function sandboxFetch(input, init = {}) {
103
117
  const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url);
104
118
  const method = (init.method || (input && input.method) || 'GET');
105
119
  const headers = init.headers || (input && input.headers) || {};
106
- const d = decide({ method, host: url.host, path: url.pathname + url.search, headers: typeof headers.get === 'function' ? headers : lower(headers) });
120
+ const d = decide({ method, host: url.host, path: url.pathname + url.search, headers: typeof headers.get === 'function' ? headers : lower(headers), allowances });
107
121
  if (d.allow) return fetchImpl(input, init);
108
122
  log.add({ ...d, method: String(method).toUpperCase(), host: url.host, path: url.pathname });
109
123
  return new Response(JSON.stringify({ error: { type: 'bongos_sandbox_refused', provider: d.provider, kind: d.kind, message: d.reason } }),
@@ -140,14 +154,14 @@ function refusedRequest(d, cb) {
140
154
  }
141
155
 
142
156
  /** Patch a node `http` / `https` module's request + get through the gate. */
143
- function wrapHttp(mod, log) {
157
+ function wrapHttp(mod, log, allowances = []) {
144
158
  if (!mod || mod.request.__bongosSandbox) return;
145
159
  const realRequest = mod.request;
146
160
  const realGet = mod.get;
147
161
  const guarded = (real, isGet) => {
148
162
  const fn = function sandboxRequest(...args) {
149
163
  const t = targetOf(args);
150
- const d = decide(t);
164
+ const d = decide({ ...t, allowances });
151
165
  if (d.allow) return real.apply(mod, args);
152
166
  log.add({ ...d, method: String(t.method).toUpperCase(), host: t.host, path: t.path });
153
167
  const req = refusedRequest(d, args[args.length - 1]);
@@ -161,4 +175,4 @@ function wrapHttp(mod, log) {
161
175
  mod.get = guarded(realGet, true);
162
176
  }
163
177
 
164
- module.exports = { decide, makeLog, wrapFetch, wrapHttp };
178
+ module.exports = { decide, makeLog, wrapFetch, wrapHttp, parseAllowances };
@@ -19,7 +19,7 @@
19
19
  //
20
20
  // Fake data is the app's own to seed: `isSandbox()` lets it seed only here.
21
21
 
22
- const { makeLog, wrapFetch, wrapHttp } = require('./gate');
22
+ const { makeLog, wrapFetch, wrapHttp, parseAllowances } = require('./gate');
23
23
 
24
24
  const PAGE = '/__bongos/sandbox';
25
25
  let state = null;
@@ -84,7 +84,9 @@ function pageHtml(log, env = process.env, { owner = false } = {}) {
84
84
  : `<p>The project's owner sees what this copy refused from their project page.</p>`)
85
85
  + `<h2>What this cannot catch</h2>`
86
86
  + `<p>Reads to a known provider go out; sends, charges and writes to it are refused. It only sees calls this app makes through Node's own web requests, and only to the providers it knows. Code that goes around them, or a provider it does not know, is not stopped. A read-only key is the real protection; this is the second wall.</p>`
87
- + `<p>The free plan also blocks outgoing email ports, so this copy cannot send email directly.</p>`
87
+ // Only true on Render's free plan (Render sets RENDER=true); the platform's own sandbox
88
+ // runs on a plain server, where saying it would be false (task 1005292).
89
+ + (String(env.RENDER || '') === 'true' ? `<p>The free plan also blocks outgoing email ports, so this copy cannot send email directly.</p>` : '')
88
90
  + `</body></html>`;
89
91
  }
90
92
 
@@ -131,9 +133,10 @@ function install({ env = process.env, fetchHost = globalThis, http = require('no
131
133
  if (!isSandbox(env)) return { active: false, middleware: passThrough, refusals: [], bandHtml: () => '', isSandbox: false };
132
134
  if (!state) {
133
135
  const log = makeLog(write ? { write } : {});
134
- if (typeof fetchHost.fetch === 'function') fetchHost.fetch = wrapFetch(fetchHost.fetch, log);
135
- wrapHttp(http, log);
136
- wrapHttp(https, log);
136
+ const allowances = parseAllowances(env.BONGOS_SANDBOX_ALLOW);
137
+ if (typeof fetchHost.fetch === 'function') fetchHost.fetch = wrapFetch(fetchHost.fetch, log, allowances);
138
+ wrapHttp(http, log, allowances);
139
+ wrapHttp(https, log, allowances);
137
140
  state = { log, middleware: makeMiddleware(log, env) };
138
141
  }
139
142
  return { active: true, middleware: state.middleware, refusals: state.log.items, bandHtml, isSandbox: true };
@@ -31,7 +31,7 @@ function el() {
31
31
  }
32
32
 
33
33
  // One page: `page` is body[data-page], `active` the nav item the shell marked.
34
- function boot({ page, active = null, govCity = false }) {
34
+ function boot({ page, active = null, govCity = false, game = false }) {
35
35
  const hops = [];
36
36
  const body = el();
37
37
  body.setAttribute('data-page', page);
@@ -55,6 +55,7 @@ function boot({ page, active = null, govCity = false }) {
55
55
  location: { search: '' },
56
56
  };
57
57
  if (govCity) window.GovCity = { STREET_HREF: '/builders/street' };
58
+ if (game) window.OTBShellGame = { on: () => true };
58
59
  window.window = window;
59
60
  vm.runInNewContext(CRAFT, { window, document, CustomEvent: function () {}, Promise, console });
60
61
  return { CM: window.OTBCraftMode, hops };
@@ -83,6 +84,16 @@ test('choosing Patron on another craft\'s page lands on the street', () => {
83
84
  assert.equal(boot({ page: 'studio' }).CM.homeHref('patron'), '/');
84
85
  });
85
86
 
87
+ // task 1005307: in game mode the street is not a place (shell-game.js sends it back
88
+ // Home, and Home sent a patron to the street again: an endless reload loop), so a
89
+ // stranded page goes straight Home.
90
+ test('in game mode, choosing Patron on another craft\'s page goes Home, never the street', () => {
91
+ const { CM, hops } = boot({ page: 'studio', active: 'studio', govCity: true, game: true });
92
+ assert.equal(CM.homeHref('patron'), '/');
93
+ CM.choose('patron');
94
+ assert.deepEqual(hops, ['/']);
95
+ });
96
+
86
97
  test('a page the chosen craft also shows stays put', () => {
87
98
  // goals is in Artisan and Artist alike
88
99
  const { CM, hops } = boot({ page: 'goals', active: 'goals' });
@@ -192,7 +192,13 @@ test('a pinned hour wins: the root attribute, then the query, then the hash; a b
192
192
  });
193
193
 
194
194
  // The page half, run against a stand-in document whose computed styles are style.css's own tokens.
195
- function page({ search = '', hash = '', attr = null } = {}) {
195
+ // `now` fixes the viewer's clock (a local "HH:MM"), so no case depends on when the suite runs.
196
+ function clockAt(now) {
197
+ if (now == null) return Date;
198
+ const [hh, mm] = now.split(':').map(Number);
199
+ return class extends Date { constructor(...a) { if (a.length) super(...a); else super(2026, 0, 1, hh, mm, 0); } };
200
+ }
201
+ function page({ search = '', hash = '', attr = null, now = null } = {}) {
196
202
  const set = {}; const attrs = attr == null ? {} : { 'data-sky-hour-at': attr };
197
203
  const timers = []; let probes = 0; let computed = 0;
198
204
  const html = {
@@ -216,7 +222,7 @@ function page({ search = '', hash = '', attr = null } = {}) {
216
222
  return { color: `rgb(${v[0]}, ${v[1]}, ${v[2]})` };
217
223
  },
218
224
  setInterval(fn, ms) { timers.push({ fn, ms }); return timers.length; },
219
- Date, Number, String, Math, Object, Array, parseInt, decodeURIComponent, isFinite,
225
+ Date: clockAt(now), Number, String, Math, Object, Array, parseInt, decodeURIComponent, isFinite,
220
226
  };
221
227
  vm.runInNewContext(read('sky-time.js'), ctx, { filename: 'sky-time.js' });
222
228
  return { api: window.OTBSkyTime, set, attrs, timers, document, stats: () => ({ probes, computed }) };
@@ -242,12 +248,26 @@ test('on load the page wears the hour\'s palette as --sky-now-* on the root, fro
242
248
  test('the override reaches the page: the hash and the root attribute pin the hour too', () => {
243
249
  assert.equal(page({ hash: '#hour=18' }).set['--sky-now-cloud'], KEYS.dusk.cloud.toLowerCase());
244
250
  assert.equal(page({ attr: '6', search: '?hour=13' }).set['--sky-now-cloud'], KEYS.dawn.cloud.toLowerCase());
245
- // with nothing pinned the viewer's clock is the source: a number from 0 up to 24
246
- const live = page();
247
- assert.ok(Number(live.attrs['data-sky-hour']) >= 0 && Number(live.attrs['data-sky-hour']) < 24);
251
+ // with nothing pinned the viewer's clock is the source (fixed here, so the case never
252
+ // depends on when the suite runs)
253
+ const live = page({ now: '13:30' });
254
+ assert.equal(live.attrs['data-sky-hour'], '13.5');
248
255
  assert.equal(live.api.hourNow({ document: live.document, location: { search: '?hour=4', hash: '' } }), 4);
249
256
  });
250
257
 
258
+ // Task 1005299: 23:57 to 23:59 rounded to one decimal is 24.0, which is not an hour; the
259
+ // last minutes of the day say the next day's first hour, and wear its palette.
260
+ test('the hour the page says never reaches 24: the last minutes of the day wrap to 0', () => {
261
+ for (const now of ['23:57', '23:58', '23:59']) {
262
+ const p = page({ now });
263
+ assert.equal(p.attrs['data-sky-hour'], '0', `${now} says hour 0, not 24`);
264
+ assert.equal(p.set['--sky-now-mid'], KEYS.night.mid.toLowerCase(), `${now} wears night`);
265
+ }
266
+ assert.equal(page({ now: '23:56' }).attrs['data-sky-hour'], '23.9');
267
+ assert.equal(page({ now: '00:00' }).attrs['data-sky-hour'], '0');
268
+ assert.equal(page({ search: '?hour=23.98' }).attrs['data-sky-hour'], '0', 'a pinned hour wraps the same way');
269
+ });
270
+
251
271
  test('no motion: the palette is recomputed at most once a minute and nothing animates between two', () => {
252
272
  const p = page({ search: '?hour=6' });
253
273
  assert.equal(p.timers.length, 1, 'one timer');
@@ -48,12 +48,13 @@ const stubs = (status, craft) => `
48
48
  window.OTBCraftMode = { iconHtml: (k, cls) => '<svg class="' + cls + '" data-icon="' + k + '"></svg>', choose: (m) => window.__chosen.push(m), current: () => window.__craft };
49
49
  window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
50
50
 
51
- async function boot({ status = 200, width = 1280, still = true, craft = null } = {}) {
51
+ async function boot({ status = 200, width = 1280, still = true, craft = null, game = false } = {}) {
52
52
  const ctx = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: still ? 'reduce' : 'no-preference' });
53
53
  const page = await ctx.newPage();
54
54
  await page.clock.install();
55
55
  await page.setContent(`<!doctype html><html><head><style>${CSS}</style></head><body><section id="home-city" class="scroll home-city" hidden></section></body></html>`);
56
56
  await page.addScriptTag({ content: stubs(status, craft) });
57
+ if (game) await page.addScriptTag({ content: 'window.OTBShellGame = { on: () => true };' });
57
58
  await page.addScriptTag({ content: GOV });
58
59
  await page.addScriptTag({ content: DRAW });
59
60
  await page.addScriptTag({ content: WIDGET });
@@ -184,6 +185,16 @@ test('the governor\'s Home is the street: Patron on Home goes there, opened or c
184
185
  await ctx.close();
185
186
  });
186
187
 
188
+ // task 1005307: in game mode the street is not a place — shell-game.js sends it back to
189
+ // Home — so hopping there from Home was an endless reload loop. The game's patron stays.
190
+ test('in game mode the patron stays on Home: no hop to the street, opened or chosen', { skip }, async () => {
191
+ const { ctx, page } = await boot({ craft: 'patron', game: true });
192
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
193
+ const flipped = await page.evaluate(() => { document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: 'patron' } })); return window.__hops.slice(); });
194
+ await ctx.close();
195
+ assert.deepEqual(flipped, [], 'Home with Patron in game mode builds the city and never leaves for the street');
196
+ });
197
+
187
198
  test('a refused read leaves the section hidden: never an empty plate', { skip }, async () => {
188
199
  const { ctx, page } = await boot({ status: 401 });
189
200
  await page.evaluate(() => new Promise((r) => setTimeout(r, 0)));
@@ -0,0 +1,96 @@
1
+ // tests/platform_sandbox.mjs — Cloud Bongos's own sandbox (task 1005292; ADR 0406 §7).
2
+ //
3
+ // The platform runs a second copy of itself that takes every release before the live hub.
4
+ // That copy is the same server started with BONGOS_SANDBOX=1. Pinned here:
5
+ // 1. the hub server installs the sandbox middleware, before any surface answers, so every
6
+ // page of the copy carries the band — and on the live hub (no variable) it is a no-op;
7
+ // 2. the sandbox page only claims Render's blocked mail ports where Render is the host,
8
+ // because the platform's sandbox runs on a plain server where that sentence is false.
9
+ //
10
+ // Run: node tests/platform_sandbox.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const { install, pageHtml } = require('../src/sandbox/index.js');
21
+
22
+ let passed = 0, failed = 0;
23
+ async function t(name, fn) {
24
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
25
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
26
+ }
27
+
28
+ const src = fs.readFileSync(path.join(ROOT, 'src/platform-server.js'), 'utf8')
29
+ .replace(/^\s*\/\/.*$/gm, ''); // comments may cite the rule; only code counts
30
+
31
+ await t('the hub server mounts the sandbox middleware', () => {
32
+ assert.match(src, /app\.use\(require\('\.\/sandbox'\)\.install\(\)\.middleware\)/);
33
+ });
34
+
35
+ await t('it is mounted before any surface or module answers', () => {
36
+ const at = src.indexOf("require('./sandbox')");
37
+ const divert = src.indexOf("resolveOptional('request.divert')");
38
+ const surfaces = src.indexOf('mountInternalSurfaces(');
39
+ assert.ok(at > 0 && divert > 0 && at < divert, 'sandbox must come before the request.divert seam');
40
+ // the import of mountInternalSurfaces sits at the top; the CALL is what matters
41
+ const call = src.indexOf('mountInternalSurfaces(', surfaces + 1);
42
+ assert.ok(call < 0 || at < call, 'sandbox must come before the surfaces mount');
43
+ });
44
+
45
+ await t('on the live hub (no BONGOS_SANDBOX) it changes nothing', () => {
46
+ const r = install({ env: {} });
47
+ assert.equal(r.active, false);
48
+ let called = false;
49
+ r.middleware({}, {}, () => { called = true; });
50
+ assert.equal(called, true);
51
+ });
52
+
53
+ const log = { items: [] };
54
+ await t('on a plain server the page makes no claim about blocked mail ports', () => {
55
+ assert.doesNotMatch(pageHtml(log, { BONGOS_SANDBOX: '1' }), /email ports/);
56
+ });
57
+ await t('on Render it still says the free plan blocks them', () => {
58
+ assert.match(pageHtml(log, { BONGOS_SANDBOX: '1', RENDER: 'true' }), /blocks outgoing email ports/);
59
+ });
60
+
61
+ // 3. The one named exception: creating a repository (owner's choice, 2026-11-14).
62
+ const { decide, parseAllowances } = require('../src/sandbox/gate.js');
63
+ const ALLOW = parseAllowances('github-repo-create');
64
+ await t('without the exception, creating a GitHub repository is refused', () => {
65
+ assert.equal(decide({ method: 'POST', host: 'api.github.com', path: '/user/repos' }).allow, false);
66
+ });
67
+ await t('with it, creating a repository for a person or an organisation passes', () => {
68
+ assert.equal(decide({ method: 'POST', host: 'api.github.com', path: '/user/repos', allowances: ALLOW }).allow, true);
69
+ assert.equal(decide({ method: 'POST', host: 'api.github.com', path: '/orgs/acme-co/repos', allowances: ALLOW }).allow, true);
70
+ });
71
+ await t('every other GitHub change stays refused with it on', () => {
72
+ for (const [m, p] of [['POST', '/repos/o/r/pulls'], ['PUT', '/repos/o/r/pulls/1/merge'], ['PATCH', '/repos/o/r'], ['DELETE', '/repos/o/r'], ['POST', '/repos/o/r/keys'], ['PUT', '/repos/o/r/collaborators/x'], ['POST', '/user/repos/extra']]) {
73
+ assert.equal(decide({ method: m, host: 'api.github.com', path: p, allowances: ALLOW }).allow, false, `${m} ${p}`);
74
+ }
75
+ });
76
+ await t('an unknown exception name allows nothing', () => {
77
+ assert.deepEqual(parseAllowances('everything, github-*'), []);
78
+ });
79
+ await t('install() reads the exception from BONGOS_SANDBOX_ALLOW', async () => {
80
+ const seen = [];
81
+ const host = { fetch: async (u) => { seen.push(String(u)); return new Response('{}', { status: 201 }); } };
82
+ const fakeMod = () => ({ request() {}, get() {} });
83
+ const idx = require('../src/sandbox/index.js');
84
+ // a fresh module instance, since install() keeps one state per process
85
+ delete require.cache[require.resolve('../src/sandbox/index.js')];
86
+ const fresh = require('../src/sandbox/index.js');
87
+ fresh.install({ env: { BONGOS_SANDBOX: '1', BONGOS_SANDBOX_ALLOW: 'github-repo-create' }, fetchHost: host, http: fakeMod(), https: fakeMod(), write() {} });
88
+ void idx;
89
+ return host.fetch('https://api.github.com/user/repos', { method: 'POST' }).then((r) => {
90
+ assert.equal(r.status, 201);
91
+ assert.equal(seen.length, 1);
92
+ });
93
+ });
94
+
95
+ console.log(`\n${passed} passed, ${failed} failed`);
96
+ if (failed) process.exit(1);