@bongos/core 1.24.45 → 1.24.46

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.
Files changed (106) hide show
  1. package/.bongos-core.json +140 -105
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +15 -0
  8. package/docs/api/openapi.json +152 -3
  9. package/docs/api-reference.md +4 -2
  10. package/docs/copy-inventory.md +1102 -1102
  11. package/docs/copy-registry.json +1538 -1538
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/onboarding/diagrams/assertions.json +2 -2
  14. package/docs/page-inventory.json +11 -0
  15. package/docs/page-readings.json +724 -724
  16. package/migrations/core_295_runner_logins.sql +50 -0
  17. package/modules/government/session-scopes.json +5 -0
  18. package/modules/hall-ui/public/ai-studio.states.json +2 -0
  19. package/modules/hall-ui/public/atlas.states.json +2 -0
  20. package/modules/hall-ui/public/board-room.states.json +2 -0
  21. package/modules/hall-ui/public/city.states.json +2 -0
  22. package/modules/hall-ui/public/collab.states.json +2 -0
  23. package/modules/hall-ui/public/commands.states.json +2 -0
  24. package/modules/hall-ui/public/copy-desk.states.json +2 -0
  25. package/modules/hall-ui/public/deploy.states.json +2 -0
  26. package/modules/hall-ui/public/goal-map.states.json +2 -0
  27. package/modules/hall-ui/public/goals.css +1 -0
  28. package/modules/hall-ui/public/goals.states.json +2 -0
  29. package/modules/hall-ui/public/government.states.json +2 -0
  30. package/modules/hall-ui/public/idea.states.json +2 -0
  31. package/modules/hall-ui/public/ideas.states.json +2 -0
  32. package/modules/hall-ui/public/index-founding.states.json +2 -0
  33. package/modules/hall-ui/public/index-genesis-decide.states.json +2 -0
  34. package/modules/hall-ui/public/index-genesis-demo.states.json +2 -0
  35. package/modules/hall-ui/public/index-genesis.states.json +2 -0
  36. package/modules/hall-ui/public/index.states.json +5 -0
  37. package/modules/hall-ui/public/market-watch-item.states.json +2 -0
  38. package/modules/hall-ui/public/market-watch-rules.states.json +2 -0
  39. package/modules/hall-ui/public/market-watch.states.json +2 -0
  40. package/modules/hall-ui/public/modules.states.json +2 -0
  41. package/modules/hall-ui/public/money.states.json +2 -0
  42. package/modules/hall-ui/public/my-agents.states.json +2 -0
  43. package/modules/hall-ui/public/notes-panel.css +1 -1
  44. package/modules/hall-ui/public/primer.states.json +2 -0
  45. package/modules/hall-ui/public/profile.states.json +2 -0
  46. package/modules/hall-ui/public/roster.states.json +2 -0
  47. package/modules/hall-ui/public/settings.states.json +2 -0
  48. package/modules/hall-ui/public/street-card.states.json +2 -0
  49. package/modules/hall-ui/public/street.states.json +2 -0
  50. package/modules/hall-ui/public/studio.states.json +2 -0
  51. package/modules/hall-ui/public/style.css +27 -24
  52. package/modules/hall-ui/public/task.states.json +2 -0
  53. package/modules/hall-ui/public/thinking.states.json +2 -0
  54. package/modules/hall-ui/public/tweak-editor.states.json +2 -0
  55. package/modules/hall-ui/public/watch.states.json +2 -0
  56. package/modules/hall-ui/public/welcome-boot.js +6 -2
  57. package/modules/hall-ui/public/work-founding.states.json +2 -0
  58. package/modules/hall-ui/public/work.states.json +3 -1
  59. package/modules/lifecycle/db-claims.js +6 -3
  60. package/modules/lifecycle/routes/claims.js +10 -0
  61. package/modules/lifecycle/routes/tasks.js +1 -1
  62. package/modules/lifecycle/runner-self-guard.js +19 -10
  63. package/modules/platform-identity/platform-identity.js +1 -1
  64. package/modules/platform-identity/tests/platform-identity.mjs +13 -2
  65. package/modules/provisioning/core-upgrade-authz.js +8 -0
  66. package/modules/public-landing/public/account.html +1 -0
  67. package/modules/public-landing/public/community.html +1 -0
  68. package/modules/public-landing/public/connections.html +1 -0
  69. package/modules/public-landing/public/contact.html +1 -0
  70. package/modules/public-landing/public/guild.html +1 -0
  71. package/modules/public-landing/public/index.html +1 -0
  72. package/modules/public-landing/public/invitations.html +1 -0
  73. package/modules/public-landing/public/privacy.html +1 -0
  74. package/modules/public-landing/public/projects.html +1 -0
  75. package/modules/public-landing/public/terms.html +1 -0
  76. package/modules/raise/views/gate.html +1 -0
  77. package/modules/raise/views/nda.html +1 -0
  78. package/modules/raise/views/page.html +1 -0
  79. package/modules/status-ui/public/index.html +1 -0
  80. package/modules/ui-design/kit/README.md +1 -1
  81. package/modules/ui-design/kit/fixtures/versions.json +1 -0
  82. package/modules/ui-design/kit/lib.js +48 -3
  83. package/modules/ui-design/kit/probe.js +1 -0
  84. package/modules/ui-design/kit/render.js +2 -0
  85. package/modules/ui-design/kit/serve.js +1 -0
  86. package/modules/ui-design/module.json +1 -1
  87. package/modules/ui-design/public/tokens.css +125 -0
  88. package/package-lock.json +2 -2
  89. package/package.json +1 -1
  90. package/release-notes.json +14 -0
  91. package/scripts/bongos/fitness.js +1 -1
  92. package/scripts/bongos/run-unit-tests.js +3 -0
  93. package/src/bongos/auth.js +25 -4
  94. package/src/bongos/route-rank-check.js +5 -0
  95. package/src/bongos/routes/auth.js +48 -0
  96. package/src/bongos/runner-login.js +151 -0
  97. package/src/bongos/serve-internal.js +6 -1
  98. package/src/module-api.js +1 -1
  99. package/tests/autobongos_runner_login.mjs +296 -0
  100. package/tests/autobongos_runner_login_db.mjs +105 -0
  101. package/tests/design_tokens_shared.mjs +183 -0
  102. package/tests/government_session_scope.mjs +2 -1
  103. package/tests/hall_reading_world.mjs +5 -1
  104. package/tests/hall_tokens.mjs +22 -3
  105. package/tests/ui_design_kit.mjs +30 -0
  106. package/tests/welcome_boot_browser.mjs +4 -0
@@ -0,0 +1,125 @@
1
+ /* modules/ui-design/public/tokens.css — THE SHARED TOKEN SHEET (task 1005081,
2
+ goal 1000138 "One Cloud Bongos design system").
3
+
4
+ One sheet every Cloud Bongos surface loads — the hall, the landing + the
5
+ projects hub, the status page and the raise page — served at the one stable
6
+ path /modules/ui-design/public/tokens.css on every host (serve-internal.js's
7
+ module-asset route; the kit's stub serves the same path).
8
+
9
+ WHAT IS HERE: the named SCALES — type sizes, spacing, corners, shadows,
10
+ motion and stacking layers. Every value was already in use (the hall's token
11
+ block, DESIGN.md's rhythm/shapes/motion), so loading this sheet moves nothing.
12
+
13
+ WHAT IS NOT HERE: the fifteen brand tokens (thirteen colours + two font
14
+ stacks). Those are the instance pack's API — serve-internal.js themeMarkup()
15
+ splices config/branding.*.json theme.ui as a plain :root at the END of <head>,
16
+ after every sheet, so the pack always wins. This sheet declares none of the
17
+ fifteen, so there is nothing for it to win against. No colour literal lives
18
+ here except black with alpha (the shadows) — the Fifteen Rule's one exception.
19
+
20
+ WHY --ds-*: the surfaces grew their own scale names, and some collide with a
21
+ DIFFERENT meaning (cosmos.css spends --line as a colour; raise.css sets
22
+ --radius to 10px). A shared name would silently change whichever surface did
23
+ not define it, so every scale is namespaced and each surface keeps its own
24
+ names as aliases until a later task migrates it (the hall's aliases live in
25
+ modules/hall-ui/public/style.css: --radius → --ds-radius-panel, …).
26
+
27
+ DARK TWINS: the one mode-dependent scale (the shadow) is written twice,
28
+ byte-identical — the switch rule (the hall's html[data-theme], the apex's
29
+ :root[data-mode]) and the prefers-color-scheme twin for first paint.
30
+
31
+ KILL SWITCH: each surface keeps its own `animation/transition: none
32
+ !important` rule; this sheet additionally zeroes its durations under
33
+ prefers-reduced-motion so a rule built on them stays still too. */
34
+
35
+ :root {
36
+ /* ---- type sizes: the steps the hall's 113 literal sizes cluster on, small
37
+ to large. The "-plus" half steps are the ones a later migration
38
+ should collapse into their neighbours. The four fluid roles are
39
+ DESIGN.md's display/headline/figure/title. ---- */
40
+ --ds-text-3xs: 10.5px;
41
+ --ds-text-2xs: 11px;
42
+ --ds-text-2xs-plus: 11.5px;
43
+ --ds-text-xs: 12px;
44
+ --ds-text-xs-plus: 12.5px;
45
+ --ds-text-sm: 13px;
46
+ --ds-text-sm-plus: 13.5px;
47
+ --ds-text-md: 14px;
48
+ --ds-text-md-plus: 14.5px;
49
+ --ds-text-lg: 15px;
50
+ --ds-text-xl: 16px;
51
+ --ds-text-2xl: 17px;
52
+ --ds-text-3xl: 20px;
53
+ --ds-text-4xl: 22px;
54
+ --ds-text-5xl: 26px;
55
+ --ds-text-title: clamp(23px, 2.4vw, 32px);
56
+ --ds-text-headline: clamp(26px, 3.1vw, 42px);
57
+ --ds-text-figure: clamp(30px, 3.7vw, 52px);
58
+ --ds-text-display: clamp(34px, 6.7vw, 96px);
59
+
60
+ /* ---- spacing: DESIGN.md's rhythm (6/10/14/22/34/52) + the band gutters ---- */
61
+ --ds-space-hair: 6px;
62
+ --ds-space-xs: 10px;
63
+ --ds-space-sm: 14px;
64
+ --ds-space-md: 22px;
65
+ --ds-space-lg: 34px;
66
+ --ds-space-xl: 52px;
67
+ --ds-space-gutter: 26px;
68
+ --ds-space-gutter-narrow: 20px;
69
+
70
+ /* ---- the hairline: a WIDTH, never a colour ---- */
71
+ --ds-line: 1px;
72
+
73
+ /* ---- corners: pill or nothing for controls; panels and tiles ---- */
74
+ --ds-radius-pill: 999px;
75
+ --ds-radius-panel: 16px;
76
+ --ds-radius-tile: 10px;
77
+
78
+ /* ---- shadows: the world has no UI shadow vocabulary; this is spent only
79
+ where an element floats over a scrim. Black with alpha. ---- */
80
+ --ds-shadow-pop: 0 18px 40px rgba(0, 0, 0, .28);
81
+
82
+ /* ---- motion: state changes take the one easing; an authored entrance
83
+ takes the exponential ease-out (DESIGN.md, Motion). ---- */
84
+ --ds-dur-fast: 150ms;
85
+ --ds-dur-base: 200ms;
86
+ --ds-dur-slow: 320ms;
87
+ --ds-dur-slower: 420ms;
88
+ --ds-dur-arrive: 700ms;
89
+ --ds-stagger: 70ms;
90
+ --ds-ease: cubic-bezier(.2, .7, .3, 1);
91
+ --ds-ease-arrive: cubic-bezier(.16, 1, .3, 1);
92
+
93
+ /* ---- stacking layers, low to high: the reading hairline under the top
94
+ bar, the chrome, the phone drawer over its scrim, a confirm dialog
95
+ over the drawer, the fullscreen diagram over everything. ---- */
96
+ --ds-z-progress: 25;
97
+ --ds-z-topbar: 30;
98
+ --ds-z-sidebar: 40;
99
+ --ds-z-scrim: 140;
100
+ --ds-z-drawer: 150;
101
+ --ds-z-modal: 160;
102
+ --ds-z-diagram-full: 9999;
103
+ }
104
+
105
+ /* ---------- dark: written twice, byte-identical ---------- */
106
+ html[data-theme="dark"], :root[data-mode="dark"] {
107
+ --ds-shadow-pop: 0 18px 40px rgba(0, 0, 0, .62);
108
+ }
109
+ @media (prefers-color-scheme: dark) {
110
+ html:not([data-theme]):not([data-mode="light"]) {
111
+ --ds-shadow-pop: 0 18px 40px rgba(0, 0, 0, .62);
112
+ }
113
+ }
114
+
115
+ /* ---------- the kill switch, for the motion scale ---------- */
116
+ @media (prefers-reduced-motion: reduce) {
117
+ :root {
118
+ --ds-dur-fast: 0ms;
119
+ --ds-dur-base: 0ms;
120
+ --ds-dur-slow: 0ms;
121
+ --ds-dur-slower: 0ms;
122
+ --ds-dur-arrive: 0ms;
123
+ --ds-stagger: 0ms;
124
+ }
125
+ }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.45",
3
+ "version": "1.24.46",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.45",
9
+ "version": "1.24.46",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.45",
3
+ "version": "1.24.46",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11233,5 +11233,19 @@
11233
11233
  "id": "1005070",
11234
11234
  "text": "The hall's sidebar now appears instantly on every page instead of popping in late, and Building mode shows a single Home button."
11235
11235
  }
11236
+ ],
11237
+ "1.24.46": [
11238
+ {
11239
+ "id": "1005081",
11240
+ "text": "Cloud Bongos now has one shared set of design measurements (text sizes, spacing, corner shapes, shadows, animation timing and layering) that the builders hall, the landing page, the projects map, the status page and the invest"
11241
+ },
11242
+ {
11243
+ "id": "1004547",
11244
+ "text": "An Autobongos PC can now sign in with a login of its own, so the server always knows its work is automatic and it can never move a live version or flip its own off switch."
11245
+ },
11246
+ {
11247
+ "id": "1005080",
11248
+ "text": "The screenshot checker builders use before shipping a design change shows the real hall pages again, instead of a pop-up covering every page. Two small accessibility fixes came with it: a few tiny links are now big enough to t"
11249
+ }
11236
11250
  ]
11237
11251
  }
@@ -540,7 +540,7 @@ const KERNEL_FILES = [
540
540
  'src/bongos/auth-admission.js', // membership kind, admission gate, first-admin seating
541
541
  'src/bongos/auth-github.js', // GitHub device + web OAuth flows
542
542
  'src/bongos/auth-idp.js', // hub federation, assertion + logout-token verification
543
- 'src/bongos/auth-discord.js', // Discord OAuth link + guild membership
543
+ 'src/bongos/auth-discord.js', 'src/bongos/runner-login.js', // Discord OAuth link + guild membership; an Autobongos PC's own login + its limits (task 1004547; requires only pool/db-kernel)
544
544
  'src/bongos/route-rank-check.js', // deterministic route→rank classification
545
545
  'src/bongos/permission-path-check.js', // protected-path matcher (PROTECTED_GLOBS, ADR 0043)
546
546
  'src/bongos/goal-scope-check.js', // goal create-gate scope wall (composes the 3 matchers above; ADR 0086 §3, BV1.R60)
@@ -140,6 +140,9 @@ const INTEGRATION = new Set([
140
140
  // task 1004677: two marked auto-pickup claims racing for the last slot of the
141
141
  // monthly cap; only real connections prove the advisory lock. Scratch DB only.
142
142
  'autopickup_cap_db',
143
+ // task 1004547: an Autobongos runner's one-time code works once, even when two
144
+ // PCs race it, and not after it expires; only real SQL proves both. Scratch DB.
145
+ 'autobongos_runner_login_db',
143
146
  'claims_requested_worktree_db',
144
147
  'clawback_confirmed_backlog',
145
148
  // task 1003113: the copy_desk_flags schema half. The module's own tests are
@@ -51,6 +51,8 @@ const { pool } = require('./pool');
51
51
  // the optional `?.` degrades to a no-op when the module is off (the stage tick is
52
52
  // best-effort anyway).
53
53
  const seams = require('../module-seams');
54
+ // task 1004547: an Autobongos runner's own login holds no SYSTEM permission.
55
+ const { isRunnerSession, withoutSystemPermissions, capRunnerRank } = require('./runner-login');
54
56
  const ic = require('../instance-config');
55
57
  const { branding, userAgent } = require('../branding');
56
58
 
@@ -284,7 +286,11 @@ async function requireBuilder(req, res, next) {
284
286
  // rank is the single source of authority (ADR 0016). Read fresh from the
285
287
  // DB on every request via lookupSession — never cached, never from the
286
288
  // token — so demotion takes effect on the builder's very next request.
287
- rank: session.rank,
289
+ // An Autobongos runner's own login acts at Metic at most (task 1004547,
290
+ // runner-login.js capRunnerRank), so every requireRank gate and every handler
291
+ // that reads this field sees the capped rank; `runner` says why.
292
+ rank: isRunnerSession(session) ? capRunnerRank(session.rank, RANKS) : session.rank,
293
+ runner: isRunnerSession(session),
288
294
  // system_role identifies a non-human service principal (NULL for every
289
295
  // human builder). The BFG cross-builder write gate (requireBfgPrincipal)
290
296
  // keys on this EXACT value — never on rank — so an Archon token cannot
@@ -456,7 +462,22 @@ async function pageGateAlsoAdmits(name, session) {
456
462
  // query fold, not the cross-request memo ADR 0016 forbids. Anything unexpected —
457
463
  // no raw keys on the row, an older port without the pure half, a throw — falls
458
464
  // through to the DB resolve, so the fold can only ever be an optimization.
459
- async function resolveHeldPermissions(builderId, rawKeys) {
465
+ //
466
+ // A RUNNER SESSION HOLDS NO SYSTEM PERMISSION (task 1004547). `session` is the
467
+ // caller's session row; when its source is 'runner' (an Autobongos PC's own login,
468
+ // src/bongos/runner-login.js) every key the catalog marks system:true is dropped —
469
+ // core.pin.move, autonomy.fence.manage, builder.rank.set and the rest. The owner
470
+ // rule (ADR 0307 §6) is that an unattended machine never moves a live version or
471
+ // touches its own kill switch; this is where the server, which the PC cannot edit,
472
+ // makes that true. An unknown key, or a port without isSystem, drops too.
473
+ async function resolveHeldPermissions(builderId, rawKeys, session = null) {
474
+ const held = await resolveHeldPermissionsForBuilder(builderId, rawKeys);
475
+ if (held === null || !isRunnerSession(session)) return held;
476
+ const governance = seams.resolveOptional('government');
477
+ return withoutSystemPermissions(held, governance && governance.isSystem);
478
+ }
479
+
480
+ async function resolveHeldPermissionsForBuilder(builderId, rawKeys) {
460
481
  if (builderId === null || builderId === undefined || builderId === '') return null;
461
482
  const governance = seams.resolveOptional('government');
462
483
  if (!governance || typeof governance.resolveBuilderPermissions !== 'function') return null;
@@ -506,7 +527,7 @@ async function runPageGate(name, req, res, next) {
506
527
  res.redirect(302, signInWithReturn(req));
507
528
  return;
508
529
  }
509
- const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
530
+ const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys, session);
510
531
  if (held === null || !held.has(permission)) {
511
532
  // The rank door said no. A page whose data route admits a SECOND audience
512
533
  // asks that door before redirecting (ADR 0210) — consulted here and nowhere
@@ -770,7 +791,7 @@ function requirePermission(...perms) {
770
791
  // (no port / malformed port / a throwing resolver) and is always a denial.
771
792
  // req.rawPermissionKeys rides the fold (task 1002556) when the session query
772
793
  // carried the grant keys; absent, the DB resolve runs exactly as before.
773
- const held = await resolveHeldPermissions(builderId, req.rawPermissionKeys);
794
+ const held = await resolveHeldPermissions(builderId, req.rawPermissionKeys, req.gdsSession);
774
795
  if (held === null) return deny(null);
775
796
  if (required.every((key) => held.has(key))) return next();
776
797
  return deny(required.filter((key) => held.has(key)));
@@ -199,6 +199,11 @@ const EXPECTED_RANKS = {
199
199
  // session revocation (kill another builder's / all sessions)
200
200
  'POST /auth/revoke': 'perm:auth.session.revoke',
201
201
  'POST /auth/revoke-all': 'perm:auth.session.revoke',
202
+ // An Autobongos runner's own login (task 1004547). Issuing the one-time code is
203
+ // Metic+ (who may run a runner); pinned so a widening cannot hand a Xenos a
204
+ // machine login. The exchange is a public auth-establishment route like
205
+ // /auth/device/exchange (the code IS the credential), so it carries no pin.
206
+ 'POST /auth/runner-code/issue': 'metic+archon',
202
207
  // deliberately any-builder (task 1003341) — the OWN-scoped half of the same
203
208
  // capability. These reach only the caller's own sessions (every statement is
204
209
  // keyed on req.builder.id), so a rank floor here would just put an Archon back
@@ -35,6 +35,8 @@ const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
35
35
  // endpoint refuses. Same composed answer the gate itself reads.
36
36
  const { effectiveJoinDoor } = require('../project-door.js');
37
37
  const db = require('../db');
38
+ // task 1004547: an Autobongos PC's own login, from a one-time code.
39
+ const { issueCode: issueRunnerCode, exchangeCode: exchangeRunnerCode } = require('../runner-login');
38
40
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
39
41
  const { accountExistenceReadRateLimit } = require('../middleware/rate-limit');
40
42
  const { branding } = require('../../branding');
@@ -1364,6 +1366,52 @@ module.exports = function buildAuthRouter() {
1364
1366
  }
1365
1367
  });
1366
1368
 
1369
+ // POST /auth/runner-code/issue — task 1004547 (goal 1000139). A ONE-TIME CODE an
1370
+ // Autobongos PC trades for its own login (src/bongos/runner-login.js). Body:
1371
+ // { label? } — the PC's name, shown later in the builder's sessions list.
1372
+ //
1373
+ // rank: metic+archon — running a runner is Metic+ today (owner, 2026-11-06).
1374
+ // Cookie ONLY, like /auth/cli-token/issue: a bearer — a CLI token or a runner's
1375
+ // own login — must never be able to mint another login. The code is returned
1376
+ // once; only its hash is stored. It works once and expires in 15 minutes.
1377
+ router.post('/auth/runner-code/issue', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1378
+ if (validateOrRespond(req, res, { label: { type: 'string', maxLength: 200 } })) return;
1379
+ if (!sessionCookieToken(req)) {
1380
+ return res.fail('cookie_required', { status: 403, message: 'A runner code is issued from the builders hall in a browser (Settings), never from a token, so a leaked token cannot mint a login.' });
1381
+ }
1382
+ try {
1383
+ const out = await issueRunnerCode({ builderId: req.builder.id, label: req.body && req.body.label });
1384
+ const issuer = String(resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin() || '').trim().replace(/\/+$/, '');
1385
+ res.status(201).json({ ok: true, code: out.code, expires_at: out.expires_at, api_base: issuer || null });
1386
+ } catch (err) {
1387
+ log.error('[gds] POST /auth/runner-code/issue', err && err.message);
1388
+ res.fail('runner_code_failed', { status: 500, message: 'internal error' });
1389
+ }
1390
+ });
1391
+
1392
+ // POST /auth/runner-code/exchange — task 1004547. The PC sends the one-time code
1393
+ // and gets back its own login (a 'runner' session). Body: { code }.
1394
+ //
1395
+ // rank: public — auth establishment; the one-time code is the bearer. A wrong,
1396
+ // used or expired code all answer the same 401, so nothing is learned from which.
1397
+ // The router's per-IP write limit (rateLimitMiddleware) is its abuse ceiling; the
1398
+ // code is 256 bits, so guessing one is not a race worth a limiter of its own.
1399
+ router.post('/auth/runner-code/exchange', async (req, res) => {
1400
+ if (validateOrRespond(req, res, { code: { required: true, type: 'string', maxLength: 200 } })) return;
1401
+ try {
1402
+ const out = await exchangeRunnerCode({ code: req.body.code, userAgent: req.headers['user-agent'] ?? null });
1403
+ if (out.refused === 'builder_inactive') return res.fail('builder_inactive', 403);
1404
+ if (out.refused) {
1405
+ return res.fail('runner_code_invalid', { status: 401, message: 'That runner code is wrong, already used or expired. Make a new one in Settings.' });
1406
+ }
1407
+ const issuer = String(resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin() || '').trim().replace(/\/+$/, '');
1408
+ res.json({ ok: true, token: out.token, session_id: out.session_id, builder_login: out.github_login, label: out.label, api_base: issuer || null });
1409
+ } catch (err) {
1410
+ log.error('[gds] POST /auth/runner-code/exchange', err && err.message);
1411
+ res.fail('runner_code_failed', { status: 500, message: 'internal error' });
1412
+ }
1413
+ });
1414
+
1367
1415
  // POST /auth/revoke — V3.R38 / #259. Archon-only. Kill a SINGLE session by
1368
1416
  // its primary-key id. Used for surgical lockouts (e.g. a compromised CLI on
1369
1417
  // a known builder's laptop) without disturbing that builder's other sessions.
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/runner-login.js — an Autobongos runner's OWN login (task 1004547, goal
4
+ // 1000139, ADR 0307 §6).
5
+ //
6
+ // WHY. A runner used to run under its builder's CLI session. The server then could
7
+ // not tell a runner's work from the person's, except by the worktree name the runner
8
+ // chose, and the worker carried the person's full authority (it could move a live
9
+ // core pin, task 1004974). Now the PC gets a login of its own:
10
+ //
11
+ // 1. In the builders hall (a browser, so a cookie session), a Metic+ builder asks
12
+ // for a ONE-TIME CODE (issueCode). The plaintext is shown once; only its sha256
13
+ // is stored. It expires after CODE_TTL_MS and works once.
14
+ // 2. The setup command on the PC trades the code for a session whose source is
15
+ // 'runner' (exchangeCode). That session belongs to the same builder, so the
16
+ // ledger credits them, but the server knows it is a machine working alone.
17
+ // 3. What 'runner' changes, server-side, where the PC cannot reach:
18
+ // - every claim it makes is marked autonomous (claims.autonomous), whatever
19
+ // the worktree is called, and the publish guard reads that mark;
20
+ // - it holds no SYSTEM permission (core.pin.move, autonomy.fence.manage, …),
21
+ // and the project-owner door to a core move is shut to it (auth.js,
22
+ // provisioning core-upgrade-authz.js);
23
+ // - it may not mint another login: issuing a code needs a browser cookie.
24
+ // 4. "Remove this PC" is the ordinary own-session revoke; the next call is a 401.
25
+ //
26
+ // The exchange is unauthenticated by design (the PC has no login yet); the code IS
27
+ // the credential, so it is 256 bits of CSPRNG, single-use, short-lived and looked up
28
+ // by its full digest, exactly as a session bearer is (db-kernel sessionTokenHash).
29
+
30
+ const crypto = require('node:crypto');
31
+ const { pool } = require('./pool');
32
+ const { sessionTokenHash } = require('./db-kernel');
33
+
34
+ const RUNNER_SOURCE = 'runner';
35
+ const CODE_TTL_MS = 15 * 60 * 1000; // 15 minutes: long enough to paste, short enough to waste if leaked
36
+ const CODE_PREFIX = 'abr_'; // recognisable in a pasted command, never part of the secret's entropy
37
+ const LABEL_MAX = 80;
38
+
39
+ const isRunnerSession = (session) => !!session && session.source === RUNNER_SOURCE;
40
+
41
+ function cleanLabel(label) {
42
+ if (typeof label !== 'string') return null;
43
+ const s = label.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, LABEL_MAX);
44
+ return s || null;
45
+ }
46
+
47
+ // Issue a one-time code for `builderId`. Returns { code, expires_at, id }. The
48
+ // plaintext code exists only in this return value.
49
+ async function issueCode({ builderId, label = null, now = Date.now() }, exec = pool) {
50
+ const code = CODE_PREFIX + crypto.randomBytes(32).toString('hex');
51
+ const expiresAt = new Date(now + CODE_TTL_MS);
52
+ const { rows } = await exec.query(
53
+ `INSERT INTO runner_enrollment_codes (builder_id, code_hash, label, expires_at)
54
+ VALUES ($1, $2, $3, $4)
55
+ RETURNING id, expires_at`,
56
+ [builderId, sessionTokenHash(code), cleanLabel(label), expiresAt]
57
+ );
58
+ return { id: rows[0].id, code, expires_at: rows[0].expires_at };
59
+ }
60
+
61
+ // Trade a code for a runner session. Returns { token, session_id, builder_id,
62
+ // github_login, label } or a refusal { refused: 'code_invalid' | 'builder_inactive' }.
63
+ // One transaction: the code is burned (used_at) by the same UPDATE that proves it
64
+ // was live, so two PCs racing one code cannot both win. A wrong, used or expired
65
+ // code all answer the same 'code_invalid', so the route leaks nothing about which.
66
+ async function exchangeCode({ code, userAgent = null }, exec = pool) {
67
+ if (typeof code !== 'string' || !code.startsWith(CODE_PREFIX) || code.length > 200) {
68
+ return { refused: 'code_invalid' };
69
+ }
70
+ const client = typeof exec.connect === 'function' ? await exec.connect() : exec;
71
+ const release = () => { if (client !== exec && typeof client.release === 'function') client.release(); };
72
+ try {
73
+ await client.query('BEGIN');
74
+ const { rows } = await client.query(
75
+ `UPDATE runner_enrollment_codes c
76
+ SET used_at = now()
77
+ FROM builders b
78
+ WHERE c.code_hash = $1
79
+ AND c.used_at IS NULL
80
+ AND c.expires_at > now()
81
+ AND b.id = c.builder_id
82
+ RETURNING c.id, c.builder_id, c.label, b.github_login, b.status`,
83
+ [sessionTokenHash(code)]
84
+ );
85
+ const hit = rows[0];
86
+ if (!hit) {
87
+ await client.query('ROLLBACK');
88
+ return { refused: 'code_invalid' };
89
+ }
90
+ if (hit.status === 'inactive') {
91
+ await client.query('ROLLBACK');
92
+ return { refused: 'builder_inactive' };
93
+ }
94
+ const token = crypto.randomBytes(32).toString('hex');
95
+ const ua = [hit.label ? `runner:${hit.label}` : 'runner', userAgent].filter(Boolean).join(' · ').slice(0, 400);
96
+ const ins = await client.query(
97
+ `INSERT INTO builder_sessions (builder_id, token_hash, source, user_agent, expires_at)
98
+ VALUES ($1, $2, $3, $4, NULL)
99
+ RETURNING id`,
100
+ [hit.builder_id, sessionTokenHash(token), RUNNER_SOURCE, ua]
101
+ );
102
+ await client.query(
103
+ `UPDATE runner_enrollment_codes SET used_session_id = $2 WHERE id = $1`,
104
+ [hit.id, ins.rows[0].id]
105
+ );
106
+ await client.query('COMMIT');
107
+ return { token, session_id: ins.rows[0].id, builder_id: hit.builder_id, github_login: hit.github_login, label: hit.label };
108
+ } catch (err) {
109
+ try { await client.query('ROLLBACK'); } catch (_) { /* the original error wins */ }
110
+ throw err;
111
+ } finally {
112
+ release();
113
+ }
114
+ }
115
+
116
+ // The highest rank a runner session ACTS at (task 1004547). A runner does its
117
+ // builder's ordinary work (claim, ship, grade, publish, file a blocker), and every
118
+ // one of those routes is open at Metic. Anything gated above that is an owner's
119
+ // decision, which an unattended machine does not make. `ranks` is the ladder,
120
+ // highest authority first (db-kernel RANKS); an unknown rank is returned as is,
121
+ // because requireRank already fails it closed.
122
+ const RUNNER_RANK_CAP = 'metic';
123
+ function capRunnerRank(rank, ranks) {
124
+ const i = ranks.indexOf(rank);
125
+ const cap = ranks.indexOf(RUNNER_RANK_CAP);
126
+ if (i === -1 || cap === -1) return rank;
127
+ return i < cap ? RUNNER_RANK_CAP : rank;
128
+ }
129
+
130
+ // Drop every SYSTEM permission from a held set, for a runner session. `isSystem` is
131
+ // the government port's catalog.isSystem: true = system, false = not, null =
132
+ // unknown key. Anything but an explicit false is dropped, so an unreadable catalog
133
+ // leaves a runner with less, never more. Without a port at all every key goes.
134
+ function withoutSystemPermissions(held, isSystem) {
135
+ const out = new Set();
136
+ if (typeof isSystem !== 'function') return out;
137
+ for (const key of held) {
138
+ let sys = null;
139
+ try { sys = isSystem(key); } catch (_) { sys = null; }
140
+ if (sys === false) out.add(key);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ module.exports = {
146
+ isRunnerSession,
147
+ issueCode,
148
+ exchangeCode,
149
+ withoutSystemPermissions,
150
+ capRunnerRank,
151
+ };
@@ -1214,10 +1214,15 @@ function mountInternalSurfaces(app) {
1214
1214
  // static handler. Gated per-request on the live enabled set so a disabled module's
1215
1215
  // assets 404 (defense in depth behind the no-injection above). Same Cache-Control
1216
1216
  // posture as the hall's own scripts.
1217
+ //
1218
+ // Served on EVERY host, the generic surface hosts (status.) included (task
1219
+ // 1005081): the shared token sheet /modules/ui-design/public/tokens.css is one
1220
+ // URL the hall, the landing, the status page and raise all load. A surface host
1221
+ // still answers its own files first — its static handler above falls through
1222
+ // only when the surface dir has no such file.
1217
1223
  const MODULE_ASSET_RE = /^\/modules\/([a-z][a-z0-9-]*)\/public\/(.+)$/;
1218
1224
  const MODULES_ROOT = path.join(ROOT, 'modules');
1219
1225
  app.get(MODULE_ASSET_RE, (req, res, next) => {
1220
- if (isModuleSurfaceHost(req)) return next();
1221
1226
  const m = req.path.match(MODULE_ASSET_RE);
1222
1227
  const key = m[1];
1223
1228
  let enabled = false;
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.45'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.46'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');