@bongos/core 1.21.4 → 1.21.6
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 +233 -108
- 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 +3 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -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 +37 -2
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +541 -658
- package/docs/copy-registry.json +1205 -2340
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +47 -28
- package/docs/page-readings.json +1128 -1339
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/governor-docket.js +101 -0
- package/modules/autonomy/routes/autonomy.js +27 -0
- package/modules/autonomy/runner-health.js +22 -1
- 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/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/genesis-home.js +9 -10
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- 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/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- 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 +24 -31
- package/modules/security/routes/reports.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +72 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/module-rechecks.js +77 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/core-update.js +68 -2
- package/src/bongos/routes/modules.js +17 -0
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- 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/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/onboard.mjs +10 -4
- package/tests/platform_boot.mjs +1 -1
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- 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/provision.mjs +78 -0
- package/tests/public_landing_projects.mjs +12 -6
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- 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 +428 -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;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// modules/platform-identity/craft-rollup.js — each craft by its own work, on the
|
|
2
|
+
// hub (task 1004440 / WA6.RP10, goal 1000095, criterion
|
|
3
|
+
// wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// A reporting instance sends, per craft the builder has EARNED credits in, that
|
|
6
|
+
// craft's headline counts (src/bongos/auth-admission.js readCraftRollup):
|
|
7
|
+
//
|
|
8
|
+
// engineer { shipped, credits }
|
|
9
|
+
// artist { pages_approved, credits }
|
|
10
|
+
// ideator { filed, full_ideas, ratified, credits }
|
|
11
|
+
//
|
|
12
|
+
// Two halves live here, next to each other so they cannot disagree on the shape:
|
|
13
|
+
//
|
|
14
|
+
// sanitizeCrafts — what upsertActivity BINDS. The report is third-party
|
|
15
|
+
// self-report (ADR 0171 D7), so the shape and the caps are the hub's, not
|
|
16
|
+
// the sender's: only the three crafts, only the named counts, each a
|
|
17
|
+
// non-negative integer under a ceiling or null. Anything else is dropped.
|
|
18
|
+
// A count is never text, so nothing here can carry a title or wording.
|
|
19
|
+
//
|
|
20
|
+
// sumCrafts — the cross-project aggregate getRollupForBuilder returns. Summed
|
|
21
|
+
// over EVERY project row, named or not, exactly as the totals are: "the
|
|
22
|
+
// aggregate is not the leak, the name is" (ADR 0171 D2). It adds no field a
|
|
23
|
+
// hidden or private account could reach — it rides the same rollup
|
|
24
|
+
// predicate as credits and works shipped (account-visibility.js), and the
|
|
25
|
+
// recruiter sliver is untouched (ADR 0215: a ceiling, not a step).
|
|
26
|
+
//
|
|
27
|
+
// A craft appears in the sum when any project reported it. A count is null in the
|
|
28
|
+
// sum only when no project could report it ("could not ask"), never because one
|
|
29
|
+
// project could not while another could.
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const CRAFT_FIELDS = Object.freeze({
|
|
33
|
+
engineer: Object.freeze(['shipped', 'credits']),
|
|
34
|
+
artist: Object.freeze(['pages_approved', 'credits']),
|
|
35
|
+
ideator: Object.freeze(['filed', 'full_ideas', 'ratified', 'credits']),
|
|
36
|
+
});
|
|
37
|
+
// Far above any honest count, low enough that a sum over every project stays a
|
|
38
|
+
// safe integer and a hostile report cannot dominate a profile by one number.
|
|
39
|
+
const MAX_CRAFT_COUNT = 10_000_000;
|
|
40
|
+
|
|
41
|
+
function cleanCount(v) {
|
|
42
|
+
return Number.isInteger(v) && v >= 0 && v <= MAX_CRAFT_COUNT ? v : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeCrafts(raw) {
|
|
46
|
+
const out = {};
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
48
|
+
for (const [craft, fields] of Object.entries(CRAFT_FIELDS)) {
|
|
49
|
+
const block = raw[craft];
|
|
50
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
|
|
51
|
+
const clean = {};
|
|
52
|
+
for (const f of fields) clean[f] = cleanCount(block[f]);
|
|
53
|
+
out[craft] = clean;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sumCrafts(rows) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
61
|
+
const crafts = sanitizeCrafts(row);
|
|
62
|
+
for (const [craft, block] of Object.entries(crafts)) {
|
|
63
|
+
const acc = out[craft] || (out[craft] = Object.fromEntries(CRAFT_FIELDS[craft].map((f) => [f, null])));
|
|
64
|
+
for (const f of CRAFT_FIELDS[craft]) {
|
|
65
|
+
if (block[f] != null) acc[f] = (acc[f] || 0) + block[f];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { sanitizeCrafts, sumCrafts };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- platform_identity_029_activity_crafts.sql — each craft's own numbers on a
|
|
2
|
+
-- builder's activity row (task 1004440 / WA6.RP10, goal 1000095).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. A rollup carried one ship count and a "Roles" list taken from the declared
|
|
5
|
+
-- preference, so on the hub profile an artist with forty approved pages, or an
|
|
6
|
+
-- ideator whose ideas the board ratified, read as an engineer with nothing
|
|
7
|
+
-- shipped. A reporting instance now sends, for each craft the builder has earned
|
|
8
|
+
-- credits in, that craft's headline counts (an artist's approved pages; an
|
|
9
|
+
-- ideator's filed, Full and ratified ideas; an engineer's shipped works).
|
|
10
|
+
--
|
|
11
|
+
-- WHAT. One jsonb column, the sanitised `crafts` object (craft-rollup.js shapes and
|
|
12
|
+
-- caps it before it is bound — never the raw body). '{}' is "no craft numbers
|
|
13
|
+
-- reported": every row written before this column, and every report from an
|
|
14
|
+
-- instance too old to send them. Counts only, never a title or wording.
|
|
15
|
+
--
|
|
16
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
|
|
17
|
+
|
|
18
|
+
BEGIN;
|
|
19
|
+
|
|
20
|
+
ALTER TABLE platform_identity_builder_project_activity
|
|
21
|
+
ADD COLUMN IF NOT EXISTS crafts jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
22
|
+
|
|
23
|
+
COMMIT;
|
|
@@ -27,6 +27,7 @@ const { urlWithinOrigin } = require('./origin-containment');
|
|
|
27
27
|
// The windowed data plane's append-only snapshot history (task 1002977, community
|
|
28
28
|
// spec D2) — one call from upsertActivity below; the mechanism itself lives there.
|
|
29
29
|
const { recordActivitySnapshot } = require('./activity-snapshots');
|
|
30
|
+
const { sanitizeCrafts, sumCrafts } = require('./craft-rollup');
|
|
30
31
|
|
|
31
32
|
const { pool: defaultPool } = api;
|
|
32
33
|
const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
|
|
@@ -665,7 +666,7 @@ function sanitizeRecentShips(raw, clientOrigin) {
|
|
|
665
666
|
// origin-containment.js:urlWithinOrigin. `asOf` is when the instance read these
|
|
666
667
|
// totals (task 1004268, migration 026): an older report never overwrites a newer
|
|
667
668
|
// one; unstamped (an older instance) keeps last-writer-wins; a future stamp is clamped.
|
|
668
|
-
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
|
|
669
|
+
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], crafts = null, clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
|
|
669
670
|
// api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
|
|
670
671
|
// lazy getter over db-kernel's canonical four-rank ladder).
|
|
671
672
|
const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
|
|
@@ -673,24 +674,26 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
673
674
|
.filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
|
|
674
675
|
.slice(0, MAX_ROLLUP_DISCIPLINES);
|
|
675
676
|
const ships = sanitizeRecentShips(recentShips, clientOrigin);
|
|
677
|
+
// Each craft's own counts (task 1004440), shaped and capped here — see craft-rollup.js.
|
|
678
|
+
const craftCounts = sanitizeCrafts(crafts);
|
|
676
679
|
// One transaction with the snapshot: the upsert's row lock queues racing reports (task 1004244).
|
|
677
680
|
return api.withTx(async (client) => {
|
|
678
681
|
const { rows } = await client.query(
|
|
679
682
|
`INSERT INTO platform_identity_builder_project_activity
|
|
680
|
-
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of)
|
|
683
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of, crafts)
|
|
681
684
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now(),
|
|
682
|
-
CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END)
|
|
685
|
+
CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END, $10::jsonb)
|
|
683
686
|
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
684
687
|
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
685
688
|
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
686
|
-
recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of,
|
|
689
|
+
recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of, crafts = EXCLUDED.crafts,
|
|
687
690
|
last_active = now(), updated_at = now()
|
|
688
691
|
WHERE EXCLUDED.reported_as_of IS NULL OR platform_identity_builder_project_activity.reported_as_of IS NULL
|
|
689
692
|
OR EXCLUDED.reported_as_of >= platform_identity_builder_project_activity.reported_as_of
|
|
690
693
|
RETURNING id, client_id, github_id`,
|
|
691
694
|
// jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
|
|
692
695
|
// Postgres ARRAY literal, which the jsonb column would reject.
|
|
693
|
-
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf],
|
|
696
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf, JSON.stringify(craftCounts)],
|
|
694
697
|
);
|
|
695
698
|
// Windowed data plane (task 1002977) — best-effort, see activity-snapshots.js.
|
|
696
699
|
await recordActivitySnapshot({ clientId, githubId }, { client });
|
|
@@ -804,7 +807,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
804
807
|
);
|
|
805
808
|
const agg = aggRows[0];
|
|
806
809
|
const { rows: projRows } = await pool.query(
|
|
807
|
-
`SELECT p.name, p.origin, a.rank_held, a.recent_ships,
|
|
810
|
+
`SELECT p.name, p.origin, a.rank_held, a.recent_ships, a.crafts,
|
|
808
811
|
(p.id IS NOT NULL AND ${onMapSql('p')} AND p.visibility = 'public') AS is_public
|
|
809
812
|
FROM platform_identity_builder_project_activity a
|
|
810
813
|
LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
|
|
@@ -834,6 +837,9 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
834
837
|
karma: agg.karma,
|
|
835
838
|
},
|
|
836
839
|
disciplines: agg.disciplines || [],
|
|
840
|
+
// Each craft's own numbers, summed over every project — named or not, like
|
|
841
|
+
// the totals above (task 1004440; craft-rollup.js says why that is safe).
|
|
842
|
+
crafts: sumCrafts(projRows.map((r) => r.crafts)),
|
|
837
843
|
last_active: agg.last_active,
|
|
838
844
|
projects: named,
|
|
839
845
|
more_projects: projRows.length - named.length,
|
|
@@ -580,6 +580,9 @@ module.exports = function ssoRoutes() {
|
|
|
580
580
|
// here, like disciplines: the shape is checked and hard-capped in
|
|
581
581
|
// upsertActivity, next to the data, so no route can forget it.
|
|
582
582
|
recent_ships: {},
|
|
583
|
+
// Each craft's own counts (task 1004440): untyped here for the same reason,
|
|
584
|
+
// shaped and capped in upsertActivity (craft-rollup.js sanitizeCrafts).
|
|
585
|
+
crafts: {},
|
|
583
586
|
})) return;
|
|
584
587
|
const b = req.body || {};
|
|
585
588
|
try {
|
|
@@ -617,6 +620,7 @@ module.exports = function ssoRoutes() {
|
|
|
617
620
|
rank: b.rank || null,
|
|
618
621
|
disciplines: Array.isArray(b.disciplines) ? b.disciplines : [],
|
|
619
622
|
recentShips: b.recent_ships,
|
|
623
|
+
crafts: b.crafts,
|
|
620
624
|
// The AUTHENTICATED client's registered origin — the bound every pushed
|
|
621
625
|
// ship link must fall inside (task 1002803). Taken from the client row,
|
|
622
626
|
// never from the body, for the same reason client_id is.
|
|
@@ -602,7 +602,7 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
|
|
|
602
602
|
// an empty list when the sender reported none, never NULL. Its own cap,
|
|
603
603
|
// shape and link bound are covered by tests/hub_recent_ships.mjs. The last param
|
|
604
604
|
// is the report's as-of stamp (task 1004268): null when the sender gave none.
|
|
605
|
-
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]', null]);
|
|
605
|
+
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]', null, '{}']);
|
|
606
606
|
});
|
|
607
607
|
|
|
608
608
|
await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
|