@bongos/core 1.21.4 → 1.21.5
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 +118 -63
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +35 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +389 -518
- package/docs/copy-registry.json +880 -2143
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +17 -25
- package/docs/page-readings.json +1008 -1246
- package/modules/autonomy/governor-docket.js +101 -0
- package/modules/autonomy/routes/autonomy.js +5 -0
- package/modules/discord/board-broadcast.js +3 -1
- package/modules/discord/craft-broadcast.js +107 -0
- package/modules/discord/routes/discord.js +10 -0
- package/modules/discord/ship-broadcast.js +30 -2
- package/modules/economy/reward.js +3 -0
- package/modules/government/board.js +20 -5
- package/modules/government/docket-vocab.js +82 -0
- package/modules/government/docket.js +458 -0
- package/modules/government/routes/government.js +34 -0
- package/modules/hall-ui/public/genesis-home.js +9 -10
- package/modules/ideas/governor-docket.js +93 -0
- package/modules/ideas/routes/blockers.js +5 -0
- package/modules/lifecycle/governor-docket.js +192 -0
- package/modules/lifecycle/routes/gate-approvals.js +78 -33
- package/modules/lifecycle/routes/lifecycle.js +3 -0
- package/modules/onboarding/routes/access-requests.js +68 -27
- package/modules/public-landing/public/assets/cosmos.css +6 -50
- package/modules/public-landing/public/projects.html +1170 -1659
- package/modules/public-landing/public/projects.probes.json +18 -18
- package/modules/public-landing/public/projects.states.json +23 -31
- package/modules/security/routes/reports.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/src/bongos/module-rechecks.js +77 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/core-update.js +41 -0
- package/src/bongos/routes/modules.js +17 -0
- package/src/module-api.js +1 -1
- package/tests/discord_craft_broadcast.mjs +162 -0
- package/tests/genesis_home.mjs +1 -1
- package/tests/government_routes.mjs +3 -1
- package/tests/governor_docket.mjs +552 -0
- package/tests/onboard.mjs +10 -4
- package/tests/platform_boot.mjs +1 -1
- package/tests/projects_hub.mjs +196 -89
- package/tests/projects_hub_app_status.mjs +3 -1
- package/tests/projects_hub_app_step.mjs +72 -48
- package/tests/projects_hub_dns_ready.mjs +10 -2
- package/tests/projects_hub_look.mjs +32 -22
- package/tests/projects_hub_module_picker.mjs +112 -62
- package/tests/projects_hub_pre_uat.mjs +48 -41
- package/tests/public_landing_projects.mjs +12 -6
- package/tests/wizard_demo.mjs +37 -11
- package/tests/wizard_draft_resume.mjs +34 -22
- package/tests/wizard_front_door.mjs +173 -103
- package/tests/wizard_intent_resume.mjs +28 -25
- package/tests/wizard_physics.mjs +94 -76
- package/tests/wizard_physics_more.mjs +409 -277
- package/tests/wizard_six_screens.mjs +126 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/governor-docket.js — lifecycle's sources for the governor's
|
|
4
|
+
// docket (task 1004523, goal 1000125; the collector is
|
|
5
|
+
// modules/government/docket.js).
|
|
6
|
+
//
|
|
7
|
+
// WHY. The Governor City reads one feed of every matter awaiting a governor. Four
|
|
8
|
+
// of those matters live in this module — override requests, removal proposals,
|
|
9
|
+
// criteria awaiting UAT and goal join requests — plus two of the six readings
|
|
10
|
+
// (flow: finished work that is stuck, and promises: done-when criteria kept of
|
|
11
|
+
// total). A module may not require a sibling (ADR 0083), so lifecycle contributes
|
|
12
|
+
// them to the `governor.docket` contribution point instead of government reaching
|
|
13
|
+
// in. Each source reads through the SAME db function its own list route serves,
|
|
14
|
+
// and declares the SAME atom that route requires, so the city can never show a
|
|
15
|
+
// viewer a matter the route would refuse them.
|
|
16
|
+
//
|
|
17
|
+
// Pure mappers (exported, unit-tested) + one registration function called from
|
|
18
|
+
// the lifecycle route factory, the boot hook that runs live and in tests.
|
|
19
|
+
|
|
20
|
+
const api = require('../../src/module-api');
|
|
21
|
+
const db = require('./db');
|
|
22
|
+
const doneWhen = require('./done-when');
|
|
23
|
+
const removalProposals = require('./removal-proposals');
|
|
24
|
+
|
|
25
|
+
const POINT = 'governor.docket';
|
|
26
|
+
const OVERRIDE_LIMIT = 50;
|
|
27
|
+
const STUCK_LIMIT = 100;
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
function overrideMatter(r, mayDecide) {
|
|
31
|
+
return {
|
|
32
|
+
id: r.id,
|
|
33
|
+
rule: 'decide',
|
|
34
|
+
state: mayDecide ? 'needs' : 'moving',
|
|
35
|
+
title: r.task_title ? `Override: ${r.task_title}` : `Override for task ${r.task_id}`,
|
|
36
|
+
short: 'override',
|
|
37
|
+
where: 'release · past a failed check or grade',
|
|
38
|
+
opened_at: r.created_at,
|
|
39
|
+
deadline: null,
|
|
40
|
+
proposer: { id: r.requested_by_builder_id, login: r.requester_login, name: r.requester_display_name },
|
|
41
|
+
tally: null,
|
|
42
|
+
href: `/task/${encodeURIComponent(r.task_id)}`,
|
|
43
|
+
ref: { type: 'override_request', id: r.id },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function removalMatter(r) {
|
|
48
|
+
return {
|
|
49
|
+
id: r.proposal_task_id,
|
|
50
|
+
rule: 'decide',
|
|
51
|
+
state: 'needs',
|
|
52
|
+
title: r.title,
|
|
53
|
+
short: 'removal',
|
|
54
|
+
where: r.goal_title ? `disputes · removal · ${r.goal_title}` : 'disputes · removal',
|
|
55
|
+
opened_at: r.filed_at,
|
|
56
|
+
deadline: null,
|
|
57
|
+
proposer: null,
|
|
58
|
+
tally: null,
|
|
59
|
+
href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
|
|
60
|
+
ref: { type: 'task', id: r.proposal_task_id },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function uatMatter(c) {
|
|
65
|
+
return {
|
|
66
|
+
id: c.id,
|
|
67
|
+
rule: 'signoff',
|
|
68
|
+
state: 'needs',
|
|
69
|
+
title: c.criterion_md,
|
|
70
|
+
short: 'waiting for a tester',
|
|
71
|
+
where: c.goal_title ? `review · ${c.goal_title}` : 'review · a promise to sign off',
|
|
72
|
+
opened_at: null,
|
|
73
|
+
deadline: null,
|
|
74
|
+
proposer: null,
|
|
75
|
+
tally: { for: c.shipped_total, against: 0, open: 0, needs: 'one sign-off by someone who did not build it' },
|
|
76
|
+
href: c.goal_id ? `/goals#/goal/${encodeURIComponent(c.goal_id)}` : '/goals',
|
|
77
|
+
ref: { type: 'criterion', id: c.id },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function joinRequestMatter(r, mine) {
|
|
82
|
+
return {
|
|
83
|
+
id: r.id,
|
|
84
|
+
rule: 'decide',
|
|
85
|
+
state: mine ? 'moving' : 'needs',
|
|
86
|
+
title: mine
|
|
87
|
+
? `Your request to join ${r.goal_title || `goal ${r.goal_id}`}`
|
|
88
|
+
: `${r.display_name || r.github_login || 'Someone'} asks to join ${r.goal_title || `goal ${r.goal_id}`}`,
|
|
89
|
+
short: 'join request',
|
|
90
|
+
where: 'people · goal teams',
|
|
91
|
+
opened_at: r.created_at,
|
|
92
|
+
deadline: null,
|
|
93
|
+
proposer: mine ? null : { id: r.builder_id, login: r.github_login, name: r.display_name },
|
|
94
|
+
tally: null,
|
|
95
|
+
href: r.goal_id ? `/goals#/goal/${encodeURIComponent(r.goal_id)}` : '/goals',
|
|
96
|
+
ref: { type: 'goal_membership_request', id: r.id },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Flow: finished work that has not moved on. `completed` waits for its grade or
|
|
101
|
+
// review; `confirmed` waits to land. Pure over the two task lists.
|
|
102
|
+
function flowReading({ completed = [], confirmed = [] }, now = Date.now()) {
|
|
103
|
+
const stuck = [...completed, ...confirmed];
|
|
104
|
+
const n = stuck.length;
|
|
105
|
+
if (!n) return { value: 0, figure: 'all moving', line: 'no finished work is waiting', hot: false };
|
|
106
|
+
const ages = stuck.map((t) => now - Date.parse(t.updated_at || t.created_at)).filter(Number.isFinite);
|
|
107
|
+
const longest = ages.length ? Math.max(...ages) : 0;
|
|
108
|
+
const days = Math.floor(longest / DAY_MS);
|
|
109
|
+
const where = completed.length >= confirmed.length ? 'waiting for review' : 'waiting to go live';
|
|
110
|
+
return {
|
|
111
|
+
value: n,
|
|
112
|
+
figure: `${n} stuck`,
|
|
113
|
+
line: `longest wait ${days} ${days === 1 ? 'day' : 'days'}, ${where}`,
|
|
114
|
+
hot: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Promises: the building version's done-when criteria, kept of total. Pure.
|
|
119
|
+
function promisesReading(criteria) {
|
|
120
|
+
const total = criteria.length;
|
|
121
|
+
if (!total) return { value: null, figure: 'no promises yet', line: 'the version building now has no done-when criteria', hot: false };
|
|
122
|
+
const kept = criteria.filter((c) => c.satisfied === true).length;
|
|
123
|
+
return { value: { kept, total }, figure: `${kept} of ${total} kept`, line: 'what the goals say they deliver', hot: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function contributeLifecycleToDocket() {
|
|
127
|
+
api.contribute(POINT, {
|
|
128
|
+
source: 'override_requests',
|
|
129
|
+
building: 'release',
|
|
130
|
+
gate: { permission: 'override_request.decide' },
|
|
131
|
+
read: async (ctx) => (await db.listOverrideRequests({ status: 'open', limit: OVERRIDE_LIMIT }))
|
|
132
|
+
.map((r) => overrideMatter(r, ctx.has('override_request.decide'))),
|
|
133
|
+
});
|
|
134
|
+
api.contribute(POINT, {
|
|
135
|
+
source: 'removal_proposals',
|
|
136
|
+
building: 'disputes',
|
|
137
|
+
gate: { permission: 'task.promote' },
|
|
138
|
+
read: async () => (await removalProposals.listPending({ limit: removalProposals.MAX_PROPOSAL_ROWS })).map(removalMatter),
|
|
139
|
+
});
|
|
140
|
+
api.contribute(POINT, {
|
|
141
|
+
source: 'criteria_uat',
|
|
142
|
+
building: 'review',
|
|
143
|
+
gate: { permission: 'criterion.review' },
|
|
144
|
+
read: async () => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true).map(uatMatter),
|
|
145
|
+
});
|
|
146
|
+
// Own-scoped by its read, like GET /goals/inbox: a goal's lead sees the requests
|
|
147
|
+
// to join their goals, and everyone sees their own pending ones. An Archon is
|
|
148
|
+
// deliberately NOT shown every goal's requests (listInboxFor's rule).
|
|
149
|
+
api.contribute(POINT, {
|
|
150
|
+
source: 'goal_join_requests',
|
|
151
|
+
building: 'people',
|
|
152
|
+
gate: { anyBuilder: true },
|
|
153
|
+
read: async (ctx) => {
|
|
154
|
+
const inbox = await db.listInboxFor(ctx.builderId);
|
|
155
|
+
return [
|
|
156
|
+
...(inbox.joinRequests || []).map((r) => joinRequestMatter(r, false)),
|
|
157
|
+
...(inbox.myPendingRequests || []).map((r) => joinRequestMatter(r, true)),
|
|
158
|
+
];
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
// Readings. Flow reads the task board, which any builder may read (GET /tasks).
|
|
162
|
+
api.contribute(POINT, {
|
|
163
|
+
reading: 'flow',
|
|
164
|
+
gate: { anyBuilder: true },
|
|
165
|
+
read: async (_ctx, { now } = {}) => {
|
|
166
|
+
const [completed, confirmed] = await Promise.all([
|
|
167
|
+
db.listTasks({ status: 'completed', limit: STUCK_LIMIT }),
|
|
168
|
+
db.listTasks({ status: 'confirmed', limit: STUCK_LIMIT }),
|
|
169
|
+
]);
|
|
170
|
+
return flowReading({ completed, confirmed }, now);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
api.contribute(POINT, {
|
|
174
|
+
reading: 'promises',
|
|
175
|
+
gate: { anyBuilder: true },
|
|
176
|
+
read: async () => {
|
|
177
|
+
const versionId = await db.currentBuildingVersionId();
|
|
178
|
+
if (!versionId) return { value: null, figure: 'no version building', line: 'nothing is promised right now', hot: false };
|
|
179
|
+
return promisesReading(await doneWhen.listCriteria(versionId));
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
contributeLifecycleToDocket,
|
|
186
|
+
overrideMatter,
|
|
187
|
+
removalMatter,
|
|
188
|
+
uatMatter,
|
|
189
|
+
joinRequestMatter,
|
|
190
|
+
flowReading,
|
|
191
|
+
promisesReading,
|
|
192
|
+
};
|
|
@@ -65,7 +65,84 @@ function summarizeGate(files) {
|
|
|
65
65
|
return { verdict: r.verdict, reasons, surfaces: r.surfaces || [] };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// The awaiting list itself: open, non-draft PRs whose gate-review check is
|
|
69
|
+
// COMPLETED and red, each with its verdict and reasons. Shared by GET
|
|
70
|
+
// /gate-approvals and the governor's docket (task 1004523), so the two can never
|
|
71
|
+
// disagree about what is waiting. `withFiles: false` skips the per-PR file read
|
|
72
|
+
// (the docket needs only the count and the titles, not the per-file reasons).
|
|
73
|
+
async function listAwaitingGateApprovals({ withFiles = true } = {}) {
|
|
74
|
+
const prs = await githubPush.listOpenPullRequests({});
|
|
75
|
+
const awaiting = [];
|
|
76
|
+
let checking = 0;
|
|
77
|
+
for (const pr of prs) {
|
|
78
|
+
if (pr.is_draft) continue;
|
|
79
|
+
const gate = findCheck(pr, 'gate-review');
|
|
80
|
+
if (!gate) continue; // not a gate-reviewed PR (or it hasn't run yet)
|
|
81
|
+
if (String(gate.status || '').toUpperCase() !== 'COMPLETED') { checking += 1; continue; }
|
|
82
|
+
if (!isFailure(gate)) continue; // green/neutral → not awaiting
|
|
83
|
+
// gate-review is red → blocked awaiting trust/owner approval. Render WHY.
|
|
84
|
+
let summary = { verdict: 'unknown', reasons: [], surfaces: [], files: [] };
|
|
85
|
+
if (withFiles) {
|
|
86
|
+
try {
|
|
87
|
+
const files = await githubPush.getPullRequestFiles({ number: pr.number });
|
|
88
|
+
summary = summarizeGate(files);
|
|
89
|
+
summary.files = files.map((f) => ({ path: f.path, status: f.status }));
|
|
90
|
+
} catch (_) {
|
|
91
|
+
summary.files = [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
awaiting.push({
|
|
95
|
+
number: pr.number,
|
|
96
|
+
title: pr.title,
|
|
97
|
+
url: pr.url,
|
|
98
|
+
task: taskIdFromPr(pr),
|
|
99
|
+
author: pr.author,
|
|
100
|
+
head_sha: pr.head_sha,
|
|
101
|
+
verdict: summary.verdict,
|
|
102
|
+
reasons: summary.reasons,
|
|
103
|
+
files: summary.files,
|
|
104
|
+
author_trusted: isSuccess(findCheck(pr, 'gate-author-trust')),
|
|
105
|
+
already_approved: isSuccess(findCheck(pr, githubPush.GATE_OWNER_APPROVED_CONTEXT)),
|
|
106
|
+
gate_review_url: gate.url || null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return { prs, awaiting, checking };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The governor's docket (task 1004523): a PR held at the gate stands at Release,
|
|
113
|
+
// shown only to a viewer holding `gate_approval.read` (this list route's own atom)
|
|
114
|
+
// and needing only a viewer who may clear it (`gate_approval.decide`). An
|
|
115
|
+
// unconfigured server credential is a read error, so the docket reports the
|
|
116
|
+
// source `unavailable` rather than an empty, falsely calm Release.
|
|
117
|
+
function contributeGateApprovalsToDocket() {
|
|
118
|
+
api.contribute('governor.docket', {
|
|
119
|
+
source: 'gate_approvals',
|
|
120
|
+
building: 'release',
|
|
121
|
+
gate: { permission: 'gate_approval.read' },
|
|
122
|
+
read: async (ctx) => {
|
|
123
|
+
if (!githubPush.isConfigured()) throw new Error('publish_unconfigured');
|
|
124
|
+
const { awaiting } = await listAwaitingGateApprovals({ withFiles: false });
|
|
125
|
+
const mayDecide = ctx.has('gate_approval.decide');
|
|
126
|
+
return awaiting.filter((pr) => !pr.already_approved).map((pr) => ({
|
|
127
|
+
id: pr.number,
|
|
128
|
+
rule: 'decide',
|
|
129
|
+
state: mayDecide ? 'needs' : 'moving',
|
|
130
|
+
title: pr.title || `Pull request ${pr.number}`,
|
|
131
|
+
short: 'held at the gate',
|
|
132
|
+
where: 'release · held at the gate',
|
|
133
|
+
opened_at: null,
|
|
134
|
+
deadline: null,
|
|
135
|
+
proposer: pr.author ? { login: pr.author } : null,
|
|
136
|
+
tally: null,
|
|
137
|
+
href: '/gate',
|
|
138
|
+
ref: { type: 'pull_request', id: pr.number },
|
|
139
|
+
}));
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
68
144
|
module.exports = function buildGateApprovalsRouter() {
|
|
145
|
+
contributeGateApprovalsToDocket();
|
|
69
146
|
const router = express.Router();
|
|
70
147
|
|
|
71
148
|
// GET /gate-approvals — open PRs awaiting owner approval (gate-review red), with the
|
|
@@ -81,39 +158,7 @@ module.exports = function buildGateApprovalsRouter() {
|
|
|
81
158
|
return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot read pull requests.' });
|
|
82
159
|
}
|
|
83
160
|
try {
|
|
84
|
-
const prs = await
|
|
85
|
-
const awaiting = [];
|
|
86
|
-
let checking = 0;
|
|
87
|
-
for (const pr of prs) {
|
|
88
|
-
if (pr.is_draft) continue;
|
|
89
|
-
const gate = findCheck(pr, 'gate-review');
|
|
90
|
-
if (!gate) continue; // not a gate-reviewed PR (or it hasn't run yet)
|
|
91
|
-
if (String(gate.status || '').toUpperCase() !== 'COMPLETED') { checking += 1; continue; }
|
|
92
|
-
if (!isFailure(gate)) continue; // green/neutral → not awaiting
|
|
93
|
-
// gate-review is red → blocked awaiting trust/owner approval. Render WHY.
|
|
94
|
-
let summary = { verdict: 'unknown', reasons: [], surfaces: [] };
|
|
95
|
-
try {
|
|
96
|
-
const files = await githubPush.getPullRequestFiles({ number: pr.number });
|
|
97
|
-
summary = summarizeGate(files);
|
|
98
|
-
summary.files = files.map((f) => ({ path: f.path, status: f.status }));
|
|
99
|
-
} catch (_) {
|
|
100
|
-
summary.files = [];
|
|
101
|
-
}
|
|
102
|
-
awaiting.push({
|
|
103
|
-
number: pr.number,
|
|
104
|
-
title: pr.title,
|
|
105
|
-
url: pr.url,
|
|
106
|
-
task: taskIdFromPr(pr),
|
|
107
|
-
author: pr.author,
|
|
108
|
-
head_sha: pr.head_sha,
|
|
109
|
-
verdict: summary.verdict,
|
|
110
|
-
reasons: summary.reasons,
|
|
111
|
-
files: summary.files,
|
|
112
|
-
author_trusted: isSuccess(findCheck(pr, 'gate-author-trust')),
|
|
113
|
-
already_approved: isSuccess(findCheck(pr, githubPush.GATE_OWNER_APPROVED_CONTEXT)),
|
|
114
|
-
gate_review_url: gate.url || null,
|
|
115
|
-
});
|
|
116
|
-
}
|
|
161
|
+
const { prs, awaiting, checking } = await listAwaitingGateApprovals();
|
|
117
162
|
// The PR check rollup is unreadable when the server credential lacks `Checks: read`
|
|
118
163
|
// (operator blocker): listOpenPullRequests then returns partialErrors and every PR's
|
|
119
164
|
// `checks` is empty, so the awaiting list is silently empty. Surface that so the hall
|
|
@@ -31,6 +31,8 @@ const cascade = require('../cascade');
|
|
|
31
31
|
const removalDerivation = require('../removal-derivation');
|
|
32
32
|
const removalTargets = require('../removal-targets');
|
|
33
33
|
const shipFiles = require('../ship-files');
|
|
34
|
+
// task 1004523: lifecycle's sources + readings for the governor's docket.
|
|
35
|
+
const governorDocket = require('../governor-docket');
|
|
34
36
|
|
|
35
37
|
// task 1003549 — the cross-role cascade listener. `task.shipped` already exists
|
|
36
38
|
// as a kernel event (emitted post-commit by the publish reconciler and the ship
|
|
@@ -136,6 +138,7 @@ function registerSeams() {
|
|
|
136
138
|
|
|
137
139
|
module.exports = function buildLifecycleRouter() {
|
|
138
140
|
registerSeams();
|
|
141
|
+
governorDocket.contributeLifecycleToDocket();
|
|
139
142
|
// No HTTP surface — the lifecycle capability is a pure kernel port. The empty
|
|
140
143
|
// router is only the loader's contributes.routes mount vehicle; returning a
|
|
141
144
|
// valid express.Router() keeps mountModuleRoutes' `router.use(sub)` happy
|
|
@@ -187,7 +187,72 @@ async function clearHubNoticeIfUninvited(login) {
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// The access-request queue at one status, shaped as GET /access-requests serves
|
|
191
|
+
// it (before the hub profiles are attached). One read, two callers: the route
|
|
192
|
+
// and the governor's docket (task 1004523), so the two cannot disagree about
|
|
193
|
+
// who is waiting to join.
|
|
194
|
+
async function listAccessRequests(status) {
|
|
195
|
+
// `kind` + `invited_by_login` let the invited history tell an invite from
|
|
196
|
+
// an approved application (ADR 0335 D2) — descriptive, like the vouch.
|
|
197
|
+
// `expired` + `expires_at` mark an invite whose 30 days are up (task 1002969):
|
|
198
|
+
// still 'invited' in storage, but its door no longer opens.
|
|
199
|
+
const { rows } = await pool.query(
|
|
200
|
+
`SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
|
|
201
|
+
ar.created_at, ar.resolved_at, ar.kind,
|
|
202
|
+
(ar.status = 'invited' AND ${inviteExpiredSql('ar')}) AS expired,
|
|
203
|
+
${inviteExpiresAtSql('ar')} AS expires_at,
|
|
204
|
+
ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
|
|
205
|
+
(ar.applicant_github_id IS NOT NULL) AS vouched,
|
|
206
|
+
rb.github_login AS resolved_by_login,
|
|
207
|
+
ib.github_login AS invited_by_login
|
|
208
|
+
FROM access_requests ar
|
|
209
|
+
LEFT JOIN builders rb ON rb.id = ar.resolved_by_builder_id
|
|
210
|
+
LEFT JOIN builders ib ON ib.id = ar.created_by
|
|
211
|
+
WHERE ar.status = $1
|
|
212
|
+
ORDER BY ar.created_at DESC
|
|
213
|
+
LIMIT 200`,
|
|
214
|
+
[status]
|
|
215
|
+
);
|
|
216
|
+
// On an invite row the stored note is the INVITER's words, but this read's
|
|
217
|
+
// `note` has always meant the applicant's (the hall quotes it as theirs),
|
|
218
|
+
// so an invite's note travels as `invite_note` instead.
|
|
219
|
+
return rows.map(({ note, ...r }) => (r.kind === 'invite'
|
|
220
|
+
? { ...r, note: null, invite_note: note }
|
|
221
|
+
: { ...r, note, invite_note: null }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// One pending request as a docket matter. Pure; exported for tests.
|
|
225
|
+
function accessRequestDocketMatter(r) {
|
|
226
|
+
return {
|
|
227
|
+
id: r.id,
|
|
228
|
+
rule: 'decide',
|
|
229
|
+
state: 'needs',
|
|
230
|
+
title: `Admit ${r.display_name || r.github_login} as a builder`,
|
|
231
|
+
short: `admit ${r.github_login}`,
|
|
232
|
+
where: r.vouched ? 'people · waiting to join · vouched' : 'people · waiting to join',
|
|
233
|
+
opened_at: r.created_at,
|
|
234
|
+
deadline: null,
|
|
235
|
+
proposer: { login: r.github_login, name: r.display_name },
|
|
236
|
+
tally: null,
|
|
237
|
+
href: '/watch',
|
|
238
|
+
ref: { type: 'access_request', id: r.id },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The governor's docket: a pending request to join stands at People, shown only
|
|
243
|
+
// to a viewer holding `access_request.review` (this queue's own atom, because it
|
|
244
|
+
// exposes would-be builders' usernames) and needing that same viewer's act.
|
|
245
|
+
function contributeAccessRequestsToDocket() {
|
|
246
|
+
api.contribute('governor.docket', {
|
|
247
|
+
source: 'access_requests',
|
|
248
|
+
building: 'people',
|
|
249
|
+
gate: { permission: 'access_request.review' },
|
|
250
|
+
read: async () => (await listAccessRequests('pending')).map(accessRequestDocketMatter),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
190
254
|
module.exports = function buildAccessRequestsRouter() {
|
|
255
|
+
contributeAccessRequestsToDocket();
|
|
191
256
|
const router = express.Router();
|
|
192
257
|
|
|
193
258
|
// POST /access-requests — PUBLIC. A would-be builder asks to be admitted.
|
|
@@ -339,33 +404,7 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
339
404
|
return res.fail('bad_status', 400);
|
|
340
405
|
}
|
|
341
406
|
try {
|
|
342
|
-
|
|
343
|
-
// an approved application (ADR 0335 D2) — descriptive, like the vouch.
|
|
344
|
-
// `expired` + `expires_at` mark an invite whose 30 days are up (task 1002969):
|
|
345
|
-
// still 'invited' in storage, but its door no longer opens.
|
|
346
|
-
const { rows } = await pool.query(
|
|
347
|
-
`SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
|
|
348
|
-
ar.created_at, ar.resolved_at, ar.kind,
|
|
349
|
-
(ar.status = 'invited' AND ${inviteExpiredSql('ar')}) AS expired,
|
|
350
|
-
${inviteExpiresAtSql('ar')} AS expires_at,
|
|
351
|
-
ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
|
|
352
|
-
(ar.applicant_github_id IS NOT NULL) AS vouched,
|
|
353
|
-
rb.github_login AS resolved_by_login,
|
|
354
|
-
ib.github_login AS invited_by_login
|
|
355
|
-
FROM access_requests ar
|
|
356
|
-
LEFT JOIN builders rb ON rb.id = ar.resolved_by_builder_id
|
|
357
|
-
LEFT JOIN builders ib ON ib.id = ar.created_by
|
|
358
|
-
WHERE ar.status = $1
|
|
359
|
-
ORDER BY ar.created_at DESC
|
|
360
|
-
LIMIT 200`,
|
|
361
|
-
[status]
|
|
362
|
-
);
|
|
363
|
-
// On an invite row the stored note is the INVITER's words, but this read's
|
|
364
|
-
// `note` has always meant the applicant's (the hall quotes it as theirs),
|
|
365
|
-
// so an invite's note travels as `invite_note` instead.
|
|
366
|
-
const shaped = rows.map(({ note, ...r }) => (r.kind === 'invite'
|
|
367
|
-
? { ...r, note: null, invite_note: note }
|
|
368
|
-
: { ...r, note, invite_note: null }));
|
|
407
|
+
const shaped = await listAccessRequests(status);
|
|
369
408
|
// C4's other half (privacy spec D7, task 1002972): the applicant's own
|
|
370
409
|
// profile, as their OWN privacy rules would show it, resolved from the hub
|
|
371
410
|
// at render. See attachApplicantProfiles — it is keyed on the vouch, never
|
|
@@ -610,3 +649,5 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
610
649
|
|
|
611
650
|
return router;
|
|
612
651
|
};
|
|
652
|
+
|
|
653
|
+
module.exports.accessRequestDocketMatter = accessRequestDocketMatter;
|
|
@@ -1232,27 +1232,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1232
1232
|
.filters button:not([aria-pressed="true"]) .n{color:var(--ink-faint);}
|
|
1233
1233
|
.mineEmpty{font-size:14.5px;font-weight:600;color:var(--ink-soft);padding:10px 0 22px;}
|
|
1234
1234
|
|
|
1235
|
-
|
|
1236
|
-
.steps{display:flex;align-items:flex-start;margin-bottom:26px;}
|
|
1237
|
-
.steps li{flex:1;display:flex;flex-direction:column;align-items:center;gap:8px;position:relative;list-style:none;}
|
|
1238
|
-
.steps li:not(:first-child)::before{content:"";position:absolute;top:15px;right:50%;width:100%;height:3px;
|
|
1239
|
-
background:var(--line);margin-right:15px;border-radius:2px;}
|
|
1240
|
-
/* the rail's marks are NON-TEXT at WCAG 1.4.11's 3:1, and the chrome world's
|
|
1241
|
-
--accent measures 2.10:1 on the card — so the ring and the connector take
|
|
1242
|
-
--accent-deep (5.77:1 on the card, and the derived bright twin in dark). The
|
|
1243
|
-
done step's FILL stays --accent with --accent-on ink on it (task 1003298). */
|
|
1244
|
-
.steps li.done:not(:first-child)::before{background:var(--accent-deep);}
|
|
1245
|
-
.steps .snum{width:32px;height:32px;border-radius:50%;border:3px solid var(--line);background:var(--bg-card);
|
|
1246
|
-
display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-weight:700;
|
|
1247
|
-
font-size:14px;color:var(--ink-faint);position:relative;z-index:1;}
|
|
1248
|
-
.steps li.done .snum{border-color:var(--accent-deep);background:var(--accent);color:var(--accent-on);}
|
|
1249
|
-
.steps li.now .snum{border-color:var(--accent-deep);color:var(--accent-deep);}
|
|
1250
|
-
.steps .slbl{font-size:12.5px;font-weight:700;color:var(--ink-faint);}
|
|
1251
|
-
.steps li.now .slbl,.steps li.done .slbl{color:var(--ink-soft);}
|
|
1252
|
-
.steps li[role=button]{cursor:pointer;}
|
|
1253
|
-
.steps li[role=button]:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:8px;}
|
|
1254
|
-
.createSplit{display:grid;grid-template-columns:1fr 300px;gap:var(--pad-md);align-items:center;}
|
|
1255
|
-
@media (max-width:840px){.createSplit{grid-template-columns:1fr;}.createSplit .bongoCell{display:none;}}
|
|
1235
|
+
.createSplit{display:block;}
|
|
1256
1236
|
|
|
1257
1237
|
/* ── the honest empty / not-available-here state. A child instance does not
|
|
1258
1238
|
provision, so it must SAY so rather than fall into the generic alert. ── */
|
|
@@ -1600,28 +1580,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1600
1580
|
═══════════════════════════════════════════════════════════════════════ */
|
|
1601
1581
|
#view-new .wrap{max-width:1180px;}
|
|
1602
1582
|
#view-new .card{background:none;border-radius:0;box-shadow:none;padding:34px 0 60px;margin:0;}
|
|
1603
|
-
/* the
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
#view-new .
|
|
1607
|
-
#view-new .steps li{display:block;position:relative;border-top:1px solid var(--rule);padding:12px 0 0;gap:0;}
|
|
1608
|
-
#view-new .steps li::before{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;margin:0;border-radius:50%;border:1px solid var(--rule);background:var(--bg);}
|
|
1609
|
-
#view-new .steps .snum{display:block;width:auto;height:auto;border:0;border-radius:0;background:none;font-family:var(--font-display);font-weight:300;font-size:22px;letter-spacing:-.02em;line-height:1.1;color:var(--ink-faint);margin-bottom:2px;}
|
|
1610
|
-
#view-new .steps .slbl{display:block;font-family:var(--label-face);font-size:var(--label-size);font-weight:400;letter-spacing:var(--label-space);text-transform:uppercase;line-height:1.3;color:var(--ink-faint);}
|
|
1611
|
-
#view-new .steps li.done{border-top-color:var(--ink-soft);}
|
|
1612
|
-
#view-new .steps li.done::before{background:var(--ink-soft);border-color:var(--ink-soft);}
|
|
1613
|
-
#view-new .steps li.done .snum,#view-new .steps li.done .slbl{color:var(--ink-soft);background:none;}
|
|
1614
|
-
#view-new .steps li.now{border-top-color:var(--accent);}
|
|
1615
|
-
#view-new .steps li.now::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
|
|
1616
|
-
#view-new .steps li.now .snum,#view-new .steps li.now .slbl{color:var(--ink);}
|
|
1617
|
-
#view-new .steps li[role=button]:hover .slbl{color:var(--ink);}
|
|
1618
|
-
/* the step: the title lowercase in the display voice, one clause, the fine
|
|
1619
|
-
print in the body face; the bongo pair beside it (the owner's one mark on
|
|
1620
|
-
every surface), its halo gone */
|
|
1621
|
-
#view-new .createSplit{align-items:start;gap:48px;}
|
|
1622
|
-
#view-new .bongoCell{padding-top:60px;}
|
|
1623
|
-
#view-new .bongos .glow{display:none;}
|
|
1624
|
-
#view-new .panel h1{font-weight:300;font-size:clamp(28px,3.4vw,44px);letter-spacing:-.035em;line-height:1.04;text-transform:lowercase;color:var(--ink);text-shadow:none;margin:0 0 10px;max-width:18ch;text-wrap:balance;}
|
|
1583
|
+
/* the screen: the title in the display voice at the design of record's light weight and
|
|
1584
|
+
sentence case (task 1004504), one clause, the fine print in the body face — over the
|
|
1585
|
+
night room the page draws behind it */
|
|
1586
|
+
#view-new .panel h1{font-weight:200;font-size:clamp(32px,3.8vw,52px);letter-spacing:-.035em;line-height:1.05;text-transform:none;color:var(--ink);text-shadow:none;margin:0 0 10px;max-width:18ch;text-wrap:balance;}
|
|
1625
1587
|
#view-new .panel .lede{font-size:15.5px;font-weight:400;line-height:1.5;color:var(--ink-soft);margin:0 0 26px;max-width:62ch;}
|
|
1626
1588
|
#view-new .panel .lede strong,#view-new .panel .lede em{color:var(--ink);font-weight:500;font-style:normal;}
|
|
1627
1589
|
/* the done clause's link to Your projects clears the 24px hit floor as a box (an inline's rect is its content box) */
|
|
@@ -1640,7 +1602,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1640
1602
|
#view-new .linklike:hover{color:var(--ink);}
|
|
1641
1603
|
/* the doors: pills, lowercase, the accent only on the one that acts; back and
|
|
1642
1604
|
skip as hairline pills; the GitHub sign-in inverted; no lift, no shadow */
|
|
1643
|
-
#view-new .btn{min-height:44px;padding:0 22px;font-family:var(--font-body);font-weight:500;font-size:15px;text-transform:
|
|
1605
|
+
#view-new .btn{min-height:44px;padding:0 22px;font-family:var(--font-body);font-weight:500;font-size:15px;text-transform:none;white-space:nowrap;box-shadow:none;transition:border-color .15s ease,background .15s ease,color .15s ease;}
|
|
1644
1606
|
#view-new .btn:hover{transform:none;box-shadow:none;background:color-mix(in srgb,var(--accent) 88%,var(--ink));}
|
|
1645
1607
|
#view-new .btn:active{transform:none;}
|
|
1646
1608
|
#view-new .btn:disabled{background:none;border:1px solid var(--rule-soft);color:var(--ink-faint);}
|
|
@@ -1659,9 +1621,3 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1659
1621
|
#view-new .inviteBox{margin-top:34px;}
|
|
1660
1622
|
#view-new .inviteBox .finePrint{margin-top:10px;}
|
|
1661
1623
|
#view-new #wizInviteLater{margin-top:14px;}
|
|
1662
|
-
/* one fold for the whole wizard: 840 is where the page's bands and ledger stack
|
|
1663
|
-
(cosmos's createSplit collapse), so the rail folds with them, not before them */
|
|
1664
|
-
@media (max-width:840px){
|
|
1665
|
-
#view-new .steps{grid-template-columns:repeat(3,1fr);gap:18px 10px;margin-bottom:30px;}
|
|
1666
|
-
#view-new .steps .snum{font-size:18px;}
|
|
1667
|
-
}
|