toolroll 0.9.50 → 0.9.51
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/mcp-oauth.d.ts +1 -1
- package/dist/mcp-oauth.js +3 -2
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +5 -1
- package/dist/request-budget.d.ts +5 -2
- package/dist/request-budget.js +7 -3
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +19 -9
- package/dist/serve.js +14 -9
- package/dist/server/people-tokens.js +1 -2
- package/dist/server/remote.js +19 -4
- package/dist/sign-in-guard.d.ts +41 -16
- package/dist/sign-in-guard.js +147 -24
- package/dist/source-key.d.ts +2 -0
- package/dist/source-key.js +49 -0
- package/dist/team-http.d.ts +3 -0
- package/dist/team-http.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.51 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **No lockout by username.** Wrong passwords slow the source that sent them,
|
|
6
|
+
not the account. A device you've signed in on always gets through.
|
|
7
|
+
- **Guessing stays bounded.** Rotating addresses can't buy more guesses at an
|
|
8
|
+
account, and IPv6 addresses count per /64.
|
|
9
|
+
- **Usernames stay private.** Real and made-up names answer the same way,
|
|
10
|
+
even after a flood.
|
|
11
|
+
|
|
3
12
|
## 0.9.50 — 2026-10-07
|
|
4
13
|
|
|
5
14
|
- **One route policy table.** Every route's access rules live in one table,
|
package/dist/mcp-oauth.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export type OAuthHttpOptions = {
|
|
|
62
62
|
clock: () => Date;
|
|
63
63
|
/** This console's canonical origin for the request (the https public address, or this computer's loopback one); null: no sign-in here. */
|
|
64
64
|
originOf: (request: IncomingMessage) => string | null;
|
|
65
|
-
/** Trusted direct peer, or the last hop appended by the console's loopback proxy. */
|
|
65
|
+
/** Trusted direct peer, or the last hop appended by the console's loopback proxy (native IPv6 counted by its /64). */
|
|
66
66
|
requesterKey: (request: IncomingMessage) => string;
|
|
67
67
|
/** The browser session signed in by cookie, or null. A bearer never consents. */
|
|
68
68
|
session: (request: IncomingMessage) => OAuthSession | null;
|
package/dist/mcp-oauth.js
CHANGED
|
@@ -2,6 +2,7 @@ import { adapterPolicy } from "./server/route-policy.js";
|
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { hashSecret, mintApiToken } from "./api-tokens.js";
|
|
4
4
|
import { limitWords } from "./request-budget.js";
|
|
5
|
+
import { sourceKey } from "./source-key.js";
|
|
5
6
|
import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
|
|
6
7
|
/** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
|
|
7
8
|
export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
|
|
@@ -176,7 +177,7 @@ export function createOAuthHttp(options) {
|
|
|
176
177
|
const name = clean(read.value.client_name ?? "") || "MCP client";
|
|
177
178
|
const now = options.clock();
|
|
178
179
|
prune(now.getTime());
|
|
179
|
-
const kept = store.registerOAuthClient({ id: randomBytes(16).toString("hex"), name, redirectUris: redirects, source: sha256(options.requesterKey(request)) }, now, OAUTH_LIMITS.clients, OAUTH_LIMITS.clientsPerSource, [...waiting.values()].map(one => one.client));
|
|
180
|
+
const kept = store.registerOAuthClient({ id: randomBytes(16).toString("hex"), name, redirectUris: redirects, source: sha256(sourceKey(options.requesterKey(request))) }, now, OAUTH_LIMITS.clients, OAUTH_LIMITS.clientsPerSource, [...waiting.values()].map(one => one.client));
|
|
180
181
|
if (kept === null) {
|
|
181
182
|
response.setHeader("retry-after", "600");
|
|
182
183
|
return oauthError(response, 429, "temporarily_unavailable", "Too many clients are waiting to connect. Try again later.");
|
|
@@ -214,7 +215,7 @@ export function createOAuthHttp(options) {
|
|
|
214
215
|
const scopes = (params.get("scope") ?? "").split(" ").filter(one => one !== "");
|
|
215
216
|
if (!scopes.every(one => OAUTH_SCOPES.includes(one)))
|
|
216
217
|
return fail("The client requested access Toolroll doesn't offer.");
|
|
217
|
-
const source = sha256(options.requesterKey(request));
|
|
218
|
+
const source = sha256(sourceKey(options.requesterKey(request)));
|
|
218
219
|
if (waiting.size >= OAUTH_LIMITS.requests || [...waiting.values()].filter(one => one.source === source).length >= OAUTH_LIMITS.requestsPerSource) {
|
|
219
220
|
response.setHeader("retry-after", "600");
|
|
220
221
|
return problemPage(response, 429, "Too many sign-ins waiting", "Try again in ten minutes from your MCP client.");
|
package/dist/operate.d.ts
CHANGED
|
@@ -124,7 +124,7 @@ export declare const OPERATE_BOOLEAN_FLAGS: ReadonlySet<string>;
|
|
|
124
124
|
export declare function parseOperateArgs(argv: readonly string[], ownValues?: ReadonlySet<string>): Args | {
|
|
125
125
|
error: string;
|
|
126
126
|
};
|
|
127
|
-
/** Route
|
|
127
|
+
/** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
|
|
128
128
|
export declare function runOperate(command: string, argv: readonly string[], write: Write, options?: OperateOptions): Promise<number>;
|
|
129
129
|
export type { Principal };
|
|
130
130
|
/**
|
package/dist/operate.js
CHANGED
|
@@ -98,6 +98,7 @@ import { pushPass } from "./push.js";
|
|
|
98
98
|
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
|
|
99
99
|
import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
|
|
100
100
|
import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
|
|
101
|
+
import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
|
|
101
102
|
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
102
103
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
103
104
|
import { createServer as createNetServer } from "node:net";
|
|
@@ -760,8 +761,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
|
|
|
760
761
|
}
|
|
761
762
|
return { positional, flags, repoList };
|
|
762
763
|
}
|
|
763
|
-
/** Route
|
|
764
|
+
/** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
|
|
764
765
|
export async function runOperate(command, argv, write, options = {}) {
|
|
766
|
+
return withPasswordSource(CLI_PASSWORD_SOURCE, () => runOperateLocal(command, argv, write, options));
|
|
767
|
+
}
|
|
768
|
+
async function runOperateLocal(command, argv, write, options = {}) {
|
|
765
769
|
const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
|
|
766
770
|
// A parse error precedes the flags map, so JSON mode is read from the raw
|
|
767
771
|
// argv — the envelope contract holds even for the earliest refusal.
|
package/dist/request-budget.d.ts
CHANGED
|
@@ -107,18 +107,21 @@ export declare const SOURCE_BUDGET_DEFAULTS: Readonly<{
|
|
|
107
107
|
/**
|
|
108
108
|
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
109
109
|
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
110
|
-
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
110
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above. An address
|
|
111
|
+
* key counts by sourceKey (a native IPv6 caller by its /64). Verified identities must explicitly use exact keys.
|
|
111
112
|
*/
|
|
112
113
|
export declare class SourceAdmission {
|
|
113
114
|
private readonly perMinute;
|
|
114
115
|
private readonly clock;
|
|
115
116
|
private readonly tracked;
|
|
116
117
|
private readonly usage;
|
|
118
|
+
private readonly keyMode;
|
|
117
119
|
constructor(options: {
|
|
118
120
|
perMinute: number;
|
|
119
121
|
clock?: () => number;
|
|
120
122
|
tracked?: number;
|
|
123
|
+
keyMode?: "source" | "exact";
|
|
121
124
|
});
|
|
122
125
|
get size(): number;
|
|
123
|
-
admit(
|
|
126
|
+
admit(key: string): Admission;
|
|
124
127
|
}
|
package/dist/request-budget.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sourceKey } from "./source-key.js";
|
|
1
2
|
export const REQUEST_BUDGET_SCHEMA = `
|
|
2
3
|
CREATE TABLE IF NOT EXISTS request_budget_limit (
|
|
3
4
|
target TEXT PRIMARY KEY CHECK (target = '*' OR (length(target) = 12 AND target NOT GLOB '*[^0-9a-f]*')),
|
|
@@ -237,21 +238,24 @@ export const SOURCE_BUDGET_DEFAULTS = Object.freeze({ password: 120, oauthToken:
|
|
|
237
238
|
/**
|
|
238
239
|
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
239
240
|
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
240
|
-
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
241
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above. An address
|
|
242
|
+
* key counts by sourceKey (a native IPv6 caller by its /64). Verified identities must explicitly use exact keys.
|
|
241
243
|
*/
|
|
242
244
|
export class SourceAdmission {
|
|
243
245
|
perMinute;
|
|
244
246
|
clock;
|
|
245
247
|
tracked;
|
|
246
248
|
usage = new Map();
|
|
249
|
+
keyMode;
|
|
247
250
|
constructor(options) {
|
|
251
|
+
this.keyMode = options.keyMode ?? "source";
|
|
248
252
|
this.perMinute = Math.max(1, Math.min(PER_MINUTE_MAX, options.perMinute));
|
|
249
253
|
this.clock = options.clock ?? Date.now;
|
|
250
254
|
this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
|
|
251
255
|
}
|
|
252
256
|
get size() { return this.usage.size; }
|
|
253
|
-
admit(
|
|
254
|
-
const now = this.clock();
|
|
257
|
+
admit(key) {
|
|
258
|
+
const now = this.clock(), source = this.keyMode === "exact" ? key : sourceKey(key);
|
|
255
259
|
let times = this.usage.get(source);
|
|
256
260
|
if (times === undefined) {
|
|
257
261
|
if (this.usage.size >= this.tracked) {
|
package/dist/scope.d.ts
CHANGED
|
@@ -498,6 +498,7 @@ export declare function authenticateAccount(store: Store, by: string, secret: st
|
|
|
498
498
|
} | {
|
|
499
499
|
ok: false;
|
|
500
500
|
reason: "no-approvers" | "unknown" | "revoked" | "locked";
|
|
501
|
+
retryAfterMs?: number;
|
|
501
502
|
};
|
|
502
503
|
/**
|
|
503
504
|
* v100: someone who signed in with the identity provider moments ago has no
|
package/dist/scope.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The rubric's vocabulary loads before anything that reaches the plan contract (see contracts/acceptance-terms.ts).
|
|
2
2
|
export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
|
|
3
|
-
import { passwordGuardOf } from "./sign-in-guard.js";
|
|
3
|
+
import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
|
|
4
4
|
import { validateScopeText } from "./task-text.js";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
6
|
import { projectAuthority } from "./project-access.js";
|
|
@@ -725,16 +725,26 @@ export function authenticateAccount(store, by, secret) {
|
|
|
725
725
|
return { ok: false, reason: "unknown" };
|
|
726
726
|
return { ok: true, role: account.role, generation: account.generation };
|
|
727
727
|
}
|
|
728
|
-
// v99: wrong passwords in a row lock the name for a while, whatever road they came by.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
728
|
+
// v99: wrong passwords in a row lock the name for a while from where they came, whatever road they came by. A
|
|
729
|
+
// locked or held-back source is refused before the password is checked.
|
|
730
|
+
const guard = passwordGuardOf(store), now = Date.now(), source = currentPasswordSource();
|
|
731
|
+
const account = store.accountOf(by), proof = provenPasswordAccount.getStore();
|
|
732
|
+
const proven = proof?.name === by && account !== null && account.revokedAt === null && proof.generation === account.generation;
|
|
733
|
+
const wait = guard.preflight(by, now, source, proven);
|
|
734
|
+
if (wait > 0)
|
|
735
|
+
return { ok: false, reason: "locked", retryAfterMs: wait };
|
|
736
|
+
// Unknown names and legacy minted-token/SSO accounts do the same one KDF as a password account. Throttled
|
|
737
|
+
// attempts above do none, for either name class; never disclose account existence through password work.
|
|
738
|
+
const dummy = `scrypt$${"0".repeat(32)}$${"0".repeat(64)}`;
|
|
739
|
+
const hash = account?.credentialHash ?? dummy;
|
|
740
|
+
if (!hash.startsWith("scrypt$"))
|
|
741
|
+
verifyCredential(dummy, secret);
|
|
742
|
+
const matches = verifyCredential(hash, secret);
|
|
743
|
+
if (account === null || !matches) {
|
|
744
|
+
guard.failed(by, now, source, account !== null);
|
|
735
745
|
return { ok: false, reason: "unknown" };
|
|
736
746
|
}
|
|
737
|
-
guard.succeeded(by);
|
|
747
|
+
guard.succeeded(by, source);
|
|
738
748
|
if (account.revokedAt !== null)
|
|
739
749
|
return { ok: false, reason: "revoked" };
|
|
740
750
|
// Inside a command, the person who signed in is who acted (their own acts never ping them).
|
package/dist/serve.js
CHANGED
|
@@ -117,7 +117,8 @@ import { createPeopleHandlers } from './server/people-tokens.js';
|
|
|
117
117
|
import { createSettingsHandlers } from './server/settings.js';
|
|
118
118
|
import { beatScript, BODY_CAP, chromeScript, decisionsFor, DEMO_BANNER, DEMO_BANNER_SHORT, escape, focusDocument, form, KBD_HELP, LEAD_BY_DEFAULT_FACT, loginHref, matchTaskPath, mateBrowserMessages, mateChatVersion, NO_PROJECT, NO_TOUCH_FRAGMENTS, NONCE_CAP, NONCE_TTL_MS, page, PersistentSessions, pinnedTheme, projectChatHref, QUEUE_VIEW, redactedPath, redirect, refuse, requestContext, respond, safeReturn, screen, SENSITIVE_INPUT, SESSION_ABSOLUTE_MS, SESSION_IDLE_MS, shell, SHUTDOWN_WAIT_MS, sidebarScript, SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH, ssoStepUps, TASK_FORM_BODY_CAP, taskChatHref, teamProposalCardParts, wrongHostPage } from "./server/shared.js";
|
|
119
119
|
import { createTasksHandlers } from './server/tasks.js';
|
|
120
|
-
import { DEFAULT_GUARD_POLICY, passwordGuardOf, SourceBudget } from "./sign-in-guard.js";
|
|
120
|
+
import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
|
|
121
|
+
import { sourceKey } from "./source-key.js";
|
|
121
122
|
import { readSsoSettings } from "./sso-settings.js";
|
|
122
123
|
import { LEAD_THREAD } from "./store.js";
|
|
123
124
|
import { waitingUpdate } from "./toolroll-update.js";
|
|
@@ -247,7 +248,7 @@ export function createDecisionServer(options) {
|
|
|
247
248
|
// Sessions from before a restart that ran out, or whose account changed since, go now.
|
|
248
249
|
store.sweepWebSessions(Date.now(), SESSION_IDLE_MS, SESSION_ABSOLUTE_MS);
|
|
249
250
|
/** Where a new session signs in from: the browser (its user agent, short) and the address. */
|
|
250
|
-
const arrival = (request) => ({ agent: (request.headers["user-agent"] ?? "").slice(0, 300) || null, address:
|
|
251
|
+
const arrival = (request) => ({ agent: (request.headers["user-agent"] ?? "").slice(0, 300) || null, address: forwardedSourceOf(request).replace(/^fwd:/, "") });
|
|
251
252
|
/** Wrong setup codes left before the first-account road closes. */
|
|
252
253
|
let setupAttemptsLeft = 5;
|
|
253
254
|
// The /join road's limiter (D6; Codex people round 1, finding 4):
|
|
@@ -262,7 +263,7 @@ export function createDecisionServer(options) {
|
|
|
262
263
|
* share the proxy's one bucket. A LOOPBACK peer is that proxy, and only
|
|
263
264
|
* then is the forwarded chain believed, taking the LAST hop (the one
|
|
264
265
|
* the trusted proxy itself appended; earlier entries are client-typed). */
|
|
265
|
-
function
|
|
266
|
+
function forwardedSourceOf(request) {
|
|
266
267
|
const peer = request.socket.remoteAddress ?? "unknown";
|
|
267
268
|
const loopback = LOOPBACK_PEERS.has(peer);
|
|
268
269
|
if (!loopback)
|
|
@@ -272,6 +273,8 @@ export function createDecisionServer(options) {
|
|
|
272
273
|
const lastHop = chain.split(",").pop()?.trim() ?? "";
|
|
273
274
|
return lastHop === "" ? peer : `fwd:${lastHop.slice(0, 64)}`;
|
|
274
275
|
}
|
|
276
|
+
/** That source as every per-source budget counts it: a native IPv6 caller by its /64 (source-key.ts). */
|
|
277
|
+
function joinSourceOf(request) { return sourceKey(forwardedSourceOf(request)); }
|
|
275
278
|
const signInBudget = new SourceBudget();
|
|
276
279
|
const ssoVisits = new Map();
|
|
277
280
|
const ssoHandoffs = new Map();
|
|
@@ -455,7 +458,7 @@ export function createDecisionServer(options) {
|
|
|
455
458
|
// Unproved password attempts share a source budget. Only successful proof spends an account budget.
|
|
456
459
|
const sourceClock = options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock };
|
|
457
460
|
const passwordSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
|
|
458
|
-
const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
|
|
461
|
+
const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, keyMode: "exact", ...sourceClock });
|
|
459
462
|
const teamsSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsSource, ...sourceClock });
|
|
460
463
|
const teamsTenantBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsTenant, ...sourceClock });
|
|
461
464
|
const oauthTokenBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.oauthToken, ...sourceClock });
|
|
@@ -476,7 +479,8 @@ export function createDecisionServer(options) {
|
|
|
476
479
|
};
|
|
477
480
|
const server = createServer((request, response) => {
|
|
478
481
|
instrumentRequest(store.telemetry, request, response);
|
|
479
|
-
|
|
482
|
+
// Every password checked while answering counts against where this request came from (sign-in-guard.ts).
|
|
483
|
+
void withPasswordSource(joinSourceOf(request), () => handle(request, response)).catch(error => {
|
|
480
484
|
// Every unhandled error is logged (v99): the path and the message, never the request's body or query.
|
|
481
485
|
logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
|
|
482
486
|
if (envValue(process.env, "SERVE_DEBUG") === "1")
|
|
@@ -604,7 +608,7 @@ export function createDecisionServer(options) {
|
|
|
604
608
|
}
|
|
605
609
|
// A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
|
|
606
610
|
// plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
|
|
607
|
-
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource:
|
|
611
|
+
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: forwardedSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
|
|
608
612
|
const hsts = hstsFor(publicOrigin?.host ?? null, request.headers.host, transport);
|
|
609
613
|
if (hsts !== null)
|
|
610
614
|
response.setHeader("Strict-Transport-Security", hsts);
|
|
@@ -651,8 +655,9 @@ export function createDecisionServer(options) {
|
|
|
651
655
|
}
|
|
652
656
|
if (who?.via === "bearer" && refuseBudget(admitBearer(request, who)))
|
|
653
657
|
return;
|
|
658
|
+
const passwordProof = who?.via === 'cookie' ? { name: who.name, generation: who.session.generation } : null;
|
|
654
659
|
if (edgeRoute?.domain === 'people')
|
|
655
|
-
return dispatchEdge(edgeRoute, { url, who, request, response, method });
|
|
660
|
+
return provenPasswordAccount.run(passwordProof, () => dispatchEdge(edgeRoute, { url, who, request, response, method }));
|
|
656
661
|
if (who === null) {
|
|
657
662
|
// A GET to an exact task or result (a phone's deep link) signs in and
|
|
658
663
|
// comes back to it — the destination is a same-site path only.
|
|
@@ -679,7 +684,7 @@ export function createDecisionServer(options) {
|
|
|
679
684
|
};
|
|
680
685
|
// An empty password stands for a fresh identity-provider sign-in, for this person only (v100).
|
|
681
686
|
if (method === "GET" || method === "POST")
|
|
682
|
-
return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
|
|
687
|
+
return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
|
|
683
688
|
const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
|
|
684
689
|
const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : url.pathname === "/flows/import" ? 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
|
|
685
690
|
const target = actionTarget(url, who, request, body === null ? null : readForm(body, CONSOLE_FORMS.ledgerTarget));
|
|
@@ -749,7 +754,7 @@ export function createDecisionServer(options) {
|
|
|
749
754
|
store.recordAction({ ...entry, at: clock().toISOString(), outcome: "error" });
|
|
750
755
|
throw error;
|
|
751
756
|
}
|
|
752
|
-
}));
|
|
757
|
+
})));
|
|
753
758
|
return respond(response, 405, "text/plain; charset=utf-8", "no such method here");
|
|
754
759
|
}
|
|
755
760
|
function actionTarget(url, who, request, body = null) {
|
|
@@ -19,7 +19,6 @@ import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcA
|
|
|
19
19
|
import { personAuditHtml, personHref } from "../people-audit-ui.js";
|
|
20
20
|
import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
|
|
21
21
|
import { hashPassword } from "../scope.js";
|
|
22
|
-
import { passwordGuardOf } from "../sign-in-guard.js";
|
|
23
22
|
import { form, HANDOFF_STYLE, joinDeadPage, joinFormPage, loginHref, loginPage, loginReturn, page, SESSION_COOKIE, SIGN_IN_COOKIE, SIGN_IN_LINK_PATH, signInSpent, signupPage, startedHere } from "./shared.js";
|
|
24
23
|
export function createPeopleHandlers(runtime) {
|
|
25
24
|
const { settingsUpdates, joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
|
|
@@ -474,7 +473,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
474
473
|
const authenticated = name !== null && token !== null ? authenticateAccount(store, name, token) : null;
|
|
475
474
|
if (authenticated === null || !authenticated.ok) {
|
|
476
475
|
if (authenticated?.reason === "locked") {
|
|
477
|
-
const locked =
|
|
476
|
+
const locked = authenticated.retryAfterMs;
|
|
478
477
|
recordSignIn(signInActor(name), "sign-in refused", "locked");
|
|
479
478
|
response.setHeader("Retry-After", String(Math.ceil(locked / 1000)));
|
|
480
479
|
return page(response, 429, loginPage(`Too many wrong passwords. Try again in ${minutesWords(locked)}.`, returnTo));
|
package/dist/server/remote.js
CHANGED
|
@@ -10,7 +10,7 @@ import { RequestBudget, SourceAdmission } from "../request-budget.js";
|
|
|
10
10
|
import { authenticateAccount } from "../scope.js";
|
|
11
11
|
import { createSessionEndpoint } from '../session-server.js';
|
|
12
12
|
import {} from "../store.js";
|
|
13
|
-
import { handleTeamHttp } from '../team-http.js';
|
|
13
|
+
import { handleTeamHttp, TEAM_PASSWORD_REVERIFY_MS } from '../team-http.js';
|
|
14
14
|
import { handleTeamsHttp } from "../teams.js";
|
|
15
15
|
import { flowHook, telegramHook } from "./remote-hooks.js";
|
|
16
16
|
import { respond } from "./shared.js";
|
|
@@ -26,6 +26,8 @@ export function createRemoteHandlers(runtime) {
|
|
|
26
26
|
return who?.via === "bearer" ? who.principal ?? null : null;
|
|
27
27
|
};
|
|
28
28
|
const presentsToken = (request) => /^Bearer so_\S+$/.test(request.headers.authorization ?? "");
|
|
29
|
+
/** When each live request's password bearer last proved its password: kept only while the request is. */
|
|
30
|
+
const passwordProvedAt = new WeakMap();
|
|
29
31
|
const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
|
|
30
32
|
authenticate: request => {
|
|
31
33
|
if (presentsToken(request)) {
|
|
@@ -34,7 +36,11 @@ export function createRemoteHandlers(runtime) {
|
|
|
34
36
|
}
|
|
35
37
|
const who = identify(request, request.method === 'POST');
|
|
36
38
|
const account = who === null ? null : store.accountOf(who.name);
|
|
37
|
-
|
|
39
|
+
if (!who || !account || account.revokedAt !== null)
|
|
40
|
+
return null;
|
|
41
|
+
if (who.via === 'bearer')
|
|
42
|
+
passwordProvedAt.set(request, clock().getTime());
|
|
43
|
+
return { name: who.name, generation: account.generation, role: account.role };
|
|
38
44
|
},
|
|
39
45
|
admit: admitPasswordSource,
|
|
40
46
|
admitAuthenticated: admitBearer,
|
|
@@ -51,10 +57,19 @@ export function createRemoteHandlers(runtime) {
|
|
|
51
57
|
actor.principal = { ...actor.principal, scope: live.scope };
|
|
52
58
|
return true;
|
|
53
59
|
}
|
|
54
|
-
// A password bearer was proved when this connection opened
|
|
60
|
+
// A password bearer was proved when this connection opened, and is proved again (the password checked, through
|
|
61
|
+
// the same per-source tries and locks) once TEAM_PASSWORD_REVERIFY_MS has passed since. Cookie expiry/revocation
|
|
55
62
|
// is rechecked without allowing a passive stream to extend its lifetime.
|
|
56
|
-
if (request.headers.authorization)
|
|
63
|
+
if (request.headers.authorization) {
|
|
64
|
+
const now = clock().getTime(), provedAt = passwordProvedAt.get(request);
|
|
65
|
+
if (provedAt !== undefined && now - provedAt < TEAM_PASSWORD_REVERIFY_MS)
|
|
66
|
+
return true;
|
|
67
|
+
const who = identify(request, false);
|
|
68
|
+
if (who?.via !== 'bearer' || who.name !== actor.name || who.generation !== actor.generation)
|
|
69
|
+
return false;
|
|
70
|
+
passwordProvedAt.set(request, now);
|
|
57
71
|
return true;
|
|
72
|
+
}
|
|
58
73
|
const who = identify(request, false);
|
|
59
74
|
return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
|
|
60
75
|
},
|
package/dist/sign-in-guard.d.ts
CHANGED
|
@@ -1,31 +1,56 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Kept in memory, per database: a restart forgives, which someone locked out
|
|
9
|
-
* by another's guessing may need. The console reports each lock to the
|
|
10
|
-
* action ledger through onLock.
|
|
3
|
+
* Five failures lock a source/account pair, with exponential backoff. Across sources, failures slow an account and
|
|
4
|
+
* admit only a few unproven checks per minute before password derivation. A validated same-account browser session
|
|
5
|
+
* bypasses that shared admission budget, never a source's own lock. Fabricated names follow the same admission path.
|
|
6
|
+
* Source and name records expire by time. Both use bounded, non-evicting keyed storage, regardless of whether the
|
|
7
|
+
* account exists. Saturation shares overflow buckets without displacing or changing individually tracked state.
|
|
11
8
|
*/
|
|
12
9
|
export type GuardPolicy = {
|
|
13
10
|
failuresBeforeLock: number;
|
|
14
11
|
firstLockMs: number;
|
|
15
12
|
maxLockMs: number;
|
|
13
|
+
firstSlowMs: number;
|
|
16
14
|
};
|
|
17
15
|
export declare const DEFAULT_GUARD_POLICY: GuardPolicy;
|
|
16
|
+
/** Default/loopback source for callers without an explicit native CLI context. */
|
|
17
|
+
export declare const LOCAL_SOURCE = "local";
|
|
18
|
+
/** Native CLI only: no HTTP peer or forwarded header can select this source. */
|
|
19
|
+
export declare const CLI_PASSWORD_SOURCE = "local-cli";
|
|
20
|
+
export declare const ACCOUNT_PASSWORD_TRIES = 3;
|
|
21
|
+
export declare const ACCOUNT_PASSWORD_WINDOW_MS = 60000;
|
|
22
|
+
/** Run `work` with every password check inside it counted against `source`. */
|
|
23
|
+
export declare const withPasswordSource: <T>(source: string, work: () => T) => T;
|
|
24
|
+
/** Where the password being checked now came from (LOCAL_SOURCE outside a request). */
|
|
25
|
+
export declare const currentPasswordSource: () => string;
|
|
26
|
+
/** Set only after the server validates a browser session, never from submitted form fields. */
|
|
27
|
+
export declare const provenPasswordAccount: AsyncLocalStorage<{
|
|
28
|
+
name: string;
|
|
29
|
+
generation: number;
|
|
30
|
+
} | null>;
|
|
18
31
|
export declare class PasswordGuard {
|
|
19
32
|
readonly policy: GuardPolicy;
|
|
20
|
-
private readonly
|
|
33
|
+
private readonly secret;
|
|
34
|
+
private readonly sources;
|
|
35
|
+
private readonly slowdowns;
|
|
36
|
+
private nextPruneAt;
|
|
21
37
|
/** Told once per lock: the account (as typed), and for how long. */
|
|
22
38
|
onLock: ((account: string, lockMs: number) => void) | null;
|
|
23
39
|
constructor(policy?: GuardPolicy);
|
|
24
40
|
private static key;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
private static pair;
|
|
42
|
+
/** Prune by the supplied time, even below capacity; retain a lock's escalation history for a day. */
|
|
43
|
+
private prune;
|
|
44
|
+
/** Source-local snapshot for existing callers. Authentication must use preflight to spend the shared budget. */
|
|
45
|
+
lockedFor(account: string, now: number, source?: string): number;
|
|
46
|
+
/** Reserve one password check, or return its wait. No password derivation may precede this call. */
|
|
47
|
+
preflight(account: string, now: number, source?: string, proven?: boolean): number;
|
|
48
|
+
/** A wrong password; only a database lookup may establish `exists`. */
|
|
49
|
+
failed(account: string, now: number, source?: string, exists?: boolean): void;
|
|
50
|
+
/** The right password clears individually tracked state; shared overflow buckets must expire by time. */
|
|
51
|
+
succeeded(account: string, source?: string): void;
|
|
52
|
+
/** Individually tracked source-and-name entries (at most TRACKED), excluding fixed shared overflow buckets. */
|
|
53
|
+
get size(): number;
|
|
29
54
|
}
|
|
30
55
|
/** The guard for one database (a store): every password check against it counts. */
|
|
31
56
|
export declare function passwordGuardOf(owner: object): PasswordGuard;
|
|
@@ -40,6 +65,6 @@ export declare class SourceBudget {
|
|
|
40
65
|
private readonly sources;
|
|
41
66
|
constructor(tries?: number, windowMs?: number);
|
|
42
67
|
/** Milliseconds until this address may try again (0: it may). */
|
|
43
|
-
waitFor(
|
|
44
|
-
failed(
|
|
68
|
+
waitFor(address: string, now: number): number;
|
|
69
|
+
failed(address: string, now: number): void;
|
|
45
70
|
}
|
package/dist/sign-in-guard.js
CHANGED
|
@@ -1,39 +1,161 @@
|
|
|
1
|
-
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
3
|
+
import { sourceKey } from "./source-key.js";
|
|
4
|
+
export const DEFAULT_GUARD_POLICY = { failuresBeforeLock: 5, firstLockMs: 15 * 60_000, maxLockMs: 24 * 60 * 60_000, firstSlowMs: 60_000 };
|
|
2
5
|
const TRACKED = 10_000;
|
|
6
|
+
/** Default/loopback source for callers without an explicit native CLI context. */
|
|
7
|
+
export const LOCAL_SOURCE = "local";
|
|
8
|
+
/** Native CLI only: no HTTP peer or forwarded header can select this source. */
|
|
9
|
+
export const CLI_PASSWORD_SOURCE = "local-cli";
|
|
10
|
+
export const ACCOUNT_PASSWORD_TRIES = 3;
|
|
11
|
+
export const ACCOUNT_PASSWORD_WINDOW_MS = 60_000;
|
|
12
|
+
const LOCAL_PEERS = new Set(["127.0.0.1", "::1"]);
|
|
13
|
+
const passwordSource = new AsyncLocalStorage();
|
|
14
|
+
/** Run `work` with every password check inside it counted against `source`. */
|
|
15
|
+
export const withPasswordSource = (source, work) => passwordSource.run(source, work);
|
|
16
|
+
/** Where the password being checked now came from (LOCAL_SOURCE outside a request). */
|
|
17
|
+
export const currentPasswordSource = () => passwordSource.getStore() ?? LOCAL_SOURCE;
|
|
18
|
+
/** Set only after the server validates a browser session, never from submitted form fields. */
|
|
19
|
+
export const provenPasswordAccount = new AsyncLocalStorage();
|
|
20
|
+
/**
|
|
21
|
+
* Keep the first TRACKED live keys exactly; further keys share TRACKED overflow buckets. Hashes use the guard's
|
|
22
|
+
* private secret, so callers cannot choose collisions. Neither tier evicts live state. Overflow never mutates the
|
|
23
|
+
* exact tier, and cannot move into a freed exact slot until all overflow state expires: moving would reset budgets.
|
|
24
|
+
* A success may clear its exact record, but never a shared bucket (which could also hold another name's failures).
|
|
25
|
+
*/
|
|
26
|
+
class KeyedGuardState {
|
|
27
|
+
secret;
|
|
28
|
+
domain;
|
|
29
|
+
entries = new Map();
|
|
30
|
+
overflow = new Map();
|
|
31
|
+
constructor(secret, domain) {
|
|
32
|
+
this.secret = secret;
|
|
33
|
+
this.domain = domain;
|
|
34
|
+
}
|
|
35
|
+
hash(key) { return createHmac("sha256", this.secret).update(this.domain).update("\0").update(key).digest("hex"); }
|
|
36
|
+
bucket(hash) { return parseInt(hash.slice(0, 8), 16) % TRACKED; }
|
|
37
|
+
get(key) {
|
|
38
|
+
const hash = this.hash(key);
|
|
39
|
+
return this.entries.get(hash) ?? this.overflow.get(this.bucket(hash));
|
|
40
|
+
}
|
|
41
|
+
retain(key, create) {
|
|
42
|
+
const hash = this.hash(key), kept = this.entries.get(hash);
|
|
43
|
+
if (kept !== undefined)
|
|
44
|
+
return kept;
|
|
45
|
+
if (this.overflow.size === 0 && this.entries.size < TRACKED) {
|
|
46
|
+
const entry = create();
|
|
47
|
+
this.entries.set(hash, entry);
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
const bucket = this.bucket(hash), shared = this.overflow.get(bucket);
|
|
51
|
+
if (shared !== undefined)
|
|
52
|
+
return shared;
|
|
53
|
+
const entry = create();
|
|
54
|
+
this.overflow.set(bucket, entry);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
clear(key) { this.entries.delete(this.hash(key)); }
|
|
58
|
+
prune(now, lifetime) {
|
|
59
|
+
let next = Infinity;
|
|
60
|
+
const pruneEntries = (entries) => {
|
|
61
|
+
for (const [key, entry] of entries) {
|
|
62
|
+
const expires = entry.lastAt + lifetime;
|
|
63
|
+
if (expires <= now)
|
|
64
|
+
entries.delete(key);
|
|
65
|
+
else
|
|
66
|
+
next = Math.min(next, expires);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
pruneEntries(this.entries);
|
|
70
|
+
pruneEntries(this.overflow);
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
/** Individual source/name records; shared overflow storage has its own fixed TRACKED-bucket bound. */
|
|
74
|
+
get size() { return this.entries.size; }
|
|
75
|
+
}
|
|
3
76
|
export class PasswordGuard {
|
|
4
77
|
policy;
|
|
5
|
-
|
|
78
|
+
secret = randomBytes(32);
|
|
79
|
+
sources = new KeyedGuardState(this.secret, "source/name");
|
|
80
|
+
slowdowns = new KeyedGuardState(this.secret, "name");
|
|
81
|
+
nextPruneAt = Infinity;
|
|
6
82
|
/** Told once per lock: the account (as typed), and for how long. */
|
|
7
83
|
onLock = null;
|
|
8
84
|
constructor(policy = DEFAULT_GUARD_POLICY) {
|
|
9
85
|
this.policy = policy;
|
|
10
86
|
}
|
|
11
87
|
static key(account) { return account.trim().toLowerCase().slice(0, 64); }
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
88
|
+
static pair(account, source) {
|
|
89
|
+
const key = sourceKey(source);
|
|
90
|
+
return `${LOCAL_PEERS.has(key) ? LOCAL_SOURCE : key}\u0000${PasswordGuard.key(account)}`;
|
|
91
|
+
}
|
|
92
|
+
/** Prune by the supplied time, even below capacity; retain a lock's escalation history for a day. */
|
|
93
|
+
prune(now) {
|
|
94
|
+
if (now < this.nextPruneAt)
|
|
95
|
+
return;
|
|
96
|
+
this.nextPruneAt = Math.min(this.sources.prune(now, this.policy.maxLockMs), this.slowdowns.prune(now, this.policy.maxLockMs));
|
|
16
97
|
}
|
|
17
|
-
|
|
98
|
+
/** Source-local snapshot for existing callers. Authentication must use preflight to spend the shared budget. */
|
|
99
|
+
lockedFor(account, now, source = currentPasswordSource()) {
|
|
100
|
+
this.prune(now);
|
|
101
|
+
const pair = PasswordGuard.pair(account, source);
|
|
102
|
+
const tries = this.sources.get(pair);
|
|
103
|
+
if (tries === undefined)
|
|
104
|
+
return 0;
|
|
105
|
+
const slowdown = this.slowdowns.get(PasswordGuard.key(account));
|
|
106
|
+
// Legacy observation only: account existence never affects authentication or state retention.
|
|
107
|
+
return Math.max(0, tries.lockedUntil - now, slowdown?.observedAccount === true ? slowdown.until - now : 0);
|
|
108
|
+
}
|
|
109
|
+
/** Reserve one password check, or return its wait. No password derivation may precede this call. */
|
|
110
|
+
preflight(account, now, source = currentPasswordSource(), proven = false) {
|
|
111
|
+
this.prune(now);
|
|
112
|
+
const tries = this.sources.get(PasswordGuard.pair(account, source));
|
|
18
113
|
const key = PasswordGuard.key(account);
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
114
|
+
const slowdown = this.slowdowns.get(key);
|
|
115
|
+
const sourceWait = Math.max(0, (tries?.lockedUntil ?? 0) - now, tries === undefined ? 0 : (slowdown?.until ?? 0) - now);
|
|
116
|
+
if (sourceWait > 0)
|
|
117
|
+
return sourceWait;
|
|
118
|
+
if (proven || slowdown === undefined || slowdown.until <= now)
|
|
119
|
+
return 0;
|
|
120
|
+
slowdown.admissions = slowdown.admissions.filter(at => at > now - ACCOUNT_PASSWORD_WINDOW_MS);
|
|
121
|
+
if (slowdown.admissions.length >= ACCOUNT_PASSWORD_TRIES) {
|
|
122
|
+
return slowdown.admissions[0] + ACCOUNT_PASSWORD_WINDOW_MS - now;
|
|
123
|
+
}
|
|
124
|
+
slowdown.admissions.push(now);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
/** A wrong password; only a database lookup may establish `exists`. */
|
|
128
|
+
failed(account, now, source = currentPasswordSource(), exists = false) {
|
|
129
|
+
this.prune(now);
|
|
130
|
+
const pair = PasswordGuard.pair(account, source);
|
|
131
|
+
const tries = this.sources.retain(pair, () => ({ failures: 0, lockedUntil: 0, locks: 0, lastAt: now }));
|
|
132
|
+
tries.failures += 1;
|
|
133
|
+
tries.lastAt = now;
|
|
134
|
+
this.nextPruneAt = Math.min(this.nextPruneAt, now + this.policy.maxLockMs);
|
|
135
|
+
if (tries.failures >= this.policy.failuresBeforeLock) {
|
|
136
|
+
const lockMs = Math.min(this.policy.maxLockMs, this.policy.firstLockMs * 2 ** tries.locks);
|
|
137
|
+
tries.lockedUntil = now + lockMs;
|
|
138
|
+
tries.locks += 1;
|
|
139
|
+
tries.failures = 0;
|
|
27
140
|
this.onLock?.(account.trim().slice(0, 64), lockMs);
|
|
28
141
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
142
|
+
const key = PasswordGuard.key(account);
|
|
143
|
+
const slowdown = this.slowdowns.retain(key, () => ({ failures: 0, until: 0, lastAt: now, admissions: [], observedAccount: false }));
|
|
144
|
+
slowdown.observedAccount = exists;
|
|
145
|
+
slowdown.failures += 1;
|
|
146
|
+
slowdown.lastAt = now;
|
|
147
|
+
const over = slowdown.failures - this.policy.failuresBeforeLock;
|
|
148
|
+
if (over >= 0)
|
|
149
|
+
slowdown.until = now + Math.min(this.policy.maxLockMs, this.policy.firstSlowMs * 2 ** Math.min(over, 32));
|
|
33
150
|
}
|
|
34
|
-
|
|
35
|
-
|
|
151
|
+
/** The right password clears individually tracked state; shared overflow buckets must expire by time. */
|
|
152
|
+
succeeded(account, source = currentPasswordSource()) {
|
|
153
|
+
const pair = PasswordGuard.pair(account, source);
|
|
154
|
+
this.sources.clear(pair);
|
|
155
|
+
this.slowdowns.clear(PasswordGuard.key(account));
|
|
36
156
|
}
|
|
157
|
+
/** Individually tracked source-and-name entries (at most TRACKED), excluding fixed shared overflow buckets. */
|
|
158
|
+
get size() { return this.sources.size; }
|
|
37
159
|
}
|
|
38
160
|
const guards = new WeakMap();
|
|
39
161
|
/** The guard for one database (a store): every password check against it counts. */
|
|
@@ -59,11 +181,12 @@ export class SourceBudget {
|
|
|
59
181
|
this.windowMs = windowMs;
|
|
60
182
|
}
|
|
61
183
|
/** Milliseconds until this address may try again (0: it may). */
|
|
62
|
-
waitFor(
|
|
63
|
-
const recent = (this.sources.get(
|
|
184
|
+
waitFor(address, now) {
|
|
185
|
+
const recent = (this.sources.get(sourceKey(address)) ?? []).filter(at => now - at < this.windowMs);
|
|
64
186
|
return recent.length < this.tries ? 0 : this.windowMs - (now - recent[0]);
|
|
65
187
|
}
|
|
66
|
-
failed(
|
|
188
|
+
failed(address, now) {
|
|
189
|
+
const source = sourceKey(address);
|
|
67
190
|
const recent = (this.sources.get(source) ?? []).filter(at => now - at < this.windowMs);
|
|
68
191
|
recent.push(now);
|
|
69
192
|
this.sources.delete(source);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key a per-source budget counts under. One IPv6 subscriber is usually handed a whole /64, so keying by the full
|
|
3
|
+
* address would give one caller 2^64 fresh budgets: a native IPv6 address counts as its /64 (`2001:db8:1:2::/64`),
|
|
4
|
+
* however it is spelled. IPv4 stays exact, and an IPv4-mapped IPv6 address (`::ffff:203.0.113.5`) is that IPv4 address.
|
|
5
|
+
* `::1` stays itself, so this computer is never pooled with anything else. The trusted proxy's `fwd:` marker is kept,
|
|
6
|
+
* and anything that isn't an address ("unknown", a tenant id) is returned unchanged.
|
|
7
|
+
*/
|
|
8
|
+
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
|
9
|
+
/** The eight 16-bit groups of an IPv6 address (brackets and a zone ignored), or null when it isn't one. */
|
|
10
|
+
function ipv6Groups(text) {
|
|
11
|
+
let bare = text.trim().replace(/^\[(.*)\]$/, "$1").replace(/%.*$/, "").toLowerCase();
|
|
12
|
+
if (!bare.includes(":") || !/^[0-9a-f:.]+$/.test(bare))
|
|
13
|
+
return null;
|
|
14
|
+
// An embedded IPv4 tail is two groups.
|
|
15
|
+
const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
|
|
16
|
+
if (tail !== null) {
|
|
17
|
+
if (!IPV4.test(tail[2]))
|
|
18
|
+
return null;
|
|
19
|
+
const [a, b, c, d] = tail[2].split(".").map(Number);
|
|
20
|
+
bare = `${tail[1]}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
21
|
+
}
|
|
22
|
+
const halves = bare.split("::");
|
|
23
|
+
if (halves.length > 2)
|
|
24
|
+
return null;
|
|
25
|
+
const part = (one) => one === "" ? [] : one.split(":").every(group => /^[0-9a-f]{1,4}$/.test(group)) ? one.split(":") : null;
|
|
26
|
+
const head = part(halves[0]), rest = halves.length === 2 ? part(halves[1]) : [];
|
|
27
|
+
if (head === null || rest === null)
|
|
28
|
+
return null;
|
|
29
|
+
const missing = 8 - head.length - rest.length;
|
|
30
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0)
|
|
31
|
+
return null;
|
|
32
|
+
return [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest].map(group => parseInt(group, 16));
|
|
33
|
+
}
|
|
34
|
+
/** The budget key for one address, as described above. */
|
|
35
|
+
export function sourceKey(source) {
|
|
36
|
+
if (source.startsWith("fwd:"))
|
|
37
|
+
return `fwd:${sourceKey(source.slice(4))}`;
|
|
38
|
+
if (IPV4.test(source))
|
|
39
|
+
return source;
|
|
40
|
+
const groups = ipv6Groups(source);
|
|
41
|
+
if (groups === null)
|
|
42
|
+
return source;
|
|
43
|
+
if (groups.slice(0, 5).every(group => group === 0) && groups[5] === 0xffff) {
|
|
44
|
+
return `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`;
|
|
45
|
+
}
|
|
46
|
+
if (groups.slice(0, 7).every(group => group === 0) && groups[7] === 1)
|
|
47
|
+
return "::1";
|
|
48
|
+
return `${groups.slice(0, 4).map(group => group.toString(16)).join(":")}::/64`;
|
|
49
|
+
}
|
package/dist/team-http.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import { type TeamActor, type TeamExecute } from './team-contract.js';
|
|
3
3
|
import { type Admission } from './request-budget.js';
|
|
4
4
|
export declare const TEAM_REQUEST_BYTES: number;
|
|
5
|
+
/** How long a password bearer's proof stands on a long /api/team/events stream before its password is checked again.
|
|
6
|
+
* The account, its revocation and its credential generation are still rechecked on every tick. */
|
|
7
|
+
export declare const TEAM_PASSWORD_REVERIFY_MS: number;
|
|
5
8
|
/** Account role belongs to HTTP authentication; API tokens carry their own scope. */
|
|
6
9
|
export type TeamHttpActor = TeamActor & {
|
|
7
10
|
role?: 'approver' | 'viewer';
|
package/dist/team-http.js
CHANGED
|
@@ -2,6 +2,9 @@ import { adapterPolicy } from "./server/route-policy.js";
|
|
|
2
2
|
import { TEAM_OPERATIONS, TEAM_MUTATIONS, teamScopeAllows } from './team-contract.js';
|
|
3
3
|
import { limitWords } from './request-budget.js';
|
|
4
4
|
export const TEAM_REQUEST_BYTES = 64 * 1024;
|
|
5
|
+
/** How long a password bearer's proof stands on a long /api/team/events stream before its password is checked again.
|
|
6
|
+
* The account, its revocation and its credential generation are still rechecked on every tick. */
|
|
7
|
+
export const TEAM_PASSWORD_REVERIFY_MS = 5 * 60_000;
|
|
5
8
|
const failure = (code, message) => ({ version: 1, ok: false, code, message });
|
|
6
9
|
function send(response, status, value, headers = {}) {
|
|
7
10
|
if (response.destroyed || response.writableEnded)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.51",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|