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.
Files changed (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/backup.d.ts +8 -0
  3. package/dist/backup.js +10 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +1 -1
  6. package/dist/browser-workspace.d.ts +1 -0
  7. package/dist/database-reclaim.d.ts +4 -0
  8. package/dist/database-reclaim.js +13 -0
  9. package/dist/demo.d.ts +1 -1
  10. package/dist/demo.js +1 -1
  11. package/dist/desktop-update.d.ts +1 -1
  12. package/dist/live-bus.d.ts +3 -3
  13. package/dist/live-bus.js +3 -3
  14. package/dist/mcp-oauth.d.ts +1 -1
  15. package/dist/mcp-oauth.js +3 -2
  16. package/dist/monitoring.js +32 -8
  17. package/dist/notification-delivery.d.ts +19 -0
  18. package/dist/notification-delivery.js +37 -0
  19. package/dist/operate-remote-arguments.js +1 -1
  20. package/dist/operate-remote.js +2 -2
  21. package/dist/operate.d.ts +1 -1
  22. package/dist/operate.js +54 -120
  23. package/dist/process-recovery-settlement.js +1 -0
  24. package/dist/request-budget.d.ts +5 -2
  25. package/dist/request-budget.js +7 -3
  26. package/dist/retention.js +6 -5
  27. package/dist/scope.d.ts +1 -0
  28. package/dist/scope.js +19 -9
  29. package/dist/serve.js +15 -10
  30. package/dist/server/people-tokens.js +1 -2
  31. package/dist/server/remote.js +19 -4
  32. package/dist/server/settings.js +3 -1
  33. package/dist/server/shared.d.ts +1 -0
  34. package/dist/server/shared.js +2 -0
  35. package/dist/sign-in-guard.d.ts +41 -16
  36. package/dist/sign-in-guard.js +147 -24
  37. package/dist/source-key.d.ts +2 -0
  38. package/dist/source-key.js +49 -0
  39. package/dist/store.d.ts +38 -43
  40. package/dist/store.js +255 -134
  41. package/dist/surface.js +1 -2
  42. package/dist/team-http.d.ts +3 -0
  43. package/dist/team-http.js +3 -0
  44. package/dist/toolroll-update.d.ts +14 -3
  45. package/dist/toolroll-update.js +105 -14
  46. package/dist/webhooks.d.ts +10 -22
  47. package/dist/webhooks.js +43 -81
  48. package/dist/work-index.js +2 -2
  49. package/dist/workspace-revision.d.ts +28 -1
  50. package/dist/workspace-revision.js +276 -49
  51. 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 = passwordGuardOf(store).lockedFor(name ?? "", at);
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));
@@ -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
- return who && account && account.revokedAt === null ? { name: who.name, generation: account.generation, role: account.role } : null;
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. Cookie expiry/revocation
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
  },
@@ -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
  }));
@@ -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[];
@@ -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),
@@ -1,31 +1,56 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  /**
2
- * Password guessing (v99). Five wrong passwords in a row lock that account
3
- * for 15 minutes, doubling with each further lock up to a day; a right one
4
- * clears the count. Every password check counts: signing in, a request that
5
- * carries a password, and each step-up inside the console. Unknown names
6
- * count too, so guessing names gains nothing.
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 accounts;
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
- /** Milliseconds this account stays locked (0: it may try). */
26
- lockedFor(account: string, now: number): number;
27
- failed(account: string, now: number): void;
28
- succeeded(account: string): void;
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(source: string, now: number): number;
44
- failed(source: string, now: number): void;
68
+ waitFor(address: string, now: number): number;
69
+ failed(address: string, now: number): void;
45
70
  }
@@ -1,39 +1,161 @@
1
- export const DEFAULT_GUARD_POLICY = { failuresBeforeLock: 5, firstLockMs: 15 * 60_000, maxLockMs: 24 * 60 * 60_000 };
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
- accounts = new Map();
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
- /** Milliseconds this account stays locked (0: it may try). */
13
- lockedFor(account, now) {
14
- const state = this.accounts.get(PasswordGuard.key(account));
15
- return state === undefined ? 0 : Math.max(0, state.lockedUntil - now);
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
- failed(account, now) {
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 state = this.accounts.get(key) ?? { failures: 0, lockedUntil: 0, locks: 0 };
20
- this.accounts.delete(key);
21
- state.failures += 1;
22
- if (state.failures >= this.policy.failuresBeforeLock) {
23
- const lockMs = Math.min(this.policy.maxLockMs, this.policy.firstLockMs * 2 ** state.locks);
24
- state.lockedUntil = now + lockMs;
25
- state.locks += 1;
26
- state.failures = 0;
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
- this.accounts.set(key, state);
30
- // Bounded: the oldest tracked names go first.
31
- while (this.accounts.size > TRACKED)
32
- this.accounts.delete(this.accounts.keys().next().value);
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
- succeeded(account) {
35
- this.accounts.delete(PasswordGuard.key(account));
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(source, now) {
63
- const recent = (this.sources.get(source) ?? []).filter(at => now - at < this.windowMs);
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(source, now) {
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,2 @@
1
+ /** The budget key for one address, as described above. */
2
+ export declare function sourceKey(source: string): string;
@@ -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 = 113;
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: receipts
1045
- * are the audit trail of what was actually sent.
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
- ledgerCheckpoint(by: string, now: Date): {
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
- /** Read receipts without changing the meaning of shell/webhook history. */
7743
- telegramDeliveries(binding: TelegramBinding): Notification[];
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. */