@bongos/core 1.21.57 → 1.21.58
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 +82 -47
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +13 -2
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
- package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +233 -6
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +360 -352
- package/docs/copy-registry.json +479 -397
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +7 -1
- package/docs/page-readings.json +828 -811
- package/modules/platform-identity/application-echo.js +11 -5
- package/modules/platform-identity/guild-engagements.js +255 -0
- package/modules/platform-identity/guilds.js +3 -1
- package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
- package/modules/platform-identity/routes/guilds.js +35 -1
- package/modules/platform-identity/routes/my-projects.js +27 -3
- package/modules/platform-identity/routes/projects.js +44 -0
- package/modules/platform-identity/routes/sso.js +21 -3
- package/modules/public-landing/public/assets/cosmos.css +7 -0
- package/modules/public-landing/public/guild.html +64 -2
- package/modules/public-landing/public/guild.probes.json +13 -3
- package/modules/public-landing/public/guild.states.json +10 -5
- package/modules/public-landing/public/projects.html +252 -16
- package/modules/public-landing/public/projects.probes.json +26 -1
- package/modules/public-landing/public/projects.states.json +4 -0
- package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
- package/modules/ui-design/kit/serve.js +54 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/applicant_profile_boundary.mjs +104 -1
- package/tests/application_echo.mjs +91 -5
- package/tests/guild_engagement.mjs +881 -0
- package/tests/guild_engagement_db.mjs +240 -0
- package/tests/hub_engagement_cards.mjs +307 -0
- package/tests/hub_guild_page.mjs +94 -7
- package/tests/privacy_matrix.mjs +99 -9
- package/tests/project_invite_ui.mjs +187 -3
|
@@ -47,19 +47,25 @@ const APPLICATION_ECHO_WINDOW_DAYS = 30;
|
|
|
47
47
|
// is measured from the most recent ask — otherwise an applicant who re-asks
|
|
48
48
|
// watches their own card go quiet while their request is still open.
|
|
49
49
|
//
|
|
50
|
+
// `guildId` is the guild this application was made with (task 1002303, ADR 0365
|
|
51
|
+
// D1) — resolved by the relay through guild-engagements.js engagementFor, never
|
|
52
|
+
// taken from the request — or null for a solo application. It is written on
|
|
53
|
+
// EVERY upsert, null included, so the most recent application decides the label
|
|
54
|
+
// the reviewer sees: applying again on your own clears it.
|
|
55
|
+
//
|
|
50
56
|
// BEST-EFFORT BY CONTRACT: returns false instead of throwing when the account is
|
|
51
57
|
// a non-numeric principal (ADR 0171 D2). The caller must also swallow a DB error
|
|
52
58
|
// — the echo is a convenience on the applicant's own view, and losing it must
|
|
53
59
|
// never turn a filed application into a reported failure.
|
|
54
|
-
async function recordApplication({ githubId, clientId }, { pool = defaultPool } = {}) {
|
|
60
|
+
async function recordApplication({ githubId, clientId, guildId = null }, { pool = defaultPool } = {}) {
|
|
55
61
|
if (!isNumericGithubId(githubId)) return false;
|
|
56
62
|
const client = String(clientId || '');
|
|
57
63
|
if (!client) return false;
|
|
58
64
|
await pool.query(
|
|
59
|
-
`INSERT INTO platform_identity_application_echoes (github_id, client_id, applied_at)
|
|
60
|
-
VALUES ($1::bigint, $2, now())
|
|
61
|
-
ON CONFLICT (github_id, client_id) DO UPDATE SET applied_at = now()`,
|
|
62
|
-
[String(githubId), client],
|
|
65
|
+
`INSERT INTO platform_identity_application_echoes (github_id, client_id, applied_at, guild_id)
|
|
66
|
+
VALUES ($1::bigint, $2, now(), $3::bigint)
|
|
67
|
+
ON CONFLICT (github_id, client_id) DO UPDATE SET applied_at = now(), guild_id = EXCLUDED.guild_id`,
|
|
68
|
+
[String(githubId), client, guildId == null ? null : String(guildId)],
|
|
63
69
|
);
|
|
64
70
|
return true;
|
|
65
71
|
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// modules/platform-identity/guild-engagements.js — a guild engaging a project:
|
|
2
|
+
// "G is applying to P" and "P invited G", plus the guild label an application
|
|
3
|
+
// carries to the project's reviewer (task 1002303, R27; ADR 0336 D7, amended by
|
|
4
|
+
// ADR 0365).
|
|
5
|
+
//
|
|
6
|
+
// WHAT LIVES HERE, next to the data so no route can forget a rule:
|
|
7
|
+
//
|
|
8
|
+
// * A PROJECT STILL ADMITS PEOPLE, ONE AT A TIME (D7). An engagement admits
|
|
9
|
+
// nobody. A member who acts on one applies AS THEMSELVES through the
|
|
10
|
+
// existing relay (routes/my-projects.js), vouched and echoed; the echo then
|
|
11
|
+
// carries the guild as a label. Filing does not apply on the owner's behalf.
|
|
12
|
+
// * A PROJECT INVITING A GUILD WRITES NO INVITES (ADR 0365 D3, after ADR
|
|
13
|
+
// 0353). It records the `invited` engagement and hands back one hall link
|
|
14
|
+
// per member on the guild's PUBLIC roster at that moment — the people the
|
|
15
|
+
// inviter could see — so each invite is the project's own row, sent from
|
|
16
|
+
// its own hall. An unlisted guild cannot be invited: it answers like an
|
|
17
|
+
// unknown one.
|
|
18
|
+
// * THE NAMING RULE (ADR 0365 D4): a project is named only when it is on the
|
|
19
|
+
// map AND Public (namedProjectSql — the getRollupForBuilder rule, composed
|
|
20
|
+
// from the feed's own onMapSql). It gates both kinds, at filing AND at
|
|
21
|
+
// read. A private or stealth project's engagement is not shown at all,
|
|
22
|
+
// never shown anonymised.
|
|
23
|
+
// * ONE FRAGMENT DECIDES WHICH ENGAGEMENTS A MEMBER MAY SEE
|
|
24
|
+
// (memberEngagementsSql): their own guild's, inside the window, naming a
|
|
25
|
+
// project by the rule above, and an `invited` row only if they were already
|
|
26
|
+
// a member when it was filed. The inbox AND the join's guild check both use
|
|
27
|
+
// it, so the join can never accept a guild the inbox would not have shown —
|
|
28
|
+
// that difference would let a member probe whether a private project had
|
|
29
|
+
// invited their guild.
|
|
30
|
+
// * STATELESS AND STATUSLESS (ADR 0201 D1.3). No status, no filer, no
|
|
31
|
+
// withdraw: a row ages out read-time at ENGAGEMENT_WINDOW_DAYS, and
|
|
32
|
+
// re-filing restarts it.
|
|
33
|
+
//
|
|
34
|
+
// NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). Every read that touches
|
|
35
|
+
// platform_identity_accounts joins account-visibility.js
|
|
36
|
+
// (tests/effective_visibility_predicate.mjs). client_id never leaves this file.
|
|
37
|
+
//
|
|
38
|
+
// Its own file because platform-identity.js is at the size ratchet. Reaches
|
|
39
|
+
// core only through the doorway (ADR 0083).
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const api = require('../../src/module-api');
|
|
43
|
+
const { accountVisibleSql } = require('./account-visibility');
|
|
44
|
+
const { onMapSql } = require('./projects-feed');
|
|
45
|
+
const { isNumericGithubId } = require('./platform-identity');
|
|
46
|
+
const { APPLICATION_ECHO_WINDOW_DAYS } = require('./application-echo');
|
|
47
|
+
const { hallInviteUrl } = require('./invite-notices');
|
|
48
|
+
const { ownerExistsSql, LIST_MAX } = require('./guilds');
|
|
49
|
+
|
|
50
|
+
const { pool: defaultPool } = api;
|
|
51
|
+
|
|
52
|
+
// How long an engagement stays visible. READ-TIME, like the application echo's
|
|
53
|
+
// window and for the same reason: no sweep to fall behind, no status to expire
|
|
54
|
+
// into. Exported so the routes, the tests and the docs quote one number.
|
|
55
|
+
const ENGAGEMENT_WINDOW_DAYS = 30;
|
|
56
|
+
|
|
57
|
+
// A project may be NAMED to a guild's members only when it is on the map and
|
|
58
|
+
// Public (ADR 0365 D4, the ADR 0171 D2 amendment). onMapSql validates the alias.
|
|
59
|
+
function namedProjectSql(alias) {
|
|
60
|
+
return `(${onMapSql(alias)} AND ${alias}.visibility = 'public')`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The FROM + WHERE every member-facing read shares (see the header). `member`
|
|
64
|
+
// and `windowDays` are placeholders, e.g. '$1' and '$2'. The client must still
|
|
65
|
+
// be an active registration: a dead client is not a project anyone can apply to.
|
|
66
|
+
function memberEngagementsSql(member, windowDays) {
|
|
67
|
+
return `
|
|
68
|
+
FROM platform_identity_guild_members m
|
|
69
|
+
JOIN platform_identity_guilds g ON g.id = m.guild_id
|
|
70
|
+
JOIN platform_identity_guild_engagements e ON e.guild_id = m.guild_id
|
|
71
|
+
JOIN platform_identity_sso_clients c ON c.client_id = e.client_id
|
|
72
|
+
JOIN platform_identity_projects p ON p.origin = c.origin
|
|
73
|
+
WHERE m.github_id = ${member}::bigint
|
|
74
|
+
AND e.created_at > now() - make_interval(days => ${windowDays}::int)
|
|
75
|
+
AND c.status = 'active' AND ${namedProjectSql('p')}
|
|
76
|
+
AND (e.kind = 'apply' OR m.joined_at <= e.created_at)`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The wire shape of one engagement. A literal whitelist: client_id, the guild's
|
|
80
|
+
// row id and the guild's visibility never cross.
|
|
81
|
+
function engagementView(r) {
|
|
82
|
+
return {
|
|
83
|
+
guild: { slug: r.slug, name: r.guild_name },
|
|
84
|
+
kind: r.kind,
|
|
85
|
+
project: { name: r.project_name, origin: r.project_origin },
|
|
86
|
+
created_at: r.created_at,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// fileEngagement — the guild's OWNER files "this guild is applying to P"
|
|
91
|
+
// (kind 'apply'). ONE statement: P is resolved from the origin the way the join
|
|
92
|
+
// relay resolves it (the oldest active registration at that exact origin), must
|
|
93
|
+
// pass the naming rule, and the owner test is re-asserted inside the INSERT, so
|
|
94
|
+
// the route's pre-check is a clearer error and never the only gate. Re-filing
|
|
95
|
+
// restarts the window. Returns the engagement view, or null when P is unknown,
|
|
96
|
+
// inactive or not named — one null, so the route's 404 cannot be an oracle.
|
|
97
|
+
async function fileEngagement({ guildId, githubId, origin }, { pool = defaultPool } = {}) {
|
|
98
|
+
const norm = String(origin || '').replace(/\/+$/, '');
|
|
99
|
+
if (!norm || !isNumericGithubId(githubId)) return null;
|
|
100
|
+
const { rows } = await pool.query(
|
|
101
|
+
`WITH target AS (
|
|
102
|
+
SELECT c.client_id, p.name, p.origin
|
|
103
|
+
FROM platform_identity_sso_clients c
|
|
104
|
+
JOIN platform_identity_projects p ON p.origin = c.origin
|
|
105
|
+
WHERE c.client_id = (SELECT c1.client_id FROM platform_identity_sso_clients c1
|
|
106
|
+
WHERE c1.origin = $3 AND c1.status = 'active'
|
|
107
|
+
ORDER BY c1.id ASC LIMIT 1)
|
|
108
|
+
AND ${namedProjectSql('p')}
|
|
109
|
+
),
|
|
110
|
+
filed AS (
|
|
111
|
+
INSERT INTO platform_identity_guild_engagements AS e (guild_id, client_id, kind)
|
|
112
|
+
SELECT g.id, target.client_id, 'apply'
|
|
113
|
+
FROM platform_identity_guilds g CROSS JOIN target
|
|
114
|
+
WHERE g.id = $1 AND ${ownerExistsSql('g.id', '$2')}
|
|
115
|
+
ON CONFLICT (guild_id, client_id, kind) DO UPDATE SET created_at = now()
|
|
116
|
+
RETURNING e.guild_id, e.kind, e.created_at
|
|
117
|
+
)
|
|
118
|
+
SELECT g.slug, g.name AS guild_name, filed.kind, filed.created_at,
|
|
119
|
+
target.name AS project_name, target.origin AS project_origin
|
|
120
|
+
FROM filed
|
|
121
|
+
JOIN platform_identity_guilds g ON g.id = filed.guild_id
|
|
122
|
+
CROSS JOIN target`,
|
|
123
|
+
[guildId, String(githubId), norm],
|
|
124
|
+
);
|
|
125
|
+
return rows[0] ? engagementView(rows[0]) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// inviteGuild — a project invites a PUBLIC guild (ADR 0365 D3). The caller has
|
|
129
|
+
// already passed the project's inviter gate and holds the active client row.
|
|
130
|
+
// Returns { guild, invites } — one hall hand-off link per member on the guild's
|
|
131
|
+
// public roster at this moment — or null when the slug is unknown OR unlisted
|
|
132
|
+
// (one answer: an unlisted guild cannot be addressed).
|
|
133
|
+
//
|
|
134
|
+
// The `invited` row is written only while P passes the naming rule, re-checked
|
|
135
|
+
// in the INSERT: a private or stealth project's invite reaches the people it
|
|
136
|
+
// chose and nobody else, so it leaves no notice for the members it could not
|
|
137
|
+
// see — not even one that would surface if P went public later.
|
|
138
|
+
async function inviteGuild({ clientId, origin, slug }, { pool = defaultPool } = {}) {
|
|
139
|
+
const g = await pool.query(
|
|
140
|
+
`SELECT id, slug, name FROM platform_identity_guilds
|
|
141
|
+
WHERE lower(slug) = $1 AND visibility = 'public'`,
|
|
142
|
+
[slug],
|
|
143
|
+
);
|
|
144
|
+
const guild = g.rows[0];
|
|
145
|
+
if (!guild) return null;
|
|
146
|
+
await pool.query(
|
|
147
|
+
`INSERT INTO platform_identity_guild_engagements AS e (guild_id, client_id, kind)
|
|
148
|
+
SELECT $1::bigint, c.client_id, 'invited'
|
|
149
|
+
FROM platform_identity_sso_clients c
|
|
150
|
+
JOIN platform_identity_projects p ON p.origin = c.origin
|
|
151
|
+
WHERE c.client_id = $2 AND c.status = 'active' AND ${namedProjectSql('p')}
|
|
152
|
+
AND EXISTS (SELECT 1 FROM platform_identity_guilds pg
|
|
153
|
+
WHERE pg.id = $1 AND pg.visibility = 'public')
|
|
154
|
+
ON CONFLICT (guild_id, client_id, kind) DO UPDATE SET created_at = now()`,
|
|
155
|
+
[guild.id, clientId],
|
|
156
|
+
);
|
|
157
|
+
const roster = await publicRosterForInvite(guild.id, { pool });
|
|
158
|
+
const invites = roster
|
|
159
|
+
.map((m) => ({ handle: m.handle, display_name: m.display_name, invite_url: hallInviteUrl(origin, m.github_login) }))
|
|
160
|
+
.filter((m) => m.invite_url);
|
|
161
|
+
return { guild: { slug: guild.slug, name: guild.name }, invites };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The guild's PUBLIC roster, exactly as getPublicGuild reads it (shown_publicly
|
|
165
|
+
// AND accountVisibleSql, owner first, then by handle, capped at LIST_MAX) — the
|
|
166
|
+
// people an inviter can already see on the guild's page. github_login is read
|
|
167
|
+
// only to build each hall link (the hall's ?invite= prefill takes a login, which
|
|
168
|
+
// a public profile already shows); it is never returned on its own.
|
|
169
|
+
async function publicRosterForInvite(guildId, { pool }) {
|
|
170
|
+
const { rows } = await pool.query(
|
|
171
|
+
`SELECT pa.handle, pa.display_name, pa.github_login
|
|
172
|
+
FROM platform_identity_guild_members m
|
|
173
|
+
JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
|
|
174
|
+
WHERE m.guild_id = $1 AND m.shown_publicly AND ${accountVisibleSql('pa')}
|
|
175
|
+
ORDER BY (m.membership_kind = 'owner') DESC, lower(pa.handle)
|
|
176
|
+
LIMIT $2`,
|
|
177
|
+
[guildId, LIST_MAX],
|
|
178
|
+
);
|
|
179
|
+
return rows;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// listMyEngagements — the caller's OWN inbox: engagements of guilds they belong
|
|
183
|
+
// to that they may act on, newest first, capped at LIST_MAX. On top of the shared
|
|
184
|
+
// rule (memberEngagementsSql) it drops any project the caller already builds on,
|
|
185
|
+
// already holds a live invite notice for (the inviteExpiry window), or already
|
|
186
|
+
// has a live application echo for — the card would ask them to do what they
|
|
187
|
+
// have done. Own-scope only: the github_id is always the session's.
|
|
188
|
+
async function listMyEngagements({ githubId }, { pool = defaultPool } = {}) {
|
|
189
|
+
if (!isNumericGithubId(githubId)) return [];
|
|
190
|
+
const { rows } = await pool.query(
|
|
191
|
+
`SELECT g.slug, g.name AS guild_name, e.kind, e.created_at,
|
|
192
|
+
p.name AS project_name, p.origin AS project_origin
|
|
193
|
+
${memberEngagementsSql('$1', '$2')}
|
|
194
|
+
AND NOT EXISTS (SELECT 1 FROM platform_identity_project_memberships pm
|
|
195
|
+
WHERE pm.client_id = e.client_id AND pm.github_id = $1::bigint
|
|
196
|
+
AND (pm.membership_kind <> 'pending'
|
|
197
|
+
OR pm.joined_at > now() - make_interval(days => $3::int)))
|
|
198
|
+
AND NOT EXISTS (SELECT 1 FROM platform_identity_application_echoes ae
|
|
199
|
+
WHERE ae.github_id = $1::bigint AND ae.client_id = e.client_id
|
|
200
|
+
AND ae.applied_at > now() - make_interval(days => $4::int))
|
|
201
|
+
ORDER BY e.created_at DESC, g.slug, e.kind
|
|
202
|
+
LIMIT $5`,
|
|
203
|
+
[String(githubId), ENGAGEMENT_WINDOW_DAYS, api.inviteExpiry.INVITE_EXPIRY_DAYS,
|
|
204
|
+
APPLICATION_ECHO_WINDOW_DAYS, LIST_MAX],
|
|
205
|
+
);
|
|
206
|
+
return rows.map(engagementView);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// engagementFor — "may THIS member apply to THIS project WITH this guild?" The
|
|
210
|
+
// join relay's check before it relays anything (ADR 0365 D2): the guild must be
|
|
211
|
+
// one the caller belongs to, with an engagement of either kind for the project
|
|
212
|
+
// that the shared rule lets them see. Returns { id, slug, name } (the id is for
|
|
213
|
+
// the echo, never the wire) or null — left the guild, aged out, or never visible.
|
|
214
|
+
async function engagementFor({ githubId, slug, clientId }, { pool = defaultPool } = {}) {
|
|
215
|
+
if (!isNumericGithubId(githubId) || !slug || !clientId) return null;
|
|
216
|
+
const { rows } = await pool.query(
|
|
217
|
+
`SELECT g.id, g.slug, g.name
|
|
218
|
+
${memberEngagementsSql('$1', '$4')}
|
|
219
|
+
AND lower(g.slug) = $2 AND e.client_id = $3
|
|
220
|
+
LIMIT 1`,
|
|
221
|
+
[String(githubId), slug, String(clientId), ENGAGEMENT_WINDOW_DAYS],
|
|
222
|
+
);
|
|
223
|
+
return rows[0] || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// echoGuildLabel — the guild an applicant's LIVE echo for this project carries,
|
|
227
|
+
// as the reviewer may see it: { slug, name } while the applicant is still a
|
|
228
|
+
// member (slug null for an unlisted guild, which has no public page to open),
|
|
229
|
+
// else null. Read by POST /sso/applicant-profile only AFTER its echo gate, so
|
|
230
|
+
// it says nothing about anyone who did not apply to the calling project. The
|
|
231
|
+
// most recent application decides: a later solo one wrote guild_id NULL.
|
|
232
|
+
async function echoGuildLabel({ githubId, clientId }, { pool = defaultPool } = {}) {
|
|
233
|
+
if (!isNumericGithubId(githubId) || !clientId) return null;
|
|
234
|
+
const { rows } = await pool.query(
|
|
235
|
+
`SELECT g.slug, g.name, g.visibility
|
|
236
|
+
FROM platform_identity_application_echoes ae
|
|
237
|
+
JOIN platform_identity_guilds g ON g.id = ae.guild_id
|
|
238
|
+
JOIN platform_identity_guild_members m ON m.guild_id = ae.guild_id AND m.github_id = ae.github_id
|
|
239
|
+
WHERE ae.github_id = $1::bigint AND ae.client_id = $2
|
|
240
|
+
AND ae.applied_at > now() - make_interval(days => $3::int)
|
|
241
|
+
LIMIT 1`,
|
|
242
|
+
[String(githubId), String(clientId), APPLICATION_ECHO_WINDOW_DAYS],
|
|
243
|
+
);
|
|
244
|
+
const r = rows[0];
|
|
245
|
+
return r ? { slug: r.visibility === 'public' ? r.slug : null, name: r.name } : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
ENGAGEMENT_WINDOW_DAYS,
|
|
250
|
+
fileEngagement,
|
|
251
|
+
inviteGuild,
|
|
252
|
+
listMyEngagements,
|
|
253
|
+
engagementFor,
|
|
254
|
+
echoGuildLabel,
|
|
255
|
+
};
|
|
@@ -80,7 +80,8 @@ const PERSON_COLUMNS = 'pa.handle, pa.display_name, pa.avatar_url';
|
|
|
80
80
|
|
|
81
81
|
// The owner test every owner-only statement re-asserts in its own WHERE, so a
|
|
82
82
|
// route's pre-check is a nicer error and never the only gate. $g/$a are the
|
|
83
|
-
// placeholders holding the guild id and the acting github_id.
|
|
83
|
+
// placeholders holding the guild id and the acting github_id. Exported for
|
|
84
|
+
// guild-engagements.js, whose owner-filed engagement asserts the same test.
|
|
84
85
|
function ownerExistsSql(g, a) {
|
|
85
86
|
return `EXISTS (SELECT 1 FROM platform_identity_guild_members o
|
|
86
87
|
WHERE o.guild_id = ${g} AND o.github_id = ${a}::bigint
|
|
@@ -686,6 +687,7 @@ module.exports = {
|
|
|
686
687
|
DESCRIPTION_MAX_CHARS,
|
|
687
688
|
INVITE_BATCH_MAX,
|
|
688
689
|
LIST_MAX,
|
|
690
|
+
ownerExistsSql,
|
|
689
691
|
normalizeGuildSlug,
|
|
690
692
|
lookupSlug,
|
|
691
693
|
createGuild,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- platform_identity_030_guild_engagements.sql — a guild's engagement with a
|
|
2
|
+
-- project, and the guild an application was made with (task 1002303, R27;
|
|
3
|
+
-- ADR 0336 D7, amended by ADR 0365).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS IS. One row says "this guild is applying to this project" (kind
|
|
6
|
+
-- 'apply', filed by the guild's owner) or "this project invited this guild"
|
|
7
|
+
-- (kind 'invited', written when a project's inviter names a public guild).
|
|
8
|
+
-- Members read it in their own inbox and may apply AS THEMSELVES through the
|
|
9
|
+
-- existing relay; nobody reaches a project without their own act. And the
|
|
10
|
+
-- application echo gains the guild the member applied with, so the project's
|
|
11
|
+
-- reviewer can see the label on the live applicant-profile read.
|
|
12
|
+
--
|
|
13
|
+
-- THE SHAPE CARRIES THE RULES, so no route has to remember them:
|
|
14
|
+
-- * NO FILER COLUMN — for the reason the guild row keeps no creator: the
|
|
15
|
+
-- owner row is the authority, and a permanent "filed by" would tie a person
|
|
16
|
+
-- to an act after they have left the guild.
|
|
17
|
+
-- * NO STATUS COLUMN, AND ADDING ONE WOULD BE THE BUG (ADR 0201 D1.3). The
|
|
18
|
+
-- hub may not hold an admission fact about someone else's project; whether
|
|
19
|
+
-- anyone got in is the project's own access_requests row, and it becomes
|
|
20
|
+
-- visible here only as membership does.
|
|
21
|
+
-- * THE KEY IS (guild, project, kind), so re-filing cannot pile up rows: the
|
|
22
|
+
-- writer's upsert restarts created_at in place.
|
|
23
|
+
-- * THE 30-DAY WINDOW IS READ-TIME, not a column and not a sweep
|
|
24
|
+
-- (ENGAGEMENT_WINDOW_DAYS in ../guild-engagements.js). A stale row is
|
|
25
|
+
-- invisible rather than wrong.
|
|
26
|
+
-- * THE GUILD FK CASCADES, so a dissolved guild takes its engagements with
|
|
27
|
+
-- it (the ADR 0336 D2 no-tombstone reason). The echo's guild_id is SET NULL
|
|
28
|
+
-- instead: the application stands without its label.
|
|
29
|
+
-- * client_id is the registered sso_client, never a caller-supplied origin —
|
|
30
|
+
-- the writer resolves the origin against the registry, as the join relay does.
|
|
31
|
+
--
|
|
32
|
+
-- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit or admission
|
|
33
|
+
-- path reads these rows; an engagement admits nobody anywhere.
|
|
34
|
+
--
|
|
35
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). CREATE and ADD COLUMN
|
|
36
|
+
-- only, so the previous release still runs against this schema. Idempotent.
|
|
37
|
+
|
|
38
|
+
BEGIN;
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guild_engagements (
|
|
41
|
+
guild_id bigint NOT NULL REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
|
|
42
|
+
client_id text NOT NULL,
|
|
43
|
+
kind text NOT NULL CHECK (kind IN ('apply', 'invited')),
|
|
44
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
45
|
+
PRIMARY KEY (guild_id, client_id, kind)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- The project side: the member's join check and the per-project reads.
|
|
49
|
+
CREATE INDEX IF NOT EXISTS platform_identity_guild_engagements_client
|
|
50
|
+
ON platform_identity_guild_engagements (client_id);
|
|
51
|
+
|
|
52
|
+
COMMENT ON TABLE platform_identity_guild_engagements IS
|
|
53
|
+
'A guild''s engagement with a project (ADR 0336 D7, ADR 0365): apply (the guild owner filed "we are applying") or invited (a project''s inviter named this public guild). No filer column, as the guild row keeps no creator. No status column: the hub holds no admission fact about someone else''s project (ADR 0201 D1.3). The 30-day window is applied when read (ENGAGEMENT_WINDOW_DAYS); nothing expires the rows. Descriptive only, never authority (ADR 0016).';
|
|
54
|
+
|
|
55
|
+
COMMENT ON COLUMN platform_identity_guild_engagements.created_at IS
|
|
56
|
+
'When this engagement was most recently filed. Re-filing restarts it in place, so the 30-day read window is measured from the latest act.';
|
|
57
|
+
|
|
58
|
+
ALTER TABLE platform_identity_application_echoes
|
|
59
|
+
ADD COLUMN IF NOT EXISTS guild_id bigint REFERENCES platform_identity_guilds(id) ON DELETE SET NULL;
|
|
60
|
+
|
|
61
|
+
COMMENT ON COLUMN platform_identity_application_echoes.guild_id IS
|
|
62
|
+
'The guild this application was made with (ADR 0365 D1), or NULL for a solo application. Written by the join relay on every application, so the most recent one decides the label: applying again on your own clears it. SET NULL when the guild is dissolved. A label only, never a status.';
|
|
63
|
+
|
|
64
|
+
COMMIT;
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
// POST /guild-requests/:id/accept invitee {visibility (the card's), counted_in_totals?} or owner
|
|
26
26
|
// POST /guild-requests/:id/decline invitee or owner; SILENT 204
|
|
27
27
|
// DELETE /guild-requests/:id whoever opened it: cancel
|
|
28
|
+
// POST /guilds/:slug/engagements owner: "this guild is applying to P" {origin} (R27)
|
|
29
|
+
// GET /me/guild-engagements my inbox: engagements I may act on (R27)
|
|
28
30
|
//
|
|
29
31
|
// A GUILD IS NEVER AUTHORITY (ADR 0016 / ADR 0141). no-store everywhere: these
|
|
30
32
|
// payloads are privacy-filtered per viewer (ADR 0171 D5).
|
|
@@ -36,6 +38,7 @@ const express = require('express');
|
|
|
36
38
|
const api = require('../../../src/module-api');
|
|
37
39
|
const pi = require('../platform-identity');
|
|
38
40
|
const guilds = require('../guilds');
|
|
41
|
+
const engagements = require('../guild-engagements');
|
|
39
42
|
const { isAccountActive } = require('../account-visibility');
|
|
40
43
|
|
|
41
44
|
const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
|
|
@@ -110,7 +113,7 @@ const COUNTED = { type: 'boolean' };
|
|
|
110
113
|
module.exports = function guildRoutes() {
|
|
111
114
|
const router = express.Router();
|
|
112
115
|
|
|
113
|
-
router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/guild-requests'], (req, res, next) => {
|
|
116
|
+
router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/me/guild-engagements', '/guild-requests'], (req, res, next) => {
|
|
114
117
|
res.set('Cache-Control', 'no-store');
|
|
115
118
|
next();
|
|
116
119
|
});
|
|
@@ -389,5 +392,36 @@ module.exports = function guildRoutes() {
|
|
|
389
392
|
res.json({ outcome: 'cancelled' });
|
|
390
393
|
}));
|
|
391
394
|
|
|
395
|
+
// rank: any-builder — owner: file "this guild is applying to P" (ADR 0365 D2,
|
|
396
|
+
// task 1002303). Each member then sees it in their own inbox and applies as
|
|
397
|
+
// themselves; filing applies for nobody, the owner included. P unknown,
|
|
398
|
+
// inactive, or not on the map and Public is ONE 404 — no oracle on a private
|
|
399
|
+
// project. The owner test is re-asserted inside the INSERT. Re-filing restarts
|
|
400
|
+
// the 30-day window.
|
|
401
|
+
router.post('/guilds/:slug/engagements', requireBuilder, handle('POST /guilds/:slug/engagements', 'guild_engagement_failed', async (req, res) => {
|
|
402
|
+
if (validateOrRespond(req, res, { origin: { required: true, type: 'string', maxLength: 512 } })) return;
|
|
403
|
+
const me = await actingAccount(req.builder);
|
|
404
|
+
if (me.fail) return res.fail(...me.fail);
|
|
405
|
+
const o = await ownerOf(me, req.params.slug);
|
|
406
|
+
if (o.fail) return res.fail(...o.fail);
|
|
407
|
+
const engagement = await engagements.fileEngagement({
|
|
408
|
+
guildId: o.guild.id, githubId: me.githubId, origin: req.body.origin,
|
|
409
|
+
}, { pool });
|
|
410
|
+
if (!engagement) return res.fail('unknown_project', 404);
|
|
411
|
+
res.json({ engagement });
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
// rank: any-builder — MY inbox of guild engagements (ADR 0365): "G is applying
|
|
415
|
+
// to P" and "P invited G", for guilds I belong to, inside 30 days, naming only
|
|
416
|
+
// projects on the map and Public, an invite only if I was a member when it was
|
|
417
|
+
// sent, and never a project I already build on, hold an invite to, or applied
|
|
418
|
+
// to. count is the array's own length; client_id never crosses.
|
|
419
|
+
router.get('/me/guild-engagements', requireBuilder, handle('GET /me/guild-engagements', 'guild_engagements_read_failed', async (req, res) => {
|
|
420
|
+
const me = await actingAccount(req.builder);
|
|
421
|
+
if (me.fail) return res.fail(...me.fail);
|
|
422
|
+
const list = await engagements.listMyEngagements({ githubId: me.githubId }, { pool });
|
|
423
|
+
res.json({ engagements: list, count: list.length });
|
|
424
|
+
}));
|
|
425
|
+
|
|
392
426
|
return router;
|
|
393
427
|
};
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// apart, plus `applied`: the projects it has
|
|
6
6
|
// asked to join and when (ADR 0201 D1.3)
|
|
7
7
|
// POST /my-projects/join — apply to a project: one click plus an optional
|
|
8
|
-
// note in the applicant's own words (task 1002330)
|
|
8
|
+
// note in the applicant's own words (task 1002330),
|
|
9
|
+
// and optionally WITH a guild that is engaging the
|
|
10
|
+
// project (`guild`, a slug; task 1002303, ADR 0365).
|
|
9
11
|
// Relayed; the project executes and decides —
|
|
10
12
|
// unless the platform's record says it is unpublished.
|
|
11
13
|
// POST /my-projects/:clientId/leave — self-service offboard (reflected to the hub)
|
|
@@ -25,6 +27,8 @@ const api = require('../../../src/module-api');
|
|
|
25
27
|
const pi = require('../platform-identity');
|
|
26
28
|
const projectStats = require('../project-stats');
|
|
27
29
|
const applicationEcho = require('../application-echo');
|
|
30
|
+
const guilds = require('../guilds');
|
|
31
|
+
const guildEngagements = require('../guild-engagements');
|
|
28
32
|
const hubKeys = require('../hub-keys');
|
|
29
33
|
const { isAccountActive } = require('../account-visibility');
|
|
30
34
|
const { projectPublishability } = require('../publish-gate');
|
|
@@ -219,12 +223,30 @@ module.exports = function myProjectsRoutes() {
|
|
|
219
223
|
if (validateOrRespond(req, res, {
|
|
220
224
|
origin: { required: true, type: 'string', maxLength: 512 },
|
|
221
225
|
note: { type: 'string', maxLength: APPLICANT_NOTE_MAX },
|
|
226
|
+
guild: { type: 'string', maxLength: 39 },
|
|
222
227
|
})) return;
|
|
223
228
|
try {
|
|
224
229
|
const client = await pi.getClientByOrigin(req.body.origin, { pool });
|
|
225
230
|
if (!client || client.status !== 'active' || !client.origin) {
|
|
226
231
|
return res.fail('unknown_project', 404);
|
|
227
232
|
}
|
|
233
|
+
// Applying WITH a guild (ADR 0365 D2). The guild must still resolve FOR THE
|
|
234
|
+
// CALLER — a guild they belong to, engaging this project inside the window,
|
|
235
|
+
// by the same rule their inbox shows it — BEFORE anything is relayed. A guild
|
|
236
|
+
// that no longer does (they left, it aged out, the project stopped being
|
|
237
|
+
// named) is a 409 with nothing relayed and nothing echoed: the applicant
|
|
238
|
+
// chose to apply with that guild, so the hub never files it without one.
|
|
239
|
+
let guildId = null;
|
|
240
|
+
if (req.body.guild != null) {
|
|
241
|
+
const slug = guilds.lookupSlug(req.body.guild);
|
|
242
|
+
const engaged = slug ? await guildEngagements.engagementFor({
|
|
243
|
+
githubId: req.builder.github_id, slug, clientId: client.client_id,
|
|
244
|
+
}, { pool }) : null;
|
|
245
|
+
if (!engaged) {
|
|
246
|
+
return res.fail('guild_engagement_gone', { status: 409, message: 'that guild is no longer engaging this project — apply on your own instead' });
|
|
247
|
+
}
|
|
248
|
+
guildId = engaged.id;
|
|
249
|
+
}
|
|
228
250
|
if (await isDarkMatter(client)) {
|
|
229
251
|
return res.json({ ok: true, status: 'not_published', project: { name: client.name, origin: client.origin },
|
|
230
252
|
message: RELAY_SENTENCE.not_published });
|
|
@@ -256,11 +278,13 @@ module.exports = function myProjectsRoutes() {
|
|
|
256
278
|
// covers the project's own already_pending 200, D1.4), and refreshes the date.
|
|
257
279
|
// Best-effort in both directions: a lost echo must never turn a filed
|
|
258
280
|
// application into a reported failure, which is the only reason this is
|
|
259
|
-
// allowed to swallow its error.
|
|
281
|
+
// allowed to swallow its error. The echo carries the guild this application
|
|
282
|
+
// was made with — NULL when none, so a later solo application clears the
|
|
283
|
+
// label the reviewer sees (ADR 0365 D1).
|
|
260
284
|
if (outcome.status === 'requested') {
|
|
261
285
|
try {
|
|
262
286
|
await applicationEcho.recordApplication(
|
|
263
|
-
{ githubId: req.builder.github_id, clientId: client.client_id }, { pool },
|
|
287
|
+
{ githubId: req.builder.github_id, clientId: client.client_id, guildId }, { pool },
|
|
264
288
|
);
|
|
265
289
|
} catch (e) {
|
|
266
290
|
api.logger('platform-identity').error(`join echo write failed (non-fatal): ${e && e.message}`);
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// POST /projects/invite — a `project.curate` holder OR the project's OWN owner:
|
|
18
18
|
// where to invite — the project's hall (invite-authz.js,
|
|
19
19
|
// invite-notices.js); writes nothing (ADR 0353)
|
|
20
|
+
// POST /projects/invite-guild — the same gate: invite a PUBLIC guild — records the
|
|
21
|
+
// `invited` engagement and answers one hall link per
|
|
22
|
+
// member on its public roster (ADR 0365 D3)
|
|
20
23
|
// PATCH /projects/:id/featured — Archon: the feature / unfeature control
|
|
21
24
|
// PATCH /projects/:id/status — `project.curate`: the TAKEDOWN lever (R08, ADR 0252
|
|
22
25
|
// §5.1). Setting 'hidden' pins the row so the ADR 0246
|
|
@@ -33,6 +36,8 @@ const projectCatalog = require('../project-catalog');
|
|
|
33
36
|
const projectStats = require('../project-stats');
|
|
34
37
|
const inviteAuthz = require('../invite-authz');
|
|
35
38
|
const inviteNotices = require('../invite-notices');
|
|
39
|
+
const guilds = require('../guilds');
|
|
40
|
+
const guildEngagements = require('../guild-engagements');
|
|
36
41
|
|
|
37
42
|
const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet,
|
|
38
43
|
parsePagination, pageMeta } = api;
|
|
@@ -168,6 +173,45 @@ module.exports = function projectsRoutes() {
|
|
|
168
173
|
},
|
|
169
174
|
);
|
|
170
175
|
|
|
176
|
+
// rank: metic+archon — own-or-metic in effect, the SAME gate as POST /projects/invite
|
|
177
|
+
// above and for the same reasons (a non-inviter gets that route's unchanged refusal,
|
|
178
|
+
// identical whether or not the project exists): a project invites a PUBLIC guild
|
|
179
|
+
// (task 1002303, ADR 0365 D3, amending ADR 0336 D7).
|
|
180
|
+
//
|
|
181
|
+
// IT WRITES NO INVITES (ADR 0353). It records the `invited` engagement — so members
|
|
182
|
+
// the inviter could not see get a "P invited this guild" notice and may apply
|
|
183
|
+
// themselves, and only while P is on the map and Public (ADR 0365 D4) — and answers
|
|
184
|
+
// one hall hand-off link per member on the guild's PUBLIC roster at this moment.
|
|
185
|
+
// The inviter sends each from the project's hall, so every invite is the project's
|
|
186
|
+
// own row. Members who join later inherit nothing. An unlisted guild cannot be
|
|
187
|
+
// addressed, so it answers the unknown-guild 404 byte for byte.
|
|
188
|
+
router.post(
|
|
189
|
+
'/projects/invite-guild',
|
|
190
|
+
requireBuilder,
|
|
191
|
+
inviteAuthz.requireProjectInviter({ curateGate: api.requirePermission('project.curate'), pool, log }),
|
|
192
|
+
async (req, res) => {
|
|
193
|
+
if (validateOrRespond(req, res, {
|
|
194
|
+
client_id: { required: true, type: 'string', maxLength: 200 },
|
|
195
|
+
guild: { required: true, type: 'string', maxLength: 39 },
|
|
196
|
+
})) return;
|
|
197
|
+
try {
|
|
198
|
+
const client = req.inviteTargetClient || await pi.getClient(req.body.client_id, { pool });
|
|
199
|
+
if (!client || client.status !== 'active' || !inviteNotices.hallInviteUrl(client.origin)) {
|
|
200
|
+
return res.fail('unknown_project', 404);
|
|
201
|
+
}
|
|
202
|
+
const slug = guilds.lookupSlug(req.body.guild);
|
|
203
|
+
const out = slug ? await guildEngagements.inviteGuild({
|
|
204
|
+
clientId: client.client_id, origin: client.origin, slug,
|
|
205
|
+
}, { pool }) : null;
|
|
206
|
+
if (!out) return res.fail('guild_not_found', 404);
|
|
207
|
+
return res.json({ invited: false, guild: out.guild, invites: out.invites, count: out.invites.length });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.error({ err: err && err.message }, 'POST /projects/invite-guild failed');
|
|
210
|
+
res.fail('invite_guild_failed', { status: 500, message: 'internal error' });
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
171
215
|
// rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate),
|
|
172
216
|
// BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). Featuring ranks among
|
|
173
217
|
// projects already on the map; it is not a way ONTO it, so featuring a dark-matter
|
|
@@ -27,6 +27,8 @@ const hubDevice = require('../hub-device');
|
|
|
27
27
|
const applicantProfile = require('../applicant-profile');
|
|
28
28
|
const terms = require('../terms-acceptance');
|
|
29
29
|
const applicationEcho = require('../application-echo');
|
|
30
|
+
// The guild label the echo carries (task 1002303, ADR 0365 D5).
|
|
31
|
+
const guildEngagements = require('../guild-engagements');
|
|
30
32
|
const instanceFeedback = require('../instance-feedback');
|
|
31
33
|
|
|
32
34
|
// One limiter per hub process for POST /sso/feedback (task 1004462).
|
|
@@ -638,8 +640,11 @@ module.exports = function ssoRoutes() {
|
|
|
638
640
|
// profile view for its own reviewer queue, LIVE at render (privacy spec D7,
|
|
639
641
|
// task 1002972). Server-to-server, client_id+client_secret auth, like the two
|
|
640
642
|
// routes above. Answers { profile: null | { view:'public', … } | { view:
|
|
641
|
-
// 'sliver', … } } — the D7 rule, decided entirely inside
|
|
642
|
-
// ../applicant-profile.js
|
|
643
|
+
// 'sliver', … }, guild } — the D7 rule, decided entirely inside
|
|
644
|
+
// ../applicant-profile.js, plus the guild the applicant applied with
|
|
645
|
+
// (ADR 0365 D5): { slug, name } while they are still a member of it (slug null
|
|
646
|
+
// for an unlisted guild), else null. A project on an older core reads only
|
|
647
|
+
// `profile` and ignores the rest (the ADR 0201 D2.2 compatibility rule).
|
|
643
648
|
//
|
|
644
649
|
// WHY A POST FOR A READ. The same reason /sso/activity/rollup is one: the
|
|
645
650
|
// credentials are in the body, and a client_secret in a query string lands in
|
|
@@ -693,12 +698,25 @@ module.exports = function ssoRoutes() {
|
|
|
693
698
|
);
|
|
694
699
|
if (!applied) return res.fail('no_application', 404);
|
|
695
700
|
const profile = await applicantProfile.getApplicantProfileView(b.github_id, { pool });
|
|
701
|
+
// The guild label rides only beside a profile: an applicant who discloses
|
|
702
|
+
// nothing discloses no guild either. BEST-EFFORT — a label is a convenience
|
|
703
|
+
// on the reviewer's queue, so a failed read answers null and never costs
|
|
704
|
+
// the profile. Read only here, after the echo gate above.
|
|
705
|
+
let guild = null;
|
|
706
|
+
if (profile) {
|
|
707
|
+
try {
|
|
708
|
+
guild = await guildEngagements.echoGuildLabel({ githubId: b.github_id, clientId: client.client_id }, { pool });
|
|
709
|
+
} catch (e) {
|
|
710
|
+
log.error('[platform-identity] /sso/applicant-profile guild label read failed (non-fatal)', e && e.message);
|
|
711
|
+
guild = null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
696
714
|
// The rollup half is live-derived and hide must beat any cache (ADR 0171
|
|
697
715
|
// D5) — and D7's "live at review time" says the same thing louder: a
|
|
698
716
|
// cached applicant view is a snapshot, which is the one thing the rule
|
|
699
717
|
// forbids.
|
|
700
718
|
res.set('Cache-Control', 'no-store');
|
|
701
|
-
res.json({ profile });
|
|
719
|
+
res.json({ profile, guild });
|
|
702
720
|
} catch (err) {
|
|
703
721
|
log.error('[platform-identity] /sso/applicant-profile', err);
|
|
704
722
|
res.fail('applicant_profile_failed', { status: 500, message: 'internal error' });
|
|
@@ -1325,6 +1325,13 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1325
1325
|
.mineJoinRow input::placeholder{color:var(--ink-faint);}
|
|
1326
1326
|
.mineJoinNote{display:block;width:100%;box-sizing:border-box;margin-top:10px;min-height:64px;padding:12px 16px;font:inherit;font-size:14px;line-height:1.5;color:var(--ink);background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:14px;resize:vertical;}
|
|
1327
1327
|
.mineJoinNote::placeholder{color:var(--ink-faint);}
|
|
1328
|
+
/* the guild cards (task 1002303, R27): a second list under the invitations, one
|
|
1329
|
+
hairline between the two when both have rows; each card's note waits behind
|
|
1330
|
+
"Add a note", and its status line takes no room until it has something to say */
|
|
1331
|
+
.mineRows:not(:empty)+.mineRows .mineRow:first-child{border-top:1px solid var(--rule-soft);}
|
|
1332
|
+
.engageRow .mineJoinNote{max-width:62ch;}
|
|
1333
|
+
.engageRow .projMsg{margin-top:6px;min-height:0;}
|
|
1334
|
+
.engageRow .projMsg:empty{margin:0;}
|
|
1328
1335
|
|
|
1329
1336
|
/* ── the ledger row: planet · name and chip · the facts · the rail · the
|
|
1330
1337
|
doors. The planet and the doors are fixed widths; the three middle tracks
|