toolroll 0.9.44 → 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 (57) hide show
  1. package/CHANGELOG.md +29 -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 +10 -16
  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 +2 -2
  18. package/dist/dispatch.d.ts +2 -2
  19. package/dist/dispatch.js +8 -4
  20. package/dist/export.js +4 -3
  21. package/dist/lead-status.d.ts +7 -3
  22. package/dist/lead-status.js +21 -12
  23. package/dist/mcp-http.d.ts +6 -1
  24. package/dist/mcp-http.js +22 -4
  25. package/dist/mcp-oauth.d.ts +82 -0
  26. package/dist/mcp-oauth.js +479 -0
  27. package/dist/mcp-person.js +1 -1
  28. package/dist/operate-remote-arguments.js +2 -0
  29. package/dist/operate-remote.d.ts +13 -1
  30. package/dist/operate-remote.js +30 -4
  31. package/dist/operate.d.ts +3 -0
  32. package/dist/operate.js +86 -12
  33. package/dist/people-audit-ui.d.ts +14 -0
  34. package/dist/people-audit-ui.js +38 -0
  35. package/dist/public-access.d.ts +50 -0
  36. package/dist/public-access.js +87 -0
  37. package/dist/public-check.d.ts +37 -0
  38. package/dist/public-check.js +170 -0
  39. package/dist/remote-audit.d.ts +79 -0
  40. package/dist/remote-audit.js +161 -0
  41. package/dist/remote-run.d.ts +4 -0
  42. package/dist/request-budget-ui.d.ts +11 -0
  43. package/dist/request-budget-ui.js +31 -0
  44. package/dist/request-budget.d.ts +96 -0
  45. package/dist/request-budget.js +227 -0
  46. package/dist/serve.d.ts +4 -1
  47. package/dist/serve.js +120 -13
  48. package/dist/store.d.ts +118 -2
  49. package/dist/store.js +236 -7
  50. package/dist/surface.js +19 -2
  51. package/dist/token-notices.d.ts +22 -0
  52. package/dist/token-notices.js +52 -0
  53. package/dist/tokens-cli.d.ts +21 -0
  54. package/dist/tokens-cli.js +184 -0
  55. package/dist/work-summary.d.ts +4 -1
  56. package/dist/work-summary.js +1 -1
  57. package/package.json +1 -1
@@ -0,0 +1,227 @@
1
+ export const REQUEST_BUDGET_SCHEMA = `
2
+ CREATE TABLE IF NOT EXISTS request_budget_limit (
3
+ target TEXT PRIMARY KEY CHECK (target = '*' OR (length(target) = 12 AND target NOT GLOB '*[^0-9a-f]*')),
4
+ read_per_minute INTEGER CHECK (read_per_minute IS NULL OR read_per_minute BETWEEN 1 AND 600),
5
+ act_per_minute INTEGER CHECK (act_per_minute IS NULL OR act_per_minute BETWEEN 1 AND 600),
6
+ per_day INTEGER CHECK (per_day IS NULL OR per_day BETWEEN 1 AND 100000),
7
+ set_by TEXT NOT NULL,
8
+ set_at TEXT NOT NULL
9
+ );
10
+ CREATE TABLE IF NOT EXISTS request_budget_usage (
11
+ token_id TEXT PRIMARY KEY,
12
+ minute TEXT NOT NULL,
13
+ day TEXT NOT NULL,
14
+ noted TEXT NOT NULL,
15
+ saved_at INTEGER NOT NULL
16
+ );
17
+ `;
18
+ export const REQUEST_BUDGET_DEFAULTS = Object.freeze({ read: 120, act: 30, day: 10_000 });
19
+ export const PER_MINUTE_MAX = 600;
20
+ export const PER_DAY_MAX = 100_000;
21
+ export const TRACKED_TOKENS = 1024;
22
+ export const FLUSH_MS = 5_000;
23
+ const MINUTE = 60_000, DAY = 86_400_000;
24
+ /** The words for a 429, in one line. */
25
+ export const limitWords = (limit, retryAfter) => `Request limit reached (${limit === "per-day" ? "requests per day" : limit === "read-per-minute" ? "read requests per minute" : "act requests per minute"}). Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.`;
26
+ /** A limit typed into a form: a whole number within bounds, "" for none, or a problem. */
27
+ export function parseLimit(raw, max) {
28
+ const text = raw.trim();
29
+ if (text === "")
30
+ return null;
31
+ if (!/^[0-9]{1,6}$/.test(text))
32
+ return { problem: `Limits are whole numbers from 1 to ${max.toLocaleString("en-US")}.` };
33
+ const value = Number(text);
34
+ return value >= 1 && value <= max ? value : { problem: `Limits are whole numbers from 1 to ${max.toLocaleString("en-US")}.` };
35
+ }
36
+ const within = (value, max) => {
37
+ if (value === null || value === undefined)
38
+ return null;
39
+ const n = Number(value);
40
+ if (!Number.isSafeInteger(n) || n < 1 || n > max)
41
+ throw new Error("request budget: a saved limit is out of bounds");
42
+ return n;
43
+ };
44
+ function readOverride(db, target) {
45
+ const row = db.prepare("SELECT read_per_minute, act_per_minute, per_day FROM request_budget_limit WHERE target = ?").get(target);
46
+ if (row === undefined)
47
+ return null;
48
+ return { readPerMinute: within(row["read_per_minute"], PER_MINUTE_MAX), actPerMinute: within(row["act_per_minute"], PER_MINUTE_MAX), perDay: within(row["per_day"], PER_DAY_MAX) };
49
+ }
50
+ /** Every override: the installation's under "*", each token's under its id. */
51
+ export function limitOverrides(db) {
52
+ const out = new Map();
53
+ for (const row of db.prepare("SELECT target FROM request_budget_limit ORDER BY target").all()) {
54
+ const target = String(row["target"]);
55
+ out.set(target, readOverride(db, target));
56
+ }
57
+ return out;
58
+ }
59
+ /** Token override, then the installation's, then the default — per limit. */
60
+ export function effectiveLimits(db, tokenId, access) {
61
+ const token = readOverride(db, tokenId), installation = readOverride(db, "*");
62
+ const pick = (of, fallback) => (token === null ? null : of(token)) ?? (installation === null ? null : of(installation)) ?? fallback;
63
+ const minute = (one) => access === "read" ? one.readPerMinute : one.actPerMinute;
64
+ const perMinute = pick(minute, REQUEST_BUDGET_DEFAULTS[access]), perDay = pick(one => one.perDay, REQUEST_BUDGET_DEFAULTS.day);
65
+ return { perMinute, perDay, overridden: perMinute !== REQUEST_BUDGET_DEFAULTS[access] || perDay !== REQUEST_BUDGET_DEFAULTS.day };
66
+ }
67
+ /** Set (or, all null, clear) an override, with the change in the policy ledger. The caller checks who may. */
68
+ export function setLimitOverride(store, target, next, by, now) {
69
+ const db = store.handle;
70
+ if (target !== "*" && !/^[0-9a-f]{12}$/.test(target))
71
+ throw new Error("request budget: unknown target");
72
+ within(next.readPerMinute, PER_MINUTE_MAX);
73
+ within(next.actPerMinute, PER_MINUTE_MAX);
74
+ within(next.perDay, PER_DAY_MAX);
75
+ const before = readOverride(db, target);
76
+ const cleared = next.readPerMinute === null && next.actPerMinute === null && next.perDay === null;
77
+ if (cleared)
78
+ db.prepare("DELETE FROM request_budget_limit WHERE target = ?").run(target);
79
+ else
80
+ db.prepare(`INSERT INTO request_budget_limit (target, read_per_minute, act_per_minute, per_day, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)
81
+ ON CONFLICT(target) DO UPDATE SET read_per_minute = excluded.read_per_minute, act_per_minute = excluded.act_per_minute, per_day = excluded.per_day, set_by = excluded.set_by, set_at = excluded.set_at`)
82
+ .run(target, next.readPerMinute, next.actPerMinute, next.perDay, by, now.toISOString());
83
+ const after = cleared ? null : next;
84
+ const words = (one) => one === null ? "defaults" : [one.readPerMinute === null ? null : `read ${one.readPerMinute}/min`, one.actPerMinute === null ? null : `act ${one.actPerMinute}/min`, one.perDay === null ? null : `${one.perDay}/day`].filter(Boolean).join(", ");
85
+ const name = target === "*" ? "installation" : `token ${store.apiTokenSecret(target)?.row.name ?? target}`;
86
+ store.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "request limits changed", outcome: "changed", source: "policy", detail: `${name}: ${words(before)} → ${words(after)}` });
87
+ return { before, after };
88
+ }
89
+ const finiteInts = (value, max) => Array.isArray(value) && value.length <= max && value.every(one => Number.isSafeInteger(one) && one >= 0);
90
+ function prune(usage, now) {
91
+ let drop = 0;
92
+ while (drop < usage.minute.length && usage.minute[drop] <= now - MINUTE)
93
+ drop++;
94
+ if (drop > 0) {
95
+ usage.minute.splice(0, drop);
96
+ usage.dirty = true;
97
+ }
98
+ for (const at of usage.day.keys())
99
+ if ((at + 1) * MINUTE + DAY <= now) {
100
+ usage.day.delete(at);
101
+ usage.dirty = true;
102
+ }
103
+ }
104
+ /** One per server: both routes share it, so a token's allowance is never doubled. */
105
+ export class RequestBudget {
106
+ store;
107
+ clock;
108
+ tracked;
109
+ flushMs;
110
+ usage = new Map();
111
+ lastFlush;
112
+ constructor(options) {
113
+ this.store = options.store;
114
+ this.clock = options.clock ?? Date.now;
115
+ this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
116
+ this.flushMs = options.flushMs ?? FLUSH_MS;
117
+ this.lastFlush = this.clock();
118
+ }
119
+ /** How many tokens are tracked in memory now (never more than `tracked`). */
120
+ get size() { return this.usage.size; }
121
+ /**
122
+ * Admit one request by an authenticated token, or say why not. Called once per HTTP request, before its body is
123
+ * read. Anything unreadable refuses with 503 and nothing runs.
124
+ */
125
+ admit(tokenId, route) {
126
+ try {
127
+ const now = this.clock();
128
+ const token = this.store.apiTokenSecret(tokenId)?.row;
129
+ if (token === undefined)
130
+ return { ok: false, status: 503 };
131
+ const limits = effectiveLimits(this.store.handle, token.id, token.access);
132
+ const usage = this.load(token.id, now);
133
+ prune(usage, now);
134
+ let wait = 0, limit = null;
135
+ if (usage.minute.length >= limits.perMinute) {
136
+ wait = usage.minute[usage.minute.length - limits.perMinute] + MINUTE - now;
137
+ limit = token.access === "read" ? "read-per-minute" : "act-per-minute";
138
+ }
139
+ let total = 0;
140
+ for (const count of usage.day.values())
141
+ total += count;
142
+ if (total >= limits.perDay) {
143
+ let freed = 0;
144
+ for (const at of [...usage.day.keys()].sort((a, b) => a - b)) {
145
+ freed += usage.day.get(at);
146
+ if (total - freed < limits.perDay) {
147
+ const until = (at + 1) * MINUTE + DAY - now;
148
+ if (until > wait || limit === null) {
149
+ wait = until;
150
+ limit = "per-day";
151
+ }
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ if (limit !== null) {
157
+ const retryAfter = Math.max(1, Math.ceil(wait / 1000));
158
+ const window = limit === "per-day" ? "day" : "minute";
159
+ // Once per exhausted window: the marker lasts until the window has room again, and is saved with the entry.
160
+ if (usage.noted[window] <= now) {
161
+ usage.noted[window] = now + retryAfter * 1000;
162
+ usage.dirty = true;
163
+ this.save(token.id, usage, now);
164
+ this.store.recordAction({ at: new Date(now).toISOString(), actor: token.account, repo: null, taskId: null, runId: null, action: "remote request limit reached", outcome: "refused", source: route, detail: `token ${token.name}: ${limit}` });
165
+ }
166
+ return { ok: false, status: 429, limit, retryAfter };
167
+ }
168
+ usage.minute.push(now);
169
+ if (usage.minute.length > PER_MINUTE_MAX)
170
+ usage.minute.splice(0, usage.minute.length - PER_MINUTE_MAX);
171
+ const at = Math.floor(now / MINUTE);
172
+ usage.day.set(at, (usage.day.get(at) ?? 0) + 1);
173
+ usage.dirty = true;
174
+ if (now - this.lastFlush >= this.flushMs)
175
+ this.flush();
176
+ return { ok: true };
177
+ }
178
+ catch {
179
+ return { ok: false, status: 503 };
180
+ }
181
+ }
182
+ /** Save every changed entry. A server calls this on a timer and when it closes. */
183
+ flush() {
184
+ const now = this.clock();
185
+ this.lastFlush = now;
186
+ for (const [id, usage] of this.usage)
187
+ if (usage.dirty)
188
+ this.save(id, usage, now);
189
+ // Saved rows whose every count has expired are no use to anyone.
190
+ this.store.handle.prepare("DELETE FROM request_budget_usage WHERE saved_at <= ?").run(now - DAY - MINUTE);
191
+ }
192
+ save(id, usage, now) {
193
+ this.store.handle.prepare(`INSERT INTO request_budget_usage (token_id, minute, day, noted, saved_at) VALUES (?, ?, ?, ?, ?)
194
+ ON CONFLICT(token_id) DO UPDATE SET minute = excluded.minute, day = excluded.day, noted = excluded.noted, saved_at = excluded.saved_at`)
195
+ .run(id, JSON.stringify(usage.minute), JSON.stringify([...usage.day]), JSON.stringify(usage.noted), now);
196
+ usage.dirty = false;
197
+ }
198
+ /** The tracked entry (now the most recently used), from memory or as last saved; the least recently used is saved and let go. */
199
+ load(id, now) {
200
+ let usage = this.usage.get(id);
201
+ if (usage !== undefined) {
202
+ this.usage.delete(id);
203
+ this.usage.set(id, usage);
204
+ return usage;
205
+ }
206
+ const row = this.store.handle.prepare("SELECT minute, day, noted FROM request_budget_usage WHERE token_id = ?").get(id);
207
+ if (row === undefined)
208
+ usage = { minute: [], day: new Map(), noted: { minute: 0, day: 0 }, dirty: false };
209
+ else {
210
+ const minute = JSON.parse(String(row["minute"])), day = JSON.parse(String(row["day"])), noted = JSON.parse(String(row["noted"]));
211
+ if (!finiteInts(minute, PER_MINUTE_MAX) || !Array.isArray(day) || day.length > 2 * DAY / MINUTE || !day.every(pair => finiteInts(pair, 2) && pair.length === 2)
212
+ || noted === null || typeof noted !== "object" || !finiteInts([noted["minute"], noted["day"]], 2)) {
213
+ throw new Error("request budget: saved usage is unreadable");
214
+ }
215
+ const kept = noted;
216
+ usage = { minute: [...minute].sort((a, b) => a - b), day: new Map(day), noted: { minute: kept.minute, day: kept.day }, dirty: false };
217
+ }
218
+ this.usage.set(id, usage);
219
+ while (this.usage.size > this.tracked) {
220
+ const [oldest, entry] = this.usage.entries().next().value;
221
+ if (entry.dirty)
222
+ this.save(oldest, entry, now);
223
+ this.usage.delete(oldest);
224
+ }
225
+ return usage;
226
+ }
227
+ }
package/dist/serve.d.ts CHANGED
@@ -41,13 +41,16 @@ export type ServeOptions = {
41
41
  * (arc 3 finding 2/16): EXACTLY an origin — no path, query, credentials.
42
42
  * The one trust anchor for secure-context features: it joins the allowed
43
43
  * hosts, its origin authorizes POSTs, cookies turn Secure, and the
44
- * install/push cards light up. X-Forwarded-* is never consulted.
44
+ * install/push cards light up. X-Forwarded-* is consulted only from a
45
+ * loopback peer, the same-host proxy (public-access.ts).
45
46
  */
46
47
  publicUrl?: string;
47
48
  /** Tests: the shared command boundary `POST /api/cli` runs (default: operate.ts's runOperateAs). */
48
49
  cliRunner?: RunOperateAs;
49
50
  /** Tests: remote command metadata until the shared contract declares it. */
50
51
  cliModeOf?: CliHttpOptions['modeOf'];
52
+ /** Tests: the clock request budgets for API tokens count by (request-budget.ts). */
53
+ requestBudgetClock?: () => number;
51
54
  /** Where repos.json lives — every enrollment locks exactly this file. */
52
55
  registryPath?: string;
53
56
  /** This console fronts an `up` process: onboarding copy says how to watch. */
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 === "/")