@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.
- package/.bongos-core.json +198 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +15 -2
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +317 -6
- package/docs/api-reference.md +8 -3
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +339 -330
- package/docs/copy-registry.json +524 -439
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +729 -678
- package/migrations/core_268_builders_main_discipline.sql +35 -0
- package/modules/copy-desk/artist-stats.js +56 -0
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +39 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -16
- package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
- package/modules/economy/credits-by-craft.js +208 -0
- package/modules/economy/reward.js +8 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/hall-render.js +2 -2
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +5 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +8 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/ideator-stats.js +149 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +7 -0
- package/modules/lifecycle/activity.js +5 -2
- package/modules/lifecycle/db-analytics.js +4 -2
- package/modules/lifecycle/db-tasks.js +49 -1
- package/modules/lifecycle/db.js +3 -2
- package/modules/lifecycle/page-tweak-reads.js +23 -5
- package/modules/provisioning/look.js +169 -0
- package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +4 -3
- package/modules/provisioning/routes/look.js +204 -0
- package/modules/provisioning/routes/provisioning.js +5 -59
- package/modules/provisioning/settings-push.js +76 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +499 -28
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +6 -3
- package/modules/ui-design/kit/serve.js +50 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/db.js +44 -6
- package/src/bongos/role-stats.js +155 -0
- package/src/bongos/routes/builders.js +15 -2
- package/src/bongos/routes/me.js +11 -3
- package/src/bongos/routes.js +4 -1
- package/src/bongos/serve-internal.js +6 -0
- package/src/branding.js +26 -1
- package/src/hall-look.js +99 -0
- package/src/module-api.js +1 -1
- package/src/night-tint.js +26 -0
- package/tests/copy_desk_page_reads.mjs +1 -1
- package/tests/currency_label.mjs +7 -4
- package/tests/fixtures/page-ledger.mjs +1 -1
- package/tests/hall_tokens.mjs +10 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/look_night_tint.mjs +169 -0
- package/tests/main_discipline.mjs +81 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/profile_role_stats.mjs +416 -0
- package/tests/projects_hub.mjs +5 -4
- package/tests/projects_hub_app_status.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +8 -7
- package/tests/projects_hub_look.mjs +255 -0
- package/tests/projects_hub_module_picker.mjs +2 -2
- package/tests/provision_settings_apply.mjs +12 -4
- package/tests/provisioning_look.mjs +376 -0
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +4 -4
- package/tests/provisioning_settings_env.mjs +4 -0
- package/tests/role_stats_db.mjs +237 -0
- package/tests/tweak_replaces_artist_review.mjs +1 -1
- package/tests/wizard_draft_resume.mjs +2 -2
- package/tests/wizard_front_door.mjs +2 -2
- 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
|
|