@bongos/core 1.19.422 → 1.19.424

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.
@@ -15,8 +15,13 @@
15
15
  // one (sign in — you're in already).
16
16
  // GET /api/bongos/access-requests Archon-only — the pending queue (and
17
17
  // ?status=invited|dismissed history).
18
+ // Every row carries `vouched` — see the
19
+ // route.
18
20
  // PATCH /api/bongos/access-requests/:id Archon-only — resolve a request:
19
21
  // { status: 'invited' | 'dismissed' }.
22
+ // Approve, decline, RESCIND an approval
23
+ // (invited → dismissed), and idempotent
24
+ // on the state already held (ADR 0201 D4).
20
25
  // POST /api/bongos/access-requests/invite Archon-only — invite a GitHub
21
26
  // username before any request exists:
22
27
  // the way in on an invite-only project.
@@ -179,9 +184,23 @@ module.exports = function buildAccessRequestsRouter() {
179
184
  }
180
185
  });
181
186
 
182
- // GET /access-requests?status=pending — Archon queue. Defaults to pending;
187
+ // GET /access-requests?status=pending — the owner's queue. Defaults to pending;
183
188
  // pass ?status=invited|dismissed for the resolved history.
184
189
  // rank: archon — the queue exposes would-be builders' usernames.
190
+ //
191
+ // EVERY ROW SAYS WHETHER IT IS VOUCHED, and that is load-bearing rather than
192
+ // decoration (ADR 0201, Consequences). This endpoint is fed by the one PUBLIC
193
+ // unauthenticated write on the GDS, so anyone may file a request naming anyone
194
+ // — including a well-known builder. `vouched` is the reviewer's ONLY basis for
195
+ // reading a cross-project record beside a name: an unvouched row that LOOKED
196
+ // vouched is the entire impersonation vulnerability the vouch exists to close.
197
+ // It is derived here from `applicant_github_id IS NOT NULL` — the single mark
198
+ // (core_230) — rather than stored, so there is no second flag free to disagree
199
+ // with the identity it claims to describe.
200
+ //
201
+ // A VOUCH IS NOT AUTHORITY (ADR 0016). It says the hub confirmed WHO this is;
202
+ // it never says they may enter. Nothing on the approve path reads these three
203
+ // fields, and `hasInvitedAccessRequest` still reads status='invited' alone.
185
204
  router.get('/access-requests', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
186
205
  const status = req.query.status || 'pending';
187
206
  if (!['pending', 'invited', 'dismissed'].includes(status)) {
@@ -191,6 +210,8 @@ module.exports = function buildAccessRequestsRouter() {
191
210
  const { rows } = await pool.query(
192
211
  `SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
193
212
  ar.created_at, ar.resolved_at,
213
+ ar.applicant_github_id, ar.vouched_at,
214
+ (ar.applicant_github_id IS NOT NULL) AS vouched,
194
215
  rb.github_login AS resolved_by_login
195
216
  FROM access_requests ar
196
217
  LEFT JOIN builders rb ON rb.id = ar.resolved_by_builder_id
@@ -201,16 +222,68 @@ module.exports = function buildAccessRequestsRouter() {
201
222
  );
202
223
  res.json({ requests: rows });
203
224
  } catch (err) {
204
- console.error('[gds] GET /access-requests', err);
225
+ log.error('[gds] GET /access-requests', err);
205
226
  res.fail('list_failed', { status: 500, message: 'internal error' });
206
227
  }
207
228
  });
208
229
 
209
- // PATCH /access-requests/:id — Archon resolves a request.
210
- // body: { status: 'invited' | 'dismissed' }. 'invited' = admitted to the hall
211
- // (the only sign-in gate — no repo access needed); 'dismissed' = declined /
212
- // spam / dup. The row stays as the audit trail either way.
230
+ // PATCH /access-requests/:id — the owner resolves a request.
231
+ // body: { status: 'invited' | 'dismissed' }. 'invited' = admitted (the only
232
+ // sign-in gate — no repo access needed); 'dismissed' = declined / spam / dup.
233
+ // The row stays as the audit trail either way.
213
234
  // rank: archon — same gate as the rest of the roster management surface.
235
+ //
236
+ // THREE STATES, AND EVERY MOVE BETWEEN THEM THAT MEANS SOMETHING (ADR 0201 D4):
237
+ //
238
+ // pending → invited approve
239
+ // pending → dismissed decline
240
+ // invited → dismissed RESCIND (task 1002284, D4.2). An approval given by
241
+ // mistake, BEFORE the applicant has ever signed in,
242
+ // must be revocable: hasInvitedAccessRequest stops
243
+ // matching and the door re-locks.
244
+ //
245
+ // AFTER they have signed in the row is history, and
246
+ // this route REFUSES rather than flipping it. The
247
+ // refusal is the whole point: firstTimeAdmissionBlocked
248
+ // short-circuits on an existing `builders` row, so
249
+ // dismissing an invited row for someone who already
250
+ // signed in changes NOTHING about their access — it
251
+ // would answer 200 and let the queue report a removal
252
+ // that did not happen. Offboarding a member is the
253
+ // instance's own act (/builder-exit), and the guard is
254
+ // a NOT EXISTS in the UPDATE's own WHERE so it cannot
255
+ // be lost to a read-then-write window.
256
+ // KNOWN LIMIT: a rescind overwrites resolved_by/
257
+ // resolved_at with the RESCINDER, so who originally
258
+ // approved is not recoverable from the row, and this
259
+ // route writes no audit_log for any transition (it
260
+ // never has — approve and decline lose nothing only
261
+ // because they start from `pending`, which has no
262
+ // resolver to lose). Carrying the prior approver out
263
+ // would mean a CTE on the one statement in this change
264
+ // that no engine executes in CI; it belongs with the
265
+ // queue UI that would display it (R11). ADR 0208.
266
+ // x → x IDEMPOTENT (D4.4). A double-clicked button in the
267
+ // queue returns the row it already holds, 200, rather
268
+ // than a 409 that reads as a failure. It does NOT
269
+ // re-stamp resolved_by/resolved_at: the audit trail
270
+ // records who ACTUALLY resolved it, not who clicked
271
+ // again afterwards.
272
+ // dismissed → invited REFUSED here on purpose. Re-admitting a declined
273
+ // applicant is POST /access-requests/invite, which
274
+ // already resolves-or-inserts an invited row for any
275
+ // login (D4.2) — a second path to the same write would
276
+ // be the drift. The refusal names the state and that
277
+ // route, per ADR 0116.
278
+ //
279
+ // The transition is enforced in the UPDATE's own WHERE, so a concurrent write
280
+ // loses the race in the database rather than in a read-then-write window. The
281
+ // follow-up SELECT runs ONLY to explain a refusal — it decides nothing.
282
+ //
283
+ // $1 is spelled $1::text in that WHERE because the same parameter is also
284
+ // the SET value. Postgres infers text from the assignment anyway, but no real
285
+ // engine ever executes this statement — there is no integration test for this
286
+ // route and CI has no Postgres — so the type is stated rather than relied on.
214
287
  router.patch('/access-requests/:id', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
215
288
  const id = parseId(req, res, { code: 'bad_request_id' });
216
289
  if (id === null) return;
@@ -227,16 +300,47 @@ module.exports = function buildAccessRequestsRouter() {
227
300
  SET status = $1,
228
301
  resolved_by_builder_id = $2,
229
302
  resolved_at = now()
230
- WHERE id = $3 AND status = 'pending'
303
+ WHERE id = $3
304
+ AND (status = 'pending'
305
+ OR (status = 'invited' AND $1::text = 'dismissed'
306
+ AND NOT EXISTS (SELECT 1 FROM builders b
307
+ WHERE lower(b.github_login) = lower(access_requests.github_login))))
231
308
  RETURNING id, github_login, status, resolved_at`,
232
309
  [next, req.builder.id, id]
233
310
  );
234
- if (rows.length === 0) {
235
- return res.fail('not_pending', { status: 409, message: 'That request is gone or already resolved.' });
311
+ if (rows.length > 0) {
312
+ return res.json({ ok: true, request: rows[0] });
313
+ }
314
+ const current = await pool.query(
315
+ `SELECT ar.id, ar.github_login, ar.status, ar.resolved_at,
316
+ EXISTS (SELECT 1 FROM builders b
317
+ WHERE lower(b.github_login) = lower(ar.github_login)) AS is_member
318
+ FROM access_requests ar
319
+ WHERE ar.id = $1`,
320
+ [id]
321
+ );
322
+ if (current.rows.length === 0) {
323
+ return res.fail('request_not_found', { status: 404, message: 'That request no longer exists.' });
324
+ }
325
+ const { is_member: isMember, ...row } = current.rows[0];
326
+ if (row.status === next) {
327
+ return res.json({ ok: true, request: row, unchanged: true });
328
+ }
329
+ if (row.status === 'invited' && next === 'dismissed' && isMember) {
330
+ return res.fail('already_member', {
331
+ status: 409,
332
+ message: 'They have already signed in, so this request is history — dismissing it would remove nothing. Offboard the builder instead (/builder-exit).',
333
+ });
334
+ }
335
+ if (row.status === 'dismissed' && next === 'invited') {
336
+ return res.fail('already_dismissed', {
337
+ status: 409,
338
+ message: 'That request was declined. To admit them anyway, invite the username directly (POST /access-requests/invite) — it opens the door without reviving a declined request.',
339
+ });
236
340
  }
237
- res.json({ ok: true, request: rows[0] });
341
+ return res.fail('not_pending', { status: 409, message: `That request is ${row.status} — it changed underneath this response. Re-read the queue and retry.` });
238
342
  } catch (err) {
239
- console.error('[gds] PATCH /access-requests/:id', err);
343
+ log.error('[gds] PATCH /access-requests/:id', err);
240
344
  res.fail('resolve_failed', { status: 500, message: 'internal error' });
241
345
  }
242
346
  });
@@ -0,0 +1,115 @@
1
+ // modules/platform-identity/application-echo.js — the hub's STATUSLESS memory of
2
+ // "you applied to this project, on this date" (task 1002284, R08 of goal 1000045;
3
+ // ADR 0201 D1.3).
4
+ //
5
+ // WHAT IT ANSWERS, and the one thing it must never answer. The application
6
+ // itself is the TARGET PROJECT's own `access_requests` row: the hub relays it
7
+ // (routes/my-projects.js) and the project decides. This file owns the only fact
8
+ // the hub is allowed to keep about that — WHO asked WHICH project WHEN — so the
9
+ // applicant's own surfaces can say "applied on <date>" without asking every
10
+ // project on the platform on every render.
11
+ //
12
+ // It can never say what came of it. A hub-held "pending / approved / declined"
13
+ // would be an admission fact on the wrong side of the trust boundary (ADR 0016 /
14
+ // ADR 0141) and stale the moment the owner acted. An approval becomes visible
15
+ // the way membership always becomes visible: the check-in writes the membership
16
+ // row and the card flips to *member*. That is why the table has no status column
17
+ // and why nothing here returns one.
18
+ //
19
+ // NOT AUTHORITY. No rank check, admission gate, permission resolve or credit path
20
+ // reads this. Writing an echo grants nobody anything anywhere; deleting every row
21
+ // would cost only a sentence on a card. Both functions are best-effort by design
22
+ // — see `recordApplication`.
23
+ //
24
+ // WHY ITS OWN FILE rather than platform-identity.js: that file sits at the 1500-
25
+ // line fitness ratchet with zero headroom, and the `account-visibility.js`
26
+ // precedent (ADR 0202 D4) is exactly this shape — one small table's answers,
27
+ // alone, requireable by a route.
28
+ 'use strict';
29
+
30
+ const { pool: defaultPool } = require('../../src/module-api');
31
+ const { isNumericGithubId } = require('./platform-identity');
32
+
33
+ // How long an echo stays visible on the applicant's own surfaces. READ-TIME, not
34
+ // a sweep and not a column: the row lives forever and simply stops matching, so
35
+ // there is no expiry job to fall behind and no second state to disagree with the
36
+ // project's own row. 30 days is the privacy spec D4 constant, reused so a
37
+ // declined application does not read as "applied" forever. Exported because the
38
+ // reader, the tests and the docs must all quote the same number.
39
+ const APPLICATION_ECHO_WINDOW_DAYS = 30;
40
+
41
+ // recordApplication — remember that this account asked to join this project, now.
42
+ // Called ONLY after the target project answered a relayed application with a
43
+ // success (ADR 0201 D1.3); a refusal is not an application and leaves no echo.
44
+ //
45
+ // Upserts on the (account, project) pair, refreshing `applied_at`, because
46
+ // re-applying is a success rather than an error (D1.4) and the visibility window
47
+ // is measured from the most recent ask — otherwise an applicant who re-asks
48
+ // watches their own card go quiet while their request is still open.
49
+ //
50
+ // BEST-EFFORT BY CONTRACT: returns false instead of throwing when the account is
51
+ // a non-numeric principal (ADR 0171 D2). The caller must also swallow a DB error
52
+ // — the echo is a convenience on the applicant's own view, and losing it must
53
+ // never turn a filed application into a reported failure.
54
+ async function recordApplication({ githubId, clientId }, { pool = defaultPool } = {}) {
55
+ if (!isNumericGithubId(githubId)) return false;
56
+ const client = String(clientId || '');
57
+ if (!client) return false;
58
+ await pool.query(
59
+ `INSERT INTO platform_identity_application_echoes (github_id, client_id, applied_at)
60
+ VALUES ($1::bigint, $2, now())
61
+ ON CONFLICT (github_id, client_id) DO UPDATE SET applied_at = now()`,
62
+ [String(githubId), client],
63
+ );
64
+ return true;
65
+ }
66
+
67
+ // listRecentApplications — the applicant's OWN "I asked these projects" list,
68
+ // inside the read-time window. Own-scope only: every caller passes the session's
69
+ // github_id, and there is no cross-account read of this table anywhere.
70
+ //
71
+ // Returns [{ client_id, applied_at, project_name, project_origin }], newest
72
+ // first, or [] for a non-numeric principal. The LEFT JOIN mirrors
73
+ // listMembershipsForAccount so a client_id renders as a project rather than an
74
+ // opaque key; a de-registered project degrades to nulls beside a real date
75
+ // rather than vanishing. Carries NO status, because the table holds none.
76
+ async function listRecentApplications(githubId, { pool = defaultPool } = {}) {
77
+ if (!isNumericGithubId(githubId)) return [];
78
+ const { rows } = await pool.query(
79
+ `SELECT e.client_id, e.applied_at,
80
+ c.name AS project_name, c.origin AS project_origin
81
+ FROM platform_identity_application_echoes e
82
+ LEFT JOIN platform_identity_sso_clients c ON c.client_id = e.client_id
83
+ WHERE e.github_id = $1::bigint
84
+ AND e.applied_at > now() - make_interval(days => $2::int)
85
+ ORDER BY e.applied_at DESC`,
86
+ [String(githubId), APPLICATION_ECHO_WINDOW_DAYS],
87
+ );
88
+ return rows;
89
+ }
90
+
91
+ // forgetApplication — drop this account's echo for one project. Called when the
92
+ // account LEAVES the project: the echo means "I asked to join and have not yet",
93
+ // and someone who joined and left has neither. Without it, leaving RESURRECTS the
94
+ // original application on the applicant's own card for the rest of the 30-day
95
+ // window — "you applied on <date>" about a project they were already a member of,
96
+ // which is the one thing the echo must never imply (ADR 0201 D1.3). Best-effort
97
+ // like its siblings; returns the number of echoes dropped (0 or 1).
98
+ async function forgetApplication({ githubId, clientId }, { pool = defaultPool } = {}) {
99
+ if (!isNumericGithubId(githubId)) return 0;
100
+ const client = String(clientId || '');
101
+ if (!client) return 0;
102
+ const { rowCount } = await pool.query(
103
+ `DELETE FROM platform_identity_application_echoes
104
+ WHERE github_id = $1::bigint AND client_id = $2`,
105
+ [String(githubId), client],
106
+ );
107
+ return rowCount;
108
+ }
109
+
110
+ module.exports = {
111
+ APPLICATION_ECHO_WINDOW_DAYS,
112
+ recordApplication,
113
+ listRecentApplications,
114
+ forgetApplication,
115
+ };
@@ -0,0 +1,68 @@
1
+ -- platform_identity_016_application_echo.sql — the hub's statusless ECHO of "you
2
+ -- applied to this project, on this date" (task 1002284, R08 of goal 1000045;
3
+ -- ADR 0201 D1.3, whose migration sketch this is).
4
+ --
5
+ -- WHAT THIS IS, AND MORE IMPORTANTLY WHAT IT IS NOT. The application itself is
6
+ -- the TARGET PROJECT's own `access_requests` row — the hub relays it and the
7
+ -- project decides. This table holds one thing the hub is allowed to know: that
8
+ -- an account asked, and when. It exists so the applicant's own surfaces (My
9
+ -- Projects, the orbs card) can say "applied on <date>" without asking every
10
+ -- project on the platform on every render.
11
+ --
12
+ -- THERE IS NO STATUS COLUMN, AND ADDING ONE WOULD BE THE BUG. A hub-held row
13
+ -- saying a project has a pending/approved/declined application would be a
14
+ -- hub-held ADMISSION FACT — authority on the wrong side of the trust boundary
15
+ -- (ADR 0016 / ADR 0141), and stale the moment the owner acted on it. An approval
16
+ -- becomes visible the way membership always becomes visible: the check-in
17
+ -- (ADR 0141 §3) writes the membership row and the card flips to *member*. The
18
+ -- honest reading of this table is "asked" or nothing; a card that wants to say
19
+ -- "declined" is asking for a fact the hub is not allowed to hold.
20
+ --
21
+ -- THE 30-DAY WINDOW IS READ-TIME, NOT A COLUMN AND NOT A SWEEP. `applied_at` is
22
+ -- the whole of it: the reader filters `applied_at > now() - 30 days`
23
+ -- (APPLICATION_ECHO_WINDOW_DAYS in ../application-echo.js — one constant, shared
24
+ -- by the reader and the doc). The same read-time constant the privacy spec D4
25
+ -- chose, so a declined application does not read as "applied" forever. Nothing
26
+ -- expires the rows; there is no status for them to expire INTO, and a stale row
27
+ -- is invisible rather than wrong.
28
+ --
29
+ -- THE KEY IS THE PAIR, so re-applying cannot pile up rows: the upsert refreshes
30
+ -- `applied_at` in place. That is deliberate — the window is measured from the
31
+ -- most recent ask, so an applicant who re-asks (which ADR 0201 D1.4 makes a
32
+ -- success, not an error) does not watch their own card go quiet while their
33
+ -- request is still open on the project.
34
+ --
35
+ -- github_id is `bigint` here and `text` on core's access_requests.applicant_github_id:
36
+ -- each side matches the table it joins (platform_identity_accounts.github_id is
37
+ -- bigint, core builders.github_id is text). That asymmetry is ADR 0201 D1.2's
38
+ -- explicit call, not an oversight.
39
+ --
40
+ -- NOTHING HERE IS AUTHORITY. No rank check, admission gate, permission resolve or
41
+ -- credit path reads this table. Writing an echo grants nobody anything anywhere;
42
+ -- deleting every row would cost only a sentence on a card.
43
+ --
44
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5 — a module migration may
45
+ -- touch only its own <key>_ objects). Forward-safe: CREATE only, so the previous
46
+ -- code release still runs against this schema. Idempotent.
47
+
48
+ BEGIN;
49
+
50
+ CREATE TABLE IF NOT EXISTS platform_identity_application_echoes (
51
+ -- The applicant's platform account. Matches platform_identity_accounts.github_id.
52
+ github_id bigint NOT NULL,
53
+ -- The project applied to — the registered sso_client, never a caller-supplied
54
+ -- origin (the relay resolves the origin against the registry first; that is
55
+ -- the SSRF guard, and this column inherits it).
56
+ client_id text NOT NULL,
57
+ applied_at timestamptz NOT NULL DEFAULT now(),
58
+ -- The pair IS the key: one echo per (account, project), refreshed in place.
59
+ PRIMARY KEY (github_id, client_id)
60
+ );
61
+
62
+ COMMENT ON TABLE platform_identity_application_echoes IS
63
+ 'The hub''s STATUSLESS echo of a cross-project application (ADR 0201 D1.3): this account asked to join this project, at this time. The application itself is the project''s own access_requests row; this exists only so the applicant''s own surfaces can say "applied on <date>" without polling every project. It must NEVER gain a status column — that would be a hub-held admission fact, authority on the wrong side of the boundary (ADR 0016 / ADR 0141), and stale the instant the owner acted. Read-time 30-day window (APPLICATION_ECHO_WINDOW_DAYS); no sweep, no expiry column. Descriptive only — nothing reads this to decide anything.';
64
+
65
+ COMMENT ON COLUMN platform_identity_application_echoes.applied_at IS
66
+ 'When this account most recently applied to this project. Refreshed in place by the upsert, because re-applying is a success (ADR 0201 D1.4) and the 30-day view window is measured from the most recent ask. The ONLY fact this table carries beyond the pair itself.';
67
+
68
+ COMMIT;
@@ -1,7 +1,9 @@
1
1
  // modules/platform-identity/routes/my-projects.js — the signed-in builder's cross-
2
2
  // project "My Projects" view + self-service join / leave (ADR 0141 §3, task #1002112).
3
3
  //
4
- // GET /my-projects — projects this account builds on, owned grouped apart
4
+ // GET /my-projects — projects this account builds on, owned grouped
5
+ // apart, plus `applied`: the projects it has
6
+ // asked to join and when (ADR 0201 D1.3)
5
7
  // POST /my-projects/join — apply to a project: one click plus an optional
6
8
  // note in the applicant's own words (task 1002330).
7
9
  // Relayed; the project executes and decides.
@@ -10,12 +12,18 @@
10
12
  // All gated on a live HUB session (requireBuilder). Authority stays LOCAL: JOIN only
11
13
  // RELAYS an admission request to the target project (which enforces its own gate);
12
14
  // the hub never admits anyone on its own. Reaches core only via the doorway (ADR 0083).
15
+ //
16
+ // `applied` is an ECHO, never a status (ADR 0201 D1.3). The hub records THAT this
17
+ // account asked and WHEN, and is not allowed to know what came of it — an
18
+ // approval becomes visible only when membership does, by moving the project from
19
+ // `applied` into `building`. See ../application-echo.js.
13
20
  'use strict';
14
21
 
15
22
  const express = require('express');
16
23
  const api = require('../../../src/module-api');
17
24
  const pi = require('../platform-identity');
18
25
  const projectStats = require('../project-stats');
26
+ const applicationEcho = require('../application-echo');
19
27
 
20
28
  const { pool, requireBuilder, validateOrRespond, userAgent } = api;
21
29
 
@@ -112,7 +120,29 @@ module.exports = function myProjectsRoutes() {
112
120
  last_active: m.last_active,
113
121
  });
114
122
  }
115
- res.json({ owned, building });
123
+ // `applied` — the echo (ADR 0201 D1.3): projects this account asked to join
124
+ // inside the read-time window, carrying WHEN and deliberately no status.
125
+ // A project the applicant now builds on is dropped, because membership is
126
+ // the only approval signal the hub is allowed to show and it already reads
127
+ // it from the membership rows above — showing both would let a card claim
128
+ // "applied" about a project it is already a member of. Best-effort: a
129
+ // failed echo read costs the applied list, never the memberships listing.
130
+ const memberOf = new Set(rows.map((m) => m.client_id));
131
+ let applied = [];
132
+ try {
133
+ const echoes = await applicationEcho.listRecentApplications(req.builder.github_id, { pool });
134
+ applied = echoes
135
+ .filter((e) => !memberOf.has(e.client_id))
136
+ .map((e) => ({
137
+ client_id: e.client_id,
138
+ name: e.project_name || e.project_origin || e.client_id,
139
+ origin: e.project_origin || null,
140
+ applied_at: e.applied_at,
141
+ }));
142
+ } catch (err) {
143
+ api.logger('platform-identity').error(`my-projects applied echo read failed (non-fatal): ${err && err.message}`);
144
+ }
145
+ res.json({ owned, building, applied });
116
146
  } catch (err) {
117
147
  console.error('[platform-identity] GET /my-projects', err);
118
148
  res.fail('my_projects_failed', { status: 500, message: 'internal error' });
@@ -153,6 +183,22 @@ module.exports = function myProjectsRoutes() {
153
183
  console.log('[platform-identity] join relay failed (non-fatal):', e.message);
154
184
  }
155
185
  if (!outcome) return res.fail('join_relay_failed', { status: 502, message: 'the project could not be reached to request access' });
186
+ // The echo (ADR 0201 D1.3) — written ONLY when the project actually took an
187
+ // application. 'already_member', the two door refusals and 'members_only'
188
+ // are not applications and must leave no trace; a re-apply IS one ('requested'
189
+ // covers the project's own already_pending 200, D1.4), and refreshes the date.
190
+ // Best-effort in both directions: a lost echo must never turn a filed
191
+ // application into a reported failure, which is the only reason this is
192
+ // allowed to swallow its error.
193
+ if (outcome.status === 'requested') {
194
+ try {
195
+ await applicationEcho.recordApplication(
196
+ { githubId: req.builder.github_id, clientId: client.client_id }, { pool },
197
+ );
198
+ } catch (e) {
199
+ api.logger('platform-identity').error(`join echo write failed (non-fatal): ${e && e.message}`);
200
+ }
201
+ }
156
202
  res.json({
157
203
  ok: true,
158
204
  status: outcome.status,
@@ -210,6 +256,15 @@ module.exports = function myProjectsRoutes() {
210
256
  if (!clientId) return res.fail('bad_client_id', 400);
211
257
  try {
212
258
  const removed = await pi.removeMembership(clientId, req.builder.github_id, { pool });
259
+ // Leaving also forgets the application that got them in — otherwise the echo
260
+ // resurfaces on their own card and claims they applied to a project they had
261
+ // already joined (ADR 0201 D1.3: the echo may never imply an outcome).
262
+ // Best-effort: a stale echo must not fail a leave that already happened.
263
+ try {
264
+ await applicationEcho.forgetApplication({ githubId: req.builder.github_id, clientId }, { pool });
265
+ } catch (e) {
266
+ api.logger('platform-identity').error(`leave echo cleanup failed (non-fatal): ${e && e.message}`);
267
+ }
213
268
  res.json({ ok: true, left: removed > 0 });
214
269
  } catch (err) {
215
270
  console.error('[platform-identity] POST /my-projects/:clientId/leave', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.422",
3
+ "version": "1.19.424",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.422",
9
+ "version": "1.19.424",
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.422",
3
+ "version": "1.19.424",
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",
@@ -106,7 +106,14 @@ const INTEGRATION = new Set([
106
106
  // the DISTINCT, and that a deactivated builder or a machine principal still
107
107
  // never governs under the widened predicate.
108
108
  'government_board_membership_db',
109
- 'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
109
+ 'government_fold_bench', 'government_live_proof',
110
+ // task 1002806: these three declare "in the INTEGRATION set" in their own headers
111
+ // and were never in it. Every one boots a real Postgres and self-skips without one,
112
+ // so in the DB-free lane they ran, skipped, exited 0 and asserted NOTHING — while
113
+ // the --integration lane never saw them either, because it runs only this set. A
114
+ // test that cannot fail in either lane is a file that looks like a gate.
115
+ 'handle_machinery', 'hub_profile_leak', 'profile_fields',
116
+ 'linkify_refs', 'memory_isolation',
110
117
  'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
111
118
  // task 1003145: whether the PLANNER uses the migration 225 index for the recall
112
119
  // freshness probe. The DB-free sibling (recall_corpus_health) pins the query
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.422'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.424'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');