@bongos/core 1.24.30 → 1.24.31

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 (101) hide show
  1. package/.bongos-core.json +153 -103
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +139 -1
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +192 -187
  10. package/docs/copy-registry.json +530 -458
  11. package/docs/module-api-changelog.md +2 -2
  12. package/docs/page-inventory.json +15 -35
  13. package/docs/page-readings.json +226 -263
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/hall-ui/public/ai-studio.html +1 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/city.html +1 -1
  22. package/modules/hall-ui/public/collab.html +1 -1
  23. package/modules/hall-ui/public/commands.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/craft-mode.js +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/hall-render.js +62 -86
  35. package/modules/hall-ui/public/home-city-draw.js +360 -52
  36. package/modules/hall-ui/public/home-city.css +47 -11
  37. package/modules/hall-ui/public/home-city.js +156 -40
  38. package/modules/hall-ui/public/home-gallery.css +129 -0
  39. package/modules/hall-ui/public/home-gallery.js +349 -0
  40. package/modules/hall-ui/public/home-studio.css +72 -0
  41. package/modules/hall-ui/public/home-studio.js +203 -0
  42. package/modules/hall-ui/public/idea.html +1 -1
  43. package/modules/hall-ui/public/ideas.html +1 -1
  44. package/modules/hall-ui/public/index.html +4 -1
  45. package/modules/hall-ui/public/market-watch-item.html +1 -1
  46. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  47. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  48. package/modules/hall-ui/public/market-watch.html +141 -54
  49. package/modules/hall-ui/public/market-watch.js +54 -0
  50. package/modules/hall-ui/public/market-watch.states.json +2 -1
  51. package/modules/hall-ui/public/modules.html +1 -1
  52. package/modules/hall-ui/public/my-agents.html +1 -1
  53. package/modules/hall-ui/public/primer.html +1 -1
  54. package/modules/hall-ui/public/profile.html +1 -1
  55. package/modules/hall-ui/public/project-settings.html +1 -1
  56. package/modules/hall-ui/public/ranks.html +1 -1
  57. package/modules/hall-ui/public/roadmap.html +1 -1
  58. package/modules/hall-ui/public/roster.html +1 -1
  59. package/modules/hall-ui/public/sessions.html +1 -1
  60. package/modules/hall-ui/public/settings.html +1 -1
  61. package/modules/hall-ui/public/shell.js +2 -3
  62. package/modules/hall-ui/public/street.html +1 -1
  63. package/modules/hall-ui/public/studio.html +9 -1
  64. package/modules/hall-ui/public/style.css +74 -27
  65. package/modules/hall-ui/public/task.html +1 -1
  66. package/modules/hall-ui/public/thinking.html +1 -1
  67. package/modules/hall-ui/public/tweak-editor.html +1 -1
  68. package/modules/hall-ui/public/watch.html +1 -1
  69. package/modules/hall-ui/public/work.html +1 -1
  70. package/modules/hall-ui/records/home-city.md +123 -0
  71. package/modules/hall-ui/records/loader.md +1 -1
  72. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  73. package/modules/hall-ui/records/market-watch.md +3 -1
  74. package/modules/lifecycle/home-city.js +58 -10
  75. package/modules/lifecycle/home-gallery.js +320 -0
  76. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  77. package/modules/lifecycle/module.json +1 -0
  78. package/modules/lifecycle/routes/home-gallery.js +37 -0
  79. package/modules/market-watch/routes/settings.js +1 -1
  80. package/package-lock.json +2 -2
  81. package/package.json +1 -1
  82. package/release-notes.json +29 -1
  83. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  84. package/scripts/hall-preview/README.md +1 -0
  85. package/scripts/hall-preview/server.js +10 -0
  86. package/src/bongos/serve-internal.js +17 -3
  87. package/src/module-api.js +1 -1
  88. package/tests/hall_builders_page_pass.mjs +18 -27
  89. package/tests/hall_loading.mjs +1 -1
  90. package/tests/hall_nav.mjs +11 -4
  91. package/tests/hall_page_gate_map.mjs +7 -4
  92. package/tests/home_city.mjs +200 -5
  93. package/tests/home_city_widget.mjs +167 -27
  94. package/tests/home_gallery.mjs +255 -0
  95. package/tests/home_gallery_widget.mjs +220 -0
  96. package/tests/home_studio.mjs +169 -0
  97. package/tests/home_studio_widget.mjs +157 -0
  98. package/tests/market_watch_pages.mjs +162 -0
  99. package/tests/nav_permission_atoms.mjs +2 -2
  100. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  101. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/home-gallery.js — the works behind the artist's Home, and the
4
+ // one heart a builder may give each (task 1005060, Home v16 step 3).
5
+ //
6
+ // WHY. In Artistry, Home is a bench in the museum facing a wall of the project's
7
+ // signed works (modules/hall-ui/public/home-gallery.js, record
8
+ // modules/hall-ui/records/home-city.md § "The gallery"). The owner's rule: "not
9
+ // about quality but about appreciation" — each work carries how many builders
10
+ // appreciated it and one heart to add yours; Most popular (the default) is that
11
+ // count, Latest the ship date. No grade, rank or leaderboard reads or writes here.
12
+ //
13
+ // WHAT A WORK IS, and why this source. A work is a SHIPPED task of the art crafts
14
+ // (discipline `artist` or `ui` — economy's credits-by-craft rule, the city's
15
+ // ART_DISCIPLINES) that carries a picture of what it made:
16
+ // · a page tweak round (`source = 'page-tweak'`) hangs its own AFTER renders, the
17
+ // desktop light and dark slots /tweak attached for the Approval queue
18
+ // (lifecycle_task_visual_slots, TWEAK_RENDER_SLOTS) — the artist's approved page;
19
+ // · any other art ship hangs its ship-time visual (tasks.visual_url).
20
+ // That is the hall's own record of finished, signed art: the artist is the task's
21
+ // `shipped_by` (a page tweak's is its artist, page-tweak-artist.js), the date its
22
+ // ship, the line under it its value summary. The design mocks under docs/ are drafts
23
+ // and the page readings are words with no picture, so neither is a work.
24
+ //
25
+ // THE PICTURE INHERITS THE TASK'S AUDIENCE (the task-visuals rule): a
26
+ // security-sensitive task never hangs here, and a work under a private goal the
27
+ // viewer is neither a member of nor an Archon over is left off the wall entirely
28
+ // (ADR 0112) — its picture is the prose. Visibility comes from the signed-in
29
+ // viewer, never the query.
30
+ //
31
+ // BOUNDED. One query, LIMIT MAX_WORKS, ordered in SQL by the order asked for, with
32
+ // the whole wall's size as a window count — the payload never grows with the corpus.
33
+ //
34
+ // MODULE BOUNDARY: reaches core only through the published doorway. The hearts are
35
+ // this module's own table (lifecycle_020); lifecycle is default-on, but the table is
36
+ // still probed with to_regclass so a database the migration has not reached answers
37
+ // a wall with no hearts rather than failing Home.
38
+
39
+ const fs = require('node:fs');
40
+ const api = require('../../src/module-api');
41
+ const taskVisuals = require('./task-visuals');
42
+
43
+ const MAX_WORKS = 60;
44
+ const ORDERS = Object.freeze(['popular', 'latest']);
45
+ const ART_DISCIPLINES = Object.freeze(['artist', 'ui']);
46
+ const SLOT_LIGHT = 'after-desktop-light';
47
+ const SLOT_DARK = 'after-desktop-dark';
48
+ const SMOKE_TITLE = '\\_\\_smoke\\_\\_%';
49
+ const HEARTS = 'lifecycle_work_appreciations';
50
+ // where a page tweak's page hangs, by the page inventory's surface (docs/page-inventory.json)
51
+ const SURFACES = Object.freeze({ builders: 'The builders’ hall', landing: 'The public site', status: 'The status page' });
52
+
53
+ // A picture whose file is gone (an old upgrade erased the store once, task 1004381)
54
+ // would hang an empty frame; such a URL is dropped, and a work left with none is not
55
+ // hung. The checks are async and run together (never a blocking stat in a request),
56
+ // and each answer is held for ON_DISK_TTL_MS: a stored picture's name never changes,
57
+ // so a wall reloaded within the window costs no file system reads.
58
+ const ON_DISK_TTL_MS = 10 * 60 * 1000;
59
+ const onDiskSeen = new Map();
60
+ async function onDisk(url) {
61
+ const hit = onDiskSeen.get(url);
62
+ if (hit && Date.now() - hit.at < ON_DISK_TTL_MS) return hit.ok;
63
+ const p = taskVisuals.resolveSafe(String(url || '').split('/').pop());
64
+ let ok = false;
65
+ if (p) { try { await fs.promises.access(p); ok = true; } catch (_) { ok = false; } }
66
+ if (onDiskSeen.size > 2000) onDiskSeen.clear();
67
+ onDiskSeen.set(url, { ok, at: Date.now() });
68
+ return ok;
69
+ }
70
+ async function keepHung(rows, exists) {
71
+ const urls = [...new Set(rows.flatMap((r) => [r.light, r.dark, r.visual_url]).filter(Boolean))];
72
+ const there = new Set();
73
+ await Promise.all(urls.map(async (u) => { if (await exists(u)) there.add(u); }));
74
+ const keep = (u) => (u && there.has(u) ? u : null);
75
+ const out = rows.map((r) => Object.assign({}, r, { light: keep(r.light), dark: keep(r.dark), visual_url: keep(r.visual_url) }))
76
+ .filter((r) => r.light || r.dark || r.visual_url);
77
+ const gone = rows.length - out.length;
78
+ if (gone) out.forEach((r) => { r.total = Math.max(out.length, (Number(r.total) || 0) - gone); });
79
+ return out;
80
+ }
81
+
82
+ function cleanOrder(v) { return ORDERS.includes(v) ? v : 'popular'; }
83
+
84
+ // Once the table is there it stays there, so a found table is remembered for the
85
+ // process; an absent one is asked again (the migration may land while it runs).
86
+ let heartsFound = false;
87
+ async function heartsExist(pool) {
88
+ if (heartsFound && pool === api.pool) return true;
89
+ const { rows } = await pool.query('SELECT to_regclass($1) IS NOT NULL AS ok', [`public.${HEARTS}`]);
90
+ const ok = !!(rows[0] && rows[0].ok);
91
+ if (ok && pool === api.pool) heartsFound = true;
92
+ return ok;
93
+ }
94
+
95
+ // The WHERE that makes a task a work THIS viewer may see. Shared by the wall and the
96
+ // heart, so a heart can only ever land on a work the wall would hang. $1 = the art
97
+ // disciplines, $2 = the viewer is an Archon, $3 = the viewer's id.
98
+ const WORK_WHERE = `
99
+ t.status = 'shipped' AND t.shipped_at IS NOT NULL
100
+ AND (t.discipline = ANY($1::text[]) OR t.source = 'page-tweak')
101
+ AND t.security_sensitive IS NOT TRUE
102
+ AND t.title NOT LIKE '${SMOKE_TITLE}' ESCAPE '\\'
103
+ AND (t.visual_url IS NOT NULL OR sl.light IS NOT NULL OR sl.dark IS NOT NULL)
104
+ AND (g.id IS NULL OR g.visibility IS DISTINCT FROM 'private' OR $2::boolean
105
+ OR EXISTS (SELECT 1 FROM goal_members gm WHERE gm.goal_id = g.id AND gm.builder_id = $3::bigint))`;
106
+ const WORK_FROM = `
107
+ FROM tasks t
108
+ JOIN builders b ON b.id = t.shipped_by AND b.system_role IS NULL
109
+ LEFT JOIN goals g ON g.id = t.goal_id
110
+ LEFT JOIN LATERAL (
111
+ SELECT max(s.visual_url) FILTER (WHERE s.slot = '${SLOT_LIGHT}') AS light,
112
+ max(s.visual_url) FILTER (WHERE s.slot = '${SLOT_DARK}') AS dark
113
+ FROM lifecycle_task_visual_slots s
114
+ WHERE s.task_id = t.id AND t.source = 'page-tweak'
115
+ ) sl ON true`;
116
+
117
+ function viewerOf(viewer) {
118
+ const id = viewer && viewer.id != null ? String(viewer.id) : null;
119
+ const archon = !!viewer && String(viewer.rank || '').toLowerCase() === 'archon';
120
+ return { id, archon };
121
+ }
122
+
123
+ // The columns a work is shaped from. `hearts` and `mine` read the table only when
124
+ // it is there; $3 is always the viewer.
125
+ function workCols(hearts) {
126
+ const count = hearts ? `(SELECT count(*) FROM ${HEARTS} h WHERE h.task_id = t.id)` : '0';
127
+ const mine = hearts ? `EXISTS (SELECT 1 FROM ${HEARTS} h WHERE h.task_id = t.id AND h.builder_id = $3::bigint)` : 'false';
128
+ return `t.id::text AS id, t.title, t.value_summary, t.shipped_at, t.source, t.source_ref,
129
+ t.visual_url, t.visual_alt, sl.light, sl.dark, g.title AS goal_title,
130
+ b.id::text AS artist_id, b.display_name, b.github_login, b.created_at AS artist_since,
131
+ ${count}::int AS hearts, ${mine} AS mine,
132
+ count(*) OVER ()::int AS total`;
133
+ }
134
+
135
+ async function readGallery({ order, viewer }, deps = {}) {
136
+ const pool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
137
+ const o = cleanOrder(order);
138
+ const v = viewerOf(viewer);
139
+ const hearts = await heartsExist(pool);
140
+ const { rows } = await pool.query(
141
+ `SELECT ${workCols(hearts)}
142
+ ${WORK_FROM}
143
+ WHERE ${WORK_WHERE}
144
+ ORDER BY ${o === 'popular' ? 'hearts DESC, ' : ''}t.shipped_at DESC, t.id DESC
145
+ LIMIT ${MAX_WORKS}`,
146
+ [ART_DISCIPLINES, v.archon, v.id],
147
+ );
148
+ return { order: o, rows: await keepHung(rows, deps.exists || onDisk) };
149
+ }
150
+
151
+ // THE STUDIO'S TWO SHELVES (task 1005061, Home v16 step 4): the works this viewer
152
+ // appreciated on the bench (newest heart first — a heart pins a work here) and the
153
+ // works they signed themselves (most appreciated first). Each is the wall's own
154
+ // WHERE, so a pin never shows a work the bench would not hang, and each is LIMIT
155
+ // MAX_SHELF with its whole size as a window count.
156
+ const MAX_SHELF = 6;
157
+ // A page id is <surface>:<page> (the page inventory's id rule); the easel asks for its
158
+ // page's newest work by the round's source ref, `page-tweak/<page id>/r<n>`.
159
+ const PAGE_ID_RE = /^[a-z0-9-]+:[a-z0-9-]+$/;
160
+ async function readStudioWorks({ viewer, page }, deps = {}) {
161
+ const pool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
162
+ const v = viewerOf(viewer);
163
+ const exists = deps.exists || onDisk;
164
+ const hearts = await heartsExist(pool);
165
+ const args = [ART_DISCIPLINES, v.archon, v.id];
166
+ const easelPage = typeof page === 'string' && PAGE_ID_RE.test(page) ? page : null;
167
+ const [pinned, yours, easel] = await Promise.all([
168
+ hearts ? pool.query(
169
+ `SELECT ${workCols(true)}
170
+ ${WORK_FROM}
171
+ JOIN ${HEARTS} pin ON pin.task_id = t.id AND pin.builder_id = $3::bigint
172
+ WHERE ${WORK_WHERE}
173
+ ORDER BY pin.at DESC, t.id DESC
174
+ LIMIT ${MAX_SHELF}`,
175
+ args,
176
+ ) : Promise.resolve({ rows: [] }),
177
+ pool.query(
178
+ `SELECT ${workCols(hearts)}
179
+ ${WORK_FROM}
180
+ WHERE ${WORK_WHERE} AND t.shipped_by = $3::bigint
181
+ ORDER BY hearts DESC, t.shipped_at DESC, t.id DESC
182
+ LIMIT ${MAX_SHELF}`,
183
+ args,
184
+ ),
185
+ easelPage ? pool.query(
186
+ `SELECT ${workCols(hearts)}
187
+ ${WORK_FROM}
188
+ WHERE ${WORK_WHERE} AND t.source = 'page-tweak' AND starts_with(t.source_ref, $4)
189
+ ORDER BY t.shipped_at DESC, t.id DESC
190
+ LIMIT 1`,
191
+ [...args, `page-tweak/${easelPage}/r`],
192
+ ) : Promise.resolve({ rows: [] }),
193
+ ]);
194
+ const [p, y, e] = await Promise.all([keepHung(pinned.rows, exists), keepHung(yours.rows, exists), keepHung(easel.rows, exists)]);
195
+ return { pinned: p, yours: y, easel: e[0] || null };
196
+ }
197
+
198
+ function shapeShelf(rows) {
199
+ const works = rows.map(shapeWork);
200
+ return { total: rows.length ? Math.max(Number(rows[0].total) || 0, works.length) : 0, works };
201
+ }
202
+
203
+ function studioWorksHandler({ log, read = readStudioWorks } = {}) {
204
+ return async function homeStudioWorksRoute(req, res) {
205
+ try {
206
+ const page = req.query && typeof req.query.page === 'string' ? req.query.page : null;
207
+ const raw = await read({ viewer: { id: req.builder.id, rank: req.builder.rank }, page });
208
+ res.set('Cache-Control', 'private, no-store');
209
+ res.json({ pinned: shapeShelf(raw.pinned), yours: shapeShelf(raw.yours), easel: raw.easel ? shapeWork(raw.easel) : null });
210
+ } catch (err) {
211
+ if (log) log.error('GET /home/studio/works', err);
212
+ res.fail('home_studio_works_failed', { status: 500, message: 'internal error' });
213
+ }
214
+ };
215
+ }
216
+
217
+ // The task title of a page tweak round is `Tweak: <page title> (<surface>:<page>)`
218
+ // (copy-desk/pages.js); its source ref ends `/r<n>`.
219
+ function tweakParts(row) {
220
+ const t = /^Tweak:\s*(.+?)\s*\(([a-z0-9-]+):([a-z0-9-]+)\)?\s*$/.exec(String(row.title || ''));
221
+ const r = /\/r([1-9][0-9]*)$/.exec(String(row.source_ref || ''));
222
+ return { title: t ? t[1] : String(row.title || ''), surface: t ? t[2] : null, page: t ? t[3] : null, round: r ? Number(r[1]) : null };
223
+ }
224
+
225
+ function isoDay(d) {
226
+ const x = d instanceof Date ? d : new Date(d);
227
+ return isNaN(x) ? null : x.toISOString().slice(0, 10);
228
+ }
229
+
230
+ // One row → one work as the wall hangs it. Pure: a test feeds it rows.
231
+ function shapeWork(row) {
232
+ const tweak = row.source === 'page-tweak';
233
+ const p = tweak ? tweakParts(row) : null;
234
+ const one = row.visual_url || null;
235
+ return {
236
+ id: String(row.id),
237
+ page: tweak && p.surface && p.page ? `${p.surface}:${p.page}` : null,
238
+ // a ship's title can open with its plan code ("WA6.RP04 · "); a wall label does not
239
+ title: tweak ? p.title : String(row.title || '').replace(/^[A-Z][A-Z0-9]*(\.[A-Z0-9]+)*\s*·\s*/, ''),
240
+ medium: tweak ? (p.round > 1 ? `A page tweak, round ${p.round}` : 'A page tweak') : 'Page design',
241
+ place: tweak ? (SURFACES[p.surface] || null) : (row.goal_title || null),
242
+ words: row.value_summary || '',
243
+ shipped: isoDay(row.shipped_at),
244
+ art: { light: row.light || row.dark || one, dark: row.dark || row.light || one, alt: row.visual_alt || null },
245
+ artist: {
246
+ id: String(row.artist_id),
247
+ name: row.display_name || row.github_login || 'An artist',
248
+ login: row.github_login || '',
249
+ since: isoDay(row.artist_since),
250
+ },
251
+ hearts: Math.max(0, Number(row.hearts) || 0),
252
+ mine: row.mine === true,
253
+ };
254
+ }
255
+
256
+ function shapeGallery(raw) {
257
+ const works = raw.rows.map(shapeWork);
258
+ return {
259
+ order: raw.order,
260
+ total: raw.rows.length ? Math.max(Number(raw.rows[0].total) || 0, works.length) : 0,
261
+ works,
262
+ };
263
+ }
264
+
265
+ // Appreciate a work, or take it back. `on` is the state wanted, so a double click or
266
+ // a retried request lands the same row (never a second heart). Refuses anything the
267
+ // viewer's wall would not hang.
268
+ async function setHeart({ taskId, on, viewer }, deps = {}) {
269
+ const pool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
270
+ const v = viewerOf(viewer);
271
+ if (!(await heartsExist(pool))) return { ok: false, code: 'gallery_unavailable', status: 503 };
272
+ const hit = await pool.query(
273
+ `SELECT t.id::text AS id ${WORK_FROM} WHERE t.id = $4::bigint AND ${WORK_WHERE}`,
274
+ [ART_DISCIPLINES, v.archon, v.id, taskId],
275
+ );
276
+ if (!hit.rows.length) return { ok: false, code: 'not_a_work', status: 404 };
277
+ if (on) {
278
+ await pool.query(`INSERT INTO ${HEARTS} (task_id, builder_id) VALUES ($1::bigint, $2::bigint) ON CONFLICT DO NOTHING`, [taskId, v.id]);
279
+ } else {
280
+ await pool.query(`DELETE FROM ${HEARTS} WHERE task_id = $1::bigint AND builder_id = $2::bigint`, [taskId, v.id]);
281
+ }
282
+ const { rows } = await pool.query(
283
+ `SELECT count(*)::int AS hearts, bool_or(builder_id = $2::bigint) AS mine FROM ${HEARTS} WHERE task_id = $1::bigint`,
284
+ [taskId, v.id],
285
+ );
286
+ return { ok: true, id: String(taskId), hearts: Number(rows[0] && rows[0].hearts) || 0, mine: !!(rows[0] && rows[0].mine) };
287
+ }
288
+
289
+ function galleryHandler({ log, read = readGallery } = {}) {
290
+ return async function homeGalleryRoute(req, res) {
291
+ try {
292
+ const viewer = req.builder ? { id: req.builder.id, rank: req.builder.rank } : null;
293
+ const raw = await read({ order: req.query && req.query.order, viewer });
294
+ res.set('Cache-Control', 'private, no-store');
295
+ res.json(shapeGallery(raw));
296
+ } catch (err) {
297
+ if (log) log.error('GET /home/gallery', err);
298
+ res.fail('home_gallery_failed', { status: 500, message: 'internal error' });
299
+ }
300
+ };
301
+ }
302
+
303
+ function heartHandler({ log, write = setHeart } = {}) {
304
+ return async function homeGalleryHeartRoute(req, res) {
305
+ const taskId = Number(req.params && req.params.id);
306
+ const on = req.body && req.body.on;
307
+ if (!Number.isInteger(taskId) || taskId <= 0) return res.fail('bad_work_id', { status: 400, message: 'work id must be a positive integer' });
308
+ if (typeof on !== 'boolean') return res.fail('bad_heart', { status: 400, message: 'send { on: true } to appreciate a work, { on: false } to take it back' });
309
+ try {
310
+ const out = await write({ taskId, on, viewer: { id: req.builder.id, rank: req.builder.rank } });
311
+ if (!out.ok) return res.fail(out.code, { status: out.status, message: out.code === 'not_a_work' ? 'no work with that id hangs in your gallery' : 'the gallery is not set up on this project yet' });
312
+ res.json({ id: out.id, hearts: out.hearts, mine: out.mine });
313
+ } catch (err) {
314
+ if (log) log.error('PUT /home/gallery/:id/heart', err);
315
+ res.fail('home_gallery_heart_failed', { status: 500, message: 'internal error' });
316
+ }
317
+ };
318
+ }
319
+
320
+ module.exports = { readStudioWorks, studioWorksHandler, ORDERS, ART_DISCIPLINES, SURFACES, cleanOrder, keepHung, readGallery, shapeWork, shapeGallery, setHeart, galleryHandler, heartHandler };
@@ -0,0 +1,36 @@
1
+ -- lifecycle_020_work_appreciations.sql — a builder may appreciate a work in the
2
+ -- gallery, once (task 1005060, Home v16 step 3).
3
+ --
4
+ -- WHY. In Artistry, Home is a gallery of the project's signed works: shipped
5
+ -- artist and ui tasks that carry a picture (modules/lifecycle/home-gallery.js says
6
+ -- which). The owner's rule for it is "not about quality but about appreciation":
7
+ -- a work carries how many builders appreciated it and one heart to add yours, and
8
+ -- Most popular (the gallery's default order) is that count. It is never a grade,
9
+ -- a vote or a rank, so it lives apart from peer_votes and task grades, and
10
+ -- nothing reads it but the gallery.
11
+ --
12
+ -- ONE ROW PER (work, builder). Appreciating twice is the same row; taking it back
13
+ -- deletes it. A work is a task, so it references tasks and goes with it.
14
+ --
15
+ -- A MODULE TABLE, lifecycle_-namespaced (ADR 0083): it references the core tasks
16
+ -- and builders tables the way lifecycle_013 does and alters neither.
17
+ --
18
+ -- PORTABLE and replay-safe: IF NOT EXISTS throughout, no instance data.
19
+
20
+ BEGIN;
21
+
22
+ CREATE TABLE IF NOT EXISTS lifecycle_work_appreciations (
23
+ task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
24
+ builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
25
+ at timestamptz NOT NULL DEFAULT now(),
26
+ PRIMARY KEY (task_id, builder_id)
27
+ );
28
+
29
+ -- "Your pins" in the studio reads one builder's hearts, newest first.
30
+ CREATE INDEX IF NOT EXISTS lifecycle_work_appreciations_builder_idx
31
+ ON lifecycle_work_appreciations (builder_id, at DESC);
32
+
33
+ COMMENT ON TABLE lifecycle_work_appreciations IS
34
+ 'One row per builder who appreciated a gallery work (a shipped artist/ui task with a picture). Appreciation, never a grade: Most popular in the artist''s Home orders by its count (task 1005060).';
35
+
36
+ COMMIT;
@@ -36,6 +36,7 @@
36
36
  "goal-task-relevance",
37
37
  "moving",
38
38
  "home-city",
39
+ "home-gallery",
39
40
  "task-votes",
40
41
  "notes"
41
42
  ],
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/home-gallery.js — the artist's Home: the gallery's works
4
+ // and the one heart a builder may give each (task 1005060, Home v16 step 3).
5
+ //
6
+ // Its own route file for the reason routes/home-city.js has one: the path is
7
+ // top-level `/home/gallery`, never under `/tasks`, whose `/:id` would swallow a
8
+ // literal segment. The reads and handlers live in ../home-gallery.js so a test
9
+ // drives them with no express and no database.
10
+ //
11
+ // MODULE BOUNDARY: reaches core only through the published doorway
12
+ // `../../../src/module-api`; `../home-gallery` is this module's own.
13
+
14
+ const express = require('express');
15
+ const api = require('../../../src/module-api');
16
+ const gallery = require('../home-gallery');
17
+
18
+ const log = api.logger('lifecycle');
19
+
20
+ module.exports = function buildHomeGalleryRouter() {
21
+ const router = express.Router();
22
+ // rank: any-builder — GET /home/gallery?order=popular|latest, at most 60 works.
23
+ // Every work is a shipped task whose picture a signed-in builder can already open
24
+ // (the task-visuals audience); a work under a private goal the viewer cannot read
25
+ // is left off. Each carries its heart count and whether the viewer gave one.
26
+ router.get('/home/gallery', api.requireBuilder, gallery.galleryHandler({ log }));
27
+ // rank: any-builder — PUT /home/gallery/:id/heart { on }. Appreciation, never a
28
+ // grade: one heart per builder per work, given or taken back by the builder
29
+ // themself (the caller, never the body). Refused for anything their wall would
30
+ // not hang.
31
+ router.put('/home/gallery/:id/heart', api.requireBuilder, gallery.heartHandler({ log }));
32
+ // rank: any-builder — GET /home/studio/works?page=<page id>, the studio's two shelves
33
+ // (task 1005061): the works the CALLER appreciated and the works the caller signed,
34
+ // at most six each, and the easel page's newest work, all by the wall's own rule.
35
+ router.get('/home/studio/works', api.requireBuilder, gallery.studioWorksHandler({ log }));
36
+ return router;
37
+ };
@@ -1,5 +1,5 @@
1
1
  // modules/market-watch/routes/settings.js — the owner's Market Watch settings (task 1004938,
2
- // goal 1000137). The page is the hall's /market-watch-settings, built to
2
+ // goal 1000137). The page is the owner's Settings tab on the hall's /market-watch (task 1005045), built to
3
3
  // docs/design/mocks/market-watch/settings.html (option B, the fuel gauge).
4
4
  //
5
5
  // GET /market-watch/settings the switch, cap, cadence, this month's spend, the next
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.30",
3
+ "version": "1.24.31",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.30",
9
+ "version": "1.24.31",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.30",
3
+ "version": "1.24.31",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11108,7 +11108,35 @@
11108
11108
  "text": "Your agent settings now live in one place, My agents: Settings shows a short link there instead of repeating them, and every old Settings link to them opens My agents."
11109
11109
  }
11110
11110
  ],
11111
- "1.24.30": [
11111
+ "1.24.31": [
11112
+ {
11113
+ "id": "1005062",
11114
+ "text": "Releases work again: a false alarm about the GitHub logo on the new sign-in card had stopped every update from going out since this morning."
11115
+ },
11116
+ {
11117
+ "id": "1005061",
11118
+ "text": "The gallery now has a studio behind it. \"Your studio\" opens a quiet room with the page you are working on and a button back into it, the pages whose words changed since an artist last designed them, the newest words flagged on"
11119
+ },
11120
+ {
11121
+ "id": "1005060",
11122
+ "text": "In Artistry, Home is now a gallery: you sit on a bench in the museum and swipe through the project's signed works, most popular first or latest. Each work has a small museum label that opens closer on a click, the artist's sig"
11123
+ },
11124
+ {
11125
+ "id": "1005059",
11126
+ "text": "In Ideating mode, Home is now a night sky of the project's ideas over the city's real skyline. Each working area's ideas form a constellation, bigger stars have more votes, and a ringed star is waiting on you. Pointing at the"
11127
+ },
11128
+ {
11129
+ "id": "1005058",
11130
+ "text": "Home is now one city around the government's core: the new buildings lead, the core and the art museum are quiet doors, and time can be rewound smoothly from live."
11131
+ },
11132
+ {
11133
+ "id": "1005045",
11134
+ "text": "The owner's Market Watch settings are now a Settings tab on the Market Watch page, so the sidebar shows Market Watch once and old settings links still land in the right place."
11135
+ },
11136
+ {
11137
+ "id": "1005057",
11138
+ "text": "The signed-out page is now a clean sign-in screen: no side panel or top bar, just the logo, the project name, one \"Sign in with GitHub\" button and a small \"Request access\" link for newcomers."
11139
+ },
11112
11140
  {
11113
11141
  "id": "1005056",
11114
11142
  "text": "The \"waiting on you\" dots on Home's project city now show every person the matters actually wait on, not just the owner."
@@ -204,6 +204,14 @@ const FILE_ALLOWLIST = [
204
204
  + 'attribute, the same "why not a shape rule" secret-scrub.js records. The leading-zero guard '
205
205
  + 'catches the variants that have one; this is the residue. No topology here, only geometry.',
206
206
  },
207
+ {
208
+ path: 'modules/hall-ui/public/hall-render.js',
209
+ classes: ['public-ip'],
210
+ reason:
211
+ 'The same inlined forge logo, on the hall\'s signed-out sign-in card (task 1005057): its SVG '
212
+ + 'path data carries the same "…1.08.58 1.23.82.72 1.21…" run as projects.html above. '
213
+ + 'Geometry, not topology (task 1005062).',
214
+ },
207
215
  {
208
216
  path: 'modules/security/tests/artifact_scan.mjs',
209
217
  classes: ['public-ip'],
@@ -22,6 +22,7 @@ node scripts/hall-preview/server.js # neutral pack, port 4600
22
22
  | `--modules-off discord,provisioning` | report those modules disabled in `window.__MODULES__` — verifies `moduleOn()` gating (the Discord links, Instances/Start-a-project widgets must vanish) |
23
23
  | `--empty` | drain every array in every fixture — the empty-state screenshot pass |
24
24
  | `--fixture-me` | don't read your own account live — serve the checked-in `me.json` snapshot (see below) |
25
+ | `--signed-out` | answer `/me` with a 401 — preview the signed-out sign-in card |
25
26
  | `--latency 3000` | hold every data read that many ms before answering, so a page's loading state (the hall's `.hall-loading` mark) stays on screen long enough to look at |
26
27
 
27
28
  Writes are not wired: any `POST/PATCH/DELETE` answers `501 preview_readonly` — except
@@ -24,6 +24,8 @@
24
24
  // --fixture-me do NOT read your own account live; serve the checked-in
25
25
  // fixtures/me.json snapshot (deterministic screenshot passes,
26
26
  // and the offline/signed-out path) — task 1003315
27
+ // --signed-out answer /me with a 401, so the hall renders the signed-out
28
+ // sign-in card (the sign-in redesign)
27
29
  //
28
30
  // The account is YOURS: when this machine holds a GDS CLI session the me-scoped
29
31
  // reads are fetched live at boot and layered over the fixtures, so the hall
@@ -65,6 +67,8 @@ const MODULES_ON = String(arg('--modules-on') || '').split(',').map((s) => s.tri
65
67
  // own account live. The screenshot matrix in the README wants a stable subject,
66
68
  // and it is the explicit way to preview the signed-out fallback (task 1003315).
67
69
  const FIXTURE_ME = process.argv.includes('--fixture-me');
70
+ // --signed-out: /me answers 401, the way prod answers a visitor with no session.
71
+ const SIGNED_OUT = process.argv.includes('--signed-out');
68
72
  // --latency: hold every data read this many ms before answering (task 1005043),
69
73
  // so a page's loading state stays on screen long enough to be looked at. The
70
74
  // fixtures answer in a millisecond, which is why no loader had ever been seen.
@@ -194,6 +198,8 @@ const CANNED_WRITES = [
194
198
  ['POST', /^specialities$/, 'specialities.post'],
195
199
  // the Market Watch weekly ask's answer (task 1004940), so its "sent." state renders.
196
200
  ['POST', /^market-watch\/ask\/answer$/, 'market-watch__ask__answer.post'],
201
+ // the artist's Home (task 1005060): a heart given in the gallery, so it stays given.
202
+ ['PUT', /^home\/gallery\/\d+\/heart$/, 'home__gallery__heart.put'],
197
203
  ['PATCH', /^specialities\/\d+$/, 'specialities__12.patch'],
198
204
  // the rest of the speciality tools, now on My agents too (task 1005052): stop
199
205
  // using one, and train your own, so their toasts and redraws render here.
@@ -329,6 +335,10 @@ app.use((req, res, next) => {
329
335
  return res.status(400).json({ error: { code: 'bad_path', message: 'path escapes the fixtures' } });
330
336
  }
331
337
 
338
+ if (SIGNED_OUT && apiPath === 'me') {
339
+ return res.status(401).json({ error: { code: 'auth_required', message: 'signed out (--signed-out)' } });
340
+ }
341
+
332
342
  // SSE live channel: emit one nudge (so "live refresh" paths run once), then
333
343
  // keep-alive comments. Pages treat this exactly like prod's stream.
334
344
  if (apiPath === 'live') {
@@ -1034,6 +1034,20 @@ function mountInternalSurfaces(app) {
1034
1034
  return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/roster${qs}`);
1035
1035
  });
1036
1036
 
1037
+ // task 1005045 (owner decision 2026-11-03, idea 1001310): the owner's Market Watch
1038
+ // settings left their own page (task 1004938) for an owner-only Settings tab on
1039
+ // /market-watch, so the sidebar says "Market Watch" once. The old URL lands on that
1040
+ // tab; 302 for the same reason as the directory above, and no page gate: the redirect
1041
+ // discloses nothing, /market-watch has its own, and the settings routes are the wall.
1042
+ const RETIRED_MW_SETTINGS_RE = /^\/builders\/market-watch-settings(?:\.html)?\/?$/;
1043
+ app.use((req, res, next) => {
1044
+ if (isModuleSurfaceHost(req)) return next();
1045
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
1046
+ if (!RETIRED_MW_SETTINGS_RE.test(path.posix.normalize(req.path))) return next();
1047
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
1048
+ return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/market-watch${qs}#settings`);
1049
+ });
1050
+
1037
1051
  // /deploy EXISTS ONLY WHERE THE DOOR DOES (task 1004296). The owner, 2026-09-25: /deploy is
1038
1052
  // "only for bongos builders hall itself, not for all production ones". Every datum and
1039
1053
  // both acts on that page are provisioning-module routes, so a hall that does not run the
@@ -1308,13 +1322,13 @@ function mountInternalSurfaces(app) {
1308
1322
  // Claude works for them. Every read behind it is the viewer's own and
1309
1323
  // requireBuilder (GET /me and its /me/* prefs, /specialities), so sign-in is the
1310
1324
  // whole gate; the governing reads it adds are gated by their own routes.
1311
- // tasks 1004921 `ai-studio` (the viewer's own routes), 1004938 `market-watch-settings` (no data; owner-only routes).
1325
+ // task 1004921 `ai-studio` (the viewer's own routes).
1312
1326
  const SIGNEDIN_BUILDERS_PAGE_RE =
1313
- /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ai-studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|my-agents|market-watch-settings|commands)(?:\.html)?\/?$/;
1327
+ /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ai-studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|my-agents|commands)(?:\.html)?\/?$/;
1314
1328
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1315
1329
  // page that embeds them.
1316
1330
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
1317
- const MARKET_WATCH_PAGE_RE = /^\/builders\/market-watch(?:-[a-z]+)?(?:\.html)?\/?$/; // task 1004941: every read behind them is requireBuilder
1331
+ const MARKET_WATCH_PAGE_RE = /^\/builders\/market-watch(?:-(?:item|rules))?(?:\.html)?\/?$/; // task 1004941: every read behind them is requireBuilder
1318
1332
  app.use((req, res, next) => {
1319
1333
  if (isModuleSurfaceHost(req)) return next();
1320
1334
  // Cheap early-out: only /builders/* paths can ever be gated, so skip the
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.30'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.31'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');