toolroll 0.9.50 → 0.9.52
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 +21 -0
- package/dist/backup.d.ts +8 -0
- package/dist/backup.js +10 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +1 -1
- package/dist/browser-workspace.d.ts +1 -0
- package/dist/database-reclaim.d.ts +4 -0
- package/dist/database-reclaim.js +13 -0
- package/dist/demo.d.ts +1 -1
- package/dist/demo.js +1 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/live-bus.d.ts +3 -3
- package/dist/live-bus.js +3 -3
- package/dist/mcp-oauth.d.ts +1 -1
- package/dist/mcp-oauth.js +3 -2
- package/dist/monitoring.js +32 -8
- package/dist/notification-delivery.d.ts +19 -0
- package/dist/notification-delivery.js +37 -0
- package/dist/operate-remote-arguments.js +1 -1
- package/dist/operate-remote.js +2 -2
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +54 -120
- package/dist/process-recovery-settlement.js +1 -0
- package/dist/request-budget.d.ts +5 -2
- package/dist/request-budget.js +7 -3
- package/dist/retention.js +6 -5
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +19 -9
- package/dist/serve.js +15 -10
- package/dist/server/people-tokens.js +1 -2
- package/dist/server/remote.js +19 -4
- package/dist/server/settings.js +3 -1
- package/dist/server/shared.d.ts +1 -0
- package/dist/server/shared.js +2 -0
- 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/store.d.ts +38 -43
- package/dist/store.js +255 -134
- package/dist/surface.js +1 -2
- package/dist/team-http.d.ts +3 -0
- package/dist/team-http.js +3 -0
- package/dist/toolroll-update.d.ts +14 -3
- package/dist/toolroll-update.js +105 -14
- package/dist/webhooks.d.ts +10 -22
- package/dist/webhooks.js +43 -81
- package/dist/work-index.js +2 -2
- package/dist/workspace-revision.d.ts +28 -1
- package/dist/workspace-revision.js +276 -49
- package/package.json +1 -3
|
@@ -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/server/settings.js
CHANGED
|
@@ -1111,6 +1111,8 @@ export function createSettingsHandlers(runtime) {
|
|
|
1111
1111
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
1112
1112
|
return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
|
|
1113
1113
|
const made = await backupNow(store, databaseFile, "manual", clock);
|
|
1114
|
+
if (made.ok && made.checkpoint !== null && "problem" in made.checkpoint)
|
|
1115
|
+
return redirect(response, `/settings/backups?problem=${encodeURIComponent(`Backed up. The activity log failed its tamper check, so it was not anchored: ${made.checkpoint.problem}`)}`);
|
|
1114
1116
|
return redirect(response, made.ok ? `/settings/backups?said=${encodeURIComponent("Backed up.")}` : `/settings/backups?problem=${encodeURIComponent(`The backup failed: ${made.error}`)}`);
|
|
1115
1117
|
}
|
|
1116
1118
|
// Sprint 8: change the backup schedule. A step-up; the ledger keeps before → after.
|
|
@@ -1978,7 +1980,7 @@ export function createSettingsHandlers(runtime) {
|
|
|
1978
1980
|
const existing = loadBotToken({}, options.telegramTokenFile);
|
|
1979
1981
|
const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
|
|
1980
1982
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
1981
|
-
return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : defaultProject, "settings"), existing, hasEnv, csrf, saved.message, null, null, null, null, {
|
|
1983
|
+
return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : defaultProject, "settings"), existing, hasEnv, csrf, saved.message, options.configDir === undefined ? null : effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null), null, null, null, {
|
|
1982
1984
|
...store.permissionDefault(),
|
|
1983
1985
|
canManage: who.role === "approver",
|
|
1984
1986
|
}));
|
package/dist/server/shared.d.ts
CHANGED
|
@@ -3138,6 +3138,7 @@ export declare function settingsPage(chrome: Chrome, existing: TokenSource | nul
|
|
|
3138
3138
|
channel: string | null;
|
|
3139
3139
|
implicit: boolean;
|
|
3140
3140
|
configured: string[];
|
|
3141
|
+
legacyWarning?: string;
|
|
3141
3142
|
} | null, push?: {
|
|
3142
3143
|
available: boolean;
|
|
3143
3144
|
devices: PushSubscription[];
|
package/dist/server/shared.js
CHANGED
|
@@ -12313,6 +12313,7 @@ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging
|
|
|
12313
12313
|
kind: "settings",
|
|
12314
12314
|
said: problem,
|
|
12315
12315
|
groups: settingsGroups(messaging, telegramFailing),
|
|
12316
|
+
...(messaging?.legacyWarning ? { legacyWebhookWarning: messaging.legacyWarning } : {}),
|
|
12316
12317
|
theme,
|
|
12317
12318
|
accent: requestContext.getStore()?.accent ?? DEFAULT_ACCENT,
|
|
12318
12319
|
accentPresets: ACCENT_PRESETS,
|
|
@@ -12346,6 +12347,7 @@ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging
|
|
|
12346
12347
|
};
|
|
12347
12348
|
return screen("Settings", [
|
|
12348
12349
|
"<h1>Settings</h1>",
|
|
12350
|
+
messaging?.legacyWarning ? `<p data-legacy-webhooks>Legacy webhooks are deprecated. Connect <a href="/settings/slack" style="display:inline-flex;min-height:44px;align-items:center">Slack</a> or <a href="/settings/discord" style="display:inline-flex;min-height:44px;align-items:center">Discord</a> in Chat settings.</p>` : "",
|
|
12349
12351
|
settingsTiles(view.groups),
|
|
12350
12352
|
phone === null ? "" : phoneSetupHtml(phone),
|
|
12351
12353
|
appearanceCard(csrf),
|
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/store.d.ts
CHANGED
|
@@ -152,7 +152,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
152
152
|
hash: string;
|
|
153
153
|
} | null;
|
|
154
154
|
};
|
|
155
|
-
export declare const SCHEMA_VERSION =
|
|
155
|
+
export declare const SCHEMA_VERSION = 114;
|
|
156
156
|
/**
|
|
157
157
|
* The migrations `toolroll update` may carry a database through in place: each
|
|
158
158
|
* one only adds (a table, a column, an accepted value) and leaves every saved row
|
|
@@ -166,6 +166,11 @@ export declare const SCHEMA_VERSION = 113;
|
|
|
166
166
|
* api_token columns (project limit, rotation); existing tokens read as before.
|
|
167
167
|
* v112 only adds the two request-budget tables.
|
|
168
168
|
* v113 adds MCP sign-in tables and API-token purpose metadata.
|
|
169
|
+
* v114 is the one declared exception to "only adds", and the rehearsal checks
|
|
170
|
+
* it as such: a settled run's exited process witnesses become one summary row
|
|
171
|
+
* (rows removed equal the witnesses summarized), and notification drops its
|
|
172
|
+
* delivery columns only after every value moves into one legacy receipt per row
|
|
173
|
+
* (rows and ids carried whole; legacy receipts added equal the rows carried).
|
|
169
174
|
*/
|
|
170
175
|
export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
|
|
171
176
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
@@ -1030,19 +1035,15 @@ export type Notification = {
|
|
|
1030
1035
|
subject: string;
|
|
1031
1036
|
body: string;
|
|
1032
1037
|
createdAt: string;
|
|
1033
|
-
attempts: number;
|
|
1034
|
-
lastAttemptAt: string | null;
|
|
1035
|
-
lastError: string | null;
|
|
1036
|
-
deliveredAt: string | null;
|
|
1037
|
-
receipt: string | null;
|
|
1038
1038
|
/** The closed push attention class, or null = this fact never pushes. */
|
|
1039
1039
|
pushClass: "decision" | "pick" | "merge" | "attention" | "progress" | null;
|
|
1040
1040
|
/** The machine-minted console path a push may deep-link — never free text. */
|
|
1041
1041
|
link: string | null;
|
|
1042
1042
|
/**
|
|
1043
1043
|
* When the fact stopped wanting a person — a decision answered, an incident
|
|
1044
|
-
* resolved. Distinct from delivery, and it never deletes the row:
|
|
1045
|
-
* are the audit trail of
|
|
1044
|
+
* resolved. Distinct from delivery, and it never deletes the row: the
|
|
1045
|
+
* per-destination receipts (notification_delivery) are the audit trail of
|
|
1046
|
+
* what was actually sent.
|
|
1046
1047
|
*/
|
|
1047
1048
|
resolvedAt: string | null;
|
|
1048
1049
|
scope: "unknown" | "installation" | "project" | "task";
|
|
@@ -1051,6 +1052,14 @@ export type Notification = {
|
|
|
1051
1052
|
taskId: string | null;
|
|
1052
1053
|
run: number | null;
|
|
1053
1054
|
};
|
|
1055
|
+
/** One notification's delivery state at one destination (notification_delivery). */
|
|
1056
|
+
export type NotificationReceipt = Notification & {
|
|
1057
|
+
attempts: number;
|
|
1058
|
+
lastAttemptAt: string | null;
|
|
1059
|
+
lastError: string | null;
|
|
1060
|
+
deliveredAt: string | null;
|
|
1061
|
+
receipt: string | null;
|
|
1062
|
+
};
|
|
1054
1063
|
export type TelegramDelivery = Notification & {
|
|
1055
1064
|
destination: string;
|
|
1056
1065
|
claimGeneration: number;
|
|
@@ -1868,6 +1877,9 @@ export declare function openStoreNoMigrate(file: string, options?: OpenOptions):
|
|
|
1868
1877
|
* retries. Correction children (the same-session structured-output
|
|
1869
1878
|
* repairs) are not attempts; they continue one. */
|
|
1870
1879
|
export declare const REVIEW_ROOT_ATTEMPTS = 3;
|
|
1880
|
+
/** The receipt v114 keeps a notification's old single-destination delivery under. No sender claims it: every real
|
|
1881
|
+
* destination is a kind and a 64-character hash, or a Telegram binding. */
|
|
1882
|
+
export declare const LEGACY_NOTIFICATION_DESTINATION = "legacy:single-destination";
|
|
1871
1883
|
/** v34: artifact.kind admits 'report'. A table already rebuilt to the v38
|
|
1872
1884
|
* shape (migrate() runs unconditionally on every open, including a fresh
|
|
1873
1885
|
* database created straight at the current shape) is a DONE shape too —
|
|
@@ -2489,8 +2501,16 @@ export declare class Store {
|
|
|
2489
2501
|
fresh?: number;
|
|
2490
2502
|
}): LedgerChainReport;
|
|
2491
2503
|
/** A checkpoint of the chain as it stands: its head, recorded here and handed back to be copied off the machine.
|
|
2492
|
-
* Never over a chain that doesn't verify: that would vouch for what broke it.
|
|
2493
|
-
|
|
2504
|
+
* Never over a chain that doesn't verify: that would vouch for what broke it. `automatic` (before a backup, after
|
|
2505
|
+
* a monitoring delivery): at the verified head, or at `head` (one that was sent) once the walk reached it, only past
|
|
2506
|
+
* the newest checkpoint (null otherwise), and with no ledger entry of its own, so a checkpoint never makes new
|
|
2507
|
+
* activity for the next backup or delivery to checkpoint again. */
|
|
2508
|
+
ledgerCheckpoint(by: string, now: Date, automatic?: {
|
|
2509
|
+
head?: {
|
|
2510
|
+
through: number;
|
|
2511
|
+
hash: string;
|
|
2512
|
+
};
|
|
2513
|
+
}): {
|
|
2494
2514
|
through: number;
|
|
2495
2515
|
hash: string;
|
|
2496
2516
|
} | {
|
|
@@ -4869,13 +4889,6 @@ export declare class Store {
|
|
|
4869
4889
|
notificationById(id: number): Notification | null;
|
|
4870
4890
|
notificationsAfter(id: number, limit?: number): Notification[];
|
|
4871
4891
|
listNotifications(only?: "pending" | "all"): Notification[];
|
|
4872
|
-
recordDelivery(id: number, outcome: {
|
|
4873
|
-
ok: true;
|
|
4874
|
-
receipt: string | null;
|
|
4875
|
-
} | {
|
|
4876
|
-
ok: false;
|
|
4877
|
-
error: string;
|
|
4878
|
-
}, now: Date): void;
|
|
4879
4892
|
/**
|
|
4880
4893
|
* Close a gap's notification episode. Called when its capability verifies:
|
|
4881
4894
|
* the next failure is a *new* fact, and a dedupe key that suppressed it
|
|
@@ -7448,6 +7461,12 @@ export declare class Store {
|
|
|
7448
7461
|
* every finished run is visited, a batch at a time. Never signals. */
|
|
7449
7462
|
recordFinishedRunExits(now: Date, batch?: number): number;
|
|
7450
7463
|
finishUnspawnedProcess(witness: number, now: Date): void;
|
|
7464
|
+
/** Settled custody, compacted (v114; compactSettledRunProcesses): a finished run whose witnesses all exited keeps
|
|
7465
|
+
* one summary row. A settled stop, an approver's settlement and a recovery compact their run at once; the worker's
|
|
7466
|
+
* reconcile pass (recordFinishedRunExits) compacts every other settled run, including one that settled at run end. */
|
|
7467
|
+
compactRunProcesses(now: Date, only?: number): number;
|
|
7468
|
+
/** Whether a run's witnesses were compacted into its summary: they existed, and all exited. */
|
|
7469
|
+
processesSummarized(runId: number): boolean;
|
|
7451
7470
|
/** The reconcile road for a pid-less witness a crash or a thrown spawn
|
|
7452
7471
|
* left behind: once its run has finished, no live child is tracked for it
|
|
7453
7472
|
* here, every other witness of the run has exited and none of the run's
|
|
@@ -7739,24 +7758,8 @@ export declare class Store {
|
|
|
7739
7758
|
error: string;
|
|
7740
7759
|
retryAt?: string;
|
|
7741
7760
|
}, now: Date): boolean;
|
|
7742
|
-
/**
|
|
7743
|
-
telegramDeliveries(binding: TelegramBinding):
|
|
7744
|
-
/**
|
|
7745
|
-
* Claim pending notifications for one deliverer. Two deliverers — the
|
|
7746
|
-
* bridge and `outbox deliver` — select-then-send-then-record, and without
|
|
7747
|
-
* a claim both can send the same row in the gap. The claim is a short
|
|
7748
|
-
* lease on the act of sending; a deliverer that dies mid-send leaves rows
|
|
7749
|
-
* that unclaim themselves by expiry.
|
|
7750
|
-
*/
|
|
7751
|
-
claimDeliveries(owner: string, ttlMs: number, now: Date, only?: "all" | "urgent"): Notification[];
|
|
7752
|
-
/** Finalize only what this owner still holds. A lapsed claim writes nothing. */
|
|
7753
|
-
finalizeDelivery(id: number, owner: string, outcome: {
|
|
7754
|
-
ok: true;
|
|
7755
|
-
receipt: string | null;
|
|
7756
|
-
} | {
|
|
7757
|
-
ok: false;
|
|
7758
|
-
error: string;
|
|
7759
|
-
}, now: Date): boolean;
|
|
7761
|
+
/** The notifications this pairing holds, each with its delivery state there. */
|
|
7762
|
+
telegramDeliveries(binding: TelegramBinding): NotificationReceipt[];
|
|
7760
7763
|
/** The digest cadence: null everyMs = off. */
|
|
7761
7764
|
telegramDigest(): TelegramDigest;
|
|
7762
7765
|
/** Set (or clear, with null) the cadence. Turning it on starts the
|
|
@@ -7879,14 +7882,6 @@ export declare class Store {
|
|
|
7879
7882
|
/** The message now showing this card, and what it shows. */
|
|
7880
7883
|
setChatCardMessage(id: number, message: string, digest: string): void;
|
|
7881
7884
|
markTelegramDigestSent(now: Date): void;
|
|
7882
|
-
/** Every batched row finalized delivered AND the anchor moved, in ONE
|
|
7883
|
-
* transaction (v4 review, finding 5): a digest is either recorded whole
|
|
7884
|
-
* or not at all. Rows whose claim lapsed meanwhile are reported, not
|
|
7885
|
-
* silently skipped. */
|
|
7886
|
-
finalizeDigest(ids: readonly number[], owner: string, receipt: string | null, now: Date): {
|
|
7887
|
-
finalized: number;
|
|
7888
|
-
lapsed: number;
|
|
7889
|
-
};
|
|
7890
7885
|
/** Routine rows waiting for the next digest: pending, and not urgent.
|
|
7891
7886
|
* A row the live pairing delivered, or skipped as pre-pairing history,
|
|
7892
7887
|
* is settled and not waiting. */
|