@bongos/core 1.19.418 → 1.19.420

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 (48) hide show
  1. package/.bongos-core.json +95 -45
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +18 -0
  5. package/clients/bongos-client/index.cjs +18 -0
  6. package/clients/bongos-client/index.d.ts +28 -1
  7. package/clients/bongos-client/index.mjs +18 -0
  8. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
  9. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +518 -5
  12. package/docs/api-reference.md +16 -3
  13. package/docs/copy-inventory.md +186 -174
  14. package/docs/copy-registry.json +348 -238
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +18 -0
  18. package/modules/onboarding/routes/access-requests.js +16 -3
  19. package/modules/platform-identity/account-visibility.js +58 -0
  20. package/modules/platform-identity/connections.js +490 -0
  21. package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
  22. package/modules/platform-identity/module.json +1 -1
  23. package/modules/platform-identity/platform-identity.js +5 -5
  24. package/modules/platform-identity/routes/connections.js +236 -0
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/public-landing/projects.html +161 -42
  34. package/scripts/gds/fitness.js +9 -0
  35. package/scripts/gds/onboard.js +41 -1
  36. package/scripts/gds/provision-net.js +7 -3
  37. package/src/bongos/middleware/rate-limit.js +68 -2
  38. package/src/bongos/routes/auth.js +19 -12
  39. package/src/module-api.js +13 -1
  40. package/tests/account_existence_rate_limit.mjs +375 -0
  41. package/tests/connections_api.mjs +568 -0
  42. package/tests/connections_ownership.mjs +313 -0
  43. package/tests/module_api.mjs +1 -0
  44. package/tests/onboard.mjs +56 -0
  45. package/tests/own_scope_guard.mjs +5 -0
  46. package/tests/projects_hub.mjs +69 -0
  47. package/tests/projects_hub_pre_uat.mjs +27 -5
  48. package/tests/provision.mjs +20 -0
package/docs/file-map.md CHANGED
@@ -349,6 +349,7 @@ tests/
349
349
  ├── sandbox_stage.mjs ← (#927, ADR 0046): sandbox-first review gate — box detection, BOX_PREVIEW_HOSTNAME parsing, previewable-surface matching, decideShipGate table, tree-stamp freshness (temp git repo), runShipGate composition with injected fs/exec/prompt.
350
350
  ├── session_start_freshness.mjs ← (#1277, ADR 0067): the SessionStart freshness decision (decideFreshness) — pull ONLY on a clean `main` full clone strictly behind origin/main; warn in every other behind case; skip when up to date.
351
351
  ├── own_scope_guard.mjs ← (task 1003343, goal 1000045 criterion own-scope-held-by-a-test): the OWN-SCOPE structural guard, generalised out of memory_isolation.mjs. An explicit inventory of own-scoped route files; inside them the route's declared PATH decides — a path naming no builder must never read a subject from the request (direct, optional-chained, destructured, aliased, computed-key or header), a path that DOES name one needs a per-route exemption whose gate the guard verifies is present. Repo-wide backstop: any file mounting a `/me` route must be in the inventory. Source-grepping, so it runs in the DB-free `unit` lane.
352
+ ├── account_existence_rate_limit.mjs ← (task 1003339, goal 1000045, ADR 0209): the two PUBLIC account-existence probes (onboarding GET /access-requests/status + core GET /auth/web/admission-status) share ONE per-IP budget. Counts to the limit on each route, proves the budget is shared in both directions and when alternated, moves Date.now to prove the window slides on the real mounted route, and proves a refused probe issues no query. DB-free; mutation-checked three ways.
352
353
  └── public_task_resolver.mjs ← (#604): the public `/public/tasks/:id` projection — title/value_summary exposed only for shipped|confirmed; withheld for in-flight so unshipped/security task names can't leak.
353
354
  ```
354
355
 
@@ -1273,5 +1273,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1273
1273
  landed since 1.19.416 with no explicit bump. run 33263505319. (task 1002620)
1274
1274
  1.19.418 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1275
1275
  landed since 1.19.417 with no explicit bump. run 33264139544. (task 1002620)
1276
+ 1.19.419 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1277
+ landed since 1.19.418 with no explicit bump. run 33265382684. (task 1002620)
1278
+ 1.19.420 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1279
+ landed since 1.19.419 with no explicit bump. run 33266034679. (task 1002620)
1276
1280
  ---------------------------------------------------------------------------
1277
1281
  ```
@@ -263,6 +263,22 @@ function createClient(opts = {}) {
263
263
  // GET /closeness/with/{builderId} — rank: any-builder — GET /closeness/with/:builderId
264
264
  getClosenessWithBuilderId: (args) => request("GET", "/closeness/with/{builderId}", { hasBody: false }, args),
265
265
  },
266
+ "connections": {
267
+ // GET /connections — rank: any-builder — GET /connections
268
+ getConnections: (args) => request("GET", "/connections", { hasBody: false }, args),
269
+ // POST /connections — rank: any-builder — POST /connections
270
+ postConnections: (args) => request("POST", "/connections", { hasBody: true }, args),
271
+ // DELETE /connections/{id} — rank: any-builder — DELETE /connections/:id
272
+ deleteConnectionsId: (args) => request("DELETE", "/connections/{id}", { hasBody: false }, args),
273
+ // POST /connections/{id}/accept — rank: any-builder — POST /connections/:id/accept
274
+ postConnectionsIdAccept: (args) => request("POST", "/connections/{id}/accept", { hasBody: true }, args),
275
+ // POST /connections/{id}/decline — rank: any-builder — POST /connections/:id/decline
276
+ postConnectionsIdDecline: (args) => request("POST", "/connections/{id}/decline", { hasBody: true }, args),
277
+ // GET /connections/degree/{handle} — rank: any-builder — GET /connections/degree/:handle
278
+ getConnectionsDegreeHandle: (args) => request("GET", "/connections/degree/{handle}", { hasBody: false }, args),
279
+ // GET /connections/requests — rank: any-builder — GET /connections/requests
280
+ getConnectionsRequests: (args) => request("GET", "/connections/requests", { hasBody: false }, args),
281
+ },
266
282
  "copyDesk": {
267
283
  // POST /copy-desk/flags — rank: any-builder — POST /copy-desk/flags
268
284
  postCopyDeskFlags: (args) => request("POST", "/copy-desk/flags", { hasBody: true }, args),
@@ -624,6 +640,8 @@ function createClient(opts = {}) {
624
640
  postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
625
641
  // GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
626
642
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
643
+ // PATCH /provisioning/instances/{id}/detail — rank: any-builder — PATCH /provisioning/instances/:id/detail
644
+ patchProvisioningInstancesIdDetail: (args) => request("PATCH", "/provisioning/instances/{id}/detail", { hasBody: true }, args),
627
645
  // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
628
646
  postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
629
647
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
@@ -28,7 +28,10 @@
28
28
  //
29
29
  // The global IP rate-limiter (mounted in routes.js before every router) guards
30
30
  // the public POST against spam. A partial unique index on lower(github_login)
31
- // stops one username piling up duplicate open requests.
31
+ // stops one username piling up duplicate open requests. The global limiter
32
+ // meters WRITES only, so the public GET /access-requests/status carries its own
33
+ // per-IP read cap — api.accountExistenceReadRateLimit, shared with core's
34
+ // admission-status twin (task 1003339, ADR 0209).
32
35
 
33
36
  const express = require('express');
34
37
  // Carved into modules/onboarding/ (BV1.R81 / ADR 0093 §1). The auth gates, the
@@ -38,7 +41,7 @@ const express = require('express');
38
41
  const api = require('../../../src/module-api');
39
42
  const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requirePermission };
40
43
  const { pool } = api;
41
- const { validateOrRespond, parseId } = api;
44
+ const { validateOrRespond, parseId, accountExistenceReadRateLimit } = api;
42
45
  const approvalBroadcast = require('../approval-broadcast');
43
46
  const log = api.logger('onboarding');
44
47
 
@@ -137,7 +140,17 @@ module.exports = function buildAccessRequestsRouter() {
137
140
  // rank: public — a would-be builder is by definition outside the system.
138
141
  // Declared BEFORE `GET /access-requests` so the literal `/status` path is matched
139
142
  // as its own route (not swallowed as a query on the list route).
140
- router.get('/access-requests/status', async (req, res) => {
143
+ //
144
+ // RATE-LIMITED (task 1003339 / ADR 0209). Public + unthrottled, this was a
145
+ // login→membership oracle an attacker could script at line rate to learn who
146
+ // builds here. accountExistenceReadRateLimit is the doorway's SHARED 120/60s
147
+ // per-IP budget: core's GET /auth/web/admission-status (the boolean twin of
148
+ // this answer) mounts the very same instance, so the two cannot be alternated
149
+ // for double the rate. The limiter runs before the queries, so a refused probe
150
+ // costs no DB work. Whether the ANSWER should also collapse — so a caller who
151
+ // is not the subject cannot tell pending/dismissed/none apart — is the owner's
152
+ // call and is filed as a blocker, not decided here.
153
+ router.get('/access-requests/status', accountExistenceReadRateLimit, async (req, res) => {
141
154
  const login = normalizeLogin(req.query.github_login);
142
155
  if (!GITHUB_LOGIN_RE.test(login)) {
143
156
  return res.fail('bad_github_login', { status: 400, message: 'That does not look like a GitHub username.' });
@@ -0,0 +1,58 @@
1
+ // modules/platform-identity/account-visibility.js — THE effective-visibility
2
+ // predicate for a platform account, published in both forms so a route and a
3
+ // query cannot drift (ADR 0202 D4; the ADR 0182 `publishableSql` precedent).
4
+ //
5
+ // WHAT IT ANSWERS: "may the world see that this account exists at all?" —
6
+ // profile_state = 'active' (consent: a hub session has touched the account,
7
+ // ADR 0171 D3) AND account_visibility = 'public' (existence: the privacy-spec
8
+ // D1 axis). Both floors, ANDed, in one place.
9
+ //
10
+ // WHY ITS OWN FILE. ADR 0202 D4 requires connections to consume ONE shared
11
+ // reader rather than grow a connections-flavoured variant — "adding a second
12
+ // predicate is the drift ADR 0194 D7 exists to prevent". The reader the ADR
13
+ // names belongs to task 1002967, which has not shipped; platform-identity.js is
14
+ // already at the file-size ratchet. So the definition lands here, alone and
15
+ // tiny, and 1002967 ADOPTS it rather than writing a second one.
16
+ //
17
+ // WHAT IT IS NOT. Not a rollup gate: hide_stats suppresses NUMBERS, not
18
+ // existence, and is applied by the rollup readers (ADR 0171 D2), never here.
19
+ // Not authority: an account being visible confers nothing (ADR 0016).
20
+ //
21
+ // Fail-closed: a missing/absent account row is invisible, never visible
22
+ // (ADR 0192 §6 / ADR 0194 §2).
23
+ 'use strict';
24
+
25
+ // The JS twin. `account` is any row carrying profile_state + account_visibility;
26
+ // anything falsy (absent row, unreadable account) answers false.
27
+ function isAccountVisible(account) {
28
+ if (!account) return false;
29
+ return account.profile_state === 'active' && account.account_visibility === 'public';
30
+ }
31
+
32
+ // The SAME predicate as a SQL fragment, for the reads that filter in the
33
+ // database. `alias` qualifies the columns for a joined query (e.g.
34
+ // accountVisibleSql('pa')); omit it for an unaliased single-table query.
35
+ function accountVisibleSql(alias) {
36
+ // The alias is INTERPOLATED, so it is the one thing here that could carry SQL.
37
+ // Callers pass a literal, but a helper that builds SQL should refuse anything
38
+ // that is not a bare identifier rather than trust every future caller.
39
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
40
+ throw new Error('accountVisibleSql: alias must be a bare SQL identifier');
41
+ }
42
+ const q = alias ? `${alias}.` : '';
43
+ return `(${q}profile_state = 'active' AND ${q}account_visibility = 'public')`;
44
+ }
45
+
46
+ // The CONSENT floor on its own — active, without the existence axis. A private
47
+ // account is still a real participant in its OWN scope: ADR 0202 D4 §2 says its
48
+ // connections appear in its own list and in the counterparty's own list, and an
49
+ // incoming request must name who is asking or it cannot be decided. Those
50
+ // own-scope reads floor on this; every PUBLIC read floors on accountVisibleSql.
51
+ function accountActiveSql(alias) {
52
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
53
+ throw new Error('accountActiveSql: alias must be a bare SQL identifier');
54
+ }
55
+ return `(${alias ? `${alias}.` : ''}profile_state = 'active')`;
56
+ }
57
+
58
+ module.exports = { isAccountVisible, accountVisibleSql, accountActiveSql };
@@ -0,0 +1,490 @@
1
+ // modules/platform-identity/connections.js — the connection graph domain
2
+ // (task 1002295 / goal 1000045 criterion C6; ADR 0202, which this implements).
3
+ //
4
+ // WHAT LIVES HERE: the request/accept/decline/cancel/remove lifecycle over
5
+ // platform_identity_connections, the two own-scope list reads (my connections,
6
+ // my inbox+outbox), and the BOUNDED degree-of-separation query behind a
7
+ // profile's "2nd" label. Every rule the ADR made load-bearing is enforced HERE,
8
+ // next to the data, so no route can forget one:
9
+ //
10
+ // * a decline is SILENT and opens a 90-day cooldown in which a re-ask is
11
+ // SWALLOWED — nothing written, and the caller gets the same answer a real
12
+ // create gets (ADR 0202 D2; ADR 0194 D3 applied to a person);
13
+ // * a pending request EXPIRES at 30 days read-time, and the write path
14
+ // resolves an expired row of the pair in the SAME statement that inserts the
15
+ // new one — now() cannot live in an index predicate, so a stale pending row
16
+ // would otherwise keep the partial unique index locked forever;
17
+ // * REMOVE hard-deletes; 'rejected' is kept because it still does work;
18
+ // * degree expansion is CAPPED (FANOUT_1, FANOUT_2, DEGREE_MAX) with a
19
+ // deterministic frontier order, and past the caps the label is null — "out
20
+ // of network", never a number the query did not establish (ADR 0202 D3);
21
+ // * every count returned is the LENGTH OF THE ARRAY the viewer may see. No
22
+ // raw count beside a filtered list — that is a leak by subtraction
23
+ // (ADR 0171 D4 / ADR 0202 D4).
24
+ //
25
+ // WHOSE GRAPH. Node visibility is viewer-relative by design (ADR 0202 title):
26
+ // the viewer's OWN connections floor on profile_state='active' alone — a private
27
+ // account that accepted you appears in your own-scope list (D4 §2) — while every
28
+ // DEEPER hop floors on the full public predicate in account-visibility.js. So a
29
+ // bridge the viewer may not see yields `null`, not "3rd", and two viewers
30
+ // legitimately seeing different labels for the same target is the model working.
31
+ //
32
+ // NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit, admission or
33
+ // permission path reads a connection; accepting one changes nobody's access.
34
+ //
35
+ // Reaches core only through the doorway (ADR 0083).
36
+ 'use strict';
37
+
38
+ const api = require('../../src/module-api');
39
+ const { accountVisibleSql, accountActiveSql } = require('./account-visibility');
40
+
41
+ const { pool: defaultPool } = api;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // The constants ADR 0202 D2/D3 named. Exported rather than spelled inline so a
45
+ // route, a test and a doc read the same number.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ // The lifecycle vocabulary (mirrors the migration's CHECK — the two must not drift).
49
+ const CONNECTION_STATUSES = ['pending', 'accepted', 'rejected', 'cancelled', 'expired'];
50
+
51
+ // A pending request is gone at 30 days, computed at read/write time — the
52
+ // privacy spec's D4 constant and mechanism, no sweeper.
53
+ const REQUEST_EXPIRY_DAYS = 30;
54
+
55
+ // A decline closes the door for 90 days. Inside it, a re-ask from the SAME
56
+ // requester is swallowed: nothing written, and the response is the one a real
57
+ // create returns, because a 409 saying "declined" would disclose exactly what
58
+ // the silence protects.
59
+ const DECLINE_COOLDOWN_DAYS = 90;
60
+
61
+ // The optional one-line "why I'm asking" on a request. Bio-sized (the P02
62
+ // precedent) — long enough to be a sentence, short enough not to be a message
63
+ // channel, which connections deliberately are not.
64
+ const NOTE_MAX_CHARS = 280;
65
+
66
+ // Bounded expansion (D3). Past DEGREE_MAX hops, or past a fan-out cap, the label
67
+ // is null. FANOUT_1 bounds a single account's 1-hop; FANOUT_2 bounds the 2-hop
68
+ // frontier the 3rd-degree test intersects against.
69
+ const DEGREE_MAX = 3;
70
+ const FANOUT_1 = 500;
71
+ const FANOUT_2 = 5000;
72
+
73
+ // What a counterparty row carries: exactly what a name in a list needs (ADR 0202
74
+ // Deferred — "handle, display name, avatar"). github_id stays internal, and
75
+ // github_login is deliberately absent: a private account that connected with you
76
+ // did not thereby publish its GitHub identity to you.
77
+ const COUNTERPARTY_COLUMNS = 'pa.handle, pa.display_name, pa.avatar_url';
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // The 1-hop, written once
81
+ // ---------------------------------------------------------------------------
82
+
83
+ // The accepted-neighbour set of ONE account, as a SQL fragment over the two
84
+ // directional indexes (never `pair_lo = x OR pair_hi = x`, which cannot use
85
+ // either). `param` is the $n placeholder holding that account's github_id.
86
+ // Written once so no caller invents a direction-aware WHERE of its own — the
87
+ // symmetry of an accepted edge is a property of the schema, not of a route.
88
+ function neighboursSql(param) {
89
+ if (!/^\$\d+$/.test(String(param))) {
90
+ throw new Error('neighboursSql: param must be a $n placeholder');
91
+ }
92
+ return `
93
+ SELECT addressee_github_id AS gid FROM platform_identity_connections
94
+ WHERE requester_github_id = ${param}::bigint AND status = 'accepted'
95
+ UNION
96
+ SELECT requester_github_id AS gid FROM platform_identity_connections
97
+ WHERE addressee_github_id = ${param}::bigint AND status = 'accepted'`;
98
+ }
99
+
100
+ // The same expansion from a SET of accounts (the 2-hop frontier). `cte` is the
101
+ // name of a CTE with one `gid` column.
102
+ function frontierSql(cte) {
103
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(cte))) {
104
+ throw new Error('frontierSql: cte must be a bare SQL identifier');
105
+ }
106
+ return `
107
+ SELECT c.addressee_github_id AS gid
108
+ FROM platform_identity_connections c
109
+ JOIN ${cte} s ON c.requester_github_id = s.gid
110
+ WHERE c.status = 'accepted'
111
+ UNION
112
+ SELECT c.requester_github_id AS gid
113
+ FROM platform_identity_connections c
114
+ JOIN ${cte} s ON c.addressee_github_id = s.gid
115
+ WHERE c.status = 'accepted'`;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Addressing: handle → account
120
+ // ---------------------------------------------------------------------------
121
+
122
+ // Resolve a handle to the row the visibility predicates need. Matches the
123
+ // current handle first, then the single-slot previous_handle (P01's rename
124
+ // window) — an ACTION endpoint follows a rename rather than answering 301, so a
125
+ // stale "Connect" link on a page still works. Returns null for an unknown
126
+ // handle; the CALLER decides visibility, because the answer is viewer-relative.
127
+ async function findAccountByHandle(handle, { pool = defaultPool } = {}) {
128
+ const norm = String(handle ?? '').trim().toLowerCase();
129
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(norm)) return null;
130
+ const { rows } = await pool.query(
131
+ `SELECT github_id, handle, display_name, avatar_url, profile_state, account_visibility
132
+ FROM platform_identity_accounts
133
+ WHERE lower(handle) = $1 OR lower(previous_handle) = $1
134
+ ORDER BY (lower(handle) = $1) DESC
135
+ LIMIT 1`,
136
+ [norm],
137
+ );
138
+ return rows[0] || null;
139
+ }
140
+
141
+ // Is this account already an ACCEPTED connection of the viewer? The other half
142
+ // of viewer-relative visibility: a private account you are connected to is
143
+ // addressable to YOU (ADR 0202 D4 §2) and to nobody else.
144
+ async function isConnectedTo(viewerGithubId, targetGithubId, { pool = defaultPool } = {}) {
145
+ const { rowCount } = await pool.query(
146
+ `SELECT 1 FROM platform_identity_connections
147
+ WHERE status = 'accepted'
148
+ AND pair_lo = LEAST($1::bigint, $2::bigint)
149
+ AND pair_hi = GREATEST($1::bigint, $2::bigint)
150
+ LIMIT 1`,
151
+ [viewerGithubId, targetGithubId],
152
+ );
153
+ return rowCount > 0;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Write: request
158
+ // ---------------------------------------------------------------------------
159
+
160
+ // Send a connection request. ONE statement, and the shape is load-bearing:
161
+ //
162
+ // `expired` resolves any timed-out pending row of this pair — referenced by
163
+ // the main query through `guard` so the executor must drain it
164
+ // BEFORE the INSERT is attempted (an UNREFERENCED data-modifying
165
+ // CTE runs in ExecPostprocessPlan, i.e. AFTER the insert, which
166
+ // would hit the very unique index the expiry exists to release);
167
+ // the cooldown NOT EXISTS inserts zero rows inside the 90-day window, which is
168
+ // how a swallowed re-ask writes nothing while still answering yes.
169
+ //
170
+ // Returns { outcome: 'created' | 'swallowed' | 'already_open' }. The ROUTE must
171
+ // answer 'created' and 'swallowed' identically — that is the whole point.
172
+ //
173
+ // WHAT THE SILENCE DOES NOT COVER, stated rather than implied: a swallowed ask
174
+ // writes nothing, so it never appears in the requester's own outbox, while a real
175
+ // one does within milliseconds. The RESPONSE reveals nothing (D2 §2); the absence
176
+ // afterwards is observable. ADR 0202's own Consequences accept exactly this — "a
177
+ // builder can send the same request twice a year and never learn why nothing
178
+ // happens" — and the alternative, writing a decoy row, would be a lie in the
179
+ // database to protect a lie in the API. See ADR 0207.
180
+ async function requestConnection(
181
+ { requesterGithubId, addresseeGithubId, note = null },
182
+ { pool = defaultPool } = {},
183
+ ) {
184
+ const sql = `
185
+ WITH expired AS (
186
+ UPDATE platform_identity_connections c
187
+ SET status = 'expired', resolved_at = now(), updated_at = now()
188
+ WHERE c.pair_lo = LEAST($1::bigint, $2::bigint)
189
+ AND c.pair_hi = GREATEST($1::bigint, $2::bigint)
190
+ AND c.status = 'pending'
191
+ AND c.created_at <= now() - make_interval(days => $3::int)
192
+ RETURNING c.id
193
+ ),
194
+ guard AS (SELECT count(*)::int AS expired_count FROM expired)
195
+ INSERT INTO platform_identity_connections (requester_github_id, addressee_github_id, note)
196
+ SELECT $1::bigint, $2::bigint, $5::text
197
+ FROM guard
198
+ WHERE NOT EXISTS (
199
+ SELECT 1 FROM platform_identity_connections c
200
+ WHERE c.pair_lo = LEAST($1::bigint, $2::bigint)
201
+ AND c.pair_hi = GREATEST($1::bigint, $2::bigint)
202
+ AND c.status = 'rejected'
203
+ AND c.requester_github_id = $1::bigint
204
+ AND c.resolved_at > now() - make_interval(days => $4::int)
205
+ )
206
+ RETURNING id`;
207
+ try {
208
+ const { rowCount, rows } = await pool.query(sql, [
209
+ requesterGithubId, addresseeGithubId,
210
+ REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS, note,
211
+ ]);
212
+ return rowCount > 0
213
+ ? { outcome: 'created', id: String(rows[0].id) }
214
+ : { outcome: 'swallowed' };
215
+ } catch (err) {
216
+ // 23505 = the partial unique index over (pending, accepted): a request or a
217
+ // connection is already open for this pair. Safe to name — both cases are
218
+ // already visible in the caller's OWN scope, so it discloses nothing the
219
+ // caller cannot read anyway.
220
+ if (err && err.code === '23505') return { outcome: 'already_open' };
221
+ throw err;
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Write: resolve — accept, decline, remove/cancel
227
+ // ---------------------------------------------------------------------------
228
+
229
+ // Accept a pending request. The addressee ONLY, in one statement, so an
230
+ // 'accepted' row can never exist without being readable as a connection.
231
+ // rowCount 0 covers every refusal — not yours, not pending, expired — and the
232
+ // route answers all of them 404, so a connection id cannot be probed.
233
+ async function acceptConnection({ id, githubId }, { pool = defaultPool } = {}) {
234
+ const { rowCount } = await pool.query(
235
+ `UPDATE platform_identity_connections
236
+ SET status = 'accepted', resolved_at = now(), updated_at = now()
237
+ WHERE id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'
238
+ AND created_at > now() - make_interval(days => $3::int)
239
+ RETURNING id`,
240
+ [id, githubId, REQUEST_EXPIRY_DAYS],
241
+ );
242
+ return rowCount > 0;
243
+ }
244
+
245
+ // Decline. The addressee ONLY. The requester is never told (D2 §1); the row is
246
+ // kept because 'rejected' IS the 90-day cooldown, not merely an audit trail.
247
+ async function declineConnection({ id, githubId }, { pool = defaultPool } = {}) {
248
+ const { rowCount } = await pool.query(
249
+ `UPDATE platform_identity_connections
250
+ SET status = 'rejected', resolved_at = now(), updated_at = now()
251
+ WHERE id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'
252
+ AND created_at > now() - make_interval(days => $3::int)
253
+ RETURNING id`,
254
+ [id, githubId, REQUEST_EXPIRY_DAYS],
255
+ );
256
+ return rowCount > 0;
257
+ }
258
+
259
+ // Remove an accepted connection (either side — it is consensual data about two
260
+ // people, so either may withdraw) or cancel a pending one (the requester only;
261
+ // an addressee ending a pending row is a DECLINE and must go through that path,
262
+ // which is what arms the cooldown). One statement, two mutually-exclusive
263
+ // branches keyed on status. Remove HARD-DELETES: a tombstone saying "A removed
264
+ // B" is data neither consented to keep and no read path uses.
265
+ // Returns 'removed' | 'cancelled' | null (nothing matched → the route 404s).
266
+ async function removeConnection({ id, githubId }, { pool = defaultPool } = {}) {
267
+ const { rows } = await pool.query(
268
+ `WITH removed AS (
269
+ DELETE FROM platform_identity_connections
270
+ WHERE id = $1 AND status = 'accepted'
271
+ AND (requester_github_id = $2::bigint OR addressee_github_id = $2::bigint)
272
+ RETURNING 'removed'::text AS outcome
273
+ ),
274
+ cancelled AS (
275
+ UPDATE platform_identity_connections
276
+ SET status = 'cancelled', resolved_at = now(), updated_at = now()
277
+ WHERE id = $1 AND status = 'pending' AND requester_github_id = $2::bigint
278
+ RETURNING 'cancelled'::text AS outcome
279
+ )
280
+ SELECT outcome FROM removed UNION ALL SELECT outcome FROM cancelled`,
281
+ [id, githubId],
282
+ );
283
+ return rows[0] ? rows[0].outcome : null;
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Read: the two own-scope lists
288
+ // ---------------------------------------------------------------------------
289
+
290
+ // The caller's accepted connections. OWN SCOPE: the floor is profile_state
291
+ // alone, because a private account that connected with you appears in your list
292
+ // and in theirs (D4 §2) — and in no public list, which is why this read has no
293
+ // public twin. The returned `count` is the array's own length by construction
294
+ // (the leak-by-subtraction rule); there is no hidden remainder.
295
+ async function listConnections({ githubId, limit = FANOUT_1, offset = 0 }, { pool = defaultPool } = {}) {
296
+ const { rows } = await pool.query(
297
+ `WITH edge AS (
298
+ SELECT id, addressee_github_id AS gid, resolved_at
299
+ FROM platform_identity_connections
300
+ WHERE requester_github_id = $1::bigint AND status = 'accepted'
301
+ UNION ALL
302
+ SELECT id, requester_github_id AS gid, resolved_at
303
+ FROM platform_identity_connections
304
+ WHERE addressee_github_id = $1::bigint AND status = 'accepted'
305
+ )
306
+ SELECT e.id::text AS id, e.resolved_at AS connected_at, ${COUNTERPARTY_COLUMNS}
307
+ FROM edge e
308
+ JOIN platform_identity_accounts pa ON pa.github_id = e.gid
309
+ WHERE ${accountActiveSql('pa')}
310
+ ORDER BY e.resolved_at DESC NULLS LAST, e.id DESC
311
+ LIMIT $2 OFFSET $3`,
312
+ [githubId, Math.min(Number(limit) || FANOUT_1, FANOUT_1), Math.max(0, Number(offset) || 0)],
313
+ );
314
+ return rows;
315
+ }
316
+
317
+ // The caller's inbox and outbox: pending, unexpired requests in both directions.
318
+ // The counterparty floors on profile_state only — an incoming request must name
319
+ // who is asking or it cannot be decided, and D4 §2 lets a private account send.
320
+ //
321
+ // Each direction carries its OWN limit: a shared cap applied before the split
322
+ // would let a busy outbox push every incoming request out of the response, and
323
+ // an empty inbox that is really a truncation is the worst kind of wrong answer.
324
+ //
325
+ // `seen_at` rides the INCOMING rows only and is stripped from the outbox — a
326
+ // decline is silent, and so is a read (D2 §1): a requester who could watch
327
+ // seen_at appear would learn the addressee is deliberating. The stamp is then
328
+ // written back for exactly the rows this call rendered, so it means "delivered",
329
+ // not "your inbox was polled".
330
+ async function listRequests({ githubId, limit = FANOUT_1 }, { pool = defaultPool } = {}) {
331
+ const cap = Math.min(Number(limit) || FANOUT_1, FANOUT_1);
332
+ const { rows } = await pool.query(
333
+ `WITH mine AS (
334
+ (SELECT c.id, c.note, c.created_at, c.seen_at, true AS incoming,
335
+ c.requester_github_id AS other
336
+ FROM platform_identity_connections c
337
+ WHERE c.addressee_github_id = $1::bigint AND c.status = 'pending'
338
+ AND c.created_at > now() - make_interval(days => $2::int)
339
+ ORDER BY c.created_at DESC, c.id DESC
340
+ LIMIT $3)
341
+ UNION ALL
342
+ (SELECT c.id, c.note, c.created_at, c.seen_at, false AS incoming,
343
+ c.addressee_github_id AS other
344
+ FROM platform_identity_connections c
345
+ WHERE c.requester_github_id = $1::bigint AND c.status = 'pending'
346
+ AND c.created_at > now() - make_interval(days => $2::int)
347
+ ORDER BY c.created_at DESC, c.id DESC
348
+ LIMIT $3)
349
+ )
350
+ SELECT m.id::text AS id, m.note, m.created_at, m.seen_at, m.incoming, ${COUNTERPARTY_COLUMNS}
351
+ FROM mine m
352
+ JOIN platform_identity_accounts pa ON pa.github_id = m.other
353
+ WHERE ${accountActiveSql('pa')}
354
+ ORDER BY m.created_at DESC, m.id DESC`,
355
+ [githubId, REQUEST_EXPIRY_DAYS, cap],
356
+ );
357
+ const incoming = rows.filter((r) => r.incoming).map(({ incoming, ...r }) => r);
358
+ const outgoing = rows.filter((r) => !r.incoming).map(({ incoming, seen_at, ...r }) => r);
359
+ const unseen = incoming.filter((r) => !r.seen_at).map((r) => r.id);
360
+ if (unseen.length) {
361
+ await pool.query(
362
+ `UPDATE platform_identity_connections
363
+ SET seen_at = now(), updated_at = now()
364
+ WHERE id = ANY($1::bigint[]) AND addressee_github_id = $2::bigint AND seen_at IS NULL`,
365
+ [unseen, githubId],
366
+ );
367
+ }
368
+ return { incoming, outgoing };
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Read: the bounded degree label
373
+ // ---------------------------------------------------------------------------
374
+
375
+ // degreeBetween(viewer, target) → { degree: 0|1|2|3|null, mutuals: [], truncated }
376
+ //
377
+ // Bounded expansion FROM THE VIEWER, staged exactly as ADR 0202 D3 describes,
378
+ // because the stages have different costs and most calls stop at the first:
379
+ //
380
+ // near — one statement: is the target in V's 1-hop (1st), and what is the
381
+ // intersection of V's and T's 1-hops (2nd, and the mutual list for
382
+ // free). Two index range scans and a hash intersect.
383
+ // far — only when `near` found nothing: V's 1-hop (cap FANOUT_1) expanded to
384
+ // a 2-hop frontier (cap FANOUT_2) and intersected with T's 1-hop.
385
+ //
386
+ // Deliberate departure from the ADR's "recursive CTE" wording, same semantics:
387
+ // a recursive term cannot carry a PER-LEVEL fan-out cap or a deterministic
388
+ // per-level truncation order, which are the two properties D3 actually decided.
389
+ // Level-wise CTEs express both; `ORDER BY gid LIMIT n` makes a truncation
390
+ // reproducible rather than a different answer each call.
391
+ //
392
+ // Past the caps or past DEGREE_MAX the answer is null — "out of network", never
393
+ // a number the query did not establish. `truncated` says a cap bit, so the UI
394
+ // can be honest about it; it describes the VIEWER'S OWN graph size and so
395
+ // discloses nothing about anyone else.
396
+ //
397
+ // TWO HONEST LIMITS (ADR 0207). (a) The TARGET's 1-hop is capped too, and that
398
+ // truncation is deliberately NOT reported: `truncated: true` on it would leak one
399
+ // bit about a third party's degree, which is a worse trade than an occasional
400
+ // null. (b) The QUERY COUNT is constant, and so is the OUTPUT size — but the
401
+ // frontier's WORK is O(sum of degree over v1), because ORDER BY … LIMIT must
402
+ // materialise the frontier before it truncates. At hub scale that is a few index
403
+ // scans; the revisit trigger is a measured p95, not a guess.
404
+ async function degreeBetween(viewerGithubId, targetGithubId, { pool = defaultPool } = {}) {
405
+ if (String(viewerGithubId) === String(targetGithubId)) {
406
+ return { degree: 0, mutuals: [], truncated: false };
407
+ }
408
+
409
+ const near = await pool.query(
410
+ `WITH v1 AS (
411
+ SELECT r.gid FROM (${neighboursSql('$1')}) r
412
+ JOIN platform_identity_accounts pa ON pa.github_id = r.gid
413
+ WHERE ${accountActiveSql('pa')}
414
+ ORDER BY r.gid LIMIT $3
415
+ ),
416
+ t1 AS (
417
+ SELECT r.gid FROM (${neighboursSql('$2')}) r ORDER BY r.gid LIMIT $3
418
+ )
419
+ SELECT
420
+ EXISTS (SELECT 1 FROM platform_identity_connections
421
+ WHERE status = 'accepted'
422
+ AND pair_lo = LEAST($1::bigint, $2::bigint)
423
+ AND pair_hi = GREATEST($1::bigint, $2::bigint)) AS is_first,
424
+ (SELECT count(*)::int FROM v1) AS v1_count,
425
+ COALESCE((
426
+ SELECT json_agg(json_build_object(
427
+ 'handle', pa.handle,
428
+ 'display_name', pa.display_name,
429
+ 'avatar_url', pa.avatar_url) ORDER BY pa.github_id)
430
+ FROM v1
431
+ JOIN t1 ON t1.gid = v1.gid
432
+ JOIN platform_identity_accounts pa ON pa.github_id = v1.gid
433
+ ), '[]'::json) AS mutuals`,
434
+ [viewerGithubId, targetGithubId, FANOUT_1],
435
+ );
436
+ const n = near.rows[0] || {};
437
+ // Every mutual is one of V's OWN connections, so V is entitled to all of them
438
+ // and the array is complete up to FANOUT_1 — which is why the count may be its
439
+ // length rather than a separate number.
440
+ const mutuals = Array.isArray(n.mutuals) ? n.mutuals : [];
441
+ const v1Truncated = (n.v1_count || 0) >= FANOUT_1;
442
+
443
+ if (n.is_first) return { degree: 1, mutuals, truncated: v1Truncated };
444
+ if (mutuals.length > 0) return { degree: 2, mutuals, truncated: v1Truncated };
445
+
446
+ const far = await pool.query(
447
+ `WITH v1 AS (
448
+ SELECT r.gid FROM (${neighboursSql('$1')}) r
449
+ JOIN platform_identity_accounts pa ON pa.github_id = r.gid
450
+ WHERE ${accountActiveSql('pa')}
451
+ ORDER BY r.gid LIMIT $3
452
+ ),
453
+ v2 AS (
454
+ SELECT f.gid FROM (${frontierSql('v1')}) f
455
+ JOIN platform_identity_accounts pa ON pa.github_id = f.gid
456
+ WHERE ${accountVisibleSql('pa')}
457
+ AND f.gid <> $1::bigint
458
+ AND f.gid NOT IN (SELECT gid FROM v1)
459
+ ORDER BY f.gid LIMIT $4
460
+ ),
461
+ t1 AS (
462
+ SELECT r.gid FROM (${neighboursSql('$2')}) r ORDER BY r.gid LIMIT $3
463
+ )
464
+ SELECT EXISTS (SELECT 1 FROM v2 JOIN t1 ON t1.gid = v2.gid) AS is_third,
465
+ (SELECT count(*)::int FROM v2) AS v2_count`,
466
+ [viewerGithubId, targetGithubId, FANOUT_1, FANOUT_2],
467
+ );
468
+ const f = far.rows[0] || {};
469
+ const truncated = v1Truncated || (f.v2_count || 0) >= FANOUT_2;
470
+ return { degree: f.is_third ? DEGREE_MAX : null, mutuals, truncated };
471
+ }
472
+
473
+ module.exports = {
474
+ CONNECTION_STATUSES,
475
+ REQUEST_EXPIRY_DAYS,
476
+ DECLINE_COOLDOWN_DAYS,
477
+ NOTE_MAX_CHARS,
478
+ DEGREE_MAX,
479
+ FANOUT_1,
480
+ FANOUT_2,
481
+ findAccountByHandle,
482
+ isConnectedTo,
483
+ requestConnection,
484
+ acceptConnection,
485
+ declineConnection,
486
+ removeConnection,
487
+ listConnections,
488
+ listRequests,
489
+ degreeBetween,
490
+ };