@bongos/core 1.20.21 → 1.20.22

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 (44) hide show
  1. package/.bongos-core.json +67 -47
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -2
  4. package/clients/bongos-client/index.cjs +4 -2
  5. package/clients/bongos-client/index.d.ts +8 -4
  6. package/clients/bongos-client/index.mjs +4 -2
  7. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -0
  8. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +70 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +137 -72
  11. package/docs/api-reference.md +6 -5
  12. package/docs/copy-inventory.md +196 -196
  13. package/docs/copy-registry.json +250 -250
  14. package/docs/module-api-changelog.md +2 -0
  15. package/docs/page-inventory.json +2 -1
  16. package/docs/page-readings.json +310 -306
  17. package/modules/hall-ui/public/watch.js +23 -0
  18. package/modules/onboarding/routes/access-requests.js +36 -6
  19. package/modules/platform-identity/invite-notices.js +61 -0
  20. package/modules/platform-identity/module.json +1 -1
  21. package/modules/platform-identity/platform-identity.js +12 -18
  22. package/modules/platform-identity/routes/my-projects.js +6 -15
  23. package/modules/platform-identity/routes/projects.js +22 -15
  24. package/modules/platform-identity/routes/sso-invites.js +82 -0
  25. package/modules/platform-identity/tests/platform-identity.mjs +14 -11
  26. package/modules/public-landing/public/assets/cosmos.css +5 -0
  27. package/modules/public-landing/public/projects.html +35 -29
  28. package/modules/public-landing/public/projects.states.json +1 -0
  29. package/modules/ui-design/kit/serve.js +9 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +6 -0
  33. package/src/bongos/auth-admission.js +40 -0
  34. package/src/bongos/auth.js +2 -1
  35. package/src/module-api.js +8 -1
  36. package/tests/access_requests_invite.mjs +39 -1
  37. package/tests/application_lifecycle.mjs +41 -1
  38. package/tests/catalog_only_client.mjs +5 -3
  39. package/tests/hub_invite_notices.mjs +261 -0
  40. package/tests/module_api.mjs +1 -0
  41. package/tests/my_projects_surface.mjs +11 -1
  42. package/tests/project_invite_ownership.mjs +12 -7
  43. package/tests/project_invite_ui.mjs +42 -45
  44. package/tests/watch_applications_queue.mjs +33 -2
@@ -0,0 +1,261 @@
1
+ // tests/hub_invite_notices.mjs — a project's own invite reaches the hub as a NOTICE, and a
2
+ // rescind takes it back (task 1002818, R6 of goal 1000110; ADR 0353).
3
+ //
4
+ // Three halves, each DB-free:
5
+ // 1. the HUB routes (POST /sso/invites/notify|clear) — the real router, the real
6
+ // platform-identity domain functions and the real error envelope, over a recording
7
+ // fake pool, so the SQL a notice runs is the SQL asserted. What must hold: only an
8
+ // authenticated project writes, only to ITS OWN client_id, only ever a `pending` row,
9
+ // and the answer never says whether the login has a hub account.
10
+ // 2. the hub's hand-off link (hallInviteUrl) — it becomes an <a href> on the hub.
11
+ // 3. the INSTANCE sender (notifyHubOfInvite) — one login and one direction on the wire,
12
+ // the client credentials from config, and nothing it does can throw into a caller
13
+ // that never awaits it.
14
+ //
15
+ // The instance ROUTE wiring (which invite outcomes notify, which rescinds clear) is pinned
16
+ // where those routes are already executed: tests/access_requests_invite.mjs and
17
+ // tests/application_lifecycle.mjs.
18
+ //
19
+ // Run: node --test tests/hub_invite_notices.mjs
20
+
21
+ import test from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import http from 'node:http';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { createRequire } from 'node:module';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+
32
+ // ── the world: two projects, one hub account ─────────────────────────────────
33
+
34
+ const pi = require('../modules/platform-identity/platform-identity.js');
35
+ const SECRET = 'project-secret';
36
+ const CLIENTS = {
37
+ mercury: { client_id: 'mercury', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://mercury.test' },
38
+ // Suspended, and catalog-only (no secret at all): neither may speak for a project.
39
+ frozen: { client_id: 'frozen', secret_hash: pi.sha256hex(SECRET), status: 'suspended', origin: 'https://frozen.test' },
40
+ shell: { client_id: 'shell', secret_hash: '', status: 'active', origin: 'https://shell.test' },
41
+ };
42
+ const ACCOUNTS = { octo: { github_id: '4242', github_login: 'Octo' } };
43
+
44
+ let calls = [];
45
+ const pool = {
46
+ async query(text, params = []) {
47
+ const sql = String(text);
48
+ calls.push({ sql, params });
49
+ if (/FROM platform_identity_sso_clients WHERE client_id = \$1/.test(sql)) {
50
+ return { rows: CLIENTS[params[0]] ? [CLIENTS[params[0]]] : [] };
51
+ }
52
+ if (/FROM platform_identity_accounts WHERE lower\(github_login\) = lower\(\$1\)/.test(sql)) {
53
+ const a = ACCOUNTS[String(params[0]).toLowerCase()];
54
+ return { rows: a ? [a] : [] };
55
+ }
56
+ if (/^\s*INSERT INTO platform_identity_project_memberships/.test(sql)) return { rows: [{ id: '1', membership_kind: 'pending' }] };
57
+ if (/^\s*DELETE FROM platform_identity_project_memberships/.test(sql)) return { rowCount: 1, rows: [] };
58
+ throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
59
+ },
60
+ };
61
+ const writes = () => calls.filter((c) => /^\s*(INSERT|UPDATE|DELETE)/.test(c.sql));
62
+
63
+ // The doorway, with the pool swapped, BEFORE the domain file and the router load: both
64
+ // take `pool` off it at require time.
65
+ const apiPath = require.resolve('../src/module-api.js');
66
+ const realApi = require(apiPath);
67
+ require.cache[apiPath].exports = { ...realApi, pool, logger: () => ({ error() {}, warn() {}, info() {} }) };
68
+ delete require.cache[require.resolve('../modules/platform-identity/platform-identity.js')];
69
+ delete require.cache[require.resolve('../modules/platform-identity/invite-notices.js')];
70
+ const buildRoutes = require('../modules/platform-identity/routes/sso-invites.js');
71
+ const inviteNotices = require('../modules/platform-identity/invite-notices.js');
72
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
73
+
74
+ const express = require('express');
75
+ const app = express();
76
+ app.use(express.json());
77
+ app.use(attachFail);
78
+ app.use('/api/bongos', buildRoutes());
79
+ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
80
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
81
+ test.after(() => server.close());
82
+
83
+ function post(route, body) {
84
+ return new Promise((resolve, reject) => {
85
+ const payload = JSON.stringify(body);
86
+ const req = http.request(`${base}${route}`, {
87
+ method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
88
+ }, (res) => {
89
+ let data = '';
90
+ res.on('data', (c) => { data += c; });
91
+ res.on('end', () => resolve({ status: res.statusCode, body: data ? JSON.parse(data) : null }));
92
+ });
93
+ req.on('error', reject);
94
+ req.end(payload);
95
+ });
96
+ }
97
+ const notice = (route, extra = {}) => post(route, { client_id: 'mercury', client_secret: SECRET, github_login: 'octo', ...extra });
98
+
99
+ test.beforeEach(() => { calls = []; });
100
+
101
+ // ── 1. the hub routes ────────────────────────────────────────────────────────
102
+
103
+ test('notify seeds a PENDING row on the calling project, and cannot touch a real membership', async () => {
104
+ const res = await notice('/sso/invites/notify');
105
+ assert.equal(res.status, 200);
106
+ assert.deepEqual(res.body, { ok: true });
107
+ const w = writes();
108
+ assert.equal(w.length, 1);
109
+ assert.match(w[0].sql, /VALUES \(\$1, \$2, 'pending'\)/, 'a notice is a pending row, never a membership');
110
+ assert.match(w[0].sql, /ON CONFLICT \(client_id, github_id\) DO NOTHING/, 'a current member is left exactly as they are');
111
+ assert.deepEqual(w[0].params, ['mercury', '4242'], 'keyed on the authenticated client and the account the LOGIN resolves to');
112
+ });
113
+
114
+ test('clear drops ONLY a pending row on the calling project', async () => {
115
+ const res = await notice('/sso/invites/clear');
116
+ assert.equal(res.status, 200);
117
+ assert.deepEqual(res.body, { ok: true });
118
+ const w = writes();
119
+ assert.equal(w.length, 1);
120
+ assert.match(w[0].sql, /^\s*DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/,
121
+ 'a rescind can never remove someone who has signed in');
122
+ assert.deepEqual(w[0].params, ['mercury', '4242']);
123
+ });
124
+
125
+ test('a login with no hub account writes nothing — and the answer is byte-identical', async () => {
126
+ // The project must not learn from this route which GitHub logins have platform accounts.
127
+ for (const route of ['/sso/invites/notify', '/sso/invites/clear']) {
128
+ calls = [];
129
+ const known = await notice(route);
130
+ const unknown = await notice(route, { github_login: 'nobody-here' });
131
+ assert.deepEqual(unknown, known, `${route}: same status, same body`);
132
+ }
133
+ calls = [];
134
+ await notice('/sso/invites/notify', { github_login: 'nobody-here' });
135
+ assert.equal(writes().length, 0);
136
+ });
137
+
138
+ test('only an authenticated, active, credentialed project may speak — and writes nothing otherwise', async () => {
139
+ const refusals = [
140
+ { client_secret: 'wrong' },
141
+ { client_id: 'frozen' },
142
+ { client_id: 'shell', client_secret: '' },
143
+ { client_id: 'nobody' },
144
+ ];
145
+ for (const bad of refusals) {
146
+ for (const route of ['/sso/invites/notify', '/sso/invites/clear']) {
147
+ const res = await notice(route, bad);
148
+ assert.ok(res.status === 401 || res.status === 400, `${route} ${JSON.stringify(bad)} → ${res.status}`);
149
+ }
150
+ }
151
+ assert.equal(writes().length, 0);
152
+ });
153
+
154
+ test('a project can only notify about ITSELF — no body field names another project', async () => {
155
+ // The client row authenticated is the one written; there is no project field to steer.
156
+ const res = await notice('/sso/invites/notify', { project: 'someone-else' });
157
+ assert.equal(res.status, 400, 'the body is strict: an unknown field is refused, not ignored');
158
+ assert.equal(writes().length, 0);
159
+ });
160
+
161
+ test('each body is exactly three fields — a later field must ride a header (ADR 0352)', () => {
162
+ const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/sso-invites.js'), 'utf8');
163
+ const schemas = [...src.matchAll(/validateOrRespond\(req, res, \{([\s\S]*?)\}\)\) return/g)].map((m) => m[1]);
164
+ assert.equal(schemas.length, 2, 'one schema per route');
165
+ for (const schema of schemas) {
166
+ assert.deepEqual([...schema.matchAll(/^\s+(\w+): \{/gm)].map((m) => m[1]), ['client_id', 'client_secret', 'github_login']);
167
+ }
168
+ });
169
+
170
+ test('the module mounts the route file, and every route in it carries its rank line', () => {
171
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/platform-identity/module.json'), 'utf8'));
172
+ assert.ok(manifest.contributes.routes.includes('sso-invites'));
173
+ const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/sso-invites.js'), 'utf8');
174
+ const routes = src.match(/router\.post\(/g) || [];
175
+ const ranks = src.match(/\/\/ rank: public — authenticated by client_id \+ client_secret/g) || [];
176
+ assert.equal(routes.length, 2);
177
+ assert.equal(ranks.length, routes.length);
178
+ });
179
+
180
+ // ── 2. the hand-off link ─────────────────────────────────────────────────────
181
+
182
+ test('hallInviteUrl points at the project\'s own hall invite box, name filled in', () => {
183
+ const f = inviteNotices.hallInviteUrl;
184
+ assert.equal(f('https://mercury.test', 'octo'), 'https://mercury.test/builders/watch?invite=octo');
185
+ assert.equal(f('https://mercury.test/some/path/', '@octo'), 'https://mercury.test/builders/watch?invite=octo',
186
+ 'the origin only, and a pasted @ is dropped');
187
+ assert.equal(f('https://mercury.test', 'a b&c'), 'https://mercury.test/builders/watch?invite=a%20b%26c', 'the login is encoded');
188
+ assert.equal(f('https://mercury.test', ''), 'https://mercury.test/builders/watch');
189
+ });
190
+
191
+ test('hallInviteUrl refuses anything that is not a web origin — it becomes an <a href>', () => {
192
+ for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'not a url', '', null]) {
193
+ assert.equal(inviteNotices.hallInviteUrl(bad, 'octo'), null, String(bad));
194
+ }
195
+ });
196
+
197
+ // ── 3. the instance sender ───────────────────────────────────────────────────
198
+
199
+ const pin = (rel, exports) => {
200
+ const p = require.resolve(rel);
201
+ require.cache[p] = { id: p, filename: p, loaded: true, exports };
202
+ };
203
+
204
+ function loadSender(idp) {
205
+ pin('../src/bongos/auth-config.js', { loadIdpConfig: typeof idp === 'function' ? idp : () => idp });
206
+ delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
207
+ return require('../src/bongos/auth-admission.js').notifyHubOfInvite;
208
+ }
209
+
210
+ async function withFetch(impl, fn) {
211
+ const real = globalThis.fetch;
212
+ const sent = [];
213
+ globalThis.fetch = async (url, init) => { sent.push({ url, init }); return impl(url, init); };
214
+ try { await fn(sent); } finally { globalThis.fetch = real; }
215
+ }
216
+
217
+ const IDP = { origin: 'https://hub.test', clientId: 'mercury', clientSecret: SECRET };
218
+
219
+ test('an invite posts one login to /notify with this project\'s credentials, and nothing else', async () => {
220
+ const send = loadSender(IDP);
221
+ await withFetch(async () => ({ ok: true }), async (sent) => {
222
+ await send('octo', { invited: true });
223
+ assert.equal(sent.length, 1);
224
+ assert.equal(sent[0].url, 'https://hub.test/api/bongos/sso/invites/notify');
225
+ assert.equal(sent[0].init.method, 'POST');
226
+ assert.deepEqual(JSON.parse(sent[0].init.body), { client_id: 'mercury', client_secret: SECRET, github_login: 'octo' });
227
+ assert.ok(sent[0].init.signal, 'bounded in time — a slow hub must not hold anything open');
228
+ });
229
+ });
230
+
231
+ test('a rescind posts to /clear', async () => {
232
+ const send = loadSender(IDP);
233
+ await withFetch(async () => ({ ok: true }), async (sent) => {
234
+ await send('octo', { invited: false });
235
+ assert.equal(sent[0].url, 'https://hub.test/api/bongos/sso/invites/clear');
236
+ });
237
+ });
238
+
239
+ test('no hub, or no login, sends nothing', async () => {
240
+ await withFetch(async () => ({ ok: true }), async (sent) => {
241
+ await loadSender(null)('octo', { invited: true });
242
+ await loadSender(IDP)('', { invited: true });
243
+ assert.equal(sent.length, 0);
244
+ });
245
+ });
246
+
247
+ test('nothing the sender meets can throw into a caller that never awaits it', async () => {
248
+ // An older hub (404), a hub that is down (rejects), and a config read that throws.
249
+ await withFetch(async () => ({ ok: false, status: 404 }), async () => {
250
+ await assert.doesNotReject(loadSender(IDP)('octo'));
251
+ });
252
+ await withFetch(async () => { throw new Error('ECONNREFUSED'); }, async () => {
253
+ await assert.doesNotReject(loadSender(IDP)('octo'));
254
+ });
255
+ await assert.doesNotReject(loadSender(() => { throw new Error('bad config'); })('octo'));
256
+ });
257
+
258
+ test('the doorway publishes the sender as a narrow port, and never the config that holds the secret', () => {
259
+ assert.equal(typeof realApi.notifyHubOfInvite, 'function');
260
+ assert.equal(realApi.loadIdpConfig, undefined);
261
+ });
@@ -48,6 +48,7 @@ const PUBLISHED_SURFACE = [
48
48
  'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
49
49
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
50
50
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
51
+ 'notifyHubOfInvite', // added by task 1002818 (ADR 0353): tell the hub about one of THIS project's invites (or its rescind) so the invitee sees it in My Projects. The same NARROW-port reason as readApplicantProfileFromHub: the call carries this instance's hub client secret, so a module passes one login and one direction and can neither widen nor re-address it
51
52
  'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
52
53
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
53
54
  'isModuleEnabled', // task 1003881: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
@@ -58,7 +58,6 @@ const MY_PROJECTS_ROUTES = [
58
58
  "router.get('/my-projects'",
59
59
  "router.post('/my-projects/join'",
60
60
  "router.get('/my-projects/invites'",
61
- "router.post('/my-projects/invites/:clientId/accept'",
62
61
  "router.post('/my-projects/invites/:clientId/decline'",
63
62
  "router.post('/my-projects/:clientId/leave'",
64
63
  ];
@@ -122,3 +121,14 @@ test('the hub never calls the accept route — ADR 0335 retired that flip', () =
122
121
  assert.doesNotMatch(html, /invites\/[^'"]*\/accept/,
123
122
  'a hub caller of the accept route reopens the dead end ADR 0335 closed — render the invite as a link into the project instead');
124
123
  });
124
+
125
+ // Task 1002818 (ADR 0353) finished the retirement on the server: the route is gone,
126
+ // so no caller — this page, an old cached copy, a script — can flip a pending row
127
+ // to 'member' and admit nobody. Signing in to the project is the only acceptance.
128
+ test('the accept route itself is gone — only decline survives (ADR 0353)', () => {
129
+ const src = read(ROUTES);
130
+ assert.doesNotMatch(src, /router\.\w+\(\s*'\/my-projects\/invites\/:clientId\/accept'/,
131
+ 'a hub-side accept admits nobody on the project; do not bring it back');
132
+ assert.doesNotMatch(src, /resolveInvite/, 'the accept-or-decline helper is retired too');
133
+ assert.match(src, /router\.post\('\/my-projects\/invites\/:clientId\/decline'/);
134
+ });
@@ -149,14 +149,18 @@ beforeEach(() => { caller = OWNER; portMode = 'ok'; invites = []; portCalls = []
149
149
 
150
150
  // ── the arm ──────────────────────────────────────────────────────────────────
151
151
 
152
+ // What an admitted caller gets since task 1002818 (ADR 0353): not a hub row, which let nobody
153
+ // in, but WHERE the invite that admits is sent — the project's own hall, name filled in.
154
+ const HANDOFF = 'https://spike-harbor.cloudbongos.test/builders/watch?invite=someone';
155
+
152
156
  test('the OWNER of a wizard-made project invites into it, holding no permission at all', async () => {
153
157
  // The whole point of the task: the wizard's audience is any signed-in GitHub user, and a
154
158
  // `xenos` owner used to get 403 on their own project before the target was even resolved.
155
159
  caller = OWNER;
156
160
  const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
157
- assert.equal(res.status, 201);
158
- assert.equal(res.body.invited, true);
159
- assert.deepEqual(invites, [{ clientId: 'spike-harbor', githubId: 5150 }]);
161
+ assert.equal(res.status, 200);
162
+ assert.deepEqual(res.body, { invited: false, invite_url: HANDOFF });
163
+ assert.deepEqual(invites, [], 'the hub writes no membership row — only the project\'s own invite admits');
160
164
  // The ownership question was asked of the provisioning row, keyed on BOTH halves of the
161
165
  // catalog projection (ADR 0246: client_id = slug, origin = https://domain).
162
166
  assert.deepEqual(portCalls, [{ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }]);
@@ -168,9 +172,9 @@ test('the OWNER of a wizard-made project invites into it, holding no permission
168
172
  test('a METIC curator still invites into a project they do not own — the old door is untouched', async () => {
169
173
  caller = CURATOR;
170
174
  const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
171
- assert.equal(res.status, 201);
172
- assert.equal(res.body.invited, true);
173
- assert.equal(invites.length, 1);
175
+ assert.equal(res.status, 200);
176
+ assert.equal(res.body.invite_url, HANDOFF);
177
+ assert.deepEqual(invites, []);
174
178
  });
175
179
 
176
180
  test('a DIFFERENT builder is refused with the unchanged shape, and writes nothing', async () => {
@@ -253,7 +257,8 @@ test('a THROWING ownership check denies — it never 500s and never admits', asy
253
257
  // door is the old door, not an outage.
254
258
  caller = CURATOR;
255
259
  const asCurator = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
256
- assert.equal(asCurator.status, 201);
260
+ assert.equal(asCurator.status, 200, 'past the gate — and handed to the hall, as every admitted caller is');
261
+ assert.equal(asCurator.body.invite_url, HANDOFF);
257
262
  });
258
263
 
259
264
  test('a non-string client_id is not an ownership claim', async () => {
@@ -19,8 +19,10 @@
19
19
  // before that an invite could only ever 404
20
20
  // • an invite sends exactly { client_id, github_login } — the slug IS the
21
21
  // client_id — on the cookie session, and nothing else
22
- // • the 201 row is what the pending line is drawn from, and the github_id it
23
- // carries is never shown
22
+ // • the answer is a HAND-OFF (task 1002818, ADR 0353): the route writes nothing
23
+ // and answers `{ invited: false, invite_url }` — the project's hall invite link — and
24
+ // each name becomes a line whose link finishes the invite there — never a
25
+ // window opened for the owner, and never a link that is not http(s)
24
26
  // • every refusal the route can give is said in the route's own vocabulary,
25
27
  // and the 403 is said CAREFULLY: it is byte-identical whether or not the
26
28
  // project exists (ADR 0248), so the page may not read it as "it is there"
@@ -57,8 +59,14 @@ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function ski
57
59
  // Narrow on purpose (the project_modules_ui.mjs rule): it supports exactly what
58
60
  // the region uses, so it stays honest about what it can prove.
59
61
 
60
- const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
61
- const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
62
+ const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><a class="btn small" href="([^"]*)" target="_blank" rel="noopener">Send the invite →<\/a><\/li>/g;
63
+ const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2], url: m[3] }));
64
+
65
+ // The route's answer to an admitted caller since task 1002818: where to send the invite.
66
+ const handoff = (login, origin = 'https://mercury.cloudbongos.test') => ({
67
+ ok: true, status: 200,
68
+ body: { invited: false, invite_url: origin + '/builders/watch?invite=' + encodeURIComponent(login) },
69
+ });
62
70
 
63
71
  // A suggestion row: whatever the row NAMES, plus the login its button carries.
64
72
  // Parsed off the button so the test reads the address the click will actually
@@ -113,7 +121,8 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
113
121
  const queued = answers.slice();
114
122
  const fetch = (url, init = {}) => {
115
123
  requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
116
- const a = queued.shift() || { ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 4242, membership_kind: 'pending' } } };
124
+ const sent = (() => { try { return JSON.parse(init.body).github_login; } catch { return ''; } })();
125
+ const a = queued.shift() || handoff(sent);
117
126
  if (a.reject) return Promise.reject(new Error('offline'));
118
127
  return Promise.resolve({ ok: a.ok, status: a.status, _json: a.body, json: () => Promise.resolve(a.body) });
119
128
  };
@@ -127,7 +136,12 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
127
136
  apiError: (data, status) => ({
128
137
  code: (data && data.error && data.error.code) || ('http_' + status),
129
138
  message: (data && data.error && data.error.message) || '',
139
+ details: (data && data.error && data.error.details) || {},
130
140
  }),
141
+ safeHttpUrl: (raw) => {
142
+ try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; }
143
+ },
144
+ URL,
131
145
  signInAgain: () => 'Your session expired — <a href="/in">sign in again</a>.',
132
146
  };
133
147
  sandbox.globalThis = sandbox;
@@ -238,7 +252,7 @@ test('an invite carries exactly { client_id, github_login } — the slug IS the
238
252
  'exactly the two fields the route validates, and nothing else');
239
253
  });
240
254
 
241
- test('the pending line is drawn from the 201 ROW, and the github_id on it is never shown', async () => {
255
+ test('the answer is a hand-off line: the name, "not sent yet", and the hall link that sends it', async () => {
242
256
  const ui = boot();
243
257
  await ui.open('mercury');
244
258
 
@@ -246,22 +260,29 @@ test('the pending line is drawn from the 201 ROW, and the github_id on it is nev
246
260
  await ui.send();
247
261
 
248
262
  assert.deepEqual(ui.rows().map((r) => r.login), ['octo-builder']);
249
- assert.match(ui.rows()[0].said, /waiting for them to accept/i, "the row's own 'pending' is what the line says");
250
- assert.equal(ui.msg().className, 'projMsg ok');
251
- assert.doesNotMatch(ui.msg().textContent + ui.rows()[0].said + ui.rows()[0].login, /4242/,
252
- 'the row carries a github_id; a person who has accepted nothing yet is not published by it');
263
+ assert.match(ui.rows()[0].said, /not sent yet/i, 'the line may not claim an invite the hall has not sent');
264
+ assert.equal(ui.rows()[0].url, 'https://mercury.cloudbongos.test/builders/watch?invite=octo-builder',
265
+ 'the link is the route\'s own invite_url, verbatim');
266
+ assert.equal(ui.msg().className, 'projMsg ok', 'being handed to the hall is the answer, not a failure');
267
+ assert.match(ui.msg().textContent, /hall/i);
253
268
  });
254
269
 
255
- test('a membership_kind the page does not know is shown VERBATIM, never papered over as pending', async () => {
256
- // The state word is the row's, not this page's: if the route ever seeds a
257
- // different kind, an owner sees that rather than a comfortable fiction.
258
- const ui = boot({ answers: [{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'member' } } }] });
270
+ test('a hand-off link that is not http(s) is never drawn — it is said as a failure instead', async () => {
271
+ const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, invite_url: 'javascript:alert(1)' } }] });
259
272
  await ui.open('mercury');
260
273
 
261
274
  await ui.type('octo-builder');
262
275
  await ui.send();
263
276
 
264
- assert.equal(ui.rows()[0].said, 'member');
277
+ assert.equal(ui.rows().length, 0, 'no line carries a link that is not a web address');
278
+ assert.doesNotMatch(ui.sandbox.$('wizInviteList').innerHTML, /javascript:/);
279
+ assert.equal(ui.msg().className, 'projMsg err');
280
+ assert.match(ui.msg().textContent, /couldn’t be sent/);
281
+ });
282
+
283
+ test('the region never opens a window for the owner — the link is theirs to click', () => {
284
+ assert.doesNotMatch(REGION, /window\.open|location\.(?:href|assign|replace)\s*[=(]/,
285
+ 'an open fired from a network answer is blocked, and a surprise navigation loses the wizard');
265
286
  });
266
287
 
267
288
  test('a name is trimmed, and an empty one is a question rather than a request', async () => {
@@ -299,12 +320,7 @@ test('Enter in the field sends the invite too', async () => {
299
320
  });
300
321
 
301
322
  test('two invites in a row both stand, newest last', async () => {
302
- const ui = boot({
303
- answers: [
304
- { ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'pending' } } },
305
- { ok: true, status: 201, body: { invited: true, row: { id: 8, client_id: 'mercury', github_id: 2, membership_kind: 'pending' } } },
306
- ],
307
- });
323
+ const ui = boot({ answers: [handoff('octo-builder'), handoff('second-builder')] });
308
324
  await ui.open('mercury');
309
325
 
310
326
  await ui.type('octo-builder');
@@ -317,29 +333,11 @@ test('two invites in a row both stand, newest last', async () => {
317
333
 
318
334
  // ── every refusal the route can give ─────────────────────────────────────────
319
335
 
320
- test('an invitee with no platform account is named honestly, and the step survives it', async () => {
321
- const ui = boot({ answers: [refused(404, 'account_not_found', 'That builder has no platform account yet — they must sign in somewhere once before they can be invited.')] });
322
- await ui.open('mercury');
323
-
324
- await ui.type('nobody-here');
325
- await ui.send();
326
-
327
- assert.equal(ui.msg().className, 'projMsg err');
328
- assert.match(ui.msg().textContent, /hasn’t signed in here yet/i);
329
- assert.equal(ui.box().hidden, false, 'the step stays — a failed invite is re-tryable');
330
- assert.equal(ui.rows().length, 0, 'and nothing is drawn as pending that is not');
331
- });
332
-
333
- test('someone already on the project is said plainly, not as a failure', async () => {
334
- const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, alreadyMember: true } }] });
335
- await ui.open('mercury');
336
-
337
- await ui.type('octo-builder');
338
- await ui.send();
339
-
340
- assert.equal(ui.msg().className, 'projMsg ok');
341
- assert.match(ui.msg().textContent, /already on this project/i);
342
- assert.equal(ui.rows().length, 0, 'an existing member is not a pending invite');
336
+ // Task 1002818 retired two answers with the hub row they described: `account_not_found`
337
+ // (the hall invites any GitHub login, account or not) and 200 `alreadyMember` (the hall's
338
+ // own invite route answers that, where the membership actually lives).
339
+ test('the refusals the retired write used to give are gone from the page too', () => {
340
+ assert.doesNotMatch(REGION, /account_not_found|alreadyMember/);
343
341
  });
344
342
 
345
343
  test('a project the map has not caught up with says exactly that, and stays re-tryable', async () => {
@@ -407,7 +405,6 @@ test('an unknown refusal falls through to the server’s own words rather than a
407
405
 
408
406
  test('inviteRefusal is a pure function over the route’s own vocabulary', () => {
409
407
  const f = boot().sandbox.inviteRefusal;
410
- assert.match(f('account_not_found', 404, ''), /signed in here yet/i);
411
408
  assert.match(f('unknown_project', 404, ''), /map yet/i);
412
409
  assert.match(f('permission_forbidden', 403, ''), /can’t invite/i);
413
410
  assert.equal(f('whatever', 500, 'the server said this'), 'the server said this');
@@ -94,6 +94,7 @@ async function boot(opts = {}) {
94
94
  rank = 'archon',
95
95
  queueStatus = null, // non-200 for the queue read (403 seals it)
96
96
  patchResult = null, // a scripted answer for PATCH /access-requests/:id
97
+ search = '', // the page's query string (?invite=<login>, task 1002818)
97
98
  } = opts;
98
99
 
99
100
  const requests = [];
@@ -136,7 +137,7 @@ async function boot(opts = {}) {
136
137
  // the ones shipped rather than ones this test invented.
137
138
  parseInto(documentObj.body, PAGE);
138
139
 
139
- const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search: '', hash: '', assign() {} };
140
+ const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search, hash: '', assign() {} };
140
141
 
141
142
  const kit = {
142
143
  makeTabs: () => ({}),
@@ -187,7 +188,7 @@ async function boot(opts = {}) {
187
188
  console: { log() {}, warn() {}, error() {} },
188
189
  setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
189
190
  Number, String, Object, Array, Boolean, Set, Map, RegExp,
190
- encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
191
+ encodeURIComponent, decodeURIComponent, parseInt, parseFloat, URLSearchParams,
191
192
  };
192
193
  sandbox.globalThis = sandbox;
193
194
  vm.createContext(sandbox);
@@ -491,4 +492,34 @@ await test('the profile changes what the row SAYS, never what it offers (ADR 001
491
492
  'a rich record is not a reason to admit and an empty one is not a reason to refuse — the decisions must be identical');
492
493
  });
493
494
 
495
+ // ── ?invite=<login>: the platform hub hands an owner here (task 1002818, ADR 0353) ──
496
+ // The hub's invite widget cannot admit anyone, so it links an owner to THIS box with the
497
+ // name filled in. Filling is all a link may do: the send stays the owner's own click.
498
+
499
+ await test('?invite= fills the invite box — and sends nothing on its own', async () => {
500
+ const h = await boot({ search: '?invite=octo-builder' });
501
+ const box = h.documentObj.getElementById('gate-invite-login');
502
+ assert.ok(box, 'the invite box rendered');
503
+ assert.equal(box.value, 'octo-builder');
504
+ assert.equal(h.requests.filter((r) => r.method === 'POST').length, 0,
505
+ 'a link anyone can craft must never be the act that admits');
506
+ });
507
+
508
+ await test('?invite= is spent on first use — a re-render does not put the name back', async () => {
509
+ const h = await boot({ search: '?invite=octo-builder' });
510
+ const box = h.documentObj.getElementById('gate-invite-login');
511
+ box.value = '';
512
+ await h.switchView('invited'); // the gate re-renders, invite box and all
513
+ assert.equal(h.documentObj.getElementById('gate-invite-login').value, '');
514
+ });
515
+
516
+ await test('?invite= that is not a GitHub username fills nothing', async () => {
517
+ for (const bad of ['?invite=%3Cscript%3E', '?invite=a%20b', '?invite=-lead', '?invite=' + 'x'.repeat(40)]) {
518
+ const h = await boot({ search: bad });
519
+ assert.equal(h.documentObj.getElementById('gate-invite-login').value || '', '', bad);
520
+ }
521
+ const at = await boot({ search: '?invite=%40octo' });
522
+ assert.equal(at.documentObj.getElementById('gate-invite-login').value, 'octo', 'a pasted @ is dropped, as the form itself does');
523
+ });
524
+
494
525
  summary();