@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.
- package/.bongos-core.json +75 -45
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
- package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +51 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +20 -19
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-readings.json +9 -9
- package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
- package/modules/hall-ui/public/watch.js +9 -3
- package/modules/onboarding/routes/access-requests.js +43 -20
- package/modules/platform-identity/platform-identity.js +12 -8
- package/modules/platform-identity/tests/platform-identity.mjs +4 -2
- package/modules/provisioning/core-upgrade.js +70 -1
- package/modules/provisioning/migrations/provisioning_029_update_channel.sql +24 -0
- package/modules/provisioning/routes/core-upgrade.js +21 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/login.js +6 -0
- package/scripts/gds/provision-core-upgrade.js +17 -11
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/db-kernel.js +6 -3
- package/src/bongos/invite-expiry.js +47 -0
- package/src/module-api.js +10 -1
- package/tests/access_requests_invite.mjs +2 -1
- package/tests/application_lifecycle.mjs +164 -5
- package/tests/bongos_login.mjs +2 -0
- package/tests/core_upgrade_door.mjs +80 -1
- package/tests/core_upgrade_runner.mjs +54 -1
- package/tests/hub_invite_notices.mjs +5 -2
- package/tests/invite_expiry.mjs +37 -0
- package/tests/invite_expiry_db.mjs +196 -0
- package/tests/module_api.mjs +1 -0
- 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
|
+
}
|
package/tests/module_api.mjs
CHANGED
|
@@ -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 — `
|
|
10
|
-
// `
|
|
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
|
-
|
|
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
|
|
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', () => {
|