toolroll 0.9.45 → 0.9.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/api-tokens.d.ts +33 -0
  3. package/dist/api-tokens.js +39 -0
  4. package/dist/audit-cli.d.ts +17 -0
  5. package/dist/audit-cli.js +86 -0
  6. package/dist/cli-http.d.ts +5 -0
  7. package/dist/cli-http.js +12 -2
  8. package/dist/cli.js +7 -1
  9. package/dist/contracts/cli.d.ts +47 -0
  10. package/dist/contracts/cli.js +11 -0
  11. package/dist/contracts/console-api.d.ts +1 -0
  12. package/dist/contracts/console-api.js +1 -0
  13. package/dist/contracts/mcp-oauth.d.ts +53 -0
  14. package/dist/contracts/mcp-oauth.js +40 -0
  15. package/dist/credentials-ui.d.ts +5 -0
  16. package/dist/credentials-ui.js +16 -4
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/export.js +4 -3
  19. package/dist/mcp-http.d.ts +6 -1
  20. package/dist/mcp-http.js +22 -4
  21. package/dist/mcp-oauth.d.ts +82 -0
  22. package/dist/mcp-oauth.js +479 -0
  23. package/dist/mcp-person.js +1 -1
  24. package/dist/operate-remote-arguments.js +2 -0
  25. package/dist/operate-remote.d.ts +1 -1
  26. package/dist/operate-remote.js +7 -1
  27. package/dist/operate.d.ts +3 -0
  28. package/dist/operate.js +52 -1
  29. package/dist/people-audit-ui.d.ts +14 -0
  30. package/dist/people-audit-ui.js +38 -0
  31. package/dist/public-access.d.ts +50 -0
  32. package/dist/public-access.js +87 -0
  33. package/dist/public-check.d.ts +37 -0
  34. package/dist/public-check.js +170 -0
  35. package/dist/remote-audit.d.ts +79 -0
  36. package/dist/remote-audit.js +161 -0
  37. package/dist/request-budget-ui.d.ts +11 -0
  38. package/dist/request-budget-ui.js +31 -0
  39. package/dist/request-budget.d.ts +96 -0
  40. package/dist/request-budget.js +227 -0
  41. package/dist/serve.d.ts +4 -1
  42. package/dist/serve.js +120 -13
  43. package/dist/store.d.ts +118 -2
  44. package/dist/store.js +236 -7
  45. package/dist/surface.js +19 -2
  46. package/dist/token-notices.d.ts +22 -0
  47. package/dist/token-notices.js +52 -0
  48. package/dist/tokens-cli.d.ts +21 -0
  49. package/dist/tokens-cli.js +184 -0
  50. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -262,9 +262,15 @@ import { LIMITS_CSS, limitsHtml, limitsView } from "./limits-ui.js";
262
262
  import { budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames as teammateNamesOf, usd as spendUsd } from "./spend.js";
263
263
  import { targetOf } from "./monitoring.js";
264
264
  import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
265
- import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS } from "./api-tokens.js";
265
+ import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS, tokenLive, tokenProjects } from "./api-tokens.js";
266
266
  import { createMcpHttp } from "./mcp-http.js";
267
+ 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";
268
+ import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
269
+ import { createOAuthHttp, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
267
270
  import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
271
+ import { REQUEST_LIMITS_CSS, requestLimitsHtml, tokenLimitWords } from "./request-budget-ui.js";
272
+ import { PEOPLE_AUDIT_CSS, personAuditHtml, personHref } from "./people-audit-ui.js";
273
+ import { cursorOf as auditCursorOf, remoteAudit } from "./remote-audit.js";
268
274
  import { logEvent } from "./log.js";
269
275
  import { modeTermsFromJson, modeWords, presetTerms, modeTermsJson, modeDigestOf, MODE_MAX_DAYS } from "./modes.js";
270
276
  import { PROVIDER_KEY_ENV, SUBSCRIPTION_CAPABLE, clearProviderKey, readProviderKey, keyStatus, plausibleKey, readAuthMode, readAuthModeStrict, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
@@ -535,7 +541,7 @@ export function createDecisionServer(options) {
535
541
  * the trusted proxy itself appended; earlier entries are client-typed). */
536
542
  function joinSourceOf(request) {
537
543
  const peer = request.socket.remoteAddress ?? "unknown";
538
- const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
544
+ const loopback = LOOPBACK_PEERS.has(peer);
539
545
  if (!loopback)
540
546
  return peer;
541
547
  const forwarded = request.headers["x-forwarded-for"];
@@ -581,7 +587,7 @@ export function createDecisionServer(options) {
581
587
  /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
582
588
  const fromThisComputer = (request) => {
583
589
  const peer = request.socket.remoteAddress ?? "";
584
- const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
590
+ const loopback = LOOPBACK_PEERS.has(peer);
585
591
  return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
586
592
  && /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
587
593
  };
@@ -797,16 +803,19 @@ export function createDecisionServer(options) {
797
803
  return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
798
804
  }, cursor: team.cursor, streams: teamStreams,
799
805
  });
806
+ // v112: one request budget per server for person API tokens, shared by /api/cli and /mcp (request-budget.ts).
807
+ const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
800
808
  // Remote CLI: one live API token names the person; the shared command boundary decides everything else.
801
809
  const cliEndpoint = (request, response) => handleCliHttp(request, response, {
810
+ admit: principal => requestBudget.admit(principal.tokenId, "api"),
802
811
  authenticate: request => {
803
- const who = identify(request, false);
812
+ const who = identify(request, false, true);
804
813
  const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
805
814
  const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
806
815
  const account = who === null ? null : store.accountOf(who.name);
807
816
  if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
808
817
  return null;
809
- return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: account.projects === null ? null : [...account.projects] };
818
+ return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: tokenProjects(account.projects, row.projects) };
810
819
  },
811
820
  run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
812
821
  modeOf: options.cliModeOf,
@@ -876,6 +885,15 @@ export function createDecisionServer(options) {
876
885
  timer.unref?.();
877
886
  server.on("close", () => clearInterval(timer));
878
887
  }
888
+ {
889
+ const flushBudget = () => { try {
890
+ requestBudget.flush();
891
+ }
892
+ catch { /* the next admit refuses if the database is unwell */ } };
893
+ const timer = setInterval(flushBudget, REQUEST_BUDGET_FLUSH_MS);
894
+ timer.unref?.();
895
+ server.on("close", () => { clearInterval(timer); flushBudget(); });
896
+ }
879
897
  const servedPort = () => {
880
898
  const address = server.address();
881
899
  return typeof address === "object" && address !== null ? address.port : null;
@@ -1237,6 +1255,17 @@ export function createDecisionServer(options) {
1237
1255
  if (!allowedHost(request.headers.host)) {
1238
1256
  return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
1239
1257
  }
1258
+ // A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
1259
+ // plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
1260
+ const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: joinSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
1261
+ const hsts = hstsFor(publicOrigin?.host ?? null, request.headers.host, transport);
1262
+ if (hsts !== null)
1263
+ response.setHeader("Strict-Transport-Security", hsts);
1264
+ const insecure = plainHttpRefusal(transport, hook.pathname, request.headers.authorization);
1265
+ if (insecure !== null) {
1266
+ request.resume();
1267
+ return respond(response, 403, "text/plain; charset=utf-8", insecure);
1268
+ }
1240
1269
  const url = new URL(request.url ?? "/", "http://placeholder");
1241
1270
  // A token in a URL is a token in history, logs, and referers. Refused
1242
1271
  // outright rather than ignored, so nobody learns the habit works.
@@ -1246,6 +1275,9 @@ export function createDecisionServer(options) {
1246
1275
  // The MCP gateway over HTTP (mcp-http.ts): coordinators and people's own API tokens, after the Host check.
1247
1276
  if (url.pathname === "/mcp")
1248
1277
  return mcpHttp(request, response);
1278
+ // MCP sign-in (mcp-oauth.ts): its discovery documents, client registration, consent and tokens.
1279
+ if (await oauthHttp(request, response, url))
1280
+ return;
1249
1281
  // Back from Google's consent screen (v89). The session cookie is SameSite=Strict and stays behind on
1250
1282
  // a return from another site: the visit's one-time state (made by a signed-in approver) is the proof.
1251
1283
  if (url.pathname === GOOGLE_CALLBACK && request.method === "GET")
@@ -2752,11 +2784,26 @@ export function createDecisionServer(options) {
2752
2784
  return sendScreen(response, 200, screen("mode", [`<h1>Operating mode</h1><p>Authorize this project's automatic approvals once, for up to ${MODE_MAX_DAYS} days. Select the touchpoints below; the signature covers the exact choices. Existing modes keep their original terms.</p><p class="meta">Scope changes, agent questions, and requested revisions still require your attention. Automatic merging also needs a publication grant.</p>`, current, signForm].join("\n"), { chrome: chromeFor(project, "mode") }));
2753
2785
  }
2754
2786
  if (url.pathname === "/people") {
2787
+ // People → a person: their API tokens and remote activity (people-audit-ui.ts). An approver may open anyone, within
2788
+ // their projects; anyone else only themselves. Another person reads exactly like nobody.
2789
+ const person = url.searchParams.get("person");
2790
+ if (person !== null) {
2791
+ const token = (url.searchParams.get("token-name") ?? "").slice(0, 60) || null, before = url.searchParams.get("before");
2792
+ const cursor = before === null ? null : auditCursorOf(before);
2793
+ if (before !== null && cursor === null)
2794
+ return refuse(response, who, 400, "Invalid activity cursor.", "/people");
2795
+ const page = who.role === "approver" || person === who.name
2796
+ ? remoteAudit(store, { self: who.name, everyone: who.role === "approver", repos: admissionList(), unplaced: store.isInstanceOperator(who.name) }, { person, token, source: null, since: null }, { before: cursor })
2797
+ : null;
2798
+ if (page === null || !page.ok)
2799
+ return refuse(response, who, 404, "No such person.", "/people");
2800
+ return sendScreen(response, 200, screen("people", personAuditHtml({ name: person, tokens: store.apiTokens(person), actions: page.actions, token, nextCursor: page.nextCursor, now }), { chrome: chromeFor(project, "people") }));
2801
+ }
2755
2802
  // Approvers see everyone (D7's ceiling: every fact already passed
2756
2803
  // the process admission); a viewer sees exactly themselves (U3).
2757
2804
  if (restricted()) {
2758
2805
  const projects = admissionList() ?? [];
2759
- return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p>`, { chrome: chromeFor(project, "people") }));
2806
+ return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p><p><a href="${escape(personHref(who.name))}">Your remote activity</a></p>`, { chrome: chromeFor(project, "people") }));
2760
2807
  }
2761
2808
  const approverView = store.isInstanceOperator(who.name);
2762
2809
  const accounts = store.accountFacts().filter(one => approverView || one.name === who.name);
@@ -2787,6 +2834,7 @@ export function createDecisionServer(options) {
2787
2834
  `<p class="meta">${one.projects === null ? "All projects" : one.projects.length === 0 ? "No project access" : one.projects.map(repo => escape(projectName(repo))).join(", ")}</p>`,
2788
2835
  approverView && one.revokedAt === null ? `<details><summary>Edit project access</summary><form method="post" action="/people/projects">${hiddenFields({ csrf, name: one.name })}${accessFields(one.projects)}<label>Your password<input type="password" name="token" autocomplete="current-password" required></label><p class="meta">Changing access signs this person out and ends their derived sessions and modes. Previously approved work stays recorded.</p><button type="submit">Save project access</button></form></details>` : "",
2789
2836
  `<p class="meta">${seen === null ? "not signed in right now" : `signed in \u2014 active ${escape(new Date(seen).toISOString().slice(11, 16))} UTC`} \u00b7 joined ${escape(one.addedAt.slice(0, 10))}</p>`,
2837
+ `<p><a href="${escape(personHref(one.name))}">Tokens and remote activity</a></p>`,
2790
2838
  attended.length === 0
2791
2839
  ? ""
2792
2840
  : `<p class="row">watching now: ${attended.map(session => `<a href="/t/${escape(session.taskId)}">${escape(session.taskId)}</a>`).join(", ")}</p>`,
@@ -3065,7 +3113,7 @@ export function createDecisionServer(options) {
3065
3113
  if (tokenId === null)
3066
3114
  return true;
3067
3115
  const token = store.apiTokenSecret(tokenId)?.row;
3068
- return token !== undefined && token.revokedAt === null && Date.parse(token.expiresAt) > Date.now();
3116
+ return token !== undefined && tokenLive(token, Date.now());
3069
3117
  };
3070
3118
  // The whole walk it starts from is shared by exports in the same minute; a reader who stops reading for a minute is let go.
3071
3119
  const pieces = ledgerExportChunks(store, { from, to }, { repos: access.repos, instance: store.isInstanceOperator(who.name) }, access, who.name, now, evidenceRoot);
@@ -4034,7 +4082,8 @@ export function createDecisionServer(options) {
4034
4082
  const everyone = url.searchParams.get("everyone") === "1" && store.isInstanceOperator(who.name);
4035
4083
  const here = sessions.hashOf(who.session);
4036
4084
  const view = { who: who.name, everyone, canSeeEveryone: store.isInstanceOperator(who.name), now: Date.now(),
4037
- sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name) };
4085
+ sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name),
4086
+ limits: { note: tokenLimitWords.bind(null, store.handle), section: store.isInstanceOperator(who.name) ? requestLimitsHtml(store.handle, store.apiTokens(null), who.session.csrf, Date.now()) : "" } };
4038
4087
  return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions &amp; tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4039
4088
  }
4040
4089
  // Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
@@ -7472,6 +7521,31 @@ export function createDecisionServer(options) {
7472
7521
  store.setApprovalRules(repo, next, who.name, now);
7473
7522
  return back("said", `Saved. ${rulesSummary(next)}`);
7474
7523
  }
7524
+ // v112: request limits for API tokens (request-budget.ts). An instance operator, in a browser, with their password;
7525
+ // never over /api/cli or /mcp (a token never reaches a console form).
7526
+ if (url.pathname === "/settings/request-limits") {
7527
+ const body = readForm(posted, CONSOLE_FORMS.requestLimits);
7528
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7529
+ return refuse(response, who, 403, "An instance operator sets request limits.", "/settings/sessions");
7530
+ const back = (key, words) => redirect(response, `/settings/sessions?${key}=${encodeURIComponent(words)}`);
7531
+ const target = body.get("target") ?? "";
7532
+ const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
7533
+ if (target !== "*" && token === null)
7534
+ return back("problem", "That token is no longer live. Nothing changed.");
7535
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7536
+ return back("problem", "Enter your Toolroll password to change request limits.");
7537
+ const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
7538
+ const clear = body.get("action") === "clear";
7539
+ const fields = clear ? { readPerMinute: null, actPerMinute: null, perDay: null }
7540
+ : token === null ? { readPerMinute: read("read-per-minute"), actPerMinute: read("act-per-minute"), perDay: read("per-day") }
7541
+ : { readPerMinute: token.access === "read" ? read("per-minute") : null, actPerMinute: token.access === "act" ? read("per-minute") : null, perDay: read("per-day") };
7542
+ const problem = Object.values(fields).find((value) => value !== null && typeof value === "object");
7543
+ if (problem !== undefined)
7544
+ return back("problem", `${problem.problem} Nothing changed.`);
7545
+ setLimitOverride(store, target, fields, who.name, now);
7546
+ const label = token === null ? "Everyone's tokens" : token.name;
7547
+ return back("said", clear || Object.values(fields).every(value => value === null) ? `${label}: default limits.` : `${label}: limits saved.`);
7548
+ }
7475
7549
  if (url.pathname === "/settings/sessions") {
7476
7550
  const body = readForm(posted, CONSOLE_FORMS.sessions);
7477
7551
  if (who.via !== "cookie")
@@ -11780,10 +11854,32 @@ export function createDecisionServer(options) {
11780
11854
  approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
11781
11855
  }));
11782
11856
  }
11783
- const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request) !== null,
11857
+ const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
11858
+ resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
11784
11859
  enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
11860
+ // MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
11861
+ const oauthHttp = createOAuthHttp({ store, clock,
11862
+ originOf: request => consoleOrigin(request.headers.host),
11863
+ requesterKey: joinSourceOf,
11864
+ session: request => {
11865
+ const who = identify(request, false);
11866
+ if (who === null || who.via !== "cookie")
11867
+ return null;
11868
+ return { name: who.name, role: who.role, csrf: who.session.csrf,
11869
+ sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider", fresh: Date.now() - who.session.sso.at < SSO_FRESH_MS } };
11870
+ },
11871
+ sameSite: request => {
11872
+ const origin = request.headers.origin, referer = request.headers.referer;
11873
+ const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
11874
+ return named !== null && allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]);
11875
+ },
11876
+ // The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
11877
+ // grant (their role, their projects) is checked beside it.
11878
+ confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
11879
+ projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
11880
+ });
11785
11881
  // ---- identity ------------------------------------------------------------
11786
- function identify(request, touch = true) {
11882
+ function identify(request, touch = true, limited = false) {
11787
11883
  // 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.
11788
11884
  const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
11789
11885
  if (presented !== undefined) {
@@ -11796,11 +11892,18 @@ export function createDecisionServer(options) {
11796
11892
  signInBudget.failed(source, at);
11797
11893
  return null;
11798
11894
  }
11799
- if (kept.row.revokedAt !== null || Date.parse(kept.row.expiresAt) <= at)
11895
+ if (!tokenLive(kept.row, at))
11896
+ return null;
11897
+ // v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
11898
+ // through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
11899
+ if (kept.row.projects !== null && !limited)
11800
11900
  return null;
11801
11901
  const account = store.accountOf(kept.row.account);
11802
11902
  if (account === null || account.revokedAt !== null)
11803
11903
  return null;
11904
+ // v113: an MCP sign-in's token works only at /mcp, while its access secret is fresh (mcp-oauth.ts).
11905
+ if (!oauthTokenAllowed(store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)))
11906
+ return null;
11804
11907
  store.touchApiToken(kept.row.id, new Date(at));
11805
11908
  return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name };
11806
11909
  }
@@ -12395,6 +12498,10 @@ export function createDecisionServer(options) {
12395
12498
  const closeServer = server.close.bind(server);
12396
12499
  server.close = ((callback) => {
12397
12500
  leadClosing = true;
12501
+ try {
12502
+ requestBudget.flush();
12503
+ }
12504
+ catch { /* saved again on the close event when it can be */ }
12398
12505
  for (const stream of teamStreams)
12399
12506
  stream.end();
12400
12507
  teamStreams.clear();
@@ -16304,7 +16411,7 @@ const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;o
16304
16411
  '.inbox-ask{margin:18px 0 0}.inbox-ask>h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}.inbox-ask>h2 .count{font:500 12px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;color:var(--so-muted)}.inbox-ask h3{font-size:13px;font-weight:600;margin:12px 0 4px}' +
16305
16412
  '.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
16306
16413
  '@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
16307
- const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
16414
+ const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
16308
16415
  /**
16309
16416
  * The board's liveness: fetch this page's own fragment on a timer and swap
16310
16417
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -20132,7 +20239,7 @@ function safeReturn(raw) {
20132
20239
  * button can name. Nothing here is a second redirect framework: one
20133
20240
  * same-site path, or "/".
20134
20241
  */
20135
- const LOGIN_RETURN_PAGES = /^\/(t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
20242
+ const LOGIN_RETURN_PAGES = /^\/(oauth\/authorize|t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
20136
20243
  function loginReturn(raw) {
20137
20244
  const safe = safeReturn(raw);
20138
20245
  if (safe === "/")
package/dist/store.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { DecisionOption } from "./contracts/decision.js";
2
2
  import { type SavedToolAction, type SavedToolRule } from "./contracts/store-json.js";
3
3
  import type { FlowCardChange, FlowCardFields } from "./contracts/flow-card.js";
4
4
  import { type Actor } from "./actor.js";
5
+ import { type TokenPurpose } from "./api-tokens.js";
5
6
  import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
6
7
  import { type ExecutionProfile, type ChainEntry, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
7
8
  import { type ProviderRoom, type CandidateTier, type TaskSize, type TaskSizing, type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RiskLevel, type RouteOverride } from "./phase-routing.js";
@@ -145,7 +146,7 @@ export type SealedLedgerEntry = LedgerEntry & {
145
146
  hash: string;
146
147
  } | null;
147
148
  };
148
- export declare const SCHEMA_VERSION = 110;
149
+ export declare const SCHEMA_VERSION = 113;
149
150
  /**
150
151
  * The migrations `toolroll update` may carry a database through in place: each
151
152
  * one only adds (a table, a column, an accepted value) and leaves every saved row
@@ -155,11 +156,43 @@ export declare const SCHEMA_VERSION = 110;
155
156
  * migrates and does not read this list.
156
157
  *
157
158
  * v110 rebuilds action_ledger only to widen its source check ('api', 'mcp'):
158
- * every row, id and hash-chain link is copied unchanged.
159
+ * every row, id and hash-chain link is copied unchanged. v111 adds nullable
160
+ * api_token columns (project limit, rotation); existing tokens read as before.
161
+ * v112 only adds the two request-budget tables.
162
+ * v113 adds MCP sign-in tables and API-token purpose metadata.
159
163
  */
160
164
  export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
161
165
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
162
166
  export declare function updateSafeSchema(version: number | null): boolean;
167
+ export type OAuthClientRow = {
168
+ id: string;
169
+ name: string;
170
+ redirectUris: string[];
171
+ createdAt: string;
172
+ };
173
+ export type OAuthCodeRow = {
174
+ client: string;
175
+ account: string;
176
+ generation: number;
177
+ access: "read" | "act";
178
+ projects: string[];
179
+ redirectUri: string;
180
+ resource: string;
181
+ challenge: string;
182
+ expiresAt: string;
183
+ usedAt: string | null;
184
+ token: string | null;
185
+ };
186
+ export type OAuthGrantRow = {
187
+ token: string;
188
+ client: string;
189
+ account: string;
190
+ generation: number;
191
+ projects: string[];
192
+ resource: string;
193
+ accessExpiresAt: string;
194
+ refreshHash: string;
195
+ };
163
196
  export type ApiTokenRow = {
164
197
  id: string;
165
198
  account: string;
@@ -171,6 +204,14 @@ export type ApiTokenRow = {
171
204
  lastUsedAt: string | null;
172
205
  revokedAt: string | null;
173
206
  revokedBy: string | null;
207
+ /** v113: MCP manages its own access-secret rotation. */
208
+ purpose: TokenPurpose;
209
+ /** v111: the projects it is limited to (null: every project its person may use). */
210
+ projects: string[] | null;
211
+ /** v111: the token it replaced, the one that replaced it, and when this one stops after being replaced. */
212
+ replaces: string | null;
213
+ replacedBy: string | null;
214
+ overlapUntil: string | null;
174
215
  };
175
216
  export type WebSessionRow = {
176
217
  idHash: string;
@@ -2591,7 +2632,26 @@ export declare class Store {
2591
2632
  access: "read" | "act";
2592
2633
  expiresAt: string;
2593
2634
  by: string;
2635
+ projects?: readonly string[] | null;
2636
+ purpose?: TokenPurpose;
2594
2637
  }, now: Date): void;
2638
+ /**
2639
+ * v111: replace a live, not-yet-replaced token with a new secret on the same terms (access, projects, end date, so
2640
+ * rotating never extends it). The old one keeps working until `overlapUntil` (never past its own end), then stops;
2641
+ * every check reads that time, and endRotatedApiTokens records the end afterwards. Only the token's own person.
2642
+ */
2643
+ rotateApiToken(oldId: string, replacement: {
2644
+ id: string;
2645
+ secretHash: string;
2646
+ }, by: string, now: Date, overlapMs: number): {
2647
+ ok: true;
2648
+ row: ApiTokenRow;
2649
+ } | {
2650
+ ok: false;
2651
+ reason: "unknown" | "replaced" | "mcp-managed";
2652
+ };
2653
+ /** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
2654
+ endRotatedApiTokens(now: Date): number;
2595
2655
  /** A token by id with its kept hash (for the check), or null. */
2596
2656
  apiTokenSecret(id: string): {
2597
2657
  row: ApiTokenRow;
@@ -2602,6 +2662,62 @@ export declare class Store {
2602
2662
  /** Kept at most once a minute: a busy script doesn't write on every request. */
2603
2663
  touchApiToken(id: string, now: Date): void;
2604
2664
  revokeApiToken(id: string, by: string, now: Date, why?: string): boolean;
2665
+ /** A registered client, or null. */
2666
+ oauthClient(id: string): OAuthClientRow | null;
2667
+ /** Expire unused registrations, retaining clients with a live code, grant or in-process consent. */
2668
+ pruneOAuthClients(now: Date, pending?: readonly string[]): void;
2669
+ /**
2670
+ * Unused registrations expire after a day. Admission has a global and per-source bound; it never evicts a live
2671
+ * registration. Repeating metadata from the same source returns its existing registration without spending a slot.
2672
+ */
2673
+ registerOAuthClient(client: {
2674
+ id: string;
2675
+ name: string;
2676
+ redirectUris: string[];
2677
+ source: string;
2678
+ }, now: Date, cap: number, perSourceCap: number, pending?: readonly string[]): OAuthClientRow | null;
2679
+ /** Keep a one-time code by its hash. */
2680
+ saveOAuthCode(hash: string, code: Omit<OAuthCodeRow, "usedAt" | "token">): void;
2681
+ /**
2682
+ * Spend a code: the first caller gets it (`taken`), once, before it expires. A code spent before answers `used`
2683
+ * with the token it made, so that token can be ended; anything else is null.
2684
+ */
2685
+ takeOAuthCode(hash: string, now: Date): {
2686
+ taken: OAuthCodeRow;
2687
+ } | {
2688
+ used: string | null;
2689
+ } | null;
2690
+ /** Make a grant: its API token (created and recorded as any other) and its binding, together; the code notes the token. */
2691
+ createOAuthGrant(code: string, token: {
2692
+ id: string;
2693
+ account: string;
2694
+ name: string;
2695
+ secretHash: string;
2696
+ access: "read" | "act";
2697
+ expiresAt: string;
2698
+ }, grant: Omit<OAuthGrantRow, "token">, now: Date): void;
2699
+ /** The grant an API token signs in for, or null when the token is an ordinary one. */
2700
+ oauthGrant(token: string): OAuthGrantRow | null;
2701
+ /**
2702
+ * Classify, validate and rotate under one write transaction. Every issued hash remains in the family, so any
2703
+ * older secret revokes even a concurrent winner. An unknown guess or the wrong client cannot end the family.
2704
+ */
2705
+ rotateOAuthGrant(token: string, ask: {
2706
+ client: string;
2707
+ resource: string;
2708
+ refreshHash: string;
2709
+ scope?: string;
2710
+ }, next: {
2711
+ refreshHash: string;
2712
+ accessHash: string;
2713
+ accessExpiresAt: string;
2714
+ }, now: Date, projectsFor: (account: string) => readonly string[]): {
2715
+ ok: true;
2716
+ access: "read" | "act";
2717
+ } | {
2718
+ ok: false;
2719
+ reason: "invalid_grant" | "invalid_scope";
2720
+ };
2605
2721
  saveWebSession(session: WebSessionRow): void;
2606
2722
  webSession(idHash: string): WebSessionRow | null;
2607
2723
  webSessions(account: string | null): WebSessionRow[];