@bongos/core 1.20.58 → 1.20.60

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 (119) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +15 -2
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +317 -6
  10. package/docs/api-reference.md +8 -3
  11. package/docs/architecture.md +1 -0
  12. package/docs/copy-inventory.md +339 -330
  13. package/docs/copy-registry.json +524 -439
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +4 -1
  16. package/docs/page-readings.json +729 -678
  17. package/migrations/core_268_builders_main_discipline.sql +35 -0
  18. package/modules/copy-desk/artist-stats.js +56 -0
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +39 -0
  21. package/modules/copy-desk/routes/copy-desk.js +7 -16
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  23. package/modules/economy/credits-by-craft.js +208 -0
  24. package/modules/economy/reward.js +8 -0
  25. package/modules/hall-ui/public/atlas.html +1 -1
  26. package/modules/hall-ui/public/blockers.html +1 -1
  27. package/modules/hall-ui/public/board-room.html +1 -1
  28. package/modules/hall-ui/public/collab.html +1 -1
  29. package/modules/hall-ui/public/commands.html +1 -1
  30. package/modules/hall-ui/public/copy-desk.html +1 -1
  31. package/modules/hall-ui/public/deploy.html +1 -1
  32. package/modules/hall-ui/public/diagrams.html +1 -1
  33. package/modules/hall-ui/public/drachmae.html +1 -1
  34. package/modules/hall-ui/public/fleet.html +1 -1
  35. package/modules/hall-ui/public/gate.html +1 -1
  36. package/modules/hall-ui/public/goal-map.html +1 -1
  37. package/modules/hall-ui/public/goals.html +1 -1
  38. package/modules/hall-ui/public/government.html +1 -1
  39. package/modules/hall-ui/public/hall-render.js +2 -2
  40. package/modules/hall-ui/public/idea.html +1 -1
  41. package/modules/hall-ui/public/ideas.html +1 -1
  42. package/modules/hall-ui/public/index.html +1 -1
  43. package/modules/hall-ui/public/modules.html +1 -1
  44. package/modules/hall-ui/public/primer.html +1 -1
  45. package/modules/hall-ui/public/profile.html +1 -1
  46. package/modules/hall-ui/public/profile.js +1 -1
  47. package/modules/hall-ui/public/project-settings.html +1 -1
  48. package/modules/hall-ui/public/ranks.html +1 -1
  49. package/modules/hall-ui/public/roadmap.html +1 -1
  50. package/modules/hall-ui/public/roster.html +1 -1
  51. package/modules/hall-ui/public/sessions.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +5 -1
  54. package/modules/hall-ui/public/studio.html +1 -1
  55. package/modules/hall-ui/public/style.css +8 -0
  56. package/modules/hall-ui/public/task.html +1 -1
  57. package/modules/hall-ui/public/thinking.html +1 -1
  58. package/modules/hall-ui/public/tweak-editor.html +1 -1
  59. package/modules/hall-ui/public/watch.html +1 -1
  60. package/modules/hall-ui/public/work.html +1 -1
  61. package/modules/ideas/ideator-stats.js +149 -0
  62. package/modules/ideas/module.json +2 -1
  63. package/modules/ideas/routes/inbox.js +7 -0
  64. package/modules/lifecycle/activity.js +5 -2
  65. package/modules/lifecycle/db-analytics.js +4 -2
  66. package/modules/lifecycle/db-tasks.js +49 -1
  67. package/modules/lifecycle/db.js +3 -2
  68. package/modules/lifecycle/page-tweak-reads.js +23 -5
  69. package/modules/provisioning/look.js +169 -0
  70. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  71. package/modules/provisioning/module.json +2 -1
  72. package/modules/provisioning/provisioning.js +4 -3
  73. package/modules/provisioning/routes/look.js +204 -0
  74. package/modules/provisioning/routes/provisioning.js +5 -59
  75. package/modules/provisioning/settings-push.js +76 -0
  76. package/modules/public-landing/public/assets/cosmos.css +1 -1
  77. package/modules/public-landing/public/projects.html +499 -28
  78. package/modules/public-landing/public/projects.probes.json +2 -2
  79. package/modules/public-landing/public/projects.states.json +6 -3
  80. package/modules/ui-design/kit/serve.js +50 -0
  81. package/package-lock.json +2 -2
  82. package/package.json +1 -1
  83. package/release-notes.json +12 -0
  84. package/scripts/gds/fitness.js +4 -0
  85. package/scripts/gds/run-unit-tests.js +5 -0
  86. package/src/bongos/db.js +44 -6
  87. package/src/bongos/role-stats.js +155 -0
  88. package/src/bongos/routes/builders.js +15 -2
  89. package/src/bongos/routes/me.js +11 -3
  90. package/src/bongos/routes.js +4 -1
  91. package/src/bongos/serve-internal.js +6 -0
  92. package/src/branding.js +26 -1
  93. package/src/hall-look.js +99 -0
  94. package/src/module-api.js +1 -1
  95. package/src/night-tint.js +26 -0
  96. package/tests/copy_desk_page_reads.mjs +1 -1
  97. package/tests/currency_label.mjs +7 -4
  98. package/tests/fixtures/page-ledger.mjs +1 -1
  99. package/tests/hall_tokens.mjs +10 -1
  100. package/tests/hall_tweak_editor.mjs +1 -1
  101. package/tests/look_night_tint.mjs +169 -0
  102. package/tests/main_discipline.mjs +81 -0
  103. package/tests/nav_permission_atoms.mjs +1 -1
  104. package/tests/profile_role_stats.mjs +416 -0
  105. package/tests/projects_hub.mjs +5 -4
  106. package/tests/projects_hub_app_status.mjs +1 -1
  107. package/tests/projects_hub_app_step.mjs +8 -7
  108. package/tests/projects_hub_look.mjs +255 -0
  109. package/tests/projects_hub_module_picker.mjs +2 -2
  110. package/tests/provision_settings_apply.mjs +12 -4
  111. package/tests/provisioning_look.mjs +376 -0
  112. package/tests/provisioning_settings.mjs +10 -7
  113. package/tests/provisioning_settings_apply.mjs +4 -4
  114. package/tests/provisioning_settings_env.mjs +4 -0
  115. package/tests/role_stats_db.mjs +237 -0
  116. package/tests/tweak_replaces_artist_review.mjs +1 -1
  117. package/tests/wizard_draft_resume.mjs +2 -2
  118. package/tests/wizard_front_door.mjs +2 -2
  119. package/tests/wizard_intent_resume.mjs +16 -15
@@ -0,0 +1,376 @@
1
+ // tests/provisioning_look.mjs — a project's look, night tint and logo, platform side
2
+ // (task 1004421, BV2.PS06; ADR 0358).
3
+ //
4
+ // The load-bearing assertions:
5
+ // • the vocabulary IS the style library plus `own` and `custom` — a look added to
6
+ // modules/ui-design/styles/ without joining the list (or the reverse) fails here;
7
+ // • the accent nudge ADJUSTS, never refuses: every colour round the hue wheel comes
8
+ // back readable in both modes, a readable one comes back untouched, and a moved
9
+ // one keeps its hue;
10
+ // • the wizard's copy of that nudge IS the server's, character for character;
11
+ // • a logo is screened by its BYTES — an SVG, a renamed file or an oversized one is
12
+ // refused before anything is stored;
13
+ // • the routes are the owner's (404 for anyone else), store the ADJUSTED accent and
14
+ // say so, refuse a custom look with no accent, and push like PATCH …/settings;
15
+ // • the public logo read serves only a content-addressed name, as an image only.
16
+ //
17
+ // DB-free: data access is stubbed on the cached module objects (the
18
+ // provisioning_repo_private_route.mjs pattern), over a real mounted router.
19
+ //
20
+ // Run: node tests/provisioning_look.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import http from 'node:http';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const provisioning = require('../modules/provisioning/provisioning.js');
32
+ const look = require('../modules/provisioning/look.js');
33
+
34
+ let passed = 0, failed = 0;
35
+ async function ta(name, fn) {
36
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
37
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
38
+ }
39
+
40
+ // ── contrast, restated independently of look.js so the nudge is checked, not echoed ──
41
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
42
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
43
+ const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
44
+ const rgb = (hex) => { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
45
+ const mix = (a, p, b) => a.map((v, i) => v * p + b[i] * (1 - p));
46
+ function readsEverywhere(hex) {
47
+ const c = rgb(hex);
48
+ const fails = [];
49
+ if (ratio([42, 19, 5], c) < 4.5) fails.push('dark ink on the accent fill');
50
+ for (const [name, ground] of [['paper', [237, 236, 238]], ['card', [251, 250, 252]]]) {
51
+ if (ratio(mix(c, 0.45, [36, 14, 0]), ground) < 4.5) fails.push(`light accent ink on ${name}`);
52
+ }
53
+ for (const [name, ground] of [['black', [0, 0, 0]], ['dark card', [16, 16, 18]],
54
+ ['tinted night', mix(c, 0.06, [0, 0, 0])], ['tinted card', mix(c, 0.05, [16, 16, 18])]]) {
55
+ if (ratio(c, ground) < 4.5) fails.push(`accent on ${name}`);
56
+ }
57
+ const info = mix([91, 111, 208], 0.8, [255, 255, 255]);
58
+ if (ratio(info, mix(info, 0.14, mix(c, 0.05, [16, 16, 18]))) < 4.5) fails.push('info chip on the tinted card');
59
+ return fails;
60
+ }
61
+ const hueOf = (hex) => {
62
+ const [r, g, b] = rgb(hex).map((v) => v / 255);
63
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
64
+ if (!d) return null;
65
+ const h = max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
66
+ return h * 60;
67
+ };
68
+ const hslHex = (h, s, l) => {
69
+ const a = s * Math.min(l, 1 - l), k = (n) => (n + h / 30) % 12;
70
+ const f = (n) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))));
71
+ return '#' + [f(0), f(8), f(4)].map((x) => x.toString(16).padStart(2, '0')).join('');
72
+ };
73
+
74
+ console.log('\nThe vocabulary is the style library:');
75
+
76
+ await ta('LIBRARY_LOOKS is exactly the style directories, and the setting adds own + custom around them', async () => {
77
+ const dirs = fs.readdirSync(path.join(ROOT, 'modules', 'ui-design', 'styles'))
78
+ .filter((d) => fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'styles', d, 'pack.json'))).sort();
79
+ assert.deepEqual([...look.LIBRARY_LOOKS].sort(), dirs);
80
+ assert.deepEqual(look.LOOK_SETTINGS_VOCAB.look, ['own', ...look.LIBRARY_LOOKS, 'custom']);
81
+ assert.deepEqual(provisioning.SETTINGS_VOCAB.look, look.LOOK_SETTINGS_VOCAB.look, 'the look is a policy setting');
82
+ assert.deepEqual(provisioning.SETTINGS_VOCAB.night_tint, ['on', 'off']);
83
+ });
84
+
85
+ await ta('the defaults change nothing: own pack, and a tint that only a look can switch on', async () => {
86
+ const s = provisioning.effectiveSettings({ settings: {} });
87
+ assert.equal(s.look, 'own');
88
+ assert.equal(s.night_tint, 'on');
89
+ const vars = provisioning.settingsEnvVars({ settings: {} }, 'X');
90
+ assert.equal(vars.X_LOOK, 'own');
91
+ assert.equal(vars.X_LOOK_ACCENT, '');
92
+ assert.equal(vars.X_LOGO_URL, '');
93
+ });
94
+
95
+ await ta('the companions carry only a well-formed value — a stored accent or address that is not one goes out empty', async () => {
96
+ const good = provisioning.settingsEnvVars({ settings: { look: 'custom', look_accent: '#B48CF2', logo_url: 'https://x.test/api/bongos/provisioning/logos/a.png' } }, 'X');
97
+ assert.equal(good.X_LOOK_ACCENT, '#b48cf2');
98
+ assert.equal(good.X_LOGO_URL, 'https://x.test/api/bongos/provisioning/logos/a.png');
99
+ const bad = provisioning.settingsEnvVars({ settings: { look_accent: 'red', logo_url: 'javascript:alert(1)' } }, 'X');
100
+ assert.equal(bad.X_LOOK_ACCENT, '');
101
+ assert.equal(bad.X_LOGO_URL, '');
102
+ const spaced = provisioning.settingsEnvVars({ settings: { logo_url: 'https://x.test/a b.png' } }, 'X');
103
+ assert.equal(spaced.X_LOGO_URL, '', 'a space would split the env line');
104
+ });
105
+
106
+ console.log('\nThe accent nudge adjusts, never refuses:');
107
+
108
+ await ta('a colour that already reads in both modes comes back untouched', async () => {
109
+ for (const hex of ['#fb9569', '#b48cf2', '#6fb3e3', '#e3c39c']) {
110
+ assert.deepEqual(look.lookAdjustAccent(hex), { hex, adjusted: false }, hex);
111
+ assert.deepEqual(readsEverywhere(hex), [], hex);
112
+ }
113
+ });
114
+
115
+ await ta('every colour round the hue wheel comes back readable everywhere the hall paints it, its hue kept', async () => {
116
+ const fails = [];
117
+ let moved = 0;
118
+ for (let h = 0; h < 360; h += 10) {
119
+ for (const s of [0, 0.4, 0.8, 1]) {
120
+ for (const l of [0, 0.15, 0.35, 0.5, 0.65, 0.85, 1]) {
121
+ const hex = hslHex(h, s, l);
122
+ const out = look.lookAdjustAccent(hex);
123
+ if (!out) { fails.push(`${hex}: refused`); continue; }
124
+ const why = readsEverywhere(out.hex);
125
+ if (why.length) fails.push(`${hex} → ${out.hex}: ${why.join(', ')}`);
126
+ if (out.adjusted) moved++;
127
+ const before = hueOf(hex), after = hueOf(out.hex);
128
+ if (s >= 0.4 && l > 0 && l < 1 && before !== null && after !== null) {
129
+ const dh = Math.min(Math.abs(before - after), 360 - Math.abs(before - after));
130
+ if (dh > 8) fails.push(`${hex} → ${out.hex}: hue moved ${dh.toFixed(1)}°`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ assert.deepEqual(fails, []);
136
+ assert.ok(moved > 100, `sanity: the sweep must actually exercise the nudge (moved ${moved})`);
137
+ });
138
+
139
+ await ta('pure black and pure white are nudged to a readable grey, not refused', async () => {
140
+ for (const hex of ['#000000', '#ffffff']) {
141
+ const out = look.lookAdjustAccent(hex);
142
+ assert.ok(out && out.adjusted, hex);
143
+ assert.deepEqual(readsEverywhere(out.hex), [], `${hex} → ${out.hex}`);
144
+ }
145
+ });
146
+
147
+ await ta('only a #rrggbb is a colour', async () => {
148
+ for (const v of ['', 'red', '#fff', '#12345g', null, undefined, 'rgb(1,2,3)']) assert.equal(look.lookAdjustAccent(v), null, String(v));
149
+ assert.deepEqual(look.lookAdjustAccent('B48CF2'), { hex: '#b48cf2', adjusted: false }, 'a missing # is forgiven');
150
+ });
151
+
152
+ await ta("the wizard's copy of lookAdjustAccent IS the server's, character for character", async () => {
153
+ const fnText = (src) => {
154
+ const start = src.indexOf('function lookAdjustAccent(');
155
+ assert.ok(start >= 0, 'function lookAdjustAccent( not found');
156
+ let depth = 0;
157
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
158
+ if (src[i] === '{') depth++;
159
+ else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1).replace(/\r\n/g, '\n').split('\n').map((l) => l.trim()).join('\n');
160
+ }
161
+ throw new Error('unbalanced');
162
+ };
163
+ const server = fnText(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'look.js'), 'utf8'));
164
+ const client = fnText(fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8'));
165
+ assert.equal(client, server, 'edit both copies together (ADR 0358 D4)');
166
+ });
167
+
168
+ console.log('\nA logo is screened by its bytes:');
169
+
170
+ const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(40, 1)]);
171
+ const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(40, 2)]);
172
+ const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('WEBP'), Buffer.alloc(40, 3)]);
173
+ const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
174
+
175
+ await ta('PNG, JPEG and WebP pass, each named by its own magic number and hashed', async () => {
176
+ for (const [buf, type, ext] of [[PNG, 'image/png', 'png'], [JPG, 'image/jpeg', 'jpg'], [WEBP, 'image/webp', 'webp']]) {
177
+ const r = look.screenLogo(buf, type);
178
+ assert.equal(r.ok, true, type);
179
+ assert.equal(r.ext, ext);
180
+ assert.match(r.sha, /^[0-9a-f]{64}$/);
181
+ }
182
+ });
183
+
184
+ await ta('SVG is refused whatever it claims to be, and so is a renamed file, an empty one and an oversized one', async () => {
185
+ assert.equal(look.screenLogo(SVG, 'image/svg+xml').reason, 'bad_type');
186
+ assert.equal(look.screenLogo(SVG, 'image/png').reason, 'sniff_mismatch', 'an SVG labelled PNG');
187
+ assert.equal(look.screenLogo(JPG, 'image/png').reason, 'sniff_mismatch', 'a JPEG labelled PNG');
188
+ assert.equal(look.screenLogo(Buffer.alloc(0), 'image/png').reason, 'empty');
189
+ const big = Buffer.concat([PNG, Buffer.alloc(look.LOGO_MAX_BYTES)]);
190
+ assert.equal(look.screenLogo(big, 'image/png').reason, 'too_big');
191
+ assert.equal(look.screenLogo(Buffer.concat([PNG.subarray(0, 8), Buffer.alloc(look.LOGO_MAX_BYTES - 8)]), 'image/png').ok, true, 'exactly the cap passes');
192
+ });
193
+
194
+ // ── the routes, over a real mounted router ───────────────────────────────────
195
+ let instance = null;
196
+ let stored = [];
197
+ let events = [];
198
+ let enqueued = [];
199
+ let logos = new Map();
200
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
201
+ provisioning.updateInstanceSettings = async (_db, id, patch) => {
202
+ stored.push(patch);
203
+ instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
204
+ return { ...instance, id };
205
+ };
206
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
207
+ provisioning.enqueueSettingsApply = async (_db, id, by) => { enqueued.push({ id, by }); return { intent: { id: 3, action: 'settings-apply', state: 'pending' }, created: true }; };
208
+ provisioning.recordSettingsPushOwed = async () => {};
209
+ look.upsertLogo = async (_db, id, s, buf) => { logos.set(id, { sha: s.sha, ext: s.ext, mime: s.mime, bytes: buf }); };
210
+ look.deleteLogo = async (_db, id) => { logos.delete(id); };
211
+ look.readLogo = async (_db, sha, ext) => [...logos.values()].find((l) => l.sha === sha && l.ext === ext) || null;
212
+
213
+ const api = require('../src/module-api.js');
214
+ let builder = null;
215
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
216
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
217
+ api.branding = () => ({ domains: { publicOrigin: 'https://platform.test' } });
218
+
219
+ const express = require('express');
220
+ const buildRouter = require('../modules/provisioning/routes/look.js');
221
+ const app = express();
222
+ app.use((req, res, next) => (/\/logo$/.test(req.path) ? next() : express.json()(req, res, next)));
223
+ app.use((_req, res, next) => {
224
+ res.fail = (code, opts) => {
225
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
226
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}) } });
227
+ };
228
+ next();
229
+ });
230
+ app.use('/api/bongos', buildRouter());
231
+ const server = app.listen(0);
232
+ await new Promise((r) => server.once('listening', r));
233
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
234
+
235
+ function call(method, p, body) {
236
+ return new Promise((resolve, reject) => {
237
+ const payload = body === undefined ? null : JSON.stringify(body);
238
+ const req = http.request(`${base}${p}`, { method, headers: payload ? { 'Content-Type': 'application/json' } : {} }, (res) => {
239
+ const chunks = [];
240
+ res.on('data', (c) => chunks.push(c));
241
+ res.on('end', () => {
242
+ const raw = Buffer.concat(chunks);
243
+ let j = null; try { j = JSON.parse(raw.toString('utf8')); } catch { /* binary */ }
244
+ resolve({ status: res.statusCode, body: j, raw, headers: res.headers });
245
+ });
246
+ });
247
+ req.on('error', reject);
248
+ if (payload) req.write(payload);
249
+ req.end();
250
+ });
251
+ }
252
+
253
+ const OWNER = { id: 7, rank: 'thetes' };
254
+ function reset(patch = {}) {
255
+ instance = { owner_builder_id: 7, slug: 'kestrel', status: 'active', hosting_shape: 'cloud-host', settings: {}, ...patch };
256
+ stored = []; events = []; enqueued = []; logos = new Map(); builder = OWNER;
257
+ }
258
+
259
+ console.log('\nThe routes:');
260
+
261
+ await ta("another builder's project is a 404 on every route, and nothing is written", async () => {
262
+ reset();
263
+ builder = { id: 99, rank: 'metic' };
264
+ for (const [m, p, b] of [['GET', '/provisioning/instances/5/look'], ['PATCH', '/provisioning/instances/5/look', { look: 'grove' }],
265
+ ['PUT', '/provisioning/instances/5/logo', { image_b64: PNG.toString('base64'), content_type: 'image/png' }], ['DELETE', '/provisioning/instances/5/logo']]) {
266
+ const r = await call(m, p, b);
267
+ assert.equal(r.status, 404, `${m} ${p}`);
268
+ }
269
+ assert.equal(stored.length + enqueued.length + logos.size, 0);
270
+ });
271
+
272
+ await ta('a library look on a live project is stored, audited and pushed with a restart', async () => {
273
+ reset();
274
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'grove', night_tint: 'off' });
275
+ assert.equal(r.status, 200, r.raw.toString());
276
+ assert.deepEqual(stored, [{ look: 'grove', night_tint: 'off' }]);
277
+ assert.equal(r.body.look.look, 'grove');
278
+ assert.equal(r.body.look.night_tint, 'off');
279
+ assert.equal(r.body.push.restart, true);
280
+ assert.equal(enqueued.length, 1, 'the settings-apply intent is queued');
281
+ assert.equal(events[0].actor, 'api:self');
282
+ assert.equal(r.body.adjusted, null);
283
+ });
284
+
285
+ await ta('a custom accent is stored ADJUSTED, and the reply names both colours', async () => {
286
+ reset();
287
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom', accent: '#1A2B3C' });
288
+ assert.equal(r.status, 200, r.raw.toString());
289
+ const kept = look.lookAdjustAccent('#1a2b3c').hex;
290
+ assert.deepEqual(stored[0], { look: 'custom', look_accent: kept });
291
+ assert.deepEqual(r.body.adjusted, { from: '#1a2b3c', to: kept });
292
+ assert.equal(r.body.look.look_accent, kept);
293
+ assert.deepEqual(readsEverywhere(kept), []);
294
+ });
295
+
296
+ await ta('a custom look with no accent anywhere is refused; one already stored is enough', async () => {
297
+ reset();
298
+ let r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom' });
299
+ assert.equal(r.status, 400);
300
+ assert.equal(r.body.error.code, 'accent_required');
301
+ assert.equal(stored.length, 0);
302
+ reset({ settings: { look_accent: '#b48cf2' } });
303
+ r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom' });
304
+ assert.equal(r.status, 200, r.raw.toString());
305
+ });
306
+
307
+ await ta('an accent that is not a colour, an unknown look and an empty body are 400s', async () => {
308
+ reset();
309
+ assert.equal((await call('PATCH', '/provisioning/instances/5/look', { look: 'custom', accent: 'tomato' })).body.error.code, 'bad_accent');
310
+ assert.equal((await call('PATCH', '/provisioning/instances/5/look', { look: 'neon' })).status, 400);
311
+ assert.equal((await call('PATCH', '/provisioning/instances/5/look', {})).body.error.code, 'bad_look');
312
+ assert.equal(stored.length, 0);
313
+ });
314
+
315
+ await ta('the same look re-sent writes nothing and restarts nothing', async () => {
316
+ reset({ settings: { look: 'grove' } });
317
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'grove' });
318
+ assert.equal(r.status, 200);
319
+ assert.equal(stored.length, 0);
320
+ assert.equal(enqueued.length, 0);
321
+ assert.match(r.body.push.message, /No change/);
322
+ });
323
+
324
+ await ta('a project still standing up stores the look and takes it at standup — no restart queued', async () => {
325
+ reset({ status: 'provisioning' });
326
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'blueprint' });
327
+ assert.equal(r.status, 200);
328
+ assert.equal(enqueued.length, 0);
329
+ assert.match(r.body.push.message, /finishes standing up/);
330
+ });
331
+
332
+ await ta('a logo is stored, the hall is told its https address, and the public read serves it as an image only', async () => {
333
+ reset();
334
+ const r = await call('PUT', '/provisioning/instances/5/logo', { image_b64: PNG.toString('base64'), content_type: 'image/png' });
335
+ assert.equal(r.status, 200, r.raw.toString());
336
+ const { sha } = look.screenLogo(PNG, 'image/png');
337
+ const url = `https://platform.test/api/bongos/provisioning/logos/${sha}.png`;
338
+ assert.equal(r.body.look.logo_url, url);
339
+ assert.equal(enqueued.length, 1);
340
+ const g = await call('GET', `/provisioning/logos/${sha}.png`);
341
+ assert.equal(g.status, 200);
342
+ assert.equal(g.headers['content-type'], 'image/png');
343
+ assert.equal(g.headers['x-content-type-options'], 'nosniff');
344
+ assert.match(g.headers['content-security-policy'], /sandbox/);
345
+ assert.match(g.headers['cache-control'], /immutable/);
346
+ assert.ok(g.raw.equals(PNG), 'the bytes come back as stored');
347
+ });
348
+
349
+ await ta('an SVG logo is refused and nothing is stored', async () => {
350
+ reset();
351
+ const r = await call('PUT', '/provisioning/instances/5/logo', { image_b64: SVG.toString('base64'), content_type: 'image/svg+xml' });
352
+ assert.equal(r.status, 400);
353
+ assert.equal(r.body.error.code, 'logo_bad_type');
354
+ assert.equal(logos.size + stored.length, 0);
355
+ });
356
+
357
+ await ta('removing the logo empties the address and pushes it', async () => {
358
+ reset({ settings: { logo_url: 'https://platform.test/api/bongos/provisioning/logos/x.png' } });
359
+ logos.set(5, { sha: 'x', ext: 'png', bytes: PNG });
360
+ const r = await call('DELETE', '/provisioning/instances/5/logo');
361
+ assert.equal(r.status, 200, r.raw.toString());
362
+ assert.deepEqual(stored, [{ logo_url: '' }]);
363
+ assert.equal(r.body.look.logo_url, null);
364
+ assert.equal(logos.size, 0);
365
+ });
366
+
367
+ await ta('the public read answers only a content-addressed name', async () => {
368
+ reset();
369
+ for (const name of ['5.png', '../etc/passwd', `${'a'.repeat(64)}.svg`, `${'a'.repeat(64)}.png`]) {
370
+ assert.equal((await call('GET', `/provisioning/logos/${encodeURIComponent(name)}`)).status, 404, name);
371
+ }
372
+ });
373
+
374
+ server.close();
375
+ console.log(`\nprovisioning_look: ${passed} passed, ${failed} failed`);
376
+ if (failed) process.exit(1);
@@ -164,7 +164,7 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
164
164
  reset();
165
165
  const r = await get('/provisioning/instances/18/settings');
166
166
  assert.equal(r.status, 200, r.raw);
167
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
167
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
169
  assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
@@ -183,6 +183,9 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
183
183
  // policy axis, offered to the form like the rest. What makes it different is
184
184
  // its CONSEQUENCE: it is the only setting that can refuse a module outright.
185
185
  spend_payer: ['none', 'project', 'builder'],
186
+ // the look a hall wears and its night tint (task 1004421, ADR 0358) — policy
187
+ // axes like the rest: they ride web.env and a restart applies them
188
+ look: ['own', 'chrome-world', 'expedition', 'grove', 'blueprint', 'custom'], night_tint: ['on', 'off'],
186
189
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
187
190
  // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
188
191
  // closed list, no default, no push — so it rides the same vocab
@@ -195,14 +198,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
195
198
  const r = await get('/provisioning/instances/18/settings');
196
199
  assert.equal(r.status, 200, r.raw);
197
200
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
198
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the planet is not a setting: the policy read is untouched');
201
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the planet is not a setting: the policy read is untouched');
199
202
  });
200
203
 
201
204
  await ta('stored keys overlay the defaults', async () => {
202
205
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
203
206
  const r = await get('/provisioning/instances/18/settings');
204
207
  assert.equal(r.status, 200, r.raw);
205
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
208
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
206
209
  });
207
210
 
208
211
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -230,7 +233,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
230
233
  reset();
231
234
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
232
235
  assert.equal(r.status, 200, r.raw);
233
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
236
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
234
237
  assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
235
238
  assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
236
239
  assert.equal(events.length, 1);
@@ -243,7 +246,7 @@ await ta('owner changes both keys in one call', async () => {
243
246
  reset();
244
247
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
245
248
  assert.equal(r.status, 200, r.raw);
246
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
249
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
247
250
  });
248
251
 
249
252
  await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
@@ -282,7 +285,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
282
285
  reset();
283
286
  const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
284
287
  assert.equal(r.status, 200, r.raw);
285
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
288
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
286
289
  assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
287
290
  assert.equal(events[0].actor, 'api:self');
288
291
  assert.match(events[0].detail, /visibility=stealth/);
@@ -342,7 +345,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
342
345
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
343
346
  assert.equal(r.status, 200, r.raw);
344
347
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
345
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the policy half is untouched');
348
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the policy half is untouched');
346
349
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
347
350
  assert.equal(r.body.push.queued, false);
348
351
  assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
@@ -272,7 +272,7 @@ await ta('ACTIVE cloud-host: the row is written FIRST, then a settings-apply int
272
272
  reset();
273
273
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
274
274
  assert.equal(r.status, 200, r.raw);
275
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
275
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
276
276
  assert.equal(r.body.settings, undefined);
277
277
  assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
278
278
  assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
@@ -335,7 +335,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
335
335
  enqueueThrows = new Error('db down');
336
336
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
337
337
  assert.equal(r.status, 200, r.raw);
338
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
338
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
339
339
  assert.equal(updateCalls.length, 1, 'the write landed');
340
340
  receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
341
341
  assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
@@ -348,7 +348,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
348
348
  reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
349
349
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
350
350
  assert.equal(r.status, 200, r.raw);
351
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
351
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
352
352
  assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
353
353
  assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
354
354
  assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
@@ -479,7 +479,7 @@ await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHA
479
479
  assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
480
480
  assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
481
481
  assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
482
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
482
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' },
483
483
  'the value that finally ships is the one the owner saved');
484
484
  });
485
485
 
@@ -121,6 +121,9 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
121
121
  // transport because src/modules.js reads it at CONFIG LOAD, which means the
122
122
  // instance must already have it in web.env before the first require.
123
123
  CLOUDBONGOS_SPEND_PAYER: 'none',
124
+ // The look and its night tint (ADR 0358, task 1004421), with their two companions:
125
+ // a custom look's accent and the logo address, empty when there is none.
126
+ CLOUDBONGOS_LOOK: 'own', CLOUDBONGOS_NIGHT_TINT: 'on', CLOUDBONGOS_LOOK_ACCENT: '', CLOUDBONGOS_LOGO_URL: '',
124
127
  });
125
128
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
126
129
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -128,6 +131,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
128
131
  assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), {
129
132
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
130
133
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
134
+ X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
131
135
  }, 'defaults, explicitly');
132
136
  });
133
137