toolroll 0.9.47 → 0.9.49

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 (60) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/browser/workspace.js +16 -16
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.js +4 -0
  5. package/dist/contracts/cli.d.ts +87 -0
  6. package/dist/contracts/cli.js +2 -0
  7. package/dist/contracts/health.d.ts +84 -0
  8. package/dist/contracts/health.js +11 -0
  9. package/dist/desktop-update.d.ts +1 -1
  10. package/dist/flow-live.d.ts +5 -13
  11. package/dist/flow-live.js +11 -110
  12. package/dist/health-cli.d.ts +3 -0
  13. package/dist/health-cli.js +79 -0
  14. package/dist/live-bus.d.ts +89 -0
  15. package/dist/live-bus.js +321 -0
  16. package/dist/mcp-http.d.ts +1 -0
  17. package/dist/mcp-http.js +6 -4
  18. package/dist/mcp-oauth.d.ts +3 -0
  19. package/dist/mcp-oauth.js +8 -0
  20. package/dist/metrics.d.ts +3 -0
  21. package/dist/metrics.js +53 -1
  22. package/dist/operate.js +37 -24
  23. package/dist/public-access.d.ts +3 -1
  24. package/dist/public-access.js +5 -1
  25. package/dist/read-executor-worker.d.ts +1 -0
  26. package/dist/read-executor-worker.js +26 -0
  27. package/dist/read-executor.d.ts +49 -0
  28. package/dist/read-executor.js +119 -0
  29. package/dist/request-budget.d.ts +31 -3
  30. package/dist/request-budget.js +50 -1
  31. package/dist/serve.d.ts +2 -0
  32. package/dist/serve.js +213 -100
  33. package/dist/server-telemetry.d.ts +86 -0
  34. package/dist/server-telemetry.js +263 -0
  35. package/dist/session-http.d.ts +5 -0
  36. package/dist/session-http.js +13 -0
  37. package/dist/session-server.d.ts +5 -0
  38. package/dist/session-server.js +2 -0
  39. package/dist/sqlite-telemetry.d.ts +9 -0
  40. package/dist/sqlite-telemetry.js +98 -0
  41. package/dist/store.d.ts +6 -0
  42. package/dist/store.js +25 -3
  43. package/dist/surface.js +2 -1
  44. package/dist/task-live.d.ts +5 -14
  45. package/dist/task-live.js +13 -110
  46. package/dist/team-cli.js +28 -6
  47. package/dist/team-contract.d.ts +7 -0
  48. package/dist/team-contract.js +7 -0
  49. package/dist/team-http.d.ts +5 -0
  50. package/dist/team-http.js +19 -3
  51. package/dist/team-leads.d.ts +5 -1
  52. package/dist/team-leads.js +42 -4
  53. package/dist/team-runtime.d.ts +1 -1
  54. package/dist/team-runtime.js +3 -0
  55. package/dist/teams.d.ts +3 -0
  56. package/dist/teams.js +15 -0
  57. package/dist/work-index.js +12 -1
  58. package/dist/workspace-revision.d.ts +2 -0
  59. package/dist/workspace-revision.js +3 -1
  60. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -44,6 +44,7 @@ import { addRoutine, removeRoutine, runRoutine } from "./teammate-desk.js";
44
44
  import { sendTeammateWeeklies, undoCall } from "./teammate-week.js";
45
45
  import { answerTeammateQuestion } from "./teammate-work.js";
46
46
  import { createFlowRooms, flowFingerprint } from "./flow-live.js";
47
+ import { createLiveBus, followWorkspace } from "./live-bus.js";
47
48
  import { createTaskRooms, taskFingerprint } from "./task-live.js";
48
49
  import { runActivityOf } from "./task-activity.js";
49
50
  import { disconnectGoogle, finishGoogleConsent, GOOGLE_CALLBACK, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "./google-mail.js";
@@ -126,8 +127,8 @@ import { projectAuthority } from "./project-access.js";
126
127
  * centralized gate — `authorizeMutation` — that proves content type, an
127
128
  * allowed Origin, and the per-session CSRF nonce, and refuses duplicated
128
129
  * security fields; a mutation route cannot forget a check it never wrote.
129
- * Bearer mutations carry no cookie for a hostile page to ride, so they skip
130
- * the cookie ceremony and nothing else.
130
+ * Bearer mutations carry no cookie for a hostile page to ride, so they skip CSRF.
131
+ * Step-up ceremonies still require a cookie session; repeating a bearer password cannot approve.
131
132
  *
132
133
  * **Approval is step-up.** A session alone never approves a scope: the
133
134
  * approval form restates the goal, the exclusions, and the touches — the
@@ -242,6 +243,7 @@ import { createIntegrationMonitor } from "./integrations.js";
242
243
  import { BACKUP_CSS, backupHtml } from "./backup-ui.js";
243
244
  import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder } from "./backup.js";
244
245
  import { prometheusMetrics } from "./metrics.js";
246
+ import { healthWords, instrumentRequest } from "./server-telemetry.js";
245
247
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
246
248
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
247
249
  import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
@@ -266,9 +268,10 @@ import { targetOf } from "./monitoring.js";
266
268
  import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
267
269
  import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS, tokenLive, tokenProjects } from "./api-tokens.js";
268
270
  import { createMcpHttp } from "./mcp-http.js";
269
- import { FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, parseLimit, PER_DAY_MAX as REQUEST_BUDGET_PER_DAY_MAX, PER_MINUTE_MAX as REQUEST_BUDGET_PER_MINUTE_MAX, RequestBudget, setLimitOverride } from "./request-budget.js";
271
+ import { FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, limitWords, parseLimit, PER_DAY_MAX as REQUEST_BUDGET_PER_DAY_MAX, PER_MINUTE_MAX as REQUEST_BUDGET_PER_MINUTE_MAX, RequestBudget, setLimitOverride, SOURCE_BUDGET_DEFAULTS, SourceAdmission } from "./request-budget.js";
272
+ import { REMOTE_MESSAGES, reproveRemote } from "./operate-remote.js";
270
273
  import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
271
- import { createOAuthHttp, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
274
+ import { createOAuthHttp, oauthProjects, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
272
275
  import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
273
276
  import { REQUEST_LIMITS_CSS, requestLimitsHtml, tokenLimitWords } from "./request-budget-ui.js";
274
277
  import { PEOPLE_AUDIT_CSS, personAuditHtml, personHref } from "./people-audit-ui.js";
@@ -291,6 +294,7 @@ import { installMethod } from "./install-method.js";
291
294
  import { PACKAGE_VERSION } from "./version.js";
292
295
  import { updateNoticeWords } from "./update-notice.js";
293
296
  import { whenHtml } from "./when-html.js";
297
+ import { ReadExecutor, attachReadExecutor, defaultReadWorkers } from "./read-executor.js";
294
298
  const SESSION_COOKIE = "standing-orders_session";
295
299
  /** Stands in for "no project" in a ceiling: no folder resolves to it, so it admits nothing. */
296
300
  const NO_PROJECT = "/\0no-project";
@@ -460,6 +464,11 @@ export function createDecisionServer(options) {
460
464
  }
461
465
  }
462
466
  const clock = options.clock ?? (() => new Date());
467
+ // Heavy reads (the work index behind `task list`) run on read-only worker connections, off the request loop.
468
+ const readFile = store.isDemo() ? null : store.databaseFile();
469
+ const readWorkers = options.readWorkers ?? (process.env["VITEST"] !== undefined ? 0 : defaultReadWorkers());
470
+ const reads = readFile === null || readWorkers < 1 ? null : new ReadExecutor(readFile, readWorkers);
471
+ const detachReads = reads === null ? () => { } : attachReadExecutor(store, reads);
463
472
  /** Task checkouts, for Settings → Storage: the pool's own root, or the folder beside the database. */
464
473
  const storagePool = (databaseFile) => new WorktreePool(store, { root: options.poolRoot ?? join(dirname(databaseFile), "worktrees") });
465
474
  const install = installMethod(options.installBin);
@@ -560,7 +569,12 @@ export function createDecisionServer(options) {
560
569
  * access to the project) is checked either way.
561
570
  */
562
571
  // v100: the shared check honours a fresh identity-provider sign-in itself (freshIdentitySignIn, set per request below).
563
- const authenticateApprover = checkApproverPassword;
572
+ function authenticateApprover(who, token, repo) {
573
+ // Re-entering a password never upgrades a machine credential into a browser ceremony.
574
+ if (who.via !== "cookie")
575
+ return { ok: false, reason: "not-an-approver" };
576
+ return checkApproverPassword(store, who.name, token, repo);
577
+ }
564
578
  const signInBudget = new SourceBudget();
565
579
  const ssoVisits = new Map();
566
580
  const ssoHandoffs = new Map();
@@ -750,8 +764,11 @@ export function createDecisionServer(options) {
750
764
  /** One-click connections on their way: the service's sign-in page and back, 15 minutes at most. */
751
765
  const connectVisits = new Map();
752
766
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
753
- const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow));
754
- const taskRooms = createTaskRooms(root => taskFingerprint(store, root, clock()));
767
+ // Live views push on write (live-bus.ts): the revision triggers move, the rooms hear it.
768
+ const liveBus = createLiveBus();
769
+ const liveFollower = followWorkspace(store, () => workspaceRevision.current(), liveBus, { file: store.databaseFile() });
770
+ const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow), { bus: liveBus });
771
+ const taskRooms = createTaskRooms(root => taskFingerprint(store, root, clock()), { bus: liveBus });
755
772
  const teamChatProvider = () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; };
756
773
  const team = createTeamRuntime({ store, repos: codingProjects, evidenceRoot, clock, workspaceRevision,
757
774
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
@@ -771,17 +788,59 @@ export function createDecisionServer(options) {
771
788
  return { ...summary, card: teamProposalCardParts(store, actor, proposal, snapshot, csrf, decision, clock(), teamChatProvider).card };
772
789
  }) } };
773
790
  };
791
+ // v112: one request budget per server for person API tokens, shared by every route a token signs in at (request-budget.ts).
792
+ const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
793
+ // Unproved password attempts share a source budget. Only successful proof spends an account budget.
794
+ const sourceClock = options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock };
795
+ const passwordSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
796
+ const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
797
+ const teamsSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsSource, ...sourceClock });
798
+ const teamsTenantBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsTenant, ...sourceClock });
799
+ const oauthTokenBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.oauthToken, ...sourceClock });
800
+ const presentsPassword = (request) => /^Bearer (.+):(.+)$/.test(request.headers.authorization ?? "");
801
+ const admitPasswordSource = (request) => presentsPassword(request) ? passwordSourceBudget.admit(joinSourceOf(request)) : { ok: true };
802
+ /** Called only with a proved identity; cookies spend neither bearer budget. */
803
+ const admitBearer = (request, actor) => {
804
+ if (actor.principal !== undefined)
805
+ return requestBudget.admit(actor.principal.tokenId, "api");
806
+ return presentsPassword(request) ? passwordAccountBudget.admit(actor.name) : { ok: true };
807
+ };
808
+ /**
809
+ * The full principal a live `so_` API token stands for: its person and their generation, the token's own read/act
810
+ * scope, its project limit narrowed by the person's current access, and its id. Every route that accepts an API token
811
+ * builds its identity here and carries it whole, so no route can drop the token's narrower scope or projects.
812
+ */
813
+ const tokenPrincipalOf = (request) => {
814
+ const who = identify(request, false, true);
815
+ return who?.via === "bearer" ? who.principal ?? null : null;
816
+ };
817
+ const presentsToken = (request) => /^Bearer so_\S+$/.test(request.headers.authorization ?? "");
774
818
  const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
775
819
  authenticate: request => {
820
+ if (presentsToken(request)) {
821
+ const principal = tokenPrincipalOf(request);
822
+ return principal === null ? null : { name: principal.account, generation: principal.generation, principal };
823
+ }
776
824
  const who = identify(request, request.method === 'POST');
777
825
  const account = who === null ? null : store.accountOf(who.name);
778
826
  return who && account && account.revokedAt === null ? { name: who.name, generation: account.generation } : null;
779
827
  },
828
+ admit: admitPasswordSource,
829
+ admitAuthenticated: admitBearer,
780
830
  revalidate: (request, actor) => {
781
831
  const account = store.accountOf(actor.name);
782
832
  if (!account || account.revokedAt !== null || account.generation !== actor.generation)
783
833
  return false;
784
- // Bearer was proved when this connection opened. Cookie expiry/revocation
834
+ // An API token is re-proved against the store every time: unrevoked, unexpired, inside its project limit.
835
+ if (actor.principal !== undefined) {
836
+ const live = reproveRemote(store, actor.principal, new Date());
837
+ if (!live.ok)
838
+ return false;
839
+ // The post-body mutation check must see a scope lowered since authentication, too.
840
+ actor.principal = { ...actor.principal, scope: live.scope };
841
+ return true;
842
+ }
843
+ // A password bearer was proved when this connection opened. Cookie expiry/revocation
785
844
  // is rechecked without allowing a passive stream to extend its lifetime.
786
845
  if (request.headers.authorization)
787
846
  return true;
@@ -789,6 +848,8 @@ export function createDecisionServer(options) {
789
848
  return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
790
849
  },
791
850
  authorizeMutation: (request, actor) => {
851
+ if (actor.principal !== undefined)
852
+ return request.headers.origin === undefined;
792
853
  const who = identify(request, false);
793
854
  if (!who || who.name !== actor.name)
794
855
  return false;
@@ -802,29 +863,22 @@ export function createDecisionServer(options) {
802
863
  },
803
864
  execute: async (actor, input) => {
804
865
  const reply = await team.execute(actor, input);
866
+ if (actor.principal !== undefined)
867
+ return reply;
805
868
  const who = identify(request, false);
806
869
  return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
807
870
  }, cursor: team.cursor, streams: teamStreams,
808
871
  });
809
- // v112: one request budget per server for person API tokens, shared by /api/cli and /mcp (request-budget.ts).
810
- const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
811
872
  // Remote CLI: one live API token names the person; the shared command boundary decides everything else.
812
873
  const cliEndpoint = (request, response) => handleCliHttp(request, response, {
813
874
  admit: principal => requestBudget.admit(principal.tokenId, "api"),
814
- authenticate: request => {
815
- const who = identify(request, false, true);
816
- const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
817
- const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
818
- const account = who === null ? null : store.accountOf(who.name);
819
- if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
820
- return null;
821
- return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: tokenProjects(account.projects, row.projects) };
822
- },
875
+ authenticate: tokenPrincipalOf,
823
876
  run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
824
877
  modeOf: options.cliModeOf,
825
878
  store,
826
879
  });
827
- const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo) });
880
+ const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo),
881
+ admit: admitPasswordSource, admitAuthenticated: admitBearer });
828
882
  /** The enumerable admission list for roll-up SQL: repos-only ceilings
829
883
  * enumerate themselves; root ceilings enumerate the STORED repos that
830
884
  * pass the ceiling (Codex roll-up review, finding 11); unscoped = null. */
@@ -841,6 +895,7 @@ export function createDecisionServer(options) {
841
895
  /** The task behind a resource, for the ceiling check; null = no ref (visible). */
842
896
  const taskRepoOf = (taskRef) => store.refForId(taskRef)?.repo ?? null;
843
897
  const server = createServer((request, response) => {
898
+ instrumentRequest(store.telemetry, request, response);
844
899
  void handle(request, response).catch(error => {
845
900
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query.
846
901
  logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
@@ -857,6 +912,8 @@ export function createDecisionServer(options) {
857
912
  }
858
913
  });
859
914
  });
915
+ server.once('listening', () => store.telemetry.start());
916
+ server.once('close', () => store.telemetry.stop());
860
917
  // --public-url (arc 3): validated to EXACTLY an https origin. Its host
861
918
  // joins the allowed set, its origin authorizes POSTs, and cookies turn
862
919
  // Secure. Anything malformed refuses at startup, loudly.
@@ -931,8 +988,18 @@ export function createDecisionServer(options) {
931
988
  * it is a smuggling attempt.
932
989
  */
933
990
  function authorizeMutation(request, who, body) {
991
+ // A read token never mutates, whatever its person's role (operate-remote.ts says the same to the remote CLI).
992
+ if (who.via === "bearer") {
993
+ if (who.principal !== undefined) {
994
+ const live = reproveRemote(store, who.principal, new Date());
995
+ if (!live.ok)
996
+ return { status: 403, message: REMOTE_MESSAGES.stale };
997
+ if (live.scope !== "act")
998
+ return { status: 403, message: REMOTE_MESSAGES.read };
999
+ }
1000
+ }
934
1001
  const current = store.accountOf(who.name);
935
- if (current === null || current.revokedAt !== null || current.role !== who.role || (who.via === "cookie" && current.generation !== who.session.generation)) {
1002
+ if (current === null || current.revokedAt !== null || current.role !== who.role || current.generation !== (who.via === "cookie" ? who.session.generation : who.generation)) {
936
1003
  return { status: 403, message: "Your access changed. Sign in again." };
937
1004
  }
938
1005
  const type = request.headers["content-type"] ?? "";
@@ -1294,7 +1361,8 @@ export function createDecisionServer(options) {
1294
1361
  return;
1295
1362
  if (await cliEndpoint(request, response))
1296
1363
  return;
1297
- if (options.configDir !== undefined && await handleTeamsHttp(request, response, { store, dir: options.configDir, ...(options.teamsFetcher ? { fetcher: options.teamsFetcher } : {}), clock }))
1364
+ if (options.configDir !== undefined && await handleTeamsHttp(request, response, { store, dir: options.configDir, ...(options.teamsFetcher ? { fetcher: options.teamsFetcher } : {}), clock,
1365
+ admitSource: request => teamsSourceBudget.admit(joinSourceOf(request)), admitTenant: tenant => teamsTenantBudget.admit(tenant) }))
1298
1366
  return;
1299
1367
  if (serveBrowserAsset(request, response, url.pathname))
1300
1368
  return;
@@ -1350,7 +1418,30 @@ export function createDecisionServer(options) {
1350
1418
  const fragmentName = url.searchParams.get("fragment");
1351
1419
  const workspaceRead = method === "GET" && url.searchParams.get('format') === 'workspace';
1352
1420
  const touch = !(method === "GET" && (workspaceRead || (fragmentName !== null && NO_TOUCH_FRAGMENTS.has(fragmentName)) || /^\/code\/[a-f0-9]{32}\/state$/.test(url.pathname)));
1353
- const who = identify(request, touch);
1421
+ const refuseBudget = (admitted) => {
1422
+ if (admitted.ok)
1423
+ return false;
1424
+ request.resume();
1425
+ if (admitted.status === 429)
1426
+ response.setHeader("Retry-After", String(admitted.retryAfter));
1427
+ respond(response, admitted.status, "text/plain; charset=utf-8", admitted.status === 429 ? limitWords(admitted.limit, admitted.retryAfter) : "Request limits could not be checked; nothing ran. Try again shortly.");
1428
+ return true;
1429
+ };
1430
+ // One source charge before password proof and one account/token charge after it, including GET /login.
1431
+ if (refuseBudget(admitPasswordSource(request)))
1432
+ return;
1433
+ const refusedToken = {};
1434
+ const who = identify(request, touch, false, refusedToken);
1435
+ if (refusedToken.principal !== undefined) {
1436
+ if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
1437
+ return;
1438
+ if (method === "POST") {
1439
+ request.resume();
1440
+ return respond(response, 403, "text/plain; charset=utf-8", REMOTE_MESSAGES["step-up"]);
1441
+ }
1442
+ }
1443
+ if (who?.via === "bearer" && refuseBudget(admitBearer(request, who)))
1444
+ return;
1354
1445
  if (url.pathname === "/login" && method === "GET") {
1355
1446
  if (options.setupCode !== undefined && store.listApprovers().length === 0) {
1356
1447
  return page(response, 200, signupPage(null, setupAttemptsLeft));
@@ -2000,7 +2091,7 @@ export function createDecisionServer(options) {
2000
2091
  url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
2001
2092
  !url.pathname.startsWith("/chat/demo/") &&
2002
2093
  !/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
2003
- !url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
2094
+ !url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/health" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
2004
2095
  !(url.pathname === "/board" && url.searchParams.get("scope") === "all");
2005
2096
  if (needsProject)
2006
2097
  return redirect(response, `/projects?return=${encodeURIComponent(safeReturn(url.pathname + url.search))}`);
@@ -4226,6 +4317,14 @@ export function createDecisionServer(options) {
4226
4317
  return sendScreen(response, 200, screen("Data", `<p><a href="/settings">Settings</a></p><h1>Data</h1>${dataExportHtml(who.session.csrf, { problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4227
4318
  }
4228
4319
  // v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
4320
+ if (url.pathname === "/health") {
4321
+ if (!store.isInstanceOperator(who.name))
4322
+ return refuse(response, who, 403, "An instance operator reads server health.", "/");
4323
+ const health = store.telemetry.snapshot();
4324
+ return request.headers.accept?.includes('application/json')
4325
+ ? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
4326
+ : respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
4327
+ }
4229
4328
  if (url.pathname === "/metrics") {
4230
4329
  if (!store.isInstanceOperator(who.name))
4231
4330
  return refuse(response, who, 403, "An instance operator reads metrics.", "/");
@@ -6613,15 +6712,15 @@ export function createDecisionServer(options) {
6613
6712
  }
6614
6713
  switch (verb) {
6615
6714
  case "approve": {
6715
+ if (who.via !== "cookie")
6716
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
6616
6717
  // Step-up, identical to a scope's: the session got you here; only
6617
6718
  // the password agrees. The digest names what was seen.
6618
6719
  const digest = body.get("digest") ?? "";
6619
6720
  const token = body.get("token") ?? "";
6620
- if (who.via === "cookie") {
6621
- const nonce = body.get("nonce") ?? "";
6622
- if (!consumeApprovalNonce(nonce, who.name, `routine:${routine.id}`, digest)) {
6623
- return routinePage(response, who, routineId, "that approval form is stale — read it again", 409);
6624
- }
6721
+ const nonce = body.get("nonce") ?? "";
6722
+ if (!consumeApprovalNonce(nonce, who.name, `routine:${routine.id}`, digest)) {
6723
+ return routinePage(response, who, routineId, "that approval form is stale — read it again", 409);
6625
6724
  }
6626
6725
  if (token === "" && !hasFreshIdentitySignIn(who.name)) {
6627
6726
  return routinePage(response, who, routineId, "approval requires your password, typed again", 400);
@@ -6655,19 +6754,18 @@ export function createDecisionServer(options) {
6655
6754
  return redirect(response, `/routines/${routineId}`);
6656
6755
  }
6657
6756
  case "run-now": {
6757
+ if (who.via !== "cookie")
6758
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
6658
6759
  // Step-up (Codex Phase C review, M3): run-now is spend outside the
6659
6760
  // approved schedule, so a session alone cannot ask for it — the
6660
- // password is typed again, like an approval. A bearer caller
6661
- // re-proved its credential on this very request.
6662
- if (who.via === "cookie") {
6663
- const token = body.get("token") ?? "";
6664
- if (token === "" && !hasFreshIdentitySignIn(who.name)) {
6665
- return routinePage(response, who, routineId, "run now requires your password, typed again", 400);
6666
- }
6667
- const authenticated = authenticateApprover(store, who.name, token);
6668
- if (!authenticated.ok) {
6669
- return routinePage(response, who, routineId, "that is not your password", 403);
6670
- }
6761
+ // password is typed again, like an approval.
6762
+ const token = body.get("token") ?? "";
6763
+ if (token === "" && !hasFreshIdentitySignIn(who.name)) {
6764
+ return routinePage(response, who, routineId, "run now requires your password, typed again", 400);
6765
+ }
6766
+ const authenticated = authenticateApprover(who, token);
6767
+ if (!authenticated.ok) {
6768
+ return routinePage(response, who, routineId, "that is not your password", 403);
6671
6769
  }
6672
6770
  const outcome = fireRoutine(store, routineId, now, { manual: true });
6673
6771
  if (!outcome.ok) {
@@ -6854,7 +6952,7 @@ export function createDecisionServer(options) {
6854
6952
  const repo = body.get('repo') ?? '';
6855
6953
  if (!permitted(repo))
6856
6954
  return fail(403, 'Choose a project available to your account.');
6857
- if (!authenticateApprover(store, who.name, body.get('password') ?? '').ok)
6955
+ if (!authenticateApprover(who, body.get('password') ?? '').ok)
6858
6956
  return fail(403, 'Enter your Toolroll password to authorize this coding session.');
6859
6957
  const session = await coding.start(actor, { repo, title: body.get('title') ?? '', model: body.get('model')?.trim() || null, prompt: body.get('prompt') ?? '', requestId: body.get('requestId') ?? '' });
6860
6958
  id = session.id;
@@ -6889,7 +6987,7 @@ export function createDecisionServer(options) {
6889
6987
  const decision = body.get('decision');
6890
6988
  if (decision !== 'accept' && decision !== 'decline' && decision !== 'cancel')
6891
6989
  return fail(400, 'Choose an explicit decision for this request.');
6892
- if (pending.kind !== 'questions' && decision === 'accept' && !authenticateApprover(store, who.name, body.get('password') ?? '').ok)
6990
+ if (pending.kind !== 'questions' && decision === 'accept' && !authenticateApprover(who, body.get('password') ?? '').ok)
6893
6991
  return fail(403, 'Enter your password to approve this additional access.');
6894
6992
  const answers = body.has('answers') ? JSON.parse(body.get('answers')) : Object.fromEntries(pending.questions.map(q => [q.id, { answers: [body.get(`question:${q.id}`) ?? ''] }]));
6895
6993
  coding.answer(id, actor, token, decision, answers);
@@ -7306,7 +7404,7 @@ export function createDecisionServer(options) {
7306
7404
  return renewed.ok ? settle(renewed.said, { reveal: renewed.reveal }) : answer(409, { ok: false, said: renewed.message });
7307
7405
  }
7308
7406
  // Linear's signing secret, pasted on this secure panel behind the password — never in chat.
7309
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7407
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7310
7408
  return answer(403, { ok: false, said: "Enter your Toolroll password to save a secret." });
7311
7409
  const saved = saveLinearSigningSecret(trigger, body.get("secret") ?? "", dir);
7312
7410
  return saved.ok ? settle("Signing secret saved. Linear's deliveries can be proved now.") : answer(400, { ok: false, said: saved.message });
@@ -7344,7 +7442,7 @@ export function createDecisionServer(options) {
7344
7442
  removeLinearKey(dir);
7345
7443
  return settle("Linear key removed.");
7346
7444
  }
7347
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7445
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7348
7446
  return answer(403, { ok: false, said: "Enter your Toolroll password to save a key." });
7349
7447
  const saved = saveLinearKey(dir, body.get("key") ?? "");
7350
7448
  return saved.ok ? settle("Linear key saved on this computer.") : answer(400, { ok: false, said: saved.message });
@@ -7444,7 +7542,7 @@ export function createDecisionServer(options) {
7444
7542
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7445
7543
  return refuse(response, who, 403, "An instance operator sets budgets.", "/spend");
7446
7544
  const back = (key, words) => redirect(response, `/spend?${key}=${encodeURIComponent(words)}`);
7447
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7545
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7448
7546
  return back("problem", "Enter your Toolroll password to change a budget.");
7449
7547
  const target = /^(installation|project|person|teammate):(.+)$/.exec(body.get("target") ?? "");
7450
7548
  if (target === null)
@@ -7508,7 +7606,7 @@ export function createDecisionServer(options) {
7508
7606
  return back("problem", `Nothing was deleted: ${view.running.join(", ")}. Stop it, then try again.`);
7509
7607
  if (body.get("step") !== "delete")
7510
7608
  return confirm(null);
7511
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7609
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7512
7610
  return confirm("That password didn't match. Nothing was deleted.");
7513
7611
  const done = await deleteProject(store, repo, { actor: who.name, via: "console", now, evidenceRoot, poolRoot: options.poolRoot ?? null });
7514
7612
  if (!done.ok)
@@ -7537,7 +7635,7 @@ export function createDecisionServer(options) {
7537
7635
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7538
7636
  return refuse(response, who, 403, "An instance operator sets the policy.", "/settings/policy");
7539
7637
  const back = (key, words) => redirect(response, `/settings/policy?${key}=${encodeURIComponent(words)}`);
7540
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7638
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7541
7639
  return back("problem", "That password didn't match. Nothing changed.");
7542
7640
  const chosen = body.getAll("provider");
7543
7641
  const checked = checkPolicy({ providers: chosen.length === 4 ? null : chosen, models: parseList(body.get("models")), tools: parseList(body.get("tools")), ceiling: body.get("ceiling") ?? "" });
@@ -7558,7 +7656,7 @@ export function createDecisionServer(options) {
7558
7656
  if (!known.includes(repo))
7559
7657
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
7560
7658
  const back = (key, words) => redirect(response, `/settings/approval?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7561
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7659
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7562
7660
  return back("problem", "That password didn't match. Nothing changed.");
7563
7661
  const protect = body.get("protect") ?? "none";
7564
7662
  const paths = parseProtectedPaths(body.get("paths") ?? "");
@@ -7581,7 +7679,7 @@ export function createDecisionServer(options) {
7581
7679
  const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
7582
7680
  if (target !== "*" && token === null)
7583
7681
  return back("problem", "That token is no longer live. Nothing changed.");
7584
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7682
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7585
7683
  return back("problem", "Enter your Toolroll password to change request limits.");
7586
7684
  const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
7587
7685
  const clear = body.get("action") === "clear";
@@ -7642,7 +7740,7 @@ export function createDecisionServer(options) {
7642
7740
  return back("problem", "A viewer's token can only read.");
7643
7741
  // A token is a credential: made with the person's password (or their identity provider's check from moments ago), counted once.
7644
7742
  const typed = body.get("password") ?? "";
7645
- const confirmed = who.role === "approver" ? authenticateApprover(store, who.name, typed).ok
7743
+ const confirmed = who.role === "approver" ? authenticateApprover(who, typed).ok
7646
7744
  : typed === "" ? requestContext.getStore()?.sso?.fresh === true : authenticateAccount(store, who.name, typed).ok;
7647
7745
  if (!confirmed)
7648
7746
  return back("problem", "Enter your Toolroll password to make a token.");
@@ -7668,7 +7766,7 @@ export function createDecisionServer(options) {
7668
7766
  const found = await providerFor(before.issuer);
7669
7767
  return found.ok ? back("said", `${before.label} answers: it's ready for people to sign in.`) : back("problem", found.said);
7670
7768
  }
7671
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7769
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7672
7770
  return back("problem", "Enter your Toolroll password to change sign-in.");
7673
7771
  if (action === "remove") {
7674
7772
  removeSsoSettings(options.configDir);
@@ -7705,7 +7803,7 @@ export function createDecisionServer(options) {
7705
7803
  }
7706
7804
  if (url.pathname === "/settings/updates/cancel")
7707
7805
  return back("said", requestRuntimeUpdateCancel(stateDir));
7708
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7806
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7709
7807
  return back("problem", "Enter your Toolroll password to update.");
7710
7808
  const version = body.get("version") ?? "";
7711
7809
  const when = { now: "now", "when-idle": "when-idle", tonight: "at" }[body.get("when") ?? ""] ?? null;
@@ -7744,7 +7842,7 @@ export function createDecisionServer(options) {
7744
7842
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7745
7843
  return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
7746
7844
  const back = (key, words) => redirect(response, `/settings/retention?${key}=${encodeURIComponent(words)}`);
7747
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7845
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7748
7846
  return back("problem", "Enter your Toolroll password to change retention.");
7749
7847
  const before = store.retentionPeriods();
7750
7848
  // Only what the page offers (or the period already set): a hand-made value doesn't save.
@@ -7766,7 +7864,7 @@ export function createDecisionServer(options) {
7766
7864
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
7767
7865
  const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
7768
7866
  const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
7769
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7867
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7770
7868
  return back("problem", `Enter your Toolroll password to ${what}.`);
7771
7869
  if (url.pathname === "/settings/storage") {
7772
7870
  const cleanup = parseCleanup(body.get("cleanup") ?? "");
@@ -7797,13 +7895,15 @@ export function createDecisionServer(options) {
7797
7895
  const body = readForm(posted, CONSOLE_FORMS.pullRequests);
7798
7896
  const repo = body.get("repo") ?? "";
7799
7897
  const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7800
- if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
7898
+ if (who.via !== "cookie")
7899
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
7900
+ if (!known.includes(repo) || !visible(repo))
7801
7901
  return refuse(response, who, 404, "No such project.", "/projects");
7802
7902
  if (who.role !== "approver" || store.isDemo())
7803
7903
  return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
7804
7904
  const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7805
7905
  const act = body.get("act");
7806
- if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
7906
+ if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
7807
7907
  return back("problem", "Enter your Toolroll password to change pull requests.");
7808
7908
  if (act === "on") {
7809
7909
  const checked = await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
@@ -7835,12 +7935,14 @@ export function createDecisionServer(options) {
7835
7935
  const body = readForm(posted, CONSOLE_FORMS.checks);
7836
7936
  const repo = body.get("repo") ?? "";
7837
7937
  const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7838
- if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
7938
+ if (who.via !== "cookie")
7939
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
7940
+ if (!known.includes(repo) || !visible(repo))
7839
7941
  return refuse(response, who, 404, "No such project.", "/projects");
7840
7942
  if (who.role !== "approver" || store.isDemo())
7841
7943
  return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
7842
7944
  const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7843
- if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
7945
+ if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
7844
7946
  return back("problem", "Enter your Toolroll password to change checks.");
7845
7947
  const act = body.get("act");
7846
7948
  // The automatic review switch: the policy log keeps before → after.
@@ -7899,7 +8001,7 @@ export function createDecisionServer(options) {
7899
8001
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
7900
8002
  return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
7901
8003
  const back = (key, words) => redirect(response, `/settings/backups?${key}=${encodeURIComponent(words)}`);
7902
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8004
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7903
8005
  return back("problem", "Enter your Toolroll password to change backups.");
7904
8006
  const every = body.get("every") ?? "";
7905
8007
  const hours = Number(every);
@@ -7924,7 +8026,7 @@ export function createDecisionServer(options) {
7924
8026
  const body = readForm(posted, CONSOLE_FORMS.data);
7925
8027
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7926
8028
  return refuse(response, who, 403, "An instance operator exports data.", "/settings");
7927
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok) {
8029
+ if (!authenticateApprover(who, body.get("password") ?? "").ok) {
7928
8030
  return redirect(response, `/settings/data?problem=${encodeURIComponent("Enter your Toolroll password to download the export.")}`);
7929
8031
  }
7930
8032
  store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: "everything exported", outcome: "exported", source: "access", detail: "downloaded as a .zip" });
@@ -7969,7 +8071,7 @@ export function createDecisionServer(options) {
7969
8071
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
7970
8072
  return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
7971
8073
  const back = (key, words) => redirect(response, `/settings/monitoring?${key}=${encodeURIComponent(words)}`);
7972
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8074
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7973
8075
  return back("problem", "Enter your Toolroll password to change monitoring.");
7974
8076
  const before = readMonitoring(options.configDir);
7975
8077
  // Never into Toolroll's own folder, a project or the build checkouts: an agent could read the stream there.
@@ -8026,7 +8128,7 @@ export function createDecisionServer(options) {
8026
8128
  if (connectionsOf(store, target).find(one => one.id === service.id)?.state !== "open")
8027
8129
  return back(`${projectName(target)} already has ${service.label}.`);
8028
8130
  }
8029
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8131
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8030
8132
  return back("Enter your Toolroll password to connect a tool.");
8031
8133
  // An app on this computer: added like a common tool and tested; one that can't be reached isn't kept.
8032
8134
  if (local !== null) {
@@ -8081,7 +8183,7 @@ export function createDecisionServer(options) {
8081
8183
  const action = body.get("action") ?? "";
8082
8184
  const name = body.get("name") ?? "";
8083
8185
  // Anything that adds what a build can run or reach, or a secret, needs the password.
8084
- if (["add-catalog", "add-custom", "import", "secret"].includes(action) && !authenticateApprover(store, who.name, body.get("password") ?? "").ok) {
8186
+ if (["add-catalog", "add-custom", "import", "secret"].includes(action) && !authenticateApprover(who, body.get("password") ?? "").ok) {
8085
8187
  return back("problem", "Enter your Toolroll password to change this project's tools.");
8086
8188
  }
8087
8189
  const now = clock();
@@ -8336,6 +8438,8 @@ export function createDecisionServer(options) {
8336
8438
  }
8337
8439
  if (["/control/setup-preview", "/control/setup-approve", "/control/instructions-preview", "/control/instructions-approve"].includes(url.pathname)) {
8338
8440
  const body = readForm(posted, CONSOLE_FORMS.projectSetup);
8441
+ if (who.via !== "cookie")
8442
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
8339
8443
  const project = projectOf(who, request);
8340
8444
  if (project == null || body.get("repo") !== project || !visible(project))
8341
8445
  return refuse(response, who, 409, "The selected project changed. Open project setup again.", "/control");
@@ -8351,7 +8455,7 @@ export function createDecisionServer(options) {
8351
8455
  }
8352
8456
  if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
8353
8457
  return refuse(response, who, 409, "This instruction preview expired. Review it again.", "/control");
8354
- if (!authenticateApprover(store, who.name, body.get("token") ?? "").ok)
8458
+ if (!authenticateApprover(who, body.get("token") ?? "").ok)
8355
8459
  return refuse(response, who, 403, "Your operator credential is required.", "/control");
8356
8460
  const added = addProjectInstructions(project, body.get("fingerprint") ?? "");
8357
8461
  if (!added.ok)
@@ -8389,7 +8493,7 @@ export function createDecisionServer(options) {
8389
8493
  savePrimary(dir, "slack");
8390
8494
  return redirect(response, "/settings/slack");
8391
8495
  }
8392
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8496
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8393
8497
  return show("Enter your Toolroll password to change Slack access.", 403);
8394
8498
  const generation = store.accountOf(who.name).generation;
8395
8499
  if (action === "connect") {
@@ -8522,7 +8626,7 @@ export function createDecisionServer(options) {
8522
8626
  savePrimary(dir, "teams");
8523
8627
  return redirect(response, "/settings/teams");
8524
8628
  }
8525
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8629
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8526
8630
  return show("Enter your Toolroll password to change Teams access.", 403);
8527
8631
  const generation = store.accountOf(who.name).generation;
8528
8632
  if (action === "connect") {
@@ -8577,7 +8681,7 @@ export function createDecisionServer(options) {
8577
8681
  savePrimary(dir, "discord");
8578
8682
  return redirect(response, "/settings/discord");
8579
8683
  }
8580
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8684
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8581
8685
  return show("Enter your Toolroll password to change Discord access.", 403);
8582
8686
  const generation = store.accountOf(who.name).generation;
8583
8687
  if (action === "connect") {
@@ -9117,7 +9221,7 @@ export function createDecisionServer(options) {
9117
9221
  if (who.via !== "cookie")
9118
9222
  return refuse(response, who, 403, "runner registration is a browser surface");
9119
9223
  const token = body.get("token") ?? "";
9120
- if (!authenticateApprover(store, who.name, token).ok) {
9224
+ if (!authenticateApprover(who, token).ok) {
9121
9225
  return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
9122
9226
  }
9123
9227
  const name = (body.get("name") ?? "").trim();
@@ -9218,7 +9322,7 @@ export function createDecisionServer(options) {
9218
9322
  return refuse(response, who, 409, "the terms moved while you were reading — read them again", "/mode");
9219
9323
  }
9220
9324
  const token = body.get("token") ?? "";
9221
- if (!authenticateApprover(store, who.name, token).ok) {
9325
+ if (!authenticateApprover(who, token).ok) {
9222
9326
  return refuse(response, who, 403, "signing a mode takes your password, typed again", "/mode");
9223
9327
  }
9224
9328
  store.signMode({
@@ -9247,7 +9351,7 @@ export function createDecisionServer(options) {
9247
9351
  const body = readForm(posted, CONSOLE_FORMS.peopleProjects);
9248
9352
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
9249
9353
  return refuse(response, who, 403, "An instance operator manages project access.", "/people");
9250
- if (!authenticateApprover(store, who.name, body.get("token") ?? "", null).ok)
9354
+ if (!authenticateApprover(who, body.get("token") ?? "", null).ok)
9251
9355
  return refuse(response, who, 403, "Saving access requires your password.", "/people");
9252
9356
  const access = readAccessForm(body);
9253
9357
  if (!access.ok)
@@ -9265,7 +9369,7 @@ export function createDecisionServer(options) {
9265
9369
  const token = body.get("token") ?? "";
9266
9370
  // RAISING authority is a password ceremony (the doctrine): adding a
9267
9371
  // person to the instance is exactly that.
9268
- if (!authenticateApprover(store, who.name, token).ok) {
9372
+ if (!authenticateApprover(who, token).ok) {
9269
9373
  return refuse(response, who, 403, "making an invite takes your password, typed again", "/people");
9270
9374
  }
9271
9375
  const role = body.get("role") === "approver" ? "approver" : "viewer";
@@ -9294,7 +9398,7 @@ export function createDecisionServer(options) {
9294
9398
  if (who.via !== "cookie")
9295
9399
  return refuse(response, who, 403, "inviting is a browser surface");
9296
9400
  const token = body.get("token") ?? "";
9297
- if (!authenticateApprover(store, who.name, token).ok) {
9401
+ if (!authenticateApprover(who, token).ok) {
9298
9402
  return refuse(response, who, 403, "cancelling an invite takes your password, typed again", "/people");
9299
9403
  }
9300
9404
  const id = Number(body.get("id") ?? "");
@@ -9306,7 +9410,7 @@ export function createDecisionServer(options) {
9306
9410
  if (who.via !== "cookie")
9307
9411
  return refuse(response, who, 403, "removing a person is a browser surface");
9308
9412
  const token = body.get("token") ?? "";
9309
- if (!authenticateApprover(store, who.name, token).ok) {
9413
+ if (!authenticateApprover(who, token).ok) {
9310
9414
  return refuse(response, who, 403, "removing a person takes your password, typed again", "/people");
9311
9415
  }
9312
9416
  const name = (body.get("name") ?? "").trim();
@@ -9326,7 +9430,7 @@ export function createDecisionServer(options) {
9326
9430
  if (who.via !== "cookie")
9327
9431
  return refuse(response, who, 403, "runner retirement is a browser surface");
9328
9432
  const token = body.get("token") ?? "";
9329
- if (!authenticateApprover(store, who.name, token).ok) {
9433
+ if (!authenticateApprover(who, token).ok) {
9330
9434
  return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/fleet");
9331
9435
  }
9332
9436
  const name = (body.get("name") ?? "").trim();
@@ -9377,6 +9481,8 @@ export function createDecisionServer(options) {
9377
9481
  }
9378
9482
  const contestAct = /^\/contest\/([0-9]{1,15})\/(arm|pick|abandon)$/.exec(url.pathname);
9379
9483
  if (contestAct !== null) {
9484
+ if (who.via !== "cookie")
9485
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
9380
9486
  const body = readForm(posted, CONSOLE_FORMS.contest);
9381
9487
  const contestId = Number(contestAct[1]);
9382
9488
  const verb = contestAct[2];
@@ -9429,7 +9535,7 @@ export function createDecisionServer(options) {
9429
9535
  // POST minted. The store consumes the nonce conditionally inside the
9430
9536
  // same transaction that moves the tournament — replay finds it gone.
9431
9537
  const token = body.get("token") ?? "";
9432
- if (!authenticateApprover(store, who.name, token).ok) {
9538
+ if (!authenticateApprover(who, token).ok) {
9433
9539
  return contestScreen(response, who, contestId, "that decision takes your password, typed again", 403);
9434
9540
  }
9435
9541
  const nonceValue = body.get("nonce") ?? "";
@@ -9471,7 +9577,7 @@ export function createDecisionServer(options) {
9471
9577
  if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
9472
9578
  return taskScreen(response, who, act.taskId, "That build isn't part of this task.", 409);
9473
9579
  }
9474
- if (!authenticateApprover(store, who.name, body.get("token") ?? "", ref.repo).ok || !store.accountCanAccess(who.name, ref.repo)) {
9580
+ if (!authenticateApprover(who, body.get("token") ?? "", ref.repo).ok || !store.accountCanAccess(who.name, ref.repo)) {
9475
9581
  return taskScreen(response, who, act.taskId, "That password didn't match. Nothing changed.", 403);
9476
9582
  }
9477
9583
  // When Toolroll can't check at all, the approver also says they checked: never a one-click confirmation.
@@ -9579,7 +9685,7 @@ export function createDecisionServer(options) {
9579
9685
  // (finding 22/33) — the record is consumed from the RETURNED live
9580
9686
  // session, synchronously, before the first await.
9581
9687
  const password = body.get("token") ?? "";
9582
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
9688
+ if (password === "" || !authenticateApprover(who, password).ok) {
9583
9689
  return projectsScreen(response, who, "cloning takes your password, typed again", 403);
9584
9690
  }
9585
9691
  const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
@@ -9647,7 +9753,7 @@ export function createDecisionServer(options) {
9647
9753
  if (store.isDemo())
9648
9754
  return refuse(response, who, 403, "the sandbox never pushes");
9649
9755
  const password = body.get("token") ?? "";
9650
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
9756
+ if (password === "" || !authenticateApprover(who, password).ok) {
9651
9757
  return redirect(response, `/settings?said=${encodeURIComponent("enrolling this device takes your password, typed again")}`);
9652
9758
  }
9653
9759
  const endpoint = body.get("endpoint") ?? "";
@@ -9750,7 +9856,7 @@ export function createDecisionServer(options) {
9750
9856
  return refuse(response, who, 403, "chat setup is a browser surface");
9751
9857
  const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
9752
9858
  const password = body.get("token") ?? "";
9753
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
9859
+ if (password === "" || !authenticateApprover(who, password).ok) {
9754
9860
  return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
9755
9861
  }
9756
9862
  if (body.get("off") === "1") {
@@ -10076,7 +10182,7 @@ export function createDecisionServer(options) {
10076
10182
  // The password, typed again, on EVERY message (v2 ruling 2): chat is
10077
10183
  // spend, and a seven-day cookie is not a spend credential.
10078
10184
  const password = body.get("token") ?? "";
10079
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
10185
+ if (password === "" || !authenticateApprover(who, password).ok) {
10080
10186
  return redirect(response, `/chat?said=${encodeURIComponent("chat spends — your password, typed again, with every message")}`);
10081
10187
  }
10082
10188
  const message = (body.get("message") ?? "").trim();
@@ -10145,7 +10251,7 @@ export function createDecisionServer(options) {
10145
10251
  const candidate = chat?.candidates.get(key);
10146
10252
  // The filing act creates durable rows from model text: password again.
10147
10253
  const password = body.get("token") ?? "";
10148
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
10254
+ if (password === "" || !authenticateApprover(who, password).ok) {
10149
10255
  return redirect(response, `/chat?said=${encodeURIComponent("filing a draft takes your password, typed again")}`);
10150
10256
  }
10151
10257
  if (chat === undefined || candidate === undefined) {
@@ -10218,7 +10324,7 @@ export function createDecisionServer(options) {
10218
10324
  if (turn === null)
10219
10325
  return refuse(response, who, 404, "no such turn", "/chat");
10220
10326
  const password = body.get("token") ?? "";
10221
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
10327
+ if (password === "" || !authenticateApprover(who, password).ok) {
10222
10328
  return refuse(response, who, 403, "acknowledging unknown spend takes your password", "/chat");
10223
10329
  }
10224
10330
  const nonce = body.get("nonce") ?? "";
@@ -10926,7 +11032,7 @@ export function createDecisionServer(options) {
10926
11032
  }
10927
11033
  basis = { kind: "mode", digest: mode.digest };
10928
11034
  }
10929
- else if (!authenticateApprover(store, who.name, token).ok) {
11035
+ else if (!authenticateApprover(who, token).ok) {
10930
11036
  return refuse(response, who, 403, "authorizing takes your password, typed again", taskHref(taskId));
10931
11037
  }
10932
11038
  const minted = store.mintAttendedAuthorization({
@@ -11103,7 +11209,7 @@ export function createDecisionServer(options) {
11103
11209
  // Authenticated like every approving act: the session alone may
11104
11210
  // read; resuming external work takes the password, typed again.
11105
11211
  const token = (body.get("token") ?? "").trim();
11106
- const authenticated = token !== "" ? authenticateApprover(store, who.name, token) : null;
11212
+ const authenticated = token !== "" ? authenticateApprover(who, token) : null;
11107
11213
  if (authenticated === null || !authenticated.ok) {
11108
11214
  return taskScreen(response, who, taskId, "reopening takes your password, typed again", 403);
11109
11215
  }
@@ -11284,7 +11390,7 @@ export function createDecisionServer(options) {
11284
11390
  // authority takes the password ceremony again — the same act
11285
11391
  // that would be required to approve that authority from scratch.
11286
11392
  const token = (body.get("token") ?? "").trim();
11287
- if (!authenticateApprover(store, who.name, token).ok) {
11393
+ if (!authenticateApprover(who, token).ok) {
11288
11394
  return taskScreen(response, who, taskId, "accepting this revision takes your password, typed again", 403);
11289
11395
  }
11290
11396
  }
@@ -11600,6 +11706,8 @@ export function createDecisionServer(options) {
11600
11706
  return redirect(response, taskHref(taskId));
11601
11707
  }
11602
11708
  case "approve": {
11709
+ if (who.via !== "cookie")
11710
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
11603
11711
  const body = readForm(posted, CONSOLE_FORMS.taskApprove);
11604
11712
  const requestedReturn = body.get("return");
11605
11713
  const approvalBack = requestedReturn === "next"
@@ -11622,11 +11730,9 @@ export function createDecisionServer(options) {
11622
11730
  // rendered to this approver and is spent either way.
11623
11731
  const digest = body.get("digest") ?? "";
11624
11732
  const token = body.get("token") ?? "";
11625
- if (who.via === "cookie") {
11626
- const nonce = body.get("nonce") ?? "";
11627
- if (!consumeApprovalNonce(nonce, who.name, taskId, digest)) {
11628
- return approvalProblem("that approval form is stale — read it again", 409);
11629
- }
11733
+ const nonce = body.get("nonce") ?? "";
11734
+ if (!consumeApprovalNonce(nonce, who.name, taskId, digest)) {
11735
+ return approvalProblem("that approval form is stale — read it again", 409);
11630
11736
  }
11631
11737
  if (token === "" && !hasFreshIdentitySignIn(who.name)) {
11632
11738
  return approvalProblem("approval requires your password, typed again", 400);
@@ -11823,7 +11929,7 @@ export function createDecisionServer(options) {
11823
11929
  return taskScreen(response, who, taskId, "which attempt? the resume names the exact stopped run", 400);
11824
11930
  const runId = Number(named);
11825
11931
  const token = body.get("token") ?? "";
11826
- if (!authenticateApprover(store, who.name, token).ok) {
11932
+ if (!authenticateApprover(who, token).ok) {
11827
11933
  return taskScreen(response, who, taskId, "resuming takes your password, typed again", 403);
11828
11934
  }
11829
11935
  const nonceValue = body.get("nonce") ?? "";
@@ -11903,7 +12009,7 @@ export function createDecisionServer(options) {
11903
12009
  approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
11904
12010
  }));
11905
12011
  }
11906
- const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
12012
+ const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, ...(options.requestBudgetClock === undefined ? {} : { requestBudgetClock: options.requestBudgetClock }), signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
11907
12013
  resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
11908
12014
  enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
11909
12015
  // MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
@@ -11926,9 +12032,10 @@ export function createDecisionServer(options) {
11926
12032
  // grant (their role, their projects) is checked beside it.
11927
12033
  confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
11928
12034
  projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
12035
+ admitToken: request => oauthTokenBudget.admit(joinSourceOf(request)),
11929
12036
  });
11930
12037
  // ---- identity ------------------------------------------------------------
11931
- function identify(request, touch = true, limited = false) {
12038
+ function identify(request, touch = true, limited = false, refusedToken) {
11932
12039
  // v101: an API token. Wrong ones spend the address's tries like a wrong password; expired or revoked ones, and a removed account's, name no one.
11933
12040
  const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
11934
12041
  if (presented !== undefined) {
@@ -11945,16 +12052,20 @@ export function createDecisionServer(options) {
11945
12052
  return null;
11946
12053
  // v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
11947
12054
  // through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
11948
- if (kept.row.projects !== null && !limited)
11949
- return null;
11950
12055
  const account = store.accountOf(kept.row.account);
11951
12056
  if (account === null || account.revokedAt !== null)
11952
12057
  return null;
11953
- // v113: an MCP sign-in's token works only at /mcp, while its access secret is fresh (mcp-oauth.ts).
11954
- if (!oauthTokenAllowed(store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)))
12058
+ const principal = { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id,
12059
+ projects: tokenProjects(tokenProjects(account.projects, kept.row.projects), oauthProjects(store, kept.row.id)) };
12060
+ // Preserve proof only for a refusal and its admission charge. This never makes an OAuth token a console login.
12061
+ if (!oauthTokenAllowed(store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)) ||
12062
+ (kept.row.projects !== null && !limited)) {
12063
+ if (refusedToken !== undefined)
12064
+ refusedToken.principal = principal;
11955
12065
  return null;
12066
+ }
11956
12067
  store.touchApiToken(kept.row.id, new Date(at));
11957
- return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name };
12068
+ return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name, generation: account.generation, principal };
11958
12069
  }
11959
12070
  const bearer = /^Bearer (.+):(.+)$/.exec(request.headers.authorization ?? "");
11960
12071
  if (bearer !== null) {
@@ -11965,7 +12076,7 @@ export function createDecisionServer(options) {
11965
12076
  const authenticated = authenticateAccount(store, bearer[1], bearer[2]);
11966
12077
  if (!authenticated.ok && authenticated.reason === "unknown")
11967
12078
  signInBudget.failed(source, at);
11968
- return authenticated.ok && passwordAllowed(bearer[1]) ? { name: bearer[1], via: "bearer", role: authenticated.role } : null;
12079
+ return authenticated.ok && passwordAllowed(bearer[1]) ? { name: bearer[1], via: "bearer", role: authenticated.role, generation: authenticated.generation } : null;
11969
12080
  }
11970
12081
  const cookies = request.headers.cookie ?? "";
11971
12082
  const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
@@ -12559,7 +12670,9 @@ export function createDecisionServer(options) {
12559
12670
  chatStreams.clear();
12560
12671
  flowRooms.close();
12561
12672
  taskRooms.close();
12562
- void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop())]).then(() => closeServer(callback)).catch(error => {
12673
+ detachReads();
12674
+ liveFollower.close();
12675
+ void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop()), bounded(reads?.close())]).then(() => closeServer(callback)).catch(error => {
12563
12676
  if (callback)
12564
12677
  callback(error instanceof Error ? error : Error('Coding session shutdown failed.'));
12565
12678
  else