@bongos/core 1.19.1026 → 1.19.1028
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 +77 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +38 -0
- package/clients/bongos-client/index.cjs +38 -0
- package/clients/bongos-client/index.d.ts +60 -0
- package/clients/bongos-client/index.mjs +38 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +4 -2
- package/docs/api/openapi.json +1345 -182
- package/docs/api-reference.md +29 -2
- package/docs/copy-inventory.md +209 -201
- package/docs/copy-registry.json +329 -257
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/builders.js +7 -4
- 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 +3 -2
- package/modules/platform-identity/routes/guilds-public.js +57 -0
- package/modules/platform-identity/routes/guilds.js +393 -0
- package/modules/platform-identity/terms-acceptance.js +14 -5
- package/modules/public-landing/public/assets/cosmos.css +36 -0
- package/modules/public-landing/public/projects.html +229 -26
- package/modules/public-landing/public/terms.html +3 -3
- package/modules/ui-design/kit/fixtures/my-projects__invites.json +1 -0
- package/modules/ui-design/kit/serve.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/card_apply_ui.mjs +8 -1
- package/tests/curve_controls_delegation.mjs +87 -0
- package/tests/guilds_api.mjs +521 -0
- package/tests/guilds_db.mjs +343 -0
- package/tests/my_projects_surface.mjs +26 -14
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/public_landing_projects.mjs +40 -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", "community-leaderboard"],
|
|
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
|
},
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// perIpSlidingWindow) reaches modules only as ready-built instances on the
|
|
5
5
|
// doorway (ADR 0209: an instance per budget), none of which is this module's.
|
|
6
6
|
// This is the module-side twin of that function — same 429 envelope and
|
|
7
|
-
// headers — written once (task 1002978) and mounted by
|
|
8
|
-
// (routes/community-leaderboard.js, routes/community-search.js)
|
|
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).
|
|
9
10
|
// routes/public-profile.js and routes/sso.js still carry older vendored copies.
|
|
10
11
|
'use strict';
|
|
11
12
|
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// modules/platform-identity/routes/guilds.js — the guild API, own-scope half
|
|
2
|
+
// (task 1002300, R24; ADR 0336 D2–D4 + D6, ADR 0337 D4.4). The one PUBLIC read,
|
|
3
|
+
// GET /guilds/:slug, lives in routes/guilds-public.js, because every route in
|
|
4
|
+
// this file sits behind requireBuilder and tests/own_scope_guard.mjs holds it.
|
|
5
|
+
//
|
|
6
|
+
// EVERY ROUTE HERE IS OWN-SCOPED. The acting account is always
|
|
7
|
+
// req.builder.github_id. A guild is named by slug, a person by public handle,
|
|
8
|
+
// an invite or request by its row id — none of them is a way to act AS someone
|
|
9
|
+
// else. Owner-only acts are re-asserted inside each statement, so the route's
|
|
10
|
+
// pre-check is a clearer error, never the only gate.
|
|
11
|
+
//
|
|
12
|
+
// POST /guilds create {slug, name, description?, visibility}
|
|
13
|
+
// GET /me/guilds my guilds, with my own two switches
|
|
14
|
+
// GET /me/guild-requests invites to me (the card) + requests I sent
|
|
15
|
+
// GET /guilds/:slug/members the members' roster (members only; 404 else)
|
|
16
|
+
// PATCH /guilds/:slug/membership my switches {shown_publicly?, counted_in_totals?}
|
|
17
|
+
// DELETE /guilds/:slug/membership leave (the owner transfers first, or is last and dissolves)
|
|
18
|
+
// POST /guilds/:slug/requests ask to join a PUBLIC guild {counted_in_totals?}
|
|
19
|
+
// PATCH /guilds/:slug owner: {name?, description?, visibility?}
|
|
20
|
+
// DELETE /guilds/:slug owner: dissolve
|
|
21
|
+
// POST /guilds/:slug/invites owner: invite {handles: [≤25]}
|
|
22
|
+
// GET /guilds/:slug/requests owner: join requests + invites still out
|
|
23
|
+
// DELETE /guilds/:slug/members/:handle owner: remove a member
|
|
24
|
+
// POST /guilds/:slug/transfer owner: hand ownership to a member {handle}
|
|
25
|
+
// POST /guild-requests/:id/accept invitee {visibility (the card's), counted_in_totals?} or owner
|
|
26
|
+
// POST /guild-requests/:id/decline invitee or owner; SILENT 204
|
|
27
|
+
// DELETE /guild-requests/:id whoever opened it: cancel
|
|
28
|
+
//
|
|
29
|
+
// A GUILD IS NEVER AUTHORITY (ADR 0016 / ADR 0141). no-store everywhere: these
|
|
30
|
+
// payloads are privacy-filtered per viewer (ADR 0171 D5).
|
|
31
|
+
//
|
|
32
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const express = require('express');
|
|
36
|
+
const api = require('../../../src/module-api');
|
|
37
|
+
const pi = require('../platform-identity');
|
|
38
|
+
const guilds = require('../guilds');
|
|
39
|
+
const { isAccountActive } = require('../account-visibility');
|
|
40
|
+
|
|
41
|
+
const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
|
|
42
|
+
const log = api.logger('platform-identity');
|
|
43
|
+
|
|
44
|
+
// A request row id is a bigserial. Anything else is 404, not 400: an id past the
|
|
45
|
+
// bigint range would reach Postgres as a 22003 and answer 500, and a different
|
|
46
|
+
// answer for a different id is a probe (the routes/connections.js rule, with the
|
|
47
|
+
// range checked exactly rather than by digit count).
|
|
48
|
+
const BIGINT_MAX = 9223372036854775807n;
|
|
49
|
+
function requestId(req) {
|
|
50
|
+
const raw = String(req.params.id || '');
|
|
51
|
+
return /^\d{1,19}$/.test(raw) && BigInt(raw) <= BIGINT_MAX ? raw : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The caller's platform account, or a { fail }. The consent axis floors the
|
|
55
|
+
// whole feature (ADR 0336 D3): a provisional or terms-less account cannot
|
|
56
|
+
// create, ask, accept or appear anywhere.
|
|
57
|
+
async function actingAccount(builder) {
|
|
58
|
+
const githubId = builder && builder.github_id;
|
|
59
|
+
if (!pi.isNumericGithubId(githubId)) {
|
|
60
|
+
return { fail: ['no_platform_identity', { status: 400, message: 'this session has no platform account' }] };
|
|
61
|
+
}
|
|
62
|
+
const acct = await pi.getAccount(githubId, { pool });
|
|
63
|
+
if (!isAccountActive(acct)) {
|
|
64
|
+
return { fail: ['profile_provisional', { status: 403, message: 'sign in to the platform once to activate your profile before using guilds' }] };
|
|
65
|
+
}
|
|
66
|
+
return { githubId };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The caller's membership of the guild in the path, or a { fail }. A non-member
|
|
70
|
+
// gets the same 404 as a guild that does not exist, so no guild route confirms
|
|
71
|
+
// an unlisted guild to a stranger. (One honest limit, inherent to a global slug:
|
|
72
|
+
// POST /guilds with a taken slug must answer "taken", unlisted or not.)
|
|
73
|
+
async function memberOf(me, rawSlug) {
|
|
74
|
+
const slug = guilds.lookupSlug(rawSlug);
|
|
75
|
+
const guild = slug ? await guilds.getMembership(slug, me.githubId, { pool }) : null;
|
|
76
|
+
if (!guild) return { fail: ['guild_not_found', 404] };
|
|
77
|
+
return { guild, slug };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// memberOf, narrowed to the owner. A member already knows the guild exists, so
|
|
81
|
+
// they may be told plainly that the act is the owner's.
|
|
82
|
+
async function ownerOf(me, rawSlug) {
|
|
83
|
+
const out = await memberOf(me, rawSlug);
|
|
84
|
+
if (out.fail) return out;
|
|
85
|
+
if (out.guild.membership_kind !== 'owner') {
|
|
86
|
+
return { fail: ['guild_owner_only', { status: 403, message: 'only the guild owner can do that' }] };
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The guild fields a member-facing response carries. The row id stays internal.
|
|
92
|
+
function guildView(g) {
|
|
93
|
+
return { slug: g.slug, name: g.name, description: g.description, visibility: g.visibility };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// One try/catch shape for every handler: the act, or a logged 500.
|
|
97
|
+
function handle(label, code, fn) {
|
|
98
|
+
return async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
await fn(req, res);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
log.error({ err: err && err.message }, `${label} failed`);
|
|
103
|
+
res.fail(code, { status: 500, message: 'internal error' });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const COUNTED = { type: 'boolean' };
|
|
109
|
+
|
|
110
|
+
module.exports = function guildRoutes() {
|
|
111
|
+
const router = express.Router();
|
|
112
|
+
|
|
113
|
+
router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/guild-requests'], (req, res, next) => {
|
|
114
|
+
res.set('Cache-Control', 'no-store');
|
|
115
|
+
next();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// rank: any-builder — create a guild. The creator becomes its one owner, and
|
|
119
|
+
// chooses its visibility: there is no default (ADR 0336 D1).
|
|
120
|
+
router.post('/guilds', requireBuilder, handle('POST /guilds', 'guild_create_failed', async (req, res) => {
|
|
121
|
+
if (validateOrRespond(req, res, {
|
|
122
|
+
slug: { required: true, type: 'string', minLength: 1, maxLength: 39 },
|
|
123
|
+
name: { required: true, type: 'string', minLength: 1, maxLength: guilds.NAME_MAX_CHARS },
|
|
124
|
+
description: { type: 'string', maxLength: guilds.DESCRIPTION_MAX_CHARS },
|
|
125
|
+
visibility: { required: true, type: 'string', enum: guilds.GUILD_VISIBILITIES },
|
|
126
|
+
})) return;
|
|
127
|
+
const me = await actingAccount(req.builder);
|
|
128
|
+
if (me.fail) return res.fail(...me.fail);
|
|
129
|
+
const s = guilds.normalizeGuildSlug(req.body.slug);
|
|
130
|
+
if (s.error === 'slug_reserved') return res.fail('guild_slug_reserved', { status: 400, message: 'that slug is reserved' });
|
|
131
|
+
if (s.error) return res.fail('guild_slug_invalid', { status: 400, message: 'a slug is 2-39 lowercase letters, digits or inner hyphens' });
|
|
132
|
+
const name = req.body.name.trim();
|
|
133
|
+
if (!name) return res.fail('guild_name_required', { status: 400, message: 'a guild needs a name' });
|
|
134
|
+
const out = await guilds.createGuild({
|
|
135
|
+
githubId: me.githubId, slug: s.slug, name,
|
|
136
|
+
description: (req.body.description || '').trim() || null,
|
|
137
|
+
visibility: req.body.visibility,
|
|
138
|
+
}, { pool });
|
|
139
|
+
if (out.taken) return res.fail('guild_slug_taken', { status: 409, message: 'that slug is taken' });
|
|
140
|
+
res.status(201).json({ guild: out.guild });
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
// rank: any-builder — MY guilds, each with my own two switches. count is the
|
|
144
|
+
// array's own length.
|
|
145
|
+
router.get('/me/guilds', requireBuilder, handle('GET /me/guilds', 'guilds_read_failed', async (req, res) => {
|
|
146
|
+
const me = await actingAccount(req.builder);
|
|
147
|
+
if (me.fail) return res.fail(...me.fail);
|
|
148
|
+
const { limit, offset } = parsePagination(req, { defaultLimit: guilds.LIST_MAX, maxLimit: guilds.LIST_MAX });
|
|
149
|
+
const mine = await guilds.listMyGuilds({ githubId: me.githubId, limit, offset }, { pool });
|
|
150
|
+
res.json({ guilds: mine, count: mine.length });
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
// rank: any-builder — MY queue: invites addressed to me, each carrying the
|
|
154
|
+
// card (the guild's current visibility, which an accept must echo back), and
|
|
155
|
+
// the join requests I sent. Stamps seen_at on the invites.
|
|
156
|
+
router.get('/me/guild-requests', requireBuilder, handle('GET /me/guild-requests', 'guild_requests_read_failed', async (req, res) => {
|
|
157
|
+
const me = await actingAccount(req.builder);
|
|
158
|
+
if (me.fail) return res.fail(...me.fail);
|
|
159
|
+
const { incoming, outgoing } = await guilds.listMyRequests({ githubId: me.githubId }, { pool });
|
|
160
|
+
res.json({ incoming, outgoing, incoming_count: incoming.length, outgoing_count: outgoing.length });
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
// rank: any-builder — the members' roster, for members only (the own-scope
|
|
164
|
+
// door to an unlisted guild, ADR 0336 D4). A private member is listed here,
|
|
165
|
+
// to fellow members, and on no public page.
|
|
166
|
+
router.get('/guilds/:slug/members', requireBuilder, handle('GET /guilds/:slug/members', 'guild_members_read_failed', async (req, res) => {
|
|
167
|
+
const me = await actingAccount(req.builder);
|
|
168
|
+
if (me.fail) return res.fail(...me.fail);
|
|
169
|
+
const m = await memberOf(me, req.params.slug);
|
|
170
|
+
if (m.fail) return res.fail(...m.fail);
|
|
171
|
+
const { limit, offset } = parsePagination(req, { defaultLimit: guilds.LIST_MAX, maxLimit: guilds.LIST_MAX });
|
|
172
|
+
const members = await guilds.listMemberRoster({ guildId: m.guild.id, limit, offset }, { pool });
|
|
173
|
+
res.json({
|
|
174
|
+
guild: guildView(m.guild),
|
|
175
|
+
you: {
|
|
176
|
+
membership_kind: m.guild.membership_kind,
|
|
177
|
+
shown_publicly: m.guild.shown_publicly,
|
|
178
|
+
counted_in_totals: m.guild.counted_in_totals,
|
|
179
|
+
},
|
|
180
|
+
members,
|
|
181
|
+
count: members.length,
|
|
182
|
+
});
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
// rank: any-builder — MY switches in one guild: shown on its public page
|
|
186
|
+
// (ADR 0336 D4) and counted in its totals (ADR 0337 D4.4). Each optional, at
|
|
187
|
+
// least one required, both keyed on the session.
|
|
188
|
+
router.patch('/guilds/:slug/membership', requireBuilder, handle('PATCH /guilds/:slug/membership', 'guild_membership_update_failed', async (req, res) => {
|
|
189
|
+
if (validateOrRespond(req, res, { shown_publicly: { type: 'boolean' }, counted_in_totals: COUNTED })) return;
|
|
190
|
+
const { shown_publicly: shown, counted_in_totals: counted } = req.body || {};
|
|
191
|
+
if (shown == null && counted == null) {
|
|
192
|
+
return res.fail('nothing_to_update', { status: 400, message: 'send shown_publicly and/or counted_in_totals' });
|
|
193
|
+
}
|
|
194
|
+
const me = await actingAccount(req.builder);
|
|
195
|
+
if (me.fail) return res.fail(...me.fail);
|
|
196
|
+
const slug = guilds.lookupSlug(req.params.slug);
|
|
197
|
+
const flags = slug ? await guilds.setMembershipFlags({
|
|
198
|
+
slug, githubId: me.githubId, shownPublicly: shown ?? null, countedInTotals: counted ?? null,
|
|
199
|
+
}, { pool }) : null;
|
|
200
|
+
if (!flags) return res.fail('guild_not_found', 404);
|
|
201
|
+
res.json(flags);
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
// rank: any-builder — leave. The owner must transfer first unless they are the
|
|
205
|
+
// last member, when leaving dissolves the guild (ADR 0336 D2).
|
|
206
|
+
router.delete('/guilds/:slug/membership', requireBuilder, handle('DELETE /guilds/:slug/membership', 'guild_leave_failed', async (req, res) => {
|
|
207
|
+
const me = await actingAccount(req.builder);
|
|
208
|
+
if (me.fail) return res.fail(...me.fail);
|
|
209
|
+
const m = await memberOf(me, req.params.slug);
|
|
210
|
+
if (m.fail) return res.fail(...m.fail);
|
|
211
|
+
const outcome = await guilds.leaveGuild({ guildId: m.guild.id, githubId: me.githubId }, { pool });
|
|
212
|
+
if (outcome === 'must_transfer') {
|
|
213
|
+
return res.fail('guild_owner_must_transfer', { status: 409, message: 'hand the guild to another member before you leave' });
|
|
214
|
+
}
|
|
215
|
+
if (!outcome) return res.fail('guild_not_found', 404);
|
|
216
|
+
res.json({ outcome });
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
// rank: any-builder — ask to join. Only a PUBLIC guild takes requests, so an
|
|
220
|
+
// unlisted guild and an unknown slug are one 404 (ADR 0336 D3/D4). A request
|
|
221
|
+
// written and one swallowed by a decline cooldown answer identically.
|
|
222
|
+
router.post('/guilds/:slug/requests', requireBuilder, handle('POST /guilds/:slug/requests', 'guild_request_failed', async (req, res) => {
|
|
223
|
+
if (validateOrRespond(req, res, { counted_in_totals: COUNTED })) return;
|
|
224
|
+
const me = await actingAccount(req.builder);
|
|
225
|
+
if (me.fail) return res.fail(...me.fail);
|
|
226
|
+
const slug = guilds.lookupSlug(req.params.slug);
|
|
227
|
+
const { outcome } = slug
|
|
228
|
+
? await guilds.requestToJoin({ slug, githubId: me.githubId, countedInTotals: req.body?.counted_in_totals }, { pool })
|
|
229
|
+
: { outcome: 'not_found' };
|
|
230
|
+
if (outcome === 'not_found') return res.fail('guild_not_found', 404);
|
|
231
|
+
if (outcome === 'already_member') return res.fail('guild_already_member', { status: 409, message: 'you are already in this guild' });
|
|
232
|
+
if (outcome === 'already_open') return res.fail('guild_request_already_open', { status: 409, message: 'there is already an open invite or request between you and this guild' });
|
|
233
|
+
res.status(201).json({ status: 'pending' });
|
|
234
|
+
}));
|
|
235
|
+
|
|
236
|
+
// rank: any-builder — owner: edit name, description or visibility. The slug is
|
|
237
|
+
// fixed at creation (ADR 0336 D6), so it is not a field here.
|
|
238
|
+
router.patch('/guilds/:slug', requireBuilder, handle('PATCH /guilds/:slug', 'guild_update_failed', async (req, res) => {
|
|
239
|
+
if (validateOrRespond(req, res, {
|
|
240
|
+
name: { type: 'string', minLength: 1, maxLength: guilds.NAME_MAX_CHARS },
|
|
241
|
+
description: { type: 'string', maxLength: guilds.DESCRIPTION_MAX_CHARS },
|
|
242
|
+
visibility: { type: 'string', enum: guilds.GUILD_VISIBILITIES },
|
|
243
|
+
})) return;
|
|
244
|
+
// The validator reads null as absent, so null is handled here: a name cannot
|
|
245
|
+
// be cleared, a description can (null or ''), and a null visibility is no
|
|
246
|
+
// change. Absent means "leave it".
|
|
247
|
+
const body = req.body || {};
|
|
248
|
+
if (body.name === undefined && body.description === undefined && body.visibility == null) {
|
|
249
|
+
return res.fail('nothing_to_update', { status: 400, message: 'send name, description and/or visibility' });
|
|
250
|
+
}
|
|
251
|
+
const name = body.name === undefined ? undefined : String(body.name ?? '').trim();
|
|
252
|
+
if (name === '') return res.fail('guild_name_required', { status: 400, message: 'a guild needs a name' });
|
|
253
|
+
const me = await actingAccount(req.builder);
|
|
254
|
+
if (me.fail) return res.fail(...me.fail);
|
|
255
|
+
const o = await ownerOf(me, req.params.slug);
|
|
256
|
+
if (o.fail) return res.fail(...o.fail);
|
|
257
|
+
const guild = await guilds.updateGuild({
|
|
258
|
+
guildId: o.guild.id, githubId: me.githubId, name,
|
|
259
|
+
description: body.description === undefined ? undefined : String(body.description ?? '').trim(),
|
|
260
|
+
visibility: body.visibility ?? undefined,
|
|
261
|
+
}, { pool });
|
|
262
|
+
if (!guild) return res.fail('guild_not_found', 404);
|
|
263
|
+
res.json({ guild });
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
// rank: any-builder — owner: dissolve. Hard delete, cascading to every
|
|
267
|
+
// membership and open request (ADR 0336 D2).
|
|
268
|
+
router.delete('/guilds/:slug', requireBuilder, handle('DELETE /guilds/:slug', 'guild_dissolve_failed', async (req, res) => {
|
|
269
|
+
const me = await actingAccount(req.builder);
|
|
270
|
+
if (me.fail) return res.fail(...me.fail);
|
|
271
|
+
const o = await ownerOf(me, req.params.slug);
|
|
272
|
+
if (o.fail) return res.fail(...o.fail);
|
|
273
|
+
const ok = await guilds.dissolveGuild({ guildId: o.guild.id, githubId: me.githubId }, { pool });
|
|
274
|
+
if (!ok) return res.fail('guild_not_found', 404);
|
|
275
|
+
res.json({ outcome: 'dissolved' });
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
// rank: any-builder — owner: invite up to INVITE_BATCH_MAX builders by handle,
|
|
279
|
+
// each an ordinary individual invite (ADR 0336 D3). Unlisted guilds take
|
|
280
|
+
// invites. count is the array's own length.
|
|
281
|
+
router.post('/guilds/:slug/invites', requireBuilder, handle('POST /guilds/:slug/invites', 'guild_invite_failed', async (req, res) => {
|
|
282
|
+
if (validateOrRespond(req, res, {
|
|
283
|
+
handles: { required: true, type: 'array', itemsType: 'string', maxItems: guilds.INVITE_BATCH_MAX },
|
|
284
|
+
})) return;
|
|
285
|
+
if (!req.body.handles.length) return res.fail('handles_required', { status: 400, message: 'name at least one handle' });
|
|
286
|
+
const me = await actingAccount(req.builder);
|
|
287
|
+
if (me.fail) return res.fail(...me.fail);
|
|
288
|
+
const o = await ownerOf(me, req.params.slug);
|
|
289
|
+
if (o.fail) return res.fail(...o.fail);
|
|
290
|
+
const results = await guilds.inviteHandles({
|
|
291
|
+
guildId: o.guild.id, ownerGithubId: me.githubId, handles: req.body.handles,
|
|
292
|
+
}, { pool });
|
|
293
|
+
res.json({ results, count: results.length });
|
|
294
|
+
}));
|
|
295
|
+
|
|
296
|
+
// rank: any-builder — owner: the join requests to decide and the invites
|
|
297
|
+
// still out. Stamps seen_at on the requests only.
|
|
298
|
+
router.get('/guilds/:slug/requests', requireBuilder, handle('GET /guilds/:slug/requests', 'guild_requests_read_failed', async (req, res) => {
|
|
299
|
+
const me = await actingAccount(req.builder);
|
|
300
|
+
if (me.fail) return res.fail(...me.fail);
|
|
301
|
+
const o = await ownerOf(me, req.params.slug);
|
|
302
|
+
if (o.fail) return res.fail(...o.fail);
|
|
303
|
+
const { incoming, outgoing } = await guilds.listGuildRequests({
|
|
304
|
+
guildId: o.guild.id, ownerGithubId: me.githubId,
|
|
305
|
+
}, { pool });
|
|
306
|
+
res.json({ incoming, outgoing, incoming_count: incoming.length, outgoing_count: outgoing.length });
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
// rank: any-builder — owner: remove a member (never the owner). Hard delete.
|
|
310
|
+
router.delete('/guilds/:slug/members/:handle', requireBuilder, handle('DELETE /guilds/:slug/members/:handle', 'guild_member_remove_failed', async (req, res) => {
|
|
311
|
+
const me = await actingAccount(req.builder);
|
|
312
|
+
if (me.fail) return res.fail(...me.fail);
|
|
313
|
+
const o = await ownerOf(me, req.params.slug);
|
|
314
|
+
if (o.fail) return res.fail(...o.fail);
|
|
315
|
+
const target = guilds.lookupSlug(req.params.handle);
|
|
316
|
+
const ok = target ? await guilds.removeMember({
|
|
317
|
+
guildId: o.guild.id, ownerGithubId: me.githubId, handle: target,
|
|
318
|
+
}, { pool }) : false;
|
|
319
|
+
if (!ok) return res.fail('guild_member_not_found', 404);
|
|
320
|
+
res.json({ outcome: 'removed' });
|
|
321
|
+
}));
|
|
322
|
+
|
|
323
|
+
// rank: any-builder — owner: hand ownership to an existing member. Two
|
|
324
|
+
// statements in one transaction (ADR 0336 D2).
|
|
325
|
+
router.post('/guilds/:slug/transfer', requireBuilder, handle('POST /guilds/:slug/transfer', 'guild_transfer_failed', async (req, res) => {
|
|
326
|
+
if (validateOrRespond(req, res, { handle: { required: true, type: 'string', minLength: 1, maxLength: 39 } })) return;
|
|
327
|
+
const me = await actingAccount(req.builder);
|
|
328
|
+
if (me.fail) return res.fail(...me.fail);
|
|
329
|
+
const o = await ownerOf(me, req.params.slug);
|
|
330
|
+
if (o.fail) return res.fail(...o.fail);
|
|
331
|
+
const target = guilds.lookupSlug(req.body.handle);
|
|
332
|
+
const outcome = target ? await guilds.transferOwnership({
|
|
333
|
+
guildId: o.guild.id, ownerGithubId: me.githubId, handle: target,
|
|
334
|
+
}, { pool }) : 'not_member';
|
|
335
|
+
if (outcome === 'not_member') return res.fail('guild_member_not_found', 404);
|
|
336
|
+
if (outcome !== 'transferred') return res.fail('guild_owner_only', { status: 403, message: 'only the guild owner can do that' });
|
|
337
|
+
res.json({ outcome });
|
|
338
|
+
}));
|
|
339
|
+
|
|
340
|
+
// rank: any-builder — accept an invite addressed to ME, or (as the guild's
|
|
341
|
+
// owner) a request to join it. An invite accept must echo the visibility its
|
|
342
|
+
// card showed; if the guild changed since, 409 and the card re-renders
|
|
343
|
+
// (ADR 0336 D4 §3). Any row the caller may not accept is 404.
|
|
344
|
+
router.post('/guild-requests/:id/accept', requireBuilder, handle('POST /guild-requests/:id/accept', 'guild_accept_failed', async (req, res) => {
|
|
345
|
+
if (validateOrRespond(req, res, {
|
|
346
|
+
visibility: { type: 'string', enum: guilds.GUILD_VISIBILITIES },
|
|
347
|
+
counted_in_totals: COUNTED,
|
|
348
|
+
})) return;
|
|
349
|
+
const id = requestId(req);
|
|
350
|
+
if (!id) return res.fail('guild_request_not_found', 404);
|
|
351
|
+
const me = await actingAccount(req.builder);
|
|
352
|
+
if (me.fail) return res.fail(...me.fail);
|
|
353
|
+
const out = await guilds.acceptRequest({
|
|
354
|
+
id, githubId: me.githubId,
|
|
355
|
+
cardVisibility: req.body?.visibility ?? null,
|
|
356
|
+
countedInTotals: req.body?.counted_in_totals ?? null,
|
|
357
|
+
}, { pool });
|
|
358
|
+
if (out.outcome === 'card_required') {
|
|
359
|
+
return res.fail('guild_card_visibility_required', { status: 400, message: 'send the visibility your invite card showed' });
|
|
360
|
+
}
|
|
361
|
+
if (out.outcome === 'visibility_changed') {
|
|
362
|
+
return res.fail('guild_visibility_changed', 409, { visibility: out.visibility });
|
|
363
|
+
}
|
|
364
|
+
if (out.outcome !== 'accepted') return res.fail('guild_request_not_found', 404);
|
|
365
|
+
res.json({ status: 'accepted' });
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
// rank: any-builder — decline an invite to ME, or (as owner) a request to my
|
|
369
|
+
// guild. SILENT: 204, and the other side is never told.
|
|
370
|
+
router.post('/guild-requests/:id/decline', requireBuilder, handle('POST /guild-requests/:id/decline', 'guild_decline_failed', async (req, res) => {
|
|
371
|
+
const id = requestId(req);
|
|
372
|
+
if (!id) return res.fail('guild_request_not_found', 404);
|
|
373
|
+
const me = await actingAccount(req.builder);
|
|
374
|
+
if (me.fail) return res.fail(...me.fail);
|
|
375
|
+
const ok = await guilds.declineRequest({ id, githubId: me.githubId }, { pool });
|
|
376
|
+
if (!ok) return res.fail('guild_request_not_found', 404);
|
|
377
|
+
res.status(204).end();
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
// rank: any-builder — cancel a row I opened: my request, or (as owner) an
|
|
381
|
+
// invite my guild sent.
|
|
382
|
+
router.delete('/guild-requests/:id', requireBuilder, handle('DELETE /guild-requests/:id', 'guild_cancel_failed', async (req, res) => {
|
|
383
|
+
const id = requestId(req);
|
|
384
|
+
if (!id) return res.fail('guild_request_not_found', 404);
|
|
385
|
+
const me = await actingAccount(req.builder);
|
|
386
|
+
if (me.fail) return res.fail(...me.fail);
|
|
387
|
+
const ok = await guilds.cancelRequest({ id, githubId: me.githubId }, { pool });
|
|
388
|
+
if (!ok) return res.fail('guild_request_not_found', 404);
|
|
389
|
+
res.json({ outcome: 'cancelled' });
|
|
390
|
+
}));
|
|
391
|
+
|
|
392
|
+
return router;
|
|
393
|
+
};
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
//
|
|
25
25
|
// WHY THE CALLER CANNOT FORGE ONE. These fragments have exactly one call site:
|
|
26
26
|
// upsertAccount's `activate` branch, whose only caller is /sso/authorize
|
|
27
|
-
// (routes/sso.js)
|
|
27
|
+
// (routes/sso.js) — the leg a builder passes through when they use their platform
|
|
28
|
+
// account to sign in to a PROJECT. Signing in to the hub itself does not reach it
|
|
29
|
+
// and records nothing (blocker 1000124; task 1003361 adds that capture point),
|
|
30
|
+
// which is why /terms names a project sign-in. There `activate: true` is a SOURCE LITERAL and every other
|
|
28
31
|
// field comes from the hub's own resolved session — the ADR 0205 D3 property
|
|
29
32
|
// established for recordFederatedSignin by task 1003340. No request-body field
|
|
30
33
|
// reaches these statements, which is the whole point: an acceptance record a
|
|
@@ -40,7 +43,7 @@
|
|
|
40
43
|
// accounts that already existed were accepted on their owner's behalf, and the
|
|
41
44
|
// record says so rather than passing that off as a sign-in. A grandfather is not
|
|
42
45
|
// an acceptance its owner gave, so it is the one acceptance that does not
|
|
43
|
-
// "stand": the owner's next sign-in replaces it with their own. This file only
|
|
46
|
+
// "stand": the owner's next project sign-in replaces it with their own. This file only
|
|
44
47
|
// ever READS the marker. tests/terms_acceptance.mjs holds both rules, and fails
|
|
45
48
|
// any other server file that writes these columns or names the marker.
|
|
46
49
|
//
|
|
@@ -57,6 +60,10 @@
|
|
|
57
60
|
//
|
|
58
61
|
// Date-stamped rather than numbered: the page says "Version <this>", and a
|
|
59
62
|
// reader comparing page to record should not have to translate.
|
|
63
|
+
//
|
|
64
|
+
// The terms_version column it fills is WRITE-ONLY today, on purpose: nothing
|
|
65
|
+
// reads it until a revision needs to know who accepted which text (ADR 0213 D5).
|
|
66
|
+
// It is not dead — dropping it would erase exactly that answer.
|
|
60
67
|
const TERMS_VERSION = '2026-08-29';
|
|
61
68
|
|
|
62
69
|
// A Postgres placeholder, e.g. '$5'. Interpolated into SQL, so it is validated
|
|
@@ -73,9 +80,11 @@ function acceptanceInsertColumns() {
|
|
|
73
80
|
return ', terms_accepted_at, terms_version, terms_accepted_source';
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
// The matching VALUES fragment. A row created by a NON-activating
|
|
77
|
-
// (
|
|
78
|
-
//
|
|
83
|
+
// The matching VALUES fragment. A row created by a NON-activating upsert — the
|
|
84
|
+
// device poll (POST /sso/device/poll) or a handle change (PATCH /me/handle) —
|
|
85
|
+
// gets NULL: fail-closed, because no sign-in the hub witnessed recorded an
|
|
86
|
+
// acceptance. The federated check-in never reaches this at all; it writes
|
|
87
|
+
// through ensureAccount, whose insert-only statement names none of these columns.
|
|
79
88
|
function acceptanceInsertValues(activateParam, versionParam) {
|
|
80
89
|
assertPlaceholder('acceptanceInsertValues', activateParam);
|
|
81
90
|
assertPlaceholder('acceptanceInsertValues', versionParam);
|