toolroll 0.9.49 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cli-http.js +3 -0
  3. package/dist/flow-triggers.js +7 -0
  4. package/dist/mcp-http.js +6 -11
  5. package/dist/mcp-oauth.js +5 -21
  6. package/dist/mcp-person.d.ts +0 -9
  7. package/dist/mcp-person.js +2 -1
  8. package/dist/operate.js +4 -1
  9. package/dist/serve.d.ts +2 -605
  10. package/dist/serve.js +423 -23783
  11. package/dist/server/chat.d.ts +41 -0
  12. package/dist/server/chat.js +1458 -0
  13. package/dist/server/edge-refusal.d.ts +5 -0
  14. package/dist/server/edge-refusal.js +56 -0
  15. package/dist/server/flows.d.ts +12 -0
  16. package/dist/server/flows.js +832 -0
  17. package/dist/server/guards.d.ts +72 -0
  18. package/dist/server/guards.js +357 -0
  19. package/dist/server/handler-context.d.ts +22 -0
  20. package/dist/server/handler-context.js +1 -0
  21. package/dist/server/handler-registry.d.ts +21 -0
  22. package/dist/server/handler-registry.js +34 -0
  23. package/dist/server/pages-assets.d.ts +10 -0
  24. package/dist/server/pages-assets.js +103 -0
  25. package/dist/server/pages.d.ts +11 -0
  26. package/dist/server/pages.js +910 -0
  27. package/dist/server/people-tokens.d.ts +20 -0
  28. package/dist/server/people-tokens.js +694 -0
  29. package/dist/server/remote-hooks.d.ts +29 -0
  30. package/dist/server/remote-hooks.js +130 -0
  31. package/dist/server/remote.d.ts +59 -0
  32. package/dist/server/remote.js +185 -0
  33. package/dist/server/route-policy.d.ts +18 -0
  34. package/dist/server/route-policy.js +30 -0
  35. package/dist/server/route-table.d.ts +74 -0
  36. package/dist/server/route-table.js +350 -0
  37. package/dist/server/runtime.d.ts +280 -0
  38. package/dist/server/runtime.js +25 -0
  39. package/dist/server/settings.d.ts +22 -0
  40. package/dist/server/settings.js +2388 -0
  41. package/dist/server/shared.d.ts +3334 -0
  42. package/dist/server/shared.js +12708 -0
  43. package/dist/server/task-views.d.ts +82 -0
  44. package/dist/server/task-views.js +1099 -0
  45. package/dist/server/tasks.d.ts +87 -0
  46. package/dist/server/tasks.js +3579 -0
  47. package/dist/session-http.js +3 -0
  48. package/dist/team-http.d.ts +5 -1
  49. package/dist/team-http.js +6 -2
  50. package/dist/teams.js +3 -0
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 0.9.49 — 2026-10-07
4
12
 
5
13
  - **Tokens keep their limits.** A token's scope and project limits now hold
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);
@@ -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.js CHANGED
@@ -1,14 +1,4 @@
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";
@@ -122,6 +112,11 @@ export function createMcpHttp(options) {
122
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) });
123
113
  }
124
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");
125
120
  if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? ""))
126
121
  return refuse(response, 415, "send the JSON-RPC message as application/json");
127
122
  if (Number(request.headers["content-length"] ?? 0) > MAX_REQUEST)
package/dist/mcp-oauth.js CHANGED
@@ -1,24 +1,4 @@
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";
24
4
  import { limitWords } from "./request-budget.js";
@@ -339,6 +319,8 @@ export function createOAuthHttp(options) {
339
319
  // Never named back: a project outside the person's access is refused without saying whether it exists.
340
320
  if (!chosen.every(path => mine.includes(path)))
341
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.");
342
324
  if (!options.confirm(session, form.get("password") ?? ""))
343
325
  return again(403, "Enter your Toolroll password to allow access.");
344
326
  waiting.delete(id);
@@ -368,6 +350,8 @@ export function createOAuthHttp(options) {
368
350
  const unsupported = !("repeated" in read) && read.issues.some(one => one.path === "grant_type");
369
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.");
370
352
  }
353
+ if (!adapterPolicy({ caller: "oauth", capability: "none" }).ok)
354
+ return oauthError(response, 403, "access_denied", "This credential cannot call this route.");
371
355
  const ask = read.value;
372
356
  const now = options.clock();
373
357
  const resource = `${origin}${RESOURCE_PATH}`;
@@ -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";
@@ -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.