@bongos/core 1.21.113 → 1.21.114
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 +603 -183
- package/.claude/skills/ask-for-help/SKILL.md +1 -1
- package/.claude/skills/bongos-feedback/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -0
- package/GOVERNMENT.md +128 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +28 -0
- package/clients/bongos-client/index.cjs +28 -0
- package/clients/bongos-client/index.d.ts +45 -2
- package/clients/bongos-client/index.mjs +28 -0
- package/docs/adr/0372-an-ask-is-stored-per-docket-matter-and-the-server-refuses-slop.md +33 -0
- package/docs/adr/0373-an-ai-session-files-a-blocker-with-an-ask.md +31 -0
- package/docs/adr/0374-a-building-lead-is-a-held-position-appointed-by-a-people-matter.md +30 -0
- package/docs/adr/0375-who-may-build-without-a-vote-comes-from-the-form-of-government.md +38 -0
- package/docs/adr/0376-every-ask-is-routed-to-a-real-seat-on-the-server.md +30 -0
- package/docs/adr/0377-governance-is-a-held-position-read-on-me.md +24 -0
- package/docs/adr/0378-a-leads-build-outs-are-rows-built-at-once-or-by-a-passed-vote.md +33 -0
- package/docs/adr/0379-the-treasury-is-pots-per-goal-a-reserve-and-borrowing-over-real-spend.md +32 -0
- package/docs/adr/0380-a-strictness-preset-is-a-bundle-of-laws-switched-by-one-laws-vote.md +26 -0
- package/docs/adr/README.md +10 -1
- package/docs/api/openapi.json +989 -87
- package/docs/api-reference.md +18 -4
- package/docs/copy-inventory.md +343 -262
- package/docs/copy-registry.json +1223 -426
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/packs/engineer.md +2 -1
- package/docs/page-inventory.json +91 -6
- package/docs/page-readings.json +5017 -4458
- package/modules/builder-settings/law-locks.js +202 -0
- package/modules/builder-settings/routes/settings.js +66 -6
- package/modules/builder-settings/settings.js +7 -0
- package/modules/discord/ship-broadcast.js +22 -13
- package/modules/government/asks-db.js +54 -0
- package/modules/government/asks.js +269 -0
- package/modules/government/board-idea-reads.js +68 -0
- package/modules/government/board.js +21 -57
- package/modules/government/build-rights.js +148 -0
- package/modules/government/buildouts.js +160 -0
- package/modules/government/counts.js +81 -0
- package/modules/government/db.js +242 -0
- package/modules/government/decision-board.js +234 -8
- package/modules/government/decision-rules.js +2 -0
- package/modules/government/docket.js +114 -6
- package/modules/government/law-board.js +131 -0
- package/modules/government/laws.js +241 -0
- package/modules/government/leads.js +170 -0
- package/modules/government/migrations/government_027_laws.sql +143 -0
- package/modules/government/migrations/government_028_asks.sql +57 -0
- package/modules/government/migrations/government_029_leads.sql +135 -0
- package/modules/government/migrations/government_030_buildouts.sql +111 -0
- package/modules/government/migrations/government_031_treasury.sql +107 -0
- package/modules/government/module.json +2 -1
- package/modules/government/pollers/treasury-borrow.js +62 -0
- package/modules/government/positions.js +107 -0
- package/modules/government/presets.js +149 -0
- package/modules/government/routes/government.js +425 -2
- package/modules/government/routing.js +154 -0
- package/modules/government/treasury-db.js +166 -0
- package/modules/government/treasury.js +241 -0
- package/modules/hall-ui/public/atlas.html +2 -2
- package/modules/hall-ui/public/blockers.html +2 -2
- package/modules/hall-ui/public/board-room.html +2 -2
- package/modules/hall-ui/public/board-room.js +2 -0
- package/modules/hall-ui/public/city-draw.js +604 -11
- package/modules/hall-ui/public/city.css +157 -1
- package/modules/hall-ui/public/city.html +37 -7
- package/modules/hall-ui/public/city.js +441 -21
- package/modules/hall-ui/public/city.states.json +110 -5
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/commands.html +2 -2
- package/modules/hall-ui/public/copy-desk.html +2 -2
- package/modules/hall-ui/public/craft-mode.js +112 -29
- package/modules/hall-ui/public/deploy.html +2 -2
- package/modules/hall-ui/public/diagram-viewer.css +12 -0
- package/modules/hall-ui/public/diagram-viewer.js +4 -0
- package/modules/hall-ui/public/diagrams.html +2 -2
- package/modules/hall-ui/public/drachmae.html +2 -2
- package/modules/hall-ui/public/fleet.html +2 -2
- package/modules/hall-ui/public/gate.html +2 -2
- package/modules/hall-ui/public/goal-map.html +2 -2
- package/modules/hall-ui/public/goals.html +2 -2
- package/modules/hall-ui/public/gov-feeds.js +141 -0
- package/modules/hall-ui/public/gov-living.js +1002 -0
- package/modules/hall-ui/public/gov-positions.js +97 -0
- package/modules/hall-ui/public/government.html +2 -2
- package/modules/hall-ui/public/hall-render.js +3 -0
- package/modules/hall-ui/public/idea.html +2 -2
- package/modules/hall-ui/public/ideas.html +2 -2
- package/modules/hall-ui/public/index.html +3 -3
- package/modules/hall-ui/public/modules.html +2 -2
- package/modules/hall-ui/public/my-agents.css +334 -0
- package/modules/hall-ui/public/my-agents.html +72 -0
- package/modules/hall-ui/public/my-agents.js +603 -0
- package/modules/hall-ui/public/my-agents.states.json +37 -0
- package/modules/hall-ui/public/primer.html +2 -2
- package/modules/hall-ui/public/profile-connections.js +40 -28
- package/modules/hall-ui/public/profile.html +2 -2
- package/modules/hall-ui/public/project-settings.html +2 -2
- package/modules/hall-ui/public/ranks.html +2 -2
- package/modules/hall-ui/public/roadmap.html +2 -2
- package/modules/hall-ui/public/room.css +8 -0
- package/modules/hall-ui/public/roster.html +2 -2
- package/modules/hall-ui/public/sessions.html +2 -2
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +116 -16
- package/modules/hall-ui/public/street-card.js +18 -1
- package/modules/hall-ui/public/street-deck.css +299 -0
- package/modules/hall-ui/public/street-deck.js +1145 -0
- package/modules/hall-ui/public/street-depth.js +712 -0
- package/modules/hall-ui/public/street-desk.js +5 -2
- package/modules/hall-ui/public/street-draw.js +109 -16
- package/modules/hall-ui/public/street-inside.js +137 -22
- package/modules/hall-ui/public/street-office.js +705 -0
- package/modules/hall-ui/public/street.css +161 -11
- package/modules/hall-ui/public/street.html +41 -11
- package/modules/hall-ui/public/street.js +78 -66
- package/modules/hall-ui/public/street.states.json +129 -21
- package/modules/hall-ui/public/studio.html +2 -2
- package/modules/hall-ui/public/style.css +5 -1
- package/modules/hall-ui/public/task.html +2 -2
- package/modules/hall-ui/public/thinking.html +2 -2
- package/modules/hall-ui/public/tweak-editor.html +2 -2
- package/modules/hall-ui/public/watch.html +2 -2
- package/modules/hall-ui/public/watch.js +14 -1
- package/modules/hall-ui/public/work.html +2 -2
- package/modules/hall-ui/records/living-city.md +57 -0
- package/modules/hall-ui/records/my-agents.md +21 -0
- package/modules/hall-ui/records/oversight-family.md +1 -0
- package/modules/hall-ui/records/profile-connections.md +10 -0
- package/modules/hall-ui/records/reading-rooms.md +2 -0
- package/modules/ideas/routes/blockers.js +92 -3
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/moving.js +17 -1
- package/modules/lifecycle/routes/moving.js +6 -0
- package/modules/lifecycle/routes/project-settings.js +23 -0
- package/modules/onboarding/approval-broadcast.js +8 -0
- package/modules/onboarding/routes/access-requests.js +16 -6
- package/modules/platform-identity/connections.js +13 -2
- package/modules/platform-identity/routes/connections.js +7 -1
- package/modules/public-landing/public/account-private.states.json +2 -2
- package/modules/public-landing/public/account.html +65 -5
- package/modules/public-landing/public/account.states.json +3 -3
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +13 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +13 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +13 -0
- package/modules/public-landing/public/assets/world.css +4 -4
- package/modules/public-landing/public/connections-empty.states.json +14 -0
- package/modules/public-landing/public/connections-inactive.states.json +14 -0
- package/modules/public-landing/public/connections.html +450 -0
- package/modules/public-landing/public/connections.probes.json +414 -0
- package/modules/public-landing/public/connections.states.json +17 -0
- package/modules/public-landing/public/contact.html +2 -2
- package/modules/public-landing/public/guild.html +2 -2
- package/modules/public-landing/public/index.html +2 -2
- package/modules/public-landing/public/invitations.html +2 -2
- package/modules/public-landing/public/privacy.html +2 -2
- package/modules/public-landing/public/projects.html +2 -2
- package/modules/public-landing/public/projects.probes.json +6 -2
- package/modules/public-landing/public/terms.html +2 -2
- package/modules/ui-design/kit/fixtures/connections-empty.json +12 -0
- package/modules/ui-design/kit/fixtures/connections-inactive.json +7 -0
- package/modules/ui-design/kit/fixtures/connections.json +22 -0
- package/modules/ui-design/kit/serve.js +95 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +122 -0
- package/scripts/discord/README.md +8 -5
- package/scripts/discord/post.js +12 -1
- package/scripts/gds/artifact-audit.js +20 -3
- package/scripts/gds/ask.js +164 -0
- package/scripts/gds/autobongos-verify.js +21 -2
- package/scripts/gds/government-walk.js +638 -4
- package/scripts/gds/pulse.js +9 -6
- package/scripts/gds/ship-escalation.js +22 -3
- package/scripts/gds/ship-finish.js +5 -4
- package/scripts/gds/ship-merge-gate.js +18 -6
- package/scripts/hall-preview/README.md +41 -1
- package/scripts/hall-preview/pref-writes.js +140 -0
- package/scripts/hall-preview/refresh-fixtures.js +101 -7
- package/scripts/hall-preview/scrub-identity.js +366 -7
- package/scripts/hall-preview/server.js +27 -1
- package/scripts/render-diagrams.sh +4 -2
- package/src/bongos/auth-idp.js +6 -1
- package/src/bongos/project-settings.js +41 -0
- package/src/bongos/route-rank-check.js +27 -0
- package/src/bongos/routes/me.js +68 -7
- package/src/bongos/serve-internal.js +5 -1
- package/src/module-api.js +7 -1
- package/src/platform-server.js +10 -0
- package/tests/application_lifecycle.mjs +47 -5
- package/tests/conductor_main_e2e.mjs +21 -0
- package/tests/connections_api.mjs +18 -0
- package/tests/craft_mode_nav.mjs +5 -3
- package/tests/craft_mode_tallies.mjs +52 -16
- package/tests/discord_approvals.mjs +11 -0
- package/tests/discord_channels.mjs +20 -0
- package/tests/fixtures/gov-seated.mjs +64 -0
- package/tests/fixtures/government-ask-cards.json +1284 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/government_agent_asks.mjs +322 -0
- package/tests/government_asks.mjs +408 -0
- package/tests/government_build_rights.mjs +253 -0
- package/tests/government_buildouts.mjs +382 -0
- package/tests/government_counts.mjs +143 -0
- package/tests/government_decision_rules.mjs +5 -1
- package/tests/government_laws.mjs +488 -0
- package/tests/government_laws_session.mjs +321 -0
- package/tests/government_leads.mjs +375 -0
- package/tests/government_positions.mjs +127 -0
- package/tests/government_presets.mjs +215 -0
- package/tests/government_routes.mjs +8 -1
- package/tests/government_routing.mjs +118 -0
- package/tests/government_seed.mjs +5 -0
- package/tests/government_treasury.mjs +394 -0
- package/tests/governor_docket.mjs +26 -2
- package/tests/hall_buildouts.mjs +208 -0
- package/tests/hall_city.mjs +14 -6
- package/tests/hall_city_living.mjs +617 -0
- package/tests/hall_gov_counts.mjs +336 -0
- package/tests/hall_gov_living.mjs +636 -0
- package/tests/hall_gov_real_site.mjs +655 -0
- package/tests/hall_matter_card.mjs +6 -1
- package/tests/hall_my_agents.mjs +481 -0
- package/tests/hall_nav.mjs +107 -4
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/hall_preview_governor_fixtures.mjs +30 -3
- package/tests/hall_preview_scrub.mjs +59 -1
- package/tests/hall_preview_security.mjs +169 -0
- package/tests/hall_reading_world.mjs +44 -0
- package/tests/hall_roster_world.mjs +6 -2
- package/tests/hall_street.mjs +18 -2
- package/tests/hall_street_amend.mjs +1 -1
- package/tests/hall_street_deck.mjs +609 -0
- package/tests/hall_street_depth.mjs +559 -0
- package/tests/hall_street_desk.mjs +1 -1
- package/tests/hall_street_inside.mjs +29 -5
- package/tests/hall_tweak_editor.mjs +4 -1
- package/tests/hub_account_page.mjs +94 -3
- package/tests/hub_connections_page.mjs +507 -0
- package/tests/hub_invite_badge.mjs +1 -1
- package/tests/idp_device_exchange.mjs +18 -0
- package/tests/landing_legal_pages.mjs +8 -5
- package/tests/landing_page.mjs +23 -4
- package/tests/nav_permission_atoms.mjs +4 -1
- package/tests/profile_connections_ui.mjs +88 -25
- package/tests/projects_hub.mjs +15 -0
- package/tests/render_check.mjs +2 -2
- package/tests/ship_broadcast_routing.mjs +57 -16
- package/tests/ship_broadcast_server.mjs +40 -7
- package/tests/ship_merge_gate.mjs +19 -0
- package/tests/ui_design_kit.mjs +2 -2
- package/tests/watch_applications_queue.mjs +23 -0
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
// NOT TOUCHED. Instance-local numeric ids (builder, task, goal) — the harness routes on
|
|
31
31
|
// them (analytics/builder/:id, /profile?id=…) and they name nobody outside that one
|
|
32
32
|
// instance. Free text (task titles, descriptions) — project content, not a person's
|
|
33
|
-
// record
|
|
33
|
+
// record — EXCEPT where it names a person the run has already met in a field: a
|
|
34
|
+
// snapshot run ends with createTextScrubber (task 1004685, below), which replaces
|
|
35
|
+
// those people inside prose too. The repo's own coordinates (instance.repo.owner is
|
|
36
|
+
// where the code lives).
|
|
34
37
|
//
|
|
35
38
|
// FIXED POINT. Every stand-in is recognised on the way back in (`builder-3` is person
|
|
36
39
|
// 3), so scrubbing an already-scrubbed fixture changes nothing. tests/hall_preview_scrub.mjs
|
|
@@ -69,8 +72,13 @@ const isByKey = (k) => k === 'by' || /_by$/.test(k);
|
|
|
69
72
|
// login. Task 1004531: the constitution fixture carries that table. Only on a
|
|
70
73
|
// table ROW (it names its building in `area`), so a login that happens to be one
|
|
71
74
|
// of the three words anywhere else is still scrubbed.
|
|
75
|
+
// The live docket carries the same word on each building's `decision` (task 1004685),
|
|
76
|
+
// which names its rule rather than its area: { rule: 'decide', decided_by: 'gate' }.
|
|
77
|
+
// Scrubbed as a login there, "gate" and "board" became stand-ins and the prose pass
|
|
78
|
+
// then rewrote every "stealth gate" and "Board Room" in the snapshot.
|
|
72
79
|
const DECISION_WORDS = new Set(['gate', 'board', 'nobody']);
|
|
73
|
-
const isDecisionWord = (k, v, obj) => k === 'decided_by' && DECISION_WORDS.has(v) && !!obj
|
|
80
|
+
const isDecisionWord = (k, v, obj) => k === 'decided_by' && DECISION_WORDS.has(v) && !!obj
|
|
81
|
+
&& (typeof obj.area === 'string' || typeof obj.rule === 'string');
|
|
74
82
|
const looksLikeLogin = (v) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(v) && !/^\d+$/.test(v);
|
|
75
83
|
|
|
76
84
|
// The login a name-shaped key belongs to, or undefined when the key is not a person's
|
|
@@ -88,9 +96,27 @@ function nameOwnerKey(key, obj) {
|
|
|
88
96
|
// createScrubber — one registry of people for one run, so a builder gets the same n in
|
|
89
97
|
// every file the run touches. `firstNumber` lets a run start above stand-ins already on
|
|
90
98
|
// disk, so a new person never collides with one a hand-written fixture already uses.
|
|
91
|
-
|
|
99
|
+
//
|
|
100
|
+
// `seed` (task 1004685) is people whose stand-in is already fixed on disk —
|
|
101
|
+
// [{ login, name, n }], which refresh-fixtures.js reads off the committed roster by
|
|
102
|
+
// builder id — so a re-run keeps every builder's number instead of renumbering the
|
|
103
|
+
// whole snapshot, and a file added to the run lines up with the ones already there.
|
|
104
|
+
function createScrubber({ firstNumber = 1, seed = [] } = {}) {
|
|
92
105
|
const people = new Map(); // 'login:<lower>' | 'name:<text>' | 'id:<github id>' | 'email:<lower>' → n
|
|
106
|
+
// A login as it was written (the registry keys it lowercased), for the free-text
|
|
107
|
+
// pass: `samRiverStone` shows where a first name ends, `samriverstone` does not.
|
|
108
|
+
const spelled = new Map();
|
|
109
|
+
const loginKeyOf = (v) => { spelled.set(v.toLowerCase(), v); return `login:${v.toLowerCase()}`; };
|
|
93
110
|
let next = Math.max(1, Number(firstNumber) || 1);
|
|
111
|
+
for (const p of Array.isArray(seed) ? seed : []) {
|
|
112
|
+
const n = Number(p && p.n);
|
|
113
|
+
if (!Number.isInteger(n) || n <= 0) continue;
|
|
114
|
+
const login = typeof p.login === 'string' && p.login && !STAND_IN_LOGIN.test(p.login) ? p.login : '';
|
|
115
|
+
const name = typeof p.name === 'string' && p.name && !STAND_IN_NAME.test(p.name) ? p.name : '';
|
|
116
|
+
if (login || name) people.set(login ? loginKeyOf(login) : `name:${name}`, n);
|
|
117
|
+
if (login && name) people.set(`name:${name}`, n);
|
|
118
|
+
if ((login || name) && n >= next) next = n + 1;
|
|
119
|
+
}
|
|
94
120
|
|
|
95
121
|
function numberFor(keys) {
|
|
96
122
|
for (const k of keys) if (k && people.has(k)) return remember(keys, people.get(k));
|
|
@@ -109,7 +135,7 @@ function createScrubber({ firstNumber = 1 } = {}) {
|
|
|
109
135
|
const name = nameKey && typeof obj[nameKey] === 'string' ? obj[nameKey] : '';
|
|
110
136
|
const standIn = STAND_IN_LOGIN.exec(login) || STAND_IN_NAME.exec(name);
|
|
111
137
|
if (standIn) return remember([], Number(standIn[1]));
|
|
112
|
-
return numberFor([login &&
|
|
138
|
+
return numberFor([login && loginKeyOf(login), name && `name:${name}`]);
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
function scrubObject(obj, ctx) {
|
|
@@ -169,13 +195,13 @@ function createScrubber({ firstNumber = 1 } = {}) {
|
|
|
169
195
|
}
|
|
170
196
|
if (isByKey(key) && looksLikeLogin(value) && !isDecisionWord(key, value, obj)) {
|
|
171
197
|
if (STAND_IN_LOGIN.test(value)) return value;
|
|
172
|
-
return `builder-${numberFor([
|
|
198
|
+
return `builder-${numberFor([loginKeyOf(value)])}`;
|
|
173
199
|
}
|
|
174
200
|
// A PR / commit author is a GitHub login; elsewhere `author` credits an
|
|
175
201
|
// organisation (a module's author), which is nobody's personal record.
|
|
176
202
|
if (key === 'author' && 'head_sha' in obj && looksLikeLogin(value)) {
|
|
177
203
|
if (STAND_IN_LOGIN.test(value)) return value;
|
|
178
|
-
return `builder-${numberFor([
|
|
204
|
+
return `builder-${numberFor([loginKeyOf(value)])}`;
|
|
179
205
|
}
|
|
180
206
|
if (key in PLACEHOLDER_TEXT) return PLACEHOLDER_TEXT[key];
|
|
181
207
|
if (isGithubIdKey(key)) return String(githubIdFor(obj, value, self, isPersonRecord));
|
|
@@ -225,7 +251,326 @@ function createScrubber({ firstNumber = 1 } = {}) {
|
|
|
225
251
|
return data && typeof data === 'object' ? scrubObject(data, ctx) : data;
|
|
226
252
|
}
|
|
227
253
|
|
|
228
|
-
|
|
254
|
+
// knownPeople — every REAL login and name this run has met in a structured field,
|
|
255
|
+
// with its stand-in n, for the free-text pass (createTextScrubber below). Logins come
|
|
256
|
+
// back as they were written. Stand-ins are not listed: a fixture already on disk
|
|
257
|
+
// teaches the registry nothing real.
|
|
258
|
+
function knownPeople() {
|
|
259
|
+
const logins = [];
|
|
260
|
+
const names = [];
|
|
261
|
+
for (const [key, n] of people) {
|
|
262
|
+
if (key.startsWith('login:')) {
|
|
263
|
+
const lower = key.slice('login:'.length);
|
|
264
|
+
logins.push({ login: spelled.get(lower) || lower, n });
|
|
265
|
+
} else if (key.startsWith('name:')) names.push({ name: key.slice('name:'.length), n });
|
|
266
|
+
}
|
|
267
|
+
return { logins, names };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { scrub, knownPeople };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------- free text (task 1004685) ----------
|
|
274
|
+
// The structured pass above replaces a person wherever a FIELD names them. Prose is
|
|
275
|
+
// left alone by design (task titles and descriptions are project content), but the
|
|
276
|
+
// governor docket carries prose that names people outright: "From <login>'s
|
|
277
|
+
// feedback", "Owner: tell <login> their project was torn down", "<name>: stop runner".
|
|
278
|
+
// So a snapshot run ends with one more pass over every string, replacing the people
|
|
279
|
+
// the run has already met: by login, by display name, by the first name a login
|
|
280
|
+
// visibly starts with (`alex42`, `AlexCode` → Alex; `willRiverStone` → Will), by a
|
|
281
|
+
// capitalised word a login starts with (`bobbytabl` → "Bobby", "Bobby Tables"), and by a
|
|
282
|
+
// capitalised nickname cut from the end of an all-caps login. It only ever knows
|
|
283
|
+
// people a structured field named; it cannot recognise a stranger in a sentence,
|
|
284
|
+
// which is why the structured pass still comes first.
|
|
285
|
+
//
|
|
286
|
+
// Kept on purpose: the repo's own coordinates (`github.com/<owner>/<repo>` and the
|
|
287
|
+
// manifest's `repo.owner`), exactly as the structured pass keeps them.
|
|
288
|
+
//
|
|
289
|
+
// A name that is also a common English word ("will") is replaced only where it is
|
|
290
|
+
// written as a name (capitalised), so "the agent will stop" survives the pass.
|
|
291
|
+
const COMMON_WORD_NAMES = new Set([
|
|
292
|
+
'will', 'mark', 'max', 'may', 'bill', 'art', 'grant', 'rob', 'pat', 'sue', 'jack', 'don', 'ray', 'rose',
|
|
293
|
+
'dawn', 'hope', 'joy', 'faith', 'grace', 'sky', 'king', 'frank', 'miles', 'chase', 'hunter', 'summer',
|
|
294
|
+
'june', 'april', 'august', 'eve', 'cliff', 'glen', 'dean', 'victor', 'sam', 'nick', 'drew', 'lane',
|
|
295
|
+
]);
|
|
296
|
+
// Capitalised words a login may start with that are not first names ("Large files…"
|
|
297
|
+
// is not `largefellow-x`). Only the start-of-login rule consults it.
|
|
298
|
+
const NOT_A_NAME = new Set([
|
|
299
|
+
'large', 'small', 'open', 'code', 'data', 'super', 'mega', 'cloud', 'home', 'build', 'test', 'team',
|
|
300
|
+
'labs', 'online', 'master', 'admin', 'user', 'real', 'other', 'scoot', 'mack', 'kind', 'line',
|
|
301
|
+
]);
|
|
302
|
+
// a system account (`discord-bot-system`) is nobody, and its "first name" is a product
|
|
303
|
+
const SYSTEM_ACCOUNT = /(^|-)(system|bot)(-|$)|\(system\)/i;
|
|
304
|
+
const EMAIL_IN_TEXT = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
305
|
+
const KEEP_EMAIL = /^(git|noreply|no-reply)@|@example\.(com|org)$/i;
|
|
306
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
307
|
+
const titleCase = (s) => s[0].toUpperCase() + s.slice(1);
|
|
308
|
+
|
|
309
|
+
// The first name a login starts with, when the login shows where it ends: a digit,
|
|
310
|
+
// a hyphen or a camelCase step, after at least three letters. An all-lowercase login
|
|
311
|
+
// with no boundary (`jpetersen`) shows no first name, so none is guessed.
|
|
312
|
+
function firstNameOf(login) {
|
|
313
|
+
if (SYSTEM_ACCOUNT.test(login)) return null;
|
|
314
|
+
const m = /^([A-Z]?[a-z]{2,})(?=[A-Z0-9_-])/.exec(login);
|
|
315
|
+
return m ? m[1].toLowerCase() : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// createTextScrubber(known) → (data) => a deep copy with the known people replaced in
|
|
319
|
+
// every string. `known` is a scrubber's knownPeople(). A login is replaced by its
|
|
320
|
+
// login-shaped stand-in (`builder-3`), a name by its name-shaped one (`Builder 3`),
|
|
321
|
+
// and a first name by whichever shape it was written in.
|
|
322
|
+
function createTextScrubber({ logins = [], names = [] } = {}) {
|
|
323
|
+
const rules = []; // { re, len, to }, applied longest needle first
|
|
324
|
+
const add = (needle, n, { caseless, shape }) => {
|
|
325
|
+
// a decision word is vocabulary, whatever field once mistook it for a login
|
|
326
|
+
if (!needle || needle.length < 3 || DECISION_WORDS.has(needle.toLowerCase())) return;
|
|
327
|
+
// never inside a longer word, never the owner segment of a GitHub URL
|
|
328
|
+
const re = new RegExp(`(?<![A-Za-z0-9_-])(?<!github\\.com/)${escapeRe(needle)}(?![A-Za-z0-9_-])`, caseless ? 'gi' : 'g');
|
|
329
|
+
const to = shape === 'login' ? () => `builder-${n}`
|
|
330
|
+
: shape === 'name' ? () => `Builder ${n}`
|
|
331
|
+
: (hit) => (/^[A-Z]/.test(hit) ? `Builder ${n}` : `builder-${n}`);
|
|
332
|
+
rules.push({ re, len: needle.length, to });
|
|
333
|
+
};
|
|
334
|
+
// a nickname or a first name that is a common word only ever counts capitalised
|
|
335
|
+
const addWord = (word, n, shape) => {
|
|
336
|
+
if (COMMON_WORD_NAMES.has(word.toLowerCase())) add(titleCase(word.toLowerCase()), n, { caseless: false, shape: shape === 'login' ? 'name' : shape });
|
|
337
|
+
else add(word, n, { caseless: true, shape });
|
|
338
|
+
};
|
|
339
|
+
const capsLogins = [];
|
|
340
|
+
const prefixLogins = [];
|
|
341
|
+
for (const { login, n } of logins) {
|
|
342
|
+
if (!login || STAND_IN_LOGIN.test(login)) continue;
|
|
343
|
+
if (!SYSTEM_ACCOUNT.test(login)) prefixLogins.push({ lower: login.toLowerCase(), n });
|
|
344
|
+
addWord(login, n, 'login');
|
|
345
|
+
const first = firstNameOf(login);
|
|
346
|
+
if (first && first !== login.toLowerCase()) addWord(first, n, 'first');
|
|
347
|
+
if (/^[A-Z0-9]{8,}$/.test(login) && /[A-Z]{6,}/.test(login)) capsLogins.push({ lower: login.toLowerCase(), n });
|
|
348
|
+
}
|
|
349
|
+
for (const { name, n } of names) {
|
|
350
|
+
if (!name || STAND_IN_NAME.test(name) || SYSTEM_ACCOUNT.test(name)) continue;
|
|
351
|
+
if (name.length >= 4 && !COMMON_WORD_NAMES.has(name.toLowerCase())) add(name, n, { caseless: false, shape: 'name' });
|
|
352
|
+
const parts = name.split(/\s+/);
|
|
353
|
+
if (parts.length > 1 && /^[A-Z][a-z]{2,}$/.test(parts[0]) && !COMMON_WORD_NAMES.has(parts[0].toLowerCase())) add(parts[0], n, { caseless: false, shape: 'name' });
|
|
354
|
+
}
|
|
355
|
+
rules.sort((a, b) => b.len - a.len);
|
|
356
|
+
|
|
357
|
+
function text(s) {
|
|
358
|
+
let out = s;
|
|
359
|
+
for (const r of rules) out = out.replace(r.re, r.to);
|
|
360
|
+
// a capitalised first name a login starts with but does not mark off (`bobbytabl`
|
|
361
|
+
// → "Bobby", "Bobby Tables"), taking a capitalised surname with it
|
|
362
|
+
if (prefixLogins.length) {
|
|
363
|
+
out = out.replace(/(?<![A-Za-z0-9_-])([A-Z][a-z]{3,})(\s[A-Z][a-z]+)?(?![A-Za-z0-9_-])/g, (whole, w, surname) => {
|
|
364
|
+
const lower = w.toLowerCase();
|
|
365
|
+
if (COMMON_WORD_NAMES.has(lower) || NOT_A_NAME.has(lower)) return whole;
|
|
366
|
+
const hit = prefixLogins.find((p) => p.lower.startsWith(lower) && p.lower.length >= lower.length + 2);
|
|
367
|
+
return hit ? `Builder ${hit.n}` : whole;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// a capitalised nickname cut from the end of an all-caps login (IAMSOMEONE → "Someone")
|
|
371
|
+
if (capsLogins.length) {
|
|
372
|
+
out = out.replace(/(?<![A-Za-z0-9_-])[A-Z][a-z]{5,}(?![A-Za-z0-9_-])/g, (w) => {
|
|
373
|
+
const hit = capsLogins.find((c) => c.lower.endsWith(w.toLowerCase()) && c.lower !== w.toLowerCase());
|
|
374
|
+
return hit ? `Builder ${hit.n}` : w;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return out.replace(EMAIL_IN_TEXT, (e) => (KEEP_EMAIL.test(e) ? e : 'builder@example.com'));
|
|
378
|
+
}
|
|
379
|
+
function walk(v, key, parent) {
|
|
380
|
+
if (typeof v === 'string') {
|
|
381
|
+
// the manifest's repo coordinates stay, as the structured pass keeps them
|
|
382
|
+
if (key === 'owner' && parent && typeof parent.repo === 'string') return v;
|
|
383
|
+
return text(v);
|
|
384
|
+
}
|
|
385
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, null, v));
|
|
386
|
+
if (v && typeof v === 'object') {
|
|
387
|
+
const out = {};
|
|
388
|
+
for (const [k, val] of Object.entries(v)) out[k] = walk(val, k, v);
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
return v;
|
|
392
|
+
}
|
|
393
|
+
return (data) => walk(data, null, null);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------- security content (task 1004685, after its security review) ----------
|
|
397
|
+
// People are not the only thing a live snapshot must not commit. The governor
|
|
398
|
+
// docket is read at a rank floor, and it carries the project's open red-team
|
|
399
|
+
// reports (file and line references, exploit chains, proofs of concept) and the
|
|
400
|
+
// owner's blockers (host config diffs, recovery runbooks naming a bot token). A
|
|
401
|
+
// committed fixture hands all of that to anyone with read access to the repo. So
|
|
402
|
+
// every snapshot run, and --scrub-only, ends with a structural pass:
|
|
403
|
+
//
|
|
404
|
+
// • A RED-TEAM RECORD (source redteam_reports, a vulnerability/security-report
|
|
405
|
+
// ref, an id like `redteam_reports:N`, a "Security report:" title, a
|
|
406
|
+
// `repro_steps` field) keeps only REPORT_KEYS — id, state, severity bucket,
|
|
407
|
+
// where it stands, its acts — and its title becomes REPORT_TITLE. Every other
|
|
408
|
+
// field (detail, body, repro, target, proposer, facts…) is dropped.
|
|
409
|
+
// • A BLOCKER (source blockers, a blocker ref, a `blocking_task_ids` row) loses
|
|
410
|
+
// every body field, and keeps its title only when the title itself names no
|
|
411
|
+
// secret, config, path, token or customer instance.
|
|
412
|
+
// • The SECURITY BACKLOG — a task flagged security_sensitive or belonging to a
|
|
413
|
+
// goal of category `security` — keeps its shape but not its words: the title
|
|
414
|
+
// becomes TASK_TITLE and its description, summary and touched paths go; the
|
|
415
|
+
// security goal itself keeps its title and loses its description.
|
|
416
|
+
// • ANY other record: a prose field (description, detail, body, summary…) that
|
|
417
|
+
// names one is emptied, and a title that does becomes ITEM_TITLE.
|
|
418
|
+
// • A customer instance's own domain (any host not on PUBLIC_DOMAINS) becomes
|
|
419
|
+
// `instance.example`, and its bare name, wherever else it appears, `instance-name`.
|
|
420
|
+
//
|
|
421
|
+
// Over-redaction is the safe failure here: a withheld title costs a preview its
|
|
422
|
+
// colour; a leaked one cannot be taken back.
|
|
423
|
+
const REPORT_TITLE = 'Security report — details withheld';
|
|
424
|
+
const BLOCKER_TITLE = 'Blocker — details withheld';
|
|
425
|
+
const ITEM_TITLE = 'Details withheld';
|
|
426
|
+
const TASK_TITLE = 'Security task — details withheld';
|
|
427
|
+
const REPORT_KEYS = new Set([
|
|
428
|
+
'id', 'source', 'building', 'rule', 'state', 'status', 'severity', 'short', 'where', 'desk', 'fact',
|
|
429
|
+
'opened_at', 'created_at', 'closed_at', 'at', 'deadline', 'outcome', 'tally', 'href', 'ref', 'acts',
|
|
430
|
+
'needs', 'tool', 'over', 'kind', 'label', 'count',
|
|
431
|
+
// a stored ask's card for a report (task 1004703; its words are walked like any prose)
|
|
432
|
+
'question', 'options', 'recommend', 'waits_on', 'if_nobody_answers', 'routing_note', 'raw',
|
|
433
|
+
// who it waits on, as the docket routes it (task 1004705): a seat, never the report's words
|
|
434
|
+
'route',
|
|
435
|
+
]);
|
|
436
|
+
const TITLE_KEYS = new Set(['title', 'name', 'subject']);
|
|
437
|
+
const PROSE_KEYS = new Set([
|
|
438
|
+
'description', 'detail', 'details', 'body', 'body_md', 'summary', 'value_summary', 'notes', 'note',
|
|
439
|
+
'repro', 'repro_steps', 'impact', 'target', 'rationale_md', 'resolution_note', 'triage_note',
|
|
440
|
+
'paused_reason', 'owner_paused_reason', 'reason', 'criterion_md', 'line', 'lines', 'facts', 'fact', 'text', 'tags',
|
|
441
|
+
]);
|
|
442
|
+
const BLOCKER_BODY_KEYS = new Set(['body', 'body_md', 'notes', 'note', 'details', 'detail', 'description', 'resolution_note', 'steps', 'facts']);
|
|
443
|
+
// What a security write-up, a host runbook or a credential looks like in prose: the
|
|
444
|
+
// SPECIFIC marks of one (exploit and proof-of-concept talk, host config and paths,
|
|
445
|
+
// a named credential, the code identifiers a live report points at, a customer's
|
|
446
|
+
// project or instance). Plain words such as "security" or "token reward" are not
|
|
447
|
+
// on it, so ordinary project prose is left alone; a red-team record itself is caught
|
|
448
|
+
// by its shape (isReportRecord), not by its words.
|
|
449
|
+
const SENSITIVE_TEXT = new RegExp([
|
|
450
|
+
'exploit', '\\bPoC\\b', 'proof[- ]of[- ]concept', '\\brepro_steps\\b', '\\bCVE-\\d', 'privilege escalation',
|
|
451
|
+
'frame-ancestors', 'X-Frame-Options', 'Content-Security-Policy', '\\bCaddy', '\\bnginx\\b', 'systemctl', '\\bsudo\\b',
|
|
452
|
+
'/etc/', '/home/', 'WorkingDirectory=', '\\bweb\\.env\\b', '\\bbot token', '\\b(api|bearer|access|personal access|deploy) token\\b',
|
|
453
|
+
'\\bapi[ _-]?key\\b', 'private key', 'secret key', '\\b(credential|password|secret)s? (is |are |still )*(live|in git|leaked|exposed)',
|
|
454
|
+
'committed_files', 'auth-idp', 'serverSideGuards', 'verifyHubAssertion', 'GRADER_BYPASS', 'reconcile-on-read',
|
|
455
|
+
'\\bproject [a-z0-9]+(?:-[a-z0-9]+)+\\b', '\\binstance \\d+\\b',
|
|
456
|
+
].join('|'), 'i');
|
|
457
|
+
// Domains that name a public service or the platform itself, never a customer.
|
|
458
|
+
const PUBLIC_DOMAINS = [
|
|
459
|
+
'example.com', 'example.org', 'example.net', 'github.com', 'githubusercontent.com', 'cloudbongos.com', 'claude.ai',
|
|
460
|
+
'claude.com', 'anthropic.com', 'discord.com', 'discordapp.com', 'discordapp.net', 'w3.org', 'google.com',
|
|
461
|
+
'googleapis.com', 'gstatic.com', 'onrender.com', 'npmjs.com', 'npmjs.org', 'nodejs.org', 'cloudflare.com', 'shields.io',
|
|
462
|
+
];
|
|
463
|
+
const HOST_IN_TEXT = /\b((?:[a-z0-9-]+\.)*)([a-z0-9-]+)\.(com|net|org|io|app|dev|co|ai|xyz|cloud|site|tech)\b/gi;
|
|
464
|
+
const isPublicDomain = (base) => PUBLIC_DOMAINS.some((d) => base === d || base.endsWith('.' + d));
|
|
465
|
+
|
|
466
|
+
function isReportRecord(o) {
|
|
467
|
+
const ref = o.ref && typeof o.ref === 'object' ? o.ref : null;
|
|
468
|
+
return /^(redteam_reports?|security_reports?|vulnerability_reports?)$/.test(String(o.source || ''))
|
|
469
|
+
|| (!!ref && /vulnerab|red_?team|security_report/i.test(String(ref.type || '')))
|
|
470
|
+
|| (typeof o.id === 'string' && /^(redteam_reports?|security_reports?|vulnerability_reports?):/.test(o.id))
|
|
471
|
+
|| /^security report\b/i.test(String(o.title || o.name || ''))
|
|
472
|
+
|| 'repro_steps' in o
|
|
473
|
+
|| /red_?team|vulnerab|security_report/i.test(String(o.kind || ''));
|
|
474
|
+
}
|
|
475
|
+
function isBlockerRecord(o) {
|
|
476
|
+
const ref = o.ref && typeof o.ref === 'object' ? o.ref : null;
|
|
477
|
+
return String(o.source || '') === 'blockers' || (!!ref && String(ref.type || '') === 'blocker')
|
|
478
|
+
|| Array.isArray(o.blocking_task_ids) || (typeof o.id === 'string' && /^blockers:/.test(o.id));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// The customer instances a set of fixtures names: the registrable label of every
|
|
482
|
+
// host not on PUBLIC_DOMAINS (`builders.someone.com` → `someone`).
|
|
483
|
+
function instanceNamesIn(values) {
|
|
484
|
+
const names = new Set();
|
|
485
|
+
const visit = (v) => {
|
|
486
|
+
if (typeof v === 'string') {
|
|
487
|
+
for (const m of v.matchAll(HOST_IN_TEXT)) {
|
|
488
|
+
const base = `${m[2]}.${m[3]}`.toLowerCase();
|
|
489
|
+
if (!isPublicDomain(base) && m[2].length >= 4) names.add(m[2].toLowerCase());
|
|
490
|
+
}
|
|
491
|
+
} else if (Array.isArray(v)) v.forEach(visit);
|
|
492
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(visit);
|
|
493
|
+
};
|
|
494
|
+
values.forEach(visit);
|
|
495
|
+
return [...names];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The goals whose work IS the security backlog (category `security`): their tasks
|
|
499
|
+
// describe open findings in their titles, so they are withheld by shape too.
|
|
500
|
+
function securityGoalIdsIn(values) {
|
|
501
|
+
const ids = new Set();
|
|
502
|
+
const visit = (v) => {
|
|
503
|
+
if (Array.isArray(v)) v.forEach(visit);
|
|
504
|
+
else if (v && typeof v === 'object') {
|
|
505
|
+
if (v.id != null && v.category && typeof v.category === 'object' && v.category.slug === 'security' && 'scope_modules' in v) ids.add(String(v.id));
|
|
506
|
+
Object.values(v).forEach(visit);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
values.forEach(visit);
|
|
510
|
+
return [...ids];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// A task (it has a goal and a title) that belongs to the security backlog or is
|
|
514
|
+
// flagged security-sensitive.
|
|
515
|
+
const TASK_PROSE_KEYS = new Set(['description', 'value_summary', 'touches', 'next_ready_title']);
|
|
516
|
+
function isSecurityTask(o, securityGoals) {
|
|
517
|
+
if (!('title' in o) || !('goal_id' in o || 'security_sensitive' in o)) return false;
|
|
518
|
+
return o.security_sensitive === true || (o.goal_id != null && securityGoals.has(String(o.goal_id)));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// createSecurityRedactor({ instanceNames, securityGoals }) → (data) => a redacted deep copy.
|
|
522
|
+
function createSecurityRedactor({ instanceNames = [], securityGoals = [] } = {}) {
|
|
523
|
+
const secGoals = new Set(securityGoals.map(String));
|
|
524
|
+
const bare = instanceNames.length
|
|
525
|
+
? new RegExp(`(?<![A-Za-z0-9_])(${instanceNames.map(escapeRe).join('|')})(?![A-Za-z0-9_])`, 'gi')
|
|
526
|
+
: null;
|
|
527
|
+
const deDomain = (s) => {
|
|
528
|
+
let out = s.replace(HOST_IN_TEXT, (host, subs, label, tld) => (isPublicDomain(`${label}.${tld}`.toLowerCase()) ? host : `${subs}instance.example`));
|
|
529
|
+
if (bare) out = out.replace(bare, 'instance-name');
|
|
530
|
+
return out;
|
|
531
|
+
};
|
|
532
|
+
const sensitive = (v) => typeof v === 'string' ? SENSITIVE_TEXT.test(v)
|
|
533
|
+
: Array.isArray(v) ? v.some(sensitive) : (v && typeof v === 'object') ? Object.values(v).some(sensitive) : false;
|
|
534
|
+
|
|
535
|
+
function walk(v, key, parent) {
|
|
536
|
+
if (typeof v === 'string') {
|
|
537
|
+
if (key === 'owner' && parent && typeof parent.repo === 'string') return v; // the repo's coordinates
|
|
538
|
+
return deDomain(v);
|
|
539
|
+
}
|
|
540
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, null, v));
|
|
541
|
+
if (!v || typeof v !== 'object') return v;
|
|
542
|
+
if (isReportRecord(v)) {
|
|
543
|
+
const out = {};
|
|
544
|
+
for (const [k, val] of Object.entries(v)) {
|
|
545
|
+
if (TITLE_KEYS.has(k) && typeof val === 'string') out[k] = REPORT_TITLE;
|
|
546
|
+
else if (REPORT_KEYS.has(k)) out[k] = walk(val, k, v);
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
const blocker = isBlockerRecord(v);
|
|
551
|
+
const secTask = !blocker && isSecurityTask(v, secGoals);
|
|
552
|
+
const secGoal = v.id != null && 'scope_modules' in v && secGoals.has(String(v.id));
|
|
553
|
+
const out = {};
|
|
554
|
+
for (const [k, val] of Object.entries(v)) {
|
|
555
|
+
if (blocker && BLOCKER_BODY_KEYS.has(k)) continue;
|
|
556
|
+
if (secGoal && k === 'description') { out[k] = ''; continue; }
|
|
557
|
+
if (secTask && k === 'title') { out[k] = TASK_TITLE; continue; }
|
|
558
|
+
if (secTask && TASK_PROSE_KEYS.has(k)) { out[k] = Array.isArray(val) ? [] : typeof val === 'string' ? '' : null; continue; }
|
|
559
|
+
if (TITLE_KEYS.has(k) && typeof val === 'string' && sensitive(val)) {
|
|
560
|
+
out[k] = blocker ? BLOCKER_TITLE : ITEM_TITLE;
|
|
561
|
+
} else if (PROSE_KEYS.has(k) && sensitive(val)) {
|
|
562
|
+
out[k] = Array.isArray(val) ? [] : typeof val === 'string' ? '' : null;
|
|
563
|
+
} else out[k] = walk(val, k, v);
|
|
564
|
+
}
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
return (data) => walk(data, null, null);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// securityRedactorFor(values) — a redactor that has learned, from every value it
|
|
571
|
+
// will be applied to, the customer instances they name and the security goals.
|
|
572
|
+
function securityRedactorFor(values) {
|
|
573
|
+
return createSecurityRedactor({ instanceNames: instanceNamesIn(values), securityGoals: securityGoalIdsIn(values) });
|
|
229
574
|
}
|
|
230
575
|
|
|
231
576
|
// A fixture named `me` or `me__…` is the snapshotting account's own record.
|
|
@@ -297,6 +642,20 @@ function rewriteScalars(text, next) {
|
|
|
297
642
|
|
|
298
643
|
module.exports = {
|
|
299
644
|
createScrubber,
|
|
645
|
+
createTextScrubber,
|
|
646
|
+
createSecurityRedactor,
|
|
647
|
+
instanceNamesIn,
|
|
648
|
+
securityGoalIdsIn,
|
|
649
|
+
securityRedactorFor,
|
|
650
|
+
TASK_TITLE,
|
|
651
|
+
isReportRecord,
|
|
652
|
+
isBlockerRecord,
|
|
653
|
+
REPORT_KEYS,
|
|
654
|
+
TITLE_KEYS,
|
|
655
|
+
REPORT_TITLE,
|
|
656
|
+
BLOCKER_TITLE,
|
|
657
|
+
ITEM_TITLE,
|
|
658
|
+
SENSITIVE_TEXT,
|
|
300
659
|
isOwnAccountFixture,
|
|
301
660
|
highestStandIn,
|
|
302
661
|
rewriteScalars,
|
|
@@ -81,6 +81,9 @@ const { injectBrandingGlobal, mountInternalSurfaces, injectModuleHallWidgets } =
|
|
|
81
81
|
const { hallWidgetScripts } = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
|
|
82
82
|
const { clientModules } = require(path.join(ROOT, 'src', 'modules.js'));
|
|
83
83
|
const { overrideKey, resolveLiveIdentity } = require('./live-identity');
|
|
84
|
+
// task 1004685: the per-builder pref PATCHes, answered by the real shapers in memory.
|
|
85
|
+
const { createPrefRecorder, readJsonBody } = require('./pref-writes');
|
|
86
|
+
const PREFS = createPrefRecorder();
|
|
84
87
|
|
|
85
88
|
// The session is the ONE thing this harness reads from outside its own tree, and
|
|
86
89
|
// it reads it through the FAIL-OPEN helper on purpose: apiCall/cliClient exit the
|
|
@@ -247,7 +250,20 @@ function emptied(v) {
|
|
|
247
250
|
// case that needs it (task 1003281) is the profile activity log, where the ROW
|
|
248
251
|
// SHAPE is chosen by ?section=, not by the path, so one fixture per path would
|
|
249
252
|
// render goal rows into the tasks ledger.
|
|
253
|
+
// ONE PATH, TWO READS (task 1004685). /tasks with no status is the ready list the
|
|
254
|
+
// goal pages read (tasks.json); /tasks?status=active is the work being built, which
|
|
255
|
+
// the living city counts as agents on the move. Query strings are otherwise ignored,
|
|
256
|
+
// so a read that differs only by query names its fixture here, ahead of the exact match.
|
|
257
|
+
const QUERY_FIXTURES = [
|
|
258
|
+
[/^tasks$/, (q) => (q.status === 'active' ? 'tasks__status-active' : null)],
|
|
259
|
+
];
|
|
260
|
+
|
|
250
261
|
function fixtureFor(apiPath, query = {}) {
|
|
262
|
+
for (const [re, pick] of QUERY_FIXTURES) {
|
|
263
|
+
const name = re.test(apiPath) ? pick(query) : null;
|
|
264
|
+
const hit = name && loadFixture(name);
|
|
265
|
+
if (hit !== undefined && hit !== null) return hit;
|
|
266
|
+
}
|
|
251
267
|
const exact = loadFixture(apiPath.replace(/\//g, '__'));
|
|
252
268
|
if (exact !== undefined) return exact;
|
|
253
269
|
for (const [re, name] of FIXTURE_FALLBACKS) {
|
|
@@ -330,6 +346,16 @@ app.use((req, res, next) => {
|
|
|
330
346
|
const READ_SHAPED_POSTS = new Set([
|
|
331
347
|
'inbox/preview-landing', // the landing verdict for a draft (task 1004083)
|
|
332
348
|
]);
|
|
349
|
+
// task 1004685: RECORDED WRITES (pref-writes.js) — the five /me/* pref PATCHes get
|
|
350
|
+
// the real route's answer from the real shapers, and the state rides later reads.
|
|
351
|
+
if (PREFS.handles(req.method, apiPath)) {
|
|
352
|
+
return readJsonBody(req).then((body) => {
|
|
353
|
+
if (body === null) return res.status(400).json({ error: { code: 'bad_json', message: 'the body is not JSON' } });
|
|
354
|
+
const me = liveOverrides.get('me') ?? fixtureFor('me');
|
|
355
|
+
const r = PREFS.write(apiPath, body, { me: RANK_OVERRIDE && me && me.builder ? { ...me, builder: { ...me.builder, rank: RANK_OVERRIDE } } : me, skillFixture: fixtureFor('me/skill-prefs') });
|
|
356
|
+
return res.status(r.status).json(r.body);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
333
359
|
const canned = CANNED_WRITES.find(([method, re]) => method === req.method && re.test(apiPath));
|
|
334
360
|
if (canned) {
|
|
335
361
|
const hit = loadFixture(canned[2]);
|
|
@@ -353,7 +379,7 @@ app.use((req, res, next) => {
|
|
|
353
379
|
if (payload.builder) payload.builder.rank = RANK_OVERRIDE;
|
|
354
380
|
else if ('rank' in payload) payload.rank = RANK_OVERRIDE;
|
|
355
381
|
}
|
|
356
|
-
res.json(payload);
|
|
382
|
+
res.json(PREFS.overlay(apiPath, payload));
|
|
357
383
|
});
|
|
358
384
|
|
|
359
385
|
// The hall pages, pre-auth-gate, through the real seams: boot-time stamped
|
|
@@ -79,8 +79,10 @@ for src in "${SOURCES[@]}"; do
|
|
|
79
79
|
png="${src%.mmd}.png"
|
|
80
80
|
echo "render-diagrams: $src -> $svg + $png"
|
|
81
81
|
# SVG first (vector, scale-independent — the interactive viewer's primary
|
|
82
|
-
# asset), then PNG at 2x (the static fallback). White background so
|
|
83
|
-
#
|
|
82
|
+
# asset), then PNG at 2x (the static fallback). White background so an
|
|
83
|
+
# external embed never shows through as transparent. The hall drops the SVG's
|
|
84
|
+
# white and, in dark, inverts both (task 1003639) — ONE light render serves
|
|
85
|
+
# both themes, so do not add a second, dark render here.
|
|
84
86
|
"${MMDC[@]}" -i "$src" -o "$svg" -b white "${CONFIG_ARGS[@]}" >/dev/null 2>&1
|
|
85
87
|
"${MMDC[@]}" -i "$src" -o "$png" -s 2 -b white "${CONFIG_ARGS[@]}" >/dev/null 2>&1
|
|
86
88
|
done
|
package/src/bongos/auth-idp.js
CHANGED
|
@@ -272,11 +272,16 @@ function applicationVouchIdentity(token, { githubLogin, publicKey, clientId, now
|
|
|
272
272
|
try {
|
|
273
273
|
payload = verifyHubApplicationVouch(token, { publicKey, clientId, nowMs });
|
|
274
274
|
} catch (err) {
|
|
275
|
-
|
|
275
|
+
// debug, not info: this runs on the PUBLIC unauthenticated POST /access-requests,
|
|
276
|
+
// so anyone with a garbage header can trigger the line.
|
|
277
|
+
log.debug({ reason: err.message }, 'application vouch not accepted — the request is written unvouched');
|
|
276
278
|
return null;
|
|
277
279
|
}
|
|
278
280
|
// The body's login is a string anyone may type: a vouch for another account is no vouch.
|
|
279
281
|
if (String(payload.applicant_login).toLowerCase() !== String(githubLogin || '').toLowerCase()) return null;
|
|
282
|
+
// The hub mints only for a numeric GitHub id; anything else is not one, and the id
|
|
283
|
+
// feeds readApplicantProfileFromHub (defence in depth, the ADR 0171 D2 guard).
|
|
284
|
+
if (!/^\d+$/.test(String(payload.sub))) return null;
|
|
280
285
|
if (vouchJtiSeenAndRecord(payload.jti, payload.exp, nowMs)) return null;
|
|
281
286
|
return {
|
|
282
287
|
githubId: String(payload.sub),
|
|
@@ -52,6 +52,7 @@ const SETTING_KEYS = {
|
|
|
52
52
|
SHIP_MODE: 'ship.mode',
|
|
53
53
|
TWEAK_AUTOPICKUP: 'tweak.autopickup',
|
|
54
54
|
TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
|
|
55
|
+
SPEND_CAP: 'treasury.spend_cap_usd',
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
// SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
|
|
@@ -349,6 +350,41 @@ function describeTweakAutopickup(resolved) {
|
|
|
349
350
|
return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month. The artist still approves every one.`;
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
// THE SPEND CAP (task 1004712, ADR 0379). The owner's ruling: there is NO spend
|
|
354
|
+
// limit by default. A cap is an optional project setting, OFF until someone sets
|
|
355
|
+
// it — no row means off, and clearing it deletes the row, so "never set" and
|
|
356
|
+
// "cleared" are the same state (the getSetting null-is-meaningful rule). The
|
|
357
|
+
// treasury (modules/government/treasury.js) reads it: with a cap on, a month past
|
|
358
|
+
// it is said so and a new loan is recommended against. It never stops work.
|
|
359
|
+
const MAX_SPEND_CAP_USD = 1000000;
|
|
360
|
+
function parseSpendCap(raw) {
|
|
361
|
+
if (raw == null || raw === '') return { reason: 'empty' };
|
|
362
|
+
const n = Number(raw);
|
|
363
|
+
if (!Number.isFinite(n)) return { reason: 'not a number' };
|
|
364
|
+
if (n <= 0) return { reason: 'must be more than $0; clear the cap to turn it off' };
|
|
365
|
+
if (n > MAX_SPEND_CAP_USD) return { reason: `out of range (1..${MAX_SPEND_CAP_USD})` };
|
|
366
|
+
return { usd: Math.round(n * 100) / 100 };
|
|
367
|
+
}
|
|
368
|
+
// resolveSpendCap() — { on, usd, source, invalid }. Off (usd null) unless a usable
|
|
369
|
+
// value is stored; an unreadable database answers off, the reporting posture.
|
|
370
|
+
async function resolveSpendCap() {
|
|
371
|
+
const r = await resolveOne(SETTING_KEYS.SPEND_CAP, parseSpendCap, (g) => g.usd, null);
|
|
372
|
+
return { on: r.value != null, usd: r.value, source: r.source, invalid: r.invalid };
|
|
373
|
+
}
|
|
374
|
+
// setSpendCap(usd, builderId) — set it, or turn it off with null.
|
|
375
|
+
async function setSpendCap(usd, builderId = null) {
|
|
376
|
+
if (usd == null || usd === '') {
|
|
377
|
+
await pool.query('DELETE FROM project_settings WHERE key = $1', [SETTING_KEYS.SPEND_CAP]);
|
|
378
|
+
logger.info(`project spend cap turned off by builder ${builderId == null ? 'unknown' : builderId}`);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
return setSetting(SETTING_KEYS.SPEND_CAP, usd, builderId);
|
|
382
|
+
}
|
|
383
|
+
function describeSpendCap(resolved) {
|
|
384
|
+
const r = resolved || {};
|
|
385
|
+
return r.on ? `On: the treasury holds each month to $${Math.round(r.usd)}. Work is never stopped by it.` : 'Off: there is no spend limit. This is the default.';
|
|
386
|
+
}
|
|
387
|
+
|
|
352
388
|
// parseDays is exported for the tests only — the range check and the default are
|
|
353
389
|
// asserted against the SAME constants the resolver reads, because a test that
|
|
354
390
|
// re-typed 30 or 365 would be the second copy of the number this file exists to
|
|
@@ -378,4 +414,9 @@ module.exports = {
|
|
|
378
414
|
parseAutopickupCap,
|
|
379
415
|
resolveTweakAutopickup,
|
|
380
416
|
describeTweakAutopickup,
|
|
417
|
+
MAX_SPEND_CAP_USD,
|
|
418
|
+
parseSpendCap,
|
|
419
|
+
resolveSpendCap,
|
|
420
|
+
setSpendCap,
|
|
421
|
+
describeSpendCap,
|
|
381
422
|
};
|
|
@@ -293,6 +293,33 @@ const GOVERNMENT_PINS = {
|
|
|
293
293
|
// writes the table; neither route does.
|
|
294
294
|
'POST /government/board/decision-rules': 'perm:board.item.open',
|
|
295
295
|
'POST /government/board/matters': 'perm:board.item.open',
|
|
296
|
+
// task 1004706: the project's laws — a change is an amendment by another door
|
|
297
|
+
// (same atom as proposing one); the law book is read where the room is. Only a
|
|
298
|
+
// passed amendment writes a law; neither route does.
|
|
299
|
+
'POST /government/board/laws': 'perm:board.item.open',
|
|
300
|
+
'GET /government/laws': 'perm:board.vote.cast',
|
|
301
|
+
'GET /government/presets': 'perm:board.vote.cast',
|
|
302
|
+
// task 1004716: the government's counts are read where the docket they count is.
|
|
303
|
+
'GET /government/counts': 'perm:board.vote.cast',
|
|
304
|
+
'POST /government/board/presets': 'perm:board.item.open',
|
|
305
|
+
// task 1004709: the Lead of each building, read where the room is. A Lead is
|
|
306
|
+
// appointed and removed only by a passed People matter (the matters route).
|
|
307
|
+
'GET /government/leads': 'perm:board.vote.cast',
|
|
308
|
+
// task 1004711: a Lead's build-out. The room's atom; the wall is server-side —
|
|
309
|
+
// only the building's holder may ask, and build_rights decides at once or a vote.
|
|
310
|
+
'POST /government/buildouts': 'perm:board.vote.cast',
|
|
311
|
+
// task 1004712: the treasury. Read where the room is; a pot is set only by whoever
|
|
312
|
+
// answers for the Treasury, and a borrow answered only by the seat it waits on —
|
|
313
|
+
// both walls server-side, per request.
|
|
314
|
+
'GET /government/treasury': 'perm:board.vote.cast',
|
|
315
|
+
'PUT /government/treasury/pots/:goalId': 'perm:board.vote.cast',
|
|
316
|
+
'POST /government/treasury/borrows/:borrowId/answer': 'perm:board.vote.cast',
|
|
317
|
+
// task 1004703: the ask, one shape for every request from an agent to a person.
|
|
318
|
+
// Checked and written where the docket is read; the write also passes the
|
|
319
|
+
// matter's own source gate (docket.findMatter), so an ask never outreaches its matter.
|
|
320
|
+
'POST /government/asks/check': 'perm:board.vote.cast',
|
|
321
|
+
'PUT /government/asks/:matterId': 'perm:board.vote.cast',
|
|
322
|
+
'POST /government/asks/:matterId/pass': 'perm:board.vote.cast',
|
|
296
323
|
// task 1004564: a proposer withdraws their own open sitting before any vote —
|
|
297
324
|
// the proposer's half of putting one up, so the same atom. The proposer-only
|
|
298
325
|
// and no-votes walls are in withdrawItem (board-withdraw.js) and its write.
|