@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.
- package/.bongos-core.json +60 -25
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +164 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +10 -5
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +25 -25
- package/docs/copy-registry.json +27 -27
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_230_access_requests_applicant_vouch.sql +79 -0
- package/modules/discord/discord-approvals.js +7 -2
- package/modules/hall-ui/public/watch.js +10 -1
- package/modules/onboarding/routes/access-requests.js +115 -11
- package/modules/platform-identity/application-echo.js +115 -0
- package/modules/platform-identity/migrations/platform_identity_016_application_echo.sql +68 -0
- package/modules/platform-identity/routes/my-projects.js +57 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/src/module-api.js +1 -1
- package/tests/application_echo.mjs +446 -0
- package/tests/application_lifecycle.mjs +634 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/profile_privacy_owner_view.mjs +477 -0
|
@@ -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 —
|
|
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
|
-
|
|
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 —
|
|
210
|
-
// body: { status: 'invited' | 'dismissed' }. 'invited' = admitted
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
|
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
|
|
235
|
-
return res.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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',
|
|
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.
|
|
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');
|