toolroll 0.9.48 → 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.
package/dist/serve.js CHANGED
@@ -127,8 +127,8 @@ import { projectAuthority } from "./project-access.js";
127
127
  * centralized gate — `authorizeMutation` — that proves content type, an
128
128
  * allowed Origin, and the per-session CSRF nonce, and refuses duplicated
129
129
  * security fields; a mutation route cannot forget a check it never wrote.
130
- * Bearer mutations carry no cookie for a hostile page to ride, so they skip
131
- * 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.
132
132
  *
133
133
  * **Approval is step-up.** A session alone never approves a scope: the
134
134
  * approval form restates the goal, the exclusions, and the touches — the
@@ -268,9 +268,10 @@ import { targetOf } from "./monitoring.js";
268
268
  import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
269
269
  import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS, tokenLive, tokenProjects } from "./api-tokens.js";
270
270
  import { createMcpHttp } from "./mcp-http.js";
271
- 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";
272
273
  import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
273
- import { createOAuthHttp, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
274
+ import { createOAuthHttp, oauthProjects, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
274
275
  import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
275
276
  import { REQUEST_LIMITS_CSS, requestLimitsHtml, tokenLimitWords } from "./request-budget-ui.js";
276
277
  import { PEOPLE_AUDIT_CSS, personAuditHtml, personHref } from "./people-audit-ui.js";
@@ -568,7 +569,12 @@ export function createDecisionServer(options) {
568
569
  * access to the project) is checked either way.
569
570
  */
570
571
  // v100: the shared check honours a fresh identity-provider sign-in itself (freshIdentitySignIn, set per request below).
571
- 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
+ }
572
578
  const signInBudget = new SourceBudget();
573
579
  const ssoVisits = new Map();
574
580
  const ssoHandoffs = new Map();
@@ -782,17 +788,59 @@ export function createDecisionServer(options) {
782
788
  return { ...summary, card: teamProposalCardParts(store, actor, proposal, snapshot, csrf, decision, clock(), teamChatProvider).card };
783
789
  }) } };
784
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 ?? "");
785
818
  const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
786
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
+ }
787
824
  const who = identify(request, request.method === 'POST');
788
825
  const account = who === null ? null : store.accountOf(who.name);
789
826
  return who && account && account.revokedAt === null ? { name: who.name, generation: account.generation } : null;
790
827
  },
828
+ admit: admitPasswordSource,
829
+ admitAuthenticated: admitBearer,
791
830
  revalidate: (request, actor) => {
792
831
  const account = store.accountOf(actor.name);
793
832
  if (!account || account.revokedAt !== null || account.generation !== actor.generation)
794
833
  return false;
795
- // 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
796
844
  // is rechecked without allowing a passive stream to extend its lifetime.
797
845
  if (request.headers.authorization)
798
846
  return true;
@@ -800,6 +848,8 @@ export function createDecisionServer(options) {
800
848
  return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
801
849
  },
802
850
  authorizeMutation: (request, actor) => {
851
+ if (actor.principal !== undefined)
852
+ return request.headers.origin === undefined;
803
853
  const who = identify(request, false);
804
854
  if (!who || who.name !== actor.name)
805
855
  return false;
@@ -813,29 +863,22 @@ export function createDecisionServer(options) {
813
863
  },
814
864
  execute: async (actor, input) => {
815
865
  const reply = await team.execute(actor, input);
866
+ if (actor.principal !== undefined)
867
+ return reply;
816
868
  const who = identify(request, false);
817
869
  return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
818
870
  }, cursor: team.cursor, streams: teamStreams,
819
871
  });
820
- // v112: one request budget per server for person API tokens, shared by /api/cli and /mcp (request-budget.ts).
821
- const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
822
872
  // Remote CLI: one live API token names the person; the shared command boundary decides everything else.
823
873
  const cliEndpoint = (request, response) => handleCliHttp(request, response, {
824
874
  admit: principal => requestBudget.admit(principal.tokenId, "api"),
825
- authenticate: request => {
826
- const who = identify(request, false, true);
827
- const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
828
- const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
829
- const account = who === null ? null : store.accountOf(who.name);
830
- if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
831
- return null;
832
- return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: tokenProjects(account.projects, row.projects) };
833
- },
875
+ authenticate: tokenPrincipalOf,
834
876
  run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
835
877
  modeOf: options.cliModeOf,
836
878
  store,
837
879
  });
838
- 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 });
839
882
  /** The enumerable admission list for roll-up SQL: repos-only ceilings
840
883
  * enumerate themselves; root ceilings enumerate the STORED repos that
841
884
  * pass the ceiling (Codex roll-up review, finding 11); unscoped = null. */
@@ -945,8 +988,18 @@ export function createDecisionServer(options) {
945
988
  * it is a smuggling attempt.
946
989
  */
947
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
+ }
948
1001
  const current = store.accountOf(who.name);
949
- 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)) {
950
1003
  return { status: 403, message: "Your access changed. Sign in again." };
951
1004
  }
952
1005
  const type = request.headers["content-type"] ?? "";
@@ -1308,7 +1361,8 @@ export function createDecisionServer(options) {
1308
1361
  return;
1309
1362
  if (await cliEndpoint(request, response))
1310
1363
  return;
1311
- 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) }))
1312
1366
  return;
1313
1367
  if (serveBrowserAsset(request, response, url.pathname))
1314
1368
  return;
@@ -1364,7 +1418,30 @@ export function createDecisionServer(options) {
1364
1418
  const fragmentName = url.searchParams.get("fragment");
1365
1419
  const workspaceRead = method === "GET" && url.searchParams.get('format') === 'workspace';
1366
1420
  const touch = !(method === "GET" && (workspaceRead || (fragmentName !== null && NO_TOUCH_FRAGMENTS.has(fragmentName)) || /^\/code\/[a-f0-9]{32}\/state$/.test(url.pathname)));
1367
- 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;
1368
1445
  if (url.pathname === "/login" && method === "GET") {
1369
1446
  if (options.setupCode !== undefined && store.listApprovers().length === 0) {
1370
1447
  return page(response, 200, signupPage(null, setupAttemptsLeft));
@@ -6635,15 +6712,15 @@ export function createDecisionServer(options) {
6635
6712
  }
6636
6713
  switch (verb) {
6637
6714
  case "approve": {
6715
+ if (who.via !== "cookie")
6716
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
6638
6717
  // Step-up, identical to a scope's: the session got you here; only
6639
6718
  // the password agrees. The digest names what was seen.
6640
6719
  const digest = body.get("digest") ?? "";
6641
6720
  const token = body.get("token") ?? "";
6642
- if (who.via === "cookie") {
6643
- const nonce = body.get("nonce") ?? "";
6644
- if (!consumeApprovalNonce(nonce, who.name, `routine:${routine.id}`, digest)) {
6645
- return routinePage(response, who, routineId, "that approval form is stale — read it again", 409);
6646
- }
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);
6647
6724
  }
6648
6725
  if (token === "" && !hasFreshIdentitySignIn(who.name)) {
6649
6726
  return routinePage(response, who, routineId, "approval requires your password, typed again", 400);
@@ -6677,19 +6754,18 @@ export function createDecisionServer(options) {
6677
6754
  return redirect(response, `/routines/${routineId}`);
6678
6755
  }
6679
6756
  case "run-now": {
6757
+ if (who.via !== "cookie")
6758
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
6680
6759
  // Step-up (Codex Phase C review, M3): run-now is spend outside the
6681
6760
  // approved schedule, so a session alone cannot ask for it — the
6682
- // password is typed again, like an approval. A bearer caller
6683
- // re-proved its credential on this very request.
6684
- if (who.via === "cookie") {
6685
- const token = body.get("token") ?? "";
6686
- if (token === "" && !hasFreshIdentitySignIn(who.name)) {
6687
- return routinePage(response, who, routineId, "run now requires your password, typed again", 400);
6688
- }
6689
- const authenticated = authenticateApprover(store, who.name, token);
6690
- if (!authenticated.ok) {
6691
- return routinePage(response, who, routineId, "that is not your password", 403);
6692
- }
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);
6693
6769
  }
6694
6770
  const outcome = fireRoutine(store, routineId, now, { manual: true });
6695
6771
  if (!outcome.ok) {
@@ -6876,7 +6952,7 @@ export function createDecisionServer(options) {
6876
6952
  const repo = body.get('repo') ?? '';
6877
6953
  if (!permitted(repo))
6878
6954
  return fail(403, 'Choose a project available to your account.');
6879
- if (!authenticateApprover(store, who.name, body.get('password') ?? '').ok)
6955
+ if (!authenticateApprover(who, body.get('password') ?? '').ok)
6880
6956
  return fail(403, 'Enter your Toolroll password to authorize this coding session.');
6881
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') ?? '' });
6882
6958
  id = session.id;
@@ -6911,7 +6987,7 @@ export function createDecisionServer(options) {
6911
6987
  const decision = body.get('decision');
6912
6988
  if (decision !== 'accept' && decision !== 'decline' && decision !== 'cancel')
6913
6989
  return fail(400, 'Choose an explicit decision for this request.');
6914
- 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)
6915
6991
  return fail(403, 'Enter your password to approve this additional access.');
6916
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}`) ?? ''] }]));
6917
6993
  coding.answer(id, actor, token, decision, answers);
@@ -7328,7 +7404,7 @@ export function createDecisionServer(options) {
7328
7404
  return renewed.ok ? settle(renewed.said, { reveal: renewed.reveal }) : answer(409, { ok: false, said: renewed.message });
7329
7405
  }
7330
7406
  // Linear's signing secret, pasted on this secure panel behind the password — never in chat.
7331
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7407
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7332
7408
  return answer(403, { ok: false, said: "Enter your Toolroll password to save a secret." });
7333
7409
  const saved = saveLinearSigningSecret(trigger, body.get("secret") ?? "", dir);
7334
7410
  return saved.ok ? settle("Signing secret saved. Linear's deliveries can be proved now.") : answer(400, { ok: false, said: saved.message });
@@ -7366,7 +7442,7 @@ export function createDecisionServer(options) {
7366
7442
  removeLinearKey(dir);
7367
7443
  return settle("Linear key removed.");
7368
7444
  }
7369
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7445
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7370
7446
  return answer(403, { ok: false, said: "Enter your Toolroll password to save a key." });
7371
7447
  const saved = saveLinearKey(dir, body.get("key") ?? "");
7372
7448
  return saved.ok ? settle("Linear key saved on this computer.") : answer(400, { ok: false, said: saved.message });
@@ -7466,7 +7542,7 @@ export function createDecisionServer(options) {
7466
7542
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7467
7543
  return refuse(response, who, 403, "An instance operator sets budgets.", "/spend");
7468
7544
  const back = (key, words) => redirect(response, `/spend?${key}=${encodeURIComponent(words)}`);
7469
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7545
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7470
7546
  return back("problem", "Enter your Toolroll password to change a budget.");
7471
7547
  const target = /^(installation|project|person|teammate):(.+)$/.exec(body.get("target") ?? "");
7472
7548
  if (target === null)
@@ -7530,7 +7606,7 @@ export function createDecisionServer(options) {
7530
7606
  return back("problem", `Nothing was deleted: ${view.running.join(", ")}. Stop it, then try again.`);
7531
7607
  if (body.get("step") !== "delete")
7532
7608
  return confirm(null);
7533
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7609
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7534
7610
  return confirm("That password didn't match. Nothing was deleted.");
7535
7611
  const done = await deleteProject(store, repo, { actor: who.name, via: "console", now, evidenceRoot, poolRoot: options.poolRoot ?? null });
7536
7612
  if (!done.ok)
@@ -7559,7 +7635,7 @@ export function createDecisionServer(options) {
7559
7635
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7560
7636
  return refuse(response, who, 403, "An instance operator sets the policy.", "/settings/policy");
7561
7637
  const back = (key, words) => redirect(response, `/settings/policy?${key}=${encodeURIComponent(words)}`);
7562
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7638
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7563
7639
  return back("problem", "That password didn't match. Nothing changed.");
7564
7640
  const chosen = body.getAll("provider");
7565
7641
  const checked = checkPolicy({ providers: chosen.length === 4 ? null : chosen, models: parseList(body.get("models")), tools: parseList(body.get("tools")), ceiling: body.get("ceiling") ?? "" });
@@ -7580,7 +7656,7 @@ export function createDecisionServer(options) {
7580
7656
  if (!known.includes(repo))
7581
7657
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
7582
7658
  const back = (key, words) => redirect(response, `/settings/approval?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7583
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7659
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7584
7660
  return back("problem", "That password didn't match. Nothing changed.");
7585
7661
  const protect = body.get("protect") ?? "none";
7586
7662
  const paths = parseProtectedPaths(body.get("paths") ?? "");
@@ -7603,7 +7679,7 @@ export function createDecisionServer(options) {
7603
7679
  const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
7604
7680
  if (target !== "*" && token === null)
7605
7681
  return back("problem", "That token is no longer live. Nothing changed.");
7606
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7682
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7607
7683
  return back("problem", "Enter your Toolroll password to change request limits.");
7608
7684
  const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
7609
7685
  const clear = body.get("action") === "clear";
@@ -7664,7 +7740,7 @@ export function createDecisionServer(options) {
7664
7740
  return back("problem", "A viewer's token can only read.");
7665
7741
  // A token is a credential: made with the person's password (or their identity provider's check from moments ago), counted once.
7666
7742
  const typed = body.get("password") ?? "";
7667
- const confirmed = who.role === "approver" ? authenticateApprover(store, who.name, typed).ok
7743
+ const confirmed = who.role === "approver" ? authenticateApprover(who, typed).ok
7668
7744
  : typed === "" ? requestContext.getStore()?.sso?.fresh === true : authenticateAccount(store, who.name, typed).ok;
7669
7745
  if (!confirmed)
7670
7746
  return back("problem", "Enter your Toolroll password to make a token.");
@@ -7690,7 +7766,7 @@ export function createDecisionServer(options) {
7690
7766
  const found = await providerFor(before.issuer);
7691
7767
  return found.ok ? back("said", `${before.label} answers: it's ready for people to sign in.`) : back("problem", found.said);
7692
7768
  }
7693
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7769
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7694
7770
  return back("problem", "Enter your Toolroll password to change sign-in.");
7695
7771
  if (action === "remove") {
7696
7772
  removeSsoSettings(options.configDir);
@@ -7727,7 +7803,7 @@ export function createDecisionServer(options) {
7727
7803
  }
7728
7804
  if (url.pathname === "/settings/updates/cancel")
7729
7805
  return back("said", requestRuntimeUpdateCancel(stateDir));
7730
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7806
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7731
7807
  return back("problem", "Enter your Toolroll password to update.");
7732
7808
  const version = body.get("version") ?? "";
7733
7809
  const when = { now: "now", "when-idle": "when-idle", tonight: "at" }[body.get("when") ?? ""] ?? null;
@@ -7766,7 +7842,7 @@ export function createDecisionServer(options) {
7766
7842
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7767
7843
  return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
7768
7844
  const back = (key, words) => redirect(response, `/settings/retention?${key}=${encodeURIComponent(words)}`);
7769
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7845
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7770
7846
  return back("problem", "Enter your Toolroll password to change retention.");
7771
7847
  const before = store.retentionPeriods();
7772
7848
  // Only what the page offers (or the period already set): a hand-made value doesn't save.
@@ -7788,7 +7864,7 @@ export function createDecisionServer(options) {
7788
7864
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
7789
7865
  const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
7790
7866
  const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
7791
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7867
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7792
7868
  return back("problem", `Enter your Toolroll password to ${what}.`);
7793
7869
  if (url.pathname === "/settings/storage") {
7794
7870
  const cleanup = parseCleanup(body.get("cleanup") ?? "");
@@ -7819,13 +7895,15 @@ export function createDecisionServer(options) {
7819
7895
  const body = readForm(posted, CONSOLE_FORMS.pullRequests);
7820
7896
  const repo = body.get("repo") ?? "";
7821
7897
  const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7822
- 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))
7823
7901
  return refuse(response, who, 404, "No such project.", "/projects");
7824
7902
  if (who.role !== "approver" || store.isDemo())
7825
7903
  return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
7826
7904
  const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7827
7905
  const act = body.get("act");
7828
- if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
7906
+ if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
7829
7907
  return back("problem", "Enter your Toolroll password to change pull requests.");
7830
7908
  if (act === "on") {
7831
7909
  const checked = await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
@@ -7857,12 +7935,14 @@ export function createDecisionServer(options) {
7857
7935
  const body = readForm(posted, CONSOLE_FORMS.checks);
7858
7936
  const repo = body.get("repo") ?? "";
7859
7937
  const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7860
- 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))
7861
7941
  return refuse(response, who, 404, "No such project.", "/projects");
7862
7942
  if (who.role !== "approver" || store.isDemo())
7863
7943
  return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
7864
7944
  const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7865
- if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
7945
+ if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
7866
7946
  return back("problem", "Enter your Toolroll password to change checks.");
7867
7947
  const act = body.get("act");
7868
7948
  // The automatic review switch: the policy log keeps before → after.
@@ -7921,7 +8001,7 @@ export function createDecisionServer(options) {
7921
8001
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
7922
8002
  return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
7923
8003
  const back = (key, words) => redirect(response, `/settings/backups?${key}=${encodeURIComponent(words)}`);
7924
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8004
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7925
8005
  return back("problem", "Enter your Toolroll password to change backups.");
7926
8006
  const every = body.get("every") ?? "";
7927
8007
  const hours = Number(every);
@@ -7946,7 +8026,7 @@ export function createDecisionServer(options) {
7946
8026
  const body = readForm(posted, CONSOLE_FORMS.data);
7947
8027
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7948
8028
  return refuse(response, who, 403, "An instance operator exports data.", "/settings");
7949
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok) {
8029
+ if (!authenticateApprover(who, body.get("password") ?? "").ok) {
7950
8030
  return redirect(response, `/settings/data?problem=${encodeURIComponent("Enter your Toolroll password to download the export.")}`);
7951
8031
  }
7952
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" });
@@ -7991,7 +8071,7 @@ export function createDecisionServer(options) {
7991
8071
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
7992
8072
  return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
7993
8073
  const back = (key, words) => redirect(response, `/settings/monitoring?${key}=${encodeURIComponent(words)}`);
7994
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8074
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
7995
8075
  return back("problem", "Enter your Toolroll password to change monitoring.");
7996
8076
  const before = readMonitoring(options.configDir);
7997
8077
  // Never into Toolroll's own folder, a project or the build checkouts: an agent could read the stream there.
@@ -8048,7 +8128,7 @@ export function createDecisionServer(options) {
8048
8128
  if (connectionsOf(store, target).find(one => one.id === service.id)?.state !== "open")
8049
8129
  return back(`${projectName(target)} already has ${service.label}.`);
8050
8130
  }
8051
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8131
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8052
8132
  return back("Enter your Toolroll password to connect a tool.");
8053
8133
  // An app on this computer: added like a common tool and tested; one that can't be reached isn't kept.
8054
8134
  if (local !== null) {
@@ -8103,7 +8183,7 @@ export function createDecisionServer(options) {
8103
8183
  const action = body.get("action") ?? "";
8104
8184
  const name = body.get("name") ?? "";
8105
8185
  // Anything that adds what a build can run or reach, or a secret, needs the password.
8106
- 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) {
8107
8187
  return back("problem", "Enter your Toolroll password to change this project's tools.");
8108
8188
  }
8109
8189
  const now = clock();
@@ -8358,6 +8438,8 @@ export function createDecisionServer(options) {
8358
8438
  }
8359
8439
  if (["/control/setup-preview", "/control/setup-approve", "/control/instructions-preview", "/control/instructions-approve"].includes(url.pathname)) {
8360
8440
  const body = readForm(posted, CONSOLE_FORMS.projectSetup);
8441
+ if (who.via !== "cookie")
8442
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
8361
8443
  const project = projectOf(who, request);
8362
8444
  if (project == null || body.get("repo") !== project || !visible(project))
8363
8445
  return refuse(response, who, 409, "The selected project changed. Open project setup again.", "/control");
@@ -8373,7 +8455,7 @@ export function createDecisionServer(options) {
8373
8455
  }
8374
8456
  if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
8375
8457
  return refuse(response, who, 409, "This instruction preview expired. Review it again.", "/control");
8376
- if (!authenticateApprover(store, who.name, body.get("token") ?? "").ok)
8458
+ if (!authenticateApprover(who, body.get("token") ?? "").ok)
8377
8459
  return refuse(response, who, 403, "Your operator credential is required.", "/control");
8378
8460
  const added = addProjectInstructions(project, body.get("fingerprint") ?? "");
8379
8461
  if (!added.ok)
@@ -8411,7 +8493,7 @@ export function createDecisionServer(options) {
8411
8493
  savePrimary(dir, "slack");
8412
8494
  return redirect(response, "/settings/slack");
8413
8495
  }
8414
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8496
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8415
8497
  return show("Enter your Toolroll password to change Slack access.", 403);
8416
8498
  const generation = store.accountOf(who.name).generation;
8417
8499
  if (action === "connect") {
@@ -8544,7 +8626,7 @@ export function createDecisionServer(options) {
8544
8626
  savePrimary(dir, "teams");
8545
8627
  return redirect(response, "/settings/teams");
8546
8628
  }
8547
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8629
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8548
8630
  return show("Enter your Toolroll password to change Teams access.", 403);
8549
8631
  const generation = store.accountOf(who.name).generation;
8550
8632
  if (action === "connect") {
@@ -8599,7 +8681,7 @@ export function createDecisionServer(options) {
8599
8681
  savePrimary(dir, "discord");
8600
8682
  return redirect(response, "/settings/discord");
8601
8683
  }
8602
- if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
8684
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
8603
8685
  return show("Enter your Toolroll password to change Discord access.", 403);
8604
8686
  const generation = store.accountOf(who.name).generation;
8605
8687
  if (action === "connect") {
@@ -9139,7 +9221,7 @@ export function createDecisionServer(options) {
9139
9221
  if (who.via !== "cookie")
9140
9222
  return refuse(response, who, 403, "runner registration is a browser surface");
9141
9223
  const token = body.get("token") ?? "";
9142
- if (!authenticateApprover(store, who.name, token).ok) {
9224
+ if (!authenticateApprover(who, token).ok) {
9143
9225
  return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
9144
9226
  }
9145
9227
  const name = (body.get("name") ?? "").trim();
@@ -9240,7 +9322,7 @@ export function createDecisionServer(options) {
9240
9322
  return refuse(response, who, 409, "the terms moved while you were reading — read them again", "/mode");
9241
9323
  }
9242
9324
  const token = body.get("token") ?? "";
9243
- if (!authenticateApprover(store, who.name, token).ok) {
9325
+ if (!authenticateApprover(who, token).ok) {
9244
9326
  return refuse(response, who, 403, "signing a mode takes your password, typed again", "/mode");
9245
9327
  }
9246
9328
  store.signMode({
@@ -9269,7 +9351,7 @@ export function createDecisionServer(options) {
9269
9351
  const body = readForm(posted, CONSOLE_FORMS.peopleProjects);
9270
9352
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
9271
9353
  return refuse(response, who, 403, "An instance operator manages project access.", "/people");
9272
- if (!authenticateApprover(store, who.name, body.get("token") ?? "", null).ok)
9354
+ if (!authenticateApprover(who, body.get("token") ?? "", null).ok)
9273
9355
  return refuse(response, who, 403, "Saving access requires your password.", "/people");
9274
9356
  const access = readAccessForm(body);
9275
9357
  if (!access.ok)
@@ -9287,7 +9369,7 @@ export function createDecisionServer(options) {
9287
9369
  const token = body.get("token") ?? "";
9288
9370
  // RAISING authority is a password ceremony (the doctrine): adding a
9289
9371
  // person to the instance is exactly that.
9290
- if (!authenticateApprover(store, who.name, token).ok) {
9372
+ if (!authenticateApprover(who, token).ok) {
9291
9373
  return refuse(response, who, 403, "making an invite takes your password, typed again", "/people");
9292
9374
  }
9293
9375
  const role = body.get("role") === "approver" ? "approver" : "viewer";
@@ -9316,7 +9398,7 @@ export function createDecisionServer(options) {
9316
9398
  if (who.via !== "cookie")
9317
9399
  return refuse(response, who, 403, "inviting is a browser surface");
9318
9400
  const token = body.get("token") ?? "";
9319
- if (!authenticateApprover(store, who.name, token).ok) {
9401
+ if (!authenticateApprover(who, token).ok) {
9320
9402
  return refuse(response, who, 403, "cancelling an invite takes your password, typed again", "/people");
9321
9403
  }
9322
9404
  const id = Number(body.get("id") ?? "");
@@ -9328,7 +9410,7 @@ export function createDecisionServer(options) {
9328
9410
  if (who.via !== "cookie")
9329
9411
  return refuse(response, who, 403, "removing a person is a browser surface");
9330
9412
  const token = body.get("token") ?? "";
9331
- if (!authenticateApprover(store, who.name, token).ok) {
9413
+ if (!authenticateApprover(who, token).ok) {
9332
9414
  return refuse(response, who, 403, "removing a person takes your password, typed again", "/people");
9333
9415
  }
9334
9416
  const name = (body.get("name") ?? "").trim();
@@ -9348,7 +9430,7 @@ export function createDecisionServer(options) {
9348
9430
  if (who.via !== "cookie")
9349
9431
  return refuse(response, who, 403, "runner retirement is a browser surface");
9350
9432
  const token = body.get("token") ?? "";
9351
- if (!authenticateApprover(store, who.name, token).ok) {
9433
+ if (!authenticateApprover(who, token).ok) {
9352
9434
  return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/fleet");
9353
9435
  }
9354
9436
  const name = (body.get("name") ?? "").trim();
@@ -9399,6 +9481,8 @@ export function createDecisionServer(options) {
9399
9481
  }
9400
9482
  const contestAct = /^\/contest\/([0-9]{1,15})\/(arm|pick|abandon)$/.exec(url.pathname);
9401
9483
  if (contestAct !== null) {
9484
+ if (who.via !== "cookie")
9485
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
9402
9486
  const body = readForm(posted, CONSOLE_FORMS.contest);
9403
9487
  const contestId = Number(contestAct[1]);
9404
9488
  const verb = contestAct[2];
@@ -9451,7 +9535,7 @@ export function createDecisionServer(options) {
9451
9535
  // POST minted. The store consumes the nonce conditionally inside the
9452
9536
  // same transaction that moves the tournament — replay finds it gone.
9453
9537
  const token = body.get("token") ?? "";
9454
- if (!authenticateApprover(store, who.name, token).ok) {
9538
+ if (!authenticateApprover(who, token).ok) {
9455
9539
  return contestScreen(response, who, contestId, "that decision takes your password, typed again", 403);
9456
9540
  }
9457
9541
  const nonceValue = body.get("nonce") ?? "";
@@ -9493,7 +9577,7 @@ export function createDecisionServer(options) {
9493
9577
  if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
9494
9578
  return taskScreen(response, who, act.taskId, "That build isn't part of this task.", 409);
9495
9579
  }
9496
- 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)) {
9497
9581
  return taskScreen(response, who, act.taskId, "That password didn't match. Nothing changed.", 403);
9498
9582
  }
9499
9583
  // When Toolroll can't check at all, the approver also says they checked: never a one-click confirmation.
@@ -9601,7 +9685,7 @@ export function createDecisionServer(options) {
9601
9685
  // (finding 22/33) — the record is consumed from the RETURNED live
9602
9686
  // session, synchronously, before the first await.
9603
9687
  const password = body.get("token") ?? "";
9604
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
9688
+ if (password === "" || !authenticateApprover(who, password).ok) {
9605
9689
  return projectsScreen(response, who, "cloning takes your password, typed again", 403);
9606
9690
  }
9607
9691
  const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
@@ -9669,7 +9753,7 @@ export function createDecisionServer(options) {
9669
9753
  if (store.isDemo())
9670
9754
  return refuse(response, who, 403, "the sandbox never pushes");
9671
9755
  const password = body.get("token") ?? "";
9672
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
9756
+ if (password === "" || !authenticateApprover(who, password).ok) {
9673
9757
  return redirect(response, `/settings?said=${encodeURIComponent("enrolling this device takes your password, typed again")}`);
9674
9758
  }
9675
9759
  const endpoint = body.get("endpoint") ?? "";
@@ -9772,7 +9856,7 @@ export function createDecisionServer(options) {
9772
9856
  return refuse(response, who, 403, "chat setup is a browser surface");
9773
9857
  const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
9774
9858
  const password = body.get("token") ?? "";
9775
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
9859
+ if (password === "" || !authenticateApprover(who, password).ok) {
9776
9860
  return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
9777
9861
  }
9778
9862
  if (body.get("off") === "1") {
@@ -10098,7 +10182,7 @@ export function createDecisionServer(options) {
10098
10182
  // The password, typed again, on EVERY message (v2 ruling 2): chat is
10099
10183
  // spend, and a seven-day cookie is not a spend credential.
10100
10184
  const password = body.get("token") ?? "";
10101
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
10185
+ if (password === "" || !authenticateApprover(who, password).ok) {
10102
10186
  return redirect(response, `/chat?said=${encodeURIComponent("chat spends — your password, typed again, with every message")}`);
10103
10187
  }
10104
10188
  const message = (body.get("message") ?? "").trim();
@@ -10167,7 +10251,7 @@ export function createDecisionServer(options) {
10167
10251
  const candidate = chat?.candidates.get(key);
10168
10252
  // The filing act creates durable rows from model text: password again.
10169
10253
  const password = body.get("token") ?? "";
10170
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
10254
+ if (password === "" || !authenticateApprover(who, password).ok) {
10171
10255
  return redirect(response, `/chat?said=${encodeURIComponent("filing a draft takes your password, typed again")}`);
10172
10256
  }
10173
10257
  if (chat === undefined || candidate === undefined) {
@@ -10240,7 +10324,7 @@ export function createDecisionServer(options) {
10240
10324
  if (turn === null)
10241
10325
  return refuse(response, who, 404, "no such turn", "/chat");
10242
10326
  const password = body.get("token") ?? "";
10243
- if (password === "" || !authenticateApprover(store, who.name, password).ok) {
10327
+ if (password === "" || !authenticateApprover(who, password).ok) {
10244
10328
  return refuse(response, who, 403, "acknowledging unknown spend takes your password", "/chat");
10245
10329
  }
10246
10330
  const nonce = body.get("nonce") ?? "";
@@ -10948,7 +11032,7 @@ export function createDecisionServer(options) {
10948
11032
  }
10949
11033
  basis = { kind: "mode", digest: mode.digest };
10950
11034
  }
10951
- else if (!authenticateApprover(store, who.name, token).ok) {
11035
+ else if (!authenticateApprover(who, token).ok) {
10952
11036
  return refuse(response, who, 403, "authorizing takes your password, typed again", taskHref(taskId));
10953
11037
  }
10954
11038
  const minted = store.mintAttendedAuthorization({
@@ -11125,7 +11209,7 @@ export function createDecisionServer(options) {
11125
11209
  // Authenticated like every approving act: the session alone may
11126
11210
  // read; resuming external work takes the password, typed again.
11127
11211
  const token = (body.get("token") ?? "").trim();
11128
- const authenticated = token !== "" ? authenticateApprover(store, who.name, token) : null;
11212
+ const authenticated = token !== "" ? authenticateApprover(who, token) : null;
11129
11213
  if (authenticated === null || !authenticated.ok) {
11130
11214
  return taskScreen(response, who, taskId, "reopening takes your password, typed again", 403);
11131
11215
  }
@@ -11306,7 +11390,7 @@ export function createDecisionServer(options) {
11306
11390
  // authority takes the password ceremony again — the same act
11307
11391
  // that would be required to approve that authority from scratch.
11308
11392
  const token = (body.get("token") ?? "").trim();
11309
- if (!authenticateApprover(store, who.name, token).ok) {
11393
+ if (!authenticateApprover(who, token).ok) {
11310
11394
  return taskScreen(response, who, taskId, "accepting this revision takes your password, typed again", 403);
11311
11395
  }
11312
11396
  }
@@ -11622,6 +11706,8 @@ export function createDecisionServer(options) {
11622
11706
  return redirect(response, taskHref(taskId));
11623
11707
  }
11624
11708
  case "approve": {
11709
+ if (who.via !== "cookie")
11710
+ return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
11625
11711
  const body = readForm(posted, CONSOLE_FORMS.taskApprove);
11626
11712
  const requestedReturn = body.get("return");
11627
11713
  const approvalBack = requestedReturn === "next"
@@ -11644,11 +11730,9 @@ export function createDecisionServer(options) {
11644
11730
  // rendered to this approver and is spent either way.
11645
11731
  const digest = body.get("digest") ?? "";
11646
11732
  const token = body.get("token") ?? "";
11647
- if (who.via === "cookie") {
11648
- const nonce = body.get("nonce") ?? "";
11649
- if (!consumeApprovalNonce(nonce, who.name, taskId, digest)) {
11650
- return approvalProblem("that approval form is stale — read it again", 409);
11651
- }
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);
11652
11736
  }
11653
11737
  if (token === "" && !hasFreshIdentitySignIn(who.name)) {
11654
11738
  return approvalProblem("approval requires your password, typed again", 400);
@@ -11845,7 +11929,7 @@ export function createDecisionServer(options) {
11845
11929
  return taskScreen(response, who, taskId, "which attempt? the resume names the exact stopped run", 400);
11846
11930
  const runId = Number(named);
11847
11931
  const token = body.get("token") ?? "";
11848
- if (!authenticateApprover(store, who.name, token).ok) {
11932
+ if (!authenticateApprover(who, token).ok) {
11849
11933
  return taskScreen(response, who, taskId, "resuming takes your password, typed again", 403);
11850
11934
  }
11851
11935
  const nonceValue = body.get("nonce") ?? "";
@@ -11925,7 +12009,7 @@ export function createDecisionServer(options) {
11925
12009
  approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
11926
12010
  }));
11927
12011
  }
11928
- 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"),
11929
12013
  resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
11930
12014
  enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
11931
12015
  // MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
@@ -11948,9 +12032,10 @@ export function createDecisionServer(options) {
11948
12032
  // grant (their role, their projects) is checked beside it.
11949
12033
  confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
11950
12034
  projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
12035
+ admitToken: request => oauthTokenBudget.admit(joinSourceOf(request)),
11951
12036
  });
11952
12037
  // ---- identity ------------------------------------------------------------
11953
- function identify(request, touch = true, limited = false) {
12038
+ function identify(request, touch = true, limited = false, refusedToken) {
11954
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.
11955
12040
  const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
11956
12041
  if (presented !== undefined) {
@@ -11967,16 +12052,20 @@ export function createDecisionServer(options) {
11967
12052
  return null;
11968
12053
  // v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
11969
12054
  // through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
11970
- if (kept.row.projects !== null && !limited)
11971
- return null;
11972
12055
  const account = store.accountOf(kept.row.account);
11973
12056
  if (account === null || account.revokedAt !== null)
11974
12057
  return null;
11975
- // v113: an MCP sign-in's token works only at /mcp, while its access secret is fresh (mcp-oauth.ts).
11976
- 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;
11977
12065
  return null;
12066
+ }
11978
12067
  store.touchApiToken(kept.row.id, new Date(at));
11979
- 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 };
11980
12069
  }
11981
12070
  const bearer = /^Bearer (.+):(.+)$/.exec(request.headers.authorization ?? "");
11982
12071
  if (bearer !== null) {
@@ -11987,7 +12076,7 @@ export function createDecisionServer(options) {
11987
12076
  const authenticated = authenticateAccount(store, bearer[1], bearer[2]);
11988
12077
  if (!authenticated.ok && authenticated.reason === "unknown")
11989
12078
  signInBudget.failed(source, at);
11990
- 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;
11991
12080
  }
11992
12081
  const cookies = request.headers.cookie ?? "";
11993
12082
  const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);