toolroll 0.9.48 → 0.9.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/cli-http.js +3 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.d.ts +1 -0
- package/dist/mcp-http.js +12 -15
- package/dist/mcp-oauth.d.ts +3 -0
- package/dist/mcp-oauth.js +13 -21
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.js +4 -1
- package/dist/public-access.d.ts +3 -1
- package/dist/public-access.js +5 -1
- package/dist/request-budget.d.ts +30 -3
- package/dist/request-budget.js +44 -1
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +462 -23733
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +694 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +185 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.d.ts +5 -0
- package/dist/session-http.js +16 -0
- package/dist/session-server.d.ts +5 -0
- package/dist/session-server.js +2 -0
- package/dist/team-cli.js +28 -6
- package/dist/team-contract.d.ts +7 -0
- package/dist/team-contract.js +7 -0
- package/dist/team-http.d.ts +10 -1
- package/dist/team-http.js +23 -3
- package/dist/team-leads.d.ts +5 -1
- package/dist/team-leads.js +42 -4
- package/dist/team-runtime.d.ts +1 -1
- package/dist/team-runtime.js +3 -0
- package/dist/teams.d.ts +3 -0
- package/dist/teams.js +18 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.50 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **One route policy table.** Every route's access rules live in one table,
|
|
6
|
+
checked before any handler runs. Undeclared routes are refused, approvals
|
|
7
|
+
are browser-only, and read tokens can't change anything.
|
|
8
|
+
- **Server split by area.** The server code now lives in smaller modules.
|
|
9
|
+
No behaviour change for users.
|
|
10
|
+
|
|
11
|
+
## 0.9.49 — 2026-10-07
|
|
12
|
+
|
|
13
|
+
- **Tokens keep their limits.** A token's scope and project limits now hold
|
|
14
|
+
on every remote route.
|
|
15
|
+
- **Browser login for step-ups.** Approvals and other password step-ups need
|
|
16
|
+
a browser login. OAuth refuses plain HTTP off loopback or your tailnet.
|
|
17
|
+
- **Request budgets everywhere.** Budgets cover every remote route, and fake
|
|
18
|
+
account names can't lock anyone out.
|
|
19
|
+
|
|
3
20
|
## 0.9.48 — 2026-10-07
|
|
4
21
|
|
|
5
22
|
- **Instant live views.** Changes reach open pages in about 2 ms (was about
|
package/dist/cli-http.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
|
|
2
3
|
import { limitWords } from './request-budget.js';
|
|
3
4
|
/** Inlined file contents, summed as UTF-8 bytes. */
|
|
@@ -105,6 +106,8 @@ export async function handleCliHttp(request, response, options) {
|
|
|
105
106
|
request.resume();
|
|
106
107
|
return true;
|
|
107
108
|
}
|
|
109
|
+
if (!adapterPolicy({ caller: "bearer", capability: principal.scope, token: true }).ok)
|
|
110
|
+
return reject(403, "read-only", "Your token reads only. Use an act token for this.");
|
|
108
111
|
let value;
|
|
109
112
|
try {
|
|
110
113
|
value = await body(request);
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -94,20 +94,20 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
94
94
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
95
95
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
96
96
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
97
|
-
|
|
97
|
+
error?: never;
|
|
98
98
|
active: boolean;
|
|
99
99
|
phase: string;
|
|
100
100
|
detail: string;
|
|
101
101
|
running: boolean;
|
|
102
102
|
canResume: boolean;
|
|
103
103
|
canCancel: boolean;
|
|
104
|
-
error?: never;
|
|
105
104
|
automaticRecovery?: never;
|
|
106
105
|
recoveryAttempts?: never;
|
|
107
106
|
recoveryRepairs?: never;
|
|
108
107
|
wasRunning?: never;
|
|
109
108
|
backupPath?: never;
|
|
110
109
|
updateId?: never;
|
|
110
|
+
version?: never;
|
|
111
111
|
buildId?: never;
|
|
112
112
|
workDir?: never;
|
|
113
113
|
} | {
|
package/dist/flow-triggers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
/**
|
|
2
3
|
* Flow triggers (v82): what starts cards in a flow without someone adding
|
|
3
4
|
* them by hand.
|
|
@@ -944,6 +945,8 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
|
|
|
944
945
|
const config = trigger === null ? null : triggerConfigOf(trigger);
|
|
945
946
|
if (trigger === null || config === null || !takesDeliveries(config))
|
|
946
947
|
return { status: 404, said: "No such address." };
|
|
948
|
+
if (!adapterPolicy({ caller: "service", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
|
|
949
|
+
return { status: 403, said: "No access to this flow." };
|
|
947
950
|
if (delivery.body.length > HOOK_BYTES)
|
|
948
951
|
return { status: 413, said: "Too large." };
|
|
949
952
|
const note = (said) => { store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: said }, now); };
|
|
@@ -1069,6 +1072,8 @@ export function flowFormPage(store, token, now) {
|
|
|
1069
1072
|
const config = trigger === null ? null : triggerConfigOf(trigger);
|
|
1070
1073
|
if (trigger === null || config?.kind !== "button")
|
|
1071
1074
|
return { status: 404, html: formHtml("Not found", "<p>This form isn't available. Ask whoever shared it for a new link.</p>") };
|
|
1075
|
+
if (!adapterPolicy({ caller: "anonymous", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
|
|
1076
|
+
return { status: 403, html: "" };
|
|
1072
1077
|
const fields = config.questions.map((question, index) => `<label>${esc(question)}${index === 0 ? `<input name="a${index}" required maxlength="200" autocomplete="off">` : `<textarea name="a${index}" maxlength="3000"></textarea>`}</label>`).join("");
|
|
1073
1078
|
return { status: 200, html: formHtml(config.label, `<form method="post">${fields}<div class="trap" aria-hidden="true"><label>Leave this empty<input name="website" tabindex="-1" autocomplete="off"></label></div><input type="hidden" name="t" value="${now.getTime()}"><button>Send</button></form>`) };
|
|
1074
1079
|
}
|
|
@@ -1078,6 +1083,8 @@ export function receiveFlowForm(store, token, fields, now) {
|
|
|
1078
1083
|
const config = trigger === null ? null : triggerConfigOf(trigger);
|
|
1079
1084
|
if (trigger === null || config?.kind !== "button")
|
|
1080
1085
|
return flowFormPage(store, token, now);
|
|
1086
|
+
if (!adapterPolicy({ caller: "anonymous", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
|
|
1087
|
+
return { status: 403, html: "" };
|
|
1081
1088
|
const again = `<p><a href="${esc(FORM_PATH + token)}">Send another</a></p>`;
|
|
1082
1089
|
const thanks = formHtml(config.label, `<p>Thanks — it's been sent.</p>${again}`);
|
|
1083
1090
|
// A filled trap, or a page posted faster than a person could, gets thanks and makes nothing.
|
package/dist/mcp-http.d.ts
CHANGED
|
@@ -27,5 +27,6 @@ export type McpHttpOptions = {
|
|
|
27
27
|
runAs?: RunOperateAs;
|
|
28
28
|
/** A person token's request budget (request-budget.ts), charged once per request before its body is read. */
|
|
29
29
|
admit?: (person: Person) => Admission;
|
|
30
|
+
requestBudgetClock?: () => number;
|
|
30
31
|
};
|
|
31
32
|
export declare function createMcpHttp(options: McpHttpOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
package/dist/mcp-http.js
CHANGED
|
@@ -1,25 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
* The MCP gateway over streamable HTTP, served at /mcp by the console (serve.ts). Stateless: one JSON-RPC message per
|
|
3
|
-
* POST, answered with one JSON body (a notification gets 202 and no body); there is no session and no event stream.
|
|
4
|
-
* The modern revision carries its version in `_meta` as on stdio; a handshake-era client may initialize, and because
|
|
5
|
-
* nothing is kept between requests its tools work without the stdio lifecycle.
|
|
6
|
-
*
|
|
7
|
-
* Every POST signs in again. The caller is a coordinator credential (the coordinator registry, exactly as on stdio) or
|
|
8
|
-
* a person's own API token `so_…` (mcp-person.ts: their role, project grants and token scope). A password
|
|
9
|
-
* (`name:password`) and cookies sign in nothing here, a request carrying a browser Origin is refused, and the console's
|
|
10
|
-
* Host check runs before this route. A revoked or expired token answers 401 on its next request.
|
|
11
|
-
*/
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
12
2
|
import { PACKAGE_VERSION } from "./version.js";
|
|
13
3
|
import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "./api-tokens.js";
|
|
14
4
|
import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
|
|
15
5
|
import { authenticateCoordinator } from "./coordinator.js";
|
|
16
6
|
import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
|
|
17
7
|
import { callPersonTool, personTools } from "./mcp-person.js";
|
|
18
|
-
import { limitWords } from "./request-budget.js";
|
|
8
|
+
import { limitWords, SOURCE_BUDGET_DEFAULTS, SourceAdmission } from "./request-budget.js";
|
|
19
9
|
/** The most decisions a coordinator credential is remembered to have read (propose_answer needs one). */
|
|
20
10
|
const READ_DECISIONS_KEPT = 200;
|
|
21
11
|
export function createMcpHttp(options) {
|
|
22
12
|
const { store } = options;
|
|
13
|
+
const coordinatorBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.coordinator, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
|
|
23
14
|
/** Stdio remembers per connection which decisions a coordinator read; stateless HTTP remembers it per credential. */
|
|
24
15
|
const readDecisions = new Map();
|
|
25
16
|
const send = (response, status, body, headers = {}) => {
|
|
@@ -110,9 +101,10 @@ export function createMcpHttp(options) {
|
|
|
110
101
|
const metadata = options.resourceMetadata?.(request) ?? null;
|
|
111
102
|
return refuse(response, 401, "sign in with your API token (Authorization: Bearer so_…) or a coordinator credential — passwords and cookies are not accepted here", { "www-authenticate": `Bearer realm="toolroll-mcp"${metadata === null ? "" : `, resource_metadata="${metadata}"`}` });
|
|
112
103
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
104
|
+
// Count every authenticated request once, including notifications. Coordinator proposal limits still apply
|
|
105
|
+
// separately inside tool transactions; this admission also bounds their reads and malformed requests.
|
|
106
|
+
{
|
|
107
|
+
const admitted = caller.kind === "person" ? options.admit?.(caller.person) ?? { ok: true } : coordinatorBudget.admit(caller.cid);
|
|
116
108
|
if (!admitted.ok && admitted.status === 503)
|
|
117
109
|
return refuse(response, 503, "request limits could not be checked; nothing ran — try again shortly");
|
|
118
110
|
if (!admitted.ok) {
|
|
@@ -120,6 +112,11 @@ export function createMcpHttp(options) {
|
|
|
120
112
|
return send(response, 429, { jsonrpc: "2.0", id: null, error: { code: -32000, message: limitWords(admitted.limit, admitted.retryAfter), data: { limit: admitted.limit, retryAfter: admitted.retryAfter } } }, { "retry-after": String(admitted.retryAfter) });
|
|
121
113
|
}
|
|
122
114
|
}
|
|
115
|
+
const policy = caller.kind === "coordinator"
|
|
116
|
+
? adapterPolicy({ caller: "coordinator", capability: "act" })
|
|
117
|
+
: adapterPolicy({ caller: oauthProjects(store, caller.person.principal.tokenId) === null ? "bearer" : "oauth", capability: caller.person.principal.scope, token: true });
|
|
118
|
+
if (!policy.ok)
|
|
119
|
+
return refuse(response, 403, "this credential cannot call this route");
|
|
123
120
|
if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? ""))
|
|
124
121
|
return refuse(response, 415, "send the JSON-RPC message as application/json");
|
|
125
122
|
if (Number(request.headers["content-length"] ?? 0) > MAX_REQUEST)
|
package/dist/mcp-oauth.d.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
23
23
|
import type { Store } from "./store.js";
|
|
24
|
+
import { type Admission } from "./request-budget.js";
|
|
24
25
|
/** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
|
|
25
26
|
export declare const OAUTH_TIMES: {
|
|
26
27
|
readonly requestMs: number;
|
|
@@ -71,6 +72,8 @@ export type OAuthHttpOptions = {
|
|
|
71
72
|
confirm: (session: OAuthSession, typed: string) => boolean;
|
|
72
73
|
/** The projects this person may choose: each a project the server knows that they can use now. */
|
|
73
74
|
projectsFor: (account: string) => string[];
|
|
75
|
+
/** The token exchange's budget by source (request-budget.ts): charged once per request, before its body is read or any code or refresh secret is spent. */
|
|
76
|
+
admitToken?: (request: IncomingMessage) => Admission;
|
|
74
77
|
};
|
|
75
78
|
/**
|
|
76
79
|
* A redirect a client may register: https anywhere, or http on 127.0.0.1 / [::1] (RFC 8252 §7.3), with no
|
package/dist/mcp-oauth.js
CHANGED
|
@@ -1,26 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
* MCP sign-in: OAuth 2.1 for /mcp, as the MCP authorization spec describes, so an engineer's MCP client connects
|
|
3
|
-
* without a pasted token. This console is both the authorization server and the protected resource.
|
|
4
|
-
*
|
|
5
|
-
* /mcp's 401 names /.well-known/oauth-protected-resource/mcp (RFC 9728), which names this console's
|
|
6
|
-
* /.well-known/oauth-authorization-server (RFC 8414). A client registers itself (RFC 7591, public clients only),
|
|
7
|
-
* sends the person to /oauth/authorize with a S256 PKCE challenge and resource=<origin>/mcp, and trades the code
|
|
8
|
-
* at /oauth/token for a short-lived access token and a refresh secret that rotates on every use.
|
|
9
|
-
*
|
|
10
|
-
* The person signs in with the console's own sign-in (password or identity provider, unchanged), then consents on
|
|
11
|
-
* one page: read or act, and which of their projects, confirmed with their password or a fresh provider check, the
|
|
12
|
-
* same step-up as making an API token. Nothing is granted without that form, its session CSRF value and a same-site
|
|
13
|
-
* post.
|
|
14
|
-
*
|
|
15
|
-
* A grant is an ordinary API token (api-tokens.ts): its access token is `so_<id>_<secret>`, it is listed and revoked
|
|
16
|
-
* with every other token (console Sessions & tokens, `toolroll tokens`), and /mcp runs it through the same person
|
|
17
|
-
* path. Its v113 binding (store.oauthGrant) adds what an ordinary token doesn't have: it works only at /mcp, its
|
|
18
|
-
* access secret lasts an hour, it is tied to the account generation that consented, and it reaches only the projects
|
|
19
|
-
* chosen (still intersected with the person's live access on every call). Only hashes are kept: of codes, access
|
|
20
|
-
* secrets and refresh secrets. Approvals, people and policy are never reachable: those have no tool and no scope.
|
|
21
|
-
*/
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
22
2
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
3
|
import { hashSecret, mintApiToken } from "./api-tokens.js";
|
|
4
|
+
import { limitWords } from "./request-budget.js";
|
|
24
5
|
import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
|
|
25
6
|
/** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
|
|
26
7
|
export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
|
|
@@ -338,6 +319,8 @@ export function createOAuthHttp(options) {
|
|
|
338
319
|
// Never named back: a project outside the person's access is refused without saying whether it exists.
|
|
339
320
|
if (!chosen.every(path => mine.includes(path)))
|
|
340
321
|
return again(403, "Choose from your own projects.");
|
|
322
|
+
if (!adapterPolicy({ caller: "cookie", capability: account.role === "approver" ? "act" : "read", viewer: account.role === "viewer" }, "step-up", source => source === "none" && chosen.every(path => mine.includes(path))).ok)
|
|
323
|
+
return again(403, "Choose from your own projects.");
|
|
341
324
|
if (!options.confirm(session, form.get("password") ?? ""))
|
|
342
325
|
return again(403, "Enter your Toolroll password to allow access.");
|
|
343
326
|
waiting.delete(id);
|
|
@@ -350,6 +333,13 @@ export function createOAuthHttp(options) {
|
|
|
350
333
|
async function token(request, response, origin) {
|
|
351
334
|
if (request.method !== "POST")
|
|
352
335
|
return oauthError(response, 405, "invalid_request", "Use POST.");
|
|
336
|
+
const admitted = options.admitToken === undefined ? { ok: true } : options.admitToken(request);
|
|
337
|
+
if (!admitted.ok) {
|
|
338
|
+
request.resume();
|
|
339
|
+
if (admitted.status === 503)
|
|
340
|
+
return oauthError(response, 503, "temporarily_unavailable", "Request limits could not be checked; nothing was issued. Try again shortly.");
|
|
341
|
+
return json(response, 429, { error: "temporarily_unavailable", error_description: limitWords(admitted.limit, admitted.retryAfter) }, { "retry-after": String(admitted.retryAfter) });
|
|
342
|
+
}
|
|
353
343
|
if (media(request) !== "application/x-www-form-urlencoded")
|
|
354
344
|
return oauthError(response, 400, "invalid_request", "Send application/x-www-form-urlencoded.");
|
|
355
345
|
const text = await readBody(request);
|
|
@@ -360,6 +350,8 @@ export function createOAuthHttp(options) {
|
|
|
360
350
|
const unsupported = !("repeated" in read) && read.issues.some(one => one.path === "grant_type");
|
|
361
351
|
return unsupported ? oauthError(response, 400, "unsupported_grant_type", "Use authorization_code or refresh_token.") : oauthError(response, 400, "invalid_request", "The token request isn't complete.");
|
|
362
352
|
}
|
|
353
|
+
if (!adapterPolicy({ caller: "oauth", capability: "none" }).ok)
|
|
354
|
+
return oauthError(response, 403, "access_denied", "This credential cannot call this route.");
|
|
363
355
|
const ask = read.value;
|
|
364
356
|
const now = options.clock();
|
|
365
357
|
const resource = `${origin}${RESOURCE_PATH}`;
|
package/dist/mcp-person.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A person's tools on the HTTP gateway. A person signs in with their own API token (api-tokens.ts) and every tool call
|
|
3
|
-
* is the exact `toolroll` command line they could type, run on the server under their principal through
|
|
4
|
-
* `runOperateAs` with source "mcp": the command's own authorization, project grants and attribution apply, and its
|
|
5
|
-
* `--json` envelope is the answer. Nothing here reads data directly — a coordinator handler would skip the person's
|
|
6
|
-
* project grants — and nothing here approves, answers or changes people or policy: those verbs have no tool.
|
|
7
|
-
*
|
|
8
|
-
* tools/list follows the account's role and the token's scope; dispatch checks again, independent of the list.
|
|
9
|
-
*/
|
|
10
1
|
import { type PersonToolName } from "./contracts/gateway-tools.js";
|
|
11
2
|
import { type CallOutcome, type Json } from "./mcp-core.js";
|
|
12
3
|
import type { Store } from "./store.js";
|
package/dist/mcp-person.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
/**
|
|
2
3
|
* A person's tools on the HTTP gateway. A person signs in with their own API token (api-tokens.ts) and every tool call
|
|
3
4
|
* is the exact `toolroll` command line they could type, run on the server under their principal through
|
|
@@ -67,7 +68,7 @@ export async function callPersonTool(person, store, runAs, params) {
|
|
|
67
68
|
const read = parseContract(PERSON_TOOL_INPUTS[tool], rawArgs);
|
|
68
69
|
if (!read.ok)
|
|
69
70
|
return { kind: "error", code: -32602, message: read.issues.map(one => one.line).join("; "), fatal: false };
|
|
70
|
-
if (!mayCall(person, tool)) {
|
|
71
|
+
if (!adapterPolicy({ caller: "bearer", capability: person.principal.scope, token: true }, TOOLS[tool].acts ? "act" : "read").ok || !mayCall(person, tool)) {
|
|
71
72
|
const why = person.principal.scope === "read" ? "this token reads only — make an act token in the console to file work" : "your account can watch, not act";
|
|
72
73
|
return refused(`${tool}: ${why}`);
|
|
73
74
|
}
|
package/dist/operate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
import { UNSENT_REPLY_MS } from "./telegram-settings.js";
|
|
2
3
|
import { checkPublicCommand } from "./public-check.js";
|
|
3
4
|
import { leadNameOf } from "./lead-identity.js";
|
|
@@ -977,7 +978,7 @@ export async function runOperateAs(argv, opts) {
|
|
|
977
978
|
// The parser's positionals must spell the same row (a value flag can't hide a subcommand).
|
|
978
979
|
if (remoteRowOf(root, parsed.positional)?.invocation !== row.invocation)
|
|
979
980
|
return refuse("not-remote", REMOTE_MESSAGES.unknown);
|
|
980
|
-
if (proved.scope === "read" && row.mutation !== "none")
|
|
981
|
+
if (!adapterPolicy({ caller: "bearer", capability: proved.scope, token: true }, row.mutation !== "none" ? "act" : "read").ok || proved.scope === "read" && row.mutation !== "none")
|
|
981
982
|
return refuse("read-only", REMOTE_MESSAGES.read);
|
|
982
983
|
if ([...parsed.flags.keys()].some(name => REMOTE_REFUSED_FLAGS.has(name)))
|
|
983
984
|
return refuse("usage", REMOTE_MESSAGES.credential);
|
|
@@ -985,6 +986,8 @@ export async function runOperateAs(argv, opts) {
|
|
|
985
986
|
const project = remoteProjectOf(store, { allows }, scope, parsed.positional, parsed.repoList, row.invocation, parsed.flags, opts.files ?? {});
|
|
986
987
|
if (!project.ok)
|
|
987
988
|
return refuse(project.reason, project.message);
|
|
989
|
+
if (!adapterPolicy({ caller: "bearer", capability: proved.scope, token: true }, undefined, source => source === "adapter" && (project.repo === null || allows(project.repo))).ok)
|
|
990
|
+
return refuse("forbidden", REMOTE_MESSAGES.no);
|
|
988
991
|
// The local mutation cache has a global key namespace. A token must not use a chosen key to read
|
|
989
992
|
// another person's/project's cached result. Revision keys already bind the actor and result in
|
|
990
993
|
// task-outcome-cli.ts, which also validates their public 32-hex format; leave those intact.
|
package/dist/public-access.d.ts
CHANGED
|
@@ -42,7 +42,9 @@ export declare function transportOf(request: {
|
|
|
42
42
|
forwardedProto: string | string[] | undefined;
|
|
43
43
|
forwarded: string | string[] | undefined;
|
|
44
44
|
}): Transport;
|
|
45
|
-
/**
|
|
45
|
+
/** The OAuth endpoints that hand out, consent to or take back a token (mcp-oauth.ts; revoke is reserved). Discovery stays public. */
|
|
46
|
+
export declare const OAUTH_TOKEN_PATHS: ReadonlySet<string>;
|
|
47
|
+
/** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, MCP sign-in, and any bearer credential. */
|
|
46
48
|
export declare function carriesToken(pathname: string, authorization: string | undefined): boolean;
|
|
47
49
|
/** The refusal for a token sent over plain HTTP from outside this computer and the tailnet, else null. */
|
|
48
50
|
export declare function plainHttpRefusal(transport: Transport, pathname: string, authorization: string | undefined): string | null;
|
package/dist/public-access.js
CHANGED
|
@@ -69,10 +69,14 @@ export function transportOf(request) {
|
|
|
69
69
|
return { source: "unknown", https, privatePath: false };
|
|
70
70
|
return { source, https, privatePath: isLoopbackPeer(source) || isTailnetAddress(source) };
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
72
|
+
/** The OAuth endpoints that hand out, consent to or take back a token (mcp-oauth.ts; revoke is reserved). Discovery stays public. */
|
|
73
|
+
export const OAUTH_TOKEN_PATHS = new Set(["/oauth/authorize", "/oauth/token", "/oauth/register", "/oauth/revoke"]);
|
|
74
|
+
/** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, MCP sign-in, and any bearer credential. */
|
|
73
75
|
export function carriesToken(pathname, authorization) {
|
|
74
76
|
if (pathname === "/api/cli" || pathname.startsWith("/api/cli/") || pathname === "/mcp" || pathname.startsWith("/mcp/"))
|
|
75
77
|
return true;
|
|
78
|
+
if (OAUTH_TOKEN_PATHS.has(pathname.replace(/\/+$/, "") || "/"))
|
|
79
|
+
return true;
|
|
76
80
|
return authorization !== undefined && /^bearer\s/i.test(authorization);
|
|
77
81
|
}
|
|
78
82
|
/** The refusal for a token sent over plain HTTP from outside this computer and the tailnet, else null. */
|
package/dist/request-budget.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Request budgets for person API tokens (v112): how many requests one `so_` token may make
|
|
3
|
-
* One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by
|
|
2
|
+
* Request budgets for person API tokens (v112): how many requests one `so_` token may make across all accepting routes.
|
|
3
|
+
* One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by those
|
|
4
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
5
|
* an instance operator may override any of these for the installation or for one token in the console (step-up).
|
|
6
6
|
*
|
|
@@ -26,7 +26,7 @@ export declare const PER_DAY_MAX = 100000;
|
|
|
26
26
|
export declare const TRACKED_TOKENS = 1024;
|
|
27
27
|
export declare const FLUSH_MS = 5000;
|
|
28
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";
|
|
29
|
+
export type BudgetLimit = "read-per-minute" | "act-per-minute" | "per-day" | "per-minute";
|
|
30
30
|
export type Admission = {
|
|
31
31
|
ok: true;
|
|
32
32
|
} | {
|
|
@@ -95,3 +95,30 @@ export declare class RequestBudget {
|
|
|
95
95
|
/** The tracked entry (now the most recently used), from memory or as last saved; the least recently used is saved and let go. */
|
|
96
96
|
private load;
|
|
97
97
|
}
|
|
98
|
+
/** Per-minute allowances for non-person-token identities and unauthenticated ingress. Password accounts get room
|
|
99
|
+
* for two-second follower polling plus ordinary commands; OAuth exchanges stay keyed by source address. */
|
|
100
|
+
export declare const SOURCE_BUDGET_DEFAULTS: Readonly<{
|
|
101
|
+
password: 120;
|
|
102
|
+
oauthToken: 30;
|
|
103
|
+
coordinator: 120;
|
|
104
|
+
teamsSource: 120;
|
|
105
|
+
teamsTenant: 600;
|
|
106
|
+
}>;
|
|
107
|
+
/**
|
|
108
|
+
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
109
|
+
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
110
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
111
|
+
*/
|
|
112
|
+
export declare class SourceAdmission {
|
|
113
|
+
private readonly perMinute;
|
|
114
|
+
private readonly clock;
|
|
115
|
+
private readonly tracked;
|
|
116
|
+
private readonly usage;
|
|
117
|
+
constructor(options: {
|
|
118
|
+
perMinute: number;
|
|
119
|
+
clock?: () => number;
|
|
120
|
+
tracked?: number;
|
|
121
|
+
});
|
|
122
|
+
get size(): number;
|
|
123
|
+
admit(source: string): Admission;
|
|
124
|
+
}
|
package/dist/request-budget.js
CHANGED
|
@@ -22,7 +22,7 @@ export const TRACKED_TOKENS = 1024;
|
|
|
22
22
|
export const FLUSH_MS = 5_000;
|
|
23
23
|
const MINUTE = 60_000, DAY = 86_400_000;
|
|
24
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"}.`;
|
|
25
|
+
export const limitWords = (limit, retryAfter) => `Request limit reached (${limit === "per-day" ? "requests per day" : limit === "read-per-minute" ? "read requests per minute" : limit === "act-per-minute" ? "act requests per minute" : "requests per minute"}). Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.`;
|
|
26
26
|
/** A limit typed into a form: a whole number within bounds, "" for none, or a problem. */
|
|
27
27
|
export function parseLimit(raw, max) {
|
|
28
28
|
const text = raw.trim();
|
|
@@ -231,3 +231,46 @@ export class RequestBudget {
|
|
|
231
231
|
return usage;
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
|
+
/** Per-minute allowances for non-person-token identities and unauthenticated ingress. Password accounts get room
|
|
235
|
+
* for two-second follower polling plus ordinary commands; OAuth exchanges stay keyed by source address. */
|
|
236
|
+
export const SOURCE_BUDGET_DEFAULTS = Object.freeze({ password: 120, oauthToken: 30, coordinator: 120, teamsSource: 120, teamsTenant: 600 });
|
|
237
|
+
/**
|
|
238
|
+
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
239
|
+
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
240
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
241
|
+
*/
|
|
242
|
+
export class SourceAdmission {
|
|
243
|
+
perMinute;
|
|
244
|
+
clock;
|
|
245
|
+
tracked;
|
|
246
|
+
usage = new Map();
|
|
247
|
+
constructor(options) {
|
|
248
|
+
this.perMinute = Math.max(1, Math.min(PER_MINUTE_MAX, options.perMinute));
|
|
249
|
+
this.clock = options.clock ?? Date.now;
|
|
250
|
+
this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
|
|
251
|
+
}
|
|
252
|
+
get size() { return this.usage.size; }
|
|
253
|
+
admit(source) {
|
|
254
|
+
const now = this.clock();
|
|
255
|
+
let times = this.usage.get(source);
|
|
256
|
+
if (times === undefined) {
|
|
257
|
+
if (this.usage.size >= this.tracked) {
|
|
258
|
+
for (const [key, kept] of this.usage)
|
|
259
|
+
if (kept.every(at => at <= now - MINUTE))
|
|
260
|
+
this.usage.delete(key);
|
|
261
|
+
if (this.usage.size >= this.tracked)
|
|
262
|
+
this.usage.delete(this.usage.keys().next().value);
|
|
263
|
+
}
|
|
264
|
+
times = [];
|
|
265
|
+
}
|
|
266
|
+
this.usage.delete(source);
|
|
267
|
+
this.usage.set(source, times);
|
|
268
|
+
while (times.length > 0 && times[0] <= now - MINUTE)
|
|
269
|
+
times.shift();
|
|
270
|
+
if (times.length >= this.perMinute) {
|
|
271
|
+
return { ok: false, status: 429, limit: "per-minute", retryAfter: Math.max(1, Math.ceil((times[times.length - this.perMinute] + MINUTE - now) / 1000)) };
|
|
272
|
+
}
|
|
273
|
+
times.push(now);
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
}
|