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.
- package/CHANGELOG.md +33 -0
- package/dist/activity-line.d.ts +26 -0
- package/dist/activity-line.js +47 -0
- package/dist/api-tokens.d.ts +33 -0
- package/dist/api-tokens.js +39 -0
- package/dist/audit-cli.d.ts +17 -0
- package/dist/audit-cli.js +86 -0
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-workspace.d.ts +18 -1
- package/dist/browser-workspace.js +9 -3
- package/dist/builder.js +4 -2
- package/dist/cli-http.d.ts +5 -0
- package/dist/cli-http.js +12 -2
- package/dist/cli.js +7 -1
- package/dist/contracts/cli.d.ts +47 -0
- package/dist/contracts/cli.js +11 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/mcp-oauth.d.ts +53 -0
- package/dist/contracts/mcp-oauth.js +40 -0
- package/dist/credentials-ui.d.ts +5 -0
- package/dist/credentials-ui.js +16 -4
- package/dist/export.js +4 -3
- package/dist/flow-card-task.d.ts +2 -0
- package/dist/flow-card-task.js +57 -0
- package/dist/flow-live.d.ts +2 -2
- package/dist/flow-live.js +69 -8
- package/dist/flows-ui.js +15 -1
- package/dist/live.d.ts +14 -0
- package/dist/live.js +71 -22
- package/dist/mcp-http.d.ts +6 -1
- package/dist/mcp-http.js +22 -4
- package/dist/mcp-oauth.d.ts +82 -0
- package/dist/mcp-oauth.js +479 -0
- package/dist/mcp-person.js +1 -1
- package/dist/operate-remote-arguments.js +2 -0
- package/dist/operate-remote.d.ts +1 -1
- package/dist/operate-remote.js +7 -1
- package/dist/operate.d.ts +3 -0
- package/dist/operate.js +58 -2
- package/dist/people-audit-ui.d.ts +14 -0
- package/dist/people-audit-ui.js +38 -0
- package/dist/planner.js +4 -3
- package/dist/public-access.d.ts +50 -0
- package/dist/public-access.js +87 -0
- package/dist/public-check.d.ts +37 -0
- package/dist/public-check.js +170 -0
- package/dist/remote-audit.d.ts +79 -0
- package/dist/remote-audit.js +161 -0
- package/dist/request-budget-ui.d.ts +11 -0
- package/dist/request-budget-ui.js +31 -0
- package/dist/request-budget.d.ts +96 -0
- package/dist/request-budget.js +227 -0
- package/dist/serve.d.ts +9 -1
- package/dist/serve.js +211 -52
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +134 -2
- package/dist/store.js +270 -7
- package/dist/surface.js +19 -2
- package/dist/task-activity.d.ts +27 -0
- package/dist/task-activity.js +53 -0
- package/dist/task-live.d.ts +17 -0
- package/dist/task-live.js +144 -0
- package/dist/token-notices.d.ts +22 -0
- package/dist/token-notices.js +52 -0
- package/dist/tokens-cli.d.ts +21 -0
- package/dist/tokens-cli.js +184 -0
- package/package.json +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
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 { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import { hashSecret, mintApiToken } from "./api-tokens.js";
|
|
24
|
+
import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
|
|
25
|
+
/** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
|
|
26
|
+
export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
|
|
27
|
+
/** The most sign-ins waiting at once, and registered clients kept. */
|
|
28
|
+
export const OAUTH_LIMITS = { requests: 200, requestsPerSource: 20, clients: 500, clientsPerSource: 20 };
|
|
29
|
+
/** The most a registration or form body may be. */
|
|
30
|
+
const MAX_BODY = 16 * 1024;
|
|
31
|
+
export const OAUTH_SCOPES = ["read", "act"];
|
|
32
|
+
const REFRESH_SHAPE = /^sor_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
|
|
33
|
+
export const RESOURCE_PATH = "/mcp";
|
|
34
|
+
export const resourceMetadataUrl = (origin) => `${origin}/.well-known/oauth-protected-resource${RESOURCE_PATH}`;
|
|
35
|
+
// ---- what a bearer may do -------------------------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Whether an API token may sign in to this request's path now. An ordinary token: yes (its own checks are elsewhere).
|
|
38
|
+
* An MCP sign-in's token: only at /mcp, only while its access secret is fresh and the account is at the generation
|
|
39
|
+
* that consented.
|
|
40
|
+
*/
|
|
41
|
+
export function oauthTokenAllowed(store, tokenId, path, now) {
|
|
42
|
+
const grant = store.oauthGrant(tokenId);
|
|
43
|
+
if (grant === null)
|
|
44
|
+
return store.apiTokenSecret(tokenId)?.row.purpose === "api";
|
|
45
|
+
if (path !== RESOURCE_PATH || !(Date.parse(grant.accessExpiresAt) > now.getTime()))
|
|
46
|
+
return false;
|
|
47
|
+
const account = store.accountOf(grant.account);
|
|
48
|
+
return account !== null && account.revokedAt === null && account.generation === grant.generation;
|
|
49
|
+
}
|
|
50
|
+
/** The projects an MCP sign-in's token was granted (the person's live access still applies), or null for an ordinary token. */
|
|
51
|
+
export const oauthProjects = (store, tokenId) => store.oauthGrant(tokenId)?.projects ?? null;
|
|
52
|
+
const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
|
|
53
|
+
const same = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
|
|
54
|
+
const escape = (text) => text.replace(/[&<>"']/g, one => `&#${one.charCodeAt(0)};`);
|
|
55
|
+
const clean = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]+/g, " ").replace(/\s+/g, " ").trim();
|
|
56
|
+
/**
|
|
57
|
+
* A redirect a client may register: https anywhere, or http on 127.0.0.1 / [::1] (RFC 8252 §7.3), with no
|
|
58
|
+
* credentials or fragment.
|
|
59
|
+
*/
|
|
60
|
+
export function redirectAllowed(value) {
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(value);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (url.username !== "" || url.password !== "" || value.includes("#"))
|
|
69
|
+
return false;
|
|
70
|
+
return url.protocol === "https:" || url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "[::1]");
|
|
71
|
+
}
|
|
72
|
+
/** A redirect matches a registered one exactly; on a loopback http address any port does (RFC 8252 §7.3). */
|
|
73
|
+
export function redirectMatches(presented, registered) {
|
|
74
|
+
if (registered.includes(presented))
|
|
75
|
+
return presented;
|
|
76
|
+
let url;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(presented);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (url.protocol !== "http:" || !redirectAllowed(presented))
|
|
84
|
+
return null;
|
|
85
|
+
for (const one of registered) {
|
|
86
|
+
const kept = new URL(one);
|
|
87
|
+
if (kept.protocol === "http:" && kept.hostname === url.hostname && kept.pathname === url.pathname && kept.search === url.search)
|
|
88
|
+
return presented;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export function createOAuthHttp(options) {
|
|
93
|
+
const { store } = options;
|
|
94
|
+
const waiting = new Map();
|
|
95
|
+
const json = (response, status, body, headers = {}) => {
|
|
96
|
+
response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", pragma: "no-cache", "x-content-type-options": "nosniff", ...headers });
|
|
97
|
+
response.end(JSON.stringify(body));
|
|
98
|
+
};
|
|
99
|
+
const oauthError = (response, status, error, description) => json(response, status, { error, error_description: description });
|
|
100
|
+
/** A page of this sign-in: script-free, never framed, posting only to this console. */
|
|
101
|
+
const page = (response, status, html) => {
|
|
102
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
|
|
103
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" });
|
|
104
|
+
response.end(html);
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* On to an address by a page that moves on by itself, never a redirect: a redirect answering a form is held to
|
|
108
|
+
* the consent page's form-action 'self', so the browser would stop it on the way to the client. Also how a
|
|
109
|
+
* sign-in that arrived from another app reaches this console's own pages with the session cookie (SameSite=Strict).
|
|
110
|
+
*/
|
|
111
|
+
const moveOn = (response, to, words) => {
|
|
112
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
|
|
113
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" });
|
|
114
|
+
response.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${escape(to)}"><title>Toolroll</title>${STYLE}<main><p>${escape(words)} <a href="${escape(to)}">Continue</a></p></main></html>`);
|
|
115
|
+
};
|
|
116
|
+
const problemPage = (response, status, title, said) => page(response, status, shell(title, `<h1>${escape(title)}</h1><p>${escape(said)}</p>`));
|
|
117
|
+
/** Back to the client with an answer: the code, or an error, and its state; `iss` names this console (RFC 9207). */
|
|
118
|
+
const backToClient = (response, origin, redirectUri, state, answer, words) => {
|
|
119
|
+
const to = new URL(redirectUri);
|
|
120
|
+
for (const [key, value] of Object.entries(answer))
|
|
121
|
+
to.searchParams.set(key, value);
|
|
122
|
+
if (state !== null)
|
|
123
|
+
to.searchParams.set("state", state);
|
|
124
|
+
to.searchParams.set("iss", origin);
|
|
125
|
+
moveOn(response, to.toString(), words);
|
|
126
|
+
};
|
|
127
|
+
const readBody = async (request) => {
|
|
128
|
+
if (Number(request.headers["content-length"] ?? 0) > MAX_BODY)
|
|
129
|
+
return null;
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
for await (const chunk of request) {
|
|
133
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
134
|
+
size += bytes.length;
|
|
135
|
+
if (size > MAX_BODY)
|
|
136
|
+
return null;
|
|
137
|
+
chunks.push(bytes);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const media = (request) => String(request.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
|
|
147
|
+
const prune = (now) => {
|
|
148
|
+
for (const [id, one] of waiting)
|
|
149
|
+
if (one.expires <= now)
|
|
150
|
+
waiting.delete(id);
|
|
151
|
+
};
|
|
152
|
+
// ---- discovery ----
|
|
153
|
+
const resourceMetadata = (origin) => ({
|
|
154
|
+
resource: `${origin}${RESOURCE_PATH}`, authorization_servers: [origin], scopes_supported: [...OAUTH_SCOPES], bearer_methods_supported: ["header"], resource_name: "Toolroll",
|
|
155
|
+
});
|
|
156
|
+
const serverMetadata = (origin) => ({
|
|
157
|
+
issuer: origin,
|
|
158
|
+
authorization_endpoint: `${origin}/oauth/authorize`,
|
|
159
|
+
token_endpoint: `${origin}/oauth/token`,
|
|
160
|
+
registration_endpoint: `${origin}/oauth/register`,
|
|
161
|
+
scopes_supported: [...OAUTH_SCOPES],
|
|
162
|
+
response_types_supported: ["code"],
|
|
163
|
+
response_modes_supported: ["query"],
|
|
164
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
165
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
166
|
+
code_challenge_methods_supported: ["S256"],
|
|
167
|
+
authorization_response_iss_parameter_supported: true,
|
|
168
|
+
});
|
|
169
|
+
// ---- registration ----
|
|
170
|
+
async function register(request, response) {
|
|
171
|
+
if (request.method !== "POST")
|
|
172
|
+
return oauthError(response, 405, "invalid_request", "Register with a POST.");
|
|
173
|
+
if (media(request) !== "application/json")
|
|
174
|
+
return oauthError(response, 400, "invalid_client_metadata", "Send the client's metadata as application/json.");
|
|
175
|
+
const text = await readBody(request);
|
|
176
|
+
if (text === null)
|
|
177
|
+
return oauthError(response, 400, "invalid_client_metadata", "The registration is over 16 KiB.");
|
|
178
|
+
let body;
|
|
179
|
+
try {
|
|
180
|
+
body = JSON.parse(text);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return oauthError(response, 400, "invalid_client_metadata", "Send JSON.");
|
|
184
|
+
}
|
|
185
|
+
const read = readOAuthRegistration(body);
|
|
186
|
+
if (!read.ok) {
|
|
187
|
+
const redirects = read.issues.some(one => one.path.startsWith("redirect_uris"));
|
|
188
|
+
return oauthError(response, 400, redirects ? "invalid_redirect_uri" : "invalid_client_metadata", read.issues.map(one => one.line).join("; ").slice(0, 300));
|
|
189
|
+
}
|
|
190
|
+
const redirects = [...new Set(read.value.redirect_uris)];
|
|
191
|
+
if (!redirects.every(redirectAllowed))
|
|
192
|
+
return oauthError(response, 400, "invalid_redirect_uri", "Redirects are https, or http on 127.0.0.1 or [::1], with no fragment or credentials.");
|
|
193
|
+
if (read.value.grant_types !== undefined && !read.value.grant_types.includes("authorization_code"))
|
|
194
|
+
return oauthError(response, 400, "invalid_client_metadata", "This server issues codes: include authorization_code.");
|
|
195
|
+
const name = clean(read.value.client_name ?? "") || "MCP client";
|
|
196
|
+
const now = options.clock();
|
|
197
|
+
prune(now.getTime());
|
|
198
|
+
const kept = store.registerOAuthClient({ id: randomBytes(16).toString("hex"), name, redirectUris: redirects, source: sha256(options.requesterKey(request)) }, now, OAUTH_LIMITS.clients, OAUTH_LIMITS.clientsPerSource, [...waiting.values()].map(one => one.client));
|
|
199
|
+
if (kept === null) {
|
|
200
|
+
response.setHeader("retry-after", "600");
|
|
201
|
+
return oauthError(response, 429, "temporarily_unavailable", "Too many clients are waiting to connect. Try again later.");
|
|
202
|
+
}
|
|
203
|
+
return json(response, 201, {
|
|
204
|
+
client_id: kept.id, client_id_issued_at: Math.floor(Date.parse(kept.createdAt) / 1000), client_name: kept.name, redirect_uris: kept.redirectUris,
|
|
205
|
+
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// ---- the sign-in and consent ----
|
|
209
|
+
function authorizeStart(request, response, url, origin) {
|
|
210
|
+
const now = options.clock().getTime();
|
|
211
|
+
prune(now);
|
|
212
|
+
store.pruneOAuthClients(new Date(now), [...waiting.values()].map(one => one.client));
|
|
213
|
+
const params = url.searchParams;
|
|
214
|
+
for (const key of ["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state", "resource", "scope"]) {
|
|
215
|
+
if (params.getAll(key).length > 1)
|
|
216
|
+
return problemPage(response, 400, "This sign-in link isn't valid", "Start again from your MCP client.");
|
|
217
|
+
}
|
|
218
|
+
const client = store.oauthClient(params.get("client_id") ?? "");
|
|
219
|
+
const redirectUri = client === null ? null : redirectMatches(params.get("redirect_uri") ?? "", client.redirectUris);
|
|
220
|
+
// Registration is anonymous. Even a registered redirect is untrusted until the person decides.
|
|
221
|
+
if (client === null || redirectUri === null)
|
|
222
|
+
return problemPage(response, 400, "This sign-in link isn't valid", "Your MCP client isn't registered here, or asked to return somewhere it didn't register. Start again from the client.");
|
|
223
|
+
const state = params.get("state");
|
|
224
|
+
const fail = (description) => problemPage(response, 400, "This sign-in link isn't valid", `${description} Start again from your MCP client.`);
|
|
225
|
+
if (state !== null && (state.length > 1024 || /[\u0000-\u001f]/.test(state)))
|
|
226
|
+
return fail("The client sent an invalid sign-in identifier.");
|
|
227
|
+
if (params.get("response_type") !== "code")
|
|
228
|
+
return fail("The client requested an unsupported sign-in method.");
|
|
229
|
+
if (params.get("code_challenge_method") !== "S256" || !PKCE_CHALLENGE.test(params.get("code_challenge") ?? ""))
|
|
230
|
+
return fail("The client didn't provide the required sign-in protection.");
|
|
231
|
+
if (params.get("resource") !== `${origin}${RESOURCE_PATH}`)
|
|
232
|
+
return fail("The client requested access to a different server.");
|
|
233
|
+
const scopes = (params.get("scope") ?? "").split(" ").filter(one => one !== "");
|
|
234
|
+
if (!scopes.every(one => OAUTH_SCOPES.includes(one)))
|
|
235
|
+
return fail("The client requested access Toolroll doesn't offer.");
|
|
236
|
+
const source = sha256(options.requesterKey(request));
|
|
237
|
+
if (waiting.size >= OAUTH_LIMITS.requests || [...waiting.values()].filter(one => one.source === source).length >= OAUTH_LIMITS.requestsPerSource) {
|
|
238
|
+
response.setHeader("retry-after", "600");
|
|
239
|
+
return problemPage(response, 429, "Too many sign-ins waiting", "Try again in ten minutes from your MCP client.");
|
|
240
|
+
}
|
|
241
|
+
const id = randomBytes(24).toString("base64url");
|
|
242
|
+
waiting.set(id, { client: client.id, redirectUri, challenge: params.get("code_challenge"), state, resource: `${origin}${RESOURCE_PATH}`, act: scopes.includes("act"), expires: now + OAUTH_TIMES.requestMs, source });
|
|
243
|
+
return moveOn(response, `/oauth/authorize?request=${id}`, "Opening Toolroll…");
|
|
244
|
+
}
|
|
245
|
+
const consentHtml = (session, id, one, problem, chosen) => {
|
|
246
|
+
const client = store.oauthClient(one.client);
|
|
247
|
+
const host = new URL(one.redirectUri).host;
|
|
248
|
+
const projects = options.projectsFor(session.name);
|
|
249
|
+
const mayAct = session.role === "approver";
|
|
250
|
+
const access = chosen?.access ?? (one.act && mayAct ? "act" : "read");
|
|
251
|
+
const checked = new Set(chosen?.projects ?? []);
|
|
252
|
+
const title = "Connect to Toolroll";
|
|
253
|
+
const projectList = projects.length === 0 ? "" : projects.map(path => {
|
|
254
|
+
const name = path.split("/").filter(part => part !== "").pop() ?? path;
|
|
255
|
+
return `<label class="pick"><input type="checkbox" name="project" value="${escape(path)}"${checked.has(path) ? " checked" : ""}><span><b>${escape(name)}</b><small>${escape(path)}</small></span></label>`;
|
|
256
|
+
}).join("");
|
|
257
|
+
const step = session.sso?.fresh === true
|
|
258
|
+
? `<p class="meta">Confirmed with ${escape(session.sso.label)}.</p><input type="hidden" name="password" value="">`
|
|
259
|
+
: `<label class="field">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>${session.sso === null ? "" : `<p class="meta"><a href="/login/sso?reauth=1&return=${escape(encodeURIComponent(`/oauth/authorize?request=${id}`))}">Confirm with ${escape(session.sso.label)}</a> instead.</p>`}`;
|
|
260
|
+
const body = [
|
|
261
|
+
`<h1>${escape(title)}</h1>`,
|
|
262
|
+
`<p class="client"><b>${escape(client.name)}</b> <span class="tag">unverified</span> <span class="meta">returns to ${escape(host)}</span></p>`,
|
|
263
|
+
problem === null ? "" : `<div class="problem" role="alert">${escape(problem)}</div>`,
|
|
264
|
+
projects.length === 0
|
|
265
|
+
? `<p>You don't have access to any projects. Ask your Toolroll administrator to add you to one.</p>`
|
|
266
|
+
+ `<form method="post" action="/oauth/authorize"><input type="hidden" name="request" value="${escape(id)}"><input type="hidden" name="csrf" value="${escape(session.csrf)}"><button name="decision" value="deny" class="secondary">Cancel</button></form>`
|
|
267
|
+
: [
|
|
268
|
+
`<p>Connects as ${escape(session.name)} in your selected projects. It can't approve work or change people or policy.</p>`,
|
|
269
|
+
`<form method="post" action="/oauth/authorize">`,
|
|
270
|
+
`<input type="hidden" name="request" value="${escape(id)}"><input type="hidden" name="csrf" value="${escape(session.csrf)}">`,
|
|
271
|
+
`<fieldset><legend>Access</legend>`,
|
|
272
|
+
`<label class="pick"><input type="radio" name="access" value="read"${access === "read" ? " checked" : ""}><span><b>Read</b><small>See tasks, results and status.</small></span></label>`,
|
|
273
|
+
mayAct ? `<label class="pick"><input type="radio" name="access" value="act"${access === "act" ? " checked" : ""}><span><b>Act</b><small>Also file tasks under your name.</small></span></label>` : "",
|
|
274
|
+
`</fieldset>`,
|
|
275
|
+
`<fieldset><legend>Projects</legend>${projectList}</fieldset>`,
|
|
276
|
+
`<p class="meta">Access lasts up to 30 days. Revoke it anytime in Settings → Sessions & tokens.</p>`,
|
|
277
|
+
step,
|
|
278
|
+
`<div class="actions"><button name="decision" value="allow">Allow access</button><button name="decision" value="deny" class="secondary" formnovalidate>Cancel</button></div>`,
|
|
279
|
+
`</form>`,
|
|
280
|
+
].join(""),
|
|
281
|
+
`<details><summary>Client details</summary><p class="meta">Client ID <code>${escape(client.id)}</code><br>Returns to <code>${escape(one.redirectUri)}</code><br>Toolroll doesn't know who made this client; its name is what it says about itself.</p></details>`,
|
|
282
|
+
].join("");
|
|
283
|
+
return shell(title, body);
|
|
284
|
+
};
|
|
285
|
+
async function authorizeConsent(request, response, url, origin) {
|
|
286
|
+
const now = options.clock();
|
|
287
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
288
|
+
const id = url.searchParams.get("request") ?? "";
|
|
289
|
+
const one = waiting.get(id);
|
|
290
|
+
if (one === undefined || one.expires <= now.getTime())
|
|
291
|
+
return problemPage(response, 410, "This sign-in has expired", "Start again from your MCP client.");
|
|
292
|
+
const session = options.session(request);
|
|
293
|
+
if (session === null) {
|
|
294
|
+
response.writeHead(303, { location: `/login?return=${encodeURIComponent(`/oauth/authorize?request=${id}`)}`, "cache-control": "no-store" });
|
|
295
|
+
return void response.end();
|
|
296
|
+
}
|
|
297
|
+
return page(response, 200, consentHtml(session, id, one, null, null));
|
|
298
|
+
}
|
|
299
|
+
if (request.method !== "POST")
|
|
300
|
+
return problemPage(response, 405, "This sign-in isn't valid", "Start again from your MCP client.");
|
|
301
|
+
const session = options.session(request);
|
|
302
|
+
if (session === null)
|
|
303
|
+
return problemPage(response, 403, "Sign in first", "Your console session ended. Start again from your MCP client.");
|
|
304
|
+
if (media(request) !== "application/x-www-form-urlencoded")
|
|
305
|
+
return problemPage(response, 400, "This form isn't valid", "Start again from your MCP client.");
|
|
306
|
+
const text = await readBody(request);
|
|
307
|
+
const form = new URLSearchParams(text ?? "");
|
|
308
|
+
// The person's own act: their session's CSRF value, posted from this console's own page.
|
|
309
|
+
if (text === null || !options.sameSite(request) || !same(form.get("csrf") ?? "", session.csrf))
|
|
310
|
+
return problemPage(response, 403, "This form expired", "Nothing was allowed. Start again from your MCP client.");
|
|
311
|
+
const id = form.get("request") ?? "";
|
|
312
|
+
const one = waiting.get(id);
|
|
313
|
+
if (one === undefined || one.expires <= now.getTime())
|
|
314
|
+
return problemPage(response, 410, "This sign-in has expired", "Nothing was allowed. Start again from your MCP client.");
|
|
315
|
+
const client = store.oauthClient(one.client);
|
|
316
|
+
if (client === null || redirectMatches(one.redirectUri, client.redirectUris) === null) {
|
|
317
|
+
waiting.delete(id);
|
|
318
|
+
return problemPage(response, 400, "This client is no longer registered", "Nothing was allowed. Start again from your MCP client.");
|
|
319
|
+
}
|
|
320
|
+
const decision = form.get("decision");
|
|
321
|
+
if (decision !== "allow" && decision !== "deny")
|
|
322
|
+
return problemPage(response, 400, "Choose whether to connect", "Nothing was allowed. Start again from your MCP client.");
|
|
323
|
+
if (decision === "deny") {
|
|
324
|
+
waiting.delete(id);
|
|
325
|
+
return backToClient(response, origin, one.redirectUri, one.state, { error: "access_denied", error_description: "the person declined" }, "Cancelled. Returning to your MCP client…");
|
|
326
|
+
}
|
|
327
|
+
const account = store.accountOf(session.name);
|
|
328
|
+
if (account === null || account.revokedAt !== null)
|
|
329
|
+
return problemPage(response, 403, "Sign in first", "Your account can't sign in. Nothing was allowed.");
|
|
330
|
+
const access = form.get("access") === "act" ? "act" : "read";
|
|
331
|
+
const chosen = [...new Set(form.getAll("project"))];
|
|
332
|
+
const again = (status, problem) => page(response, status, consentHtml(session, id, one, problem, { access, projects: chosen }));
|
|
333
|
+
if (access === "act" && account.role !== "approver")
|
|
334
|
+
return again(403, "Your account can watch, so the client can only read.");
|
|
335
|
+
const mine = options.projectsFor(session.name);
|
|
336
|
+
if (chosen.length === 0)
|
|
337
|
+
return again(400, "Choose at least one project.");
|
|
338
|
+
// Never named back: a project outside the person's access is refused without saying whether it exists.
|
|
339
|
+
if (!chosen.every(path => mine.includes(path)))
|
|
340
|
+
return again(403, "Choose from your own projects.");
|
|
341
|
+
if (!options.confirm(session, form.get("password") ?? ""))
|
|
342
|
+
return again(403, "Enter your Toolroll password to allow access.");
|
|
343
|
+
waiting.delete(id);
|
|
344
|
+
const code = randomBytes(32).toString("base64url");
|
|
345
|
+
store.saveOAuthCode(sha256(code), { client: client.id, account: session.name, generation: account.generation, access, projects: chosen, redirectUri: one.redirectUri, resource: one.resource,
|
|
346
|
+
challenge: one.challenge, expiresAt: new Date(now.getTime() + OAUTH_TIMES.codeMs).toISOString() });
|
|
347
|
+
return backToClient(response, origin, one.redirectUri, one.state, { code }, "Allowed. Returning to your MCP client…");
|
|
348
|
+
}
|
|
349
|
+
// ---- tokens ----
|
|
350
|
+
async function token(request, response, origin) {
|
|
351
|
+
if (request.method !== "POST")
|
|
352
|
+
return oauthError(response, 405, "invalid_request", "Use POST.");
|
|
353
|
+
if (media(request) !== "application/x-www-form-urlencoded")
|
|
354
|
+
return oauthError(response, 400, "invalid_request", "Send application/x-www-form-urlencoded.");
|
|
355
|
+
const text = await readBody(request);
|
|
356
|
+
if (text === null)
|
|
357
|
+
return oauthError(response, 400, "invalid_request", "The request is over 16 KiB.");
|
|
358
|
+
const read = readOAuthTokenRequest(new URLSearchParams(text));
|
|
359
|
+
if (!read.ok) {
|
|
360
|
+
const unsupported = !("repeated" in read) && read.issues.some(one => one.path === "grant_type");
|
|
361
|
+
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
|
+
}
|
|
363
|
+
const ask = read.value;
|
|
364
|
+
const now = options.clock();
|
|
365
|
+
const resource = `${origin}${RESOURCE_PATH}`;
|
|
366
|
+
if (ask.resource !== undefined && ask.resource !== resource)
|
|
367
|
+
return oauthError(response, 400, "invalid_target", `resource must be ${resource}`);
|
|
368
|
+
const client = store.oauthClient(ask.client_id);
|
|
369
|
+
if (client === null)
|
|
370
|
+
return oauthError(response, 401, "invalid_client", "This client isn't registered here.");
|
|
371
|
+
const invalid = () => oauthError(response, 400, "invalid_grant", "This sign-in is no longer valid. Sign in again from your MCP client.");
|
|
372
|
+
/** The projects still in the person's reach, of those granted; a grant that reaches none is over. */
|
|
373
|
+
const reachable = (account, projects) => { const mine = options.projectsFor(account); return projects.filter(path => mine.includes(path)); };
|
|
374
|
+
if (ask.grant_type === "authorization_code") {
|
|
375
|
+
const codeHash = sha256(ask.code);
|
|
376
|
+
const taken = store.takeOAuthCode(codeHash, now);
|
|
377
|
+
if (taken === null)
|
|
378
|
+
return invalid();
|
|
379
|
+
// A code spent twice: whatever it made ends too (RFC 6749 §4.1.2).
|
|
380
|
+
if ("used" in taken) {
|
|
381
|
+
if (taken.used !== null)
|
|
382
|
+
store.revokeApiToken(taken.used, "toolroll", now, "its sign-in code was used twice");
|
|
383
|
+
return invalid();
|
|
384
|
+
}
|
|
385
|
+
const code = taken.taken;
|
|
386
|
+
const proof = createHash("sha256").update(ask.code_verifier, "ascii").digest("base64url");
|
|
387
|
+
if (code.client !== client.id || code.redirectUri !== ask.redirect_uri || code.resource !== resource || !same(proof, code.challenge))
|
|
388
|
+
return invalid();
|
|
389
|
+
const account = store.accountOf(code.account);
|
|
390
|
+
if (account === null || account.revokedAt !== null || account.generation !== code.generation)
|
|
391
|
+
return invalid();
|
|
392
|
+
if (code.access === "act" && account.role !== "approver")
|
|
393
|
+
return invalid();
|
|
394
|
+
if (reachable(code.account, code.projects).length === 0)
|
|
395
|
+
return invalid();
|
|
396
|
+
const minted = mintApiToken();
|
|
397
|
+
const refresh = randomBytes(32).toString("base64url");
|
|
398
|
+
const accessExpiresAt = new Date(now.getTime() + OAUTH_TIMES.accessSeconds * 1000).toISOString();
|
|
399
|
+
store.createOAuthGrant(codeHash, { id: minted.id, account: code.account, name: `MCP: ${client.name}`.slice(0, 60), secretHash: minted.hash, access: code.access, expiresAt: new Date(now.getTime() + OAUTH_TIMES.grantDays * 86_400_000).toISOString() }, { client: client.id, account: code.account, generation: code.generation, projects: code.projects, resource, accessExpiresAt, refreshHash: hashSecret(refresh) }, now);
|
|
400
|
+
return json(response, 200, { access_token: minted.token, token_type: "Bearer", expires_in: OAUTH_TIMES.accessSeconds, refresh_token: `sor_${minted.id}_${refresh}`, scope: code.access });
|
|
401
|
+
}
|
|
402
|
+
const parsed = REFRESH_SHAPE.exec(ask.refresh_token);
|
|
403
|
+
if (parsed === null)
|
|
404
|
+
return invalid();
|
|
405
|
+
const [, id, secret] = parsed;
|
|
406
|
+
const accessSecret = randomBytes(32).toString("base64url");
|
|
407
|
+
const refresh = randomBytes(32).toString("base64url");
|
|
408
|
+
const accessExpiresAt = new Date(now.getTime() + OAUTH_TIMES.accessSeconds * 1000).toISOString();
|
|
409
|
+
const rotated = store.rotateOAuthGrant(id, { client: client.id, resource, refreshHash: hashSecret(secret), ...(ask.scope === undefined ? {} : { scope: ask.scope }) }, { refreshHash: hashSecret(refresh), accessHash: hashSecret(accessSecret), accessExpiresAt }, now, options.projectsFor);
|
|
410
|
+
if (!rotated.ok)
|
|
411
|
+
return rotated.reason === "invalid_scope" ? oauthError(response, 400, "invalid_scope", "A refresh can't widen access.") : invalid();
|
|
412
|
+
return json(response, 200, { access_token: `so_${id}_${accessSecret}`, token_type: "Bearer", expires_in: OAUTH_TIMES.accessSeconds, refresh_token: `sor_${id}_${refresh}`, scope: rotated.access });
|
|
413
|
+
}
|
|
414
|
+
/** Handles the sign-in's addresses; false for any other path. */
|
|
415
|
+
return async (request, response, url) => {
|
|
416
|
+
const path = url.pathname;
|
|
417
|
+
const discovery = path === "/.well-known/oauth-protected-resource" || path === `/.well-known/oauth-protected-resource${RESOURCE_PATH}` || path === "/.well-known/oauth-authorization-server";
|
|
418
|
+
if (!discovery && !path.startsWith("/oauth/"))
|
|
419
|
+
return false;
|
|
420
|
+
const origin = options.originOf(request);
|
|
421
|
+
if (origin === null) {
|
|
422
|
+
request.resume();
|
|
423
|
+
if (discovery || path !== "/oauth/authorize") {
|
|
424
|
+
oauthError(response, 404, "invalid_request", "MCP sign-in needs this console's https public address (--public-url).");
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
problemPage(response, 404, "MCP sign-in isn't available at this address", "Open Toolroll at its public https address.");
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
if (discovery) {
|
|
431
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
432
|
+
oauthError(response, 405, "invalid_request", "Use GET.");
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
json(response, 200, path.startsWith("/.well-known/oauth-protected-resource") ? resourceMetadata(origin) : serverMetadata(origin), { "cache-control": "public, max-age=300" });
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
if (path === "/oauth/register") {
|
|
439
|
+
await register(request, response);
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
if (path === "/oauth/token") {
|
|
443
|
+
await token(request, response, origin);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
if (path === "/oauth/authorize") {
|
|
447
|
+
if ((request.method === "GET" || request.method === "HEAD") && !url.searchParams.has("request"))
|
|
448
|
+
authorizeStart(request, response, url, origin);
|
|
449
|
+
else
|
|
450
|
+
await authorizeConsent(request, response, url, origin);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
request.resume();
|
|
454
|
+
oauthError(response, 404, "invalid_request", "No such address.");
|
|
455
|
+
return true;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** The sign-in's pages stand alone: no stylesheet, script or font loads here, so the palette rides inline. */
|
|
459
|
+
const STYLE = [
|
|
460
|
+
`<style>`,
|
|
461
|
+
`:root{color-scheme:light dark;--ground:#efefef;--paper:#fff;--ink:#171717;--muted:#666;--line:#e6e6e6;--soft:#f2f2f2;--accent:#171717;--on-accent:#fff;--problem:#b42318}`,
|
|
462
|
+
`@media (prefers-color-scheme:dark){:root{--ground:#0b0b0b;--paper:#161616;--ink:#ededed;--muted:#a1a1a1;--line:#262626;--soft:#1f1f1f;--accent:#ededed;--on-accent:#0b0b0b;--problem:#ff8a80}}`,
|
|
463
|
+
`*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--ground);color:var(--ink);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}`,
|
|
464
|
+
`main{width:100%;min-width:0;max-width:520px;overflow-wrap:anywhere;background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:28px}`,
|
|
465
|
+
`h1{font-size:19px;line-height:1.35;margin:0 0 10px;overflow-wrap:anywhere}p{margin:0 0 14px}`,
|
|
466
|
+
`.client{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:baseline;overflow-wrap:anywhere}.client>*{min-width:0}.tag{font-size:12px;border:1px solid var(--line);border-radius:999px;padding:1px 8px;color:var(--muted)}`,
|
|
467
|
+
`.meta,small{color:var(--muted);font-size:13px}fieldset{border:0;min-width:0;margin:0 0 16px;padding:0}legend{font-weight:600;margin-bottom:6px}`,
|
|
468
|
+
`.pick{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;cursor:pointer;min-height:44px}`,
|
|
469
|
+
`.pick input{margin-top:4px;width:18px;height:18px;flex:none;accent-color:var(--accent)}.pick span{display:flex;flex-direction:column;min-width:0}.pick b,.pick small{overflow-wrap:anywhere}`,
|
|
470
|
+
`.field{display:flex;flex-direction:column;gap:6px;font-weight:600;margin-bottom:14px}.field input{font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink);min-height:44px}`,
|
|
471
|
+
`.actions{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 0}button{font:inherit;font-weight:600;min-height:44px;padding:0 18px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--on-accent);cursor:pointer;white-space:nowrap}`,
|
|
472
|
+
`button.secondary{background:transparent;color:var(--ink);border-color:var(--line)}button:hover{opacity:.85}button:focus-visible,input:focus-visible,summary:focus-visible,a:focus-visible{outline:2px solid var(--ink);outline-offset:2px}`,
|
|
473
|
+
`form{margin:0 0 16px}`,
|
|
474
|
+
`.problem{border:1px solid var(--problem);color:var(--problem);border-radius:8px;padding:10px 12px;margin-bottom:14px}a{color:inherit;text-underline-offset:3px}`,
|
|
475
|
+
`details{border-top:1px solid var(--line)}summary{cursor:pointer;color:var(--muted);font-size:13px;min-height:44px;padding:12px 0}code{font:12.5px/1.5 ui-monospace,"SF Mono",Menlo,monospace;background:var(--soft);border-radius:5px;padding:1px 5px;overflow-wrap:anywhere}`,
|
|
476
|
+
`@media (max-width:480px){main{padding:20px}.field input{font-size:16px}.actions button{flex:1 1 auto}}`,
|
|
477
|
+
`</style>`,
|
|
478
|
+
].join("");
|
|
479
|
+
const shell = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>${escape(title)}</title>${STYLE}</head><body><main>${body}</main></body></html>`;
|
package/dist/mcp-person.js
CHANGED
|
@@ -75,7 +75,7 @@ export async function callPersonTool(person, store, runAs, params) {
|
|
|
75
75
|
let written = "";
|
|
76
76
|
let code;
|
|
77
77
|
try {
|
|
78
|
-
code = await runAs(argv, { principal: person.principal, store, write: chunk => { written += chunk; }, source: "mcp" });
|
|
78
|
+
code = await runAs(argv, { principal: person.principal, store, write: chunk => { written += chunk; }, source: "mcp", tool });
|
|
79
79
|
}
|
|
80
80
|
catch (error) {
|
|
81
81
|
return refused(`the command failed on the server: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -13,6 +13,8 @@ export const REMOTE_ARGUMENTS = new Map([
|
|
|
13
13
|
'routine list', 'intake show', 'intake run', 'template list', 'webhook status', 'webhook test', 'review show',
|
|
14
14
|
'chat-approval show', 'chat-approval off'].map(row => [row, args()]),
|
|
15
15
|
['integrations', args('', 'saved')],
|
|
16
|
+
// --person and --token-name are names compared inside the reader's own view; --token stays a refused credential.
|
|
17
|
+
['audit', args('', 'person token-name source since limit cursor')],
|
|
16
18
|
['outbox list', args('', 'all')],
|
|
17
19
|
['task add', args('title*', 'key report checks', { id: 'refused', replaces: 'task' })],
|
|
18
20
|
['task ask', args('id:task', 'person why')],
|
package/dist/operate-remote.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type Principal = {
|
|
|
9
9
|
generation: number;
|
|
10
10
|
scope: "read" | "act";
|
|
11
11
|
tokenId: string;
|
|
12
|
-
/** The projects the person may use (null: all of them). */
|
|
12
|
+
/** The projects the person may use through this token (null: all of them): their access, narrowed by the token's limit (tokenProjects). */
|
|
13
13
|
projects: string[] | null;
|
|
14
14
|
};
|
|
15
15
|
export type RemoteSource = Extract<LedgerSource, "api" | "mcp">;
|
package/dist/operate-remote.js
CHANGED
|
@@ -10,6 +10,7 @@ import { randomBytes } from "node:crypto";
|
|
|
10
10
|
import { isAbsolute } from "node:path";
|
|
11
11
|
import { COMMAND_GUIDE } from "./surface.js";
|
|
12
12
|
import { remoteArgumentsOf, REMOTE_NOT_FOUND } from "./operate-remote-arguments.js";
|
|
13
|
+
import { tokenLive, withinTokenLimit } from "./api-tokens.js";
|
|
13
14
|
export { activeRemote, withRemote } from "./remote-run.js";
|
|
14
15
|
/** Thrown where a remote run reaches something only this machine's owner may use; the command ends refused. */
|
|
15
16
|
export class RemoteRefusal extends Error {
|
|
@@ -47,6 +48,8 @@ export const REMOTE_SCOPES = new Map([
|
|
|
47
48
|
["intake pr-comments", installation], ["template list", installation], ["template show", installation], ["contest show", installation],
|
|
48
49
|
["contest exclude", installation], ["webhook status", installation], ["webhook test", installation], ["review show", installation],
|
|
49
50
|
["chat-approval show", self], ["chat-approval off", self],
|
|
51
|
+
// The person's own remote actions, or everyone's within their projects for an approver (remote-audit.ts).
|
|
52
|
+
["audit", self],
|
|
50
53
|
["knowledge search", repo], ["knowledge impact", repo], ["knowledge refresh", repo],
|
|
51
54
|
...["search", "decisions", "show", "decide", "retire", "review", "status"].map(action => [`memory ${action}`, self]),
|
|
52
55
|
...["list", "show", "export", "create", "edit", "trigger add", "trigger pause", "trigger resume", "trigger remove", "card add", "archive"]
|
|
@@ -100,7 +103,10 @@ export function reproveRemote(store, principal, now) {
|
|
|
100
103
|
if (account === null || account.revokedAt !== null || account.generation !== principal.generation)
|
|
101
104
|
return { ok: false };
|
|
102
105
|
const token = store.apiTokenSecret(principal.tokenId)?.row ?? null;
|
|
103
|
-
if (token === null || token.account !== principal.account ||
|
|
106
|
+
if (token === null || token.account !== principal.account || !tokenLive(token, now.getTime()))
|
|
107
|
+
return { ok: false };
|
|
108
|
+
// v111: a principal never stands for more projects than its token is limited to, whoever built it.
|
|
109
|
+
if (!Array.isArray(principal.projects) && principal.projects !== null || !withinTokenLimit(principal.projects, token.projects))
|
|
104
110
|
return { ok: false };
|
|
105
111
|
const scope = principal.scope === "act" && token.access === "act" && account.role === "approver" ? "act" : "read";
|
|
106
112
|
return { ok: true, scope, tokenName: token.name };
|
package/dist/operate.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type OperateOptions = {
|
|
|
39
39
|
releaseIo?: ReleaseIo;
|
|
40
40
|
/** Injected by tests: the bin whose real path says how Toolroll was installed. */
|
|
41
41
|
installBin?: string;
|
|
42
|
+
/** Tests: what `serve check-public` fetches with instead of the network. */
|
|
43
|
+
publicProbe?: import("./public-check.js").Probe;
|
|
42
44
|
/** Injected by tests: where `storage` looks for leftover test temp folders (default: the temp folder and /tmp). */
|
|
43
45
|
tempRoots?: readonly string[];
|
|
44
46
|
openDatabase?: (file: string) => Store;
|
|
@@ -142,6 +144,7 @@ export declare function runOperateAs(argv: string[], opts: {
|
|
|
142
144
|
write: (s: string) => void;
|
|
143
145
|
files?: Record<string, string>;
|
|
144
146
|
source?: RemoteSource;
|
|
147
|
+
tool?: string;
|
|
145
148
|
options?: OperateOptions;
|
|
146
149
|
}): Promise<number>;
|
|
147
150
|
/** Durably create the login file BEFORE the bootstrap commits (arc 2
|