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,170 @@
1
+ /**
2
+ * `toolroll serve check-public` (remote team, phase 3): is this server ready to be reached on its real domain?
3
+ * Read-only: it sends no credentials, follows no redirects and changes nothing. It checks the --public-url and
4
+ * --allow-host values, asks the console on this computer what it does with relayed requests, then fetches the public
5
+ * address with the platform's own certificate checks and reads what comes back. Each problem is one line saying
6
+ * what to change.
7
+ */
8
+ import { request as httpRequest } from "node:http";
9
+ import { request as httpsRequest } from "node:https";
10
+ import { HSTS_VALUE, isLoopbackAddress, isTailnetAddress, USE_HTTPS } from "./public-access.js";
11
+ const TIMEOUT_MS = 10_000;
12
+ /** A documentation address (RFC 5737): the remote caller the local probe pretends to relay for. */
13
+ const SAMPLE_CALLER = "203.0.113.10";
14
+ const HOST_GRAMMAR = /^[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$|^\[[0-9A-Fa-f:.]{2,45}\](:[0-9]{1,5})?$/;
15
+ /** The default probe: node's http/https with the platform trust store, a timeout and a 4 KB body cap. */
16
+ export const networkProbe = ({ url, headers }) => new Promise(resolve => {
17
+ const target = new URL(url);
18
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
19
+ const outgoing = send(target, { method: "GET", headers, timeout: TIMEOUT_MS }, incoming => {
20
+ let body = "";
21
+ incoming.setEncoding("utf8");
22
+ incoming.on("data", (chunk) => { if (body.length < 4096)
23
+ body += chunk; });
24
+ incoming.on("end", () => resolve({ status: incoming.statusCode ?? 0, headers: incoming.headers, body }));
25
+ incoming.on("error", error => resolve({ error: codeOf(error) }));
26
+ });
27
+ outgoing.on("timeout", () => { outgoing.destroy(); resolve({ error: "ETIMEDOUT" }); });
28
+ outgoing.on("error", error => resolve({ error: codeOf(error) }));
29
+ outgoing.end();
30
+ });
31
+ const codeOf = (error) => typeof error === "object" && error !== null && typeof error.code === "string" ? error.code : "EUNKNOWN";
32
+ /** The exact https origin --public-url must be, as serve.ts requires it; else what is wrong with it. */
33
+ export function publicOriginOf(value) {
34
+ if (value === undefined)
35
+ return "--public-url is missing. Pass the address engineers will use, like --public-url https://toolroll.example.com.";
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(value);
39
+ }
40
+ catch {
41
+ return `--public-url ${value} is not a URL. Use https://<your domain>.`;
42
+ }
43
+ if (parsed.protocol !== "https:")
44
+ return `--public-url must start with https:// (got ${parsed.protocol}//).`;
45
+ if (parsed.username !== "" || parsed.password !== "" || parsed.hash !== "" || parsed.search !== "" || (parsed.pathname !== "/" && parsed.pathname !== "")) {
46
+ return "--public-url must be just https://<host>, with no path, query or credentials.";
47
+ }
48
+ return parsed;
49
+ }
50
+ /** Names a plain-HTTP browser can reasonably use: this computer, the tailnet, a LAN or a single-label name. */
51
+ function privateName(host) {
52
+ const name = host.replace(/:[0-9]{1,5}$/, "").replace(/^\[(.*)\]$/, "$1").toLowerCase();
53
+ if (name === "localhost" || name.endsWith(".ts.net") || name.endsWith(".local") || !name.includes(".") && !name.includes(":"))
54
+ return true;
55
+ if (isLoopbackAddress(name) || isTailnetAddress(name))
56
+ return true;
57
+ const v4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(name);
58
+ if (v4 !== null) {
59
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
60
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
61
+ }
62
+ return /^f[cd][0-9a-f]{2}:|^fe80:/.test(name);
63
+ }
64
+ const header = (answer, name) => {
65
+ const value = answer.headers[name];
66
+ return Array.isArray(value) ? value.join(", ") : value;
67
+ };
68
+ /** The HSTS max-age a response sets, or null when it sets none. */
69
+ const hstsAge = (value) => {
70
+ const match = value === undefined ? null : /(?:^|;)\s*max-age\s*=\s*"?(\d+)"?/i.exec(value);
71
+ return match === null ? null : Number(match[1]);
72
+ };
73
+ const refusedForHttp = (answer) => !("error" in answer) && answer.status === 403 && answer.body.trim() === USE_HTTPS;
74
+ function certificateProblem(host, code) {
75
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN")
76
+ return `${host} does not resolve. Point its DNS at this server or your tunnel.`;
77
+ if (code === "ECONNREFUSED")
78
+ return `Nothing answers HTTPS at ${host}. Start Caddy or the tunnel.`;
79
+ if (code === "ETIMEDOUT" || code === "ECONNRESET")
80
+ return `${host} did not answer over HTTPS within ${TIMEOUT_MS / 1000} seconds. Check the firewall, Caddy or the tunnel.`;
81
+ if (code === "CERT_HAS_EXPIRED")
82
+ return `The certificate for ${host} has expired. Renew it.`;
83
+ if (code === "ERR_TLS_CERT_ALTNAME_INVALID")
84
+ return `The certificate does not name ${host}. Issue one for this exact host.`;
85
+ if (/SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER|CERT_UNTRUSTED/.test(code))
86
+ return `The certificate for ${host} is not trusted (${code}). Use a publicly trusted certificate, such as Caddy's automatic one.`;
87
+ return `Could not reach https://${host} (${code}).`;
88
+ }
89
+ /** Every problem with serving on --public-url, in a fixed order; an empty list means ready. */
90
+ export async function checkPublic(options, probe = networkProbe) {
91
+ const origin = publicOriginOf(options.publicUrl);
92
+ if (typeof origin === "string")
93
+ return [{ check: "public-url", message: origin }];
94
+ const host = origin.host;
95
+ const findings = [];
96
+ for (const allowed of options.allowHosts) {
97
+ if (!HOST_GRAMMAR.test(allowed))
98
+ findings.push({ check: "allow-host", message: `--allow-host ${allowed} is not a host name or host:port.` });
99
+ else if (allowed !== host && !privateName(allowed)) {
100
+ findings.push({ check: "allow-host", message: `--allow-host ${allowed} is a public name served over plain HTTP, so passwords would cross the internet unencrypted. Remove it: --public-url already admits ${host}.` });
101
+ }
102
+ }
103
+ // The console on this computer, asked as the same-host proxy would ask it.
104
+ const local = `http://127.0.0.1:${options.port}`;
105
+ const relayed = { host, "x-forwarded-for": SAMPLE_CALLER };
106
+ const page = await probe({ url: `${local}/login`, headers: { ...relayed, "x-forwarded-proto": "https" } });
107
+ if ("error" in page) {
108
+ findings.push({ check: "local", message: `Nothing answers on 127.0.0.1:${options.port}. Start Toolroll there, or pass the --port it uses.` });
109
+ }
110
+ else if (page.status === 421) {
111
+ findings.push({ check: "host", message: `The server on port ${options.port} does not answer as ${host}. Restart it with --public-url ${origin.origin}.` });
112
+ }
113
+ else {
114
+ if (header(page, "strict-transport-security") !== HSTS_VALUE) {
115
+ findings.push({ check: "hsts", message: `The server sends no Strict-Transport-Security for ${host} over HTTPS. Update Toolroll and restart it with --public-url ${origin.origin}.` });
116
+ }
117
+ const plain = await probe({ url: `${local}/api/cli`, headers: { ...relayed, "x-forwarded-proto": "http" } });
118
+ if (!refusedForHttp(plain)) {
119
+ findings.push({ check: "forwarding", message: "The server does not refuse API tokens relayed over plain HTTP from another computer. Update Toolroll." });
120
+ }
121
+ }
122
+ // The public address, with the platform's certificate checks.
123
+ const outside = await probe({ url: `${origin.origin}/login`, headers: {} });
124
+ if ("error" in outside) {
125
+ findings.push({ check: "certificate", message: certificateProblem(host, outside.error) });
126
+ return findings;
127
+ }
128
+ if (outside.status === 421) {
129
+ findings.push({ check: "proxy", message: `The proxy changes the Host header, so Toolroll refuses ${host}. Pass the original Host through.` });
130
+ return findings;
131
+ }
132
+ const age = hstsAge(header(outside, "strict-transport-security"));
133
+ if (age === null) {
134
+ findings.push({ check: "hsts", message: `https://${host} sends no Strict-Transport-Security. Run the proxy on this computer and have it send X-Forwarded-Proto: https.` });
135
+ }
136
+ else if (age < 31_536_000) {
137
+ findings.push({ check: "hsts", message: `https://${host} sets Strict-Transport-Security max-age=${age}; use at least 31536000 (one year).` });
138
+ }
139
+ const api = await probe({ url: `${origin.origin}/api/cli`, headers: {} });
140
+ if (refusedForHttp(api)) {
141
+ findings.push({ check: "proxy", message: `Requests through https://${host} reach Toolroll as plain HTTP from another computer. Run the proxy on this computer, pointed at 127.0.0.1:${options.port}, sending X-Forwarded-For and X-Forwarded-Proto.` });
142
+ }
143
+ return findings;
144
+ }
145
+ /** The command: findings as lines (or one envelope), exit 0 when ready, 1 when not, 2 for a malformed --port. */
146
+ export async function checkPublicCommand(flags, context) {
147
+ const command = "serve check-public";
148
+ const value = (name) => { const one = flags.get(name); return typeof one === "string" ? one : undefined; };
149
+ const portGiven = value("port");
150
+ const port = Number(portGiven ?? 4180);
151
+ if (!Number.isInteger(port) || port < 1 || port >= 65536) {
152
+ const message = "--port is a whole number under 65536";
153
+ context.write(context.json ? context.envelope({ ok: false, command, reason: "usage", message }) : message);
154
+ return 2;
155
+ }
156
+ const publicUrl = value("public-url");
157
+ const allowHosts = (value("allow-host") ?? "").split(",").map(one => one.trim()).filter(one => one !== "");
158
+ const findings = await checkPublic({ publicUrl, port, allowHosts }, context.probe);
159
+ const ready = findings.length === 0;
160
+ const message = ready ? `${publicUrl} is ready: valid certificate, HSTS, and API tokens only over HTTPS.` : `${findings.length === 1 ? "1 problem" : `${findings.length} problems`} serving ${publicUrl ?? "on a public address"}:`;
161
+ if (context.json) {
162
+ context.write(context.envelope({ ok: ready, command, ...(ready ? {} : { reason: "not-ready", message }), publicUrl: publicUrl ?? null, port, findings }));
163
+ }
164
+ else {
165
+ context.write(message);
166
+ for (const finding of findings)
167
+ context.write(`- ${finding.message}`);
168
+ }
169
+ return ready ? 0 : 1;
170
+ }
@@ -0,0 +1,79 @@
1
+ import type { ApiTokenRow, Store } from "./store.js";
2
+ export declare const AUDIT_SOURCES: readonly ["api", "mcp"];
3
+ export type AuditSource = typeof AUDIT_SOURCES[number];
4
+ export type AuditOutcome = "ok" | "refused" | "error";
5
+ export declare const AUDIT_LIMIT: {
6
+ readonly default: 20;
7
+ readonly max: 100;
8
+ };
9
+ export type AuditFilters = {
10
+ person: string | null;
11
+ token: string | null;
12
+ source: AuditSource | null;
13
+ since: string | null;
14
+ };
15
+ /** One remote action. `kind` says whether a person's CLI ran a command or their agent called a tool over MCP. */
16
+ export type AuditAction = {
17
+ id: number;
18
+ at: string;
19
+ person: string;
20
+ token: string | null;
21
+ source: AuditSource;
22
+ kind: "command" | "tool";
23
+ /** The command row (`task show`), or for a tool call the tool's name when the line recorded it. */
24
+ name: string;
25
+ command: string;
26
+ tool: string | null;
27
+ repo: string | null;
28
+ taskId: string | null;
29
+ outcome: AuditOutcome;
30
+ /** A refusal's stable reason (`read-only`, `not-found`, ...), never free text. */
31
+ reason: string | null;
32
+ };
33
+ /**
34
+ * Who is reading. `self` null: the machine's own owner, reading the database here. Otherwise a person:
35
+ * `everyone` when they approve; `repos` their project ceiling (null: every project); `unplaced` whether lines in no
36
+ * project (refused before a project was known) are theirs to see for other people.
37
+ */
38
+ export type AuditReader = {
39
+ self: string | null;
40
+ everyone: boolean;
41
+ repos: readonly string[] | null;
42
+ unplaced: boolean;
43
+ };
44
+ export declare const OWNER_READER: AuditReader;
45
+ export type AuditPage = {
46
+ ok: true;
47
+ actions: AuditAction[];
48
+ nextCursor: string | null;
49
+ limit: number;
50
+ } | {
51
+ ok: false;
52
+ reason: "not-found";
53
+ message: string;
54
+ };
55
+ /** The old final words, in the vocabulary people read. Anything unrecognized reads as an error, never as ok. */
56
+ export declare function auditOutcome(outcome: string): AuditOutcome | null;
57
+ /** `7d` → the instant that many days before `now`; null for anything else (no zero, sign, fraction or other unit). */
58
+ export declare function sinceOf(value: string, now: Date): string | null;
59
+ export declare const cursorOf: (value: string) => number | null;
60
+ /**
61
+ * A line's token, tool and reason. The detail is `token <name>` then ` · `-separated parts: the token name is the
62
+ * longest of this person's token names (or ids, for a line written before the token was proved) that matches up to a
63
+ * separator, since a name may itself contain ` · `. Unmatched text is never echoed.
64
+ */
65
+ export declare function readDetail(detail: string | null, tokens: readonly Pick<ApiTokenRow, "id" | "name">[]): {
66
+ token: string | null;
67
+ tool: string | null;
68
+ reason: string | null;
69
+ };
70
+ /**
71
+ * One page of remote actions, newest first, keyed by entry id so new lines never shift an older page. The reader's
72
+ * visibility and every filter are applied before the page is cut; the token filter is an exact name match.
73
+ */
74
+ export declare function remoteAudit(store: Store, reader: AuditReader, filters: AuditFilters, page?: {
75
+ limit?: number;
76
+ before?: number | null;
77
+ }): AuditPage;
78
+ /** A token's standing, for People. */
79
+ export declare function tokenStatus(token: Pick<ApiTokenRow, "revokedAt" | "expiresAt">, now: Date): "active" | "expired" | "revoked";
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Remote activity (phase 3, docs/plans/remote-team.md): what people did on this server with their API tokens, read
3
+ * from the action history runOperateAs already writes — one `requested` line when a command starts, and a final line
4
+ * (`done`, `refused`, `usage`, `failed`) when it ends or is refused. The history is append-only and sealed, so old
5
+ * words are never rewritten: this projection maps them to `ok`, `refused` and `error` as it reads, and leaves out the
6
+ * `requested` lines. It never shows a line's raw detail; the token's name, an MCP tool and a refusal reason are
7
+ * recovered from it by exact match.
8
+ *
9
+ * Who sees what is decided inside the query, before a page is cut: the machine's owner (a local run) sees everyone;
10
+ * an approver sees everyone within their project ceiling and all of their own lines; a viewer sees only their own.
11
+ * Another person the reader may not see answers exactly like a person who doesn't exist.
12
+ */
13
+ import { IN_RANGE } from "./ledger-chain.js";
14
+ export const AUDIT_SOURCES = ["api", "mcp"];
15
+ export const AUDIT_LIMIT = { default: 20, max: 100 };
16
+ const SINCE_MAX_DAYS = 3650;
17
+ export const OWNER_READER = Object.freeze({ self: null, everyone: true, repos: null, unplaced: true });
18
+ /** The old final words, in the vocabulary people read. Anything unrecognized reads as an error, never as ok. */
19
+ export function auditOutcome(outcome) {
20
+ if (outcome === "requested")
21
+ return null;
22
+ if (outcome === "done")
23
+ return "ok";
24
+ if (outcome === "refused" || outcome === "usage")
25
+ return "refused";
26
+ return "error";
27
+ }
28
+ /** `7d` → the instant that many days before `now`; null for anything else (no zero, sign, fraction or other unit). */
29
+ export function sinceOf(value, now) {
30
+ const match = /^([1-9][0-9]{0,3})d$/.exec(value);
31
+ if (match === null || Number(match[1]) > SINCE_MAX_DAYS)
32
+ return null;
33
+ return new Date(now.getTime() - Number(match[1]) * 86_400_000).toISOString();
34
+ }
35
+ export const cursorOf = (value) => /^[1-9][0-9]{0,15}$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
36
+ const REASON = /^[a-z][a-z0-9-]{0,40}$/;
37
+ const TOOL = /^[a-z][a-z0-9_]{0,40}$/;
38
+ const COMMAND = "remote command: ";
39
+ /**
40
+ * A line's token, tool and reason. The detail is `token <name>` then ` · `-separated parts: the token name is the
41
+ * longest of this person's token names (or ids, for a line written before the token was proved) that matches up to a
42
+ * separator, since a name may itself contain ` · `. Unmatched text is never echoed.
43
+ */
44
+ export function readDetail(detail, tokens) {
45
+ const none = { token: null, tool: null, reason: null };
46
+ if (detail === null || !detail.startsWith("token "))
47
+ return none;
48
+ const body = detail.slice("token ".length);
49
+ let token = null, rest = "", matched = -1;
50
+ for (const one of tokens) {
51
+ for (const label of [one.name, one.id]) {
52
+ if ((body === label || body.startsWith(`${label} · `)) && label.length > matched) {
53
+ token = one.name;
54
+ rest = body.slice(label.length);
55
+ matched = label.length;
56
+ }
57
+ }
58
+ }
59
+ if (token === null)
60
+ return none;
61
+ let tool = null, reason = null;
62
+ for (const part of rest.split(" · ").slice(1)) {
63
+ if (part.startsWith("tool ") && TOOL.test(part.slice(5)))
64
+ tool = part.slice(5);
65
+ else if (REASON.test(part))
66
+ reason ??= part;
67
+ }
68
+ return { token, tool, reason };
69
+ }
70
+ /**
71
+ * One page of remote actions, newest first, keyed by entry id so new lines never shift an older page. The reader's
72
+ * visibility and every filter are applied before the page is cut; the token filter is an exact name match.
73
+ */
74
+ export function remoteAudit(store, reader, filters, page = {}) {
75
+ const notFound = { ok: false, reason: "not-found", message: "Not found." };
76
+ const limit = Math.max(1, Math.min(AUDIT_LIMIT.max, Math.trunc(page.limit ?? AUDIT_LIMIT.default)));
77
+ if (filters.person !== null) {
78
+ if (store.accountOf(filters.person) === null)
79
+ return notFound;
80
+ if (reader.self !== null && !reader.everyone && filters.person !== reader.self)
81
+ return notFound;
82
+ }
83
+ const clauses = ["source IN ('api','mcp')", "outcome <> 'requested'", `action LIKE '${COMMAND}%'`];
84
+ const args = [];
85
+ if (reader.self !== null) {
86
+ if (!reader.everyone) {
87
+ clauses.push("actor = ?");
88
+ args.push(reader.self);
89
+ }
90
+ else if (reader.repos !== null || !reader.unplaced) {
91
+ const placed = reader.repos === null ? "repo IS NOT NULL" : reader.repos.length === 0 ? "0" : `repo IN (${reader.repos.map(() => "?").join(",")})`;
92
+ clauses.push(`(actor = ? OR ${placed}${reader.unplaced ? " OR repo IS NULL" : ""})`);
93
+ args.push(reader.self, ...(reader.repos ?? []));
94
+ }
95
+ }
96
+ if (filters.person !== null) {
97
+ clauses.push("actor = ?");
98
+ args.push(filters.person);
99
+ }
100
+ if (filters.source !== null) {
101
+ clauses.push("source = ?");
102
+ args.push(filters.source);
103
+ }
104
+ if (filters.since !== null) {
105
+ clauses.push("at >= ?");
106
+ args.push(filters.since);
107
+ }
108
+ const tokens = new Map();
109
+ for (const one of store.apiTokens(null))
110
+ tokens.set(one.account, [...(tokens.get(one.account) ?? []), one]);
111
+ if (filters.token !== null) {
112
+ // Narrow by every label a line for this name could start with; the exact owner of the line is decided below.
113
+ const labels = [...new Set([...tokens.values()].flat().filter(one => one.name === filters.token).flatMap(one => [one.name, one.id]))];
114
+ if (labels.length === 0)
115
+ return { ok: true, actions: [], nextCursor: null, limit };
116
+ clauses.push(`(${labels.map(() => "(detail = ? OR substr(detail, 1, ?) = ?)").join(" OR ")})`);
117
+ for (const label of labels)
118
+ args.push(`token ${label}`, `token ${label} · `.length, `token ${label} · `);
119
+ }
120
+ const statement = store.handle.prepare(`SELECT id, at, actor, repo, task_id, action, outcome, source, detail FROM action_ledger
121
+ WHERE id ${IN_RANGE} AND id < ? AND ${clauses.join(" AND ")} ORDER BY id DESC LIMIT ?`);
122
+ const actions = [];
123
+ let before = page.before ?? Number.MAX_SAFE_INTEGER, more = false;
124
+ const batch = limit + 1;
125
+ while (actions.length <= limit) {
126
+ const rows = statement.all(before, ...args, batch);
127
+ for (const row of rows) {
128
+ before = Number(row["id"]);
129
+ const action = actionOf(row, tokens.get(String(row["actor"])) ?? []);
130
+ if (action === null || filters.token !== null && action.token !== filters.token)
131
+ continue;
132
+ if (actions.length === limit) {
133
+ more = true;
134
+ break;
135
+ }
136
+ actions.push(action);
137
+ }
138
+ if (more || rows.length < batch)
139
+ break;
140
+ }
141
+ return { ok: true, actions, nextCursor: more ? String(actions.at(-1).id) : null, limit };
142
+ }
143
+ function actionOf(row, tokens) {
144
+ const outcome = auditOutcome(String(row["outcome"]));
145
+ const source = String(row["source"]);
146
+ if (outcome === null || (source !== "api" && source !== "mcp"))
147
+ return null;
148
+ const command = String(row["action"]).slice(COMMAND.length);
149
+ const read = readDetail(row["detail"] == null ? null : String(row["detail"]), tokens);
150
+ const tool = source === "mcp" ? read.tool : null;
151
+ return {
152
+ id: Number(row["id"]), at: String(row["at"]), person: String(row["actor"]), token: read.token, source,
153
+ kind: source === "mcp" ? "tool" : "command", name: tool ?? command, command, tool,
154
+ repo: row["repo"] == null ? null : String(row["repo"]), taskId: row["task_id"] == null ? null : String(row["task_id"]),
155
+ outcome, reason: outcome === "ok" ? null : read.reason ?? (String(row["outcome"]) === "usage" ? "usage" : null),
156
+ };
157
+ }
158
+ /** A token's standing, for People. */
159
+ export function tokenStatus(token, now) {
160
+ return token.revokedAt !== null ? "revoked" : Date.parse(token.expiresAt) <= now.getTime() ? "expired" : "active";
161
+ }
@@ -8,6 +8,10 @@ export type RemoteRun = {
8
8
  readonly secret: string;
9
9
  /** Whether the person may use `repo` now (their token's projects and their account's current access). */
10
10
  readonly allows: (repo: string | null) => boolean;
11
+ /** The projects listings and task references may disclose (remoteLensOf); null shows every project. */
12
+ readonly lens: readonly string[] | null;
13
+ /** Called with the task a remote `task add` filed (or replayed), so its action history names it. */
14
+ readonly noteTask: (taskId: string) => void;
11
15
  /** Files the caller sent, keyed by the argument that names them. Nothing else is read for input. */
12
16
  readonly files: Readonly<Record<string, string>>;
13
17
  };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Settings → Sessions & tokens: each token's request limits in its line, and — for an instance operator — one
3
+ * disclosure to change them for everyone's tokens or for one token (request-budget.ts). Saving takes the operator's
4
+ * password (or a fresh identity-provider sign-in), in a browser only.
5
+ */
6
+ import type { ApiTokenRow, Database } from "./store.js";
7
+ export declare const REQUEST_LIMITS_CSS: string;
8
+ /** "120 requests a minute · 10,000 a day" for one token's line. */
9
+ export declare function tokenLimitWords(db: Database, token: ApiTokenRow): string;
10
+ /** The operator's disclosure: everyone's tokens, or one token. Blank fields use the default shown. */
11
+ export declare function requestLimitsHtml(db: Database, tokens: readonly ApiTokenRow[], csrf: string, now: number): string;
@@ -0,0 +1,31 @@
1
+ import { effectiveLimits, limitOverrides, PER_DAY_MAX, PER_MINUTE_MAX, REQUEST_BUDGET_DEFAULTS } from "./request-budget.js";
2
+ const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
+ const n = (value) => value.toLocaleString("en-US");
4
+ export const REQUEST_LIMITS_CSS = `.credentials .limits{display:grid;gap:14px}.credentials .limits form{display:grid;gap:10px}.credentials .limits label{display:grid;gap:6px}` +
5
+ `.credentials .limits input,.credentials .limits select{box-sizing:border-box;width:100%;min-width:0}.credentials .limits .pair{display:grid;grid-template-columns:1fr 1fr;gap:10px}.credentials .limits .actions{display:flex;flex-wrap:wrap;gap:8px}` +
6
+ `.credentials .limits h3{font-size:.95rem;margin:4px 0 0}@media(max-width:600px){.credentials .limits .pair{grid-template-columns:1fr}.credentials .limits input,.credentials .limits select{font-size:16px}}`;
7
+ /** "120 requests a minute · 10,000 a day" for one token's line. */
8
+ export function tokenLimitWords(db, token) {
9
+ const limits = effectiveLimits(db, token.id, token.access);
10
+ return `${n(limits.perMinute)} requests a minute · ${n(limits.perDay)} a day`;
11
+ }
12
+ /** The operator's disclosure: everyone's tokens, or one token. Blank fields use the default shown. */
13
+ export function requestLimitsHtml(db, tokens, csrf, now) {
14
+ const overrides = limitOverrides(db);
15
+ const everyone = overrides.get("*") ?? { readPerMinute: null, actPerMinute: null, perDay: null };
16
+ const field = (name, label, value, placeholder, max) => `<label>${label}<input type="number" name="${name}" min="1" max="${max}" step="1" inputmode="numeric" value="${value === null ? "" : value}" placeholder="${placeholder}"></label>`;
17
+ const password = `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`;
18
+ const buttons = `<div class="actions"><button name="action" value="save">Save limits</button><button class="secondary" name="action" value="clear">Use defaults</button></div>`;
19
+ const live = tokens.filter(one => one.revokedAt === null && Date.parse(one.expiresAt) > now);
20
+ const installation = `<form method="post" action="/settings/request-limits"><input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="target" value="*">` +
21
+ `<h3>Everyone's tokens</h3><div class="pair">${field("read-per-minute", "Read tokens, a minute", everyone.readPerMinute, String(REQUEST_BUDGET_DEFAULTS.read), PER_MINUTE_MAX)}` +
22
+ `${field("act-per-minute", "Act tokens, a minute", everyone.actPerMinute, String(REQUEST_BUDGET_DEFAULTS.act), PER_MINUTE_MAX)}</div>` +
23
+ `${field("per-day", "Each token, a day", everyone.perDay, String(REQUEST_BUDGET_DEFAULTS.day), PER_DAY_MAX)}${password}${buttons}</form>`;
24
+ const one = live.length === 0 ? "" : `<form method="post" action="/settings/request-limits"><input type="hidden" name="csrf" value="${e(csrf)}">` +
25
+ `<h3>One token</h3><label>Token<select name="target">${live.map(row => {
26
+ const set = overrides.get(row.id);
27
+ return `<option value="${e(row.id)}">${e(row.name)} (${e(row.account)}, ${row.access === "act" ? "act" : "read"})${set === undefined ? "" : " · own limits"}</option>`;
28
+ }).join("")}</select></label>` +
29
+ `<div class="pair">${field("per-minute", "A minute", null, "Everyone's", PER_MINUTE_MAX)}${field("per-day", "A day", null, "Everyone's", PER_DAY_MAX)}</div>${password}${buttons}</form>`;
30
+ return `<details class="card"><summary>Request limits</summary><div class="limits">${installation}${one}</div></details>`;
31
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Request budgets for person API tokens (v112): how many requests one `so_` token may make to `/api/cli` and `/mcp`.
3
+ * One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by both
4
+ * routes. A read token gets 120 requests in any sliding minute, an act token 30, and either 10,000 in a rolling day;
5
+ * an instance operator may override any of these for the installation or for one token in the console (step-up).
6
+ *
7
+ * The token's saved access decides its class: nothing in a request body moves it. A request is counted once when it
8
+ * is admitted, however long it then holds the server (a task wait long-poll is one request, not one per second).
9
+ * Over budget answers 429 with Retry-After; the first refusal in each exhausted window is ledgered, never each one.
10
+ *
11
+ * Tracking is in memory and bounded: at most TRACKED_TOKENS tokens (least recently used goes first, saved before it
12
+ * goes), each with at most PER_MINUTE_MAX request times and one counter per minute of the day. It is saved every
13
+ * FLUSH_MS, on eviction, on server close and at once on a refusal, so a restart doesn't hand a client hammering the
14
+ * server a fresh budget. Anything unreadable (a policy or saved usage outside its bounds) refuses: fail closed.
15
+ */
16
+ import type { Database, Store } from "./store.js";
17
+ import type { TokenAccess } from "./api-tokens.js";
18
+ export declare const REQUEST_BUDGET_SCHEMA = "\nCREATE TABLE IF NOT EXISTS request_budget_limit (\n target TEXT PRIMARY KEY CHECK (target = '*' OR (length(target) = 12 AND target NOT GLOB '*[^0-9a-f]*')),\n read_per_minute INTEGER CHECK (read_per_minute IS NULL OR read_per_minute BETWEEN 1 AND 600),\n act_per_minute INTEGER CHECK (act_per_minute IS NULL OR act_per_minute BETWEEN 1 AND 600),\n per_day INTEGER CHECK (per_day IS NULL OR per_day BETWEEN 1 AND 100000),\n set_by TEXT NOT NULL,\n set_at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS request_budget_usage (\n token_id TEXT PRIMARY KEY,\n minute TEXT NOT NULL,\n day TEXT NOT NULL,\n noted TEXT NOT NULL,\n saved_at INTEGER NOT NULL\n);\n";
19
+ export declare const REQUEST_BUDGET_DEFAULTS: Readonly<{
20
+ read: 120;
21
+ act: 30;
22
+ day: 10000;
23
+ }>;
24
+ export declare const PER_MINUTE_MAX = 600;
25
+ export declare const PER_DAY_MAX = 100000;
26
+ export declare const TRACKED_TOKENS = 1024;
27
+ export declare const FLUSH_MS = 5000;
28
+ /** Which limit a refusal hit: the body of a 429 names only this and Retry-After. */
29
+ export type BudgetLimit = "read-per-minute" | "act-per-minute" | "per-day";
30
+ export type Admission = {
31
+ ok: true;
32
+ } | {
33
+ ok: false;
34
+ status: 429;
35
+ limit: BudgetLimit;
36
+ retryAfter: number;
37
+ } | {
38
+ ok: false;
39
+ status: 503;
40
+ };
41
+ export type BudgetRoute = "api" | "mcp";
42
+ /** An override: null leaves that limit to the installation (for a token) or the default. */
43
+ export type LimitOverride = {
44
+ readPerMinute: number | null;
45
+ actPerMinute: number | null;
46
+ perDay: number | null;
47
+ };
48
+ export type EffectiveLimits = {
49
+ perMinute: number;
50
+ perDay: number;
51
+ overridden: boolean;
52
+ };
53
+ /** The words for a 429, in one line. */
54
+ export declare const limitWords: (limit: BudgetLimit, retryAfter: number) => string;
55
+ /** A limit typed into a form: a whole number within bounds, "" for none, or a problem. */
56
+ export declare function parseLimit(raw: string, max: number): number | null | {
57
+ problem: string;
58
+ };
59
+ /** Every override: the installation's under "*", each token's under its id. */
60
+ export declare function limitOverrides(db: Database): Map<string, LimitOverride>;
61
+ /** Token override, then the installation's, then the default — per limit. */
62
+ export declare function effectiveLimits(db: Database, tokenId: string, access: TokenAccess): EffectiveLimits;
63
+ /** Set (or, all null, clear) an override, with the change in the policy ledger. The caller checks who may. */
64
+ export declare function setLimitOverride(store: Store, target: string, next: LimitOverride, by: string, now: Date): {
65
+ before: LimitOverride | null;
66
+ after: LimitOverride | null;
67
+ };
68
+ export type RequestBudgetOptions = {
69
+ store: Store;
70
+ /** Milliseconds since the epoch; tests inject their own. */
71
+ clock?: () => number;
72
+ tracked?: number;
73
+ flushMs?: number;
74
+ };
75
+ /** One per server: both routes share it, so a token's allowance is never doubled. */
76
+ export declare class RequestBudget {
77
+ private readonly store;
78
+ private readonly clock;
79
+ private readonly tracked;
80
+ private readonly flushMs;
81
+ private readonly usage;
82
+ private lastFlush;
83
+ constructor(options: RequestBudgetOptions);
84
+ /** How many tokens are tracked in memory now (never more than `tracked`). */
85
+ get size(): number;
86
+ /**
87
+ * Admit one request by an authenticated token, or say why not. Called once per HTTP request, before its body is
88
+ * read. Anything unreadable refuses with 503 and nothing runs.
89
+ */
90
+ admit(tokenId: string, route: BudgetRoute): Admission;
91
+ /** Save every changed entry. A server calls this on a timer and when it closes. */
92
+ flush(): void;
93
+ private save;
94
+ /** The tracked entry (now the most recently used), from memory or as last saved; the least recently used is saved and let go. */
95
+ private load;
96
+ }