@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,343 @@
|
|
|
1
|
+
// tests/guilds_db.mjs — guild CRUD, membership and visibility against REAL SQL
|
|
2
|
+
// (task 1002300, R24; ADR 0336 D1–D4 + D6, ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// Why a real database: the rules that matter here live in the schema and in
|
|
5
|
+
// single statements a fake pool cannot execute — the one-owner PARTIAL unique
|
|
6
|
+
// index, the open-request partial index, the expiry CTE that must be drained
|
|
7
|
+
// before its INSERT, the FK cascade on dissolve, the two-statement transfer, and
|
|
8
|
+
// an accept that resolves a row and inserts a membership in one statement. The
|
|
9
|
+
// DB-free sibling, tests/guilds_api.mjs, covers the route layer.
|
|
10
|
+
//
|
|
11
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
12
|
+
// INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
|
|
13
|
+
// high-numbered and every guild slug starts r24-, so a shared DB is safe; all
|
|
14
|
+
// fixtures are deleted before and after.
|
|
15
|
+
//
|
|
16
|
+
// Run: DATABASE_URL=... node tests/guilds_db.mjs
|
|
17
|
+
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { Pool } = require('pg');
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
29
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
30
|
+
|
|
31
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
32
|
+
try {
|
|
33
|
+
await pool.query('SELECT 1');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(`guilds_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
36
|
+
await pool.end().catch(() => {});
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const guilds = require('../modules/platform-identity/guilds.js');
|
|
41
|
+
|
|
42
|
+
const OWNER = 9002300001; // public, active — creates the guilds
|
|
43
|
+
const ALICE = 9002300002; // public, active
|
|
44
|
+
const PRIV = 9002300003; // private, active, NOT connected to OWNER
|
|
45
|
+
const PRIVC = 9002300004; // private, active, connected to OWNER
|
|
46
|
+
const PROV = 9002300005; // provisional — can take part in nothing
|
|
47
|
+
const BOB = 9002300006; // public, active
|
|
48
|
+
const CARL = 9002300007; // public, active
|
|
49
|
+
const PRIV2 = 9002300008; // private, active, connected to OWNER only
|
|
50
|
+
const ALL_IDS = [OWNER, ALICE, PRIV, PRIVC, PROV, BOB, CARL, PRIV2];
|
|
51
|
+
|
|
52
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
53
|
+
async function cleanup() {
|
|
54
|
+
await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r24-%'`).catch(() => {});
|
|
55
|
+
await q(`DELETE FROM platform_identity_connections
|
|
56
|
+
WHERE requester_github_id = ANY($1::bigint[]) OR addressee_github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
57
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let pass = 0;
|
|
61
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
62
|
+
async function rejects(promise, code, msg) {
|
|
63
|
+
let caught = null;
|
|
64
|
+
try { await promise; } catch (e) { caught = e; }
|
|
65
|
+
ok(caught && caught.code === code, `${msg} (expected ${code}, got ${caught ? caught.code : 'no error'})`);
|
|
66
|
+
}
|
|
67
|
+
const memberRow = async (slug, gid) => (await q(
|
|
68
|
+
`SELECT m.* FROM platform_identity_guild_members m JOIN platform_identity_guilds g ON g.id = m.guild_id
|
|
69
|
+
WHERE g.slug = $1 AND m.github_id = $2`, [slug, gid])).rows[0] || null;
|
|
70
|
+
const guildId = async (slug) => (await q(`SELECT id FROM platform_identity_guilds WHERE slug = $1`, [slug])).rows[0]?.id;
|
|
71
|
+
const openRows = async (slug, gid) => (await q(
|
|
72
|
+
`SELECT r.* FROM platform_identity_guild_requests r JOIN platform_identity_guilds g ON g.id = r.guild_id
|
|
73
|
+
WHERE g.slug = $1 AND r.github_id = $2 ORDER BY r.id`, [slug, gid])).rows;
|
|
74
|
+
const pendingId = async (slug, gid) => (await openRows(slug, gid)).find((r) => r.status === 'pending')?.id;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// The migrations this domain reads, all idempotent: accounts + consent,
|
|
78
|
+
// handles, privacy, terms, connections (addressing), and guilds.
|
|
79
|
+
for (const f of [
|
|
80
|
+
'platform_identity_001_tables.sql', 'platform_identity_007_profile_consent.sql',
|
|
81
|
+
'platform_identity_008_handle.sql', 'platform_identity_012_account_privacy.sql',
|
|
82
|
+
'platform_identity_015_connections.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
83
|
+
'platform_identity_025_guilds.sql',
|
|
84
|
+
]) await q(migrationSql(f));
|
|
85
|
+
await q(migrationSql('platform_identity_025_guilds.sql')); // re-runnable
|
|
86
|
+
await cleanup();
|
|
87
|
+
|
|
88
|
+
await q(
|
|
89
|
+
`INSERT INTO platform_identity_accounts
|
|
90
|
+
(github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility)
|
|
91
|
+
VALUES ($1, 'r24own', 'r24own', 'Owner', 'active', now(), 'public'),
|
|
92
|
+
($2, 'r24alice', 'r24alice', 'Alice', 'active', now(), 'public'),
|
|
93
|
+
($3, 'r24priv', 'r24priv', 'Priv', 'active', now(), 'private'),
|
|
94
|
+
($4, 'r24privc', 'r24privc', 'PrivC', 'active', now(), 'private'),
|
|
95
|
+
($5, 'r24prov', 'r24prov', 'Prov', 'provisional', NULL, 'public'),
|
|
96
|
+
($6, 'r24bob', 'r24bob', 'Bob', 'active', now(), 'public'),
|
|
97
|
+
($7, 'r24carl', 'r24carl', 'Carl', 'active', now(), 'public'),
|
|
98
|
+
($8, 'r24priv2', 'r24priv2', 'Priv2', 'active', now(), 'private')`,
|
|
99
|
+
ALL_IDS,
|
|
100
|
+
);
|
|
101
|
+
await q(`INSERT INTO platform_identity_connections (requester_github_id, addressee_github_id, status, resolved_at)
|
|
102
|
+
VALUES ($1, $2, 'accepted', now()), ($1, $3, 'accepted', now())`, [OWNER, PRIVC, PRIV2]);
|
|
103
|
+
|
|
104
|
+
// --- 1. the schema holds its own rules (ADR 0336 D1) ---------------------
|
|
105
|
+
await rejects(q(`INSERT INTO platform_identity_guilds (slug, name) VALUES ('r24-nodefault', 'x')`),
|
|
106
|
+
'23502', 'visibility has NO default — the creator must choose');
|
|
107
|
+
await rejects(q(`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ('r24-bad', 'x', 'private')`),
|
|
108
|
+
'23514', 'visibility is public or unlisted, nothing else');
|
|
109
|
+
await rejects(q(`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ('R24-Upper', 'x', 'public')`),
|
|
110
|
+
'23514', 'the slug is stored in canonical lowercase form only');
|
|
111
|
+
const cols = (await q(
|
|
112
|
+
`SELECT column_name, column_default, is_nullable FROM information_schema.columns
|
|
113
|
+
WHERE table_name = 'platform_identity_guild_members'`)).rows;
|
|
114
|
+
const col = (n) => cols.find((c) => c.column_name === n);
|
|
115
|
+
ok(col('shown_publicly').is_nullable === 'NO' && col('shown_publicly').column_default === null,
|
|
116
|
+
'shown_publicly is NOT NULL with no default — every join must say what its card showed');
|
|
117
|
+
ok(col('counted_in_totals').is_nullable === 'NO' && col('counted_in_totals').column_default === 'true',
|
|
118
|
+
'counted_in_totals is NOT NULL DEFAULT true (ADR 0337 D4.4)');
|
|
119
|
+
ok(!col('role') && !!col('membership_kind'), 'membership_kind, never role (ADR 0174)');
|
|
120
|
+
|
|
121
|
+
// --- 2. create ------------------------------------------------------------
|
|
122
|
+
const pub = await guilds.createGuild({ githubId: OWNER, slug: 'r24-pub', name: 'Public Guild', visibility: 'public' }, { pool });
|
|
123
|
+
ok(pub.guild && pub.guild.slug === 'r24-pub' && pub.guild.id === undefined, 'createGuild returns the guild without its row id');
|
|
124
|
+
const ownerPub = await memberRow('r24-pub', OWNER);
|
|
125
|
+
ok(ownerPub.membership_kind === 'owner' && ownerPub.shown_publicly === true && ownerPub.counted_in_totals === true,
|
|
126
|
+
'the creator is the one owner, shown on a public guild, counted by default');
|
|
127
|
+
const unl = await guilds.createGuild({ githubId: OWNER, slug: 'r24-unl', name: 'Quiet Guild', visibility: 'unlisted' }, { pool });
|
|
128
|
+
ok(unl.guild.visibility === 'unlisted' && (await memberRow('r24-unl', OWNER)).shown_publicly === false,
|
|
129
|
+
'creating an unlisted guild leaves its owner unshown');
|
|
130
|
+
ok((await guilds.createGuild({ githubId: ALICE, slug: 'r24-pub', name: 'Dup', visibility: 'public' }, { pool })).taken === true,
|
|
131
|
+
'a taken slug is refused');
|
|
132
|
+
await rejects(q(`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly)
|
|
133
|
+
VALUES ($1, $2, 'owner', true)`, [await guildId('r24-pub'), ALICE]),
|
|
134
|
+
'23505', 'the database refuses a second owner');
|
|
135
|
+
|
|
136
|
+
// --- 3. the public read (D4) ------------------------------------------------
|
|
137
|
+
ok((await guilds.getPublicGuild('r24-unl', { pool })) === null, 'an unlisted guild has no public existence');
|
|
138
|
+
ok((await guilds.getPublicGuild('r24-nope', { pool })) === null, '…the same null as a guild that does not exist');
|
|
139
|
+
|
|
140
|
+
// --- 4. join requests: public only, two consents, silent decline -----------
|
|
141
|
+
ok((await guilds.requestToJoin({ slug: 'r24-unl', githubId: ALICE }, { pool })).outcome === 'not_found',
|
|
142
|
+
'an unlisted guild takes no requests, and says so as not_found');
|
|
143
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: ALICE, countedInTotals: false }, { pool })).outcome === 'created',
|
|
144
|
+
'a builder may ask a public guild');
|
|
145
|
+
ok(!(await memberRow('r24-pub', ALICE)), 'asking is not joining — no self-join');
|
|
146
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: ALICE }, { pool })).outcome === 'already_open',
|
|
147
|
+
'a second open request for the pair is refused by the open-row index');
|
|
148
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: OWNER }, { pool })).outcome === 'already_member',
|
|
149
|
+
'a member cannot ask to join');
|
|
150
|
+
|
|
151
|
+
const aliceReq = await pendingId('r24-pub', ALICE);
|
|
152
|
+
ok((await guilds.acceptRequest({ id: aliceReq, githubId: BOB }, { pool })).outcome === 'not_found',
|
|
153
|
+
'only the owner may accept a request — anyone else is not_found');
|
|
154
|
+
ok((await guilds.acceptRequest({ id: aliceReq, githubId: OWNER, countedInTotals: true }, { pool })).outcome === 'accepted',
|
|
155
|
+
'the owner accepts the request');
|
|
156
|
+
const alicePub = await memberRow('r24-pub', ALICE);
|
|
157
|
+
ok(alicePub && alicePub.membership_kind === 'member' && alicePub.shown_publicly === true,
|
|
158
|
+
'a request to a public guild joins shown');
|
|
159
|
+
ok(alicePub.counted_in_totals === false,
|
|
160
|
+
'the requester\'s own counted_in_totals choice rides the request — the owner cannot override it');
|
|
161
|
+
|
|
162
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'created', 'Bob asks');
|
|
163
|
+
ok(await guilds.declineRequest({ id: await pendingId('r24-pub', BOB), githubId: OWNER }, { pool }), 'the owner declines');
|
|
164
|
+
const before = (await openRows('r24-pub', BOB)).length;
|
|
165
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'swallowed',
|
|
166
|
+
'a re-ask inside the cooldown is swallowed…');
|
|
167
|
+
ok((await openRows('r24-pub', BOB)).length === before, '…and writes nothing');
|
|
168
|
+
|
|
169
|
+
// --- 5. invites: addressing, batch, the card (D3/D4) ------------------------
|
|
170
|
+
const unlId = await guildId('r24-unl');
|
|
171
|
+
const res = await guilds.inviteHandles({
|
|
172
|
+
guildId: unlId, ownerGithubId: OWNER,
|
|
173
|
+
handles: ['r24alice', 'R24PRIV', 'r24privc', 'r24prov', 'r24own', 'r24-ghost', 'r24alice'],
|
|
174
|
+
}, { pool });
|
|
175
|
+
const outcome = (h) => res.find((r) => r.handle === h)?.outcome;
|
|
176
|
+
ok(outcome('r24alice') === 'invited', 'a public account is invitable into an unlisted guild');
|
|
177
|
+
ok(outcome('r24priv') === 'not_found', 'a private stranger is not_found');
|
|
178
|
+
ok(outcome('r24privc') === 'invited', 'a private account the owner is connected to is invitable (ADR 0202 D4 §2)');
|
|
179
|
+
ok(outcome('r24prov') === 'not_found', 'a provisional account cannot be invited');
|
|
180
|
+
ok(outcome('r24own') === 'already_member', 'the owner is already a member');
|
|
181
|
+
ok(outcome('r24-ghost') === 'not_found', 'an unknown handle is not_found — one answer with private and provisional');
|
|
182
|
+
ok(res.filter((r) => r.handle === 'r24alice').length === 1, 'a repeated handle in one batch is invited once');
|
|
183
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: ALICE, handles: ['r24bob'] }, { pool });
|
|
184
|
+
ok((await openRows('r24-unl', BOB)).length === 0,
|
|
185
|
+
'a non-owner invite writes nothing (the owner gate is in the statement, not only the route)');
|
|
186
|
+
ok((await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24priv2'] }, { pool }))[0].outcome === 'invited',
|
|
187
|
+
'the owner invites a second private account they are connected to');
|
|
188
|
+
|
|
189
|
+
const aliceInv = await pendingId('r24-unl', ALICE);
|
|
190
|
+
ok((await guilds.acceptRequest({ id: aliceInv, githubId: ALICE }, { pool })).outcome === 'card_required',
|
|
191
|
+
'an invite accept must carry the visibility its card showed');
|
|
192
|
+
const changed = await guilds.acceptRequest({ id: aliceInv, githubId: ALICE, cardVisibility: 'public' }, { pool });
|
|
193
|
+
ok(changed.outcome === 'visibility_changed' && changed.visibility === 'unlisted',
|
|
194
|
+
'a card that does not match the guild is refused, naming the current visibility');
|
|
195
|
+
ok((await guilds.acceptRequest({ id: aliceInv, githubId: OWNER, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
|
|
196
|
+
'only the invitee may accept an invite');
|
|
197
|
+
ok((await guilds.acceptRequest({ id: aliceInv, githubId: ALICE, cardVisibility: 'unlisted', countedInTotals: false }, { pool })).outcome === 'accepted',
|
|
198
|
+
'the invitee accepts with the card they saw');
|
|
199
|
+
const aliceUnl = await memberRow('r24-unl', ALICE);
|
|
200
|
+
ok(aliceUnl.shown_publicly === false && aliceUnl.counted_in_totals === false,
|
|
201
|
+
'joining an unlisted guild leaves the member unshown; the card switch is honoured');
|
|
202
|
+
|
|
203
|
+
const privcInv = await pendingId('r24-unl', PRIVC);
|
|
204
|
+
ok((await guilds.acceptRequest({ id: privcInv, githubId: PRIVC, cardVisibility: 'unlisted' }, { pool })).outcome === 'accepted',
|
|
205
|
+
'a private account can join');
|
|
206
|
+
ok((await guilds.acceptRequest({ id: privcInv, githubId: PRIVC, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
|
|
207
|
+
'an accepted row cannot be accepted twice');
|
|
208
|
+
|
|
209
|
+
// Decline cooldown on invites, and the owner's queue.
|
|
210
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24bob', 'r24carl'] }, { pool });
|
|
211
|
+
const q1 = await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: OWNER }, { pool });
|
|
212
|
+
ok(q1.outgoing.length === 3 && q1.outgoing.some((r) => r.handle === 'r24priv2') && q1.outgoing.every((r) => !('seen_at' in r) && r.handle && !('github_id' in r)),
|
|
213
|
+
'the owner sees invites still out — named by handle, never github_id, never the invitee\'s seen_at');
|
|
214
|
+
ok((await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: ALICE }, { pool })).outgoing.length === 0,
|
|
215
|
+
'a non-owner reads an empty queue (the owner gate is in the statement)');
|
|
216
|
+
const bobQ = await guilds.listMyRequests({ githubId: BOB }, { pool });
|
|
217
|
+
ok(bobQ.incoming.length === 1 && bobQ.incoming[0].visibility === 'unlisted' && bobQ.incoming[0].slug === 'r24-unl',
|
|
218
|
+
'the invitee\'s queue carries the card: slug and current visibility');
|
|
219
|
+
ok((await openRows('r24-unl', BOB))[0].seen_at !== null, 'reading the queue stamps seen_at on the invite');
|
|
220
|
+
ok(await guilds.declineRequest({ id: await pendingId('r24-unl', BOB), githubId: BOB }, { pool }), 'Bob declines — silently');
|
|
221
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24bob'] }, { pool });
|
|
222
|
+
ok((await openRows('r24-unl', BOB)).length === 1, 'a re-invite inside the cooldown is swallowed: no new row');
|
|
223
|
+
|
|
224
|
+
// Cancel: the opener only.
|
|
225
|
+
const carlInv = await pendingId('r24-unl', CARL);
|
|
226
|
+
ok(!(await guilds.cancelRequest({ id: carlInv, githubId: CARL }, { pool })), 'an invitee cannot cancel an invite (that is a decline)');
|
|
227
|
+
ok(await guilds.cancelRequest({ id: carlInv, githubId: OWNER }, { pool }), 'the owner cancels an invite');
|
|
228
|
+
|
|
229
|
+
// Expiry is read-time, and the next write resolves the stale row in the same statement.
|
|
230
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24carl'] }, { pool });
|
|
231
|
+
await q(`UPDATE platform_identity_guild_requests SET created_at = now() - interval '31 days'
|
|
232
|
+
WHERE github_id = $1 AND status = 'pending'`, [CARL]);
|
|
233
|
+
const stale = await pendingId('r24-unl', CARL);
|
|
234
|
+
ok((await guilds.acceptRequest({ id: stale, githubId: CARL, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
|
|
235
|
+
'an invite older than the expiry cannot be accepted');
|
|
236
|
+
const again = await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24carl'] }, { pool });
|
|
237
|
+
ok(again[0].outcome === 'invited' && (await openRows('r24-unl', CARL)).some((r) => r.status === 'expired'),
|
|
238
|
+
'a fresh invite resolves the stale row as expired in the same statement, instead of hitting the open-row index');
|
|
239
|
+
|
|
240
|
+
// --- 6. visibility changes flip only the owner (D4 §3) ---------------------
|
|
241
|
+
const flipped = await guilds.updateGuild({ guildId: unlId, githubId: OWNER, visibility: 'public' }, { pool });
|
|
242
|
+
ok(flipped && flipped.visibility === 'public', 'the owner makes the guild public');
|
|
243
|
+
ok((await memberRow('r24-unl', OWNER)).shown_publicly === true, 'the owner who flipped it is now shown');
|
|
244
|
+
ok((await memberRow('r24-unl', ALICE)).shown_publicly === false, 'a member who joined it unlisted is NOT shown until they agree');
|
|
245
|
+
ok(!(await guilds.updateGuild({ guildId: unlId, githubId: ALICE, name: 'Hijack' }, { pool })), 'a non-owner cannot edit the guild');
|
|
246
|
+
const carlCard = await guilds.acceptRequest({ id: await pendingId('r24-unl', CARL), githubId: CARL, cardVisibility: 'unlisted' }, { pool });
|
|
247
|
+
ok(carlCard.outcome === 'visibility_changed', 'an invite accepted from a card that predates the flip is refused');
|
|
248
|
+
|
|
249
|
+
let page = await guilds.getPublicGuild('r24-unl', { pool });
|
|
250
|
+
ok(page && page.members.map((m) => m.handle).join(',') === 'r24own' && page.size === 1,
|
|
251
|
+
'the public roster shows only members who agreed to be shown; size is its length');
|
|
252
|
+
ok(page.members.every((m) => !('github_id' in m)), 'the public roster never carries github_id');
|
|
253
|
+
|
|
254
|
+
ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: ALICE, shownPublicly: true }, { pool })).shown_publicly === true,
|
|
255
|
+
'a member agrees to be shown');
|
|
256
|
+
await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: PRIVC, shownPublicly: true }, { pool });
|
|
257
|
+
page = await guilds.getPublicGuild('r24-unl', { pool });
|
|
258
|
+
ok(page.members.map((m) => m.handle).sort().join(',') === 'r24alice,r24own' && page.size === 2,
|
|
259
|
+
'a private account is never on a public roster, whatever its flag');
|
|
260
|
+
ok(page.members[0].handle === 'r24own', 'the owner leads; the rest are ordered by handle, a published field');
|
|
261
|
+
const roster = await guilds.listMemberRoster({ guildId: unlId }, { pool });
|
|
262
|
+
ok(roster.some((m) => m.handle === 'r24privc'), 'fellow members still see the private member in the own-scope roster');
|
|
263
|
+
ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: ALICE, countedInTotals: true }, { pool })).counted_in_totals === true,
|
|
264
|
+
'a member flips their own counted_in_totals');
|
|
265
|
+
ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: BOB, countedInTotals: false }, { pool })) === null,
|
|
266
|
+
'a non-member has no switch to flip');
|
|
267
|
+
ok((await guilds.listMyGuilds({ githubId: ALICE }, { pool })).length === 2, 'my guilds lists both memberships');
|
|
268
|
+
|
|
269
|
+
// --- 7. transfer, remove, leave, dissolve (D2) -----------------------------
|
|
270
|
+
ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24privc' }, { pool }) === 'not_owner',
|
|
271
|
+
'a member cannot transfer ownership');
|
|
272
|
+
ok((await memberRow('r24-unl', ALICE)).membership_kind === 'member', '…and the refused attempt changed nothing');
|
|
273
|
+
ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24bob' }, { pool }) === 'not_member',
|
|
274
|
+
'ownership goes only to an existing member');
|
|
275
|
+
ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24alice' }, { pool }) === 'transferred',
|
|
276
|
+
'the owner transfers to a member');
|
|
277
|
+
ok((await memberRow('r24-unl', ALICE)).membership_kind === 'owner' && (await memberRow('r24-unl', OWNER)).membership_kind === 'member',
|
|
278
|
+
'demote then promote: exactly one owner, and it is the new one');
|
|
279
|
+
const newOwnerQ = await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: ALICE }, { pool });
|
|
280
|
+
ok(newOwnerQ.outgoing.some((r) => r.handle === 'r24carl') && !newOwnerQ.outgoing.some((r) => r.handle === 'r24priv2'),
|
|
281
|
+
'after a transfer, the new owner sees public invitees but not a private one the previous owner invited');
|
|
282
|
+
ok((await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: OWNER }, { pool })).outgoing.length === 0,
|
|
283
|
+
'and the previous owner no longer reads the queue at all');
|
|
284
|
+
|
|
285
|
+
ok(!(await guilds.removeMember({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24privc' }, { pool })),
|
|
286
|
+
'the former owner can no longer remove members');
|
|
287
|
+
ok(await guilds.removeMember({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24privc' }, { pool }),
|
|
288
|
+
'the owner removes a member');
|
|
289
|
+
ok(!(await memberRow('r24-unl', PRIVC)), 'removal is a hard delete');
|
|
290
|
+
ok(!(await openRows('r24-unl', PRIVC)).some((r) => r.status === 'accepted'),
|
|
291
|
+
'removal also deletes the accepted invite that admitted them — no record they were ever here');
|
|
292
|
+
ok(!(await guilds.removeMember({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24alice' }, { pool })),
|
|
293
|
+
'the owner cannot remove themselves');
|
|
294
|
+
|
|
295
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: ALICE }, { pool }) === 'must_transfer',
|
|
296
|
+
'an owner with members must transfer before leaving');
|
|
297
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: OWNER }, { pool }) === 'left', 'a member leaves');
|
|
298
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: OWNER }, { pool }) === null, 'a non-member cannot leave');
|
|
299
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: ALICE }, { pool }) === 'dissolved',
|
|
300
|
+
'the last member leaving dissolves the guild');
|
|
301
|
+
ok(!(await guildId('r24-unl')) && (await openRows('r24-unl', CARL)).length === 0,
|
|
302
|
+
'dissolving hard-deletes the guild and cascades to its open requests');
|
|
303
|
+
|
|
304
|
+
const pubId = await guildId('r24-pub');
|
|
305
|
+
|
|
306
|
+
// A leave racing a transfer that promotes the leaver. The transfer holds the
|
|
307
|
+
// leaver's row locked; the leave must wait, then see 'owner', and refuse —
|
|
308
|
+
// never delete the new owner from a stale snapshot and orphan the guild.
|
|
309
|
+
const tx = await pool.connect();
|
|
310
|
+
let raced;
|
|
311
|
+
try {
|
|
312
|
+
await tx.query('BEGIN');
|
|
313
|
+
await tx.query(`SELECT 1 FROM platform_identity_guild_members WHERE guild_id = $1 AND github_id = $2 FOR UPDATE`, [pubId, ALICE]);
|
|
314
|
+
await tx.query(`UPDATE platform_identity_guild_members SET membership_kind = 'member' WHERE guild_id = $1 AND github_id = $2`, [pubId, OWNER]);
|
|
315
|
+
await tx.query(`UPDATE platform_identity_guild_members SET membership_kind = 'owner' WHERE guild_id = $1 AND github_id = $2`, [pubId, ALICE]);
|
|
316
|
+
raced = guilds.leaveGuild({ guildId: pubId, githubId: ALICE }, { pool });
|
|
317
|
+
let waiting = false;
|
|
318
|
+
for (let i = 0; i < 50 && !waiting; i++) {
|
|
319
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
320
|
+
const { rows } = await q(`SELECT 1 FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE '%WITH mine AS%'`);
|
|
321
|
+
waiting = rows.length > 0;
|
|
322
|
+
}
|
|
323
|
+
ok(waiting, 'the racing leave is blocked on the transfer\'s row lock');
|
|
324
|
+
await tx.query('COMMIT');
|
|
325
|
+
} finally { tx.release(); }
|
|
326
|
+
ok(await raced === 'must_transfer', 'once the transfer commits, the leave re-reads the row as owner and refuses');
|
|
327
|
+
ok((await memberRow('r24-pub', ALICE)).membership_kind === 'owner', 'the guild still has its (new) owner');
|
|
328
|
+
|
|
329
|
+
ok(await guilds.transferOwnership({ guildId: pubId, ownerGithubId: ALICE, handle: 'r24own' }, { pool }) === 'transferred',
|
|
330
|
+
'ownership is handed back');
|
|
331
|
+
ok(await guilds.leaveGuild({ guildId: pubId, githubId: ALICE }, { pool }) === 'left', 'the member leaves');
|
|
332
|
+
ok(!(await openRows('r24-pub', ALICE)).some((r) => r.status === 'accepted'),
|
|
333
|
+
'leaving deletes the accepted request that admitted them');
|
|
334
|
+
ok(!(await guilds.dissolveGuild({ guildId: pubId, githubId: ALICE }, { pool })), 'a non-owner cannot dissolve');
|
|
335
|
+
ok(await guilds.dissolveGuild({ guildId: pubId, githubId: OWNER }, { pool }), 'the owner dissolves');
|
|
336
|
+
ok((await q(`SELECT count(*)::int AS n FROM platform_identity_guild_members WHERE guild_id = $1`, [pubId])).rows[0].n === 0,
|
|
337
|
+
'dissolving cascades to every membership');
|
|
338
|
+
|
|
339
|
+
console.log(`\nguilds_db.mjs: ${pass} passed`);
|
|
340
|
+
} finally {
|
|
341
|
+
await cleanup();
|
|
342
|
+
await pool.end();
|
|
343
|
+
}
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
// 2. WHETHER ANYTHING LEAKED. No: every /my-projects* route is sign-in-gated
|
|
20
20
|
// and reads the caller's OWN identity off the session. This is the half the
|
|
21
21
|
// audit could not confirm, so it is asserted here rather than described.
|
|
22
|
-
// 3. WHAT
|
|
23
|
-
// surface
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// 3. WHAT WAS STILL MISSING. The invitations half had no caller anywhere — no
|
|
23
|
+
// surface fetched the invite list, so a builder holding an invitation could
|
|
24
|
+
// not act on it. Closed by task 1003774: the hub's /projects?view=mine now
|
|
25
|
+
// reads GET /my-projects/invites (idea 1000903). The pin below flipped from
|
|
26
|
+
// "no surface calls this" to "some surface does" for the same reason.
|
|
26
27
|
//
|
|
27
28
|
// Static reads only (no import of the app, no DB) → runs in the `unit` gate.
|
|
28
29
|
//
|
|
@@ -88,13 +89,11 @@ test('every /my-projects handler reads the caller identity from the session', ()
|
|
|
88
89
|
'no /my-projects handler may take the account id from the request body or query');
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
// ── 3. the gap
|
|
92
|
-
test('
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// 1000903 own the replacement; this pin exists so the gap stays visible until
|
|
97
|
-
// one of them lands, and so it is impossible to close this quietly.
|
|
92
|
+
// ── 3. the gap is closed — a surface now calls the invite list ──────────────
|
|
93
|
+
test('an invitations surface exists — the hub reads GET /my-projects/invites (task 1003774)', () => {
|
|
94
|
+
// The quoted form is the actual fetch-call shape (`API + '/my-projects/invites'`);
|
|
95
|
+
// matching on that, rather than the bare path, keeps a doc comment that merely
|
|
96
|
+
// NAMES the route from satisfying this on its own.
|
|
98
97
|
const surfaces = [];
|
|
99
98
|
const modules = path.join(ROOT, 'modules');
|
|
100
99
|
for (const key of fs.readdirSync(modules)) {
|
|
@@ -102,11 +101,24 @@ test('the invitations API still has no caller — the tracked gap, pinned', () =
|
|
|
102
101
|
if (!fs.existsSync(pub)) continue;
|
|
103
102
|
for (const f of fs.readdirSync(pub)) {
|
|
104
103
|
if (!/\.(js|html)$/.test(f)) continue;
|
|
105
|
-
if (fs.readFileSync(path.join(pub, f), 'utf8').includes('/my-projects/invites')) {
|
|
104
|
+
if (fs.readFileSync(path.join(pub, f), 'utf8').includes("'/my-projects/invites'")) {
|
|
106
105
|
surfaces.push(`modules/${key}/public/${f}`);
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
}
|
|
110
|
-
assert.
|
|
111
|
-
|
|
109
|
+
assert.ok(surfaces.length > 0,
|
|
110
|
+
'no surface calls GET /my-projects/invites — the gap task 1003774 closed is back open');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── 4. the accept flip stays retired (ADR 0335) ─────────────────────────────
|
|
114
|
+
// Task 1003774 restores the invitations list but deliberately does NOT wire an
|
|
115
|
+
// Accept button to POST /my-projects/invites/:id/accept: that hub-only flip
|
|
116
|
+
// never admits anyone into the project's own instance (ADR 0335), so it is a
|
|
117
|
+
// dead end the moment a caller exists to press it. Each invite renders as a
|
|
118
|
+
// way IN (a link to the project) instead — signing in there is the acceptance
|
|
119
|
+
// act (task 1002969).
|
|
120
|
+
test('the hub never calls the accept route — ADR 0335 retired that flip', () => {
|
|
121
|
+
const html = read('modules/public-landing/public/projects.html');
|
|
122
|
+
assert.doesNotMatch(html, /invites\/[^'"]*\/accept/,
|
|
123
|
+
'a hub caller of the accept route reopens the dead end ADR 0335 closed — render the invite as a link into the project instead');
|
|
112
124
|
});
|
|
@@ -123,6 +123,11 @@ const OWN_SCOPED_SURFACES = [
|
|
|
123
123
|
why: 'the connection graph (ADR 0202, task 1002295). The ACTING account is always the session; a counterparty is named by public handle or by the connection row id, never by an account identifier — and the degree label is a viewer-relative own-scope read (D4 §4).',
|
|
124
124
|
crossOwner: [],
|
|
125
125
|
},
|
|
126
|
+
{
|
|
127
|
+
file: 'modules/platform-identity/routes/guilds.js',
|
|
128
|
+
why: 'guilds (ADR 0336, task 1002300). The ACTING account is always the session: a guild is named by slug, a person by public handle, an invite or request by its row id, and owner-only acts are re-asserted against the session inside each statement. The one public read, GET /guilds/:slug, is deliberately in routes/guilds-public.js so this whole file can be held to the rule.',
|
|
129
|
+
crossOwner: [],
|
|
130
|
+
},
|
|
126
131
|
{
|
|
127
132
|
file: 'modules/platform-identity/routes/my-projects.js',
|
|
128
133
|
why: "the caller's OWN cross-project memberships, invitations, applications and leave (ADR 0141 §3; the application echo, ADR 0201 D1.3, task 1002284) — every route keyed on req.builder.github_id / github_login. The only path param is :clientId, which names a PROJECT and never a builder: a project is the resource being acted on, and the actor is always the session, which is why join can relay an application for nobody but the caller.",
|
|
@@ -508,3 +508,43 @@ test('a stamped asset still serves — the query string is not part of the filen
|
|
|
508
508
|
assert.equal(res.status, 200, 'express.static must ignore the ?v= when resolving the file');
|
|
509
509
|
assert.match(res.body, /\.shell\{/, 'and it must be the real stylesheet');
|
|
510
510
|
});
|
|
511
|
+
|
|
512
|
+
// ── #view-mine: invitations, projects-you-build-on, join-by-URL (task 1003774) ──
|
|
513
|
+
// The hall's my-projects page was deleted in task 1003308 on the owner's own
|
|
514
|
+
// decision that this is a hub surface; these three reads/actions were left with
|
|
515
|
+
// nowhere to live until now. loadMemberships() already fetched GET /my-projects
|
|
516
|
+
// for the map's Apply button (R10, task 1002286) — this just renders the rest
|
|
517
|
+
// of what that same read carries, plus the separate invites list.
|
|
518
|
+
test('#view-mine reads the invitations list, alongside the building-on and join surfaces', async () => {
|
|
519
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
520
|
+
assert.match(body, /\/my-projects\/invites/, 'GET /my-projects/invites must be called');
|
|
521
|
+
assert.match(body, /id="invitesList"/);
|
|
522
|
+
assert.match(body, /id="buildingList"/);
|
|
523
|
+
assert.match(body, /\/my-projects\/join/, 'the join-by-URL field must post to /my-projects/join');
|
|
524
|
+
assert.match(body, /id="joinOrigin"/);
|
|
525
|
+
assert.match(body, /id="joinNote"/, 'the applicant note (task 1002330) rides along, and stays optional');
|
|
526
|
+
assert.match(body, /\/my-projects\/'\s*\+\s*encodeURIComponent/, 'Leave must address the project by its own client id');
|
|
527
|
+
assert.match(body, /origin = 'https:\/\/' \+ origin/,
|
|
528
|
+
'a bare host ("grove.cloudbongos.com") must be assumed https:// rather than sent as-is — the server\'s origin lookup is an exact match');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('an invite renders as a way INTO the project, never the retired accept flip (ADR 0335)', async () => {
|
|
532
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
533
|
+
// the hub-only accept flip never admits anyone to the project's own instance
|
|
534
|
+
// (ADR 0335) — a caller of it here would reopen the exact dead end that ADR
|
|
535
|
+
// retired, so the invite's action is an outbound link instead
|
|
536
|
+
assert.doesNotMatch(body, /invites\/[^'"]*\/accept/, 'no caller of the retired hub accept route');
|
|
537
|
+
assert.match(body, /Open project/, 'the invite links into the project — signing in there is the acceptance act');
|
|
538
|
+
assert.match(body, /'\/my-projects\/invites\/' \+ encodeURIComponent\([^)]+\) \+ '\/decline'/,
|
|
539
|
+
'decline still works: it only clears the hub\'s own pending row');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('the map card\'s Apply button reads as truly inert once applied, with a live status region (R10 fixups)', async () => {
|
|
543
|
+
const page = await request('/projects', 'game.test.local');
|
|
544
|
+
assert.match(page.body, /id="cardApplyMsg" role="status" aria-live="polite"/, 'the apply outcome must be announced to assistive tech');
|
|
545
|
+
const stamped = (page.body.match(/\/assets\/cosmos\.css\?v=[0-9a-f]+/) || [])[0];
|
|
546
|
+
assert.ok(stamped, 'need a stamped URL to fetch');
|
|
547
|
+
const css = await request(stamped);
|
|
548
|
+
assert.match(css.body, /\.btn\.ghost:disabled\{/, 'a disabled ghost button (the Applied state) must not keep full-strength styling');
|
|
549
|
+
assert.match(css.body, /\.projCard \.cardActions \.projMsg\{flex-basis:100%;?\}/, 'the outcome must sit under the buttons, not beside them');
|
|
550
|
+
});
|