@bongos/core 1.19.1024 → 1.19.1026

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 (42) hide show
  1. package/.bongos-core.json +80 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
  8. package/docs/api/openapi.json +106 -5
  9. package/docs/api-reference.md +7 -5
  10. package/docs/copy-inventory.md +19 -17
  11. package/docs/copy-registry.json +44 -36
  12. package/docs/module-api-changelog.md +5 -1
  13. package/migrations/core_255_access_requests_invite_kind.sql +52 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/hall-ui/public/goal-map.html +1 -1
  16. package/modules/onboarding/routes/access-requests.js +62 -11
  17. package/modules/platform-identity/community-leaderboard.js +235 -0
  18. package/modules/platform-identity/connection-suggestions.js +1 -1
  19. package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
  20. package/modules/platform-identity/module.json +1 -1
  21. package/modules/platform-identity/read-rate-limit.js +61 -0
  22. package/modules/platform-identity/routes/community-leaderboard.js +69 -0
  23. package/modules/platform-identity/routes/community-search.js +4 -31
  24. package/modules/platform-identity/routes/projects.js +1 -1
  25. package/modules/platform-identity/terms-acceptance.js +34 -14
  26. package/modules/public-landing/public/terms.html +2 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/run-unit-tests.js +10 -0
  30. package/src/module-api.js +1 -1
  31. package/tests/access_requests_invite.mjs +81 -4
  32. package/tests/application_lifecycle.mjs +174 -6
  33. package/tests/community_leaderboard_db.mjs +202 -0
  34. package/tests/community_leaderboard_route.mjs +414 -0
  35. package/tests/connection_suggestions.mjs +1 -1
  36. package/tests/connections_api.mjs +1 -1
  37. package/tests/goal_map_page.mjs +8 -0
  38. package/tests/handle_machinery.mjs +2 -0
  39. package/tests/profile_connections_ui.mjs +2 -2
  40. package/tests/profile_rollup_consent.mjs +3 -0
  41. package/tests/terms_acceptance.mjs +264 -14
  42. package/tests/terms_grandfather_db.mjs +144 -0
@@ -0,0 +1,69 @@
1
+ // modules/platform-identity/routes/community-leaderboard.js — GET
2
+ // /community/leaderboard, the PUBLIC windowed leaderboard behind the hub's
3
+ // /community page (D3, docs/specs/bongos-v1-community-tab.md; task 1002978,
4
+ // goal 1000110 CT2). The sibling of routes/community-search.js: same module,
5
+ // same public posture. Who appears and how a window is counted live entirely in
6
+ // ../community-leaderboard.js; this file is the HTTP shell — validation, the
7
+ // per-IP read cap, the response envelope. It never reads a session, so the
8
+ // board is the same for every viewer.
9
+ //
10
+ // Reaches core only through the doorway (ADR 0083).
11
+ 'use strict';
12
+
13
+ const express = require('express');
14
+ const api = require('../../../src/module-api');
15
+ const leaderboard = require('../community-leaderboard');
16
+ const { createReadRateLimit } = require('../read-rate-limit');
17
+
18
+ const { pool, corsPublicGet } = api;
19
+
20
+ const READ_RATE_LIMIT = 60; // per IP per minute — the community-search budget
21
+ const READ_RATE_SCOPE = 'community-leaderboard-read';
22
+ const leaderboardReadRateLimit = createReadRateLimit({ scope: READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
23
+
24
+ module.exports = function communityLeaderboardRoutes() {
25
+ const router = express.Router();
26
+
27
+ router.use('/community/leaderboard', corsPublicGet('/community/leaderboard'));
28
+
29
+ // rank: public — the cross-project leaderboard (?window=7d|30d|365d|all,
30
+ // default all; ?discipline=; ?limit=&offset=). Eligibility is the privacy
31
+ // matrix's numbers predicate (active + public + stats shown); the recruiting
32
+ // opt-out does not apply. no-store: hide must beat the cache (ADR 0171 D5).
33
+ // Rollout note: until a builder's first changed report after snapshot history
34
+ // began, their whole lifetime total counts inside every window (spec D2's
35
+ // missing-baseline-is-zero rule) — expected on day one, and self-correcting.
36
+ router.get('/community/leaderboard', leaderboardReadRateLimit, async (req, res) => {
37
+ const window = req.query.window === undefined || req.query.window === ''
38
+ ? leaderboard.DEFAULT_WINDOW
39
+ : req.query.window;
40
+ if (!leaderboard.isWindow(window)) {
41
+ return res.fail('bad_window', { status: 400, message: `window must be one of: ${leaderboard.WINDOWS.join(', ')}` });
42
+ }
43
+ const discipline = (typeof req.query.discipline === 'string' ? req.query.discipline : '').trim();
44
+ if (discipline.length > leaderboard.MAX_DISCIPLINE_CHARS) {
45
+ return res.fail('discipline_too_long', { status: 400, message: `discipline must be at most ${leaderboard.MAX_DISCIPLINE_CHARS} characters` });
46
+ }
47
+ try {
48
+ const board = await leaderboard.listLeaderboard({
49
+ window, discipline, limit: req.query.limit, offset: req.query.offset, pool,
50
+ });
51
+ res.set('Cache-Control', 'no-store');
52
+ res.json({
53
+ window: board.window,
54
+ since: board.since,
55
+ discipline: discipline || null,
56
+ count: board.results.length,
57
+ results: board.results,
58
+ page: { limit: board.limit, offset: board.offset },
59
+ });
60
+ } catch (err) {
61
+ api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/leaderboard failed');
62
+ res.fail('leaderboard_failed', { status: 500, message: 'internal error' });
63
+ }
64
+ });
65
+
66
+ return router;
67
+ };
68
+
69
+ module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE };
@@ -16,6 +16,7 @@
16
16
  const express = require('express');
17
17
  const api = require('../../../src/module-api');
18
18
  const builderSearch = require('../builder-search');
19
+ const { createReadRateLimit } = require('../read-rate-limit');
19
20
 
20
21
  const { pool, corsPublicGet } = api;
21
22
 
@@ -33,39 +34,11 @@ const log = {
33
34
 
34
35
  // Per-IP read cap for this UNAUTHENTICATED route (the public-profile.js R05
35
36
  // posture: the global limiter only meters WRITES, so an uncapped scraper could
36
- // otherwise run the trigram leg at line rate). Vendored, not shared — the core
37
- // /public/* limiter is not on the doorway, and each public GET here owns its
38
- // own budget rather than reaching into a sibling module's copy.
37
+ // otherwise run the trigram leg at line rate). Its own budget, built by the
38
+ // module's shared factory (task 1002978) rather than a vendored copy.
39
39
  const READ_RATE_WINDOW_MS = 60 * 1000;
40
40
  const READ_RATE_LIMIT = 60;
41
- const buckets = new Map(); // ip -> timestamp[]
42
-
43
- const sweepTimer = setInterval(() => {
44
- const cutoff = Date.now() - READ_RATE_WINDOW_MS;
45
- for (const [k, arr] of buckets) {
46
- while (arr.length && arr[0] < cutoff) arr.shift();
47
- if (arr.length === 0) buckets.delete(k);
48
- }
49
- }, 5 * 60 * 1000);
50
- if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
51
-
52
- function searchReadRateLimit(req, res, next) {
53
- const now = Date.now();
54
- const key = req.ip || req.socket?.remoteAddress || 'unknown';
55
- let bucket = buckets.get(key);
56
- if (!bucket) { bucket = []; buckets.set(key, bucket); }
57
- const cutoff = now - READ_RATE_WINDOW_MS;
58
- while (bucket.length && bucket[0] < cutoff) bucket.shift();
59
- if (bucket.length >= READ_RATE_LIMIT) {
60
- const retryAfter = Math.max(1, Math.ceil((bucket[0] + READ_RATE_WINDOW_MS - now) / 1000));
61
- res.set('Retry-After', String(retryAfter));
62
- res.set('X-RateLimit-Scope', 'community-search-read');
63
- res.set('X-RateLimit-Limit', String(READ_RATE_LIMIT));
64
- return res.fail('rate_limited', 429, { scope: 'community-search-read', retry_after_seconds: retryAfter });
65
- }
66
- bucket.push(now);
67
- return next();
68
- }
41
+ const searchReadRateLimit = createReadRateLimit({ scope: 'community-search-read', limit: READ_RATE_LIMIT, windowMs: READ_RATE_WINDOW_MS });
69
42
 
70
43
  module.exports = function communitySearchRoutes() {
71
44
  const router = express.Router();
@@ -120,7 +120,7 @@ module.exports = function projectsRoutes() {
120
120
  // as any-builder — wrong in the dangerous direction (the ADR 0210 rule).
121
121
 
122
122
  // rank: metic+archon — own-or-metic in effect: invite a builder into a project
123
- // (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project's OWN
123
+ // (ADR 0141 §5, task 1002115), called by a `project.curate` holder OR the project's OWN
124
124
  // OWNER (task 1003580; the ownership proof is `provisioning_instances.owner_builder_id`,
125
125
  // reached through a port — see invite-authz.js). The wizard's audience is any signed-in
126
126
  // GitHub user, typically a xenos who used to get 403 on their own project. A caller who is
@@ -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.1026",
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.1026",
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.1026",
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",
@@ -87,6 +87,11 @@ const INTEGRATION = new Set([
87
87
  // WHERE NOT EXISTS statement) and the upsertActivity wiring against real SQL —
88
88
  // a fake pool can prove the query shape but not which reports it inserts.
89
89
  'activity_snapshots_db',
90
+ // task 1002978: the windowed leaderboard against real SQL. Which snapshot
91
+ // becomes a window baseline (a LATERAL at-or-before probe), a missing baseline
92
+ // counting as zero, and the privacy matrix filtering are all planner facts the
93
+ // DB-free route test can only assert the shape of.
94
+ 'community_leaderboard_db',
90
95
  'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
91
96
  'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
92
97
  // consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
@@ -198,6 +203,11 @@ const INTEGRATION = new Set([
198
203
  // DB-free siblings (session_records, session_cost) cover the transcript
199
204
  // de-duplication half.
200
205
  'session_cost_row_raise',
206
+ // task 1003360: the T&C grandfather migration against real SQL. The DB-free
207
+ // sibling (the T&C acceptance suite) pins the statement text and runs the conflict
208
+ // branch over a fake table; only a real engine runs the one-shot DO block and
209
+ // shows a legacy account published again and marked apart from a sign-in.
210
+ 'terms_grandfather_db',
201
211
  'setup_smoke',
202
212
  'status-probe',
203
213
  ]);
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.1026'; // 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 () => {