@bongos/core 1.19.418 → 1.19.419
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 +58 -23
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +16 -0
- package/clients/bongos-client/index.cjs +16 -0
- package/clients/bongos-client/index.d.ts +23 -0
- package/clients/bongos-client/index.mjs +16 -0
- package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +418 -3
- package/docs/api-reference.md +13 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +16 -0
- package/modules/platform-identity/account-visibility.js +58 -0
- package/modules/platform-identity/connections.js +490 -0
- package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/connections.js +236 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/connections_api.mjs +568 -0
- package/tests/connections_ownership.mjs +313 -0
- package/tests/own_scope_guard.mjs +5 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- platform_identity_015_connections.sql — the connection graph (task 1002295 /
|
|
2
|
+
-- goal 1000045 criterion C6; ADR 0202 D1, whose migration sketch this is).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT THIS IS. One directed row per relationship — the REQUEST, not the edge.
|
|
5
|
+
-- requester/addressee are kept because the inbox, the cancel path and the
|
|
6
|
+
-- 90-day decline cooldown all need to know who asked; the unordered pair is
|
|
7
|
+
-- GENERATED from LEAST/GREATEST so A→B and B→A collide on one index instead of
|
|
8
|
+
-- both being accepted. Reads are symmetric (the module unions both directions);
|
|
9
|
+
-- no route writes a direction-aware WHERE of its own.
|
|
10
|
+
--
|
|
11
|
+
-- WHY GENERATED + A PARTIAL UNIQUE INDEX rather than a route check: a duplicate
|
|
12
|
+
-- request and a simultaneous reciprocal request then become DATABASE errors, not
|
|
13
|
+
-- a race two handlers have to remember to lose. The index covers only the OPEN
|
|
14
|
+
-- statuses, so resolved rows stay as the audit trail and never block a later ask
|
|
15
|
+
-- (the goal_membership_requests precedent, core_188).
|
|
16
|
+
--
|
|
17
|
+
-- WHY NO TOMBSTONE ON REMOVE (ADR 0202 D2): removing an accepted connection
|
|
18
|
+
-- HARD-DELETES the row. "A removed B" is data neither party consented to keep
|
|
19
|
+
-- and no read path uses. 'rejected' is kept because it still does work — it is
|
|
20
|
+
-- the cooldown — which is exactly why the asymmetry is not an inconsistency.
|
|
21
|
+
--
|
|
22
|
+
-- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank check, admission gate
|
|
23
|
+
-- or credit path reads this table; accepting a connection changes nobody's rank,
|
|
24
|
+
-- credits, or access to any project. It is descriptive social data only.
|
|
25
|
+
--
|
|
26
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Forward-safe: CREATE
|
|
27
|
+
-- only, so the previous code release still runs against this schema. Idempotent.
|
|
28
|
+
|
|
29
|
+
BEGIN;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS platform_identity_connections (
|
|
32
|
+
id bigserial PRIMARY KEY,
|
|
33
|
+
requester_github_id bigint NOT NULL,
|
|
34
|
+
addressee_github_id bigint NOT NULL,
|
|
35
|
+
-- The unordered pair, derived — never written by a caller, so A→B and B→A
|
|
36
|
+
-- collide on the index below instead of both being accepted.
|
|
37
|
+
pair_lo bigint GENERATED ALWAYS AS (LEAST(requester_github_id, addressee_github_id)) STORED,
|
|
38
|
+
pair_hi bigint GENERATED ALWAYS AS (GREATEST(requester_github_id, addressee_github_id)) STORED,
|
|
39
|
+
status text NOT NULL DEFAULT 'pending'
|
|
40
|
+
CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'expired')),
|
|
41
|
+
-- The note cap lives HERE as well as in the route: requestConnection is an
|
|
42
|
+
-- exported domain function, and every other invariant in this table is one the
|
|
43
|
+
-- engine refuses to break rather than one a caller must remember.
|
|
44
|
+
note text CONSTRAINT platform_identity_connections_note_len
|
|
45
|
+
CHECK (note IS NULL OR length(note) <= 280),
|
|
46
|
+
seen_at timestamptz,
|
|
47
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
48
|
+
resolved_at timestamptz,
|
|
49
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
50
|
+
CONSTRAINT platform_identity_connections_not_self
|
|
51
|
+
CHECK (requester_github_id <> addressee_github_id)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
-- At most ONE open relationship per pair. Resolved rows stay as the audit trail
|
|
55
|
+
-- and never block a later ask.
|
|
56
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_connections_open
|
|
57
|
+
ON platform_identity_connections (pair_lo, pair_hi)
|
|
58
|
+
WHERE status IN ('pending', 'accepted');
|
|
59
|
+
|
|
60
|
+
-- The three reads: my inbox, my outbox, and the 1-hop neighbourhood both the
|
|
61
|
+
-- degree traversal and the suggester expand from.
|
|
62
|
+
CREATE INDEX IF NOT EXISTS platform_identity_connections_addressee
|
|
63
|
+
ON platform_identity_connections (addressee_github_id, status);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS platform_identity_connections_requester
|
|
65
|
+
ON platform_identity_connections (requester_github_id, status);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS platform_identity_connections_pair_accepted
|
|
67
|
+
ON platform_identity_connections (pair_lo, pair_hi) WHERE status = 'accepted';
|
|
68
|
+
|
|
69
|
+
COMMENT ON TABLE platform_identity_connections IS
|
|
70
|
+
'Account-to-account connections on the hub (ADR 0202). One DIRECTED request row per relationship; pair_lo/pair_hi are the generated unordered key a partial unique index over (pending, accepted) enforces. Lifecycle pending -> accepted | rejected | cancelled | expired; remove hard-deletes. Descriptive only — never authority (ADR 0016).';
|
|
71
|
+
|
|
72
|
+
COMMENT ON COLUMN platform_identity_connections.status IS
|
|
73
|
+
'pending -> accepted (addressee accepted) | rejected (addressee declined — SILENT to the requester, and the 90-day cooldown that swallows a re-ask) | cancelled (requester withdrew) | expired (30 days unanswered, resolved at write time because now() cannot live in an index predicate).';
|
|
74
|
+
|
|
75
|
+
COMMENT ON COLUMN platform_identity_connections.seen_at IS
|
|
76
|
+
'Stamped on first inbox render (ADR 0202 D2, the privacy-spec D4 parity item) so a delivered/seen affordance can land later without a schema change. Never exposed to the requester — a decline is silent, and so is a read.';
|
|
77
|
+
|
|
78
|
+
COMMIT;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"maintainer": "Cloud Bongos maintainers",
|
|
11
11
|
"maintenance": { "status": "core-maintained" },
|
|
12
12
|
"contributes": {
|
|
13
|
-
"routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile"],
|
|
13
|
+
"routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
|
|
14
14
|
"migrations": true,
|
|
15
15
|
"pollers": ["code-sweep"]
|
|
16
16
|
},
|