@bongos/core 1.19.1024 → 1.19.1025

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 (32) hide show
  1. package/.bongos-core.json +50 -35
  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 +4 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
  8. package/docs/api/openapi.json +58 -4
  9. package/docs/api-reference.md +4 -3
  10. package/docs/copy-inventory.md +17 -15
  11. package/docs/copy-registry.json +41 -23
  12. package/docs/module-api-changelog.md +3 -1
  13. package/migrations/core_255_access_requests_invite_kind.sql +52 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/onboarding/routes/access-requests.js +62 -11
  16. package/modules/platform-identity/connection-suggestions.js +1 -1
  17. package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
  18. package/modules/platform-identity/terms-acceptance.js +34 -14
  19. package/modules/public-landing/public/terms.html +2 -1
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/scripts/gds/run-unit-tests.js +5 -0
  23. package/src/module-api.js +1 -1
  24. package/tests/access_requests_invite.mjs +81 -4
  25. package/tests/application_lifecycle.mjs +174 -6
  26. package/tests/connection_suggestions.mjs +1 -1
  27. package/tests/connections_api.mjs +1 -1
  28. package/tests/handle_machinery.mjs +2 -0
  29. package/tests/profile_connections_ui.mjs +2 -2
  30. package/tests/profile_rollup_consent.mjs +3 -0
  31. package/tests/terms_acceptance.mjs +264 -14
  32. package/tests/terms_grandfather_db.mjs +144 -0
@@ -0,0 +1,68 @@
1
+ -- platform_identity_024_terms_grandfathered.sql — grandfather existing accounts
2
+ -- onto the platform T&C, and record that they were grandfathered rather than
3
+ -- signed in (task 1003360; owner decision on blocker 1000123; ADR 0217 D3,
4
+ -- amending ADR 0213 D4).
5
+ --
6
+ -- WHY. Migration 019 made consent two columns and deliberately backfilled
7
+ -- nothing, so every account the hub had already witnessed ('active') stopped
8
+ -- being published until its owner next signed in. The owner chose instead to
9
+ -- grandfather them — nothing goes dark — having been shown that this records a
10
+ -- consent which did not happen. So the record must not hide it: a grandfathered
11
+ -- acceptance is marked as such, and /terms says so.
12
+ --
13
+ -- WHAT IT DOES, once:
14
+ -- 1. adds terms_accepted_source ('signin' | 'grandfathered'). A named marker,
15
+ -- not a magic timestamp, so any later audit can count the two apart.
16
+ -- 2. marks every acceptance already on record as 'signin' — until now the one
17
+ -- statement that could write an acceptance was the hub sign-in
18
+ -- (upsertAccount's activate branch, ADR 0213 D1). This runs FIRST, or step 3's
19
+ -- rows would be relabelled too.
20
+ -- 3. grandfathers every 'active' account with no acceptance: stamped now(),
21
+ -- with the version in force today, marked 'grandfathered'. profile_state is
22
+ -- not touched — a 'provisional' account was never public and stays
23
+ -- unpublished; this keeps published profiles published, it publishes nobody.
24
+ --
25
+ -- ONE-SHOT, ON PURPOSE. The writes run only in the transaction that creates the
26
+ -- column. Grandfathering is an event at this deploy ("accounts that existed
27
+ -- before these terms"), not a rule: replaying this file (the integration tests
28
+ -- do) must never grandfather an account created since.
29
+ --
30
+ -- The version literal is pinned, not a placeholder: it names the text these
31
+ -- accounts were accepted under, and a later revision must not rewrite that.
32
+ --
33
+ -- AFTER THIS, the grandfather mark gives way to a real acceptance: the owner's
34
+ -- next hub sign-in restamps all three columns as a 'signin'
35
+ -- (terms-acceptance.js acceptanceConflictSet). No runtime code writes
36
+ -- 'grandfathered'; tests/terms_acceptance.mjs fails any that does.
37
+ --
38
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Safe to re-run.
39
+
40
+ BEGIN;
41
+
42
+ DO $$
43
+ BEGIN
44
+ IF NOT EXISTS (
45
+ SELECT 1 FROM information_schema.columns
46
+ WHERE table_schema = current_schema()
47
+ AND table_name = 'platform_identity_accounts'
48
+ AND column_name = 'terms_accepted_source'
49
+ ) THEN
50
+ ALTER TABLE platform_identity_accounts
51
+ ADD COLUMN terms_accepted_source text
52
+ CHECK (terms_accepted_source IN ('signin', 'grandfathered'));
53
+
54
+ UPDATE platform_identity_accounts
55
+ SET terms_accepted_source = 'signin'
56
+ WHERE terms_accepted_at IS NOT NULL;
57
+
58
+ UPDATE platform_identity_accounts
59
+ SET terms_accepted_at = now(),
60
+ terms_version = '2026-08-29',
61
+ terms_accepted_source = 'grandfathered'
62
+ WHERE profile_state = 'active'
63
+ AND terms_accepted_at IS NULL;
64
+ END IF;
65
+ END
66
+ $$;
67
+
68
+ COMMIT;
@@ -30,12 +30,22 @@
30
30
  // reaches these statements, which is the whole point: an acceptance record a
31
31
  // caller could assert would be evidence of nothing.
32
32
  //
33
- // IDEMPOTENT BY CONSTRUCTION: the conflict branch is COALESCE(existing, new), so
34
- // the FIRST acceptance stands and a second sign-in never rewrites or duplicates
35
- // it. The two columns are only ever written together, so they cannot diverge.
33
+ // THE FIRST ACCEPTANCE A HUMAN GAVE STANDS. The conflict branch keeps an existing
34
+ // acceptance (COALESCE), so a second sign-in never rewrites or duplicates one.
35
+ // The three columns are only ever written together, so they cannot diverge.
36
+ //
37
+ // HOW IT WAS GIVEN IS RECORDED TOO (task 1003360, ADR 0217 D3). Every acceptance
38
+ // written here is marked terms_accepted_source = 'signin'. The only other value,
39
+ // the grandfather marker, is written by migration platform_identity_024 alone —
40
+ // accounts that already existed were accepted on their owner's behalf, and the
41
+ // record says so rather than passing that off as a sign-in. A grandfather is not
42
+ // an acceptance its owner gave, so it is the one acceptance that does not
43
+ // "stand": the owner's next sign-in replaces it with their own. This file only
44
+ // ever READS the marker. tests/terms_acceptance.mjs holds both rules, and fails
45
+ // any other server file that writes these columns or names the marker.
36
46
  //
37
47
  // A LATER REVISION IS A KNOWN GAP, NOT A SILENT ONE. Because the version is
38
- // recorded and never overwritten, publishing a new TERMS_VERSION leaves every
48
+ // recorded and a sign-in never overwrites it, publishing a new TERMS_VERSION leaves every
39
49
  // existing row naming the text it actually agreed to — so the platform can see
40
50
  // who has not accepted the new one. Re-acceptance on revision is deliberately
41
51
  // NOT built here (ADR 0213 D4): it is a product flow, not a storage shape.
@@ -60,7 +70,7 @@ function assertPlaceholder(fn, value) {
60
70
  // The acceptance columns, for an INSERT column list. Leading comma: it appends
61
71
  // to an existing list rather than standing alone.
62
72
  function acceptanceInsertColumns() {
63
- return ', terms_accepted_at, terms_version';
73
+ return ', terms_accepted_at, terms_version, terms_accepted_source';
64
74
  }
65
75
 
66
76
  // The matching VALUES fragment. A row created by a NON-activating caller
@@ -70,23 +80,33 @@ function acceptanceInsertValues(activateParam, versionParam) {
70
80
  assertPlaceholder('acceptanceInsertValues', activateParam);
71
81
  assertPlaceholder('acceptanceInsertValues', versionParam);
72
82
  return `, CASE WHEN ${activateParam}::boolean THEN now() ELSE NULL END`
73
- + `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END`;
83
+ + `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END`
84
+ + `, CASE WHEN ${activateParam}::boolean THEN 'signin' ELSE NULL END`;
74
85
  }
75
86
 
76
- // The ON CONFLICT DO UPDATE SET fragment — the idempotent half. COALESCE keeps
77
- // the FIRST acceptance, so signing in again re-reads the terms but does not
78
- // restamp them; a non-activating upsert leaves both columns exactly as they are,
79
- // mirroring the one-way rule profile_state already follows.
87
+ // The ON CONFLICT DO UPDATE SET fragment — the write-once half. On the
88
+ // activating sign-in: a grandfathered acceptance is replaced outright; any other
89
+ // acceptance on record is kept (COALESCE), so a second sign-in re-reads the terms
90
+ // without restamping them; and the source becomes 'signin' either way, since a
91
+ // human just accepted. Keying the replacement on the grandfather marker, not on
92
+ // "no signin recorded", matters: an acceptance with no source (written by the
93
+ // pre-024 code in the deploy window) is a real sign-in and keeps its date. A
94
+ // non-activating upsert leaves every column exactly as it is, mirroring the
95
+ // one-way rule profile_state already follows. Postgres evaluates each right-hand
96
+ // side against the row BEFORE the update, so all three read the same old source.
80
97
  //
81
98
  // Trailing comma: it sits between two other assignments in the SET list.
82
99
  function acceptanceConflictSet(activateParam, versionParam) {
83
100
  assertPlaceholder('acceptanceConflictSet', activateParam);
84
101
  assertPlaceholder('acceptanceConflictSet', versionParam);
85
102
  const self = 'platform_identity_accounts';
86
- return `terms_accepted_at = CASE WHEN ${activateParam}::boolean`
87
- + ` THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
88
- + ` terms_version = CASE WHEN ${activateParam}::boolean`
89
- + ` THEN COALESCE(${self}.terms_version, ${versionParam}::text) ELSE ${self}.terms_version END,`;
103
+ const signin = `${activateParam}::boolean`;
104
+ const replaces = `WHEN ${signin} AND ${self}.terms_accepted_source = 'grandfathered'`;
105
+ return `terms_accepted_at = CASE ${replaces} THEN now()`
106
+ + ` WHEN ${signin} THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
107
+ + ` terms_version = CASE ${replaces} THEN ${versionParam}::text`
108
+ + ` WHEN ${signin} THEN COALESCE(${self}.terms_version, ${versionParam}::text) ELSE ${self}.terms_version END,`
109
+ + ` terms_accepted_source = CASE WHEN ${signin} THEN 'signin' ELSE ${self}.terms_accepted_source END,`;
90
110
  }
91
111
 
92
112
  module.exports = {
@@ -67,7 +67,8 @@
67
67
  <p><b>These numbers describe; they do not govern.</b> Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and none may treat it as permission. A rank shown here is that project&rsquo;s own standing for you, as that project reported it &mdash; the platform holds no rank of its own.</p>
68
68
  <p><b>What is public.</b> Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in here stays provisional and is published nowhere.</p>
69
69
  <p><b>What you control.</b> From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform&rsquo;s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.</p>
70
- <p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written only by signing in here, and only once &mdash; signing in again does not change the original date. This is version <b>2026-08-29</b>.</p>
70
+ <p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written by signing in here, and only once &mdash; signing in again does not change the original date. This is version <b>2026-08-29</b>.</p>
71
+ <p><b>Accounts from before these terms.</b> If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in.</p>
71
72
  <!--apex-only:end-->
72
73
  <h2>Fair use</h2>
73
74
  <p>Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.</p>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1024",
3
+ "version": "1.19.1025",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1024",
9
+ "version": "1.19.1025",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1024",
3
+ "version": "1.19.1025",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -198,6 +198,11 @@ const INTEGRATION = new Set([
198
198
  // DB-free siblings (session_records, session_cost) cover the transcript
199
199
  // de-duplication half.
200
200
  'session_cost_row_raise',
201
+ // task 1003360: the T&C grandfather migration against real SQL. The DB-free
202
+ // sibling (the T&C acceptance suite) pins the statement text and runs the conflict
203
+ // branch over a fake table; only a real engine runs the one-shot DO block and
204
+ // shows a legacy account published again and marked apart from a sign-in.
205
+ 'terms_grandfather_db',
201
206
  'setup_smoke',
202
207
  'status-probe',
203
208
  ]);
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1024'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1025'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -4,7 +4,9 @@
4
4
  // branch is pinned: already a member, an invite already standing, a pending
5
5
  // request resolved in place, a fresh row, a bad username, a DB failure. Plus
6
6
  // the public POST's three doors against the same router: apply reaches the
7
- // queries, invite_only and open refuse before any.
7
+ // queries, invite_only and open refuse before any. Task 1002814 (ADR 0335 D2)
8
+ // adds the invite row's own marks — kind, created_by, the inviter's note — and
9
+ // the outstanding-invite list; tests/application_lifecycle.mjs executes them.
8
10
  //
9
11
  // The doorway is stubbed in require.cache BEFORE the route loads (the route
10
12
  // destructures pool/validators at require time — the web_admission_status
@@ -76,6 +78,15 @@ function post(pathname, body) {
76
78
  req.end();
77
79
  });
78
80
  }
81
+ function get(pathname) {
82
+ return new Promise((resolve, reject) => {
83
+ http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
84
+ let data = '';
85
+ res.on('data', (c) => { data += c; });
86
+ res.on('end', () => resolve({ status: res.statusCode, json: data ? JSON.parse(data) : null }));
87
+ }).on('error', reject);
88
+ });
89
+ }
79
90
  const reset = (m, s) => { mode = m; script = s; calls = []; };
80
91
  const none = { rows: [] };
81
92
  const one = (row = {}) => ({ rows: [row] });
@@ -112,14 +123,80 @@ test('invite: a pending request is resolved in place — the same row the sign-i
112
123
  assert.deepEqual(calls[2].params, [42, 'Octocat'], 'resolved by the Archon; the login is trimmed and un-@ed, case kept for lower()');
113
124
  });
114
125
 
115
- test('invite: no request at all → a fresh invited row, 201', async () => {
116
- reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited' })]);
126
+ test('invite: no request at all → a fresh invited row, 201, marked as an invite from its sender', async () => {
127
+ reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited', kind: 'invite' })]);
117
128
  const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
118
129
  assert.equal(r.status, 201);
119
130
  assert.equal(r.json.request.status, 'invited');
131
+ assert.equal(r.json.request.kind, 'invite');
120
132
  assert.match(calls[3].sql, /INSERT INTO access_requests/);
121
133
  assert.match(calls[3].sql, /'invited'/);
122
- assert.deepEqual(calls[3].params, ['octocat', 42]);
134
+ assert.match(calls[3].sql, /'invite'/, 'kind = invite, so the row can be told apart from an approved application (ADR 0335 D2.1)');
135
+ assert.match(calls[3].sql, /\bcreated_by\b/, 'created_by names the sender, and no rescind overwrites it (D2.2)');
136
+ assert.deepEqual(calls[3].params, ['octocat', null, 42]);
137
+ });
138
+
139
+ test('invite: an optional note rides on the invite row — the inviter’s words', async () => {
140
+ reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited', kind: 'invite' })]);
141
+ const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat', note: ' Come build the parser with us ' });
142
+ assert.equal(r.status, 201);
143
+ assert.match(calls[3].sql, /\bnote\b/);
144
+ assert.deepEqual(calls[3].params, ['octocat', 'Come build the parser with us', 42]);
145
+ });
146
+
147
+ test('invite: a note over 500 characters → 400 before any query', async () => {
148
+ reset('invite_only', []);
149
+ const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat', note: 'x'.repeat(501) });
150
+ assert.equal(r.status, 400);
151
+ assert.equal(calls.length, 0);
152
+ });
153
+
154
+ test('invite: resolving a pending application in place leaves it an application — its kind, sender column and note untouched', async () => {
155
+ reset('invite_only', [none, none, one({ id: 7, github_login: 'octocat', status: 'invited', kind: 'application' })]);
156
+ const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat', note: 'not their words' });
157
+ assert.equal(r.status, 200);
158
+ assert.equal(r.json.request.kind, 'application', 'it was an application, and its history says so (D2.1)');
159
+ const update = calls[2].sql;
160
+ assert.match(update, /UPDATE access_requests/);
161
+ assert.doesNotMatch(update, /\bkind\s*=/, 'the resolve never re-labels an application as an invite');
162
+ assert.doesNotMatch(update, /\bcreated_by\b/, 'created_by is set on invite rows only (D2.2)');
163
+ assert.doesNotMatch(update, /\bnote\b/, 'the applicant’s note is theirs — an inviter’s note never overwrites it');
164
+ });
165
+
166
+ // ── GET /access-requests/invites — the sender's outstanding list ─────────────
167
+
168
+ test('invites list: outstanding invites only, each naming who sent it', async () => {
169
+ const sent = { id: 8, github_login: 'octocat', note: 'hi', created_at: '2026-09-25T00:00:00Z', invited_by_login: 'archon' };
170
+ reset('invite_only', [one(sent)]);
171
+ const r = await get('/api/gds/access-requests/invites');
172
+ assert.equal(r.status, 200);
173
+ assert.deepEqual(r.json.invites, [sent]);
174
+ assert.equal(calls.length, 1);
175
+ const { sql } = calls[0];
176
+ assert.match(sql, /ar\.kind = 'invite'/, 'an approved application is not an invite this project sent');
177
+ assert.match(sql, /ar\.status = 'invited'/, 'a rescinded invite is no longer outstanding');
178
+ assert.match(sql, /NOT EXISTS \(SELECT 1 FROM builders b\s+WHERE lower\(b\.github_login\) = lower\(ar\.github_login\)\)/,
179
+ 'an invitee who already signed in is a member, not an outstanding invite');
180
+ assert.match(sql, /LEFT JOIN builders ib ON ib\.id = ar\.created_by/, 'the sender is read from created_by, which survives a rescind');
181
+ assert.doesNotMatch(sql, /ar\.created_by = \$/, 'without ?mine=1 the list is the whole project’s');
182
+ assert.deepEqual(calls[0].params, []);
183
+ });
184
+
185
+ test('invites list ?mine=1: only the caller’s own invites', async () => {
186
+ reset('invite_only', [none]);
187
+ const r = await get('/api/gds/access-requests/invites?mine=1');
188
+ assert.equal(r.status, 200);
189
+ assert.deepEqual(r.json.invites, []);
190
+ assert.match(calls[0].sql, /AND ar\.created_by = \$1/);
191
+ assert.deepEqual(calls[0].params, [42], 'narrowed to the acting builder, never to a caller-supplied id');
192
+ });
193
+
194
+ test('invites list: a DB failure → 500 list_invites_failed, no internals in the message', async () => {
195
+ reset('invite_only', [new Error('connection refused')]);
196
+ const r = await get('/api/gds/access-requests/invites');
197
+ assert.equal(r.status, 500);
198
+ assert.equal(r.json.error.code, 'list_invites_failed');
199
+ assert.doesNotMatch(r.json.error.message, /connection refused/);
123
200
  });
124
201
 
125
202
  test('invite: a bad username → 400 before any query', async () => {
@@ -65,11 +65,14 @@ let nextId = 100;
65
65
  // comparing them rather than by reading the code and believing it.
66
66
  function seed() {
67
67
  const now = Date.now();
68
+ // `kind`/`created_by` carry core_255's column default on every seeded row:
69
+ // each of these is an application, and cy's is an APPROVED one.
70
+ const app = { kind: 'application', created_by: null };
68
71
  rows = [
69
- { id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
70
- { id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
71
- { id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR },
72
- { id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR },
72
+ { id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
73
+ { id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
74
+ { id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR, ...app },
75
+ { id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR, ...app },
73
76
  ];
74
77
  builders = [{ github_login: 'existing' }];
75
78
  statements = [];
@@ -102,6 +105,15 @@ function predicateFor(rawWhere, params) {
102
105
  where = where.replace(transition[0], ' TRUE ');
103
106
  }
104
107
 
108
+ // The outstanding-invite list's member exclusion (task 1002814): an invitee who
109
+ // has signed in is a member, not an outstanding invite. Stripped as one unit,
110
+ // like the transition guard, so its inner `lower(...)` is not re-read.
111
+ const notMember = /NOT EXISTS \(SELECT 1 FROM builders b WHERE lower\(b\.github_login\) = lower\(ar\.github_login\)\)/.exec(where);
112
+ if (notMember) {
113
+ tests.push((r) => !isBuilder(r.github_login));
114
+ where = where.replace(notMember[0], ' TRUE ');
115
+ }
116
+
105
117
  let m;
106
118
  const eat = () => { where = where.replace(m[0], ' TRUE '); };
107
119
  if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
@@ -112,6 +124,8 @@ function predicateFor(rawWhere, params) {
112
124
  }
113
125
  if ((m = /\b(?:ar\.)?status = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => r.status === v); eat(); }
114
126
  if ((m = /\b(?:ar\.)?status = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
127
+ if ((m = /\b(?:ar\.)?kind = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.kind === v); eat(); }
128
+ if ((m = /\b(?:ar\.)?created_by = \$(\d+)/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.created_by) === String(v)); eat(); }
115
129
  if ((m = /\bapplicant_github_id IS NULL\b/.exec(where))) { tests.push((r) => r.applicant_github_id == null); eat(); }
116
130
 
117
131
  for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
@@ -181,11 +195,47 @@ const pool = {
181
195
  applicant_github_id: applicantGithubId, applicant_handle: applicantHandle, vouched_at: vouchedAt,
182
196
  resolved_by_builder_id: null, resolved_at: null,
183
197
  created_at: Date.now(),
198
+ kind: 'application', created_by: null, // the column defaults — the public write names neither
184
199
  };
185
200
  rows.push(fresh);
186
201
  return { rows: [{ id: fresh.id, github_login: fresh.github_login, status: fresh.status, created_at: fresh.created_at }] };
187
202
  }
188
203
 
204
+ // The invite route's fresh row (task 1002814, ADR 0335 D2). Matched EXACTLY,
205
+ // like the public write above, so `kind` or `created_by` cannot be dropped
206
+ // from the real statement with every test still green.
207
+ if (/^INSERT INTO access_requests \(github_login, note, kind, created_by, status, resolved_by_builder_id, resolved_at\) VALUES \(\$1, \$2, 'invite', \$3, 'invited', \$3, now\(\)\) RETURNING id, github_login, status, kind, resolved_at$/.test(sql)) {
208
+ const [login, note, sender] = params;
209
+ const at = Date.now();
210
+ const fresh = {
211
+ id: nextId++, github_login: login, display_name: null, note, status: 'invited',
212
+ applicant_github_id: null, applicant_handle: null, vouched_at: null,
213
+ resolved_by_builder_id: sender, resolved_at: at, created_at: at,
214
+ kind: 'invite', created_by: sender,
215
+ };
216
+ rows.push(fresh);
217
+ return { rows: [{ id: fresh.id, github_login: login, status: 'invited', kind: 'invite', resolved_at: at }] };
218
+ }
219
+
220
+ // The invite route resolving a PENDING application in place. The SET list is
221
+ // matched exactly: it must never touch kind, created_by or the applicant's note.
222
+ if ((m = /^UPDATE access_requests SET status = 'invited', resolved_by_builder_id = \$1, resolved_at = now\(\) WHERE (.+?) RETURNING id, github_login, status, kind, resolved_at$/.exec(sql))) {
223
+ const hit = rows.filter(predicateFor(m[1], params));
224
+ for (const r of hit) { r.status = 'invited'; r.resolved_by_builder_id = params[0]; r.resolved_at = Date.now(); }
225
+ return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, kind: r.kind, resolved_at: r.resolved_at })) };
226
+ }
227
+
228
+ // The outstanding-invite list. Its sender is joined through created_by.
229
+ if ((m = /^SELECT ar\.id, ar\.github_login, ar\.note, ar\.created_at, ib\.github_login AS invited_by_login FROM access_requests ar LEFT JOIN builders ib ON ib\.id = ar\.created_by WHERE (.+?) ORDER BY ar\.created_at DESC LIMIT 200$/.exec(sql))) {
230
+ const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
231
+ return {
232
+ rows: hit.map((r) => ({
233
+ id: r.id, github_login: r.github_login, note: r.note, created_at: r.created_at,
234
+ invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null,
235
+ })),
236
+ };
237
+ }
238
+
189
239
  // The owner's queue.
190
240
  if ((m = /^SELECT ar\.id, .* FROM access_requests ar .* WHERE (.+?) ORDER BY/.exec(sql))) {
191
241
  const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
@@ -197,6 +247,9 @@ const pool = {
197
247
  // Returned only when the real SELECT names it, so the queue test below
198
248
  // proves the route reads the column rather than that this fake invents it.
199
249
  ...(/\bar\.applicant_handle\b/.test(sql) ? { applicant_handle: r.applicant_handle ?? null } : {}),
250
+ ...(/\bar\.kind\b/.test(sql) ? { kind: r.kind } : {}),
251
+ ...(/\bib\.github_login AS invited_by_login\b/.test(sql) && /\bON ib\.id = ar\.created_by\b/.test(sql)
252
+ ? { invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null } : {}),
200
253
  vouched: r.applicant_github_id !== null,
201
254
  resolved_by_login: null,
202
255
  })),
@@ -280,6 +333,9 @@ const dbKernel = require('../src/bongos/db-kernel.js');
280
333
  // Who is calling, and at what LIVE rank. The rank is resolved through the real
281
334
  // government port on every request — never cached — exactly as production does.
282
335
  let actingRank = 'archon';
336
+ // Which builder is acting — switched only to prove an invite keeps its SENDER
337
+ // when a different archon rescinds it.
338
+ let actingId = 42;
283
339
 
284
340
  require.cache[apiPath].exports = {
285
341
  ...realApi,
@@ -287,7 +343,7 @@ require.cache[apiPath].exports = {
287
343
  joinabilityMode: () => 'apply',
288
344
  projectJoinDoor: () => 'apply',
289
345
  logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
290
- requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
346
+ requireBuilder: (req, _res, next) => { req.builder = { id: actingId, github_login: 'owner' }; next(); },
291
347
  // requirePermission is NOT stubbed: realApi's own gate is used, so the denial
292
348
  // below is the production denial.
293
349
  readApplicationVouch: (token, { githubLogin } = {}) =>
@@ -336,7 +392,7 @@ function call(method, pathname, body, extraHeaders = {}) {
336
392
  req.end(payload);
337
393
  });
338
394
  }
339
- const reset = (rank = 'archon') => { seed(); actingRank = rank; };
395
+ const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; };
340
396
 
341
397
  // ===========================================================================
342
398
  // The interpreter is the instrument, so it is calibrated first
@@ -692,6 +748,118 @@ test('only invited|dismissed are accepted, and a rejected status writes nothing'
692
748
  assert.equal(JSON.stringify(rows), snapshot);
693
749
  });
694
750
 
751
+ // ===========================================================================
752
+ // Invite — a pre-approved row the gate already reads (task 1002814, ADR 0335 D2)
753
+ // ===========================================================================
754
+
755
+ // The two archons the invite tests act as. Only their ids matter: the sender is
756
+ // joined through created_by → builders.id.
757
+ const ARCHONS = [{ id: 42, github_login: 'owner' }, { id: 43, github_login: 'second' }];
758
+
759
+ test('invite: a login that never applied signs straight in, and the row says it is an invite and who sent it', async () => {
760
+ reset();
761
+ builders.push(...ARCHONS);
762
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'precondition: eve never applied');
763
+ const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
764
+ assert.equal(r.status, 201);
765
+ assert.equal(r.json.request.kind, 'invite');
766
+ const eve = rows.find((x) => x.github_login === 'eve');
767
+ assert.equal(eve.kind, 'invite');
768
+ assert.equal(eve.created_by, 42, 'the sender');
769
+ assert.equal(eve.note, 'Come build the parser', 'the inviter’s words, and kind says whose');
770
+ statements = [];
771
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'the REAL admission read admits her — signing in is the acceptance act');
772
+ assert.ok(statements.length > 0 && statements.every(({ sql }) => !/\bkind\b|\bcreated_by\b/.test(sql)),
773
+ 'and it admitted her without reading either new column (D2.5)');
774
+ });
775
+
776
+ test('rescind: an invite keeps its sender through a rescind by a different archon, and the door re-locks', async () => {
777
+ reset();
778
+ builders.push(...ARCHONS);
779
+ const sent = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
780
+ actingId = 43;
781
+ const r = await call('PATCH', `/api/gds/access-requests/${sent.json.request.id}`, { status: 'dismissed' });
782
+ assert.equal(r.status, 200);
783
+ const eve = rows.find((x) => x.github_login === 'eve');
784
+ assert.equal(eve.status, 'dismissed');
785
+ assert.equal(eve.resolved_by_builder_id, 43, 'resolved_by records whoever acted last (ADR 0201 D4.2)…');
786
+ assert.equal(eve.created_by, 42, '…so the sender needs its own column, and the rescind does not touch it (D2.2)');
787
+ assert.equal(eve.kind, 'invite', 'a rescinded invite is still an invite in the history');
788
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'the door is shut again');
789
+ });
790
+
791
+ test('invites list: outstanding only — not an approved application, a rescinded invite, or an invitee who signed in', async () => {
792
+ reset();
793
+ builders.push(...ARCHONS);
794
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'hi' });
795
+ actingId = 43;
796
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
797
+ const gus = await call('POST', '/api/gds/access-requests/invite', { github_login: 'gus' });
798
+ await call('PATCH', `/api/gds/access-requests/${gus.json.request.id}`, { status: 'dismissed' });
799
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'hal' });
800
+ builders.push({ id: 90, github_login: 'HAL' }); // hal accepted: he signed in
801
+ const r = await call('GET', '/api/gds/access-requests/invites');
802
+ assert.equal(r.status, 200);
803
+ const byLogin = Object.fromEntries(r.json.invites.map((x) => [x.github_login, x]));
804
+ assert.deepEqual(Object.keys(byLogin).sort(), ['eve', 'fay'],
805
+ 'cy (an approved application), gus (rescinded) and hal (signed in, case-insensitively) are not outstanding');
806
+ assert.equal(byLogin.eve.invited_by_login, 'owner');
807
+ assert.equal(byLogin.fay.invited_by_login, 'second', 'each names its own sender');
808
+ assert.equal(byLogin.eve.note, 'hi');
809
+
810
+ const mine43 = await call('GET', '/api/gds/access-requests/invites?mine=1');
811
+ assert.deepEqual(mine43.json.invites.map((x) => x.github_login), ['fay'], '?mine=1: the sender lists their own outstanding invites');
812
+ actingId = 42;
813
+ const mine42 = await call('GET', '/api/gds/access-requests/invites?mine=1');
814
+ assert.deepEqual(mine42.json.invites.map((x) => x.github_login), ['eve']);
815
+ });
816
+
817
+ test('the queue history tells an invite from an approved application, and names the sender', async () => {
818
+ reset();
819
+ builders.push(...ARCHONS);
820
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
821
+ await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' }); // approve ada's application
822
+ const r = await call('GET', '/api/gds/access-requests?status=invited');
823
+ const byLogin = Object.fromEntries(r.json.requests.map((x) => [x.github_login, x]));
824
+ assert.equal(byLogin.cy.kind, 'application', 'cy applied and was approved');
825
+ assert.equal(byLogin.cy.invited_by_login, null);
826
+ assert.equal(byLogin.eve.kind, 'invite', 'eve never applied — an archon invited her');
827
+ assert.equal(byLogin.eve.invited_by_login, 'owner');
828
+ // `note` on this read has always meant the APPLICANT's words (the hall quotes it
829
+ // as theirs), so an inviter's note must never arrive in it.
830
+ assert.equal(byLogin.eve.note, null, 'an inviter’s note is not the invitee’s words');
831
+ assert.equal(byLogin.eve.invite_note, 'Come build the parser');
832
+ assert.equal(byLogin.ada.note, 'I build tools', 'an applicant’s note stays where it always was');
833
+ assert.equal(byLogin.ada.invite_note, null);
834
+ });
835
+
836
+ test('invite: resolving a pending application in place keeps it an application, with the applicant’s own note', async () => {
837
+ reset();
838
+ builders.push(...ARCHONS);
839
+ const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'ada', note: 'not their words' });
840
+ assert.equal(r.status, 200);
841
+ assert.equal(r.json.request.kind, 'application');
842
+ assert.equal(row(1).status, 'invited');
843
+ assert.equal(row(1).kind, 'application', 'it was an application, and its history says so (D2.1)');
844
+ assert.equal(row(1).created_by, null, 'created_by is set on invite rows only (D2.2)');
845
+ assert.equal(row(1).note, 'I build tools', 'an inviter never overwrites the applicant’s note');
846
+ assert.equal(rows.filter((x) => x.github_login === 'ada').length, 1, 'resolved, not duplicated');
847
+ });
848
+
849
+ for (const rank of ['thetes', 'metic']) {
850
+ test(`a ${rank} can neither invite nor list outstanding invites — access_request.review until R3 mints project.recruit`, async () => {
851
+ reset(rank);
852
+ const snapshot = JSON.stringify(rows);
853
+ const invited = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
854
+ assert.equal(invited.status, 403);
855
+ assert.deepEqual(invited.json.error.details.required, ['access_request.review']);
856
+ const listed = await call('GET', '/api/gds/access-requests/invites');
857
+ assert.equal(listed.status, 403, 'the list names logins that are not members yet');
858
+ assert.equal(JSON.stringify(rows), snapshot, 'nothing was written');
859
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false);
860
+ });
861
+ }
862
+
695
863
  // ===========================================================================
696
864
  // Authority — "a builder cannot approve or decline for a project they don't own"
697
865
  // ===========================================================================
@@ -76,7 +76,7 @@ test('scores mutuals and shared projects per D5\'s weights, caps mutuals at 10,
76
76
  assert.deepEqual(out.map((s) => s.handle), ['ada', 'grace'], '2003 is excluded and never appears; 2001 outranks 2002');
77
77
  assert.deepEqual(Object.keys(out[0]).sort(), ['avatar_url', 'display_name', 'handle', 'reason'],
78
78
  'an explicit whitelist — no score, no github_id');
79
- assert.equal(out[0].reason, '3 mutual connections', 'mutuals is the primary (and only) signal for ada');
79
+ assert.equal(out[0].reason, '3 connections in common', 'mutuals is the primary (and only) signal for ada — worded to match the profile page (task 1004253)');
80
80
  assert.equal(out[1].reason, 'You both build on Acme', 'shared projects outweighs the single mutual, and the project is named');
81
81
  });
82
82
 
@@ -554,7 +554,7 @@ test('suggestions: returns the ranked list with reasons, and costs the bounded r
554
554
  const res = await call('GET', '/api/bongos/connections/suggestions');
555
555
  assert.equal(res.status, 200);
556
556
  assert.deepEqual(res.json.suggestions.map((s) => s.handle), ['bob']);
557
- assert.equal(res.json.suggestions[0].reason, '2 mutual connections');
557
+ assert.equal(res.json.suggestions[0].reason, '2 connections in common');
558
558
  assert.equal(res.json.count, res.json.suggestions.length, 'the count is the array\'s own length');
559
559
  assert.equal(calls.length, 6, 'own account, then the three candidate reads, then identity + last-active');
560
560
  });
@@ -70,6 +70,8 @@ try {
70
70
  // handle assertion.
71
71
  await q(migrationSql('platform_identity_012_account_privacy.sql'));
72
72
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
73
+ // upsertAccount writes terms_accepted_source (024, task 1003360).
74
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
73
75
  await cleanup();
74
76
 
75
77
  await q(
@@ -950,12 +950,12 @@ test('the own profile renders "builders you may know" with the server\'s own rea
950
950
  reset();
951
951
  const page = boot({
952
952
  transport: suggestTransport([
953
- { handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 mutual connections' },
953
+ { handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 connections in common' },
954
954
  ]),
955
955
  });
956
956
  await page.render(profileOf('octo', 7), 7);
957
957
  assert.match(page.html(), /Builders you may know/);
958
- assert.match(page.html(), /3 mutual connections/, 'the server picked the reason; the page states it verbatim');
958
+ assert.match(page.html(), /3 connections in common/, 'the server picked the reason; the page states it verbatim');
959
959
  assert.match(page.html(), /data-act="suggest-connect"/);
960
960
  assert.match(page.html(), /data-handle="grace"/);
961
961
  });
@@ -96,6 +96,9 @@ try {
96
96
  await q(migrationSql('platform_identity_012_recent_ships.sql'));
97
97
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
98
98
  await q(migrationSql('platform_identity_020_visibility_pull.sql'));
99
+ // upsertAccount writes terms_accepted_source (024, task 1003360). It runs
100
+ // before the fixtures, so its one-shot grandfather never sees LEGACY below.
101
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
99
102
  await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
100
103
  await cleanup();
101
104