toolroll 0.9.45 → 0.9.47

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 (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/activity-line.d.ts +26 -0
  3. package/dist/activity-line.js +47 -0
  4. package/dist/api-tokens.d.ts +33 -0
  5. package/dist/api-tokens.js +39 -0
  6. package/dist/audit-cli.d.ts +17 -0
  7. package/dist/audit-cli.js +86 -0
  8. package/dist/browser/workspace.js +25 -25
  9. package/dist/browser-workspace.d.ts +18 -1
  10. package/dist/browser-workspace.js +9 -3
  11. package/dist/builder.js +4 -2
  12. package/dist/cli-http.d.ts +5 -0
  13. package/dist/cli-http.js +12 -2
  14. package/dist/cli.js +7 -1
  15. package/dist/contracts/cli.d.ts +47 -0
  16. package/dist/contracts/cli.js +11 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/mcp-oauth.d.ts +53 -0
  20. package/dist/contracts/mcp-oauth.js +40 -0
  21. package/dist/credentials-ui.d.ts +5 -0
  22. package/dist/credentials-ui.js +16 -4
  23. package/dist/export.js +4 -3
  24. package/dist/flow-card-task.d.ts +2 -0
  25. package/dist/flow-card-task.js +57 -0
  26. package/dist/flow-live.d.ts +2 -2
  27. package/dist/flow-live.js +69 -8
  28. package/dist/flows-ui.js +15 -1
  29. package/dist/live.d.ts +14 -0
  30. package/dist/live.js +71 -22
  31. package/dist/mcp-http.d.ts +6 -1
  32. package/dist/mcp-http.js +22 -4
  33. package/dist/mcp-oauth.d.ts +82 -0
  34. package/dist/mcp-oauth.js +479 -0
  35. package/dist/mcp-person.js +1 -1
  36. package/dist/operate-remote-arguments.js +2 -0
  37. package/dist/operate-remote.d.ts +1 -1
  38. package/dist/operate-remote.js +7 -1
  39. package/dist/operate.d.ts +3 -0
  40. package/dist/operate.js +58 -2
  41. package/dist/people-audit-ui.d.ts +14 -0
  42. package/dist/people-audit-ui.js +38 -0
  43. package/dist/planner.js +4 -3
  44. package/dist/public-access.d.ts +50 -0
  45. package/dist/public-access.js +87 -0
  46. package/dist/public-check.d.ts +37 -0
  47. package/dist/public-check.js +170 -0
  48. package/dist/remote-audit.d.ts +79 -0
  49. package/dist/remote-audit.js +161 -0
  50. package/dist/request-budget-ui.d.ts +11 -0
  51. package/dist/request-budget-ui.js +31 -0
  52. package/dist/request-budget.d.ts +96 -0
  53. package/dist/request-budget.js +227 -0
  54. package/dist/serve.d.ts +9 -1
  55. package/dist/serve.js +211 -52
  56. package/dist/setup-guide.d.ts +2 -2
  57. package/dist/store.d.ts +134 -2
  58. package/dist/store.js +270 -7
  59. package/dist/surface.js +19 -2
  60. package/dist/task-activity.d.ts +27 -0
  61. package/dist/task-activity.js +53 -0
  62. package/dist/task-live.d.ts +17 -0
  63. package/dist/task-live.js +144 -0
  64. package/dist/token-notices.d.ts +22 -0
  65. package/dist/token-notices.js +52 -0
  66. package/dist/tokens-cli.d.ts +21 -0
  67. package/dist/tokens-cli.js +184 -0
  68. package/package.json +1 -1
@@ -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
+ }
@@ -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
+ }
@@ -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. */
@@ -271,6 +274,11 @@ type Chrome = {
271
274
  scope?: "all" | "project" | "board-all";
272
275
  /** The saturated inbox count — never a sum of unbounded list reads. */
273
276
  inboxCount: number;
277
+ /** The project the Tasks count covers: the request's own project view
278
+ * (the open project, or /work's ?project=), null for every admitted project. */
279
+ inboxProject?: string | null;
280
+ /** The Tasks badge's words, naming what it covers: "3 need you in shop". */
281
+ inboxLabel?: string;
274
282
  inboxSaturated: boolean;
275
283
  settings: boolean;
276
284
  /** This database is a demo sandbox: banner every page, spend fenced. */