@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,169 @@
1
+ // modules/provisioning/look.js — a project's look, night tint and logo, as the
2
+ // platform stores and pushes them (ADR 0358, task 1004421; spec D2 + D8).
3
+ //
4
+ // `look` and `night_tint` are POLICY settings: they join SETTINGS_VOCAB, ride web.env
5
+ // and a restart applies them, like every other key there (src/hall-look.js reads them
6
+ // on the instance). Their two companions are values, not choices from a list, so they
7
+ // are reserved settings-jsonb keys written only by routes/look.js:
8
+ //
9
+ // look_accent a custom look's ONE accent, stored AFTER lookAdjustAccent made it
10
+ // readable in both modes, so the instance never has to judge it.
11
+ // logo_url the content-addressed public address of the stored logo.
12
+ //
13
+ // Neither is in SETTINGS_VOCAB, so the generic settings PATCH cannot write them, and
14
+ // neither leaks through effectiveSettings.
15
+ 'use strict';
16
+
17
+ const crypto = require('node:crypto');
18
+
19
+ // The style library's looks (modules/ui-design/styles/, ADR 0219) — a test pins this
20
+ // list to the directory — plus `own` (the instance's own pack: today's hall, and the
21
+ // default) and `custom` (one accent over the chrome world).
22
+ const LIBRARY_LOOKS = Object.freeze(['chrome-world', 'expedition', 'grove', 'blueprint']);
23
+ const LOOK_SETTINGS_VOCAB = Object.freeze({
24
+ look: Object.freeze(['own', ...LIBRARY_LOOKS, 'custom']),
25
+ night_tint: Object.freeze(['on', 'off']),
26
+ });
27
+ // `own` is today's behaviour. The tint defaults ON (owner D2), and only ever tints a
28
+ // hall that wears a look, so the default changes nothing for a hall that chose none.
29
+ const LOOK_DEFAULTS = Object.freeze({ look: 'own', night_tint: 'on' });
30
+
31
+ const HEX = /^#[0-9a-f]{6}$/i;
32
+ const LOGO_URL = /^https:\/\/[^\s"'<>()\\]+$/;
33
+ const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
34
+ const lookAccentOf = (row) => (HEX.test(String(storedOf(row).look_accent || '')) ? String(storedOf(row).look_accent).toLowerCase() : '');
35
+ const logoUrlOf = (row) => (LOGO_URL.test(String(storedOf(row).logo_url || '')) ? String(storedOf(row).logo_url) : '');
36
+
37
+ // The companion env (SETTINGS_COMPANION_ENV): always emitted, empty meaning none, so a
38
+ // value that came and went never leaves a stale line in a patched web.env.
39
+ const LOOK_COMPANION_ENV = Object.freeze({ LOOK_ACCENT: lookAccentOf, LOGO_URL: logoUrlOf });
40
+
41
+ // The project's look as the manage page and the wizard read it. PURE.
42
+ function lookChoice(row, effective) {
43
+ return {
44
+ look: effective.look,
45
+ night_tint: effective.night_tint,
46
+ look_accent: lookAccentOf(row) || null,
47
+ logo_url: logoUrlOf(row) || null,
48
+ };
49
+ }
50
+
51
+ // ONE algorithm, two copies: the create wizard and the manage page carry this exact
52
+ // function (modules/public-landing/public/projects.html), and
53
+ // tests/provisioning_look.mjs pins the two texts equal. The founder picks one colour;
54
+ // it must read in both modes, against everything the hall paints with it:
55
+ // * the dark ink the hall puts on an accent fill (#2a1305, --accent-on);
56
+ // * the light mode's derived accent ink (45% accent into #240e00) on the chrome
57
+ // world's paper and card;
58
+ // * the accent itself as text on the black grounds and on the tinted ones
59
+ // (src/night-tint.js, which mixes this same accent in);
60
+ // * the hall's info chip on the tinted card (--info on --info-weak, both mixed from
61
+ // the chrome world's sea and that card) — the thinnest pair a tint moves.
62
+ // 4.6 rather than 4.5 leaves rounding room; the info chip is held at exactly 4.5,
63
+ // computed as tests/hall_tokens.mjs computes it, because 4.6 there would move library
64
+ // accents that already pass. A colour that passes comes back as it
65
+ // was; one that fails keeps its hue and saturation and moves to the nearest
66
+ // lightness that passes. null only for a value that is not #rrggbb.
67
+ function lookAdjustAccent(hex) {
68
+ var m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
69
+ if (!m) return null;
70
+ var n = parseInt(m[1], 16);
71
+ var rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
72
+ function mix(a, p, b) { return [0, 1, 2].map(function (i) { return a[i] * p + b[i] * (1 - p); }); }
73
+ function lum(c) {
74
+ var v = c.map(function (x) { x /= 255; return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); });
75
+ return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
76
+ }
77
+ function ratio(a, b) { var x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }
78
+ function reads(c) {
79
+ var ink = mix(c, 0.45, [36, 14, 0]);
80
+ var darks = [[0, 0, 0], [16, 16, 18], mix(c, 0.06, [0, 0, 0]), mix(c, 0.05, [16, 16, 18])];
81
+ var info = mix([91, 111, 208], 0.8, [255, 255, 255]);
82
+ return ratio([42, 19, 5], c) >= 4.6 && ratio(ink, [237, 236, 238]) >= 4.6 && ratio(ink, [251, 250, 252]) >= 4.6 &&
83
+ darks.every(function (d) { return ratio(c, d) >= 4.6; }) && ratio(info, mix(info, 0.14, darks[3])) >= 4.5;
84
+ }
85
+ function toHex(c) { return '#' + c.map(function (x) { var s = Math.round(x).toString(16); return s.length < 2 ? '0' + s : s; }).join(''); }
86
+ var r = rgb[0] / 255, g = rgb[1] / 255, b = rgb[2] / 255;
87
+ var max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, h = 0, s = 0, d = max - min;
88
+ if (d) {
89
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
90
+ h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
91
+ h /= 6;
92
+ }
93
+ function hsl(L) {
94
+ function f(p, q, t) { t = (t + 1) % 1; return t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p; }
95
+ if (!s) return [L * 255, L * 255, L * 255];
96
+ var q = L < 0.5 ? L * (1 + s) : L + s - L * s, p = 2 * L - q;
97
+ return [f(p, q, h + 1 / 3) * 255, f(p, q, h) * 255, f(p, q, h - 1 / 3) * 255];
98
+ }
99
+ if (reads(rgb)) return { hex: toHex(rgb), adjusted: false };
100
+ for (var step = 1; step <= 200; step++) {
101
+ var tries = [l + step / 200, l - step / 200];
102
+ for (var t = 0; t < 2; t++) {
103
+ if (tries[t] < 0 || tries[t] > 1) continue;
104
+ var c = hsl(tries[t]).map(Math.round);
105
+ if (reads(c)) return { hex: toHex(c), adjusted: true };
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ // ── the logo ────────────────────────────────────────────────────────────────
112
+ // PNG, JPEG or WebP, at most 256 KB, checked by its bytes. Never SVG: an SVG is a
113
+ // document, and a document can carry script. The task-visuals screen
114
+ // (modules/lifecycle/task-visuals.js) is the precedent; this is its narrower cousin.
115
+ const LOGO_MAX_BYTES = 256 * 1024;
116
+ const LOGO_TYPES = Object.freeze({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' });
117
+ const LOGO_MIME = Object.freeze({ png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp' });
118
+
119
+ function sniffLogo(buf) {
120
+ if (!buf || buf.length < 12) return null;
121
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png';
122
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpg';
123
+ if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
124
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return 'webp';
125
+ return null;
126
+ }
127
+
128
+ // { ok:true, ext, mime, sha } or { ok:false, reason, message }. PURE; never throws.
129
+ function screenLogo(buf, contentType) {
130
+ const declared = LOGO_TYPES[String(contentType || '').split(';')[0].trim().toLowerCase()];
131
+ if (!declared) return { ok: false, reason: 'bad_type', message: 'A logo must be a PNG, JPEG or WebP image.' };
132
+ if (!buf || !buf.length) return { ok: false, reason: 'empty', message: 'The image is empty.' };
133
+ if (buf.length > LOGO_MAX_BYTES) {
134
+ return { ok: false, reason: 'too_big', message: `The image is ${Math.round(buf.length / 1024)} KB — a logo can be at most ${LOGO_MAX_BYTES / 1024} KB.` };
135
+ }
136
+ const ext = sniffLogo(buf);
137
+ if (ext !== declared) return { ok: false, reason: 'sniff_mismatch', message: 'The file is not really the kind of image it says it is.' };
138
+ return { ok: true, ext, mime: LOGO_MIME[ext], sha: crypto.createHash('sha256').update(buf).digest('hex') };
139
+ }
140
+
141
+ // The stored logo's public file name: content-addressed, so the address changes when
142
+ // the image does (no stale cache), is unguessable, and names no project.
143
+ const logoFileName = (sha, ext) => `${sha}.${ext}`;
144
+ const LOGO_FILE = /^([0-9a-f]{64})\.(png|jpg|webp)$/;
145
+
146
+ // The logo's three statements. The route calls them through this module object, so a
147
+ // test can stand in for the database without one.
148
+ async function upsertLogo(db, instanceId, { sha, ext, mime }, buf) {
149
+ await db.query(
150
+ `INSERT INTO provisioning_logos (instance_id, sha256, ext, mime, bytes)
151
+ VALUES ($1, $2, $3, $4, $5)
152
+ ON CONFLICT (instance_id) DO UPDATE
153
+ SET sha256 = EXCLUDED.sha256, ext = EXCLUDED.ext, mime = EXCLUDED.mime, bytes = EXCLUDED.bytes, updated_at = now()`,
154
+ [instanceId, sha, ext, mime, buf]);
155
+ }
156
+ async function deleteLogo(db, instanceId) {
157
+ await db.query('DELETE FROM provisioning_logos WHERE instance_id = $1', [instanceId]);
158
+ }
159
+ async function readLogo(db, sha, ext) {
160
+ const { rows } = await db.query('SELECT mime, bytes FROM provisioning_logos WHERE sha256 = $1 AND ext = $2 LIMIT 1', [sha, ext]);
161
+ return rows[0] || null;
162
+ }
163
+
164
+ module.exports = {
165
+ upsertLogo, deleteLogo, readLogo,
166
+ LIBRARY_LOOKS, LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV,
167
+ lookChoice, lookAdjustAccent, lookAccentOf, logoUrlOf,
168
+ LOGO_MAX_BYTES, LOGO_MIME, screenLogo, sniffLogo, logoFileName, LOGO_FILE,
169
+ };
@@ -0,0 +1,35 @@
1
+ -- provisioning_034_project_logo.sql — a project's logo, kept by the platform
2
+ -- (task 1004421, BV2.PS06; ADR 0358 D6; goal 1000121, spec docs/specs/bongos-v2-project-startup.md).
3
+ --
4
+ -- WHY THIS EXISTS. A founder can give a project a logo at creation, and an existing
5
+ -- project's owner can add one on its manage page (spec D8). The hall shows it in its mark
6
+ -- and uses it as the browser tab's icon, and the hall is a different machine from the
7
+ -- platform — so the platform keeps the image and serves it at a public address the hall
8
+ -- is told (the LOGO_URL setting companion). An instance has no shared disk with the
9
+ -- platform, and the platform's own disk is replaced on every core upgrade, so the bytes
10
+ -- live here.
11
+ --
12
+ -- WHAT IT ADDS. One row per project that has a logo:
13
+ -- instance_id the project (provisioning_instances.id); deleting the project row drops it.
14
+ -- sha256 hex digest of the bytes. The public address is <sha256>.<ext>, so it is
15
+ -- content-addressed: it changes when the image does, names no project and
16
+ -- cannot be guessed.
17
+ -- ext / mime png | jpg | webp, from the bytes' own magic number (never SVG, which is a
18
+ -- document that can carry script). The route refuses anything else.
19
+ -- bytes the image itself, at most 256 KB (enforced by the route).
20
+ --
21
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
22
+
23
+ BEGIN;
24
+
25
+ CREATE TABLE IF NOT EXISTS provisioning_logos (
26
+ instance_id bigint PRIMARY KEY REFERENCES provisioning_instances(id) ON DELETE CASCADE,
27
+ sha256 text NOT NULL,
28
+ ext text NOT NULL CHECK (ext IN ('png', 'jpg', 'webp')),
29
+ mime text NOT NULL,
30
+ bytes bytea NOT NULL,
31
+ updated_at timestamptz NOT NULL DEFAULT now()
32
+ );
33
+ CREATE INDEX IF NOT EXISTS provisioning_logos_sha256 ON provisioning_logos (sha256);
34
+
35
+ COMMIT;
@@ -19,7 +19,8 @@
19
19
  "core-upgrade",
20
20
  "env-manifest",
21
21
  "repo-private",
22
- "render-standup"
22
+ "render-standup",
23
+ "look"
23
24
  ],
24
25
  "migrations": true,
25
26
  "pollers": [
@@ -24,6 +24,7 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
24
24
  // projection both have to read them.
25
25
  const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
26
  const { softLimitsFor } = require('./soft-limits');
27
+ const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look');
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
@@ -198,7 +199,7 @@ const SETTINGS_VOCAB = Object.freeze({
198
199
  visibility: ['public', 'private', 'stealth'],
199
200
  join_grant: ['view', 'apply', 'full'],
200
201
  artist_gate: ['off', 'advisory', 'strict'],
201
- spend_payer: ['none', 'project', 'builder'],
202
+ spend_payer: ['none', 'project', 'builder'], ...LOOK_SETTINGS_VOCAB, // + the look, ADR 0358 (look.js)
202
203
  });
203
204
  // Default = today's behavior on every axis, including the join door: ADR 0182's
204
205
  // migration gives the hub column the same `public` default, "existing rows keep
@@ -206,7 +207,7 @@ const SETTINGS_VOCAB = Object.freeze({
206
207
  // owner's ruling rather than the status quo — see the note above it.
207
208
  const DEFAULT_SETTINGS = Object.freeze({
208
209
  platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full',
209
- artist_gate: 'strict', spend_payer: 'none',
210
+ artist_gate: 'strict', spend_payer: 'none', ...LOOK_DEFAULTS,
210
211
  });
211
212
 
212
213
  // The project's PLANET (task 1003347, direction record §16): how the platform
@@ -517,7 +518,7 @@ function settingsEnvVars(row, prefix) {
517
518
  // cutoff and every unresolved review counts, which is the right answer for a
518
519
  // project that has been strict since it stood up.
519
520
  const SETTINGS_COMPANION_ENV = Object.freeze({
520
- ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '',
521
+ ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, // + accent, logo (look.js)
521
522
  });
522
523
 
523
524
  // The web.env block the runner writes at STANDUP (task 1003139): a comment that
@@ -0,0 +1,204 @@
1
+ // modules/provisioning/routes/look.js — a project's look, night tint and logo
2
+ // (task 1004421, BV2.PS06; ADR 0358; spec D2 + D8).
3
+ //
4
+ // GET /provisioning/instances/:id/look the look as stored: { look, night_tint, look_accent, logo_url }
5
+ // PATCH /provisioning/instances/:id/look { look?, night_tint?, accent? } — a custom look's accent is
6
+ // nudged until it reads in both modes (lookAdjustAccent) and
7
+ // the reply names the colour actually kept
8
+ // PUT /provisioning/instances/:id/logo { image_b64, content_type } — PNG, JPEG or WebP, ≤256 KB
9
+ // DELETE /provisioning/instances/:id/logo
10
+ // GET /provisioning/logos/:file the stored image, public and content-addressed
11
+ //
12
+ // The four own-scoped routes are the owner's, with the archon override (the settings
13
+ // route's rule, 404 for anyone else so an id confirms nothing). Every write that changes
14
+ // what the hall wears answers through settings-push.js, exactly as PATCH …/settings
15
+ // does: an ACTIVE project restarts to pick it up, any other state takes it at standup.
16
+ //
17
+ // Its own routes file (declared in module.json) because routes/provisioning.js sits at
18
+ // the size ratchet — the routes/disconnect.js precedent.
19
+ 'use strict';
20
+
21
+ const express = require('express');
22
+ const api = require('../../../src/module-api');
23
+ const { pool, validateOrRespond, parseId } = api;
24
+ const provisioning = require('../provisioning');
25
+ const look = require('../look');
26
+ const { settingsPush, failPushConflict } = require('../settings-push');
27
+ const { failFrom } = require('../public-refusal');
28
+
29
+ const log = api.logger('provisioning');
30
+
31
+ // The JSON cap for the logo upload: 256 KB decoded is ~342 KB of base64. Two layers,
32
+ // like the task-visual upload: this bounds the ENCODED body, screenLogo the bytes.
33
+ const LOGO_BODY_LIMIT = '400kb';
34
+ const LOGO_B64_MAX = 360 * 1024;
35
+
36
+ // The platform's own public origin — where the hall is told to fetch the logo. The
37
+ // provisioning front door when there is one, else the platform's public origin (the
38
+ // mothershipOrigin rule in routes/provisioning.js). '' when unconfigured.
39
+ function platformOrigin() {
40
+ try {
41
+ const d = api.branding().domains || {};
42
+ return String(d.provisioningOrigin || d.publicOrigin || '').replace(/\/+$/, '');
43
+ } catch { return ''; }
44
+ }
45
+
46
+ async function ownInstance(req, res) {
47
+ const id = parseId(req, res); // sends 400 bad_id + returns null on failure
48
+ if (!id) return null;
49
+ const inst = await provisioning.getInstanceById(pool, id);
50
+ const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
51
+ if (!inst || (!isOwner && req.builder.rank !== 'archon')) {
52
+ res.fail('instance_not_found', 404);
53
+ return null;
54
+ }
55
+ return inst;
56
+ }
57
+
58
+ const actorFor = (inst, builder) => (String(inst.owner_builder_id) === String(builder.id) ? 'api:self' : `api:archon:${builder.id}`);
59
+ const choiceOf = (row) => look.lookChoice(row, provisioning.effectiveSettings(row));
60
+
61
+ // Write a look change and answer it — the shared tail of PATCH …/look and both logo
62
+ // writes. `stored` is the jsonb patch; `changed` says whether the hall's look moves.
63
+ async function saveAndPush({ req, res, inst, stored, changed, event, extra = {} }) {
64
+ const id = inst.id;
65
+ const actor = actorFor(inst, req.builder);
66
+ const pushOwed = provisioning.settingsPushOwed(inst);
67
+ // The owner re-kick (the settings route's rule): the same values re-sent over a push
68
+ // that never got carried, or that ran and failed, queue it again.
69
+ const policyResent = provisioning.ownsErrorNote(inst, 'settings-apply') || pushOwed;
70
+ const updated = changed ? await provisioning.updateInstanceSettings(pool, id, stored) : inst;
71
+ if (changed) {
72
+ await provisioning.recordEvent(pool, { instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings', detail: event, actor }).catch(() => {});
73
+ }
74
+ const { push, conflict } = await settingsPush({ pool, provisioning, inst, id, actor, changed, policyChanged: changed,
75
+ policyResent, pushOwed, log, logTag: `${req.method} ${req.route && req.route.path}` });
76
+ if (conflict) return failPushConflict(res, conflict);
77
+ return res.json({ ok: true, look: choiceOf(updated || inst), push, ...extra });
78
+ }
79
+
80
+ module.exports = function buildLookRouter() {
81
+ const router = express.Router();
82
+
83
+ // GET — rank: any authenticated builder (own resource; archon may read any).
84
+ router.get('/provisioning/instances/:id/look', api.requireBuilder, async (req, res) => {
85
+ try {
86
+ const inst = await ownInstance(req, res);
87
+ if (!inst) return;
88
+ res.json({ ok: true, look: choiceOf(inst), options: look.LOOK_SETTINGS_VOCAB, logo_max_bytes: look.LOGO_MAX_BYTES });
89
+ } catch (err) {
90
+ log.error('[provisioning] GET /provisioning/instances/:id/look', err);
91
+ failFrom(res, err, 'look_read_failed');
92
+ }
93
+ });
94
+
95
+ // PATCH — rank: any authenticated builder (own resource; archon may change any). A
96
+ // custom look needs an accent, sent now or already stored; the stored one is the
97
+ // ADJUSTED colour, and `adjusted` in the reply says when it differs from what came in.
98
+ router.patch('/provisioning/instances/:id/look', api.requireBuilder, async (req, res) => {
99
+ if (validateOrRespond(req, res, {
100
+ look: { type: 'string', enum: look.LOOK_SETTINGS_VOCAB.look },
101
+ night_tint: { type: 'string', enum: look.LOOK_SETTINGS_VOCAB.night_tint },
102
+ accent: { type: 'string', maxLength: 16 },
103
+ })) return;
104
+ const body = req.body || {};
105
+ if (body.look === undefined && body.night_tint === undefined && body.accent === undefined) {
106
+ return res.fail('bad_look', { status: 400, message: 'The body carries nothing to change — send any of: look, night_tint, accent.' });
107
+ }
108
+ try {
109
+ const inst = await ownInstance(req, res);
110
+ if (!inst) return;
111
+ const before = provisioning.effectiveSettings(inst);
112
+ const beforeAccent = look.lookAccentOf(inst);
113
+ const stored = {};
114
+ if (body.look !== undefined) stored.look = body.look;
115
+ if (body.night_tint !== undefined) stored.night_tint = body.night_tint;
116
+ let adjusted = null;
117
+ if (body.accent !== undefined) {
118
+ const a = look.lookAdjustAccent(body.accent);
119
+ if (!a) return res.fail('bad_accent', { status: 400, message: 'An accent is a colour written #rrggbb, for example #b48cf2.' });
120
+ stored.look_accent = a.hex;
121
+ if (a.adjusted) adjusted = { from: String(body.accent).trim().toLowerCase(), to: a.hex };
122
+ }
123
+ const nextLook = stored.look || before.look;
124
+ if (nextLook === 'custom' && !(stored.look_accent || beforeAccent)) {
125
+ return res.fail('accent_required', { status: 400, message: 'Your own look needs an accent colour — send accent as #rrggbb.' });
126
+ }
127
+ const changed = Object.entries(stored).some(([k, v]) => (k === 'look_accent' ? beforeAccent !== v : before[k] !== v));
128
+ const event = `look changed: ${Object.entries(stored).map(([k, v]) => `${k}=${v}`).join(', ')}`;
129
+ return await saveAndPush({ req, res, inst, stored, changed, event, extra: { adjusted } });
130
+ } catch (err) {
131
+ log.error('[provisioning] PATCH /provisioning/instances/:id/look', err);
132
+ failFrom(res, err, 'look_update_failed');
133
+ }
134
+ });
135
+
136
+ // PUT …/logo — rank: any authenticated builder (own resource; archon may change any).
137
+ // The image is screened by its bytes, stored, and the hall told its new address.
138
+ router.put('/provisioning/instances/:id/logo', api.requireBuilder, express.json({ limit: LOGO_BODY_LIMIT }), async (req, res) => {
139
+ if (validateOrRespond(req, res, {
140
+ image_b64: { required: true, type: 'string', minLength: 1, maxLength: LOGO_B64_MAX },
141
+ content_type: { required: true, type: 'string', minLength: 1, maxLength: 100 },
142
+ })) return;
143
+ try {
144
+ const inst = await ownInstance(req, res);
145
+ if (!inst) return;
146
+ // Buffer.from drops what it cannot decode, so a mangled body arrives as wrong
147
+ // bytes, and the sniff below refuses those.
148
+ const screened = look.screenLogo(Buffer.from(req.body.image_b64, 'base64'), req.body.content_type);
149
+ if (!screened.ok) return res.fail(`logo_${screened.reason}`, { status: 400, message: screened.message });
150
+ const origin = platformOrigin();
151
+ if (!/^https:\/\//.test(origin)) {
152
+ return res.fail('logo_unavailable', { status: 409, message: 'This platform has no public https address configured, so a hall could not load the logo from it.' });
153
+ }
154
+ const buf = Buffer.from(req.body.image_b64, 'base64');
155
+ await look.upsertLogo(pool, inst.id, screened, buf);
156
+ const url = `${origin}/api/bongos/provisioning/logos/${look.logoFileName(screened.sha, screened.ext)}`;
157
+ const changed = look.logoUrlOf(inst) !== url;
158
+ return await saveAndPush({ req, res, inst, stored: { logo_url: url }, changed, event: 'logo set' });
159
+ } catch (err) {
160
+ log.error('[provisioning] PUT /provisioning/instances/:id/logo', err);
161
+ failFrom(res, err, 'logo_update_failed');
162
+ }
163
+ });
164
+
165
+ // DELETE …/logo — rank: any authenticated builder (own resource; archon may change any).
166
+ router.delete('/provisioning/instances/:id/logo', api.requireBuilder, async (req, res) => {
167
+ if (validateOrRespond(req, res, {})) return;
168
+ try {
169
+ const inst = await ownInstance(req, res);
170
+ if (!inst) return;
171
+ await look.deleteLogo(pool, inst.id);
172
+ const had = !!look.logoUrlOf(inst);
173
+ // An empty string, not a removed key: the merge-only settings write cannot drop a
174
+ // key, and logoUrlOf reads '' as no logo, so the companion env goes out empty.
175
+ return await saveAndPush({ req, res, inst, stored: { logo_url: '' }, changed: had, event: 'logo removed' });
176
+ } catch (err) {
177
+ log.error('[provisioning] DELETE /provisioning/instances/:id/logo', err);
178
+ failFrom(res, err, 'logo_update_failed');
179
+ }
180
+ });
181
+
182
+ // rank: public — a logo is shown on a public hall, and its address is its own sha256,
183
+ // so it names no project and cannot be guessed. Served as an image only: nosniff, a
184
+ // sandboxing CSP, and a cache that never goes stale because a new image is a new name.
185
+ router.get('/provisioning/logos/:file', async (req, res) => {
186
+ const m = look.LOGO_FILE.exec(String(req.params.file || ''));
187
+ if (!m) return res.fail('logo_not_found', 404);
188
+ try {
189
+ const logo = await look.readLogo(pool, m[1], m[2]);
190
+ if (!logo) return res.fail('logo_not_found', 404);
191
+ res.setHeader('Content-Type', look.LOGO_MIME[m[2]]);
192
+ res.setHeader('X-Content-Type-Options', 'nosniff');
193
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
194
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
195
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
196
+ res.end(logo.bytes);
197
+ } catch (err) {
198
+ log.error('[provisioning] GET /provisioning/logos/:file', err);
199
+ failFrom(res, err, 'logo_read_failed');
200
+ }
201
+ });
202
+
203
+ return router;
204
+ };
@@ -63,6 +63,7 @@ const { callbackPage } = require('./callback-page');
63
63
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
64
64
  const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators');
65
65
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
66
+ const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
66
67
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
67
68
  const { registerProvisioningSeams } = require('../seams');
68
69
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -927,65 +928,10 @@ module.exports = function provisioningRoutes() {
927
928
  // needs no restart and no reachable shape — it is the PLATFORM's own row.
928
929
  await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
929
930
  }
930
- // The push (task 1003140). The row is now the platform's truth, but a
931
- // RUNNING instance reads its settings from web.env through a pack memoized
932
- // for the process lifetime — so an ACTIVE instance on a reachable shape gets
933
- // a 'settings-apply' intent (web.env patched in place + RESTART, drained by
934
- // the runner; the web tier holds no tokens, ADR 0111 §2). The owner decided
935
- // the restart is acceptable and that the surface must SAY so plainly. Every
936
- // other state stores the value and says when it takes effect. One receipt
937
- // shape on every branch — `restart` = a restart is what applies it,
938
- // `reachable` = the platform can perform that restart — and state-only
939
- // sentences throughout (the F16 rule): no wall-clock promises.
940
- const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
941
- const receipt = (fields) => ({ reachable, ...fields });
942
- let push;
943
- if (!changed && !policyResent) {
944
- push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
945
- } else if (!policyChanged && !policyResent) {
946
- push = receipt({ queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' });
947
- } else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
948
- const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
949
- push = receipt({ queued: false, restart: false, message: offline
950
- ? 'Saved. This project is offline, so there is nothing to apply it to yet — it takes effect if the project is brought back.'
951
- : inst.status === 'error'
952
- ? 'Saved. This project’s standup stopped partway, so nothing is applying changes right now — it takes effect the next time the project is stood up.'
953
- : 'Saved. It takes effect when the project finishes standing up.' });
954
- } else if (!reachable) {
955
- // A dedicated droplet has no runner write path — refuse the push honestly
956
- // rather than queue one that never lands (the runner refuses it too).
957
- push = receipt({ queued: false, restart: true, message:
958
- 'Saved on the platform. This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to apply it there.' });
959
- } else {
960
- let r = null;
961
- try { r = await provisioning.enqueueSettingsApply(pool, id, actor); }
962
- catch (err) {
963
- // The value IS saved — say so; a 500 here would deny a durable write.
964
- log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
965
- // The remedy this sentence names has to WORK (task 1003524): nothing is
966
- // going to carry the saved value, so record the debt. Best-effort — a failed
967
- // marker write must never turn a durable settings write into a 500.
968
- await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
969
- push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
970
- }
971
- if (r && r.conflict) {
972
- // Saved, but the slot is held by work that will not carry this change, so
973
- // "send it again once that settles" has to re-queue when they do (task 1003524).
974
- await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
975
- return res.fail('intent_conflict', { status: 409,
976
- message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
977
- details: { action: r.intent.action, state: r.intent.state, recorded: true } });
978
- }
979
- if (r) {
980
- // A push is now in hand — fresh, or a PENDING one that has not read the row
981
- // yet — so either way it carries the current settings. Only written when
982
- // there IS a debt, so the hot path stays a single UPDATE.
983
- if (pushOwed) await provisioning.recordSettingsPushOwed(pool, id, false).catch(() => {});
984
- push = receipt({ queued: r.created, restart: true, message: r.created
985
- ? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
986
- : 'Saved. A restart is already queued for this project and will carry this change.' });
987
- }
988
- }
931
+ // The push and its receipt (task 1003140) — settings-push.js, shared with PATCH …/look.
932
+ const { push, conflict } = await settingsPush({ pool, provisioning, inst, id, actor, changed, policyChanged,
933
+ policyResent, pushOwed, log, logTag: 'PATCH /provisioning/instances/:id/settings' });
934
+ if (conflict) return failPushConflict(res, conflict);
989
935
  res.json({ ok: true, saved: provisioning.effectiveSettings(updated), planet: provisioning.planetChoice(updated), push });
990
936
  } catch (err) {
991
937
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
@@ -0,0 +1,76 @@
1
+ // modules/provisioning/settings-push.js — after a settings save: queue the push that
2
+ // carries it to a running project, and say what happens next (task 1003140; shared
3
+ // with the look route by task 1004421, ADR 0358).
4
+ //
5
+ // The row is the platform's truth, but a RUNNING instance reads its settings from
6
+ // web.env through a pack memoized for the process lifetime — so an ACTIVE instance on
7
+ // a reachable shape gets a 'settings-apply' intent (web.env patched in place + RESTART,
8
+ // drained by the runner; the web tier holds no tokens, ADR 0111 §2). The owner decided
9
+ // the restart is acceptable and that the surface must SAY so plainly. Every other
10
+ // state stores the value and says when it takes effect. One receipt shape on every
11
+ // branch — `restart` = a restart is what applies it, `reachable` = the platform can
12
+ // perform that restart — and state-only sentences throughout (the F16 rule): no
13
+ // wall-clock promises.
14
+ //
15
+ // Moved out of routes/provisioning.js (at its size ratchet) so PATCH …/settings and
16
+ // PATCH …/look answer from one copy. Returns { push } or { conflict } — the 409 the
17
+ // caller sends when the open slot is held by other work (the change IS saved).
18
+ 'use strict';
19
+
20
+ async function settingsPush({ pool, provisioning, inst, id, actor, changed, policyChanged, policyResent, pushOwed, log, logTag }) {
21
+ const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
22
+ const receipt = (fields) => ({ reachable, ...fields });
23
+ if (!changed && !policyResent) {
24
+ return { push: receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' }) };
25
+ }
26
+ if (!policyChanged && !policyResent) {
27
+ return { push: receipt({ queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' }) };
28
+ }
29
+ if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
30
+ const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
31
+ return { push: receipt({ queued: false, restart: false, message: offline
32
+ ? 'Saved. This project is offline, so there is nothing to apply it to yet — it takes effect if the project is brought back.'
33
+ : inst.status === 'error'
34
+ ? 'Saved. This project’s standup stopped partway, so nothing is applying changes right now — it takes effect the next time the project is stood up.'
35
+ : 'Saved. It takes effect when the project finishes standing up.' }) };
36
+ }
37
+ if (!reachable) {
38
+ // A dedicated droplet has no runner write path — refuse the push honestly
39
+ // rather than queue one that never lands (the runner refuses it too).
40
+ return { push: receipt({ queued: false, restart: true, message:
41
+ 'Saved on the platform. This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to apply it there.' }) };
42
+ }
43
+ let r = null;
44
+ try { r = await provisioning.enqueueSettingsApply(pool, id, actor); }
45
+ catch (err) {
46
+ // The value IS saved — say so; a 500 here would deny a durable write.
47
+ log.error(`[provisioning] ${logTag} enqueue`, err);
48
+ // The remedy this sentence names has to WORK (task 1003524): nothing is
49
+ // going to carry the saved value, so record the debt. Best-effort — a failed
50
+ // marker write must never turn a durable settings write into a 500.
51
+ await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
52
+ return { push: receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' }) };
53
+ }
54
+ if (r && r.conflict) {
55
+ // Saved, but the slot is held by work that will not carry this change, so
56
+ // "send it again once that settles" has to re-queue when they do (task 1003524).
57
+ await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
58
+ return { conflict: r };
59
+ }
60
+ // A push is now in hand — fresh, or a PENDING one that has not read the row
61
+ // yet — so either way it carries the current settings. Only written when
62
+ // there IS a debt, so the hot path stays a single UPDATE.
63
+ if (pushOwed) await provisioning.recordSettingsPushOwed(pool, id, false).catch(() => {});
64
+ return { push: receipt({ queued: r.created, restart: true, message: r.created
65
+ ? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
66
+ : 'Saved. A restart is already queued for this project and will carry this change.' }) };
67
+ }
68
+
69
+ // The 409 a conflicted push answers with — one wording for both routes.
70
+ function failPushConflict(res, r) {
71
+ return res.fail('intent_conflict', { status: 409,
72
+ message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
73
+ details: { action: r.intent.action, state: r.intent.state, recorded: true } });
74
+ }
75
+
76
+ module.exports = { settingsPush, failPushConflict };
@@ -1603,7 +1603,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1603
1603
  /* the six-step strip as the rail: six hairlines, a 7px dot, one lit step on the
1604
1604
  accent with its halo, the number in the display voice, the name in the label
1605
1605
  face; a step already answered (role=button) stays a way back */
1606
- #view-new .steps{display:grid;grid-template-columns:repeat(8,1fr);gap:0 10px;align-items:start;margin:0 0 40px;}
1606
+ #view-new .steps{display:grid;grid-template-columns:repeat(9,1fr);gap:0 10px;align-items:start;margin:0 0 40px;}
1607
1607
  #view-new .steps li{display:block;position:relative;border-top:1px solid var(--rule);padding:12px 0 0;gap:0;}
1608
1608
  #view-new .steps li::before{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;margin:0;border-radius:50%;border:1px solid var(--rule);background:var(--bg);}
1609
1609
  #view-new .steps .snum{display:block;width:auto;height:auto;border:0;border-radius:0;background:none;font-family:var(--font-display);font-weight:300;font-size:22px;letter-spacing:-.02em;line-height:1.1;color:var(--ink-faint);margin-bottom:2px;}