@bongos/core 1.20.27 → 1.20.29

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 +75 -45
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
  8. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
  9. package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +51 -3
  12. package/docs/api-reference.md +3 -2
  13. package/docs/copy-inventory.md +20 -19
  14. package/docs/copy-registry.json +34 -25
  15. package/docs/module-api-changelog.md +5 -1
  16. package/docs/page-readings.json +9 -9
  17. package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
  18. package/modules/hall-ui/public/watch.js +9 -3
  19. package/modules/onboarding/routes/access-requests.js +43 -20
  20. package/modules/platform-identity/platform-identity.js +12 -8
  21. package/modules/platform-identity/tests/platform-identity.mjs +4 -2
  22. package/modules/provisioning/core-upgrade.js +70 -1
  23. package/modules/provisioning/migrations/provisioning_029_update_channel.sql +24 -0
  24. package/modules/provisioning/routes/core-upgrade.js +21 -1
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +16 -0
  28. package/scripts/gds/fitness.js +4 -0
  29. package/scripts/gds/login.js +6 -0
  30. package/scripts/gds/provision-core-upgrade.js +17 -11
  31. package/scripts/gds/run-unit-tests.js +4 -0
  32. package/src/bongos/db-kernel.js +6 -3
  33. package/src/bongos/invite-expiry.js +47 -0
  34. package/src/module-api.js +10 -1
  35. package/tests/access_requests_invite.mjs +2 -1
  36. package/tests/application_lifecycle.mjs +164 -5
  37. package/tests/bongos_login.mjs +2 -0
  38. package/tests/core_upgrade_door.mjs +80 -1
  39. package/tests/core_upgrade_runner.mjs +54 -1
  40. package/tests/hub_invite_notices.mjs +5 -2
  41. package/tests/invite_expiry.mjs +37 -0
  42. package/tests/invite_expiry_db.mjs +196 -0
  43. package/tests/module_api.mjs +1 -0
  44. package/tests/project_admin_console_model.mjs +12 -5
@@ -0,0 +1,196 @@
1
+ // tests/invite_expiry_db.mjs — task 1002969 (privacy spec D4, ADR 0356). Real DB.
2
+ //
3
+ // A recruit invite's door closes 30 days after it is sent, computed at read time.
4
+ // tests/application_lifecycle.mjs pins the behaviour over an interpreter that reads
5
+ // each statement's WHERE; this file runs the SAME statements on Postgres, because an
6
+ // interpreter cannot tell you that Postgres would reject a statement, or what
7
+ // `now() - interval '30 days'` does at the boundary. Two halves:
8
+ //
9
+ // the instance — the real onboarding router and the real db-kernel admission reads,
10
+ // over access_requests built from its real migrations;
11
+ // the hub — inviteToProject and listPendingInvitesForAccount over the real
12
+ // memberships table: an expired notice is hidden, a re-invite
13
+ // restamps it, and a member's row is never touched.
14
+ //
15
+ // Each half builds its tables in a throwaway schema, so a shared database's rows are
16
+ // never seen or changed.
17
+ //
18
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
19
+ // INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
20
+ // never runs it.
21
+ //
22
+ // Run: DATABASE_URL=... node tests/invite_expiry_db.mjs
23
+
24
+ import { createRequire } from 'node:module';
25
+ import { readFileSync } from 'node:fs';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { dirname, join } from 'node:path';
28
+ import { strict as assert } from 'node:assert';
29
+ import http from 'node:http';
30
+ const require = createRequire(import.meta.url);
31
+ const { Pool } = require('pg');
32
+ const express = require('express');
33
+
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ const coreSql = (f) => readFileSync(join(here, '..', 'migrations', `${f}.sql`), 'utf8');
36
+ const piSql = (f) => readFileSync(join(here, '..', 'modules', 'platform-identity', 'migrations', `platform_identity_${f}.sql`), 'utf8');
37
+
38
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
39
+ let client;
40
+ try {
41
+ client = await pgPool.connect();
42
+ } catch (e) {
43
+ console.log(`invite_expiry_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
44
+ await pgPool.end().catch(() => {});
45
+ process.exit(0);
46
+ }
47
+
48
+ const q = (sql, params) => client.query(sql, params);
49
+ const SCHEMA = `t1002969_${process.pid}`;
50
+ let pass = 0;
51
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
52
+
53
+ // Core's admission reads go through ./pool, and the router through the doorway's
54
+ // pool: both are pointed at this one connection, whose search_path is the schema.
55
+ const poolPath = require.resolve('../src/bongos/pool.js');
56
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
57
+ const apiPath = require.resolve('../src/module-api.js');
58
+ const realApi = require(apiPath);
59
+ let notices = [];
60
+ require.cache[apiPath].exports = {
61
+ ...realApi,
62
+ pool: client,
63
+ projectJoinDoor: () => 'apply',
64
+ logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
65
+ requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
66
+ requirePermission: () => (_req, _res, next) => next(),
67
+ readApplicationVouch: () => null,
68
+ notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
69
+ };
70
+ const broadcastPath = require.resolve('../modules/onboarding/approval-broadcast.js');
71
+ require.cache[broadcastPath] = { id: broadcastPath, filename: broadcastPath, loaded: true, exports: { postMemberApprovalRequest: async () => ({}) } };
72
+
73
+ const dbKernel = require('../src/bongos/db-kernel.js');
74
+ const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
75
+ const pi = require('../modules/platform-identity/platform-identity.js');
76
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
77
+
78
+ const app = express();
79
+ app.use(express.json());
80
+ app.use(attachFail);
81
+ app.use('/api/gds', buildAccessRequestsRouter());
82
+ const server = http.createServer(app);
83
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
84
+ const port = server.address().port;
85
+ function call(method, path, body) {
86
+ return new Promise((resolve, reject) => {
87
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
88
+ const req = http.request({ host: '127.0.0.1', port, method, path,
89
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {} }, (res) => {
90
+ const chunks = [];
91
+ res.on('data', (c) => chunks.push(c));
92
+ res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
93
+ });
94
+ req.on('error', reject);
95
+ req.end(payload);
96
+ });
97
+ }
98
+
99
+ // Backdate one login's rows by `days` (an invite's resolved_at is its created_at).
100
+ const age = (login, days) => q(
101
+ `UPDATE access_requests SET created_at = now() - make_interval(days => $2),
102
+ resolved_at = CASE WHEN kind = 'invite' THEN now() - make_interval(days => $2) ELSE resolved_at END
103
+ WHERE github_login = $1`, [login, days]);
104
+
105
+ try {
106
+ await q(`CREATE SCHEMA ${SCHEMA}`);
107
+ await q(`SET search_path TO ${SCHEMA}`);
108
+ await q('CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())');
109
+ await q('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
110
+ await q(`INSERT INTO builders (id, github_login) VALUES (42, 'owner')`);
111
+ for (const f of ['060_access_requests', 'core_230_access_requests_applicant_vouch',
112
+ 'core_254_access_requests_applicant_handle', 'core_255_access_requests_invite_kind',
113
+ 'core_266_access_requests_invited_login_idx']) await q(coreSql(f));
114
+
115
+ // ── The instance: the door, the queues, the probe ────────────────────────
116
+ console.log('the instance:');
117
+ for (const login of ['eve', 'fay']) assert.equal((await call('POST', '/api/gds/access-requests/invite', { github_login: login })).status, 201);
118
+ await q(`INSERT INTO access_requests (github_login, status, resolved_at, created_at) VALUES ('cy', 'invited', now(), now() - interval '60 days')`);
119
+ await age('eve', 29);
120
+ await age('fay', 31);
121
+ ok(await dbKernel.hasInvitedAccessRequest('eve') === true, 'day 29: the invite still admits');
122
+ ok(await dbKernel.hasInvitedAccessRequest('fay') === false, 'day 31: the sign-in gate refuses it');
123
+ ok(await dbKernel.hasInvitedAccessRequest('cy') === true, 'an application approved 60 days ago is not an invite and still admits');
124
+ ok((await dbKernel.accessStateByLogin('fay')).invited === false, 'the waiting page agrees with the gate');
125
+
126
+ // core_266: the gate's own statement, captured as it runs, can use the invited-login
127
+ // index. Seq scans are switched off only so a tiny table still shows the plan.
128
+ const seen = [];
129
+ const realQuery = client.query.bind(client);
130
+ client.query = (text, params) => { seen.push({ text, params }); return realQuery(text, params); };
131
+ await dbKernel.hasInvitedAccessRequest('eve');
132
+ client.query = realQuery;
133
+ await q('SET enable_seqscan = off');
134
+ const plan = (await q(`EXPLAIN ${seen[0].text}`, seen[0].params)).rows.map((r) => r['QUERY PLAN']).join('\n');
135
+ await q('RESET enable_seqscan');
136
+ ok(/idx_access_requests_invited_login/.test(plan), 'the sign-in gate can read invited rows through core_266\'s index');
137
+
138
+ const hist = Object.fromEntries((await call('GET', '/api/gds/access-requests?status=invited')).json.requests.map((r) => [r.github_login, r]));
139
+ ok(hist.fay.expired === true && hist.eve.expired === false && hist.cy.expired === false, 'the queue marks only the expired invite');
140
+ ok(hist.cy.expires_at === null && new Date(hist.fay.expires_at) <= new Date() && new Date(hist.eve.expires_at) > new Date(),
141
+ 'and says when each invite closes; an application has no such day');
142
+
143
+ const probe = (await call('GET', '/api/gds/access-requests/status?github_login=fay')).json;
144
+ ok(probe.status === 'expired' && probe.admitted === false, 'the status probe reports expired, not invited');
145
+
146
+ notices = [];
147
+ const again = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
148
+ ok(again.status === 201 && await dbKernel.hasInvitedAccessRequest('fay') === true, 're-inviting an expired login opens a fresh door');
149
+ ok(notices.length === 1 && notices[0].invited === true, 'and tells the hub');
150
+ const out = (await call('GET', '/api/gds/access-requests/invites')).json.invites;
151
+ ok(out.filter((r) => r.github_login === 'fay').length === 1 && out.find((r) => r.github_login === 'fay').expired === false,
152
+ 'the outstanding list shows the live invite in place of the expired one');
153
+ const mine = (await call('GET', '/api/gds/access-requests/invites?mine=1')).json.invites;
154
+ ok(mine.filter((r) => r.github_login === 'fay').length === 1, 'and so does the sender\'s own list (?mine=1)');
155
+
156
+ notices = [];
157
+ await call('PATCH', `/api/gds/access-requests/${again.json.request.id}`, { status: 'dismissed' });
158
+ ok(notices.length === 1 && notices[0].invited === false, 'rescinding the live invite clears the hub notice; the expired row does not stand');
159
+
160
+ // ── The hub: the notice follows the door ──────────────────────────────────
161
+ console.log('the hub:');
162
+ for (const f of ['001_tables', '003_project_memberships', '010_membership_kind', '021_drop_legacy_role']) await q(piSql(f));
163
+ const joined = async (id) => (await q(
164
+ `SELECT membership_kind, joined_at FROM platform_identity_project_memberships WHERE client_id = 'demo' AND github_id = $1`, [id])).rows[0];
165
+ const backdate = (id, days) => q(
166
+ `UPDATE platform_identity_project_memberships SET joined_at = now() - make_interval(days => $2) WHERE client_id = 'demo' AND github_id = $1`, [id, days]);
167
+
168
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === true, 'a first invite writes a notice');
169
+ await backdate(1, 10);
170
+ const before = (await joined(1)).joined_at;
171
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === false
172
+ && (await joined(1)).joined_at.getTime() === before.getTime(), 'a re-send while the invite is live leaves the notice as it was');
173
+
174
+ await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client });
175
+ await backdate(2, 31);
176
+ const listed = async (id) => (await pi.listPendingInvitesForAccount(id, { pool: client })).length;
177
+ ok(await listed(1) === 1, 'a 10-day-old notice is listed');
178
+ ok(await listed(2) === 0, 'a 31-day-old notice is not: its door no longer opens');
179
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client })).invited === true && await listed(2) === 1,
180
+ 'a re-invite after the 30 days restamps the notice, so it shows again');
181
+
182
+ await q(`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, joined_at)
183
+ VALUES ('demo', 3, 'member', now() - interval '400 days')`);
184
+ const member = await joined(3);
185
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 3 }, { pool: client })).invited === false, 'inviting a member writes nothing');
186
+ const after = await joined(3);
187
+ ok(after.membership_kind === 'member' && after.joined_at.getTime() === member.joined_at.getTime(),
188
+ 'and their row is untouched, however old it is');
189
+
190
+ console.log(`\ninvite_expiry_db.mjs: ${pass} passed`);
191
+ } finally {
192
+ await new Promise((r) => server.close(r));
193
+ await q(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
194
+ client.release();
195
+ await pgPool.end().catch(() => {});
196
+ }
@@ -49,6 +49,7 @@ const PUBLISHED_SURFACE = [
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
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
52
+ 'inviteExpiry', // added by task 1002969 (ADR 0356): the ONE read-time definition of when a recruit invite's door closes. The sign-in gate reads it in core; the onboarding queues and the hub's notice list are modules, so without the doorway each would restate the 30 days and the predicate, and two copies can close a door on different days
52
53
  '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
53
54
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
54
55
  '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
@@ -6,8 +6,9 @@
6
6
  // drift quietly is worse than none, so the load-bearing ones are pinned here:
7
7
  //
8
8
  // D1 the hub relays and notifies; it never writes a project's admission row
9
- // D2 an invite is a row the existing gate already reads — `kind` and
10
- // `created_by` are descriptive and never enter the admission read
9
+ // D2 an invite is a row the existing gate already reads — `created_by` never
10
+ // enters the admission read, and `kind` enters it only through the invite
11
+ // expiry, which can close a door and never open one (ADR 0356 amends D2.5)
11
12
  // D3 the admission verdict stays `access_request.review` at the archon floor,
12
13
  // and `project.recruit`, once minted, takes exactly the shape the ADR fixes
13
14
  //
@@ -66,16 +67,22 @@ test('the ADR exists, is indexed, and ADR 0141 names it as superseding its §5 i
66
67
 
67
68
  // Both admission reads: the gate's own (hasInvitedAccessRequest) and the state the
68
69
  // "confirming access" waiting page polls (accessStateByLogin), which must agree with it.
69
- test('D2: both admission reads look at status alone — an invite and an approved application admit identically', () => {
70
+ // Since ADR 0356 (task 1002969) `kind` reaches them through ONE door: the ANDed
71
+ // invite-expiry unit, which only ever narrows `status = 'invited'`.
72
+ test('D2: both admission reads look at status, narrowed only by the invite expiry — never widened by kind', () => {
70
73
  const src = read('src', 'bongos', 'db-kernel.js');
71
74
  for (const fn of ['hasInvitedAccessRequest', 'accessStateByLogin']) {
72
75
  const m = new RegExp(`async function ${fn}\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src);
73
76
  assert.ok(m, `${fn} is still an admission read in db-kernel.js`);
74
77
  const body = m[1];
75
- assert.match(body, /status\s*=\s*'invited'/, `${fn}: admission is status = invited`);
76
- assert.doesNotMatch(body, /\bkind\b/, `${fn}: kind is descriptive; it must never decide admission`);
78
+ assert.match(body, /status\s*=\s*'invited'\s+AND\s+\$\{inviteLiveSql\(\)\}/, `${fn}: admission is status = invited AND the invite has not expired`);
79
+ assert.doesNotMatch(body, /\bkind\b/, `${fn}: kind is never read directly; only through inviteLiveSql`);
77
80
  assert.doesNotMatch(body, /\bcreated_by\b/, `${fn}: created_by is audit data; it must never decide admission`);
78
81
  }
82
+ // And that unit can only close a door: NOT (an invite AND past its window).
83
+ const { inviteLiveSql } = require('../src/bongos/invite-expiry.js');
84
+ assert.match(inviteLiveSql(), /^NOT \(kind = 'invite' AND created_at <= now\(\) - interval '\d+ days'\)$/,
85
+ 'the expiry is a pure narrowing: a row that is not an invite is untouched by it');
79
86
  });
80
87
 
81
88
  test('D1: no hub (platform-identity) source writes access_requests — the hub relays, it never admits', () => {