@bongos/core 1.19.1025 → 1.19.1027
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 +88 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +40 -0
- package/clients/bongos-client/index.cjs +40 -0
- package/clients/bongos-client/index.d.ts +63 -0
- package/clients/bongos-client/index.mjs +40 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +4 -2
- package/docs/api/openapi.json +1361 -151
- package/docs/api-reference.md +32 -4
- package/docs/copy-inventory.md +6 -6
- package/docs/copy-registry.json +10 -20
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/platform-identity/community-leaderboard.js +235 -0
- package/modules/platform-identity/connections.js +1 -0
- package/modules/platform-identity/guilds.js +705 -0
- package/modules/platform-identity/migrations/platform_identity_025_guilds.sql +100 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/read-rate-limit.js +62 -0
- package/modules/platform-identity/routes/community-leaderboard.js +69 -0
- package/modules/platform-identity/routes/community-search.js +4 -31
- package/modules/platform-identity/routes/guilds-public.js +57 -0
- package/modules/platform-identity/routes/guilds.js +393 -0
- package/modules/platform-identity/routes/projects.js +1 -1
- package/modules/platform-identity/terms-acceptance.js +14 -5
- package/modules/public-landing/public/terms.html +3 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +10 -0
- package/src/module-api.js +1 -1
- package/tests/community_leaderboard_db.mjs +202 -0
- package/tests/community_leaderboard_route.mjs +414 -0
- package/tests/goal_map_page.mjs +8 -0
- package/tests/guilds_api.mjs +521 -0
- package/tests/guilds_db.mjs +343 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/terms_acceptance.mjs +74 -28
- package/tests/terms_grandfather_db.mjs +2 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
-- platform_identity_025_guilds.sql — guilds on the hub (task 1002300, R24;
|
|
2
|
+
-- ADR 0336 D1, whose migration sketch this is, amended by ADR 0337 D4.4).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT THIS IS. Three tables: the guild, its members, and the open invites and
|
|
5
|
+
-- join requests between them. A guild is account-global, so it lives beside
|
|
6
|
+
-- connections in platform-identity rather than beside core goal_members (a goal
|
|
7
|
+
-- is an instance-local work container; a guild is a cross-project group of
|
|
8
|
+
-- people). A standalone instance never enables this module, so it has no guild
|
|
9
|
+
-- surface at all.
|
|
10
|
+
--
|
|
11
|
+
-- THE SHAPE CARRIES THE RULES, so no route has to remember them:
|
|
12
|
+
-- * visibility has NO DEFAULT — the creator chooses public or unlisted, and a
|
|
13
|
+
-- silent default would decide a privacy question for them (ADR 0336 D1);
|
|
14
|
+
-- * there is no creator column — the owner ROW is the authority, and a
|
|
15
|
+
-- permanent "created by" would keep a departed creator tied to the guild;
|
|
16
|
+
-- * membership_kind, never `role` — ADR 0174 reserves that word for the crafts;
|
|
17
|
+
-- * exactly one owner per guild is a partial unique index, held by the
|
|
18
|
+
-- database. Because it is checked row by row and a partial index cannot be
|
|
19
|
+
-- deferred, a transfer is two statements in one transaction (ADR 0336 D2);
|
|
20
|
+
-- * shown_publicly has NO DEFAULT — it records what the join card showed
|
|
21
|
+
-- (ADR 0336 D4 §3), so every insert must say it;
|
|
22
|
+
-- * counted_in_totals DEFAULTS TRUE — ADR 0337 D4.4: every member counts in the
|
|
23
|
+
-- guild's rounded totals unless they switch it off for that guild. The
|
|
24
|
+
-- request row carries the requester's choice until the owner accepts.
|
|
25
|
+
--
|
|
26
|
+
-- Leaving, removal and dissolving HARD-DELETE (ADR 0336 D2, the ADR 0202 D2
|
|
27
|
+
-- reason); the FKs cascade so a dissolved guild takes its open requests with it.
|
|
28
|
+
--
|
|
29
|
+
-- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit or admission
|
|
30
|
+
-- path reads these tables; a guild membership admits nobody anywhere.
|
|
31
|
+
--
|
|
32
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). CREATE only, so the
|
|
33
|
+
-- previous release still runs against this schema. Idempotent.
|
|
34
|
+
|
|
35
|
+
BEGIN;
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guilds (
|
|
38
|
+
id bigserial PRIMARY KEY,
|
|
39
|
+
-- The /g/<slug> key, fixed at creation (ADR 0336 D6): the handle charset,
|
|
40
|
+
-- lowercase, 2-39 characters. Canonical form is enforced here as well as in
|
|
41
|
+
-- the domain, so a direct write cannot store a slug the URL cannot carry.
|
|
42
|
+
slug text NOT NULL CONSTRAINT platform_identity_guilds_slug_form
|
|
43
|
+
CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$'),
|
|
44
|
+
name text NOT NULL CONSTRAINT platform_identity_guilds_name_len
|
|
45
|
+
CHECK (length(name) BETWEEN 1 AND 80),
|
|
46
|
+
description text CONSTRAINT platform_identity_guilds_description_len
|
|
47
|
+
CHECK (description IS NULL OR length(description) <= 500),
|
|
48
|
+
visibility text NOT NULL CHECK (visibility IN ('public', 'unlisted')),
|
|
49
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
50
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
51
|
+
);
|
|
52
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guilds_slug
|
|
53
|
+
ON platform_identity_guilds (lower(slug));
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guild_members (
|
|
56
|
+
guild_id bigint NOT NULL REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
|
|
57
|
+
github_id bigint NOT NULL,
|
|
58
|
+
membership_kind text NOT NULL DEFAULT 'member' CHECK (membership_kind IN ('owner', 'member')),
|
|
59
|
+
shown_publicly boolean NOT NULL,
|
|
60
|
+
counted_in_totals boolean NOT NULL DEFAULT true,
|
|
61
|
+
joined_at timestamptz NOT NULL DEFAULT now(),
|
|
62
|
+
PRIMARY KEY (guild_id, github_id)
|
|
63
|
+
);
|
|
64
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guild_owner
|
|
65
|
+
ON platform_identity_guild_members (guild_id) WHERE membership_kind = 'owner';
|
|
66
|
+
CREATE INDEX IF NOT EXISTS platform_identity_guild_members_account
|
|
67
|
+
ON platform_identity_guild_members (github_id);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guild_requests (
|
|
70
|
+
id bigserial PRIMARY KEY,
|
|
71
|
+
guild_id bigint NOT NULL REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
|
|
72
|
+
github_id bigint NOT NULL,
|
|
73
|
+
kind text NOT NULL CHECK (kind IN ('invite', 'request')),
|
|
74
|
+
status text NOT NULL DEFAULT 'pending'
|
|
75
|
+
CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'expired')),
|
|
76
|
+
counted_in_totals boolean NOT NULL DEFAULT true,
|
|
77
|
+
seen_at timestamptz,
|
|
78
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
79
|
+
resolved_at timestamptz
|
|
80
|
+
);
|
|
81
|
+
-- At most one OPEN row per (guild, person), whichever direction it runs, so an
|
|
82
|
+
-- invite and a request for the same pair collide instead of racing.
|
|
83
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guild_requests_open
|
|
84
|
+
ON platform_identity_guild_requests (guild_id, github_id) WHERE status = 'pending';
|
|
85
|
+
CREATE INDEX IF NOT EXISTS platform_identity_guild_requests_account
|
|
86
|
+
ON platform_identity_guild_requests (github_id, status);
|
|
87
|
+
|
|
88
|
+
COMMENT ON TABLE platform_identity_guilds IS
|
|
89
|
+
'Hub guilds (ADR 0336). visibility is the owner''s choice: public (a public page, takes join requests) or unlisted (no public existence, invites only). No creator column: the owner row in platform_identity_guild_members is the authority. Descriptive only, never authority (ADR 0016).';
|
|
90
|
+
|
|
91
|
+
COMMENT ON COLUMN platform_identity_guild_members.shown_publicly IS
|
|
92
|
+
'This member agreed to appear on the guild''s PUBLIC page (ADR 0336 D4). Set from the visibility the join card showed; changing the guild''s visibility changes only the owner''s flag, never another member''s.';
|
|
93
|
+
|
|
94
|
+
COMMENT ON COLUMN platform_identity_guild_members.counted_in_totals IS
|
|
95
|
+
'The member''s per-guild switch for the guild''s rounded totals (ADR 0337 D4.4). On by default; the member may turn it off for any guild at any time.';
|
|
96
|
+
|
|
97
|
+
COMMENT ON COLUMN platform_identity_guild_requests.status IS
|
|
98
|
+
'pending -> accepted | rejected (declined: SILENT, and a 90-day cooldown that swallows a re-ask of the same kind) | cancelled | expired (30 days, resolved at write time). The connections lifecycle, verbatim (ADR 0336 D3, ADR 0202 D2).';
|
|
99
|
+
|
|
100
|
+
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", "connections", "community-search"],
|
|
13
|
+
"routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
|
|
14
14
|
"migrations": true,
|
|
15
15
|
"pollers": ["code-sweep", "visibility-pull"]
|
|
16
16
|
},
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// modules/platform-identity/read-rate-limit.js — a per-IP read cap for an
|
|
2
|
+
// UNAUTHENTICATED public GET in this module. The global limiter meters writes
|
|
3
|
+
// only, and core's per-IP sliding window (src/bongos/middleware/rate-limit.js
|
|
4
|
+
// perIpSlidingWindow) reaches modules only as ready-built instances on the
|
|
5
|
+
// doorway (ADR 0209: an instance per budget), none of which is this module's.
|
|
6
|
+
// This is the module-side twin of that function — same 429 envelope and
|
|
7
|
+
// headers — written once (task 1002978) and mounted by the community reads
|
|
8
|
+
// (routes/community-leaderboard.js, routes/community-search.js) and the public
|
|
9
|
+
// guild read (routes/guilds-public.js).
|
|
10
|
+
// routes/public-profile.js and routes/sso.js still carry older vendored copies.
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const SWEEP_EVERY_MS = 5 * 60 * 1000;
|
|
14
|
+
// Bounds the bucket map between sweeps: rotating source addresses (an IPv6
|
|
15
|
+
// block) must not grow the heap without limit on an unauthenticated route.
|
|
16
|
+
const MAX_TRACKED_IPS = 10000;
|
|
17
|
+
|
|
18
|
+
// createReadRateLimit({ scope, limit, windowMs, maxTrackedIps }) → Express
|
|
19
|
+
// middleware allowing `limit` requests per IP per sliding `windowMs`, then 429
|
|
20
|
+
// rate_limited with Retry-After. `scope` names the budget in the response so a
|
|
21
|
+
// client can tell which cap it hit. Buckets live in this closure: one budget
|
|
22
|
+
// per call. The map is kept in least-recently-seen order, so at the tracking
|
|
23
|
+
// cap the IP evicted is the one idle longest — O(1), no scan on the hot path.
|
|
24
|
+
function createReadRateLimit({ scope, limit, windowMs = 60 * 1000, maxTrackedIps = MAX_TRACKED_IPS }) {
|
|
25
|
+
const buckets = new Map(); // ip -> timestamp[], least recently seen first
|
|
26
|
+
|
|
27
|
+
const sweepTimer = setInterval(() => {
|
|
28
|
+
const cutoff = Date.now() - windowMs;
|
|
29
|
+
for (const [k, arr] of buckets) {
|
|
30
|
+
while (arr.length && arr[0] < cutoff) arr.shift();
|
|
31
|
+
if (arr.length === 0) buckets.delete(k);
|
|
32
|
+
}
|
|
33
|
+
}, SWEEP_EVERY_MS);
|
|
34
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
35
|
+
|
|
36
|
+
return function readRateLimit(req, res, next) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const key = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
39
|
+
let bucket = buckets.get(key);
|
|
40
|
+
if (bucket) {
|
|
41
|
+
buckets.delete(key); // re-inserted below: most recently seen goes last
|
|
42
|
+
} else {
|
|
43
|
+
if (buckets.size >= maxTrackedIps) buckets.delete(buckets.keys().next().value);
|
|
44
|
+
bucket = [];
|
|
45
|
+
}
|
|
46
|
+
buckets.set(key, bucket);
|
|
47
|
+
const cutoff = now - windowMs;
|
|
48
|
+
while (bucket.length && bucket[0] < cutoff) bucket.shift();
|
|
49
|
+
if (bucket.length >= limit) {
|
|
50
|
+
const retryAfter = Math.max(1, Math.ceil((bucket[0] + windowMs - now) / 1000));
|
|
51
|
+
res.set('Retry-After', String(retryAfter));
|
|
52
|
+
res.set('X-RateLimit-Scope', scope);
|
|
53
|
+
res.set('X-RateLimit-Limit', String(limit));
|
|
54
|
+
res.set('X-RateLimit-Window-Seconds', String(windowMs / 1000));
|
|
55
|
+
return res.fail('rate_limited', 429, { scope, retry_after_seconds: retryAfter });
|
|
56
|
+
}
|
|
57
|
+
bucket.push(now);
|
|
58
|
+
return next();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { createReadRateLimit, MAX_TRACKED_IPS };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// modules/platform-identity/routes/community-leaderboard.js — GET
|
|
2
|
+
// /community/leaderboard, the PUBLIC windowed leaderboard behind the hub's
|
|
3
|
+
// /community page (D3, docs/specs/bongos-v1-community-tab.md; task 1002978,
|
|
4
|
+
// goal 1000110 CT2). The sibling of routes/community-search.js: same module,
|
|
5
|
+
// same public posture. Who appears and how a window is counted live entirely in
|
|
6
|
+
// ../community-leaderboard.js; this file is the HTTP shell — validation, the
|
|
7
|
+
// per-IP read cap, the response envelope. It never reads a session, so the
|
|
8
|
+
// board is the same for every viewer.
|
|
9
|
+
//
|
|
10
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const api = require('../../../src/module-api');
|
|
15
|
+
const leaderboard = require('../community-leaderboard');
|
|
16
|
+
const { createReadRateLimit } = require('../read-rate-limit');
|
|
17
|
+
|
|
18
|
+
const { pool, corsPublicGet } = api;
|
|
19
|
+
|
|
20
|
+
const READ_RATE_LIMIT = 60; // per IP per minute — the community-search budget
|
|
21
|
+
const READ_RATE_SCOPE = 'community-leaderboard-read';
|
|
22
|
+
const leaderboardReadRateLimit = createReadRateLimit({ scope: READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
|
|
23
|
+
|
|
24
|
+
module.exports = function communityLeaderboardRoutes() {
|
|
25
|
+
const router = express.Router();
|
|
26
|
+
|
|
27
|
+
router.use('/community/leaderboard', corsPublicGet('/community/leaderboard'));
|
|
28
|
+
|
|
29
|
+
// rank: public — the cross-project leaderboard (?window=7d|30d|365d|all,
|
|
30
|
+
// default all; ?discipline=; ?limit=&offset=). Eligibility is the privacy
|
|
31
|
+
// matrix's numbers predicate (active + public + stats shown); the recruiting
|
|
32
|
+
// opt-out does not apply. no-store: hide must beat the cache (ADR 0171 D5).
|
|
33
|
+
// Rollout note: until a builder's first changed report after snapshot history
|
|
34
|
+
// began, their whole lifetime total counts inside every window (spec D2's
|
|
35
|
+
// missing-baseline-is-zero rule) — expected on day one, and self-correcting.
|
|
36
|
+
router.get('/community/leaderboard', leaderboardReadRateLimit, async (req, res) => {
|
|
37
|
+
const window = req.query.window === undefined || req.query.window === ''
|
|
38
|
+
? leaderboard.DEFAULT_WINDOW
|
|
39
|
+
: req.query.window;
|
|
40
|
+
if (!leaderboard.isWindow(window)) {
|
|
41
|
+
return res.fail('bad_window', { status: 400, message: `window must be one of: ${leaderboard.WINDOWS.join(', ')}` });
|
|
42
|
+
}
|
|
43
|
+
const discipline = (typeof req.query.discipline === 'string' ? req.query.discipline : '').trim();
|
|
44
|
+
if (discipline.length > leaderboard.MAX_DISCIPLINE_CHARS) {
|
|
45
|
+
return res.fail('discipline_too_long', { status: 400, message: `discipline must be at most ${leaderboard.MAX_DISCIPLINE_CHARS} characters` });
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const board = await leaderboard.listLeaderboard({
|
|
49
|
+
window, discipline, limit: req.query.limit, offset: req.query.offset, pool,
|
|
50
|
+
});
|
|
51
|
+
res.set('Cache-Control', 'no-store');
|
|
52
|
+
res.json({
|
|
53
|
+
window: board.window,
|
|
54
|
+
since: board.since,
|
|
55
|
+
discipline: discipline || null,
|
|
56
|
+
count: board.results.length,
|
|
57
|
+
results: board.results,
|
|
58
|
+
page: { limit: board.limit, offset: board.offset },
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/leaderboard failed');
|
|
62
|
+
res.fail('leaderboard_failed', { status: 500, message: 'internal error' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return router;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE };
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const express = require('express');
|
|
17
17
|
const api = require('../../../src/module-api');
|
|
18
18
|
const builderSearch = require('../builder-search');
|
|
19
|
+
const { createReadRateLimit } = require('../read-rate-limit');
|
|
19
20
|
|
|
20
21
|
const { pool, corsPublicGet } = api;
|
|
21
22
|
|
|
@@ -33,39 +34,11 @@ const log = {
|
|
|
33
34
|
|
|
34
35
|
// Per-IP read cap for this UNAUTHENTICATED route (the public-profile.js R05
|
|
35
36
|
// posture: the global limiter only meters WRITES, so an uncapped scraper could
|
|
36
|
-
// otherwise run the trigram leg at line rate).
|
|
37
|
-
//
|
|
38
|
-
// own budget rather than reaching into a sibling module's copy.
|
|
37
|
+
// otherwise run the trigram leg at line rate). Its own budget, built by the
|
|
38
|
+
// module's shared factory (task 1002978) rather than a vendored copy.
|
|
39
39
|
const READ_RATE_WINDOW_MS = 60 * 1000;
|
|
40
40
|
const READ_RATE_LIMIT = 60;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const sweepTimer = setInterval(() => {
|
|
44
|
-
const cutoff = Date.now() - READ_RATE_WINDOW_MS;
|
|
45
|
-
for (const [k, arr] of buckets) {
|
|
46
|
-
while (arr.length && arr[0] < cutoff) arr.shift();
|
|
47
|
-
if (arr.length === 0) buckets.delete(k);
|
|
48
|
-
}
|
|
49
|
-
}, 5 * 60 * 1000);
|
|
50
|
-
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
51
|
-
|
|
52
|
-
function searchReadRateLimit(req, res, next) {
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
const key = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
55
|
-
let bucket = buckets.get(key);
|
|
56
|
-
if (!bucket) { bucket = []; buckets.set(key, bucket); }
|
|
57
|
-
const cutoff = now - READ_RATE_WINDOW_MS;
|
|
58
|
-
while (bucket.length && bucket[0] < cutoff) bucket.shift();
|
|
59
|
-
if (bucket.length >= READ_RATE_LIMIT) {
|
|
60
|
-
const retryAfter = Math.max(1, Math.ceil((bucket[0] + READ_RATE_WINDOW_MS - now) / 1000));
|
|
61
|
-
res.set('Retry-After', String(retryAfter));
|
|
62
|
-
res.set('X-RateLimit-Scope', 'community-search-read');
|
|
63
|
-
res.set('X-RateLimit-Limit', String(READ_RATE_LIMIT));
|
|
64
|
-
return res.fail('rate_limited', 429, { scope: 'community-search-read', retry_after_seconds: retryAfter });
|
|
65
|
-
}
|
|
66
|
-
bucket.push(now);
|
|
67
|
-
return next();
|
|
68
|
-
}
|
|
41
|
+
const searchReadRateLimit = createReadRateLimit({ scope: 'community-search-read', limit: READ_RATE_LIMIT, windowMs: READ_RATE_WINDOW_MS });
|
|
69
42
|
|
|
70
43
|
module.exports = function communitySearchRoutes() {
|
|
71
44
|
const router = express.Router();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// modules/platform-identity/routes/guilds-public.js — the PUBLIC guild read
|
|
2
|
+
// (task 1002300, R24; ADR 0336 D4). Its own file because every route in
|
|
3
|
+
// routes/guilds.js is own-scoped and held there by tests/own_scope_guard.mjs;
|
|
4
|
+
// this one is deliberately not.
|
|
5
|
+
//
|
|
6
|
+
// GET /guilds/:slug — a PUBLIC guild's name, description, public roster and
|
|
7
|
+
// size. 404 for an unknown slug AND for an unlisted guild, to everyone, its own
|
|
8
|
+
// members included: the read takes no viewer, so it cannot vary by one (the
|
|
9
|
+
// private-profile rule, ADR 0171 D4). Members reach an unlisted guild through
|
|
10
|
+
// the own-scope GET /guilds/:slug/members. The page at /g/<slug> is task
|
|
11
|
+
// 1002301's; this is its read.
|
|
12
|
+
//
|
|
13
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const express = require('express');
|
|
17
|
+
const api = require('../../../src/module-api');
|
|
18
|
+
const guilds = require('../guilds');
|
|
19
|
+
const { createReadRateLimit } = require('../read-rate-limit');
|
|
20
|
+
|
|
21
|
+
const { pool } = api;
|
|
22
|
+
|
|
23
|
+
// Resolved at call time, the community-search.js precedent: a proof that stubs a
|
|
24
|
+
// partial doorway can still require() this file.
|
|
25
|
+
let resolvedLog = null;
|
|
26
|
+
function logError(msg) {
|
|
27
|
+
if (!resolvedLog) resolvedLog = typeof api.logger === 'function' ? api.logger('platform-identity') : { error: () => {} };
|
|
28
|
+
resolvedLog.error(msg);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Per-IP read cap for this UNAUTHENTICATED route, its own budget. The global
|
|
32
|
+
// limiter meters writes only, and a roster read maps handles to guilds, so an
|
|
33
|
+
// uncapped scraper could walk the slug space at line rate.
|
|
34
|
+
const guildReadRateLimit = createReadRateLimit({ scope: 'guild-public-read', limit: 120 });
|
|
35
|
+
|
|
36
|
+
module.exports = function guildPublicRoutes() {
|
|
37
|
+
const router = express.Router();
|
|
38
|
+
|
|
39
|
+
// rank: public — a public guild's page data. The roster is the members who
|
|
40
|
+
// agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4);
|
|
41
|
+
// size is that roster's length. no-store: a hide or a visibility flip must beat
|
|
42
|
+
// every cache (ADR 0171 D5).
|
|
43
|
+
router.get('/guilds/:slug', guildReadRateLimit, async (req, res) => {
|
|
44
|
+
res.set('Cache-Control', 'no-store');
|
|
45
|
+
try {
|
|
46
|
+
const slug = guilds.lookupSlug(req.params.slug);
|
|
47
|
+
const out = slug ? await guilds.getPublicGuild(slug, { pool }) : null;
|
|
48
|
+
if (!out) return res.fail('guild_not_found', 404);
|
|
49
|
+
res.json({ guild: out.guild, members: out.members, size: out.size });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
logError(`GET /guilds/:slug failed: ${err && err.message}`);
|
|
52
|
+
res.fail('guild_read_failed', { status: 500, message: 'internal error' });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return router;
|
|
57
|
+
};
|