toolroll 0.9.45 → 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 (50) hide show
  1. package/CHANGELOG.md +18 -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 +5 -0
  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 +1 -1
  18. package/dist/export.js +4 -3
  19. package/dist/mcp-http.d.ts +6 -1
  20. package/dist/mcp-http.js +22 -4
  21. package/dist/mcp-oauth.d.ts +82 -0
  22. package/dist/mcp-oauth.js +479 -0
  23. package/dist/mcp-person.js +1 -1
  24. package/dist/operate-remote-arguments.js +2 -0
  25. package/dist/operate-remote.d.ts +1 -1
  26. package/dist/operate-remote.js +7 -1
  27. package/dist/operate.d.ts +3 -0
  28. package/dist/operate.js +52 -1
  29. package/dist/people-audit-ui.d.ts +14 -0
  30. package/dist/people-audit-ui.js +38 -0
  31. package/dist/public-access.d.ts +50 -0
  32. package/dist/public-access.js +87 -0
  33. package/dist/public-check.d.ts +37 -0
  34. package/dist/public-check.js +170 -0
  35. package/dist/remote-audit.d.ts +79 -0
  36. package/dist/remote-audit.js +161 -0
  37. package/dist/request-budget-ui.d.ts +11 -0
  38. package/dist/request-budget-ui.js +31 -0
  39. package/dist/request-budget.d.ts +96 -0
  40. package/dist/request-budget.js +227 -0
  41. package/dist/serve.d.ts +4 -1
  42. package/dist/serve.js +120 -13
  43. package/dist/store.d.ts +118 -2
  44. package/dist/store.js +236 -7
  45. package/dist/surface.js +19 -2
  46. package/dist/token-notices.d.ts +22 -0
  47. package/dist/token-notices.js +52 -0
  48. package/dist/tokens-cli.d.ts +21 -0
  49. package/dist/tokens-cli.js +184 -0
  50. package/package.json +1 -1
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import type { IncomingMessage, ServerResponse } from "node:http";
13
13
  import type { Store } from "./store.js";
14
- import { type RunOperateAs } from "./mcp-person.js";
14
+ import { type Person, type RunOperateAs } from "./mcp-person.js";
15
+ import { type Admission } from "./request-budget.js";
15
16
  export type McpHttpOptions = {
16
17
  store: Store;
17
18
  clock: () => Date;
@@ -20,7 +21,11 @@ export type McpHttpOptions = {
20
21
  evidenceRoot?: string;
21
22
  /** The console's own sign-in for an `so_` bearer: the address's tries, expiry, revocation, a removed account. */
22
23
  signedIn: (request: IncomingMessage) => boolean;
24
+ /** Where an MCP client finds how to sign in (mcp-oauth.ts), named on a 401; null when this address offers none. */
25
+ resourceMetadata?: (request: IncomingMessage) => string | null;
23
26
  /** Runs a person's command on the server (operate.ts); resolved from operate.ts when not injected. */
24
27
  runAs?: RunOperateAs;
28
+ /** A person token's request budget (request-budget.ts), charged once per request before its body is read. */
29
+ admit?: (person: Person) => Admission;
25
30
  };
26
31
  export declare function createMcpHttp(options: McpHttpOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
package/dist/mcp-http.js CHANGED
@@ -10,10 +10,12 @@
10
10
  * Host check runs before this route. A revoked or expired token answers 401 on its next request.
11
11
  */
12
12
  import { PACKAGE_VERSION } from "./version.js";
13
- import { parseApiToken, secretMatches } from "./api-tokens.js";
13
+ import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "./api-tokens.js";
14
+ import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
14
15
  import { authenticateCoordinator } from "./coordinator.js";
15
16
  import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
16
17
  import { callPersonTool, personTools } from "./mcp-person.js";
18
+ import { limitWords } from "./request-budget.js";
17
19
  /** The most decisions a coordinator credential is remembered to have read (propose_answer needs one). */
18
20
  const READ_DECISIONS_KEPT = 200;
19
21
  export function createMcpHttp(options) {
@@ -38,15 +40,20 @@ export function createMcpHttp(options) {
38
40
  // The secret is checked here too, so this door never rests on how signedIn reads the request.
39
41
  if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash))
40
42
  return null;
41
- if (kept.row.revokedAt !== null || Date.parse(kept.row.expiresAt) <= options.clock().getTime())
43
+ if (!tokenLive(kept.row, options.clock().getTime()))
42
44
  return null;
43
45
  const account = store.accountOf(kept.row.account);
44
46
  if (account === null || account.revokedAt !== null)
45
47
  return null;
48
+ // An MCP sign-in's token: only while its access is fresh, and only in the projects the person chose (mcp-oauth.ts).
49
+ if (!oauthTokenAllowed(store, kept.row.id, RESOURCE_PATH, options.clock()))
50
+ return null;
51
+ const granted = oauthProjects(store, kept.row.id);
52
+ const projects = tokenProjects(tokenProjects(account.projects, kept.row.projects), granted);
46
53
  return {
47
54
  kind: "person",
48
55
  person: {
49
- principal: { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id, projects: account.projects === null ? null : [...account.projects] },
56
+ principal: { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id, projects },
50
57
  role: account.role,
51
58
  tokenName: kept.row.name,
52
59
  },
@@ -100,7 +107,18 @@ export function createMcpHttp(options) {
100
107
  return refuse(response, 403, "browser requests are refused — connect an MCP client with your API token");
101
108
  const caller = callerOf(request);
102
109
  if (caller === null) {
103
- 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"' });
110
+ const metadata = options.resourceMetadata?.(request) ?? null;
111
+ 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
+ }
113
+ // A person's token is counted once per request, notifications included; a coordinator keeps its own proposal limit.
114
+ if (caller.kind === "person" && options.admit !== undefined) {
115
+ const admitted = options.admit(caller.person);
116
+ if (!admitted.ok && admitted.status === 503)
117
+ return refuse(response, 503, "request limits could not be checked; nothing ran — try again shortly");
118
+ if (!admitted.ok) {
119
+ request.resume();
120
+ 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
+ }
104
122
  }
105
123
  if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? ""))
106
124
  return refuse(response, 415, "send the JSON-RPC message as application/json");
@@ -0,0 +1,82 @@
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
+ */
22
+ import type { IncomingMessage, ServerResponse } from "node:http";
23
+ import type { Store } from "./store.js";
24
+ /** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
25
+ export declare const OAUTH_TIMES: {
26
+ readonly requestMs: number;
27
+ readonly codeMs: 60000;
28
+ readonly accessSeconds: 3600;
29
+ readonly grantDays: 30;
30
+ };
31
+ /** The most sign-ins waiting at once, and registered clients kept. */
32
+ export declare const OAUTH_LIMITS: {
33
+ readonly requests: 200;
34
+ readonly requestsPerSource: 20;
35
+ readonly clients: 500;
36
+ readonly clientsPerSource: 20;
37
+ };
38
+ export declare const OAUTH_SCOPES: readonly ["read", "act"];
39
+ export declare const RESOURCE_PATH = "/mcp";
40
+ export declare const resourceMetadataUrl: (origin: string) => string;
41
+ /**
42
+ * Whether an API token may sign in to this request's path now. An ordinary token: yes (its own checks are elsewhere).
43
+ * An MCP sign-in's token: only at /mcp, only while its access secret is fresh and the account is at the generation
44
+ * that consented.
45
+ */
46
+ export declare function oauthTokenAllowed(store: Store, tokenId: string, path: string, now: Date): boolean;
47
+ /** The projects an MCP sign-in's token was granted (the person's live access still applies), or null for an ordinary token. */
48
+ export declare const oauthProjects: (store: Store, tokenId: string) => string[] | null;
49
+ export type OAuthSession = {
50
+ name: string;
51
+ role: "approver" | "viewer";
52
+ csrf: string;
53
+ /** Signed in with an identity provider: its name, and whether it checked the person in the last ten minutes. */
54
+ sso: {
55
+ label: string;
56
+ fresh: boolean;
57
+ } | null;
58
+ };
59
+ export type OAuthHttpOptions = {
60
+ store: Store;
61
+ clock: () => Date;
62
+ /** This console's canonical origin for the request (the https public address, or this computer's loopback one); null: no sign-in here. */
63
+ originOf: (request: IncomingMessage) => string | null;
64
+ /** Trusted direct peer, or the last hop appended by the console's loopback proxy. */
65
+ requesterKey: (request: IncomingMessage) => string;
66
+ /** The browser session signed in by cookie, or null. A bearer never consents. */
67
+ session: (request: IncomingMessage) => OAuthSession | null;
68
+ /** A posted form came from a page of this console (its Origin, or Referer, names an allowed host). */
69
+ sameSite: (request: IncomingMessage) => boolean;
70
+ /** The step-up: the person's password, or (empty) a fresh identity-provider check. */
71
+ confirm: (session: OAuthSession, typed: string) => boolean;
72
+ /** The projects this person may choose: each a project the server knows that they can use now. */
73
+ projectsFor: (account: string) => string[];
74
+ };
75
+ /**
76
+ * A redirect a client may register: https anywhere, or http on 127.0.0.1 / [::1] (RFC 8252 §7.3), with no
77
+ * credentials or fragment.
78
+ */
79
+ export declare function redirectAllowed(value: string): boolean;
80
+ /** A redirect matches a registered one exactly; on a loopback http address any port does (RFC 8252 §7.3). */
81
+ export declare function redirectMatches(presented: string, registered: readonly string[]): string | null;
82
+ export declare function createOAuthHttp(options: OAuthHttpOptions): (request: IncomingMessage, response: ServerResponse, url: URL) => Promise<boolean>;