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
package/dist/operate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { notificationClaimHeld, claimNotifications, finalizeNotification, notificationDestination } from "./notification-delivery.js";
1
2
  import { adapterPolicy } from "./server/route-policy.js";
2
3
  import { UNSENT_REPLY_MS } from "./telegram-settings.js";
3
4
  import { checkPublicCommand } from "./public-check.js";
@@ -47,8 +48,6 @@ import { runRequestedUndos, sendTeammateWeeklies } from "./teammate-week.js";
47
48
  import { refreshConnections } from "./mcp-connect.js";
48
49
  import { followDiscord } from "./discord.js";
49
50
  import { followTeams } from "./teams.js";
50
- import { loadDiscordCredentials } from "./discord-api.js";
51
- import { loadSlackCredentials } from "./slack-api.js";
52
51
  import { followSlack } from "./slack.js";
53
52
  import { validateScopeText } from "./task-text.js";
54
53
  import { runMemoryCommand } from "./memory-cli.js";
@@ -98,6 +97,7 @@ import { pushPass } from "./push.js";
98
97
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
99
98
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
100
99
  import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
100
+ import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
101
101
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
102
102
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
103
103
  import { createServer as createNetServer } from "node:net";
@@ -196,7 +196,7 @@ import { isRiskLevel, isTaskSize, legOf, projectRoute, riskConsequence, routeDig
196
196
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
197
197
  import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
198
198
  import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
199
- import { clearWebhook, effectivePrimary, isMessagingChannel, loadConsoleUrl, loadPrimary, loadWebhookTargets, phoneOrigin, saveConsoleUrl, savePrimary, saveWebhook, webhookPass, SLACK_ENV, DISCORD_ENV } from "./webhooks.js";
199
+ import { activeWebhookTargets, webhookPass, LEGACY_WEBHOOK_WARNING, loadWebhookTargets, effectivePrimary, isMessagingChannel, loadConsoleUrl, phoneOrigin, saveConsoleUrl, savePrimary } from "./webhooks.js";
200
200
  import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
201
201
  import { attestProvider, attestationOf, versionInRange } from "./attest.js";
202
202
  import { recognizesEligible } from "./exhaustion.js";
@@ -574,21 +574,14 @@ Agents — which provider and model each phase runs on
574
574
  says REVIEW was not read)
575
575
 
576
576
  The outbox — facts that want a person, durably
577
- toolroll webhook set slack|discord <url>
578
- UI-only chat mirrors: every page a
579
- message with a console link; acting
580
- stays in the console. Delivers when
581
- Telegram is not configured.
582
577
  toolroll webhook set console-url <http://host:port>
583
578
  toolroll webhook primary telegram|slack|discord
584
579
  which service receives alerts when
585
580
  several are connected (asked once,
586
581
  the first time you add a second)
587
- toolroll webhook status | test | clear slack|discord
588
- toolroll outbox list [--all]
589
- toolroll outbox deliver --cmd <c> runs once per pending row, reading
590
- $TOOLROLL_KIND / _SUBJECT / _BODY;
591
- exit 0 delivered receipts, 1 any fail
582
+ toolroll webhook status
583
+ toolroll outbox list [--all] pending facts (--all: resolved too)
584
+ toolroll outbox deliver --cmd <cmd> deliver through an operator command
592
585
 
593
586
  Runners — the machines that may be given work
594
587
  toolroll runner register <name> [--capacity <n>] [--token-file <path>]
@@ -760,8 +753,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
760
753
  }
761
754
  return { positional, flags, repoList };
762
755
  }
763
- /** Route an `operate` command. Returns the process exit code. */
756
+ /** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
764
757
  export async function runOperate(command, argv, write, options = {}) {
758
+ return withPasswordSource(CLI_PASSWORD_SOURCE, () => runOperateLocal(command, argv, write, options));
759
+ }
760
+ async function runOperateLocal(command, argv, write, options = {}) {
765
761
  const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
766
762
  // A parse error precedes the flags map, so JSON mode is read from the raw
767
763
  // argv — the envelope contract holds even for the earliest refusal.
@@ -1059,8 +1055,8 @@ function telegramConversation(context, options = {}) {
1059
1055
  evidenceRoot: context.evidenceRoot,
1060
1056
  ...(context.mateSeams?.subscriptionRunner === undefined ? {} : { subscriptionRunner: context.mateSeams.subscriptionRunner }),
1061
1057
  ...(context.heldCoordinator === undefined ? {} : { held: context.heldCoordinator }),
1062
- // Re-read on every card and every `/task`: the same console-url the
1063
- // mirrors use, held to an https origin, and — inside `up`, where this
1058
+ // Re-read on every card and every `/task`: the same console-url chat
1059
+ // links use, held to an https origin, and — inside `up`, where this
1064
1060
  // process also serves the console — equal to that console's own
1065
1061
  // `--public-url`, or no link at all.
1066
1062
  phoneOrigin: () => phoneOrigin(process.env, dirname(context.databaseFile), { serverOrigin: options.serverOrigin ?? null }),
@@ -4918,7 +4914,7 @@ async function briefCommand(flags, context) {
4918
4914
  lines.push(` #${pull.number} ${pull.title}`);
4919
4915
  }
4920
4916
  if (pending.length > 0) {
4921
- lines.push(` ▸ OUTBOX ${pending.length} undelivered — toolroll outbox deliver --cmd …`);
4917
+ lines.push(` ▸ OUTBOX ${pending.length} undelivered — toolroll outbox list`);
4922
4918
  }
4923
4919
  if (decisions.length > 0) {
4924
4920
  const overdue = decisions.filter(one => one.state === "expired").length;
@@ -5425,18 +5421,16 @@ async function incidentCommand(positional, flags, context) {
5425
5421
  ]);
5426
5422
  }
5427
5423
  /**
5428
- * `toolroll webhook …` — Slack and Discord as UI-ONLY mirrors: every
5429
- * page is a message with a console link; acting stays in the console
5430
- * behind its own authentication. The URL is a credential: 0600 file
5431
- * beside the database, or the environment, never anywhere else.
5424
+ * `toolroll webhook status|primary|set console-url` — messaging settings:
5425
+ * which connected chat service receives alerts, and the console address
5426
+ * chat links open.
5432
5427
  */
5433
- async function webhookCommand(positional, flags, context) {
5434
- const { store, write, json, clock } = context;
5428
+ async function webhookCommand(positional, _flags, context) {
5429
+ const { write, json } = context;
5435
5430
  const dir = dirname(context.databaseFile);
5436
5431
  const [action, which, value] = positional;
5437
5432
  const telegramConfigured = loadBotToken(process.env, context.telegramTokenFile) !== null;
5438
5433
  if (action === undefined || action === "status") {
5439
- const targets = loadWebhookTargets(process.env, dir);
5440
5434
  const consoleUrl = loadConsoleUrl(process.env, dir);
5441
5435
  const primary = effectivePrimary(process.env, dir, telegramConfigured);
5442
5436
  if (json) {
@@ -5452,29 +5446,8 @@ async function webhookCommand(positional, flags, context) {
5452
5446
  write(` Choose: toolroll webhook primary telegram|slack|discord`);
5453
5447
  }
5454
5448
  write(` links ${consoleUrl ?? "NOT SET — messages will carry no console link; toolroll webhook set console-url http://host:port"}`);
5455
- if (targets.length === 0) {
5456
- write("");
5457
- write(" toolroll webhook set slack https://hooks.slack.com/services/…");
5458
- write(" toolroll webhook set discord https://discord.com/api/webhooks/…");
5459
- write(` (or export ${SLACK_ENV} / ${DISCORD_ENV})`);
5460
- }
5461
- write("");
5462
- write(" Mirrors deliver when Telegram is not configured; with a paired Telegram");
5463
- write(" chat, Telegram carries the page (it can hold buttons) and mirrors stay quiet.");
5464
5449
  return EXIT.ok;
5465
5450
  }
5466
- if (action === "test") {
5467
- const targets = loadWebhookTargets(process.env, dir);
5468
- if (targets.length === 0) {
5469
- return fail(write, json, "webhook test", "unconfigured", "no webhook configured — `toolroll webhook set slack|discord <url>`", EXIT.refused);
5470
- }
5471
- store.enqueueNotification({ source: { installation: true }, dedupeKey: `webhook-test:${clock().getTime()}`, kind: "test", subject: "toolroll webhook test", body: "If you can read this, the mirror works. Acting happens in the console." }, clock());
5472
- const report = await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock });
5473
- if (report.problems.length > 0) {
5474
- return fail(write, json, "webhook test", "delivery", report.problems.join("; "), EXIT.failed);
5475
- }
5476
- return succeed(write, json, "webhook test", { sent: report.sent }, () => [`Sent ${report.sent} message(s). Check the channel.`]);
5477
- }
5478
5451
  if (action === "primary") {
5479
5452
  if (which === undefined || !isMessagingChannel(which)) {
5480
5453
  return fail(write, json, "webhook primary", "usage", "primary is one of telegram, slack, discord", EXIT.usage);
@@ -5488,46 +5461,13 @@ async function webhookCommand(positional, flags, context) {
5488
5461
  `Alerts now go to ${which}. ${which === "telegram" ? "Buttons and replies work there as always." : "Telegram (if configured) still accepts taps and replies — it just stops sending alerts."}`,
5489
5462
  ]);
5490
5463
  }
5491
- if (action === "clear" && (which === "slack" || which === "discord")) {
5492
- clearWebhook(dir, which);
5493
- return succeed(write, json, "webhook clear", { which }, () => [`${which} mirror cleared.`]);
5494
- }
5495
- if (action !== "set" || which === undefined || value === undefined) {
5496
- return fail(write, json, "webhook", "usage", "`toolroll webhook [status|test|set slack|discord|console-url <value>|clear slack|discord]`", EXIT.usage);
5497
- }
5498
- if (which === "console-url") {
5499
- const saved = saveConsoleUrl(dir, value);
5500
- if (!saved.ok)
5501
- return fail(write, json, "webhook set", "invalid", saved.message, EXIT.usage);
5502
- return succeed(write, json, "webhook set", { which }, () => [`Console links will open ${value.replace(/\/+$/, "")}.`]);
5503
- }
5504
- if (which !== "slack" && which !== "discord") {
5505
- return fail(write, json, "webhook set", "usage", "set what? slack, discord, or console-url", EXIT.usage);
5464
+ if (action !== "set" || which !== "console-url" || value === undefined) {
5465
+ return fail(write, json, "webhook", "usage", "`toolroll webhook [status|primary <service>|set console-url <url>]`", EXIT.usage);
5506
5466
  }
5507
- const saved = saveWebhook(dir, which, value);
5467
+ const saved = saveConsoleUrl(dir, value);
5508
5468
  if (!saved.ok)
5509
5469
  return fail(write, json, "webhook set", "invalid", saved.message, EXIT.usage);
5510
- // The first moment more than one service exists is the moment to ask
5511
- // which one pages — once, right here, not at 3am when both fire.
5512
- const after = effectivePrimary(process.env, dir, telegramConfigured);
5513
- let chosen = null;
5514
- if (after.implicit && loadPrimary(process.env, dir) === null && after.configured.length > 1 && interactive() && !json) {
5515
- write(`You now have ${after.configured.join(" and ")} connected.`);
5516
- const answer = (await ask(`Which service should receive alerts? [${after.configured.join("/")}] `)).trim().toLowerCase();
5517
- if (isMessagingChannel(answer) && after.configured.includes(answer)) {
5518
- savePrimary(dir, answer);
5519
- chosen = answer;
5520
- }
5521
- else {
5522
- write(`Left unchosen — ${after.channel} receives alerts by default. Decide any time: toolroll webhook primary <service>`);
5523
- }
5524
- }
5525
- return succeed(write, json, "webhook set", { which, ...(chosen === null ? {} : { primary: chosen }) }, () => [
5526
- `${which} mirror configured — the URL lives in a private file beside the database.`,
5527
- ...(chosen === null ? [] : [`${chosen} carries the pages.`]),
5528
- ...(after.implicit && chosen === null && !interactive() ? [`Several services are configured — choose the pager: toolroll webhook primary <service>`] : []),
5529
- `Send yourself a proof: toolroll webhook test`,
5530
- ]);
5470
+ return succeed(write, json, "webhook set", { which }, () => [`Console links will open ${value.replace(/\/+$/, "")}.`]);
5531
5471
  }
5532
5472
  /**
5533
5473
  * `toolroll providers resume <provider>` — a person says the sign-in works
@@ -7861,6 +7801,7 @@ async function runWatchLoop(args) {
7861
7801
  let lastTick = 0;
7862
7802
  let lastBridge = 0;
7863
7803
  let lastPush = 0;
7804
+ let lastWebhook = 0;
7864
7805
  let ticks = 0;
7865
7806
  let built = 0;
7866
7807
  let brokeCount = 0;
@@ -8156,6 +8097,14 @@ async function runWatchLoop(args) {
8156
8097
  // Push is additive; a broken pass never stops the watch.
8157
8098
  }
8158
8099
  }
8100
+ if (now - lastWebhook >= bridgeEveryMs) {
8101
+ lastWebhook = now;
8102
+ const dir = dirname(context.databaseFile);
8103
+ const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
8104
+ const targets = activeWebhookTargets(process.env, dir, primary.channel);
8105
+ if (targets.length > 0)
8106
+ await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
8107
+ }
8159
8108
  // The embedded follower owns the wire while it lives; the timer-driven
8160
8109
  // pass is the fallback shape for a watch started before a token existed.
8161
8110
  if (follower === null && now - lastBridge >= bridgeEveryMs) {
@@ -8171,12 +8120,6 @@ async function runWatchLoop(args) {
8171
8120
  quiet.length = 0;
8172
8121
  await bridgeCommand(["telegram"], passFlags(primary.channel === "telegram" ? {} : { "inbound-only": "1" }), quietContext);
8173
8122
  }
8174
- if (primary.channel !== null && primary.channel !== "telegram") {
8175
- const targets = loadWebhookTargets(process.env, dir).filter(one => one.kind === primary.channel && (one.kind !== "slack" || loadSlackCredentials(dir) === null) && (one.kind !== "discord" || loadDiscordCredentials(dir) === null));
8176
- if (targets.length > 0) {
8177
- await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
8178
- }
8179
- }
8180
8123
  }
8181
8124
  // Work-conserving: if the world moved while we worked — or we just
8182
8125
  // finished something that may have freed a dependent — go again now.
@@ -8190,7 +8133,7 @@ async function runWatchLoop(args) {
8190
8133
  // due, a signal lands, or the trial window (--for) ends. Reading one
8191
8134
  // integer from SQLite twice a second is the entire idle cost — no
8192
8135
  // token has anywhere to be spent from here.
8193
- const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
8136
+ const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, lastWebhook + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
8194
8137
  // Doze for the WHOLE idle window, waking early only for a signal or a
8195
8138
  // sequence move — not for one 500ms step and then another pass
8196
8139
  // (2026-09-05: that read as two ticks a second, all night, with
@@ -9800,18 +9743,6 @@ async function publishCommand(positional, flags, context) {
9800
9743
  return succeed(write, json, "publish", { report }, lines);
9801
9744
  }
9802
9745
  // ---- the outbox -----------------------------------------------------------
9803
- /**
9804
- * `toolroll outbox list|deliver` — reading and draining the durable
9805
- * outbox. Delivery runs an operator-supplied command once per pending row;
9806
- * the notification's text reaches it as environment variables, never
9807
- * substituted into the command line, because subjects and bodies quote
9808
- * things agents and repositories said and a shell must not meet those.
9809
- *
9810
- * toolroll outbox deliver --cmd 'curl -d "$TOOLROLL_SUBJECT" ntfy.sh/mine'
9811
- *
9812
- * Exit 0 when everything pending delivered (or nothing was pending);
9813
- * 1 when any delivery failed — a broken channel is breakage, not a "no".
9814
- */
9815
9746
  /**
9816
9747
  * `toolroll peek [<run>] [--tmux] [--lines <n>]` — watch live agents
9817
9748
  * in the terminal: one pane per open run with its stage and transcript
@@ -9861,6 +9792,11 @@ async function peekCommand(positional, flags, context) {
9861
9792
  }
9862
9793
  return runPeek(store, context.evidenceRoot, { ...(runId === undefined ? {} : { runId }), io: { stdout: process.stdout, stdin: process.stdin }, clock });
9863
9794
  }
9795
+ /**
9796
+ * `toolroll outbox list [--all]` — read the durable outbox: the facts that
9797
+ * want a person, pending until resolved. An operator command can also deliver
9798
+ * them with its own destination receipts.
9799
+ */
9864
9800
  async function outboxCommand(positional, flags, context) {
9865
9801
  const { store, write, json, clock } = context;
9866
9802
  const [action] = positional;
@@ -9872,17 +9808,12 @@ async function outboxCommand(positional, flags, context) {
9872
9808
  return EXIT.ok;
9873
9809
  }
9874
9810
  if (notifications.length === 0) {
9875
- write(wanted === "pending" ? "Nothing waiting to be delivered." : "The outbox is empty.");
9811
+ write(wanted === "pending" ? "Nothing pending." : "The outbox is empty.");
9876
9812
  return EXIT.ok;
9877
9813
  }
9878
9814
  for (const one of notifications) {
9879
- const state = one.deliveredAt !== null
9880
- ? `delivered ${one.deliveredAt}`
9881
- : one.attempts > 0
9882
- ? `pending, ${one.attempts} failed attempt(s): ${one.lastError ?? ""}`
9883
- : "pending";
9884
9815
  write(` #${one.id} ${one.kind.padEnd(18)} ${one.subject}`);
9885
- write(` ${state}`);
9816
+ write(` ${one.resolvedAt === null ? "pending" : `resolved ${one.resolvedAt}`}`);
9886
9817
  }
9887
9818
  return EXIT.ok;
9888
9819
  }
@@ -9894,14 +9825,9 @@ async function outboxCommand(positional, flags, context) {
9894
9825
  if (command === undefined) {
9895
9826
  return fail(write, json, "outbox deliver", "usage", "--cmd says how: it runs once per notification, reading $TOOLROLL_KIND, $TOOLROLL_SUBJECT, $TOOLROLL_BODY", EXIT.usage);
9896
9827
  }
9897
- // Claimed, not merely listed: the Telegram bridge drains this same
9898
- // outbox, and select-then-send-then-record from two deliverers pages a
9899
- // person twice. The claim is a short lease on the act of sending; a
9900
- // deliverer that dies mid-send leaves rows that unclaim by expiry.
9828
+ // The exact command owns a leased destination receipt, including across concurrent CLI invocations.
9901
9829
  const owner = `outbox-${randomUUID()}`;
9902
- // Push first, independently (arc 3 finding 8): its pair ledger does not
9903
- // depend on globally-undelivered notifications — Telegram or a webhook
9904
- // may already have stamped delivered_at.
9830
+ // Push delivery has independent receipts and must keep working if this command fails.
9905
9831
  let pushed = 0;
9906
9832
  try {
9907
9833
  pushed = (await pushPass(store, { configDir: dirname(context.databaseFile), clock })).accepted;
@@ -9909,7 +9835,7 @@ async function outboxCommand(positional, flags, context) {
9909
9835
  catch {
9910
9836
  // additive; the shell delivery below still runs
9911
9837
  }
9912
- const pending = store.claimDeliveries(owner, 2 * 60_000, clock());
9838
+ const pending = claimNotifications(store, notificationDestination("command", command), owner, clock());
9913
9839
  if (pending.length === 0) {
9914
9840
  return succeed(write, json, "outbox deliver", { delivered: 0, failed: 0, pushed }, () => [
9915
9841
  pushed > 0 ? `Nothing for the shell command; ${pushed} push(es) accepted.` : "Nothing waiting to be delivered.",
@@ -9918,6 +9844,10 @@ async function outboxCommand(positional, flags, context) {
9918
9844
  let delivered = 0;
9919
9845
  let failed = 0;
9920
9846
  for (const one of pending) {
9847
+ if (!notificationClaimHeld(store, one, owner, clock())) {
9848
+ failed++;
9849
+ continue;
9850
+ }
9921
9851
  const sent = await run("sh", ["-lc", command], {
9922
9852
  timeoutMs: 30_000,
9923
9853
  env: {
@@ -9929,14 +9859,16 @@ async function outboxCommand(positional, flags, context) {
9929
9859
  });
9930
9860
  if (sent.code === 0) {
9931
9861
  const receipt = sent.stdout.split("\n")[0]?.trim() ?? "";
9932
- store.finalizeDelivery(one.id, owner, { ok: true, receipt: receipt === "" ? null : receipt }, clock());
9933
- delivered++;
9862
+ if (finalizeNotification(store, one, owner, { ok: true, receipt: receipt === "" ? null : receipt }, clock()))
9863
+ delivered++;
9864
+ else
9865
+ failed++;
9934
9866
  }
9935
9867
  else {
9936
9868
  const error = sent.timedOut
9937
9869
  ? "timed out"
9938
9870
  : sent.stderr.split("\n")[0]?.trim() || `exit ${sent.code}`;
9939
- store.finalizeDelivery(one.id, owner, { ok: false, error }, clock());
9871
+ finalizeNotification(store, one, owner, { ok: false, error }, clock());
9940
9872
  failed++;
9941
9873
  }
9942
9874
  }
@@ -10167,6 +10099,7 @@ async function statusCommand(positional, flags, context) {
10167
10099
  // An update waiting, on what, and the action: before anything else, as it holds up new work.
10168
10100
  const waiting = waitingUpdate(context.databaseFile, run => context.store.stopQuiescenceProblem(run) !== null, context.clock());
10169
10101
  const updateWaiting = waiting === null ? {} : { updateWaiting: { app: waiting.app, version: waiting.version, stopped: waiting.stopped, run: waiting.run, on: waiting.on, action: waiting.action } };
10102
+ const legacyWebhookWarning = loadWebhookTargets(process.env, dirname(context.databaseFile)).length > 0 ? LEGACY_WEBHOOK_WARNING : null;
10170
10103
  const projects = projectBuildsStatus(context);
10171
10104
  const projectsLine = projects.length === 0 ? null
10172
10105
  : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
@@ -10174,7 +10107,7 @@ async function statusCommand(positional, flags, context) {
10174
10107
  const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
10175
10108
  const unsentLine = unsentRepliesLine(unsent);
10176
10109
  const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
10177
- return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
10110
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies, ...(legacyWebhookWarning === null ? {} : { legacyWebhookWarning }) }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine]), ...(legacyWebhookWarning === null ? [] : [legacyWebhookWarning])]);
10178
10111
  }
10179
10112
  function unsentRepliesLine(unsent) {
10180
10113
  if (unsent.length === 0)
@@ -12825,6 +12758,7 @@ async function backupCommand(positional, flags, context) {
12825
12758
  return fail(context.write, context.json, command, "failed", `The backup failed: ${made.error}`, EXIT.failed, { backup: made.id });
12826
12759
  return succeed(context.write, context.json, command, { backup: made }, () => [
12827
12760
  `Backed up to ${made.file} (${bytesWords(made.bytes)}).${made.removed === 0 ? "" : ` Removed ${made.removed} older ${made.removed === 1 ? "backup" : "backups"}.`}`,
12761
+ ...(made.checkpoint !== null && "problem" in made.checkpoint ? [`The activity log failed its tamper check, so it was not anchored: ${made.checkpoint.problem}`] : []),
12828
12762
  ]);
12829
12763
  }
12830
12764
  const settings = store.backupSettings();
@@ -123,6 +123,7 @@ export async function recoverPreparedObserverGap(store, input) {
123
123
  const actionId = store.recordAction({ at: new Date().toISOString(), actor: "system", repo: current.binding.run.repo,
124
124
  taskId: current.binding.run.taskId, runId: receipt.targetRun, action: "process absence recovered",
125
125
  outcome: digest, source: "work" });
126
+ store.compactRunProcesses(new Date(), receipt.targetRun);
126
127
  return { ok: true, recorded: true, ...result, actionId, certificatePath };
127
128
  }));
128
129
  }
@@ -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(source: string): Admission;
126
+ admit(key: string): Admission;
124
127
  }
@@ -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(source) {
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/retention.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { reclaimDatabase } from "./database-reclaim.js";
1
2
  /**
2
3
  * Retention (v105): how long Toolroll keeps run evidence and logs,
3
4
  * finished checkouts' records, chat messages and notifications. Until an
@@ -56,7 +57,7 @@ export const RETENTION_KINDS = [
56
57
  { kind: "evidence", label: "Run evidence and logs", detail: "Diffs, check logs, screenshots and reports saved for each run" },
57
58
  { kind: "checkouts", label: "Finished checkout records", detail: "Records of build checkouts that were let go and are no longer on disk" },
58
59
  { kind: "chat", label: "Chat messages", detail: "Messages in lead and team chats" },
59
- { kind: "notifications", label: "Notifications", detail: "Delivered or dismissed notifications" },
60
+ { kind: "notifications", label: "Notifications", detail: "Resolved notifications" },
60
61
  ];
61
62
  /** The periods the page offers for each kind; the command line takes any number of days from 1 to 3650. */
62
63
  const LONG_CHOICES = [30, 90, 180, 365, 730, null];
@@ -183,12 +184,11 @@ export function retentionPlan(store, evidenceRoot, now, periods = store.retentio
183
184
  items.chat = rows.slice(0, SWEEP_BATCH).map(row => ({ id: Number(row["id"]), bytes: Number(row["bytes"]) }));
184
185
  }
185
186
  if (periods.notifications !== null) {
186
- const rows = db.prepare(`SELECT n.id AS id, length(n.subject) + length(n.body) + COALESCE(length(n.receipt), 0) + COALESCE(length(n.last_error), 0) + 64 AS bytes FROM notification n
187
- WHERE n.created_at < ? AND (n.resolved_at IS NOT NULL OR n.delivered_at IS NOT NULL)
188
- AND NOT (n.claim_owner IS NOT NULL AND n.claim_expires_at > ?)
187
+ const rows = db.prepare(`SELECT n.id AS id, length(n.subject) + length(n.body) + 64 AS bytes FROM notification n
188
+ WHERE n.created_at < ? AND n.resolved_at IS NOT NULL
189
189
  AND NOT EXISTS (SELECT 1 FROM notification_delivery d WHERE d.notification = n.id AND d.claim_owner IS NOT NULL AND d.claim_expires_at > ?)
190
190
  AND NOT (n.resolved_at IS NULL AND n.task_ref IS NOT NULL AND NOT EXISTS (SELECT 1 FROM task_ref r WHERE r.id = n.task_ref AND ${finishedTask("r")}))
191
- ORDER BY n.id LIMIT ?`).all(cutoff(now, periods.notifications), now.toISOString(), now.toISOString(), SWEEP_BATCH + 1);
191
+ ORDER BY n.id LIMIT ?`).all(cutoff(now, periods.notifications), now.toISOString(), SWEEP_BATCH + 1);
192
192
  more.notifications = rows.length > SWEEP_BATCH;
193
193
  items.notifications = rows.slice(0, SWEEP_BATCH).map(row => ({ id: Number(row["id"]), bytes: Number(row["bytes"]) }));
194
194
  }
@@ -297,6 +297,7 @@ export function sweepRetention(store, evidenceRoot, now, actor = "worker") {
297
297
  const counts = plan.counts.map(one => ({ ...one, count: done[one.kind].count, bytes: done[one.kind].bytes }));
298
298
  const freed = counts.reduce((sum, one) => sum + one.bytes, 0);
299
299
  const ledgerId = store.recordAction({ at: now.toISOString(), actor, repo: null, taskId: null, runId: null, action: "retention sweep", outcome: freed > 0 || counts.some(one => one.count > 0) ? "removed" : "nothing due", source: "policy", detail: sweepWords(counts, freed) });
300
+ reclaimDatabase(store.handle, "retention");
300
301
  return { at: now.toISOString(), counts, freed, ledgerId };
301
302
  }
302
303
  /** Whether a day has passed since the last sweep. */
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
- const guard = passwordGuardOf(store), now = Date.now();
730
- if (guard.lockedFor(by, now) > 0)
731
- return { ok: false, reason: "locked" };
732
- const account = store.accountOf(by);
733
- if (account === null || !verifyCredential(account.credentialHash, secret)) {
734
- guard.failed(by, now);
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: joinSourceOf(request).replace(/^fwd:/, "") });
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 joinSourceOf(request) {
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();
@@ -426,7 +429,7 @@ export function createDecisionServer(options) {
426
429
  /** One-click connections on their way: the service's sign-in page and back, 15 minutes at most. */
427
430
  const connectVisits = new Map();
428
431
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
429
- // Live views push on write (live-bus.ts): the revision triggers move, the rooms hear it.
432
+ // Live views push on write (live-bus.ts): each commit moves the workspace revision, the rooms hear it.
430
433
  const liveBus = createLiveBus();
431
434
  const liveFollower = followWorkspace(store, () => workspaceRevision.current(), liveBus, { file: store.databaseFile() });
432
435
  const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow), { bus: liveBus });
@@ -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
- void handle(request, response).catch(error => {
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: joinSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
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) {