@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.
- package/.bongos-core.json +80 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
- package/docs/api/openapi.json +106 -5
- package/docs/api-reference.md +7 -5
- package/docs/copy-inventory.md +19 -17
- package/docs/copy-registry.json +44 -36
- package/docs/module-api-changelog.md +5 -1
- package/migrations/core_255_access_requests_invite_kind.sql +52 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/onboarding/routes/access-requests.js +62 -11
- package/modules/platform-identity/community-leaderboard.js +235 -0
- package/modules/platform-identity/connection-suggestions.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/read-rate-limit.js +61 -0
- package/modules/platform-identity/routes/community-leaderboard.js +69 -0
- package/modules/platform-identity/routes/community-search.js +4 -31
- package/modules/platform-identity/routes/projects.js +1 -1
- package/modules/platform-identity/terms-acceptance.js +34 -14
- package/modules/public-landing/public/terms.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +10 -0
- package/src/module-api.js +1 -1
- package/tests/access_requests_invite.mjs +81 -4
- package/tests/application_lifecycle.mjs +174 -6
- package/tests/community_leaderboard_db.mjs +202 -0
- package/tests/community_leaderboard_route.mjs +414 -0
- package/tests/connection_suggestions.mjs +1 -1
- package/tests/connections_api.mjs +1 -1
- package/tests/goal_map_page.mjs +8 -0
- package/tests/handle_machinery.mjs +2 -0
- package/tests/profile_connections_ui.mjs +2 -2
- package/tests/profile_rollup_consent.mjs +3 -0
- package/tests/terms_acceptance.mjs +264 -14
- 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).
|
|
37
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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
|
|
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
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
+ ` THEN COALESCE(${self}.
|
|
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’s own standing for you, as that project reported it — 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’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’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
|
|
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 — 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 () => {
|