@bongos/core 1.19.1016 → 1.19.1018

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 (51) hide show
  1. package/.bongos-core.json +98 -48
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
  8. package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
  9. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +56 -5
  12. package/docs/api-reference.md +7 -1
  13. package/docs/copy-inventory.md +5 -3
  14. package/docs/copy-registry.json +22 -4
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +9 -0
  17. package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
  18. package/modules/hall-ui/public/people.html +11 -0
  19. package/modules/hall-ui/public/people.js +77 -11
  20. package/modules/hall-ui/public/people.states.json +1 -1
  21. package/modules/onboarding/routes/access-requests.js +31 -4
  22. package/modules/platform-identity/account-visibility.js +33 -2
  23. package/modules/platform-identity/builder-search.js +153 -0
  24. package/modules/platform-identity/hub-keys.js +51 -32
  25. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  26. package/modules/platform-identity/module.json +1 -1
  27. package/modules/platform-identity/routes/community-search.js +120 -0
  28. package/modules/platform-identity/routes/my-projects.js +33 -1
  29. package/modules/platform-identity/routes/scouting.js +14 -7
  30. package/modules/platform-identity/scouting-name-only.js +47 -0
  31. package/modules/public-landing/public/projects-floor.states.json +9 -2
  32. package/modules/public-landing/public/projects.html +69 -4
  33. package/modules/ui-design/styles/expedition/mock.html +16 -3
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/src/bongos/auth-idp.js +93 -48
  37. package/src/bongos/auth.js +2 -1
  38. package/src/module-api.js +7 -1
  39. package/tests/account_privacy_flags.mjs +4 -1
  40. package/tests/application_echo.mjs +100 -1
  41. package/tests/application_lifecycle.mjs +145 -6
  42. package/tests/card_apply_ui.mjs +297 -0
  43. package/tests/community_search_route.mjs +385 -0
  44. package/tests/effective_visibility_predicate.mjs +49 -1
  45. package/tests/idp_device_exchange.mjs +174 -0
  46. package/tests/module_api.mjs +1 -0
  47. package/tests/people_name_only.mjs +262 -0
  48. package/tests/profile_route.mjs +23 -5
  49. package/tests/recruiter_sliver_boundary.mjs +3 -0
  50. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  51. package/tests/ui_design_styles.mjs +13 -0
@@ -0,0 +1,297 @@
1
+ // tests/card_apply_ui.mjs — the "Apply to join" affordance on the map's
2
+ // project card, EXECUTED (BV1.R10, task 1002286, goal 1000110).
3
+ //
4
+ // The idiom is tests/project_invite_ui.mjs beside it (there is no jsdom in
5
+ // this repo): lift the region out of modules/public-landing/public/projects.html,
6
+ // run it in a vm under a minimal DOM/fetch stub, and drive it directly —
7
+ // rather than reading the source and hoping. cardApplyHtml/submitApply are
8
+ // deliberately small and depend on only a handful of free variables
9
+ // (isMember, hostOf, appliedOrigins, me, $, fetch, jsonOrEmpty, apiError,
10
+ // signInAgain), all supplied by the sandbox the way project_invite_ui.mjs
11
+ // supplies esc()/jsonOrEmpty()/apiError() rather than slicing them too.
12
+ //
13
+ // What it proves, and why each matters:
14
+ // • the button is invisible for the platform's own card, for a member (the
15
+ // "yours"/"you build here" chip already says that), and for a signed-out
16
+ // visitor (there is no session to file the request under) — C6's rule
17
+ // applied to a fourth region
18
+ // • Apply sends exactly { origin } on the cookie session, to the join relay
19
+ // R08 already built (POST /my-projects/join)
20
+ // • only `status === 'requested'` moves the button to Applied and marks the
21
+ // origin applied; every other outcome the relay can give (already_member,
22
+ // invite_only, open_enrollment, members_only, not_published) is shown as
23
+ // the DOOR'S OWN WORDS and leaves the button re-tryable — R08's relay
24
+ // invented this vocabulary once already; the card must not invent a
25
+ // second one
26
+ // • a refusal, an expired session and an unreachable platform are each said
27
+ // honestly and never mistaken for a filed application
28
+ // • loadMemberships() reads the `applied` echo GET /my-projects returns
29
+ // (R08, ADR 0201 D1.3) into appliedOrigins, by host, the same way it
30
+ // already reads `owned`/`building` into mineOrigins
31
+ //
32
+ // Run: node --test --test-reporter=tap tests/card_apply_ui.mjs
33
+
34
+ import test from 'node:test';
35
+ import assert from 'node:assert/strict';
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import vm from 'node:vm';
39
+ import { fileURLToPath } from 'node:url';
40
+
41
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
43
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
44
+ const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
45
+
46
+ // ── the card-apply region ────────────────────────────────────────────────
47
+ const START = SCRIPT.indexOf('/* ══ APPLY TO JOIN (R10, task 1002286)');
48
+ const END = SCRIPT.indexOf('function openCard(p, rel, returnTo) {');
49
+ assert.ok(START > 0, 'the region must start at the APPLY TO JOIN banner');
50
+ assert.ok(END > START, 'the region must end before openCard()');
51
+ const REGION = SCRIPT.slice(START, END);
52
+ for (const fn of ['function cardApplyHtml(', 'function submitApply(']) {
53
+ assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
54
+ }
55
+
56
+ function boot({ me = { rank: 'thetes' }, appliedOrigins = {}, answers = [] } = {}) {
57
+ const requests = [];
58
+ const els = new Map();
59
+ const makeEl = (id) => ({
60
+ _id: id, textContent: '', className: '', _html: '', disabled: false,
61
+ set innerHTML(v) { this._html = String(v); },
62
+ get innerHTML() { return this._html; },
63
+ });
64
+ const $ = (id) => {
65
+ if (id == null) return null;
66
+ if (!els.has(id)) els.set(id, makeEl(id));
67
+ return els.get(id);
68
+ };
69
+
70
+ const queued = answers.slice();
71
+ const fetch = (url, init = {}) => {
72
+ requests.push({ url, method: init.method, body: init.body, credentials: init.credentials, headers: init.headers });
73
+ const a = queued.shift() || { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Requested.' } };
74
+ if (a.reject) return Promise.reject(new Error('offline'));
75
+ return Promise.resolve({ ok: a.ok, status: a.status, json: () => Promise.resolve(a.body) });
76
+ };
77
+
78
+ // isMember/hostOf mirror the real page's own trivial pure functions — kept
79
+ // here rather than sliced in, the project_invite_ui.mjs precedent for a
80
+ // dependency simple enough that a second, hand-written copy cannot drift.
81
+ const sandbox = {
82
+ $, fetch, console, Promise, JSON, String, Object,
83
+ me, appliedOrigins,
84
+ API: '/api/bongos',
85
+ isMember: (rel) => rel === 'owner' || rel === 'builder',
86
+ hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
87
+ jsonOrEmpty: (res) => res.json().catch(() => ({})),
88
+ apiError: (data, status) => ({
89
+ code: (data && data.error && data.error.code) || ('http_' + status),
90
+ message: (data && data.error && data.error.message) || '',
91
+ }),
92
+ signInAgain: () => 'Your session expired — <a href="/in">sign in again</a>.',
93
+ };
94
+ sandbox.globalThis = sandbox;
95
+ vm.createContext(sandbox);
96
+ vm.runInContext(REGION, sandbox, { filename: 'projects.html#card-apply' });
97
+
98
+ const settle = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
99
+ return { requests, sandbox, settle, $, msg: () => $('cardApplyMsg') };
100
+ }
101
+
102
+ // ── cardApplyHtml: who sees the button at all ───────────────────────────
103
+
104
+ test('the platform\'s own card never offers Apply', () => {
105
+ const { sandbox } = boot();
106
+ const html = sandbox.cardApplyHtml({ isSelf: true, origin: 'https://x.example' }, 'none');
107
+ assert.equal(html, '');
108
+ });
109
+
110
+ test('a member sees nothing — the relationship chip already says so', () => {
111
+ const { sandbox } = boot();
112
+ for (const rel of ['owner', 'builder']) {
113
+ assert.equal(sandbox.cardApplyHtml({ origin: 'https://x.example' }, rel), '', rel);
114
+ }
115
+ });
116
+
117
+ test('a signed-out visitor sees nothing — there is no session to apply under', () => {
118
+ const { sandbox } = boot({ me: null });
119
+ assert.equal(sandbox.cardApplyHtml({ origin: 'https://x.example' }, 'none'), '');
120
+ });
121
+
122
+ test('a signed-in visitor with no filed application gets an enabled Apply button', () => {
123
+ const { sandbox } = boot();
124
+ const html = sandbox.cardApplyHtml({ origin: 'https://x.example' }, 'none');
125
+ assert.match(html, /id="cardApply"/);
126
+ assert.doesNotMatch(html, /disabled/);
127
+ assert.match(html, />Apply to join</);
128
+ });
129
+
130
+ test('an already-applied origin renders Applied, disabled — the echo, by host', () => {
131
+ const { sandbox } = boot({ appliedOrigins: { 'x.example': true } });
132
+ const html = sandbox.cardApplyHtml({ origin: 'https://x.example/' }, 'none');
133
+ assert.match(html, /id="cardApply" disabled/);
134
+ assert.match(html, />Applied</);
135
+ });
136
+
137
+ // ── submitApply: the request itself ─────────────────────────────────────
138
+
139
+ test('Apply sends exactly { origin } to the join relay, on the cookie session', async () => {
140
+ const ui = boot();
141
+ const btn = { textContent: '', disabled: false };
142
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
143
+ await ui.settle();
144
+
145
+ assert.equal(ui.requests.length, 1);
146
+ const req = ui.requests[0];
147
+ assert.equal(req.url, '/api/bongos/my-projects/join');
148
+ assert.equal(req.method, 'POST');
149
+ assert.equal(req.credentials, 'same-origin');
150
+ assert.deepEqual(JSON.parse(req.body), { origin: 'https://mercury.example' });
151
+ });
152
+
153
+ test('a filed application moves the button to Applied and marks the origin', async () => {
154
+ const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Requested access.' } }] });
155
+ const btn = { textContent: '', disabled: false };
156
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
157
+ await ui.settle();
158
+
159
+ assert.equal(btn.textContent, 'Applied');
160
+ assert.equal(btn.disabled, true, 'a filed application does not re-offer the click');
161
+ assert.equal(ui.sandbox.appliedOrigins['mercury.example'], true);
162
+ assert.equal(ui.msg().className, 'projMsg ok');
163
+ assert.equal(ui.msg().textContent, 'Requested access.');
164
+ });
165
+
166
+ test('already_member is the door\'s own answer, not a filed application', async () => {
167
+ const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'already_member', message: 'You already build on this project — sign in to it to see it here.' } }] });
168
+ const btn = { textContent: '', disabled: false };
169
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
170
+ await ui.settle();
171
+
172
+ assert.equal(btn.textContent, 'Apply to join', 're-tryable — this was not an application');
173
+ assert.equal(btn.disabled, false);
174
+ assert.equal(ui.sandbox.appliedOrigins['mercury.example'], undefined, 'not marked applied');
175
+ assert.equal(ui.msg().className, 'projMsg', 'the door\'s own answer, not an error');
176
+ assert.match(ui.msg().textContent, /already build on this project/);
177
+ });
178
+
179
+ test('invite_only is said in the door\'s own words, and the button stays re-tryable', async () => {
180
+ const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'invite_only', message: 'This project is invite-only — it doesn’t take requests.' } }] });
181
+ const btn = { textContent: '', disabled: false };
182
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
183
+ await ui.settle();
184
+
185
+ assert.equal(btn.disabled, false);
186
+ assert.match(ui.msg().textContent, /invite-only/);
187
+ });
188
+
189
+ test('a real refusal (e.g. the project is not yet on the map) is shown as an error, via apiError', async () => {
190
+ const ui = boot({ answers: [{ ok: false, status: 404, body: { error: { code: 'unknown_project', message: 'no such project' } } }] });
191
+ const btn = { textContent: '', disabled: false };
192
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
193
+ await ui.settle();
194
+
195
+ assert.equal(btn.textContent, 'Apply to join');
196
+ assert.equal(btn.disabled, false);
197
+ assert.equal(ui.msg().className, 'projMsg err');
198
+ assert.equal(ui.msg().textContent, 'no such project');
199
+ });
200
+
201
+ test('an expired session offers a way back in rather than a silent failure', async () => {
202
+ const ui = boot({ answers: [{ ok: false, status: 401, body: {} }] });
203
+ const btn = { textContent: '', disabled: false };
204
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
205
+ await ui.settle();
206
+
207
+ assert.equal(btn.disabled, false);
208
+ assert.match(ui.msg().innerHTML, /sign in again/i);
209
+ assert.equal(ui.msg().className, 'projMsg err');
210
+ });
211
+
212
+ test('an unreachable platform is said as a network problem, and the click is re-tryable', async () => {
213
+ const ui = boot({ answers: [{ reject: true }] });
214
+ const btn = { textContent: '', disabled: false };
215
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
216
+ await ui.settle();
217
+
218
+ assert.equal(btn.disabled, false);
219
+ assert.equal(ui.msg().className, 'projMsg err');
220
+ assert.match(ui.msg().textContent, /reach the platform/i);
221
+ });
222
+
223
+ test('the button disables and reads Applying… the instant it is pressed', () => {
224
+ const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'requested', message: 'ok' } }] });
225
+ const btn = { textContent: '', disabled: false };
226
+ ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
227
+ assert.equal(btn.disabled, true, 'disabled synchronously — no double-send on a fast second click');
228
+ assert.equal(btn.textContent, 'Applying…');
229
+ });
230
+
231
+ // ── loadMemberships: the applied echo, by host ───────────────────────────
232
+
233
+ test('loadMemberships reads GET /my-projects\' applied echo into appliedOrigins, by host', () => {
234
+ const START2 = SCRIPT.indexOf('function loadMemberships() {');
235
+ const END2 = SCRIPT.indexOf('\n }', START2) + 4;
236
+ assert.ok(START2 > 0 && END2 > START2, 'loadMemberships must be findable');
237
+ const region = SCRIPT.slice(START2, END2);
238
+
239
+ const sandbox = {
240
+ API: '/api/bongos',
241
+ mineOrigins: {},
242
+ appliedOrigins: {},
243
+ hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
244
+ fetch: () => Promise.resolve({
245
+ ok: true,
246
+ json: () => Promise.resolve({
247
+ owned: [{ origin: 'https://owned.example' }],
248
+ building: [{ origin: 'https://built.example' }],
249
+ applied: [{ origin: 'https://applied.example/', applied_at: '2026-09-01T00:00:00Z' }],
250
+ }),
251
+ }),
252
+ };
253
+ sandbox.globalThis = sandbox;
254
+ vm.createContext(sandbox);
255
+ vm.runInContext(region, sandbox, { filename: 'projects.html#load-memberships' });
256
+
257
+ return sandbox.loadMemberships().then(() => {
258
+ // The region reassigns mineOrigins/appliedOrigins to a fresh `{}` it
259
+ // creates INSIDE the vm's own realm, so Object.assign re-parents a copy
260
+ // to this realm before comparing — a cross-realm deepStrictEqual reads
261
+ // an identically-shaped object as unequal over its prototype alone.
262
+ assert.deepEqual(Object.assign({}, sandbox.mineOrigins), { 'owned.example': 'owner', 'built.example': 'builder' });
263
+ assert.deepEqual(Object.assign({}, sandbox.appliedOrigins), { 'applied.example': true });
264
+ });
265
+ });
266
+
267
+ test('a fetch failure clears appliedOrigins too, not just mineOrigins', () => {
268
+ const START2 = SCRIPT.indexOf('function loadMemberships() {');
269
+ const END2 = SCRIPT.indexOf('\n }', START2) + 4;
270
+ const region = SCRIPT.slice(START2, END2);
271
+ const sandbox = {
272
+ API: '/api/bongos',
273
+ mineOrigins: { stale: 'owner' },
274
+ appliedOrigins: { stale: true },
275
+ hostOf: (o) => String(o || ''),
276
+ fetch: () => Promise.reject(new Error('offline')),
277
+ };
278
+ sandbox.globalThis = sandbox;
279
+ vm.createContext(sandbox);
280
+ vm.runInContext(region, sandbox, { filename: 'projects.html#load-memberships-fail' });
281
+
282
+ return sandbox.loadMemberships().then(() => {
283
+ assert.deepEqual(Object.assign({}, sandbox.mineOrigins), {});
284
+ assert.deepEqual(Object.assign({}, sandbox.appliedOrigins), {});
285
+ });
286
+ });
287
+
288
+ // ── the card is actually wired to it (source pins) ───────────────────────
289
+
290
+ test('openCard builds the Apply button from cardApplyHtml and wires its click to submitApply', () => {
291
+ assert.match(SCRIPT, /var applyBtn = cardApplyHtml\(p, rel\);/);
292
+ assert.match(SCRIPT, /\) \+ applyBtn \+ \(applyBtn \? '<p class="projMsg" id="cardApplyMsg"><\/p>' : ''\);/,
293
+ 'the message line only ships when there is a button that could produce one');
294
+ assert.match(SCRIPT, /var applyEl = \$\('cardApply'\);/);
295
+ assert.match(SCRIPT, /if \(applyEl && !applyEl\.disabled\) applyEl\.addEventListener\('click', function \(\) \{ submitApply\(applyEl, p\); \}\);/,
296
+ 'an already-Applied card (disabled) is never re-wired to send a second request');
297
+ });