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.
- package/CHANGELOG.md +18 -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/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/desktop-update.d.ts +1 -1
- package/dist/export.js +4 -3
- 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 +52 -1
- package/dist/people-audit-ui.d.ts +14 -0
- package/dist/people-audit-ui.js +38 -0
- 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 +4 -1
- package/dist/serve.js +120 -13
- package/dist/store.d.ts +118 -2
- package/dist/store.js +236 -7
- package/dist/surface.js +19 -2
- 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
package/dist/serve.js
CHANGED
|
@@ -262,9 +262,15 @@ import { LIMITS_CSS, limitsHtml, limitsView } from "./limits-ui.js";
|
|
|
262
262
|
import { budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames as teammateNamesOf, usd as spendUsd } from "./spend.js";
|
|
263
263
|
import { targetOf } from "./monitoring.js";
|
|
264
264
|
import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
|
|
265
|
-
import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS } from "./api-tokens.js";
|
|
265
|
+
import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS, tokenLive, tokenProjects } from "./api-tokens.js";
|
|
266
266
|
import { createMcpHttp } from "./mcp-http.js";
|
|
267
|
+
import { FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, parseLimit, PER_DAY_MAX as REQUEST_BUDGET_PER_DAY_MAX, PER_MINUTE_MAX as REQUEST_BUDGET_PER_MINUTE_MAX, RequestBudget, setLimitOverride } from "./request-budget.js";
|
|
268
|
+
import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
|
|
269
|
+
import { createOAuthHttp, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
|
|
267
270
|
import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
|
|
271
|
+
import { REQUEST_LIMITS_CSS, requestLimitsHtml, tokenLimitWords } from "./request-budget-ui.js";
|
|
272
|
+
import { PEOPLE_AUDIT_CSS, personAuditHtml, personHref } from "./people-audit-ui.js";
|
|
273
|
+
import { cursorOf as auditCursorOf, remoteAudit } from "./remote-audit.js";
|
|
268
274
|
import { logEvent } from "./log.js";
|
|
269
275
|
import { modeTermsFromJson, modeWords, presetTerms, modeTermsJson, modeDigestOf, MODE_MAX_DAYS } from "./modes.js";
|
|
270
276
|
import { PROVIDER_KEY_ENV, SUBSCRIPTION_CAPABLE, clearProviderKey, readProviderKey, keyStatus, plausibleKey, readAuthMode, readAuthModeStrict, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
|
|
@@ -535,7 +541,7 @@ export function createDecisionServer(options) {
|
|
|
535
541
|
* the trusted proxy itself appended; earlier entries are client-typed). */
|
|
536
542
|
function joinSourceOf(request) {
|
|
537
543
|
const peer = request.socket.remoteAddress ?? "unknown";
|
|
538
|
-
const loopback =
|
|
544
|
+
const loopback = LOOPBACK_PEERS.has(peer);
|
|
539
545
|
if (!loopback)
|
|
540
546
|
return peer;
|
|
541
547
|
const forwarded = request.headers["x-forwarded-for"];
|
|
@@ -581,7 +587,7 @@ export function createDecisionServer(options) {
|
|
|
581
587
|
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
|
|
582
588
|
const fromThisComputer = (request) => {
|
|
583
589
|
const peer = request.socket.remoteAddress ?? "";
|
|
584
|
-
const loopback =
|
|
590
|
+
const loopback = LOOPBACK_PEERS.has(peer);
|
|
585
591
|
return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
|
|
586
592
|
&& /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
|
|
587
593
|
};
|
|
@@ -797,16 +803,19 @@ export function createDecisionServer(options) {
|
|
|
797
803
|
return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
|
|
798
804
|
}, cursor: team.cursor, streams: teamStreams,
|
|
799
805
|
});
|
|
806
|
+
// v112: one request budget per server for person API tokens, shared by /api/cli and /mcp (request-budget.ts).
|
|
807
|
+
const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
|
|
800
808
|
// Remote CLI: one live API token names the person; the shared command boundary decides everything else.
|
|
801
809
|
const cliEndpoint = (request, response) => handleCliHttp(request, response, {
|
|
810
|
+
admit: principal => requestBudget.admit(principal.tokenId, "api"),
|
|
802
811
|
authenticate: request => {
|
|
803
|
-
const who = identify(request, false);
|
|
812
|
+
const who = identify(request, false, true);
|
|
804
813
|
const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
|
|
805
814
|
const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
|
|
806
815
|
const account = who === null ? null : store.accountOf(who.name);
|
|
807
816
|
if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
|
|
808
817
|
return null;
|
|
809
|
-
return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: account.projects
|
|
818
|
+
return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: tokenProjects(account.projects, row.projects) };
|
|
810
819
|
},
|
|
811
820
|
run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
|
|
812
821
|
modeOf: options.cliModeOf,
|
|
@@ -876,6 +885,15 @@ export function createDecisionServer(options) {
|
|
|
876
885
|
timer.unref?.();
|
|
877
886
|
server.on("close", () => clearInterval(timer));
|
|
878
887
|
}
|
|
888
|
+
{
|
|
889
|
+
const flushBudget = () => { try {
|
|
890
|
+
requestBudget.flush();
|
|
891
|
+
}
|
|
892
|
+
catch { /* the next admit refuses if the database is unwell */ } };
|
|
893
|
+
const timer = setInterval(flushBudget, REQUEST_BUDGET_FLUSH_MS);
|
|
894
|
+
timer.unref?.();
|
|
895
|
+
server.on("close", () => { clearInterval(timer); flushBudget(); });
|
|
896
|
+
}
|
|
879
897
|
const servedPort = () => {
|
|
880
898
|
const address = server.address();
|
|
881
899
|
return typeof address === "object" && address !== null ? address.port : null;
|
|
@@ -1237,6 +1255,17 @@ export function createDecisionServer(options) {
|
|
|
1237
1255
|
if (!allowedHost(request.headers.host)) {
|
|
1238
1256
|
return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
|
|
1239
1257
|
}
|
|
1258
|
+
// A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
|
|
1259
|
+
// plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
|
|
1260
|
+
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: joinSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
|
|
1261
|
+
const hsts = hstsFor(publicOrigin?.host ?? null, request.headers.host, transport);
|
|
1262
|
+
if (hsts !== null)
|
|
1263
|
+
response.setHeader("Strict-Transport-Security", hsts);
|
|
1264
|
+
const insecure = plainHttpRefusal(transport, hook.pathname, request.headers.authorization);
|
|
1265
|
+
if (insecure !== null) {
|
|
1266
|
+
request.resume();
|
|
1267
|
+
return respond(response, 403, "text/plain; charset=utf-8", insecure);
|
|
1268
|
+
}
|
|
1240
1269
|
const url = new URL(request.url ?? "/", "http://placeholder");
|
|
1241
1270
|
// A token in a URL is a token in history, logs, and referers. Refused
|
|
1242
1271
|
// outright rather than ignored, so nobody learns the habit works.
|
|
@@ -1246,6 +1275,9 @@ export function createDecisionServer(options) {
|
|
|
1246
1275
|
// The MCP gateway over HTTP (mcp-http.ts): coordinators and people's own API tokens, after the Host check.
|
|
1247
1276
|
if (url.pathname === "/mcp")
|
|
1248
1277
|
return mcpHttp(request, response);
|
|
1278
|
+
// MCP sign-in (mcp-oauth.ts): its discovery documents, client registration, consent and tokens.
|
|
1279
|
+
if (await oauthHttp(request, response, url))
|
|
1280
|
+
return;
|
|
1249
1281
|
// Back from Google's consent screen (v89). The session cookie is SameSite=Strict and stays behind on
|
|
1250
1282
|
// a return from another site: the visit's one-time state (made by a signed-in approver) is the proof.
|
|
1251
1283
|
if (url.pathname === GOOGLE_CALLBACK && request.method === "GET")
|
|
@@ -2752,11 +2784,26 @@ export function createDecisionServer(options) {
|
|
|
2752
2784
|
return sendScreen(response, 200, screen("mode", [`<h1>Operating mode</h1><p>Authorize this project's automatic approvals once, for up to ${MODE_MAX_DAYS} days. Select the touchpoints below; the signature covers the exact choices. Existing modes keep their original terms.</p><p class="meta">Scope changes, agent questions, and requested revisions still require your attention. Automatic merging also needs a publication grant.</p>`, current, signForm].join("\n"), { chrome: chromeFor(project, "mode") }));
|
|
2753
2785
|
}
|
|
2754
2786
|
if (url.pathname === "/people") {
|
|
2787
|
+
// People → a person: their API tokens and remote activity (people-audit-ui.ts). An approver may open anyone, within
|
|
2788
|
+
// their projects; anyone else only themselves. Another person reads exactly like nobody.
|
|
2789
|
+
const person = url.searchParams.get("person");
|
|
2790
|
+
if (person !== null) {
|
|
2791
|
+
const token = (url.searchParams.get("token-name") ?? "").slice(0, 60) || null, before = url.searchParams.get("before");
|
|
2792
|
+
const cursor = before === null ? null : auditCursorOf(before);
|
|
2793
|
+
if (before !== null && cursor === null)
|
|
2794
|
+
return refuse(response, who, 400, "Invalid activity cursor.", "/people");
|
|
2795
|
+
const page = who.role === "approver" || person === who.name
|
|
2796
|
+
? remoteAudit(store, { self: who.name, everyone: who.role === "approver", repos: admissionList(), unplaced: store.isInstanceOperator(who.name) }, { person, token, source: null, since: null }, { before: cursor })
|
|
2797
|
+
: null;
|
|
2798
|
+
if (page === null || !page.ok)
|
|
2799
|
+
return refuse(response, who, 404, "No such person.", "/people");
|
|
2800
|
+
return sendScreen(response, 200, screen("people", personAuditHtml({ name: person, tokens: store.apiTokens(person), actions: page.actions, token, nextCursor: page.nextCursor, now }), { chrome: chromeFor(project, "people") }));
|
|
2801
|
+
}
|
|
2755
2802
|
// Approvers see everyone (D7's ceiling: every fact already passed
|
|
2756
2803
|
// the process admission); a viewer sees exactly themselves (U3).
|
|
2757
2804
|
if (restricted()) {
|
|
2758
2805
|
const projects = admissionList() ?? [];
|
|
2759
|
-
return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p>`, { chrome: chromeFor(project, "people") }));
|
|
2806
|
+
return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p><p><a href="${escape(personHref(who.name))}">Your remote activity</a></p>`, { chrome: chromeFor(project, "people") }));
|
|
2760
2807
|
}
|
|
2761
2808
|
const approverView = store.isInstanceOperator(who.name);
|
|
2762
2809
|
const accounts = store.accountFacts().filter(one => approverView || one.name === who.name);
|
|
@@ -2787,6 +2834,7 @@ export function createDecisionServer(options) {
|
|
|
2787
2834
|
`<p class="meta">${one.projects === null ? "All projects" : one.projects.length === 0 ? "No project access" : one.projects.map(repo => escape(projectName(repo))).join(", ")}</p>`,
|
|
2788
2835
|
approverView && one.revokedAt === null ? `<details><summary>Edit project access</summary><form method="post" action="/people/projects">${hiddenFields({ csrf, name: one.name })}${accessFields(one.projects)}<label>Your password<input type="password" name="token" autocomplete="current-password" required></label><p class="meta">Changing access signs this person out and ends their derived sessions and modes. Previously approved work stays recorded.</p><button type="submit">Save project access</button></form></details>` : "",
|
|
2789
2836
|
`<p class="meta">${seen === null ? "not signed in right now" : `signed in \u2014 active ${escape(new Date(seen).toISOString().slice(11, 16))} UTC`} \u00b7 joined ${escape(one.addedAt.slice(0, 10))}</p>`,
|
|
2837
|
+
`<p><a href="${escape(personHref(one.name))}">Tokens and remote activity</a></p>`,
|
|
2790
2838
|
attended.length === 0
|
|
2791
2839
|
? ""
|
|
2792
2840
|
: `<p class="row">watching now: ${attended.map(session => `<a href="/t/${escape(session.taskId)}">${escape(session.taskId)}</a>`).join(", ")}</p>`,
|
|
@@ -3065,7 +3113,7 @@ export function createDecisionServer(options) {
|
|
|
3065
3113
|
if (tokenId === null)
|
|
3066
3114
|
return true;
|
|
3067
3115
|
const token = store.apiTokenSecret(tokenId)?.row;
|
|
3068
|
-
return token !== undefined &&
|
|
3116
|
+
return token !== undefined && tokenLive(token, Date.now());
|
|
3069
3117
|
};
|
|
3070
3118
|
// The whole walk it starts from is shared by exports in the same minute; a reader who stops reading for a minute is let go.
|
|
3071
3119
|
const pieces = ledgerExportChunks(store, { from, to }, { repos: access.repos, instance: store.isInstanceOperator(who.name) }, access, who.name, now, evidenceRoot);
|
|
@@ -4034,7 +4082,8 @@ export function createDecisionServer(options) {
|
|
|
4034
4082
|
const everyone = url.searchParams.get("everyone") === "1" && store.isInstanceOperator(who.name);
|
|
4035
4083
|
const here = sessions.hashOf(who.session);
|
|
4036
4084
|
const view = { who: who.name, everyone, canSeeEveryone: store.isInstanceOperator(who.name), now: Date.now(),
|
|
4037
|
-
sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name)
|
|
4085
|
+
sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name),
|
|
4086
|
+
limits: { note: tokenLimitWords.bind(null, store.handle), section: store.isInstanceOperator(who.name) ? requestLimitsHtml(store.handle, store.apiTokens(null), who.session.csrf, Date.now()) : "" } };
|
|
4038
4087
|
return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions & tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
4039
4088
|
}
|
|
4040
4089
|
// Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
|
|
@@ -7472,6 +7521,31 @@ export function createDecisionServer(options) {
|
|
|
7472
7521
|
store.setApprovalRules(repo, next, who.name, now);
|
|
7473
7522
|
return back("said", `Saved. ${rulesSummary(next)}`);
|
|
7474
7523
|
}
|
|
7524
|
+
// v112: request limits for API tokens (request-budget.ts). An instance operator, in a browser, with their password;
|
|
7525
|
+
// never over /api/cli or /mcp (a token never reaches a console form).
|
|
7526
|
+
if (url.pathname === "/settings/request-limits") {
|
|
7527
|
+
const body = readForm(posted, CONSOLE_FORMS.requestLimits);
|
|
7528
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
7529
|
+
return refuse(response, who, 403, "An instance operator sets request limits.", "/settings/sessions");
|
|
7530
|
+
const back = (key, words) => redirect(response, `/settings/sessions?${key}=${encodeURIComponent(words)}`);
|
|
7531
|
+
const target = body.get("target") ?? "";
|
|
7532
|
+
const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
|
|
7533
|
+
if (target !== "*" && token === null)
|
|
7534
|
+
return back("problem", "That token is no longer live. Nothing changed.");
|
|
7535
|
+
if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
|
|
7536
|
+
return back("problem", "Enter your Toolroll password to change request limits.");
|
|
7537
|
+
const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
|
|
7538
|
+
const clear = body.get("action") === "clear";
|
|
7539
|
+
const fields = clear ? { readPerMinute: null, actPerMinute: null, perDay: null }
|
|
7540
|
+
: token === null ? { readPerMinute: read("read-per-minute"), actPerMinute: read("act-per-minute"), perDay: read("per-day") }
|
|
7541
|
+
: { readPerMinute: token.access === "read" ? read("per-minute") : null, actPerMinute: token.access === "act" ? read("per-minute") : null, perDay: read("per-day") };
|
|
7542
|
+
const problem = Object.values(fields).find((value) => value !== null && typeof value === "object");
|
|
7543
|
+
if (problem !== undefined)
|
|
7544
|
+
return back("problem", `${problem.problem} Nothing changed.`);
|
|
7545
|
+
setLimitOverride(store, target, fields, who.name, now);
|
|
7546
|
+
const label = token === null ? "Everyone's tokens" : token.name;
|
|
7547
|
+
return back("said", clear || Object.values(fields).every(value => value === null) ? `${label}: default limits.` : `${label}: limits saved.`);
|
|
7548
|
+
}
|
|
7475
7549
|
if (url.pathname === "/settings/sessions") {
|
|
7476
7550
|
const body = readForm(posted, CONSOLE_FORMS.sessions);
|
|
7477
7551
|
if (who.via !== "cookie")
|
|
@@ -11780,10 +11854,32 @@ export function createDecisionServer(options) {
|
|
|
11780
11854
|
approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
|
|
11781
11855
|
}));
|
|
11782
11856
|
}
|
|
11783
|
-
const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request) !== null,
|
|
11857
|
+
const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
|
|
11858
|
+
resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
|
|
11784
11859
|
enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
|
|
11860
|
+
// MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
|
|
11861
|
+
const oauthHttp = createOAuthHttp({ store, clock,
|
|
11862
|
+
originOf: request => consoleOrigin(request.headers.host),
|
|
11863
|
+
requesterKey: joinSourceOf,
|
|
11864
|
+
session: request => {
|
|
11865
|
+
const who = identify(request, false);
|
|
11866
|
+
if (who === null || who.via !== "cookie")
|
|
11867
|
+
return null;
|
|
11868
|
+
return { name: who.name, role: who.role, csrf: who.session.csrf,
|
|
11869
|
+
sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider", fresh: Date.now() - who.session.sso.at < SSO_FRESH_MS } };
|
|
11870
|
+
},
|
|
11871
|
+
sameSite: request => {
|
|
11872
|
+
const origin = request.headers.origin, referer = request.headers.referer;
|
|
11873
|
+
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
11874
|
+
return named !== null && allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]);
|
|
11875
|
+
},
|
|
11876
|
+
// The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
|
|
11877
|
+
// grant (their role, their projects) is checked beside it.
|
|
11878
|
+
confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
|
|
11879
|
+
projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
|
|
11880
|
+
});
|
|
11785
11881
|
// ---- identity ------------------------------------------------------------
|
|
11786
|
-
function identify(request, touch = true) {
|
|
11882
|
+
function identify(request, touch = true, limited = false) {
|
|
11787
11883
|
// v101: an API token. Wrong ones spend the address's tries like a wrong password; expired or revoked ones, and a removed account's, name no one.
|
|
11788
11884
|
const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
|
|
11789
11885
|
if (presented !== undefined) {
|
|
@@ -11796,11 +11892,18 @@ export function createDecisionServer(options) {
|
|
|
11796
11892
|
signInBudget.failed(source, at);
|
|
11797
11893
|
return null;
|
|
11798
11894
|
}
|
|
11799
|
-
if (
|
|
11895
|
+
if (!tokenLive(kept.row, at))
|
|
11896
|
+
return null;
|
|
11897
|
+
// v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
|
|
11898
|
+
// through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
|
|
11899
|
+
if (kept.row.projects !== null && !limited)
|
|
11800
11900
|
return null;
|
|
11801
11901
|
const account = store.accountOf(kept.row.account);
|
|
11802
11902
|
if (account === null || account.revokedAt !== null)
|
|
11803
11903
|
return null;
|
|
11904
|
+
// v113: an MCP sign-in's token works only at /mcp, while its access secret is fresh (mcp-oauth.ts).
|
|
11905
|
+
if (!oauthTokenAllowed(store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)))
|
|
11906
|
+
return null;
|
|
11804
11907
|
store.touchApiToken(kept.row.id, new Date(at));
|
|
11805
11908
|
return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name };
|
|
11806
11909
|
}
|
|
@@ -12395,6 +12498,10 @@ export function createDecisionServer(options) {
|
|
|
12395
12498
|
const closeServer = server.close.bind(server);
|
|
12396
12499
|
server.close = ((callback) => {
|
|
12397
12500
|
leadClosing = true;
|
|
12501
|
+
try {
|
|
12502
|
+
requestBudget.flush();
|
|
12503
|
+
}
|
|
12504
|
+
catch { /* saved again on the close event when it can be */ }
|
|
12398
12505
|
for (const stream of teamStreams)
|
|
12399
12506
|
stream.end();
|
|
12400
12507
|
teamStreams.clear();
|
|
@@ -16304,7 +16411,7 @@ const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;o
|
|
|
16304
16411
|
'.inbox-ask{margin:18px 0 0}.inbox-ask>h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}.inbox-ask>h2 .count{font:500 12px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;color:var(--so-muted)}.inbox-ask h3{font-size:13px;font-weight:600;margin:12px 0 4px}' +
|
|
16305
16412
|
'.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
|
|
16306
16413
|
'@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
|
|
16307
|
-
const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
|
|
16414
|
+
const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
|
|
16308
16415
|
/**
|
|
16309
16416
|
* The board's liveness: fetch this page's own fragment on a timer and swap
|
|
16310
16417
|
* it in place — no flicker, no scroll reset, no long-lived stream to manage,
|
|
@@ -20132,7 +20239,7 @@ function safeReturn(raw) {
|
|
|
20132
20239
|
* button can name. Nothing here is a second redirect framework: one
|
|
20133
20240
|
* same-site path, or "/".
|
|
20134
20241
|
*/
|
|
20135
|
-
const LOGIN_RETURN_PAGES = /^\/(t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
|
|
20242
|
+
const LOGIN_RETURN_PAGES = /^\/(oauth\/authorize|t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
|
|
20136
20243
|
function loginReturn(raw) {
|
|
20137
20244
|
const safe = safeReturn(raw);
|
|
20138
20245
|
if (safe === "/")
|
package/dist/store.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { DecisionOption } from "./contracts/decision.js";
|
|
|
2
2
|
import { type SavedToolAction, type SavedToolRule } from "./contracts/store-json.js";
|
|
3
3
|
import type { FlowCardChange, FlowCardFields } from "./contracts/flow-card.js";
|
|
4
4
|
import { type Actor } from "./actor.js";
|
|
5
|
+
import { type TokenPurpose } from "./api-tokens.js";
|
|
5
6
|
import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
|
|
6
7
|
import { type ExecutionProfile, type ChainEntry, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
|
|
7
8
|
import { type ProviderRoom, type CandidateTier, type TaskSize, type TaskSizing, type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RiskLevel, type RouteOverride } from "./phase-routing.js";
|
|
@@ -145,7 +146,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
145
146
|
hash: string;
|
|
146
147
|
} | null;
|
|
147
148
|
};
|
|
148
|
-
export declare const SCHEMA_VERSION =
|
|
149
|
+
export declare const SCHEMA_VERSION = 113;
|
|
149
150
|
/**
|
|
150
151
|
* The migrations `toolroll update` may carry a database through in place: each
|
|
151
152
|
* one only adds (a table, a column, an accepted value) and leaves every saved row
|
|
@@ -155,11 +156,43 @@ export declare const SCHEMA_VERSION = 110;
|
|
|
155
156
|
* migrates and does not read this list.
|
|
156
157
|
*
|
|
157
158
|
* v110 rebuilds action_ledger only to widen its source check ('api', 'mcp'):
|
|
158
|
-
* every row, id and hash-chain link is copied unchanged.
|
|
159
|
+
* every row, id and hash-chain link is copied unchanged. v111 adds nullable
|
|
160
|
+
* api_token columns (project limit, rotation); existing tokens read as before.
|
|
161
|
+
* v112 only adds the two request-budget tables.
|
|
162
|
+
* v113 adds MCP sign-in tables and API-token purpose metadata.
|
|
159
163
|
*/
|
|
160
164
|
export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
|
|
161
165
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
162
166
|
export declare function updateSafeSchema(version: number | null): boolean;
|
|
167
|
+
export type OAuthClientRow = {
|
|
168
|
+
id: string;
|
|
169
|
+
name: string;
|
|
170
|
+
redirectUris: string[];
|
|
171
|
+
createdAt: string;
|
|
172
|
+
};
|
|
173
|
+
export type OAuthCodeRow = {
|
|
174
|
+
client: string;
|
|
175
|
+
account: string;
|
|
176
|
+
generation: number;
|
|
177
|
+
access: "read" | "act";
|
|
178
|
+
projects: string[];
|
|
179
|
+
redirectUri: string;
|
|
180
|
+
resource: string;
|
|
181
|
+
challenge: string;
|
|
182
|
+
expiresAt: string;
|
|
183
|
+
usedAt: string | null;
|
|
184
|
+
token: string | null;
|
|
185
|
+
};
|
|
186
|
+
export type OAuthGrantRow = {
|
|
187
|
+
token: string;
|
|
188
|
+
client: string;
|
|
189
|
+
account: string;
|
|
190
|
+
generation: number;
|
|
191
|
+
projects: string[];
|
|
192
|
+
resource: string;
|
|
193
|
+
accessExpiresAt: string;
|
|
194
|
+
refreshHash: string;
|
|
195
|
+
};
|
|
163
196
|
export type ApiTokenRow = {
|
|
164
197
|
id: string;
|
|
165
198
|
account: string;
|
|
@@ -171,6 +204,14 @@ export type ApiTokenRow = {
|
|
|
171
204
|
lastUsedAt: string | null;
|
|
172
205
|
revokedAt: string | null;
|
|
173
206
|
revokedBy: string | null;
|
|
207
|
+
/** v113: MCP manages its own access-secret rotation. */
|
|
208
|
+
purpose: TokenPurpose;
|
|
209
|
+
/** v111: the projects it is limited to (null: every project its person may use). */
|
|
210
|
+
projects: string[] | null;
|
|
211
|
+
/** v111: the token it replaced, the one that replaced it, and when this one stops after being replaced. */
|
|
212
|
+
replaces: string | null;
|
|
213
|
+
replacedBy: string | null;
|
|
214
|
+
overlapUntil: string | null;
|
|
174
215
|
};
|
|
175
216
|
export type WebSessionRow = {
|
|
176
217
|
idHash: string;
|
|
@@ -2591,7 +2632,26 @@ export declare class Store {
|
|
|
2591
2632
|
access: "read" | "act";
|
|
2592
2633
|
expiresAt: string;
|
|
2593
2634
|
by: string;
|
|
2635
|
+
projects?: readonly string[] | null;
|
|
2636
|
+
purpose?: TokenPurpose;
|
|
2594
2637
|
}, now: Date): void;
|
|
2638
|
+
/**
|
|
2639
|
+
* v111: replace a live, not-yet-replaced token with a new secret on the same terms (access, projects, end date, so
|
|
2640
|
+
* rotating never extends it). The old one keeps working until `overlapUntil` (never past its own end), then stops;
|
|
2641
|
+
* every check reads that time, and endRotatedApiTokens records the end afterwards. Only the token's own person.
|
|
2642
|
+
*/
|
|
2643
|
+
rotateApiToken(oldId: string, replacement: {
|
|
2644
|
+
id: string;
|
|
2645
|
+
secretHash: string;
|
|
2646
|
+
}, by: string, now: Date, overlapMs: number): {
|
|
2647
|
+
ok: true;
|
|
2648
|
+
row: ApiTokenRow;
|
|
2649
|
+
} | {
|
|
2650
|
+
ok: false;
|
|
2651
|
+
reason: "unknown" | "replaced" | "mcp-managed";
|
|
2652
|
+
};
|
|
2653
|
+
/** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
|
|
2654
|
+
endRotatedApiTokens(now: Date): number;
|
|
2595
2655
|
/** A token by id with its kept hash (for the check), or null. */
|
|
2596
2656
|
apiTokenSecret(id: string): {
|
|
2597
2657
|
row: ApiTokenRow;
|
|
@@ -2602,6 +2662,62 @@ export declare class Store {
|
|
|
2602
2662
|
/** Kept at most once a minute: a busy script doesn't write on every request. */
|
|
2603
2663
|
touchApiToken(id: string, now: Date): void;
|
|
2604
2664
|
revokeApiToken(id: string, by: string, now: Date, why?: string): boolean;
|
|
2665
|
+
/** A registered client, or null. */
|
|
2666
|
+
oauthClient(id: string): OAuthClientRow | null;
|
|
2667
|
+
/** Expire unused registrations, retaining clients with a live code, grant or in-process consent. */
|
|
2668
|
+
pruneOAuthClients(now: Date, pending?: readonly string[]): void;
|
|
2669
|
+
/**
|
|
2670
|
+
* Unused registrations expire after a day. Admission has a global and per-source bound; it never evicts a live
|
|
2671
|
+
* registration. Repeating metadata from the same source returns its existing registration without spending a slot.
|
|
2672
|
+
*/
|
|
2673
|
+
registerOAuthClient(client: {
|
|
2674
|
+
id: string;
|
|
2675
|
+
name: string;
|
|
2676
|
+
redirectUris: string[];
|
|
2677
|
+
source: string;
|
|
2678
|
+
}, now: Date, cap: number, perSourceCap: number, pending?: readonly string[]): OAuthClientRow | null;
|
|
2679
|
+
/** Keep a one-time code by its hash. */
|
|
2680
|
+
saveOAuthCode(hash: string, code: Omit<OAuthCodeRow, "usedAt" | "token">): void;
|
|
2681
|
+
/**
|
|
2682
|
+
* Spend a code: the first caller gets it (`taken`), once, before it expires. A code spent before answers `used`
|
|
2683
|
+
* with the token it made, so that token can be ended; anything else is null.
|
|
2684
|
+
*/
|
|
2685
|
+
takeOAuthCode(hash: string, now: Date): {
|
|
2686
|
+
taken: OAuthCodeRow;
|
|
2687
|
+
} | {
|
|
2688
|
+
used: string | null;
|
|
2689
|
+
} | null;
|
|
2690
|
+
/** Make a grant: its API token (created and recorded as any other) and its binding, together; the code notes the token. */
|
|
2691
|
+
createOAuthGrant(code: string, token: {
|
|
2692
|
+
id: string;
|
|
2693
|
+
account: string;
|
|
2694
|
+
name: string;
|
|
2695
|
+
secretHash: string;
|
|
2696
|
+
access: "read" | "act";
|
|
2697
|
+
expiresAt: string;
|
|
2698
|
+
}, grant: Omit<OAuthGrantRow, "token">, now: Date): void;
|
|
2699
|
+
/** The grant an API token signs in for, or null when the token is an ordinary one. */
|
|
2700
|
+
oauthGrant(token: string): OAuthGrantRow | null;
|
|
2701
|
+
/**
|
|
2702
|
+
* Classify, validate and rotate under one write transaction. Every issued hash remains in the family, so any
|
|
2703
|
+
* older secret revokes even a concurrent winner. An unknown guess or the wrong client cannot end the family.
|
|
2704
|
+
*/
|
|
2705
|
+
rotateOAuthGrant(token: string, ask: {
|
|
2706
|
+
client: string;
|
|
2707
|
+
resource: string;
|
|
2708
|
+
refreshHash: string;
|
|
2709
|
+
scope?: string;
|
|
2710
|
+
}, next: {
|
|
2711
|
+
refreshHash: string;
|
|
2712
|
+
accessHash: string;
|
|
2713
|
+
accessExpiresAt: string;
|
|
2714
|
+
}, now: Date, projectsFor: (account: string) => readonly string[]): {
|
|
2715
|
+
ok: true;
|
|
2716
|
+
access: "read" | "act";
|
|
2717
|
+
} | {
|
|
2718
|
+
ok: false;
|
|
2719
|
+
reason: "invalid_grant" | "invalid_scope";
|
|
2720
|
+
};
|
|
2605
2721
|
saveWebSession(session: WebSessionRow): void;
|
|
2606
2722
|
webSession(idHash: string): WebSessionRow | null;
|
|
2607
2723
|
webSessions(account: string | null): WebSessionRow[];
|