@bongos/core 1.20.20 → 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 (49) hide show
  1. package/.bongos-core.json +80 -50
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -2
  4. package/clients/bongos-client/index.cjs +6 -2
  5. package/clients/bongos-client/index.d.ts +11 -4
  6. package/clients/bongos-client/index.mjs +6 -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 +179 -73
  11. package/docs/api-reference.md +8 -6
  12. package/docs/copy-inventory.md +196 -196
  13. package/docs/copy-registry.json +250 -250
  14. package/docs/module-api-changelog.md +4 -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/guild-map.js +105 -0
  20. package/modules/platform-identity/invite-notices.js +61 -0
  21. package/modules/platform-identity/module.json +1 -1
  22. package/modules/platform-identity/platform-identity.js +12 -18
  23. package/modules/platform-identity/routes/guilds-public.js +36 -0
  24. package/modules/platform-identity/routes/my-projects.js +6 -15
  25. package/modules/platform-identity/routes/projects.js +22 -15
  26. package/modules/platform-identity/routes/sso-invites.js +82 -0
  27. package/modules/platform-identity/tests/platform-identity.mjs +14 -11
  28. package/modules/public-landing/public/assets/cosmos.css +5 -0
  29. package/modules/public-landing/public/projects.html +35 -29
  30. package/modules/public-landing/public/projects.states.json +1 -0
  31. package/modules/ui-design/kit/serve.js +9 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +12 -0
  35. package/scripts/gds/run-unit-tests.js +4 -0
  36. package/src/bongos/auth-admission.js +40 -0
  37. package/src/bongos/auth.js +2 -1
  38. package/src/module-api.js +8 -1
  39. package/tests/access_requests_invite.mjs +39 -1
  40. package/tests/application_lifecycle.mjs +41 -1
  41. package/tests/catalog_only_client.mjs +5 -3
  42. package/tests/guild_map_db.mjs +157 -0
  43. package/tests/guilds_api.mjs +74 -0
  44. package/tests/hub_invite_notices.mjs +261 -0
  45. package/tests/module_api.mjs +1 -0
  46. package/tests/my_projects_surface.mjs +11 -1
  47. package/tests/project_invite_ownership.mjs +12 -7
  48. package/tests/project_invite_ui.mjs +42 -45
  49. package/tests/watch_applications_queue.mjs +33 -2
@@ -0,0 +1,157 @@
1
+ // tests/guild_map_db.mjs — the guild map against REAL SQL (R30, task 1002306;
2
+ // ADR 0336 D8, amended by ADR 0337 D4.4).
3
+ //
4
+ // Why a real database: who is listed is an INNER join to an aggregated roster,
5
+ // size is a capped count under the shared existence predicate, and the shipped
6
+ // ordering puts un-refreshed guilds (NULL) last — each a planner fact the
7
+ // DB-free route test (tests/guilds_api.mjs §3b) can only assert the shape of.
8
+ //
9
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
10
+ // INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
11
+ // high-numbered and every guild slug starts r30-, so a shared DB is safe; all
12
+ // fixtures are deleted before and after.
13
+ //
14
+ // Run: DATABASE_URL=... node tests/guild_map_db.mjs
15
+
16
+ import { createRequire } from 'node:module';
17
+ import { readFileSync } from 'node:fs';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join } from 'node:path';
20
+ import { strict as assert } from 'node:assert';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { Pool } = require('pg');
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
27
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
28
+
29
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
30
+ try {
31
+ await pool.query('SELECT 1');
32
+ } catch (e) {
33
+ console.log(`guild_map_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
34
+ await pool.end().catch(() => {});
35
+ process.exit(0);
36
+ }
37
+
38
+ const guildMap = require('../modules/platform-identity/guild-map.js');
39
+ const guilds = require('../modules/platform-identity/guilds.js');
40
+
41
+ const A = 9002306001; // public
42
+ const B = 9002306002; // public
43
+ const C = 9002306003; // public
44
+ const HID = 9002306004; // public account, hides their numbers
45
+ const PRIV = 9002306005; // private account
46
+ const PROV = 9002306006; // provisional
47
+ const ALL_IDS = [A, B, C, HID, PRIV, PROV];
48
+
49
+ const q = (sql, params) => pool.query(sql, params);
50
+ async function cleanup() {
51
+ await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r30-%'`).catch(() => {});
52
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
53
+ }
54
+
55
+ let pass = 0;
56
+ const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
57
+
58
+ async function account(id, handle, { visibility = 'public', hide = false, state = 'active' } = {}) {
59
+ await q(
60
+ `INSERT INTO platform_identity_accounts
61
+ (github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility, hide_stats)
62
+ VALUES ($1, $2, $2, $2, $3, CASE WHEN $3 = 'active' THEN now() END, $4, $5)`,
63
+ [id, handle, state, visibility, hide],
64
+ );
65
+ }
66
+ async function guild(slug, visibility = 'public') {
67
+ const { rows } = await q(
68
+ `INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ($1, $1, $2) RETURNING id`,
69
+ [slug, visibility],
70
+ );
71
+ return rows[0].id;
72
+ }
73
+ async function member(gid, id, { kind = 'member', shown = true } = {}) {
74
+ await q(
75
+ `INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly)
76
+ VALUES ($1, $2, $3, $4)`,
77
+ [gid, id, kind, shown],
78
+ );
79
+ }
80
+ const totals = (gid, shipped) => q(
81
+ `INSERT INTO platform_identity_guild_totals (guild_id, credits, works_shipped, karma, projects)
82
+ VALUES ($1, 0, $2, 0, 0)`, [gid, shipped],
83
+ );
84
+ // Only this file's guilds: a shared DB may hold others.
85
+ const mine = async (order) => (await guildMap.listGuildMap({ order, limit: 100 }, { pool }))
86
+ .results.filter((r) => r.slug.startsWith('r30-'));
87
+
88
+ try {
89
+ for (const f of [
90
+ 'platform_identity_001_tables.sql', 'platform_identity_007_profile_consent.sql',
91
+ 'platform_identity_008_handle.sql', 'platform_identity_012_account_privacy.sql',
92
+ 'platform_identity_019_terms_acceptance.sql', 'platform_identity_025_guilds.sql',
93
+ 'platform_identity_027_guild_totals.sql',
94
+ ]) await q(migrationSql(f));
95
+ await cleanup();
96
+
97
+ await account(A, 'r30a');
98
+ await account(B, 'r30b');
99
+ await account(C, 'r30c');
100
+ await account(HID, 'r30hid', { hide: true });
101
+ await account(PRIV, 'r30priv', { visibility: 'private' });
102
+ await account(PROV, 'r30prov', { state: 'provisional' });
103
+
104
+ // r30-big: three public members, one who hides numbers (still shown), one private (never).
105
+ const big = await guild('r30-big');
106
+ await member(big, A, { kind: 'owner' });
107
+ await member(big, B);
108
+ await member(big, HID);
109
+ await member(big, PRIV);
110
+ await totals(big, 20);
111
+ // r30-alpha / r30-beta: two each, a size tie broken on slug; beta has no record yet.
112
+ const alpha = await guild('r30-alpha');
113
+ await member(alpha, A, { kind: 'owner' });
114
+ await member(alpha, C);
115
+ await totals(alpha, 50);
116
+ const beta = await guild('r30-beta');
117
+ await member(beta, B, { kind: 'owner' });
118
+ await member(beta, C);
119
+ // r30-gamma: same shipped band as big (tie on shipped → slug).
120
+ const gamma = await guild('r30-gamma');
121
+ await member(gamma, C, { kind: 'owner' });
122
+ await totals(gamma, 20);
123
+ // Never on the map: unlisted; a public guild whose only members are private,
124
+ // provisional or unshown.
125
+ const quiet = await guild('r30-quiet', 'unlisted');
126
+ await member(quiet, A, { kind: 'owner', shown: false });
127
+ await member(quiet, B, { shown: false });
128
+ const hollow = await guild('r30-hollow');
129
+ await member(hollow, PRIV, { kind: 'owner' });
130
+ await member(hollow, PROV);
131
+ await member(hollow, A, { shown: false });
132
+
133
+ same((await mine('size')).map((r) => [r.slug, r.size]),
134
+ [['r30-big', 3], ['r30-alpha', 2], ['r30-beta', 2], ['r30-gamma', 1]],
135
+ 'size order: public roster length, slug tiebreak; the hidden member counts, the private one never; unlisted and empty-roster guilds absent');
136
+ same((await mine('shipped')).map((r) => [r.slug, r.shipped]),
137
+ [['r30-alpha', 50], ['r30-big', 20], ['r30-gamma', 20], ['r30-beta', null]],
138
+ 'shipped order: the stored band floor, slug tiebreak, a guild with no record yet last');
139
+
140
+ const page = await guilds.getPublicGuild('r30-big', { pool });
141
+ same((await mine('size')).find((r) => r.slug === 'r30-big').size, page.size,
142
+ 'the map\'s size is exactly the guild page\'s size');
143
+
144
+ // Hides beat the map on the very next read: B goes private.
145
+ await q(`UPDATE platform_identity_accounts SET account_visibility = 'private' WHERE github_id = $1`, [B]);
146
+ same((await mine('size')).map((r) => [r.slug, r.size]),
147
+ [['r30-alpha', 2], ['r30-big', 2], ['r30-beta', 1], ['r30-gamma', 1]],
148
+ 'a member going private leaves every guild\'s size on the next read — nothing cached');
149
+
150
+ same(Object.keys((await mine('size'))[0]).sort(), ['name', 'shipped', 'size', 'slug'],
151
+ 'a row carries only what it publishes');
152
+
153
+ console.log(`\nguild_map_db.mjs: ${pass} passed`);
154
+ } finally {
155
+ await cleanup();
156
+ await pool.end();
157
+ }
@@ -10,6 +10,10 @@
10
10
  // 3. THE PUBLIC READ — 404 for an unlisted guild is byte-identical to an
11
11
  // unknown slug, needs no session, is no-store, and its roster statement
12
12
  // carries shown_publicly AND the shared existence predicate.
13
+ // 3b. THE GUILD MAP (task 1002306) — public guilds with a non-empty public
14
+ // roster, sized by the guild page's own predicate and cap, ranked only by
15
+ // published fields (size, or the STORED shipped band floor), paging
16
+ // clamped, and an unknown order refused before any SQL.
13
17
  // 4. SILENCE — a request written and one swallowed by a decline cooldown
14
18
  // answer identically; a decline is an empty 204.
15
19
  // 5. THE CARD — an invite accept must echo the visibility its card showed,
@@ -90,6 +94,7 @@ require.cache[moduleApiPath] = {
90
94
  },
91
95
  };
92
96
  const guilds = require('../modules/platform-identity/guilds.js');
97
+ const guildMap = require('../modules/platform-identity/guild-map.js');
93
98
  const buildGuildRoutes = require('../modules/platform-identity/routes/guilds.js');
94
99
  const buildPublicRoutes = require('../modules/platform-identity/routes/guilds-public.js');
95
100
  const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
@@ -281,6 +286,75 @@ test('public read: the roster is shown_publicly AND the shared existence predica
281
286
  assert.equal(calls[0].params[0], 'guild-a', 'the slug is looked up in canonical form');
282
287
  });
283
288
 
289
+ // ===========================================================================
290
+ // 3b. The guild map (R30, task 1002306; ADR 0336 D8 + ADR 0337 D4.4)
291
+ // ===========================================================================
292
+
293
+ const IS_MAP = (t) => /WITH roster AS/.test(t) && /FROM platform_identity_guilds g/.test(t);
294
+
295
+ test('guild map: public guilds with a public roster, sized like the guild page, ranked by size then slug', async () => {
296
+ on((t) => (IS_MAP(t) ? result([
297
+ { slug: 'big', name: 'Big', size: 9, shipped: 50 },
298
+ { slug: 'new', name: 'New', size: 2, shipped: null },
299
+ ]) : result()));
300
+ session = null; // the map must not need one
301
+ try {
302
+ const res = await call('GET', '/api/bongos/guilds');
303
+ assert.equal(res.status, 200);
304
+ assert.equal(res.headers['cache-control'], 'no-store');
305
+ assert.deepEqual(Object.keys(res.json).sort(), ['count', 'order', 'page', 'results']);
306
+ assert.equal(res.json.order, 'size', 'size is the default ordering');
307
+ assert.equal(res.json.count, res.json.results.length);
308
+ assert.deepEqual(res.json.page, { limit: guildMap.DEFAULT_LIMIT, offset: 0 });
309
+ assert.equal(calls.length, 1, 'one statement, and no read of the viewer');
310
+ const { text, params } = calls[0];
311
+ assert.match(text, /AND m\.shown_publicly AND \(\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\) AND pa\.account_visibility = 'public'\)/,
312
+ 'size is the public roster: shown_publicly AND accountVisibleSql — the getPublicGuild predicate');
313
+ assert.match(text, /JOIN roster r ON r\.guild_id = g\.id/, 'an INNER join: a guild with an empty public roster is not on the map');
314
+ assert.match(text, /WHERE g\.visibility = 'public'/, 'an unlisted guild is never listed');
315
+ assert.match(text, /LEAST\(count\(\*\), \$3\)/, 'size is capped like the guild page\'s roster');
316
+ assert.match(text.split('SELECT g.slug')[0], /WHERE pg\.visibility = 'public' AND m\.shown_publicly/,
317
+ 'the aggregate groups PUBLIC guilds\' memberships only — an unlisted guild is never scanned for the map');
318
+ assert.deepEqual(params, [guildMap.DEFAULT_LIMIT, 0, guilds.LIST_MAX]);
319
+ assert.match(text, /ORDER BY size DESC, g\.slug\s+LIMIT/, 'ties break on slug');
320
+ } finally { session = { github_id: ME, github_login: 'octo' }; }
321
+ });
322
+
323
+ test('guild map: shipped ranks by the STORED band floor, never an exact sum; a guild not yet refreshed sorts last', async () => {
324
+ on((t) => (IS_MAP(t) ? result([]) : result()));
325
+ const res = await call('GET', '/api/bongos/guilds?order=shipped');
326
+ assert.equal(res.status, 200);
327
+ assert.equal(res.json.order, 'shipped');
328
+ const { text } = calls[0];
329
+ assert.match(text, /LEFT JOIN platform_identity_guild_totals t ON t\.guild_id = g\.id/);
330
+ assert.match(text, /t\.works_shipped AS shipped/, 'the published key IS the sort key (ADR 0255)');
331
+ assert.match(text, /ORDER BY shipped DESC NULLS LAST, g\.slug/);
332
+ assert.doesNotMatch(text, /platform_identity_builder_project_activity/, 'no exact sum is ever read here (ADR 0337 D4.4)');
333
+ });
334
+
335
+ test('guild map: a row carries slug, name, size and shipped — nothing that names an account', async () => {
336
+ on((t) => (IS_MAP(t) ? result([]) : result()));
337
+ await call('GET', '/api/bongos/guilds');
338
+ const select = calls[0].text.split(/SELECT g\.slug/)[1].split('FROM')[0];
339
+ assert.equal(`g.slug${select}`.replace(/\s+/g, ' ').trim(), 'g.slug, g.name, r.size, t.works_shipped AS shipped');
340
+ });
341
+
342
+ test('guild map: an unknown order is a 400 that reaches no SQL; paging is clamped and echoed', async () => {
343
+ on(() => result());
344
+ const bad = await call('GET', '/api/bongos/guilds?order=credits');
345
+ assert.equal(bad.status, 400);
346
+ assert.equal(bad.json.error.code, 'guild_map_bad_order');
347
+ assert.equal(calls.length, 0);
348
+
349
+ on((t) => (IS_MAP(t) ? result([]) : result()));
350
+ const big = await call('GET', '/api/bongos/guilds?limit=1000&offset=99999');
351
+ assert.deepEqual(big.json.page, { limit: guildMap.MAX_LIMIT, offset: guildMap.MAX_OFFSET });
352
+ assert.deepEqual(calls[0].params.slice(0, 2), [guildMap.MAX_LIMIT, guildMap.MAX_OFFSET]);
353
+ on((t) => (IS_MAP(t) ? result([]) : result()));
354
+ const junk = await call('GET', '/api/bongos/guilds?limit=-3&offset=abc');
355
+ assert.deepEqual(junk.json.page, { limit: guildMap.DEFAULT_LIMIT, offset: 0 });
356
+ });
357
+
284
358
  // ===========================================================================
285
359
  // 4. Join requests and silence (D3)
286
360
  // ===========================================================================
@@ -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 () => {