toolroll 0.9.48 → 0.9.49
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 +9 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/mcp-http.d.ts +1 -0
- package/dist/mcp-http.js +6 -4
- package/dist/mcp-oauth.d.ts +3 -0
- package/dist/mcp-oauth.js +8 -0
- package/dist/public-access.d.ts +3 -1
- package/dist/public-access.js +5 -1
- package/dist/request-budget.d.ts +30 -3
- package/dist/request-budget.js +44 -1
- package/dist/serve.js +185 -96
- package/dist/session-http.d.ts +5 -0
- package/dist/session-http.js +13 -0
- package/dist/session-server.d.ts +5 -0
- package/dist/session-server.js +2 -0
- package/dist/team-cli.js +28 -6
- package/dist/team-contract.d.ts +7 -0
- package/dist/team-contract.js +7 -0
- package/dist/team-http.d.ts +5 -0
- package/dist/team-http.js +19 -3
- package/dist/team-leads.d.ts +5 -1
- package/dist/team-leads.js +42 -4
- package/dist/team-runtime.d.ts +1 -1
- package/dist/team-runtime.js +3 -0
- package/dist/teams.d.ts +3 -0
- package/dist/teams.js +15 -0
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -127,8 +127,8 @@ import { projectAuthority } from "./project-access.js";
|
|
|
127
127
|
* centralized gate — `authorizeMutation` — that proves content type, an
|
|
128
128
|
* allowed Origin, and the per-session CSRF nonce, and refuses duplicated
|
|
129
129
|
* security fields; a mutation route cannot forget a check it never wrote.
|
|
130
|
-
* Bearer mutations carry no cookie for a hostile page to ride, so they skip
|
|
131
|
-
*
|
|
130
|
+
* Bearer mutations carry no cookie for a hostile page to ride, so they skip CSRF.
|
|
131
|
+
* Step-up ceremonies still require a cookie session; repeating a bearer password cannot approve.
|
|
132
132
|
*
|
|
133
133
|
* **Approval is step-up.** A session alone never approves a scope: the
|
|
134
134
|
* approval form restates the goal, the exclusions, and the touches — the
|
|
@@ -268,9 +268,10 @@ import { targetOf } from "./monitoring.js";
|
|
|
268
268
|
import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
|
|
269
269
|
import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS, tokenLive, tokenProjects } from "./api-tokens.js";
|
|
270
270
|
import { createMcpHttp } from "./mcp-http.js";
|
|
271
|
-
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";
|
|
271
|
+
import { FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, limitWords, parseLimit, PER_DAY_MAX as REQUEST_BUDGET_PER_DAY_MAX, PER_MINUTE_MAX as REQUEST_BUDGET_PER_MINUTE_MAX, RequestBudget, setLimitOverride, SOURCE_BUDGET_DEFAULTS, SourceAdmission } from "./request-budget.js";
|
|
272
|
+
import { REMOTE_MESSAGES, reproveRemote } from "./operate-remote.js";
|
|
272
273
|
import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
|
|
273
|
-
import { createOAuthHttp, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
|
|
274
|
+
import { createOAuthHttp, oauthProjects, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
|
|
274
275
|
import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
|
|
275
276
|
import { REQUEST_LIMITS_CSS, requestLimitsHtml, tokenLimitWords } from "./request-budget-ui.js";
|
|
276
277
|
import { PEOPLE_AUDIT_CSS, personAuditHtml, personHref } from "./people-audit-ui.js";
|
|
@@ -568,7 +569,12 @@ export function createDecisionServer(options) {
|
|
|
568
569
|
* access to the project) is checked either way.
|
|
569
570
|
*/
|
|
570
571
|
// v100: the shared check honours a fresh identity-provider sign-in itself (freshIdentitySignIn, set per request below).
|
|
571
|
-
|
|
572
|
+
function authenticateApprover(who, token, repo) {
|
|
573
|
+
// Re-entering a password never upgrades a machine credential into a browser ceremony.
|
|
574
|
+
if (who.via !== "cookie")
|
|
575
|
+
return { ok: false, reason: "not-an-approver" };
|
|
576
|
+
return checkApproverPassword(store, who.name, token, repo);
|
|
577
|
+
}
|
|
572
578
|
const signInBudget = new SourceBudget();
|
|
573
579
|
const ssoVisits = new Map();
|
|
574
580
|
const ssoHandoffs = new Map();
|
|
@@ -782,17 +788,59 @@ export function createDecisionServer(options) {
|
|
|
782
788
|
return { ...summary, card: teamProposalCardParts(store, actor, proposal, snapshot, csrf, decision, clock(), teamChatProvider).card };
|
|
783
789
|
}) } };
|
|
784
790
|
};
|
|
791
|
+
// v112: one request budget per server for person API tokens, shared by every route a token signs in at (request-budget.ts).
|
|
792
|
+
const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
|
|
793
|
+
// Unproved password attempts share a source budget. Only successful proof spends an account budget.
|
|
794
|
+
const sourceClock = options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock };
|
|
795
|
+
const passwordSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
|
|
796
|
+
const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
|
|
797
|
+
const teamsSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsSource, ...sourceClock });
|
|
798
|
+
const teamsTenantBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsTenant, ...sourceClock });
|
|
799
|
+
const oauthTokenBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.oauthToken, ...sourceClock });
|
|
800
|
+
const presentsPassword = (request) => /^Bearer (.+):(.+)$/.test(request.headers.authorization ?? "");
|
|
801
|
+
const admitPasswordSource = (request) => presentsPassword(request) ? passwordSourceBudget.admit(joinSourceOf(request)) : { ok: true };
|
|
802
|
+
/** Called only with a proved identity; cookies spend neither bearer budget. */
|
|
803
|
+
const admitBearer = (request, actor) => {
|
|
804
|
+
if (actor.principal !== undefined)
|
|
805
|
+
return requestBudget.admit(actor.principal.tokenId, "api");
|
|
806
|
+
return presentsPassword(request) ? passwordAccountBudget.admit(actor.name) : { ok: true };
|
|
807
|
+
};
|
|
808
|
+
/**
|
|
809
|
+
* The full principal a live `so_` API token stands for: its person and their generation, the token's own read/act
|
|
810
|
+
* scope, its project limit narrowed by the person's current access, and its id. Every route that accepts an API token
|
|
811
|
+
* builds its identity here and carries it whole, so no route can drop the token's narrower scope or projects.
|
|
812
|
+
*/
|
|
813
|
+
const tokenPrincipalOf = (request) => {
|
|
814
|
+
const who = identify(request, false, true);
|
|
815
|
+
return who?.via === "bearer" ? who.principal ?? null : null;
|
|
816
|
+
};
|
|
817
|
+
const presentsToken = (request) => /^Bearer so_\S+$/.test(request.headers.authorization ?? "");
|
|
785
818
|
const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
|
|
786
819
|
authenticate: request => {
|
|
820
|
+
if (presentsToken(request)) {
|
|
821
|
+
const principal = tokenPrincipalOf(request);
|
|
822
|
+
return principal === null ? null : { name: principal.account, generation: principal.generation, principal };
|
|
823
|
+
}
|
|
787
824
|
const who = identify(request, request.method === 'POST');
|
|
788
825
|
const account = who === null ? null : store.accountOf(who.name);
|
|
789
826
|
return who && account && account.revokedAt === null ? { name: who.name, generation: account.generation } : null;
|
|
790
827
|
},
|
|
828
|
+
admit: admitPasswordSource,
|
|
829
|
+
admitAuthenticated: admitBearer,
|
|
791
830
|
revalidate: (request, actor) => {
|
|
792
831
|
const account = store.accountOf(actor.name);
|
|
793
832
|
if (!account || account.revokedAt !== null || account.generation !== actor.generation)
|
|
794
833
|
return false;
|
|
795
|
-
//
|
|
834
|
+
// An API token is re-proved against the store every time: unrevoked, unexpired, inside its project limit.
|
|
835
|
+
if (actor.principal !== undefined) {
|
|
836
|
+
const live = reproveRemote(store, actor.principal, new Date());
|
|
837
|
+
if (!live.ok)
|
|
838
|
+
return false;
|
|
839
|
+
// The post-body mutation check must see a scope lowered since authentication, too.
|
|
840
|
+
actor.principal = { ...actor.principal, scope: live.scope };
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
// A password bearer was proved when this connection opened. Cookie expiry/revocation
|
|
796
844
|
// is rechecked without allowing a passive stream to extend its lifetime.
|
|
797
845
|
if (request.headers.authorization)
|
|
798
846
|
return true;
|
|
@@ -800,6 +848,8 @@ export function createDecisionServer(options) {
|
|
|
800
848
|
return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
|
|
801
849
|
},
|
|
802
850
|
authorizeMutation: (request, actor) => {
|
|
851
|
+
if (actor.principal !== undefined)
|
|
852
|
+
return request.headers.origin === undefined;
|
|
803
853
|
const who = identify(request, false);
|
|
804
854
|
if (!who || who.name !== actor.name)
|
|
805
855
|
return false;
|
|
@@ -813,29 +863,22 @@ export function createDecisionServer(options) {
|
|
|
813
863
|
},
|
|
814
864
|
execute: async (actor, input) => {
|
|
815
865
|
const reply = await team.execute(actor, input);
|
|
866
|
+
if (actor.principal !== undefined)
|
|
867
|
+
return reply;
|
|
816
868
|
const who = identify(request, false);
|
|
817
869
|
return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
|
|
818
870
|
}, cursor: team.cursor, streams: teamStreams,
|
|
819
871
|
});
|
|
820
|
-
// v112: one request budget per server for person API tokens, shared by /api/cli and /mcp (request-budget.ts).
|
|
821
|
-
const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
|
|
822
872
|
// Remote CLI: one live API token names the person; the shared command boundary decides everything else.
|
|
823
873
|
const cliEndpoint = (request, response) => handleCliHttp(request, response, {
|
|
824
874
|
admit: principal => requestBudget.admit(principal.tokenId, "api"),
|
|
825
|
-
authenticate:
|
|
826
|
-
const who = identify(request, false, true);
|
|
827
|
-
const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
|
|
828
|
-
const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
|
|
829
|
-
const account = who === null ? null : store.accountOf(who.name);
|
|
830
|
-
if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
|
|
831
|
-
return null;
|
|
832
|
-
return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: tokenProjects(account.projects, row.projects) };
|
|
833
|
-
},
|
|
875
|
+
authenticate: tokenPrincipalOf,
|
|
834
876
|
run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
|
|
835
877
|
modeOf: options.cliModeOf,
|
|
836
878
|
store,
|
|
837
879
|
});
|
|
838
|
-
const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo)
|
|
880
|
+
const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo),
|
|
881
|
+
admit: admitPasswordSource, admitAuthenticated: admitBearer });
|
|
839
882
|
/** The enumerable admission list for roll-up SQL: repos-only ceilings
|
|
840
883
|
* enumerate themselves; root ceilings enumerate the STORED repos that
|
|
841
884
|
* pass the ceiling (Codex roll-up review, finding 11); unscoped = null. */
|
|
@@ -945,8 +988,18 @@ export function createDecisionServer(options) {
|
|
|
945
988
|
* it is a smuggling attempt.
|
|
946
989
|
*/
|
|
947
990
|
function authorizeMutation(request, who, body) {
|
|
991
|
+
// A read token never mutates, whatever its person's role (operate-remote.ts says the same to the remote CLI).
|
|
992
|
+
if (who.via === "bearer") {
|
|
993
|
+
if (who.principal !== undefined) {
|
|
994
|
+
const live = reproveRemote(store, who.principal, new Date());
|
|
995
|
+
if (!live.ok)
|
|
996
|
+
return { status: 403, message: REMOTE_MESSAGES.stale };
|
|
997
|
+
if (live.scope !== "act")
|
|
998
|
+
return { status: 403, message: REMOTE_MESSAGES.read };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
948
1001
|
const current = store.accountOf(who.name);
|
|
949
|
-
if (current === null || current.revokedAt !== null || current.role !== who.role || (who.via === "cookie"
|
|
1002
|
+
if (current === null || current.revokedAt !== null || current.role !== who.role || current.generation !== (who.via === "cookie" ? who.session.generation : who.generation)) {
|
|
950
1003
|
return { status: 403, message: "Your access changed. Sign in again." };
|
|
951
1004
|
}
|
|
952
1005
|
const type = request.headers["content-type"] ?? "";
|
|
@@ -1308,7 +1361,8 @@ export function createDecisionServer(options) {
|
|
|
1308
1361
|
return;
|
|
1309
1362
|
if (await cliEndpoint(request, response))
|
|
1310
1363
|
return;
|
|
1311
|
-
if (options.configDir !== undefined && await handleTeamsHttp(request, response, { store, dir: options.configDir, ...(options.teamsFetcher ? { fetcher: options.teamsFetcher } : {}), clock
|
|
1364
|
+
if (options.configDir !== undefined && await handleTeamsHttp(request, response, { store, dir: options.configDir, ...(options.teamsFetcher ? { fetcher: options.teamsFetcher } : {}), clock,
|
|
1365
|
+
admitSource: request => teamsSourceBudget.admit(joinSourceOf(request)), admitTenant: tenant => teamsTenantBudget.admit(tenant) }))
|
|
1312
1366
|
return;
|
|
1313
1367
|
if (serveBrowserAsset(request, response, url.pathname))
|
|
1314
1368
|
return;
|
|
@@ -1364,7 +1418,30 @@ export function createDecisionServer(options) {
|
|
|
1364
1418
|
const fragmentName = url.searchParams.get("fragment");
|
|
1365
1419
|
const workspaceRead = method === "GET" && url.searchParams.get('format') === 'workspace';
|
|
1366
1420
|
const touch = !(method === "GET" && (workspaceRead || (fragmentName !== null && NO_TOUCH_FRAGMENTS.has(fragmentName)) || /^\/code\/[a-f0-9]{32}\/state$/.test(url.pathname)));
|
|
1367
|
-
const
|
|
1421
|
+
const refuseBudget = (admitted) => {
|
|
1422
|
+
if (admitted.ok)
|
|
1423
|
+
return false;
|
|
1424
|
+
request.resume();
|
|
1425
|
+
if (admitted.status === 429)
|
|
1426
|
+
response.setHeader("Retry-After", String(admitted.retryAfter));
|
|
1427
|
+
respond(response, admitted.status, "text/plain; charset=utf-8", admitted.status === 429 ? limitWords(admitted.limit, admitted.retryAfter) : "Request limits could not be checked; nothing ran. Try again shortly.");
|
|
1428
|
+
return true;
|
|
1429
|
+
};
|
|
1430
|
+
// One source charge before password proof and one account/token charge after it, including GET /login.
|
|
1431
|
+
if (refuseBudget(admitPasswordSource(request)))
|
|
1432
|
+
return;
|
|
1433
|
+
const refusedToken = {};
|
|
1434
|
+
const who = identify(request, touch, false, refusedToken);
|
|
1435
|
+
if (refusedToken.principal !== undefined) {
|
|
1436
|
+
if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
|
|
1437
|
+
return;
|
|
1438
|
+
if (method === "POST") {
|
|
1439
|
+
request.resume();
|
|
1440
|
+
return respond(response, 403, "text/plain; charset=utf-8", REMOTE_MESSAGES["step-up"]);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (who?.via === "bearer" && refuseBudget(admitBearer(request, who)))
|
|
1444
|
+
return;
|
|
1368
1445
|
if (url.pathname === "/login" && method === "GET") {
|
|
1369
1446
|
if (options.setupCode !== undefined && store.listApprovers().length === 0) {
|
|
1370
1447
|
return page(response, 200, signupPage(null, setupAttemptsLeft));
|
|
@@ -6635,15 +6712,15 @@ export function createDecisionServer(options) {
|
|
|
6635
6712
|
}
|
|
6636
6713
|
switch (verb) {
|
|
6637
6714
|
case "approve": {
|
|
6715
|
+
if (who.via !== "cookie")
|
|
6716
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
6638
6717
|
// Step-up, identical to a scope's: the session got you here; only
|
|
6639
6718
|
// the password agrees. The digest names what was seen.
|
|
6640
6719
|
const digest = body.get("digest") ?? "";
|
|
6641
6720
|
const token = body.get("token") ?? "";
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
return routinePage(response, who, routineId, "that approval form is stale — read it again", 409);
|
|
6646
|
-
}
|
|
6721
|
+
const nonce = body.get("nonce") ?? "";
|
|
6722
|
+
if (!consumeApprovalNonce(nonce, who.name, `routine:${routine.id}`, digest)) {
|
|
6723
|
+
return routinePage(response, who, routineId, "that approval form is stale — read it again", 409);
|
|
6647
6724
|
}
|
|
6648
6725
|
if (token === "" && !hasFreshIdentitySignIn(who.name)) {
|
|
6649
6726
|
return routinePage(response, who, routineId, "approval requires your password, typed again", 400);
|
|
@@ -6677,19 +6754,18 @@ export function createDecisionServer(options) {
|
|
|
6677
6754
|
return redirect(response, `/routines/${routineId}`);
|
|
6678
6755
|
}
|
|
6679
6756
|
case "run-now": {
|
|
6757
|
+
if (who.via !== "cookie")
|
|
6758
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
6680
6759
|
// Step-up (Codex Phase C review, M3): run-now is spend outside the
|
|
6681
6760
|
// approved schedule, so a session alone cannot ask for it — the
|
|
6682
|
-
// password is typed again, like an approval.
|
|
6683
|
-
|
|
6684
|
-
if (
|
|
6685
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
if (!authenticated.ok) {
|
|
6691
|
-
return routinePage(response, who, routineId, "that is not your password", 403);
|
|
6692
|
-
}
|
|
6761
|
+
// password is typed again, like an approval.
|
|
6762
|
+
const token = body.get("token") ?? "";
|
|
6763
|
+
if (token === "" && !hasFreshIdentitySignIn(who.name)) {
|
|
6764
|
+
return routinePage(response, who, routineId, "run now requires your password, typed again", 400);
|
|
6765
|
+
}
|
|
6766
|
+
const authenticated = authenticateApprover(who, token);
|
|
6767
|
+
if (!authenticated.ok) {
|
|
6768
|
+
return routinePage(response, who, routineId, "that is not your password", 403);
|
|
6693
6769
|
}
|
|
6694
6770
|
const outcome = fireRoutine(store, routineId, now, { manual: true });
|
|
6695
6771
|
if (!outcome.ok) {
|
|
@@ -6876,7 +6952,7 @@ export function createDecisionServer(options) {
|
|
|
6876
6952
|
const repo = body.get('repo') ?? '';
|
|
6877
6953
|
if (!permitted(repo))
|
|
6878
6954
|
return fail(403, 'Choose a project available to your account.');
|
|
6879
|
-
if (!authenticateApprover(
|
|
6955
|
+
if (!authenticateApprover(who, body.get('password') ?? '').ok)
|
|
6880
6956
|
return fail(403, 'Enter your Toolroll password to authorize this coding session.');
|
|
6881
6957
|
const session = await coding.start(actor, { repo, title: body.get('title') ?? '', model: body.get('model')?.trim() || null, prompt: body.get('prompt') ?? '', requestId: body.get('requestId') ?? '' });
|
|
6882
6958
|
id = session.id;
|
|
@@ -6911,7 +6987,7 @@ export function createDecisionServer(options) {
|
|
|
6911
6987
|
const decision = body.get('decision');
|
|
6912
6988
|
if (decision !== 'accept' && decision !== 'decline' && decision !== 'cancel')
|
|
6913
6989
|
return fail(400, 'Choose an explicit decision for this request.');
|
|
6914
|
-
if (pending.kind !== 'questions' && decision === 'accept' && !authenticateApprover(
|
|
6990
|
+
if (pending.kind !== 'questions' && decision === 'accept' && !authenticateApprover(who, body.get('password') ?? '').ok)
|
|
6915
6991
|
return fail(403, 'Enter your password to approve this additional access.');
|
|
6916
6992
|
const answers = body.has('answers') ? JSON.parse(body.get('answers')) : Object.fromEntries(pending.questions.map(q => [q.id, { answers: [body.get(`question:${q.id}`) ?? ''] }]));
|
|
6917
6993
|
coding.answer(id, actor, token, decision, answers);
|
|
@@ -7328,7 +7404,7 @@ export function createDecisionServer(options) {
|
|
|
7328
7404
|
return renewed.ok ? settle(renewed.said, { reveal: renewed.reveal }) : answer(409, { ok: false, said: renewed.message });
|
|
7329
7405
|
}
|
|
7330
7406
|
// Linear's signing secret, pasted on this secure panel behind the password — never in chat.
|
|
7331
|
-
if (!authenticateApprover(
|
|
7407
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7332
7408
|
return answer(403, { ok: false, said: "Enter your Toolroll password to save a secret." });
|
|
7333
7409
|
const saved = saveLinearSigningSecret(trigger, body.get("secret") ?? "", dir);
|
|
7334
7410
|
return saved.ok ? settle("Signing secret saved. Linear's deliveries can be proved now.") : answer(400, { ok: false, said: saved.message });
|
|
@@ -7366,7 +7442,7 @@ export function createDecisionServer(options) {
|
|
|
7366
7442
|
removeLinearKey(dir);
|
|
7367
7443
|
return settle("Linear key removed.");
|
|
7368
7444
|
}
|
|
7369
|
-
if (!authenticateApprover(
|
|
7445
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7370
7446
|
return answer(403, { ok: false, said: "Enter your Toolroll password to save a key." });
|
|
7371
7447
|
const saved = saveLinearKey(dir, body.get("key") ?? "");
|
|
7372
7448
|
return saved.ok ? settle("Linear key saved on this computer.") : answer(400, { ok: false, said: saved.message });
|
|
@@ -7466,7 +7542,7 @@ export function createDecisionServer(options) {
|
|
|
7466
7542
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
7467
7543
|
return refuse(response, who, 403, "An instance operator sets budgets.", "/spend");
|
|
7468
7544
|
const back = (key, words) => redirect(response, `/spend?${key}=${encodeURIComponent(words)}`);
|
|
7469
|
-
if (!authenticateApprover(
|
|
7545
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7470
7546
|
return back("problem", "Enter your Toolroll password to change a budget.");
|
|
7471
7547
|
const target = /^(installation|project|person|teammate):(.+)$/.exec(body.get("target") ?? "");
|
|
7472
7548
|
if (target === null)
|
|
@@ -7530,7 +7606,7 @@ export function createDecisionServer(options) {
|
|
|
7530
7606
|
return back("problem", `Nothing was deleted: ${view.running.join(", ")}. Stop it, then try again.`);
|
|
7531
7607
|
if (body.get("step") !== "delete")
|
|
7532
7608
|
return confirm(null);
|
|
7533
|
-
if (!authenticateApprover(
|
|
7609
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7534
7610
|
return confirm("That password didn't match. Nothing was deleted.");
|
|
7535
7611
|
const done = await deleteProject(store, repo, { actor: who.name, via: "console", now, evidenceRoot, poolRoot: options.poolRoot ?? null });
|
|
7536
7612
|
if (!done.ok)
|
|
@@ -7559,7 +7635,7 @@ export function createDecisionServer(options) {
|
|
|
7559
7635
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
7560
7636
|
return refuse(response, who, 403, "An instance operator sets the policy.", "/settings/policy");
|
|
7561
7637
|
const back = (key, words) => redirect(response, `/settings/policy?${key}=${encodeURIComponent(words)}`);
|
|
7562
|
-
if (!authenticateApprover(
|
|
7638
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7563
7639
|
return back("problem", "That password didn't match. Nothing changed.");
|
|
7564
7640
|
const chosen = body.getAll("provider");
|
|
7565
7641
|
const checked = checkPolicy({ providers: chosen.length === 4 ? null : chosen, models: parseList(body.get("models")), tools: parseList(body.get("tools")), ceiling: body.get("ceiling") ?? "" });
|
|
@@ -7580,7 +7656,7 @@ export function createDecisionServer(options) {
|
|
|
7580
7656
|
if (!known.includes(repo))
|
|
7581
7657
|
return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
|
|
7582
7658
|
const back = (key, words) => redirect(response, `/settings/approval?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7583
|
-
if (!authenticateApprover(
|
|
7659
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7584
7660
|
return back("problem", "That password didn't match. Nothing changed.");
|
|
7585
7661
|
const protect = body.get("protect") ?? "none";
|
|
7586
7662
|
const paths = parseProtectedPaths(body.get("paths") ?? "");
|
|
@@ -7603,7 +7679,7 @@ export function createDecisionServer(options) {
|
|
|
7603
7679
|
const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
|
|
7604
7680
|
if (target !== "*" && token === null)
|
|
7605
7681
|
return back("problem", "That token is no longer live. Nothing changed.");
|
|
7606
|
-
if (!authenticateApprover(
|
|
7682
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7607
7683
|
return back("problem", "Enter your Toolroll password to change request limits.");
|
|
7608
7684
|
const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
|
|
7609
7685
|
const clear = body.get("action") === "clear";
|
|
@@ -7664,7 +7740,7 @@ export function createDecisionServer(options) {
|
|
|
7664
7740
|
return back("problem", "A viewer's token can only read.");
|
|
7665
7741
|
// A token is a credential: made with the person's password (or their identity provider's check from moments ago), counted once.
|
|
7666
7742
|
const typed = body.get("password") ?? "";
|
|
7667
|
-
const confirmed = who.role === "approver" ? authenticateApprover(
|
|
7743
|
+
const confirmed = who.role === "approver" ? authenticateApprover(who, typed).ok
|
|
7668
7744
|
: typed === "" ? requestContext.getStore()?.sso?.fresh === true : authenticateAccount(store, who.name, typed).ok;
|
|
7669
7745
|
if (!confirmed)
|
|
7670
7746
|
return back("problem", "Enter your Toolroll password to make a token.");
|
|
@@ -7690,7 +7766,7 @@ export function createDecisionServer(options) {
|
|
|
7690
7766
|
const found = await providerFor(before.issuer);
|
|
7691
7767
|
return found.ok ? back("said", `${before.label} answers: it's ready for people to sign in.`) : back("problem", found.said);
|
|
7692
7768
|
}
|
|
7693
|
-
if (!authenticateApprover(
|
|
7769
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7694
7770
|
return back("problem", "Enter your Toolroll password to change sign-in.");
|
|
7695
7771
|
if (action === "remove") {
|
|
7696
7772
|
removeSsoSettings(options.configDir);
|
|
@@ -7727,7 +7803,7 @@ export function createDecisionServer(options) {
|
|
|
7727
7803
|
}
|
|
7728
7804
|
if (url.pathname === "/settings/updates/cancel")
|
|
7729
7805
|
return back("said", requestRuntimeUpdateCancel(stateDir));
|
|
7730
|
-
if (!authenticateApprover(
|
|
7806
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7731
7807
|
return back("problem", "Enter your Toolroll password to update.");
|
|
7732
7808
|
const version = body.get("version") ?? "";
|
|
7733
7809
|
const when = { now: "now", "when-idle": "when-idle", tonight: "at" }[body.get("when") ?? ""] ?? null;
|
|
@@ -7766,7 +7842,7 @@ export function createDecisionServer(options) {
|
|
|
7766
7842
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
7767
7843
|
return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
|
|
7768
7844
|
const back = (key, words) => redirect(response, `/settings/retention?${key}=${encodeURIComponent(words)}`);
|
|
7769
|
-
if (!authenticateApprover(
|
|
7845
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7770
7846
|
return back("problem", "Enter your Toolroll password to change retention.");
|
|
7771
7847
|
const before = store.retentionPeriods();
|
|
7772
7848
|
// Only what the page offers (or the period already set): a hand-made value doesn't save.
|
|
@@ -7788,7 +7864,7 @@ export function createDecisionServer(options) {
|
|
|
7788
7864
|
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
7789
7865
|
const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
|
|
7790
7866
|
const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
|
|
7791
|
-
if (!authenticateApprover(
|
|
7867
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7792
7868
|
return back("problem", `Enter your Toolroll password to ${what}.`);
|
|
7793
7869
|
if (url.pathname === "/settings/storage") {
|
|
7794
7870
|
const cleanup = parseCleanup(body.get("cleanup") ?? "");
|
|
@@ -7819,13 +7895,15 @@ export function createDecisionServer(options) {
|
|
|
7819
7895
|
const body = readForm(posted, CONSOLE_FORMS.pullRequests);
|
|
7820
7896
|
const repo = body.get("repo") ?? "";
|
|
7821
7897
|
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
7822
|
-
if (who.via !== "cookie"
|
|
7898
|
+
if (who.via !== "cookie")
|
|
7899
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
7900
|
+
if (!known.includes(repo) || !visible(repo))
|
|
7823
7901
|
return refuse(response, who, 404, "No such project.", "/projects");
|
|
7824
7902
|
if (who.role !== "approver" || store.isDemo())
|
|
7825
7903
|
return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
|
|
7826
7904
|
const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7827
7905
|
const act = body.get("act");
|
|
7828
|
-
if (!authenticateApprover(
|
|
7906
|
+
if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
|
|
7829
7907
|
return back("problem", "Enter your Toolroll password to change pull requests.");
|
|
7830
7908
|
if (act === "on") {
|
|
7831
7909
|
const checked = await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
|
|
@@ -7857,12 +7935,14 @@ export function createDecisionServer(options) {
|
|
|
7857
7935
|
const body = readForm(posted, CONSOLE_FORMS.checks);
|
|
7858
7936
|
const repo = body.get("repo") ?? "";
|
|
7859
7937
|
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
7860
|
-
if (who.via !== "cookie"
|
|
7938
|
+
if (who.via !== "cookie")
|
|
7939
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
7940
|
+
if (!known.includes(repo) || !visible(repo))
|
|
7861
7941
|
return refuse(response, who, 404, "No such project.", "/projects");
|
|
7862
7942
|
if (who.role !== "approver" || store.isDemo())
|
|
7863
7943
|
return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
|
|
7864
7944
|
const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7865
|
-
if (!authenticateApprover(
|
|
7945
|
+
if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
|
|
7866
7946
|
return back("problem", "Enter your Toolroll password to change checks.");
|
|
7867
7947
|
const act = body.get("act");
|
|
7868
7948
|
// The automatic review switch: the policy log keeps before → after.
|
|
@@ -7921,7 +8001,7 @@ export function createDecisionServer(options) {
|
|
|
7921
8001
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
7922
8002
|
return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
|
|
7923
8003
|
const back = (key, words) => redirect(response, `/settings/backups?${key}=${encodeURIComponent(words)}`);
|
|
7924
|
-
if (!authenticateApprover(
|
|
8004
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7925
8005
|
return back("problem", "Enter your Toolroll password to change backups.");
|
|
7926
8006
|
const every = body.get("every") ?? "";
|
|
7927
8007
|
const hours = Number(every);
|
|
@@ -7946,7 +8026,7 @@ export function createDecisionServer(options) {
|
|
|
7946
8026
|
const body = readForm(posted, CONSOLE_FORMS.data);
|
|
7947
8027
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
7948
8028
|
return refuse(response, who, 403, "An instance operator exports data.", "/settings");
|
|
7949
|
-
if (!authenticateApprover(
|
|
8029
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok) {
|
|
7950
8030
|
return redirect(response, `/settings/data?problem=${encodeURIComponent("Enter your Toolroll password to download the export.")}`);
|
|
7951
8031
|
}
|
|
7952
8032
|
store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: "everything exported", outcome: "exported", source: "access", detail: "downloaded as a .zip" });
|
|
@@ -7991,7 +8071,7 @@ export function createDecisionServer(options) {
|
|
|
7991
8071
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
|
|
7992
8072
|
return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
|
|
7993
8073
|
const back = (key, words) => redirect(response, `/settings/monitoring?${key}=${encodeURIComponent(words)}`);
|
|
7994
|
-
if (!authenticateApprover(
|
|
8074
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
7995
8075
|
return back("problem", "Enter your Toolroll password to change monitoring.");
|
|
7996
8076
|
const before = readMonitoring(options.configDir);
|
|
7997
8077
|
// Never into Toolroll's own folder, a project or the build checkouts: an agent could read the stream there.
|
|
@@ -8048,7 +8128,7 @@ export function createDecisionServer(options) {
|
|
|
8048
8128
|
if (connectionsOf(store, target).find(one => one.id === service.id)?.state !== "open")
|
|
8049
8129
|
return back(`${projectName(target)} already has ${service.label}.`);
|
|
8050
8130
|
}
|
|
8051
|
-
if (!authenticateApprover(
|
|
8131
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
8052
8132
|
return back("Enter your Toolroll password to connect a tool.");
|
|
8053
8133
|
// An app on this computer: added like a common tool and tested; one that can't be reached isn't kept.
|
|
8054
8134
|
if (local !== null) {
|
|
@@ -8103,7 +8183,7 @@ export function createDecisionServer(options) {
|
|
|
8103
8183
|
const action = body.get("action") ?? "";
|
|
8104
8184
|
const name = body.get("name") ?? "";
|
|
8105
8185
|
// Anything that adds what a build can run or reach, or a secret, needs the password.
|
|
8106
|
-
if (["add-catalog", "add-custom", "import", "secret"].includes(action) && !authenticateApprover(
|
|
8186
|
+
if (["add-catalog", "add-custom", "import", "secret"].includes(action) && !authenticateApprover(who, body.get("password") ?? "").ok) {
|
|
8107
8187
|
return back("problem", "Enter your Toolroll password to change this project's tools.");
|
|
8108
8188
|
}
|
|
8109
8189
|
const now = clock();
|
|
@@ -8358,6 +8438,8 @@ export function createDecisionServer(options) {
|
|
|
8358
8438
|
}
|
|
8359
8439
|
if (["/control/setup-preview", "/control/setup-approve", "/control/instructions-preview", "/control/instructions-approve"].includes(url.pathname)) {
|
|
8360
8440
|
const body = readForm(posted, CONSOLE_FORMS.projectSetup);
|
|
8441
|
+
if (who.via !== "cookie")
|
|
8442
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
8361
8443
|
const project = projectOf(who, request);
|
|
8362
8444
|
if (project == null || body.get("repo") !== project || !visible(project))
|
|
8363
8445
|
return refuse(response, who, 409, "The selected project changed. Open project setup again.", "/control");
|
|
@@ -8373,7 +8455,7 @@ export function createDecisionServer(options) {
|
|
|
8373
8455
|
}
|
|
8374
8456
|
if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
|
|
8375
8457
|
return refuse(response, who, 409, "This instruction preview expired. Review it again.", "/control");
|
|
8376
|
-
if (!authenticateApprover(
|
|
8458
|
+
if (!authenticateApprover(who, body.get("token") ?? "").ok)
|
|
8377
8459
|
return refuse(response, who, 403, "Your operator credential is required.", "/control");
|
|
8378
8460
|
const added = addProjectInstructions(project, body.get("fingerprint") ?? "");
|
|
8379
8461
|
if (!added.ok)
|
|
@@ -8411,7 +8493,7 @@ export function createDecisionServer(options) {
|
|
|
8411
8493
|
savePrimary(dir, "slack");
|
|
8412
8494
|
return redirect(response, "/settings/slack");
|
|
8413
8495
|
}
|
|
8414
|
-
if (!authenticateApprover(
|
|
8496
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
8415
8497
|
return show("Enter your Toolroll password to change Slack access.", 403);
|
|
8416
8498
|
const generation = store.accountOf(who.name).generation;
|
|
8417
8499
|
if (action === "connect") {
|
|
@@ -8544,7 +8626,7 @@ export function createDecisionServer(options) {
|
|
|
8544
8626
|
savePrimary(dir, "teams");
|
|
8545
8627
|
return redirect(response, "/settings/teams");
|
|
8546
8628
|
}
|
|
8547
|
-
if (!authenticateApprover(
|
|
8629
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
8548
8630
|
return show("Enter your Toolroll password to change Teams access.", 403);
|
|
8549
8631
|
const generation = store.accountOf(who.name).generation;
|
|
8550
8632
|
if (action === "connect") {
|
|
@@ -8599,7 +8681,7 @@ export function createDecisionServer(options) {
|
|
|
8599
8681
|
savePrimary(dir, "discord");
|
|
8600
8682
|
return redirect(response, "/settings/discord");
|
|
8601
8683
|
}
|
|
8602
|
-
if (!authenticateApprover(
|
|
8684
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
8603
8685
|
return show("Enter your Toolroll password to change Discord access.", 403);
|
|
8604
8686
|
const generation = store.accountOf(who.name).generation;
|
|
8605
8687
|
if (action === "connect") {
|
|
@@ -9139,7 +9221,7 @@ export function createDecisionServer(options) {
|
|
|
9139
9221
|
if (who.via !== "cookie")
|
|
9140
9222
|
return refuse(response, who, 403, "runner registration is a browser surface");
|
|
9141
9223
|
const token = body.get("token") ?? "";
|
|
9142
|
-
if (!authenticateApprover(
|
|
9224
|
+
if (!authenticateApprover(who, token).ok) {
|
|
9143
9225
|
return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
|
|
9144
9226
|
}
|
|
9145
9227
|
const name = (body.get("name") ?? "").trim();
|
|
@@ -9240,7 +9322,7 @@ export function createDecisionServer(options) {
|
|
|
9240
9322
|
return refuse(response, who, 409, "the terms moved while you were reading — read them again", "/mode");
|
|
9241
9323
|
}
|
|
9242
9324
|
const token = body.get("token") ?? "";
|
|
9243
|
-
if (!authenticateApprover(
|
|
9325
|
+
if (!authenticateApprover(who, token).ok) {
|
|
9244
9326
|
return refuse(response, who, 403, "signing a mode takes your password, typed again", "/mode");
|
|
9245
9327
|
}
|
|
9246
9328
|
store.signMode({
|
|
@@ -9269,7 +9351,7 @@ export function createDecisionServer(options) {
|
|
|
9269
9351
|
const body = readForm(posted, CONSOLE_FORMS.peopleProjects);
|
|
9270
9352
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
9271
9353
|
return refuse(response, who, 403, "An instance operator manages project access.", "/people");
|
|
9272
|
-
if (!authenticateApprover(
|
|
9354
|
+
if (!authenticateApprover(who, body.get("token") ?? "", null).ok)
|
|
9273
9355
|
return refuse(response, who, 403, "Saving access requires your password.", "/people");
|
|
9274
9356
|
const access = readAccessForm(body);
|
|
9275
9357
|
if (!access.ok)
|
|
@@ -9287,7 +9369,7 @@ export function createDecisionServer(options) {
|
|
|
9287
9369
|
const token = body.get("token") ?? "";
|
|
9288
9370
|
// RAISING authority is a password ceremony (the doctrine): adding a
|
|
9289
9371
|
// person to the instance is exactly that.
|
|
9290
|
-
if (!authenticateApprover(
|
|
9372
|
+
if (!authenticateApprover(who, token).ok) {
|
|
9291
9373
|
return refuse(response, who, 403, "making an invite takes your password, typed again", "/people");
|
|
9292
9374
|
}
|
|
9293
9375
|
const role = body.get("role") === "approver" ? "approver" : "viewer";
|
|
@@ -9316,7 +9398,7 @@ export function createDecisionServer(options) {
|
|
|
9316
9398
|
if (who.via !== "cookie")
|
|
9317
9399
|
return refuse(response, who, 403, "inviting is a browser surface");
|
|
9318
9400
|
const token = body.get("token") ?? "";
|
|
9319
|
-
if (!authenticateApprover(
|
|
9401
|
+
if (!authenticateApprover(who, token).ok) {
|
|
9320
9402
|
return refuse(response, who, 403, "cancelling an invite takes your password, typed again", "/people");
|
|
9321
9403
|
}
|
|
9322
9404
|
const id = Number(body.get("id") ?? "");
|
|
@@ -9328,7 +9410,7 @@ export function createDecisionServer(options) {
|
|
|
9328
9410
|
if (who.via !== "cookie")
|
|
9329
9411
|
return refuse(response, who, 403, "removing a person is a browser surface");
|
|
9330
9412
|
const token = body.get("token") ?? "";
|
|
9331
|
-
if (!authenticateApprover(
|
|
9413
|
+
if (!authenticateApprover(who, token).ok) {
|
|
9332
9414
|
return refuse(response, who, 403, "removing a person takes your password, typed again", "/people");
|
|
9333
9415
|
}
|
|
9334
9416
|
const name = (body.get("name") ?? "").trim();
|
|
@@ -9348,7 +9430,7 @@ export function createDecisionServer(options) {
|
|
|
9348
9430
|
if (who.via !== "cookie")
|
|
9349
9431
|
return refuse(response, who, 403, "runner retirement is a browser surface");
|
|
9350
9432
|
const token = body.get("token") ?? "";
|
|
9351
|
-
if (!authenticateApprover(
|
|
9433
|
+
if (!authenticateApprover(who, token).ok) {
|
|
9352
9434
|
return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/fleet");
|
|
9353
9435
|
}
|
|
9354
9436
|
const name = (body.get("name") ?? "").trim();
|
|
@@ -9399,6 +9481,8 @@ export function createDecisionServer(options) {
|
|
|
9399
9481
|
}
|
|
9400
9482
|
const contestAct = /^\/contest\/([0-9]{1,15})\/(arm|pick|abandon)$/.exec(url.pathname);
|
|
9401
9483
|
if (contestAct !== null) {
|
|
9484
|
+
if (who.via !== "cookie")
|
|
9485
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
9402
9486
|
const body = readForm(posted, CONSOLE_FORMS.contest);
|
|
9403
9487
|
const contestId = Number(contestAct[1]);
|
|
9404
9488
|
const verb = contestAct[2];
|
|
@@ -9451,7 +9535,7 @@ export function createDecisionServer(options) {
|
|
|
9451
9535
|
// POST minted. The store consumes the nonce conditionally inside the
|
|
9452
9536
|
// same transaction that moves the tournament — replay finds it gone.
|
|
9453
9537
|
const token = body.get("token") ?? "";
|
|
9454
|
-
if (!authenticateApprover(
|
|
9538
|
+
if (!authenticateApprover(who, token).ok) {
|
|
9455
9539
|
return contestScreen(response, who, contestId, "that decision takes your password, typed again", 403);
|
|
9456
9540
|
}
|
|
9457
9541
|
const nonceValue = body.get("nonce") ?? "";
|
|
@@ -9493,7 +9577,7 @@ export function createDecisionServer(options) {
|
|
|
9493
9577
|
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
9494
9578
|
return taskScreen(response, who, act.taskId, "That build isn't part of this task.", 409);
|
|
9495
9579
|
}
|
|
9496
|
-
if (!authenticateApprover(
|
|
9580
|
+
if (!authenticateApprover(who, body.get("token") ?? "", ref.repo).ok || !store.accountCanAccess(who.name, ref.repo)) {
|
|
9497
9581
|
return taskScreen(response, who, act.taskId, "That password didn't match. Nothing changed.", 403);
|
|
9498
9582
|
}
|
|
9499
9583
|
// When Toolroll can't check at all, the approver also says they checked: never a one-click confirmation.
|
|
@@ -9601,7 +9685,7 @@ export function createDecisionServer(options) {
|
|
|
9601
9685
|
// (finding 22/33) — the record is consumed from the RETURNED live
|
|
9602
9686
|
// session, synchronously, before the first await.
|
|
9603
9687
|
const password = body.get("token") ?? "";
|
|
9604
|
-
if (password === "" || !authenticateApprover(
|
|
9688
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
9605
9689
|
return projectsScreen(response, who, "cloning takes your password, typed again", 403);
|
|
9606
9690
|
}
|
|
9607
9691
|
const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
|
|
@@ -9669,7 +9753,7 @@ export function createDecisionServer(options) {
|
|
|
9669
9753
|
if (store.isDemo())
|
|
9670
9754
|
return refuse(response, who, 403, "the sandbox never pushes");
|
|
9671
9755
|
const password = body.get("token") ?? "";
|
|
9672
|
-
if (password === "" || !authenticateApprover(
|
|
9756
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
9673
9757
|
return redirect(response, `/settings?said=${encodeURIComponent("enrolling this device takes your password, typed again")}`);
|
|
9674
9758
|
}
|
|
9675
9759
|
const endpoint = body.get("endpoint") ?? "";
|
|
@@ -9772,7 +9856,7 @@ export function createDecisionServer(options) {
|
|
|
9772
9856
|
return refuse(response, who, 403, "chat setup is a browser surface");
|
|
9773
9857
|
const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
|
|
9774
9858
|
const password = body.get("token") ?? "";
|
|
9775
|
-
if (password === "" || !authenticateApprover(
|
|
9859
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
9776
9860
|
return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
|
|
9777
9861
|
}
|
|
9778
9862
|
if (body.get("off") === "1") {
|
|
@@ -10098,7 +10182,7 @@ export function createDecisionServer(options) {
|
|
|
10098
10182
|
// The password, typed again, on EVERY message (v2 ruling 2): chat is
|
|
10099
10183
|
// spend, and a seven-day cookie is not a spend credential.
|
|
10100
10184
|
const password = body.get("token") ?? "";
|
|
10101
|
-
if (password === "" || !authenticateApprover(
|
|
10185
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
10102
10186
|
return redirect(response, `/chat?said=${encodeURIComponent("chat spends — your password, typed again, with every message")}`);
|
|
10103
10187
|
}
|
|
10104
10188
|
const message = (body.get("message") ?? "").trim();
|
|
@@ -10167,7 +10251,7 @@ export function createDecisionServer(options) {
|
|
|
10167
10251
|
const candidate = chat?.candidates.get(key);
|
|
10168
10252
|
// The filing act creates durable rows from model text: password again.
|
|
10169
10253
|
const password = body.get("token") ?? "";
|
|
10170
|
-
if (password === "" || !authenticateApprover(
|
|
10254
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
10171
10255
|
return redirect(response, `/chat?said=${encodeURIComponent("filing a draft takes your password, typed again")}`);
|
|
10172
10256
|
}
|
|
10173
10257
|
if (chat === undefined || candidate === undefined) {
|
|
@@ -10240,7 +10324,7 @@ export function createDecisionServer(options) {
|
|
|
10240
10324
|
if (turn === null)
|
|
10241
10325
|
return refuse(response, who, 404, "no such turn", "/chat");
|
|
10242
10326
|
const password = body.get("token") ?? "";
|
|
10243
|
-
if (password === "" || !authenticateApprover(
|
|
10327
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
10244
10328
|
return refuse(response, who, 403, "acknowledging unknown spend takes your password", "/chat");
|
|
10245
10329
|
}
|
|
10246
10330
|
const nonce = body.get("nonce") ?? "";
|
|
@@ -10948,7 +11032,7 @@ export function createDecisionServer(options) {
|
|
|
10948
11032
|
}
|
|
10949
11033
|
basis = { kind: "mode", digest: mode.digest };
|
|
10950
11034
|
}
|
|
10951
|
-
else if (!authenticateApprover(
|
|
11035
|
+
else if (!authenticateApprover(who, token).ok) {
|
|
10952
11036
|
return refuse(response, who, 403, "authorizing takes your password, typed again", taskHref(taskId));
|
|
10953
11037
|
}
|
|
10954
11038
|
const minted = store.mintAttendedAuthorization({
|
|
@@ -11125,7 +11209,7 @@ export function createDecisionServer(options) {
|
|
|
11125
11209
|
// Authenticated like every approving act: the session alone may
|
|
11126
11210
|
// read; resuming external work takes the password, typed again.
|
|
11127
11211
|
const token = (body.get("token") ?? "").trim();
|
|
11128
|
-
const authenticated = token !== "" ? authenticateApprover(
|
|
11212
|
+
const authenticated = token !== "" ? authenticateApprover(who, token) : null;
|
|
11129
11213
|
if (authenticated === null || !authenticated.ok) {
|
|
11130
11214
|
return taskScreen(response, who, taskId, "reopening takes your password, typed again", 403);
|
|
11131
11215
|
}
|
|
@@ -11306,7 +11390,7 @@ export function createDecisionServer(options) {
|
|
|
11306
11390
|
// authority takes the password ceremony again — the same act
|
|
11307
11391
|
// that would be required to approve that authority from scratch.
|
|
11308
11392
|
const token = (body.get("token") ?? "").trim();
|
|
11309
|
-
if (!authenticateApprover(
|
|
11393
|
+
if (!authenticateApprover(who, token).ok) {
|
|
11310
11394
|
return taskScreen(response, who, taskId, "accepting this revision takes your password, typed again", 403);
|
|
11311
11395
|
}
|
|
11312
11396
|
}
|
|
@@ -11622,6 +11706,8 @@ export function createDecisionServer(options) {
|
|
|
11622
11706
|
return redirect(response, taskHref(taskId));
|
|
11623
11707
|
}
|
|
11624
11708
|
case "approve": {
|
|
11709
|
+
if (who.via !== "cookie")
|
|
11710
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
11625
11711
|
const body = readForm(posted, CONSOLE_FORMS.taskApprove);
|
|
11626
11712
|
const requestedReturn = body.get("return");
|
|
11627
11713
|
const approvalBack = requestedReturn === "next"
|
|
@@ -11644,11 +11730,9 @@ export function createDecisionServer(options) {
|
|
|
11644
11730
|
// rendered to this approver and is spent either way.
|
|
11645
11731
|
const digest = body.get("digest") ?? "";
|
|
11646
11732
|
const token = body.get("token") ?? "";
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
return approvalProblem("that approval form is stale — read it again", 409);
|
|
11651
|
-
}
|
|
11733
|
+
const nonce = body.get("nonce") ?? "";
|
|
11734
|
+
if (!consumeApprovalNonce(nonce, who.name, taskId, digest)) {
|
|
11735
|
+
return approvalProblem("that approval form is stale — read it again", 409);
|
|
11652
11736
|
}
|
|
11653
11737
|
if (token === "" && !hasFreshIdentitySignIn(who.name)) {
|
|
11654
11738
|
return approvalProblem("approval requires your password, typed again", 400);
|
|
@@ -11845,7 +11929,7 @@ export function createDecisionServer(options) {
|
|
|
11845
11929
|
return taskScreen(response, who, taskId, "which attempt? the resume names the exact stopped run", 400);
|
|
11846
11930
|
const runId = Number(named);
|
|
11847
11931
|
const token = body.get("token") ?? "";
|
|
11848
|
-
if (!authenticateApprover(
|
|
11932
|
+
if (!authenticateApprover(who, token).ok) {
|
|
11849
11933
|
return taskScreen(response, who, taskId, "resuming takes your password, typed again", 403);
|
|
11850
11934
|
}
|
|
11851
11935
|
const nonceValue = body.get("nonce") ?? "";
|
|
@@ -11925,7 +12009,7 @@ export function createDecisionServer(options) {
|
|
|
11925
12009
|
approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
|
|
11926
12010
|
}));
|
|
11927
12011
|
}
|
|
11928
|
-
const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
|
|
12012
|
+
const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, ...(options.requestBudgetClock === undefined ? {} : { requestBudgetClock: options.requestBudgetClock }), signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
|
|
11929
12013
|
resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
|
|
11930
12014
|
enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
|
|
11931
12015
|
// MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
|
|
@@ -11948,9 +12032,10 @@ export function createDecisionServer(options) {
|
|
|
11948
12032
|
// grant (their role, their projects) is checked beside it.
|
|
11949
12033
|
confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
|
|
11950
12034
|
projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
|
|
12035
|
+
admitToken: request => oauthTokenBudget.admit(joinSourceOf(request)),
|
|
11951
12036
|
});
|
|
11952
12037
|
// ---- identity ------------------------------------------------------------
|
|
11953
|
-
function identify(request, touch = true, limited = false) {
|
|
12038
|
+
function identify(request, touch = true, limited = false, refusedToken) {
|
|
11954
12039
|
// 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.
|
|
11955
12040
|
const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
|
|
11956
12041
|
if (presented !== undefined) {
|
|
@@ -11967,16 +12052,20 @@ export function createDecisionServer(options) {
|
|
|
11967
12052
|
return null;
|
|
11968
12053
|
// v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
|
|
11969
12054
|
// through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
|
|
11970
|
-
if (kept.row.projects !== null && !limited)
|
|
11971
|
-
return null;
|
|
11972
12055
|
const account = store.accountOf(kept.row.account);
|
|
11973
12056
|
if (account === null || account.revokedAt !== null)
|
|
11974
12057
|
return null;
|
|
11975
|
-
|
|
11976
|
-
|
|
12058
|
+
const principal = { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id,
|
|
12059
|
+
projects: tokenProjects(tokenProjects(account.projects, kept.row.projects), oauthProjects(store, kept.row.id)) };
|
|
12060
|
+
// Preserve proof only for a refusal and its admission charge. This never makes an OAuth token a console login.
|
|
12061
|
+
if (!oauthTokenAllowed(store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)) ||
|
|
12062
|
+
(kept.row.projects !== null && !limited)) {
|
|
12063
|
+
if (refusedToken !== undefined)
|
|
12064
|
+
refusedToken.principal = principal;
|
|
11977
12065
|
return null;
|
|
12066
|
+
}
|
|
11978
12067
|
store.touchApiToken(kept.row.id, new Date(at));
|
|
11979
|
-
return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name };
|
|
12068
|
+
return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name, generation: account.generation, principal };
|
|
11980
12069
|
}
|
|
11981
12070
|
const bearer = /^Bearer (.+):(.+)$/.exec(request.headers.authorization ?? "");
|
|
11982
12071
|
if (bearer !== null) {
|
|
@@ -11987,7 +12076,7 @@ export function createDecisionServer(options) {
|
|
|
11987
12076
|
const authenticated = authenticateAccount(store, bearer[1], bearer[2]);
|
|
11988
12077
|
if (!authenticated.ok && authenticated.reason === "unknown")
|
|
11989
12078
|
signInBudget.failed(source, at);
|
|
11990
|
-
return authenticated.ok && passwordAllowed(bearer[1]) ? { name: bearer[1], via: "bearer", role: authenticated.role } : null;
|
|
12079
|
+
return authenticated.ok && passwordAllowed(bearer[1]) ? { name: bearer[1], via: "bearer", role: authenticated.role, generation: authenticated.generation } : null;
|
|
11991
12080
|
}
|
|
11992
12081
|
const cookies = request.headers.cookie ?? "";
|
|
11993
12082
|
const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
|