@bongos/core 1.24.2 → 1.24.4

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 (57) hide show
  1. package/.bongos-core.json +111 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/config/branding.neutral.json +0 -1
  8. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
  9. package/docs/api/openapi.json +121 -4
  10. package/docs/api-reference.md +8 -1
  11. package/docs/branding-contract.md +1 -1
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-readings.json +7 -7
  14. package/modules/discord/ship-broadcast.js +7 -0
  15. package/modules/economy/credit-wording.js +93 -0
  16. package/modules/economy/credits.js +26 -12
  17. package/modules/economy/governor-docket.js +12 -6
  18. package/modules/economy/reward-policy.js +49 -3
  19. package/modules/economy/reward.js +5 -0
  20. package/modules/government/routes/government.js +7 -0
  21. package/modules/hall-ui/public/street-inside.js +1 -1
  22. package/modules/ideas/routes/inbox.js +6 -0
  23. package/modules/lifecycle/routes/project-settings.js +6 -4
  24. package/modules/market-watch/ask.js +425 -0
  25. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  26. package/modules/market-watch/module.json +10 -3
  27. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  28. package/modules/market-watch/public/hall-widget.js +271 -0
  29. package/modules/market-watch/public/weekly-ask.css +39 -0
  30. package/modules/market-watch/routes/ask.js +95 -0
  31. package/modules/onboarding/routes/access-requests.js +38 -10
  32. package/modules/platform-identity/account-logins.js +13 -0
  33. package/modules/platform-identity/routes/my-projects.js +10 -7
  34. package/modules/platform-identity/routes/sso.js +11 -6
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +22 -0
  38. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  39. package/scripts/bongos/run-unit-tests.js +4 -0
  40. package/scripts/hall-preview/server.js +13 -0
  41. package/src/bongos/auth-idp.js +17 -5
  42. package/src/bongos/db-kernel.js +14 -1
  43. package/src/bongos/module-scope-map.js +1 -0
  44. package/src/module-api.js +1 -1
  45. package/tests/account_logins.mjs +6 -4
  46. package/tests/backfill_subagent_rewards.mjs +7 -7
  47. package/tests/credit_grant.mjs +7 -7
  48. package/tests/credit_wording.mjs +154 -0
  49. package/tests/google_halls.mjs +211 -0
  50. package/tests/governor_docket.mjs +7 -3
  51. package/tests/hall_street_inside.mjs +2 -2
  52. package/tests/market_watch_ask.mjs +369 -0
  53. package/tests/market_watch_ask_db.mjs +122 -0
  54. package/tests/market_watch_data_db.mjs +4 -0
  55. package/tests/market_watch_module.mjs +9 -5
  56. package/tests/project_settings_page.mjs +1 -1
  57. package/tests/reward_cost_scale.mjs +200 -0
@@ -0,0 +1,271 @@
1
+ // modules/market-watch/public/hall-widget.js — the weekly ask card in the hall (task 1004940).
2
+ //
3
+ // WHERE IT SHOWS. An action awaiting a member goes in the hall's "Needs your attention" card
4
+ // (goal-inbox.js), never in a new Home card (hall-ui/CLAUDE.md, Placement). So this file adds
5
+ // one SLICE to that card (#attention-market-watch), drawn as the hall's ask card (hall-ask.css
6
+ // `.hask` on an inbox row, the same shape the Blockers slice uses), and calls
7
+ // OTBAttention.refresh() so the card's count and visibility include it. The server injects
8
+ // this file only while Market Watch is enabled (src/module-loader/loader.js
9
+ // hallWidgetScripts); module off, there is no slice.
10
+ //
11
+ // No card this week, a closed week or an answered one: GET /market-watch/ask says
12
+ // { card: null } and the slice stays empty. It never reminds anyone.
13
+ //
14
+ // Design of record: docs/design/mocks/market-watch/weekly-ask.html (option A, quick-tap
15
+ // chips). Every string from the server is escaped here (window.OTB.escapeHtml) — chip
16
+ // labels come from tracked items and the team rows carry members' own words.
17
+ //
18
+ // Under Node this file is require()d for its pure parts (cardHtml, answerBody), the
19
+ // hall-ask.js precedent; nothing touches the DOM until the browser half runs.
20
+ (function () {
21
+ 'use strict';
22
+ const isNode = typeof window === 'undefined';
23
+ const esc = (s) => (isNode ? require('../../../src/module-api').escapeHtml : window.OTB.escapeHtml)(s);
24
+ const API = '/api/bongos/market-watch';
25
+ const SLICE_ID = 'attention-market-watch';
26
+ const MAX_TEXT = 80;
27
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
28
+ const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
29
+
30
+ // "monday 3 nov", the mock's way of naming the next ask's day.
31
+ function dayName(iso) {
32
+ const d = new Date(`${String(iso).slice(0, 10)}T00:00:00Z`);
33
+ if (Number.isNaN(d.getTime())) return '';
34
+ return `${DAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
35
+ }
36
+
37
+ const ADD = {
38
+ rival: { chip: '+ name one', placeholder: 'e.g. Kilo Code', aria: 'name a competitor' },
39
+ watch: { chip: '+ a topic', placeholder: 'e.g. EU AI Act rules', aria: 'name a topic' },
40
+ };
41
+
42
+ function streakHtml(n) {
43
+ if (!(n > 0)) return '';
44
+ const bars = [0, 1, 2, 3, 4].map((i) => `<i${i < Math.min(n, 5) ? '' : ' class="off"'}></i>`).join('');
45
+ return `<span class="mwask__streak" title="${esc(`${n} week${n === 1 ? '' : 's'} in a row`)}">${bars} ${esc(`${n}-week streak`)}</span>`;
46
+ }
47
+
48
+ function chipHtml(q, o) {
49
+ return `<button class="mwask__chip" type="button" aria-pressed="false" data-mwask-q="${esc(q)}" data-mwask-choice="${esc(o.label)}">`
50
+ + `${esc(o.label)}${o.example ? ` <small>${esc(o.example)}</small>` : ''}</button>`;
51
+ }
52
+
53
+ function questionHtml(q, i) {
54
+ const add = ADD[q.key] || ADD.watch;
55
+ const head = i === 0
56
+ ? `<h4 class="hask__q">${esc(q.prompt)}</h4><p class="hask__said">Tap any that fit. Each tap becomes a proposal the owner sees.</p>`
57
+ : `<p class="mwask__sub">${esc(`and ${q.prompt}`)}</p>`;
58
+ return head
59
+ + `<div class="mwask__chips" data-mwask-group="${esc(q.key)}">${(q.options || []).map((o) => chipHtml(q.key, o)).join('')}`
60
+ + `<button class="mwask__chip mwask__chip--add" type="button" data-mwask-add="${esc(q.key)}">${esc(add.chip)}</button></div>`
61
+ + `<div class="mwask__add" data-mwask-addrow="${esc(q.key)}" hidden><input type="text" maxlength="${MAX_TEXT}" placeholder="${esc(add.placeholder)}" aria-label="${esc(add.aria)}">`
62
+ + `<button class="btn-ghost" type="button" data-mwask-ok="${esc(q.key)}">add</button></div>`;
63
+ }
64
+
65
+ // A decided proposal's pill, in words, never the status column itself.
66
+ const DECIDED = { approved: 'approved', applied: 'now watched' };
67
+
68
+ function rowHtml(r) {
69
+ const decided = r.status !== 'open';
70
+ const dot = decided ? ' mwask__dot--done' : (r.verb === 'Watch' ? ' mwask__dot--watch' : '');
71
+ const what = `${esc(r.verb)} <b>${esc(r.name)}</b>${r.tail ? ` ${esc(r.tail)}` : ''}`;
72
+ const pill = decided
73
+ ? `<span class="mwask__pill">${esc(DECIDED[r.status] || 'decided')}</span>`
74
+ : `<span class="mwask__pill${r.agree >= 2 ? ' mwask__pill--hot' : ''}">${esc(`${r.agree} agree`)}</span>`;
75
+ return `<li class="mwask__row"><span class="mwask__dot${dot}" aria-hidden="true"></span><span class="mwask__what">${what}</span>`
76
+ + (r.who ? `<span class="mwask__who">${esc(r.who)}</span>` : '') + `${pill}</li>`;
77
+ }
78
+
79
+ // cardHtml(card, human) → the slice's markup. `human` is the HUMAN DECISION badge markup
80
+ // (OTBAsk.human), already safe; '' draws the plain dot.
81
+ function cardHtml(card, human = '') {
82
+ if (!card || !Array.isArray(card.questions) || !card.questions.length) return '';
83
+ const where = `market watch · week ${Number(card.week_no) || ''} · about 30 seconds`;
84
+ return '<h3 class="ginbox__sec-h">This week\'s market ask <span class="ginbox__sub">— about 30 seconds</span></h3>'
85
+ + '<ul class="ginbox">'
86
+ + `<li class="hask ginbox__item mwask" data-mwask data-mwask-week="${esc(card.week_of)}" data-mwask-streak="${Number(card.streak) || 0}" data-mwask-next="${esc(dayName(card.next_on))}">`
87
+ + `<div class="hask__where">${human || '<span class="hask__dot" aria-hidden="true"></span>'}<span class="hask__where-t">${esc(where)}</span><span class="mwask__sp"></span>${streakHtml(Number(card.streak) || 0)}</div>`
88
+ + '<div class="mwask__body">'
89
+ + card.questions.map(questionHtml).join('')
90
+ + '<p class="mwask__err" role="alert"></p>'
91
+ + '<div class="hask__opts hask__opts--row">'
92
+ + '<button class="hask__opt hask__opt--rec" type="button" data-mwask-send><span class="hask__label">Pick at least one</span><span class="hask__result">or skip below</span></button>'
93
+ + '<button class="hask__opt" type="button" data-mwask-nothing><span class="hask__label">Nothing new this week</span><span class="hask__result">keeps your streak</span></button>'
94
+ + '</div></div>'
95
+ + '<div class="mwask__done" hidden><h4 class="hask__q" data-mwask-donet></h4><p class="hask__said" data-mwask-dones></p></div>'
96
+ + '</li></ul>'
97
+ + (card.team && card.team.length
98
+ ? `<div class="mwask__lbl">this week's proposals from the team</div><ul class="mwask__rows">${card.team.map(rowHtml).join('')}</ul>`
99
+ : '');
100
+ }
101
+
102
+ // answerBody(weekOf, pressed) → the POST body. `pressed` is [{ question, choice } | { question, text }]:
103
+ // a chip carries its label as `choice`, an entry its words as `text`. No builder id: the
104
+ // server records the caller and refuses a body that names anyone else.
105
+ function answerBody(weekOf, pressed, nothing = false) {
106
+ if (nothing) return { week_of: weekOf, nothing: true };
107
+ const picks = [];
108
+ const names = [];
109
+ for (const p of pressed || []) {
110
+ if (typeof p.text === 'string') names.push({ question: p.question, text: p.text.slice(0, MAX_TEXT) });
111
+ else picks.push({ question: p.question, choice: p.choice });
112
+ }
113
+ return { week_of: weekOf, picks, names };
114
+ }
115
+
116
+ function sendLabel(n) {
117
+ return n
118
+ ? { label: `Send ${n} proposal${n > 1 ? 's' : ''}`, result: 'the owner sees each one' }
119
+ : { label: 'Pick at least one', result: 'or skip below' };
120
+ }
121
+
122
+ if (isNode) {
123
+ module.exports = { cardHtml, answerBody, sendLabel, dayName };
124
+ return;
125
+ }
126
+
127
+ // ---- the browser half -------------------------------------------------------
128
+
129
+ if (!window.OTB) return;
130
+ const client = window.BongosClient && window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
131
+ let box = null;
132
+
133
+ function css() {
134
+ if (document.querySelector('link[data-mwask-css]')) return;
135
+ const l = document.createElement('link');
136
+ l.rel = 'stylesheet';
137
+ l.href = '/modules/market-watch/public/weekly-ask.css';
138
+ l.setAttribute('data-mwask-css', '1');
139
+ document.head.appendChild(l);
140
+ }
141
+
142
+ const refresh = () => { if (window.OTBAttention) window.OTBAttention.refresh(); };
143
+
144
+ function pressedIn(li) {
145
+ return [...li.querySelectorAll('.mwask__chip[aria-pressed="true"]')].map((c) => (c.hasAttribute('data-mwask-free')
146
+ ? { question: c.getAttribute('data-mwask-q'), text: c.getAttribute('data-mwask-free') }
147
+ : { question: c.getAttribute('data-mwask-q'), choice: c.getAttribute('data-mwask-choice') }));
148
+ }
149
+
150
+ function recount(li) {
151
+ const s = sendLabel(pressedIn(li).length);
152
+ const btn = li.querySelector('[data-mwask-send]');
153
+ btn.querySelector('.hask__label').textContent = s.label;
154
+ btn.querySelector('.hask__result').textContent = s.result;
155
+ }
156
+
157
+ function done(li, title) {
158
+ const streak = (Number(li.getAttribute('data-mwask-streak')) || 0) + 1;
159
+ const next = li.getAttribute('data-mwask-next');
160
+ li.querySelector('.mwask__body').hidden = true;
161
+ li.querySelector('.mwask__body').style.display = 'none';
162
+ li.querySelector('[data-mwask-donet]').textContent = title;
163
+ li.querySelector('[data-mwask-dones]').textContent = `${streak}-week streak${next ? ` · next ask on ${next}` : ''}`;
164
+ li.querySelector('.mwask__done').hidden = false;
165
+ }
166
+
167
+ async function post(li, body) {
168
+ const err = li.querySelector('.mwask__err');
169
+ err.textContent = '';
170
+ li.querySelectorAll('.hask__opt').forEach((b) => { b.disabled = true; });
171
+ const res = client ? await client.request('POST', `${API}/ask/answer`, { body }) : { ok: false };
172
+ li.querySelectorAll('.hask__opt').forEach((b) => { b.disabled = false; });
173
+ if (!res.ok) {
174
+ const msg = res.data && res.data.error && res.data.error.message;
175
+ err.textContent = msg || 'Could not send that. Try again in a moment.';
176
+ return null;
177
+ }
178
+ return res.data;
179
+ }
180
+
181
+ function addEntry(li, q) {
182
+ const row = li.querySelector(`[data-mwask-addrow="${q}"]`);
183
+ const input = row && row.querySelector('input');
184
+ const v = input ? input.value.replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT) : '';
185
+ if (!v) return;
186
+ const c = document.createElement('button');
187
+ c.type = 'button';
188
+ c.className = 'mwask__chip';
189
+ c.setAttribute('aria-pressed', 'true');
190
+ c.setAttribute('data-mwask-q', q);
191
+ c.setAttribute('data-mwask-free', v);
192
+ c.textContent = v; // textContent: a member's words are never markup
193
+ const plus = li.querySelector(`[data-mwask-add="${q}"]`);
194
+ plus.parentNode.insertBefore(c, plus);
195
+ input.value = '';
196
+ row.hidden = true;
197
+ recount(li);
198
+ }
199
+
200
+ async function onClick(e) {
201
+ const li = e.target.closest('[data-mwask]');
202
+ if (!li) return;
203
+ const chip = e.target.closest('.mwask__chip:not(.mwask__chip--add)');
204
+ if (chip) { chip.setAttribute('aria-pressed', chip.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'); recount(li); return; }
205
+ const plus = e.target.closest('[data-mwask-add]');
206
+ if (plus) {
207
+ const row = li.querySelector(`[data-mwask-addrow="${plus.getAttribute('data-mwask-add')}"]`);
208
+ row.hidden = false;
209
+ row.querySelector('input').focus();
210
+ return;
211
+ }
212
+ const ok = e.target.closest('[data-mwask-ok]');
213
+ if (ok) { addEntry(li, ok.getAttribute('data-mwask-ok')); return; }
214
+ const week = li.getAttribute('data-mwask-week');
215
+ if (e.target.closest('[data-mwask-send]')) {
216
+ const pressed = pressedIn(li);
217
+ if (!pressed.length) return;
218
+ const r = await post(li, answerBody(week, pressed));
219
+ if (r) done(li, `sent. ${pressed.length} proposal${pressed.length > 1 ? 's' : ''} in the queue.`);
220
+ return;
221
+ }
222
+ if (e.target.closest('[data-mwask-nothing]')) {
223
+ const r = await post(li, answerBody(week, [], true));
224
+ if (r) done(li, 'noted. nothing new this week.');
225
+ }
226
+ }
227
+
228
+ function onKey(e) {
229
+ if (e.key !== 'Enter' || !e.target.matches('.mwask__add input')) return;
230
+ e.preventDefault();
231
+ const li = e.target.closest('[data-mwask]');
232
+ if (li) addEntry(li, e.target.closest('[data-mwask-addrow]').getAttribute('data-mwask-addrow'));
233
+ }
234
+
235
+ async function load() {
236
+ if (!client || !box) return;
237
+ const res = await client.request('GET', `${API}/ask`);
238
+ const card = res && res.ok && res.data ? res.data.card : null;
239
+ const human = window.OTBAsk && typeof window.OTBAsk.human === 'function' ? window.OTBAsk.human('decision') : '';
240
+ box.innerHTML = cardHtml(card, human);
241
+ refresh();
242
+ }
243
+
244
+ // The attention card builds its slices when the hall mounts its widgets, which is after
245
+ // this file runs (both are deferred, this one first). Wait for the container, once.
246
+ function mount(slices) {
247
+ if (box || !slices) return;
248
+ css();
249
+ box = document.createElement('div');
250
+ box.id = SLICE_ID;
251
+ // Beside the other personal asks (the profile nudge), ahead of project-wide queues.
252
+ const anchor = slices.querySelector('#attention-ideas');
253
+ slices.insertBefore(box, anchor || null);
254
+ box.addEventListener('click', onClick);
255
+ box.addEventListener('keydown', onKey);
256
+ load().catch(() => { box.innerHTML = ''; refresh(); });
257
+ }
258
+
259
+ function watch() {
260
+ const found = document.getElementById('attention-slices');
261
+ if (found) { mount(found); return; }
262
+ const mo = new MutationObserver(() => {
263
+ const s = document.getElementById('attention-slices');
264
+ if (s) { mo.disconnect(); mount(s); }
265
+ });
266
+ mo.observe(document.documentElement, { childList: true, subtree: true });
267
+ }
268
+
269
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch);
270
+ else watch();
271
+ })();
@@ -0,0 +1,39 @@
1
+ /* modules/market-watch/public/weekly-ask.css — the weekly ask card's chips (task 1004940).
2
+ The card itself is the hall's ask card (hall-ask.css `.hask`, drawn as an inbox row);
3
+ this sheet adds only what option A (docs/design/mocks/market-watch/weekly-ask.html)
4
+ has that the hall card does not: the tap-to-toggle chips, the inline add, the streak
5
+ bars and the team's proposal rows. Colours are the hall's own tokens, never the mock's. */
6
+
7
+ .mwask__sp { flex: 1; }
8
+ .mwask__streak { display: inline-flex; gap: 6px; align-items: center; flex: none; font-size: 13px; color: var(--ink-soft); white-space: nowrap; }
9
+ .mwask__streak i { width: 18px; height: 6px; border-radius: 3px; background: var(--accent); display: inline-block; }
10
+ .mwask__streak i.off { background: var(--hask-edge); }
11
+ .mwask .hask__where { flex-wrap: wrap; row-gap: 6px; }
12
+ .mwask .hask__where-t { flex: 1 1 auto; min-width: 12ch; }
13
+ .mwask__body { display: contents; }
14
+ .mwask__chips { display: flex; flex-wrap: wrap; gap: 8px; }
15
+ .mwask__chip { display: inline-block; max-width: 100%; min-height: 36px; padding: 8px 14px; box-sizing: border-box; border-radius: 999px; border: 1px solid var(--hask-edge); background: transparent; color: var(--ink); font-family: var(--font-body); font-size: 14px; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
16
+ .mwask__chip:hover { background: var(--hask-chip-pick); }
17
+ .mwask__chip small { margin-left: 4px; color: var(--ink-faint); font-size: 12px; }
18
+ .mwask__chip[aria-pressed="true"] { background: var(--accent-tint); border-color: var(--accent); }
19
+ .mwask__chip[aria-pressed="true"]::before { content: "\2713"; margin-right: 6px; color: var(--accent-ink); font-weight: 600; }
20
+ .mwask__chip--add { border-style: dashed; color: var(--ink-soft); }
21
+ .mwask__chip:focus-visible, .mwask__add input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
22
+ .mwask__sub { margin: 2px 0 -4px; font-size: 13px; color: var(--ink-soft); }
23
+ .mwask__add { display: flex; gap: 8px; }
24
+ .mwask__add[hidden] { display: none; }
25
+ .mwask__add input { flex: 1; min-width: 0; min-height: 36px; padding: 8px 14px; box-sizing: border-box; border-radius: 999px; border: 1px solid var(--hask-edge-pick); background: transparent; color: var(--ink); font: inherit; font-size: 14px; }
26
+ .mwask__done { display: flex; flex-direction: column; gap: 6px; }
27
+ .mwask__done[hidden] { display: none; }
28
+ .mwask__err { margin: 0; font-size: 13px; color: var(--danger); }
29
+ .mwask__err:empty { display: none; }
30
+ .mwask__lbl { margin: 18px 0 8px; font-size: 12px; letter-spacing: .06em; color: var(--ink-faint); }
31
+ .mwask__rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--rule-soft); }
32
+ .mwask__row { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 8px 2px; border-bottom: 1px solid var(--rule-soft); white-space: nowrap; overflow: hidden; }
33
+ .mwask__row .mwask__what { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
34
+ .mwask__row .mwask__who { color: var(--ink-soft); min-width: 0; overflow: hidden; text-overflow: ellipsis; max-width: 30%; }
35
+ .mwask__dot { flex: none; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
36
+ .mwask__dot--watch { background: var(--info); }
37
+ .mwask__dot--done { background: var(--hask-edge-pick); }
38
+ .mwask__pill { flex: none; font-size: 12px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--hask-edge); color: var(--ink-soft); white-space: nowrap; }
39
+ .mwask__pill--hot { background: var(--accent-tint); border-color: transparent; color: var(--accent-ink); }
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/routes/ask.js — the weekly ask card's two routes (task 1004940).
4
+ //
5
+ // GET /market-watch/ask the caller's card this week, or { card: null }
6
+ // POST /market-watch/ask/answer the caller's taps and entries
7
+ //
8
+ // MEMBERS ONLY, SERVER-CHECKED. requireBuilder resolves the session (a signed-out caller is
9
+ // 401, a deactivated builder 403) and the handler's gate() runs `isMember`, which admits
10
+ // exactly the active builders on the rank ladder, so a reserved or unknown rank is refused.
11
+ // No requireRank here: tests/government_require_permission.mjs refuses it under
12
+ // modules/*/routes, and no catalog permission names a module's member-only action.
13
+ //
14
+ // NOBODY ANSWERS FOR ANYONE ELSE. The answer is always recorded under req.builder.id. A body
15
+ // that names another builder (`builder_id`) is refused 403 rather than quietly re-pointed,
16
+ // so a client that tries it learns it cannot.
17
+ //
18
+ // The project is this instance's own public origin (seed.js projectKey), never the body's.
19
+
20
+ const express = require('express');
21
+ const api = require('../../../src/module-api');
22
+ const ask = require('../ask');
23
+ const { projectKey } = require('../seed');
24
+
25
+ const log = api.logger('market-watch');
26
+
27
+ function defaultProject() {
28
+ try { return projectKey(api.branding().domains.publicOrigin); } catch { return null; }
29
+ }
30
+
31
+ // isMember(builder) — an active builder on this project's rank ladder.
32
+ function isMember(builder) {
33
+ if (!builder || builder.id == null) return false;
34
+ if (builder.status === 'inactive') return false;
35
+ return api.rankMeetsThreshold(builder.rank, ['xenos']);
36
+ }
37
+
38
+ const fail = (res, status, code, message) => res.status(status).json({ error: { code, message } });
39
+
40
+ module.exports = function buildMarketWatchAskRouter(deps = {}) {
41
+ const router = express.Router();
42
+ // deps (tests only): store — a store or a function returning one; project; now.
43
+ const store = () => (typeof deps.store === 'function' ? deps.store() : deps.store) || ask.createAskStore(api.pool);
44
+ const project = () => (deps.project !== undefined ? deps.project : defaultProject());
45
+ const now = () => (typeof deps.now === 'function' ? deps.now() : new Date());
46
+
47
+ function gate(req, res) {
48
+ if (!isMember(req.builder)) { fail(res, 403, 'not_a_member', 'Only members of this project get the weekly ask. Ask the project owner to add you.'); return null; }
49
+ const p = project();
50
+ if (!p) { fail(res, 503, 'no_project_origin', 'This project has no public origin set.'); return null; }
51
+ return p;
52
+ }
53
+
54
+ // rank: any-builder — every member reads their own card; nobody else's (isMember in gate()).
55
+ router.get('/market-watch/ask', api.requireBuilder, async (req, res) => {
56
+ const p = gate(req, res);
57
+ if (!p) return;
58
+ try {
59
+ res.set('Cache-Control', 'private, no-store');
60
+ res.json({ card: await ask.cardFor(store(), { project: p, builderId: req.builder.id, now: now() }) });
61
+ } catch (e) {
62
+ log.error(`weekly ask read failed: ${e && e.message}`);
63
+ fail(res, 500, 'ask_read_failed', "Could not read this week's ask.");
64
+ }
65
+ });
66
+
67
+ // rank: any-builder — a member answers for themselves only (isMember in gate()).
68
+ router.post('/market-watch/ask/answer', api.requireBuilder, express.json({ limit: '16kb' }), async (req, res) => {
69
+ const p = gate(req, res);
70
+ if (!p) return;
71
+ const body = req.body || {};
72
+ if (body.builder_id != null && String(body.builder_id) !== String(req.builder.id)) {
73
+ fail(res, 403, 'not_your_answer', 'You can only answer for yourself.');
74
+ return;
75
+ }
76
+ // The envelope's shape, strict (an unknown field is refused). Each entry's own fields,
77
+ // lengths and the chip-must-be-offered rule are ask.js parseAnswer's and answer's.
78
+ if (api.validateOrRespond(req, res, {
79
+ week_of: { type: 'string', maxLength: 10 },
80
+ picks: { type: 'array', maxItems: 20 },
81
+ names: { type: 'array', maxItems: 6 },
82
+ nothing: { type: 'boolean' },
83
+ builder_id: { type: 'integer' },
84
+ })) return;
85
+ try {
86
+ const r = await ask.answer(store(), { project: p, builderId: req.builder.id, body, now: now() });
87
+ res.status(r.status).json(r.body);
88
+ } catch (e) {
89
+ log.error(`weekly ask answer failed: ${e && e.message}`);
90
+ fail(res, 500, 'ask_answer_failed', 'Could not save your answer.');
91
+ }
92
+ });
93
+
94
+ return router;
95
+ };
@@ -83,6 +83,16 @@ const DOOR_REFUSAL = {
83
83
  // GitHub usernames: 1–39 chars, alphanumeric or single hyphens, no leading/
84
84
  // trailing hyphen. We accept a leading '@' and strip it (people paste "@name").
85
85
  const GITHUB_LOGIN_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
86
+ // A person who joined with Google has no GitHub login; their builder row carries
87
+ // the stand-in `~<handle>` core's standInLogin writes (ADR 0396). `~` is never in
88
+ // a GitHub login, so the two shapes cannot be confused.
89
+ const STAND_IN_LOGIN_RE = /^~[a-z0-9-]{1,39}$/;
90
+
91
+ // A login as people should read it: the stand-in's handle without its `~`.
92
+ function shownLogin(login) {
93
+ const s = String(login || '');
94
+ return STAND_IN_LOGIN_RE.test(s) ? s.slice(1) : s;
95
+ }
86
96
 
87
97
  function normalizeLogin(raw) {
88
98
  return String(raw || '').trim().replace(/^@/, '');
@@ -176,6 +186,10 @@ async function attachApplicantProfiles(rows) {
176
186
  // re-invite nor keeps the hub's notice alive.
177
187
  const STANDING_INVITE_SQL = `SELECT 1 FROM access_requests
178
188
  WHERE lower(github_login) = lower($1) AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`;
189
+ // The same, for a person who joined with Google: keyed on the vouched person key,
190
+ // never the stand-in handle (task 1004994; mirrors core hasInvitedAccessRequest).
191
+ const STAND_IN_INVITE_SQL = `SELECT 1 FROM access_requests
192
+ WHERE applicant_github_id = $1 AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`;
179
193
 
180
194
  function noticeHubInvited(login) {
181
195
  api.notifyHubOfInvite(login, { invited: true });
@@ -235,8 +249,8 @@ function accessRequestDocketMatter(r) {
235
249
  id: r.id,
236
250
  rule: 'decide',
237
251
  state: 'needs',
238
- title: `Admit ${r.display_name || r.github_login} as a builder`,
239
- short: `admit ${r.github_login}`,
252
+ title: `Admit ${r.display_name || shownLogin(r.github_login)} as a builder`,
253
+ short: `admit ${shownLogin(r.github_login)}`,
240
254
  where: r.vouched ? 'people · waiting to join · vouched' : 'people · waiting to join',
241
255
  opened_at: r.created_at,
242
256
  deadline: null,
@@ -287,29 +301,42 @@ module.exports = function buildAccessRequestsRouter() {
287
301
  })) return;
288
302
 
289
303
  const login = normalizeLogin(req.body.github_login);
290
- if (!GITHUB_LOGIN_RE.test(login)) {
304
+ const standIn = STAND_IN_LOGIN_RE.test(login);
305
+ if (!standIn && !GITHUB_LOGIN_RE.test(login)) {
291
306
  return res.fail('bad_github_login', { status: 400, message: 'That does not look like a GitHub username.' });
292
307
  }
293
308
  const displayName = (req.body.display_name || '').trim() || null;
294
309
  const note = (req.body.note || '').trim() || null;
295
310
  // A verified vouch that names this login → the account the hub confirmed; anything
296
311
  // else → null, and the unvouched row this route always wrote (never a refusal,
297
- // ADR 0201 D2.3). Descriptive only: no admission path reads it (ADR 0016).
312
+ // ADR 0201 D2.3). Descriptive only for a GitHub login: no admission path reads it
313
+ // (ADR 0016).
298
314
  const vouch = api.readApplicationVouch(req.get(APPLICATION_VOUCH_HEADER), { githubLogin: login });
315
+ // A person who joined with Google applies by their stand-in `~handle`, and only
316
+ // through the hub's vouch for their person key (task 1004994): a stand-in is
317
+ // not an identity anyone may type, and their approval is matched on the key.
318
+ if (standIn && !vouch) {
319
+ return res.fail('bad_github_login', { status: 400, message: 'Apply to this project from your Cloud Bongos account.' });
320
+ }
299
321
 
300
322
  try {
301
323
  // Already a builder? They have an account — point them at sign-in instead
302
- // of opening a redundant request. (2026-05-30)
303
- const member = await pool.query(
304
- `SELECT 1 FROM builders WHERE lower(github_login) = lower($1) LIMIT 1`,
305
- [login]
306
- );
324
+ // of opening a redundant request. (2026-05-30) A stand-in is matched on the
325
+ // vouched person key, since a handle can change hands.
326
+ const member = standIn
327
+ ? await pool.query(`SELECT 1 FROM builders WHERE github_id = $1 LIMIT 1`, [vouch.githubId])
328
+ : await pool.query(`SELECT 1 FROM builders WHERE lower(github_login) = lower($1) LIMIT 1`, [login]);
307
329
  if (member.rows.length > 0) {
308
330
  return res.fail('already_member', { status: 409, message: 'You already have an account — just sign in with GitHub.' });
309
331
  }
310
332
  // Already approved (invited) but not yet signed in? No new request needed.
311
333
  // An expired invite is not an approval any more, so its invitee may ask.
312
- const invited = await pool.query(STANDING_INVITE_SQL, [login]);
334
+ // A stand-in's approval is the vouched person's, so it is found by their key —
335
+ // the same match the sign-in gate makes (hasInvitedAccessRequest) — and a
336
+ // stale approval left under a handle someone else now holds never answers.
337
+ const invited = standIn
338
+ ? await pool.query(STAND_IN_INVITE_SQL, [vouch.githubId])
339
+ : await pool.query(STANDING_INVITE_SQL, [login]);
313
340
  if (invited.rows.length > 0) {
314
341
  return res.fail('already_approved', { status: 409, message: "You're already approved — sign in with GitHub to enter." });
315
342
  }
@@ -675,5 +702,6 @@ module.exports = function buildAccessRequestsRouter() {
675
702
  };
676
703
 
677
704
  module.exports.accessRequestDocketMatter = accessRequestDocketMatter;
705
+ module.exports.shownLogin = shownLogin;
678
706
 
679
707
  module.exports.INVITE_DESK_ROW = INVITE_DESK_ROW;
@@ -58,6 +58,17 @@ function isMintedPersonKey(key) {
58
58
  return /^-\d+$/.test(String(key ?? ''));
59
59
  }
60
60
 
61
+ // A project's hall that can take a minted key says so on its authorize link
62
+ // (`person_keys=1`, added by core's idpAuthorizeUrl from task 1004994). An older
63
+ // hall would store a negative key as a GitHub id, so it is never sent one
64
+ // (ADR 0393). Boundary-forced mirror of core's PERSON_KEYS_PARAM.
65
+ const PERSON_KEYS_PARAM = 'person_keys';
66
+
67
+ // May the hub hand this person's key to the hall whose authorize query this is?
68
+ function hallTakesPersonKey(key, query) {
69
+ return !isMintedPersonKey(key) || String((query && query[PERSON_KEYS_PARAM]) ?? '') === '1';
70
+ }
71
+
61
72
  // An email as a provider vouched for it, trimmed and capped, or null. Shown to
62
73
  // the person in their own settings and nothing else.
63
74
  function cleanEmail(email) {
@@ -185,6 +196,8 @@ module.exports = {
185
196
  LOGIN_PROVIDERS,
186
197
  normalizeLogin,
187
198
  isMintedPersonKey,
199
+ PERSON_KEYS_PARAM,
200
+ hallTakesPersonKey,
188
201
  findPersonByLogin,
189
202
  createAccountForLogin,
190
203
  signInWithLogin,
@@ -77,8 +77,12 @@ const RELAY_SENTENCE = {
77
77
  // because the fall-through is a 502 "could not be reached" — the exact lie about
78
78
  // someone else's door that ADR 0194 D5 forbids.
79
79
  not_published: 'This project isn’t published yet, so it isn’t taking anyone on. Its owner has to finish setting it up first.',
80
+ // A hall on a core older than task 1004994 refuses a Google-only person's
81
+ // stand-in `~handle` as "not a GitHub username". It is the project's version,
82
+ // not the person, so say that rather than "could not be reached".
83
+ needs_update: 'This project runs an older version that can’t take a Google sign-in yet. Ask its owner to update it, or apply with GitHub.',
80
84
  };
81
- function joinRelayOutcome(status, body) {
85
+ function joinRelayOutcome(status, body, { standIn = false } = {}) {
82
86
  const err = (body && body.error) || null;
83
87
  const code = err && (err.code || err);
84
88
  let outcome;
@@ -86,6 +90,7 @@ function joinRelayOutcome(status, body) {
86
90
  else if (code === 'invite_only' || code === 'open_enrollment' || code === 'not_published') outcome = code;
87
91
  else if (status === 409) outcome = 'already_member';
88
92
  else if (status === 401) outcome = 'members_only';
93
+ else if (status === 400 && code === 'bad_github_login' && standIn) outcome = 'needs_update';
89
94
  else return null;
90
95
  return { status: outcome, message: RELAY_SENTENCE[outcome] };
91
96
  }
@@ -227,11 +232,9 @@ module.exports = function myProjectsRoutes() {
227
232
  note: { type: 'string', maxLength: APPLICANT_NOTE_MAX },
228
233
  guild: { type: 'string', maxLength: 39 },
229
234
  })) return;
230
- // A Google-only person (a minted key) cannot be relayed to a project yet:
231
- // halls learn to take one in task 1004994 (ADR 0393). Refuse before relaying.
232
- if (isMintedPersonKey(req.builder.github_id)) {
233
- return res.fail('google_join_not_yet', { status: 409, message: 'Projects can’t be joined with a Google sign-in yet. That is coming soon.' });
234
- }
235
+ // A Google-only person is relayed like anyone else, by their stand-in login
236
+ // and a vouch for their person key (task 1004994). A hall too old to take
237
+ // one answers bad_github_login, which joinRelayOutcome reads as needs_update.
235
238
  try {
236
239
  const client = await pi.getClientByOrigin(req.body.origin, { pool });
237
240
  if (!client || client.status !== 'active' || !client.origin) {
@@ -274,7 +277,7 @@ module.exports = function myProjectsRoutes() {
274
277
  }),
275
278
  });
276
279
  const body = await r.json().catch(() => null);
277
- outcome = joinRelayOutcome(r.status, body);
280
+ outcome = joinRelayOutcome(r.status, body, { standIn: isMintedPersonKey(req.builder.github_id) });
278
281
  } catch (e) {
279
282
  console.log('[platform-identity] join relay failed (non-fatal):', e.message);
280
283
  }
@@ -32,7 +32,11 @@ const guildEngagements = require('../guild-engagements');
32
32
  const instanceFeedback = require('../instance-feedback');
33
33
  const projectStats = require('../project-stats');
34
34
  // Ways in other than GitHub (task 1004993, ADR 0393/0396).
35
- const { signInWithLogin, isMintedPersonKey } = require('../account-logins');
35
+ const { signInWithLogin, hallTakesPersonKey } = require('../account-logins');
36
+
37
+ // What a Google-only person reads when a project's hall runs a core too old to
38
+ // take them (task 1004994).
39
+ const OLDER_HALL_SENTENCE = 'This project runs an older version that can’t take a Google sign-in yet. Ask its owner to update it, or sign in to it with GitHub.';
36
40
 
37
41
  // One limiter per hub process for POST /sso/feedback (task 1004462).
38
42
  const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
@@ -327,11 +331,12 @@ module.exports = function ssoRoutes() {
327
331
  : 'This session type cannot be used for cross-project sign-in.';
328
332
  return res.status(403).type('text/html').send(errorPage(msg));
329
333
  }
330
- // A person who signed up with Google has a minted key, which a project's hall
331
- // cannot take yet (ADR 0393: an older core must not be sent one). Task 1004994
332
- // opens this door; until then say so plainly instead of sending the key.
333
- if (isMintedPersonKey(session.github_id)) {
334
- return res.status(403).type('text/html').send(errorPage('Projects can’t be joined with a Google sign-in yet. That is coming soon; for now, sign in to the project with GitHub.'));
334
+ // A person who signed up with Google has a minted key. A hall takes one only
335
+ // when its authorize link says it can (`person_keys=1`, task 1004994): an older
336
+ // core would store the negative key as a GitHub id (ADR 0393). Say so plainly
337
+ // instead of sending it.
338
+ if (!hallTakesPersonKey(session.github_id, req.query)) {
339
+ return res.status(403).type('text/html').send(errorPage(OLDER_HALL_SENTENCE));
335
340
  }
336
341
 
337
342
  // A live hub session — record/refresh the platform account and mint a
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.2",
3
+ "version": "1.24.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.2",
9
+ "version": "1.24.4",
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.2",
3
+ "version": "1.24.4",
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",