@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.
- package/.bongos-core.json +95 -45
- package/.claude/skills/new-project/SKILL.md +22 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +28 -1
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
- package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +518 -5
- package/docs/api-reference.md +16 -3
- package/docs/copy-inventory.md +186 -174
- package/docs/copy-registry.json +348 -238
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +18 -0
- package/modules/onboarding/routes/access-requests.js +16 -3
- 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/platform-identity.js +5 -5
- package/modules/platform-identity/routes/connections.js +236 -0
- package/modules/platform-identity/tests/platform-identity.mjs +20 -0
- package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
- package/modules/provisioning/onboard-plan.js +13 -0
- package/modules/provisioning/provisioning.js +113 -5
- package/modules/provisioning/routes/provisioning.js +105 -0
- package/modules/provisioning/tests/provisioning.mjs +84 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +161 -42
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/onboard.js +41 -1
- package/scripts/gds/provision-net.js +7 -3
- package/src/bongos/middleware/rate-limit.js +68 -2
- package/src/bongos/routes/auth.js +19 -12
- package/src/module-api.js +13 -1
- package/tests/account_existence_rate_limit.mjs +375 -0
- package/tests/connections_api.mjs +568 -0
- package/tests/connections_ownership.mjs +313 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboard.mjs +56 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/projects_hub.mjs +69 -0
- package/tests/projects_hub_pre_uat.mjs +27 -5
- 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
|
-
|
|
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
|
+
};
|