@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.
- package/.bongos-core.json +36 -26
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +17 -17
- package/docs/copy-registry.json +17 -17
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +382 -408
- package/docs/recipes/platform-sandbox.md +54 -0
- package/modules/hall-ui/public/craft-mode.js +4 -0
- package/modules/hall-ui/public/home-city.js +9 -1
- package/modules/hall-ui/public/sky-time.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +15 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +8 -0
- package/src/sandbox/gate.js +22 -8
- package/src/sandbox/index.js +8 -5
- package/tests/craft_mode_home.mjs +12 -1
- package/tests/hall_sky_time.mjs +25 -5
- package/tests/home_city_widget.mjs +12 -1
- package/tests/platform_sandbox.mjs +96 -0
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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');
|
package/src/platform-server.js
CHANGED
|
@@ -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
|
package/src/sandbox/gate.js
CHANGED
|
@@ -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 }`
|
|
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 };
|
package/src/sandbox/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
wrapHttp(
|
|
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' });
|
package/tests/hall_sky_time.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
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);
|