@bongos/core 1.19.971 → 1.19.973

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 (77) hide show
  1. package/.bongos-core.json +116 -76
  2. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  3. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  4. package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
  5. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/api/openapi.json +1 -1
  8. package/docs/api-reference.md +1 -1
  9. package/docs/architecture.md +6 -6
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/canonical-permissions.md +24 -24
  12. package/docs/copy-inventory.md +193 -187
  13. package/docs/copy-registry.json +320 -276
  14. package/docs/design/projects-hub-direction-v2.md +1 -1
  15. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
  16. package/docs/file-map.md +7 -7
  17. package/docs/handoff-template.md +1 -1
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/modules-contract.md +1 -1
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/packs/engineer.md +5 -5
  22. package/docs/packs/ideator.md +1 -1
  23. package/docs/recipes/README.md +1 -1
  24. package/docs/recipes/audit-replay.md +2 -2
  25. package/docs/recipes/gds-db-backup.md +3 -3
  26. package/docs/recipes/managed-settings-remote-control.md +9 -9
  27. package/docs/recipes/memory-sync-conflicts.md +2 -2
  28. package/docs/recipes/ops-gotchas.md +24 -5
  29. package/docs/recipes/overnight-builder.md +3 -3
  30. package/docs/recipes/parallel-goal-waves.md +3 -3
  31. package/docs/recipes/reranking-a-goals-board.md +2 -2
  32. package/docs/recipes/search-index-ingestion.md +1 -1
  33. package/docs/recipes/ship-and-regrade.md +2 -2
  34. package/docs/recipes/staging-environment.md +1 -1
  35. package/docs/recipes/windows-builders.md +1 -1
  36. package/modules/hall-ui/public/deploy.html +10 -5
  37. package/modules/hall-ui/public/deploy.js +199 -110
  38. package/modules/hall-ui/public/goals-page.js +6 -0
  39. package/modules/hall-ui/public/goals-render.js +16 -0
  40. package/modules/hall-ui/public/mine-lens.js +172 -0
  41. package/modules/hall-ui/public/project-settings.css +36 -0
  42. package/modules/hall-ui/public/work-summary.js +90 -0
  43. package/modules/hall-ui/public/work.css +50 -0
  44. package/modules/hall-ui/public/work.html +24 -0
  45. package/modules/hall-ui/public/work.js +38 -51
  46. package/modules/lifecycle/db-goals.js +20 -3
  47. package/modules/lifecycle/routes/goals.js +2 -2
  48. package/modules/provisioning/core-upgrade.js +61 -2
  49. package/modules/public-landing/public/contact.html +1 -1
  50. package/modules/public-landing/public/index.html +1 -1
  51. package/modules/public-landing/public/privacy.html +1 -1
  52. package/modules/public-landing/public/projects.html +1 -1
  53. package/modules/public-landing/public/terms.html +1 -1
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/scripts/gds/api-alias-caller-scan.js +47 -9
  57. package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
  58. package/scripts/gds/fitness.js +3 -0
  59. package/scripts/gds/provision-core-upgrade.js +132 -1
  60. package/scripts/gds/run-unit-tests.js +14 -9
  61. package/scripts/gds/scope-roster-guard.js +118 -0
  62. package/scripts/hall-preview/server.js +5 -0
  63. package/src/bongos/module-scope-map.js +1 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/api_alias_caller_ratchet.mjs +68 -2
  66. package/tests/core_upgrade_door.mjs +81 -0
  67. package/tests/core_upgrade_runner.mjs +132 -0
  68. package/tests/deploy_page_projects.mjs +234 -79
  69. package/tests/fitness_scope_roster.mjs +117 -0
  70. package/tests/goal_mine_lens.mjs +233 -0
  71. package/tests/goal_tier.mjs +30 -4
  72. package/tests/hall_mine_lens.mjs +381 -0
  73. package/tests/idea_spark_hall.mjs +101 -12
  74. package/tests/known_failing_ratchet.mjs +19 -9
  75. package/tests/landing_legal_pages.mjs +5 -1
  76. package/tests/projects_hub_pre_uat.mjs +12 -6
  77. package/tests/served_paths_shipped.mjs +36 -2
@@ -0,0 +1,233 @@
1
+ // tests/goal_mine_lens.mjs — the goal board's half of the hall's "mine" lens:
2
+ // the goals you are added on, and inside one goal, the tasks you are holding.
3
+ //
4
+ // Two things are pinned here, and both are the kind that render fine while being
5
+ // wrong:
6
+ //
7
+ // * "My goals" NOW READS THE ROW. The lens used to answer membership out of the
8
+ // per-goal DETAIL prefetch — a best-effort fan-out. When that fan-out had not
9
+ // landed, or had failed, every goal answered "not a member" and the lens
10
+ // returned an empty board: an assertion about the READER, manufactured out of
11
+ // a failed fetch. GET /goals now carries `viewer_is_member`, so the lens has
12
+ // evidence that arrives with the list it is filtering.
13
+ // * A GOAL'S TO-DO LIST CAN BE NARROWED TO YOUR OWN CLAIMS, which is the board's
14
+ // "Only mine" question scoped to one goal.
15
+ //
16
+ // Executes the REAL goals-page.js + goals-render.js in a vm against the stub DOM
17
+ // this repo uses in place of jsdom — the tests/goal_criterion_confirm_ui.mjs
18
+ // precedent, and for its stated reason: it is the only way a renderer bug is
19
+ // caught rather than a source string.
20
+ //
21
+ // Run: node --test tests/goal_mine_lens.mjs
22
+
23
+ import { test } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import vm from 'node:vm';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const hall = (f) => fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8');
32
+ const TASK_LIB_SRC = hall('task-lib.js');
33
+ const GOALS_LIB_SRC = hall('goals-lib.js');
34
+ // goal-forest-lib.js is loaded so the detail view's dependency forest does not
35
+ // throw on the #/goal/N tests; nothing here asserts on it. It is a UMD module
36
+ // that attaches to the sandbox global rather than to `window`, so it is bridged
37
+ // across by hand after it runs.
38
+ const GOAL_FOREST_LIB_SRC = hall('goal-forest-lib.js');
39
+ const GOAL_FOREST_RENDER_SRC = hall('goal-forest-render.js');
40
+ const GOALS_RENDER_SRC = hall('goals-render.js');
41
+ const GOALS_PAGE_SRC = hall('goals-page.js');
42
+
43
+ const ME_ID = 1;
44
+
45
+ // Boot goals-page.js.
46
+ // `filters` — control values seeded BEFORE boot, so the first render already
47
+ // runs under the lens (the stub filter bar fires no onChange).
48
+ // `detailsFail` — the per-goal detail prefetch rejects, which is the exact
49
+ // state that used to empty the "My goals" board.
50
+ async function boot({
51
+ hash = '',
52
+ goals = [],
53
+ tasks = [],
54
+ activeClaims = [],
55
+ filters = {},
56
+ detailsFail = false,
57
+ } = {}) {
58
+ const requests = [];
59
+ const els = new Map();
60
+ const makeEl = (key) => ({
61
+ _key: key, dataset: {}, style: {}, title: '', textContent: '', hidden: false,
62
+ disabled: false, value: '', innerHTML: '',
63
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
64
+ setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
65
+ appendChild() {}, focus() {}, click() {}, querySelectorAll() { return []; },
66
+ addEventListener() {},
67
+ });
68
+ const $ = (sel) => {
69
+ if (!sel) return null;
70
+ if (!els.has(sel)) els.set(sel, makeEl(sel));
71
+ return els.get(sel);
72
+ };
73
+ // Seed the lens controls before anything reads them.
74
+ for (const [id, value] of Object.entries(filters)) $(`#${id}`).value = value;
75
+
76
+ const api = {
77
+ request(method, p) {
78
+ requests.push([method, p]);
79
+ const reply = (data, status = 200) => Promise.resolve({ status, ok: true, data });
80
+ if (method === 'GET' && /\/me$/.test(p)) return reply({ builder: { id: ME_ID, rank: 'metic' }, active_claims: [] });
81
+ if (method === 'GET' && /\/goals$/.test(p)) return reply({ goals });
82
+ if (method === 'GET' && /\/goals\/\d+$/.test(p)) {
83
+ if (detailsFail) return Promise.resolve({ status: 500, ok: false, data: null });
84
+ const id = Number(p.match(/\/goals\/(\d+)$/)[1]);
85
+ const goal = goals.find((g) => Number(g.id) === id) || goals[0];
86
+ return reply({ goal, members: [], criteria: [], dependencies: [] });
87
+ }
88
+ if (method === 'GET' && /\/tasks\/claimable/.test(p)) return reply({ claimable: [], active_claims: activeClaims });
89
+ if (method === 'GET' && /\/tasks\?/.test(p)) return reply({ tasks });
90
+ if (method === 'GET' && /\/conflicts$/.test(p)) return reply({ pairs: [] });
91
+ if (method === 'GET' && /\/requests$/.test(p)) return reply({ requests: [] });
92
+ return reply({});
93
+ },
94
+ };
95
+
96
+ const windowObj = {
97
+ OTB: {
98
+ roleAxisLabel: () => 'Role',
99
+ roleLabel: (d) => String(d || ''),
100
+ roleOptions: () => [{ value: '', label: '— any —' }],
101
+ roleIsUnset: () => true,
102
+ ROLE_UNSET: 'unclassified',
103
+ KIND_UNSET: 'unclassified',
104
+ kindIsUnset: () => true,
105
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])),
106
+ $, fmtNum: (n) => String(n), fmtDate: () => '', rankLabel: (r) => r,
107
+ toast() {}, categoryChip: () => '', fillKindFilter() {},
108
+ },
109
+ OTBKit: {
110
+ makePager: (prefix) => ({ prefix, page: 1, perPage: 50 }),
111
+ paginate: (rows) => rows,
112
+ wirePager() {}, pagerBarHtml: () => '',
113
+ makeTabs: () => ({ setCount() {}, select() {}, activate() {} }),
114
+ makeFilterBar() {},
115
+ emptyStateHtml: (m) => `<div class="kit-empty">${m}</div>`,
116
+ skeletonRows: () => '',
117
+ ageChipHtml: () => '',
118
+ },
119
+ OTBBranding: { currencyLabel: () => 'credits', taskSettled: () => false, taskCreditValue: () => 0 },
120
+ OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
121
+ BongosClient: { createClient: () => api },
122
+ location: { hash, pathname: '/builders/goals', search: '', assign() {} },
123
+ addEventListener() {},
124
+ scrollTo() {},
125
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
126
+ localStorage: { getItem() { return null; }, setItem() {} },
127
+ };
128
+ const documentObj = {
129
+ body: { dataset: {} },
130
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
131
+ getElementById: (id) => $(`#${id}`),
132
+ querySelector: (sel) => $(sel),
133
+ querySelectorAll: () => [],
134
+ createElement: () => makeEl('created'),
135
+ addEventListener() {},
136
+ };
137
+
138
+ const sandbox = {
139
+ window: windowObj, document: documentObj, location: windowObj.location,
140
+ console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object,
141
+ JSON, Math, encodeURIComponent, Promise, Date, URLSearchParams,
142
+ };
143
+ sandbox.globalThis = sandbox;
144
+ vm.createContext(sandbox);
145
+ vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
146
+ vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
147
+ vm.runInContext(GOAL_FOREST_LIB_SRC, sandbox, { filename: 'goal-forest-lib.js' });
148
+ windowObj.OTBGoalForestLib = sandbox.OTBGoalForestLib;
149
+ vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
150
+ vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
151
+ vm.runInContext(GOALS_PAGE_SRC, sandbox, { filename: 'goals-page.js' });
152
+ for (let i = 0; i < 30; i += 1) await new Promise((r) => setTimeout(r, 0));
153
+
154
+ return { requests, el: (sel) => $(sel) };
155
+ }
156
+
157
+ const GOALS = [
158
+ { id: 11, version_id: 'V1', title: 'Goal I am on', status: 'open', visibility: 'public', created_by: 999, scope_modules: [], viewer_is_member: true },
159
+ { id: 12, version_id: 'V1', title: 'Goal I am not on', status: 'open', visibility: 'public', created_by: 999, scope_modules: [], viewer_is_member: false },
160
+ ];
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // 1. "My goals" — the goals you are added on
164
+ // ---------------------------------------------------------------------------
165
+
166
+ test('the goal board shows both goals with the lens off', async () => {
167
+ const { el } = await boot({ goals: GOALS });
168
+ const grid = el('#goal-grid').innerHTML;
169
+ assert.match(grid, /Goal I am on/);
170
+ assert.match(grid, /Goal I am not on/);
171
+ });
172
+
173
+ test('"My goals" keeps the goals the viewer is a member of and drops the rest', async () => {
174
+ const { el } = await boot({ goals: GOALS, filters: { 'f-goal-mine': 'mine' } });
175
+ const grid = el('#goal-grid').innerHTML;
176
+ assert.match(grid, /Goal I am on/);
177
+ assert.doesNotMatch(grid, /Goal I am not on/);
178
+ });
179
+
180
+ // The regression this change exists for. Before `viewer_is_member`, membership
181
+ // was read from the per-goal detail — so a failed prefetch answered "you are on
182
+ // none of them" and the lens rendered an empty board.
183
+ test('"My goals" still answers when the per-goal detail prefetch FAILS', async () => {
184
+ const { el } = await boot({ goals: GOALS, filters: { 'f-goal-mine': 'mine' }, detailsFail: true });
185
+ const grid = el('#goal-grid').innerHTML;
186
+ assert.match(grid, /Goal I am on/,
187
+ 'the row carries the membership, so a dead detail read cannot empty the lens');
188
+ assert.doesNotMatch(grid, /Goal I am not on/);
189
+ });
190
+
191
+ // A deploy whose server has not yet grown the flag must fall back rather than
192
+ // answer "no" — the boolean check is what makes the fallback reachable.
193
+ test('a goal row WITHOUT the flag falls back to the detail instead of reading as "not mine"', async () => {
194
+ const legacy = GOALS.map(({ viewer_is_member, ...g }) => g); // eslint-disable-line no-unused-vars
195
+ const { el } = await boot({ goals: legacy, filters: { 'f-goal-mine': 'mine' } });
196
+ // The stub's detail returns members: [], so nothing matches — the point is that
197
+ // the page consulted the detail at all rather than short-circuiting on a
198
+ // missing field, and that it did not crash.
199
+ assert.ok(el('#goal-grid').innerHTML.length > 0, 'the board still renders');
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // 2. Inside one goal — the tasks you have claimed
204
+ // ---------------------------------------------------------------------------
205
+
206
+ const GOAL_TASKS = [
207
+ { id: 301, title: 'held by me', status: 'active', goal_id: 11, touches: [] },
208
+ { id: 302, title: 'held by someone else', status: 'active', goal_id: 11, touches: [] },
209
+ { id: 303, title: 'nobody has claimed this', status: 'ready', goal_id: 11, touches: [] },
210
+ ];
211
+ const GOAL_CLAIMS = [
212
+ { claim_id: 1, task_id: 301, builder_id: ME_ID, github_login: 'me', display_name: 'Me' },
213
+ { claim_id: 2, task_id: 302, builder_id: 999, github_login: 'them', display_name: 'Them' },
214
+ ];
215
+
216
+ test("a goal's To-do list shows every open task with the lens off", async () => {
217
+ const { el } = await boot({ hash: '#/goal/11', goals: GOALS, tasks: GOAL_TASKS, activeClaims: GOAL_CLAIMS });
218
+ const list = el('#goal-todo-list').innerHTML;
219
+ for (const title of ['held by me', 'held by someone else', 'nobody has claimed this']) {
220
+ assert.match(list, new RegExp(title), `${title} is there unfiltered`);
221
+ }
222
+ });
223
+
224
+ test('"claimed by me" narrows a goal\'s To-do list to the viewer\'s own claims', async () => {
225
+ const { el } = await boot({
226
+ hash: '#/goal/11', goals: GOALS, tasks: GOAL_TASKS, activeClaims: GOAL_CLAIMS,
227
+ filters: { 'f-goal-claimed': 'mine' },
228
+ });
229
+ const list = el('#goal-todo-list').innerHTML;
230
+ assert.match(list, /held by me/);
231
+ assert.doesNotMatch(list, /held by someone else/, "another builder's claim is not mine");
232
+ assert.doesNotMatch(list, /nobody has claimed this/, 'an unclaimed task is nobody\'s');
233
+ });
@@ -116,14 +116,15 @@ await test('getGoal: unknown id returns null (not undefined)', async () => {
116
116
  // =========================================================================
117
117
  // listGoals
118
118
  // =========================================================================
119
- await test('listGoals: no args binds [null, null, null] (all three filters off)', async () => {
119
+ await test('listGoals: no args binds [null, null, null, null] (every filter off, no viewer)', async () => {
120
120
  const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [GOAL_ROW] } : null));
121
121
  const out = await listGoals(undefined, { pool });
122
122
  assert.deepEqual(out, [GOAL_ROW]);
123
123
  const sel = find(pool.queries, /FROM goals/);
124
124
  // task 1002539 added an OPTIONAL limit. Postgres treats `LIMIT NULL` as unbounded,
125
125
  // so the goal board's read is byte-for-byte unchanged in behaviour.
126
- assert.deepEqual(sel.params, [null, null, null]);
126
+ // $4 is the viewer whose membership `viewer_is_member` answers; null = nobody.
127
+ assert.deepEqual(sel.params, [null, null, null, null]);
127
128
  assert.match(sel.sql, /ORDER BY version_id, sort_order, created_at, id/, 'priority-first, then stable ordering');
128
129
  assert.match(sel.sql, /LIMIT \$3::int/, 'the limit is bound, never interpolated');
129
130
  });
@@ -131,13 +132,38 @@ await test('listGoals: no args binds [null, null, null] (all three filters off)'
131
132
  await test('listGoals: versionId + status flow through to the bound params', async () => {
132
133
  const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
133
134
  await listGoals({ versionId: 'BONGOS-V1', status: 'open' }, { pool });
134
- assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', null]);
135
+ assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', null, null]);
135
136
  });
136
137
 
137
138
  await test('listGoals: an explicit limit is bound so Postgres truncates, not JS', async () => {
138
139
  const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
139
140
  await listGoals({ versionId: 'BONGOS-V1', status: 'open', limit: 6 }, { pool });
140
- assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', 6]);
141
+ assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', 6, null]);
142
+ });
143
+
144
+ // The "goals I am added on" half of the hall's Only-mine lens. The board asks this
145
+ // of a list it already fetches, so the answer has to ride the row rather than cost
146
+ // a membership read per goal.
147
+ await test('listGoals: a viewer binds as $4 and the row carries viewer_is_member', async () => {
148
+ const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
149
+ await listGoals({ viewerId: 77 }, { pool });
150
+ const sel = find(pool.queries, /FROM goals/);
151
+ assert.deepEqual(sel.params, [null, null, null, 77]);
152
+ assert.match(sel.sql, /AS viewer_is_member/, 'the flag is named on the wire');
153
+ assert.match(sel.sql, /EXISTS \(SELECT 1 FROM goal_members gm[\s\S]*?gm\.builder_id = \$4::bigint\)/,
154
+ 'and it is an EXISTS over goal_members for THAT builder');
155
+ });
156
+
157
+ // A NULL viewer must yield false, never NULL: the client branches on
158
+ // `typeof g.viewer_is_member === 'boolean'` to decide whether the server answered
159
+ // at all, so a three-valued column would make "nobody is reading" indistinguishable
160
+ // from "this deploy does not send the flag" and silently re-arm the empty-lens bug.
161
+ await test('listGoals: a viewer-less read still answers the flag with a BOOLEAN false', async () => {
162
+ const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
163
+ await listGoals({}, { pool });
164
+ const sel = find(pool.queries, /FROM goals/);
165
+ assert.match(sel.sql, /\(\$4::bigint IS NOT NULL\s*\n?\s*AND EXISTS/,
166
+ 'the NULL case is short-circuited to false before the EXISTS is consulted');
141
167
  });
142
168
 
143
169
  // =========================================================================