talon-agent 5.14.0 → 5.18.2

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 (116) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +14 -7
  5. package/package.json +6 -4
  6. package/prompts/system/heartbeat-agent.md +1 -1
  7. package/src/backend/agy/factory.ts +3 -0
  8. package/src/backend/agy/mcp/config.ts +14 -2
  9. package/src/backend/claude-sdk/factory.ts +3 -0
  10. package/src/backend/claude-sdk/options.ts +24 -3
  11. package/src/backend/codex/factory.ts +3 -0
  12. package/src/backend/codex/init.ts +4 -0
  13. package/src/backend/codex/mcp-config.ts +10 -0
  14. package/src/backend/codex/oauth-incompat.ts +1 -1
  15. package/src/backend/codex/token-usage.ts +2 -2
  16. package/src/backend/openai-agents/factory.ts +3 -0
  17. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  18. package/src/backend/remote-server/factory.ts +3 -0
  19. package/src/backend/remote-server/mcp.ts +3 -0
  20. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  21. package/src/bootstrap.ts +8 -0
  22. package/src/cli/commands/backup.ts +61 -5
  23. package/src/cli/commands/mesh.ts +133 -0
  24. package/src/cli/config.ts +3 -1
  25. package/src/cli/daemon-api.ts +22 -0
  26. package/src/cli/index.ts +6 -0
  27. package/src/cli/install-sources.ts +40 -5
  28. package/src/cli/plugin.ts +10 -0
  29. package/src/cli/setup.ts +45 -4
  30. package/src/cli/skill.ts +3 -0
  31. package/src/core/agent-runtime/backend-registry.ts +16 -0
  32. package/src/core/backup/archive/crypt.ts +429 -0
  33. package/src/core/backup/archive/manifest-auth.ts +98 -0
  34. package/src/core/backup/passphrase.ts +130 -0
  35. package/src/core/backup/plan.ts +66 -13
  36. package/src/core/backup/restore-guard.ts +101 -0
  37. package/src/core/backup/restore.ts +249 -32
  38. package/src/core/backup/snapshot.ts +418 -60
  39. package/src/core/backup/sources/plugins.ts +223 -0
  40. package/src/core/backup/sources/relocate.ts +136 -0
  41. package/src/core/backup/sources/sessions.ts +198 -0
  42. package/src/core/backup/store.ts +3 -1
  43. package/src/core/backup/types.ts +66 -0
  44. package/src/core/backup/upload.ts +66 -6
  45. package/src/core/config/index.ts +140 -8
  46. package/src/core/daemon/control.ts +9 -0
  47. package/src/core/daemon/discovery.ts +7 -0
  48. package/src/core/engine/backend-router/headroom.ts +29 -5
  49. package/src/core/engine/backend-router/usage.ts +6 -2
  50. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  51. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  52. package/src/core/engine/gateway-actions/index.ts +4 -2
  53. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  54. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  55. package/src/core/engine/gateway-auth.ts +164 -0
  56. package/src/core/engine/gateway-routes.ts +100 -5
  57. package/src/core/engine/gateway.ts +12 -4
  58. package/src/core/mcp-hub/guest-scope.ts +170 -29
  59. package/src/core/mcp-hub/index.ts +33 -16
  60. package/src/core/mcp-hub/talon-server.ts +71 -14
  61. package/src/core/mesh/credentials/admin.ts +146 -0
  62. package/src/core/mesh/credentials/index.ts +19 -0
  63. package/src/core/mesh/credentials/store.ts +443 -0
  64. package/src/core/mesh/credentials/token.ts +45 -0
  65. package/src/core/mesh/credentials/types.ts +83 -0
  66. package/src/core/mesh/devices/service.ts +58 -6
  67. package/src/core/mesh/links/bridge-links.ts +46 -4
  68. package/src/core/mesh/links/node-binaries.ts +1 -1
  69. package/src/core/mesh/links/node-provision.ts +8 -1
  70. package/src/core/models/active-model.ts +1 -1
  71. package/src/core/plugin/loader.ts +4 -0
  72. package/src/core/plugin/mcp.ts +4 -0
  73. package/src/core/tools/bridge.ts +2 -1
  74. package/src/core/types.ts +13 -0
  75. package/src/core/weaver/weaver.ts +47 -15
  76. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  77. package/src/frontend/discord/handlers/delivery.ts +3 -0
  78. package/src/frontend/discord/handlers/queue.ts +1 -0
  79. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  80. package/src/frontend/native/bridge/auth.ts +84 -1
  81. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  82. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  83. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  84. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  85. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  86. package/src/frontend/native/bridge/routes/host.ts +11 -1
  87. package/src/frontend/native/bridge/routes/index.ts +2 -0
  88. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  89. package/src/frontend/native/bridge/routes/table.ts +73 -51
  90. package/src/frontend/native/bridge/server.ts +266 -83
  91. package/src/frontend/native/index.ts +54 -3
  92. package/src/frontend/native/turn/turn.ts +2 -0
  93. package/src/frontend/teams/turn.ts +1 -0
  94. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  95. package/src/frontend/telegram/actions/send.ts +4 -0
  96. package/src/frontend/telegram/admin.ts +20 -0
  97. package/src/frontend/telegram/commands/admin.ts +1 -1
  98. package/src/frontend/telegram/commands/state.ts +7 -9
  99. package/src/frontend/telegram/handlers/access.ts +31 -10
  100. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  101. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  102. package/src/frontend/telegram/handlers/messages.ts +1 -0
  103. package/src/frontend/telegram/handlers/queue.ts +14 -0
  104. package/src/frontend/telegram/handlers/state.ts +2 -2
  105. package/src/frontend/telegram/index.ts +32 -9
  106. package/src/frontend/telegram/middleware.ts +2 -2
  107. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  108. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  109. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  110. package/src/frontend/telegram/userbot.ts +103 -10
  111. package/src/frontend/terminal/index.ts +8 -1
  112. package/src/frontend/whatsapp/commands.ts +3 -3
  113. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  114. package/src/plugins/playwright/index.ts +1 -1
  115. package/src/storage/backup/index.ts +1 -1
  116. package/src/storage/db.ts +23 -0
@@ -24,6 +24,22 @@ import {
24
24
  type NodeBinaryResolver,
25
25
  } from "./node-binaries.js";
26
26
  import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
27
+ import {
28
+ DEFAULT_COMPANION_SCOPES,
29
+ NODE_SCOPES,
30
+ type CredentialOrigin,
31
+ type MeshScope,
32
+ } from "../credentials/index.js";
33
+
34
+ /**
35
+ * Mints the per-device credential a pairing link or installer carries,
36
+ * unbound until the device first names itself with it. Absent (tests,
37
+ * embedders without a credential store) = links carry the shared token.
38
+ */
39
+ export type PairingCredentialMinter = (
40
+ scopes: readonly MeshScope[],
41
+ origin: CredentialOrigin,
42
+ ) => string;
27
43
 
28
44
  /**
29
45
  * What the native bridge tells the mesh about itself once it's listening —
@@ -45,6 +61,10 @@ export type MeshBridgeInfo = {
45
61
  * address isn't reachable as-is (containers, NAT, proxies).
46
62
  */
47
63
  publicUrl?: string;
64
+ /** `native.legacySharedToken`: whether remote clients may still use `token`. */
65
+ legacySharedToken?: boolean;
66
+ /** Scopes a companion's pairing credential carries (`native.companionScopes`). */
67
+ companionScopes?: readonly MeshScope[];
48
68
  };
49
69
 
50
70
  export class BridgeLinks {
@@ -54,7 +74,24 @@ export class BridgeLinks {
54
74
  private readonly companionPairs = new CompanionPairStore();
55
75
  private bridgeInfo: MeshBridgeInfo | null = null;
56
76
 
57
- constructor(private readonly resolveNode: NodeBinaryResolver) {}
77
+ constructor(
78
+ private readonly resolveNode: NodeBinaryResolver,
79
+ private readonly mintCredential?: PairingCredentialMinter,
80
+ ) {}
81
+
82
+ /**
83
+ * The bearer a link hands a new device: its own credential when the mesh
84
+ * has a credential store, else the shared bridge token (legacy).
85
+ */
86
+ private linkCredential(
87
+ sharedToken: string,
88
+ scopes: readonly MeshScope[],
89
+ origin: CredentialOrigin,
90
+ ): string {
91
+ return this.mintCredential
92
+ ? this.mintCredential(scopes, origin)
93
+ : sharedToken;
94
+ }
58
95
 
59
96
  /** The native bridge reports its reachable identity here (null on stop). */
60
97
  setBridgeInfo(info: MeshBridgeInfo | null): void {
@@ -133,7 +170,7 @@ export class BridgeLinks {
133
170
  size: bin.size,
134
171
  version: bin.version,
135
172
  bridgeUrl: base,
136
- bearerToken: info.token,
173
+ bearerToken: this.linkCredential(info.token, NODE_SCOPES, "install"),
137
174
  ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
138
175
  });
139
176
  return {
@@ -185,9 +222,14 @@ export class BridgeLinks {
185
222
  }
186
223
  const base = this.bridgeBaseUrl(info, bridgeUrl);
187
224
  if (typeof base !== "string") return { ok: false, text: base.error };
225
+ const token = this.linkCredential(
226
+ info.token,
227
+ info.companionScopes ?? DEFAULT_COMPANION_SCOPES,
228
+ "pair",
229
+ );
188
230
  const grant = this.companionPairs.create({
189
231
  bridgeUrl: base,
190
- bearerToken: info.token,
232
+ bearerToken: token,
191
233
  ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
192
234
  ...(typeof label === "string" && label.trim()
193
235
  ? { label: label.trim() }
@@ -197,7 +239,7 @@ export class BridgeLinks {
197
239
  ok: true,
198
240
  link: pairLink(grant),
199
241
  url: base,
200
- token: info.token,
242
+ token,
201
243
  ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
202
244
  };
203
245
  }
@@ -57,7 +57,7 @@ export const NODE_TARGETS: readonly NodeTarget[] = [
57
57
  ];
58
58
 
59
59
  const SUMS_ASSET = "talon-node-SHA256SUMS";
60
- const RELEASE_BASE = "https://github.com/dylanneve1/talon/releases/download";
60
+ const RELEASE_BASE = "https://github.com/thefalconry/talon/releases/download";
61
61
  const DOWNLOAD_TIMEOUT_MS = 120_000;
62
62
  const BUILD_TIMEOUT_MS = 300_000;
63
63
 
@@ -166,7 +166,13 @@ echo "Done — this host is now on the mesh. Check with: $BIN status"
166
166
  `;
167
167
  }
168
168
 
169
- /** PowerShell installer (Windows PowerShell 5+ compatible). */
169
+ /**
170
+ * PowerShell installer (Windows PowerShell 5+ compatible). The binary lands in
171
+ * the installing user's %LOCALAPPDATA% and `talon-node install` registers a
172
+ * boot task that runs as that same user (never SYSTEM — a SYSTEM task would
173
+ * run a binary the user can rewrite). Registering a boot task needs an
174
+ * elevated PowerShell.
175
+ */
170
176
  function powershellInstaller(grant: NodeProvisionGrant): string {
171
177
  const nameArg = grant.name ? `, "--name", "${grant.name}"` : "";
172
178
  const fpArg = grant.fingerprint
@@ -184,6 +190,7 @@ Invoke-WebRequest -UseBasicParsing "$bridge/node/binary?provision=${grant.token}
184
190
  $got = (Get-FileHash $bin -Algorithm SHA256).Hash.ToLower()
185
191
  if ($got -ne "${grant.sha256}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
186
192
  Write-Host "Installed $bin"
193
+ Write-Host "Registering the boot task to run as $env:USERDOMAIN\\$env:USERNAME (not SYSTEM)..."
187
194
  & $bin install --bridge $bridge --token "${grant.bearerToken}"${fpArg}${nameArg}
188
195
  Write-Host "Done - this host is now on the mesh. Check with: $bin status"
189
196
  `;
@@ -14,7 +14,7 @@
14
14
  * a single global-default fallback that ignored the per-chat backend.
15
15
  * That produced two recurring bug classes:
16
16
  *
17
- * 1. **Reset-on-non-default-backend** (Dylan, 2026-05-21): chat on
17
+ * 1. **Reset-on-non-default-backend** (Ada, 2026-05-21): chat on
18
18
  * Codex, user hits Reset, code clears the override, read side
19
19
  * returns `config.model = "claude-opus-4-7"`. Codex chat then
20
20
  * tries to run an Anthropic id.
@@ -15,6 +15,7 @@ import type {
15
15
  } from "./types.js";
16
16
  import { isMcpPlugin } from "./types.js";
17
17
  import { registry, _deps } from "./registry.js";
18
+ import { GATEWAY_TOKEN_ENV } from "../engine/gateway-auth.js";
18
19
 
19
20
  /**
20
21
  * Candidate entry point paths, checked in order. Exported for
@@ -67,6 +68,9 @@ export async function loadPlugins(
67
68
 
68
69
  function applyEnvVars(envVars: Record<string, string>): void {
69
70
  for (const [key, value] of Object.entries(envVars)) {
71
+ // The gateway token is the daemon's own credential; a plugin may read
72
+ // it but never replace it.
73
+ if (key === GATEWAY_TOKEN_ENV) continue;
70
74
  process.env[key] = value;
71
75
  }
72
76
  }
@@ -10,6 +10,7 @@ import { wrapMcpServer } from "../mcp-hub/launcher.js";
10
10
  import { isBunRuntime } from "../../util/runtime.js";
11
11
  import { registry, reloadState } from "./registry.js";
12
12
  import type { McpServerConfig } from "./types.js";
13
+ import { GATEWAY_TOKEN_ENV, gatewayToken } from "../engine/gateway-auth.js";
13
14
 
14
15
  function buildBridgeEnv(
15
16
  bridgeUrl: string,
@@ -19,6 +20,9 @@ function buildBridgeEnv(
19
20
  return {
20
21
  ...envVars,
21
22
  TALON_BRIDGE_URL: bridgeUrl,
23
+ // Plugins that call back into the gateway (POST /action) must send
24
+ // this as `Authorization: Bearer <token>`.
25
+ [GATEWAY_TOKEN_ENV]: gatewayToken(),
22
26
  TALON_CHAT_ID: chatId,
23
27
  TALON_RELOAD_AT: reloadState.lastReloadAt,
24
28
  };
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { Agent, fetch as undiciFetch } from "undici";
14
14
  import { isBunRuntime } from "../../util/runtime.js";
15
+ import { gatewayAuthHeaders } from "../engine/gateway-auth.js";
15
16
  import type { BridgeFunction } from "./types.js";
16
17
 
17
18
  /** Default wall-clock budget for a bridge action. */
@@ -91,7 +92,7 @@ export function createBridge(
91
92
  const timeoutMs = LONG_ACTION_TIMEOUTS_MS[action] ?? DEFAULT_TIMEOUT_MS;
92
93
  const init = {
93
94
  method: "POST",
94
- headers: { "Content-Type": "application/json" },
95
+ headers: { "Content-Type": "application/json", ...gatewayAuthHeaders() },
95
96
  // chat_id stays in body when set — gateway uses its presence as the
96
97
  // "explicit routing" signal. _chatId is the routing key either way.
97
98
  body: JSON.stringify({ action, ...params, _chatId: effectiveChatId }),
package/src/core/types.ts CHANGED
@@ -227,6 +227,19 @@ export type ExecuteParams = {
227
227
  senderName: string;
228
228
  /** Sender's platform handle without `@` (Telegram username, Discord username). */
229
229
  senderHandle?: string;
230
+ /**
231
+ * Every key the ONE person behind this turn is known by, in operator-id
232
+ * form (Telegram user id, `wa_dm_<number>`, `discord:<id>`,
233
+ * `teams:<id>`, or `LOCAL_OPERATOR_SENDER`). Decides the turn's tool
234
+ * scope (core/mcp-hub/guest-scope.ts). Omit when the sender is unknown
235
+ * or the turn batches several senders — the turn is then guest-scoped.
236
+ */
237
+ senderKeys?: readonly string[];
238
+ /**
239
+ * Frontend-attested: the operator is a member of this group chat. With
240
+ * `guestDmScope.operatorGroups` on, such a turn keeps the full surface.
241
+ */
242
+ operatorInChat?: boolean;
230
243
  isGroup: boolean;
231
244
  /** Provider message ID. Numeric for Telegram, string snowflake for Discord. */
232
245
  messageId?: number | string;
@@ -29,6 +29,12 @@ import { recordSessionTurnPhases } from "../../storage/sessions.js";
29
29
  import type { TurnPhase } from "../../storage/session-record.js";
30
30
  import { retrieveForTurn, type TurnMemory } from "../memory/turn-retrieval.js";
31
31
  import { TalonError } from "../errors.js";
32
+ import { backendEnforcesGuestScope } from "../agent-runtime/backend-registry.js";
33
+ import {
34
+ enterTurnScope,
35
+ resolveTurnScope,
36
+ scopePrompt,
37
+ } from "../mcp-hub/guest-scope.js";
32
38
  import { Loom } from "./loom.js";
33
39
  import { carryTurnEvents, startShuttleTiming } from "./shuttle.js";
34
40
  import type { Thread, ThreadSnapshot } from "./thread.js";
@@ -184,23 +190,22 @@ export class Weaver {
184
190
  });
185
191
  phases.warpResolve = Date.now() - warpStartedAt;
186
192
  if (!warp.ok) {
187
- // Refusals are delivered through the same event sink the backend
188
- // would use for output (as an `assistant_message` event, so the
189
- // frontend delivers it normally).
190
- try {
191
- await params.onEvent?.({
192
- type: "assistant_message",
193
- text: warp.message,
194
- });
195
- } catch (err) {
196
- logWarn(
197
- "dispatcher",
198
- `onEvent(no-model) threw: ${err instanceof Error ? err.message : String(err)}`,
199
- );
200
- }
193
+ await deliverRefusal(params, warp.message, "no-model");
201
194
  return this.emptyResult(warp.message, params);
202
195
  }
203
196
 
197
+ // Tool scope is decided per sender: a non-operator gets the guest
198
+ // surface, and a backend that can't enforce it doesn't get the turn.
199
+ const scope = resolveTurnScope(params);
200
+ if (scope === "guest" && !backendEnforcesGuestScope(backend.id)) {
201
+ logWarn(
202
+ "dispatcher",
203
+ `[${reqId}] guest-scoped turn refused chat=${params.chatId}: backend "${backend.id}" cannot enforce the guest tool scope`,
204
+ );
205
+ await deliverRefusal(params, GUEST_BACKEND_REFUSAL, "guest-scope");
206
+ return this.emptyResult(GUEST_BACKEND_REFUSAL, params);
207
+ }
208
+
204
209
  // Bind the warp — record the model/backend actually resolved for this turn
205
210
  // on the Thread. `weaver.snapshot()` reports it, and a change since the
206
211
  // last turn (per-chat rebind, per-run override, or config drift) is logged
@@ -235,6 +240,7 @@ export class Weaver {
235
240
  `[${reqId}] ${params.source} chat=${params.chatId} started (active=${this.activeCount})`,
236
241
  );
237
242
  context.acquire(params.numericChatId, params.chatId);
243
+ const releaseScope = enterTurnScope(params.chatId, scope);
238
244
  const stopTyping = startTypingLoop(
239
245
  this.deps.sendTyping,
240
246
  params.numericChatId,
@@ -251,7 +257,7 @@ export class Weaver {
251
257
  const stream = backend.chat.runChatTurn({
252
258
  chatId: params.chatId,
253
259
  model: warp.ref,
254
- text: params.prompt,
260
+ text: scopePrompt(scope, params.prompt),
255
261
  senderName: params.senderName,
256
262
  senderHandle: params.senderHandle,
257
263
  isGroup: params.isGroup,
@@ -293,6 +299,7 @@ export class Weaver {
293
299
  return this.toExecuteResult(agentResult, params);
294
300
  } finally {
295
301
  stopTyping();
302
+ releaseScope();
296
303
  context.release(params.numericChatId, params.chatId);
297
304
  }
298
305
  }
@@ -331,6 +338,31 @@ export class Weaver {
331
338
  }
332
339
  }
333
340
 
341
+ const GUEST_BACKEND_REFUSAL =
342
+ "I can't answer this here: messages from anyone but the operator run with a " +
343
+ "limited tool set, and this chat's current model backend can't enforce it. " +
344
+ "The operator can switch this chat to a backend that does (Claude).";
345
+
346
+ /**
347
+ * Refusals are delivered through the same event sink the backend would use
348
+ * for output (as an `assistant_message` event, so the frontend delivers it
349
+ * normally).
350
+ */
351
+ async function deliverRefusal(
352
+ params: ExecuteParams,
353
+ text: string,
354
+ kind: string,
355
+ ): Promise<void> {
356
+ try {
357
+ await params.onEvent?.({ type: "assistant_message", text });
358
+ } catch (err) {
359
+ logWarn(
360
+ "dispatcher",
361
+ `onEvent(${kind}) threw: ${err instanceof Error ? err.message : String(err)}`,
362
+ );
363
+ }
364
+ }
365
+
334
366
  /**
335
367
  * This turn's retrieved memory (`core/memory/turn-retrieval.ts`) — off
336
368
  * unless `TALON_MEMORY_STORE=1`, fail-closed, and a pure read: nothing
@@ -64,6 +64,7 @@ export async function forwardToAgent(
64
64
  numericChatId,
65
65
  prompt,
66
66
  senderName: sender,
67
+ senderKeys: [`discord:${interaction.user.id}`],
67
68
  isGroup,
68
69
  source: "message",
69
70
  onEvent: async (event) => {
@@ -25,6 +25,8 @@ export type ProcessAndReplyParams = {
25
25
  isGroup: boolean;
26
26
  senderUsername?: string;
27
27
  senderId: string;
28
+ /** False when the batch mixes senders — the turn is then guest-scoped. */
29
+ singleSender?: boolean;
28
30
  channel: TextBasedChannel;
29
31
  chatTitle?: string;
30
32
  };
@@ -53,6 +55,7 @@ export async function processAndReply(p: ProcessAndReplyParams): Promise<void> {
53
55
  prompt: p.prompt,
54
56
  senderName: p.senderName,
55
57
  senderHandle: p.senderUsername,
58
+ senderKeys: p.singleSender === false ? [] : [`discord:${p.senderId}`],
56
59
  isGroup: p.isGroup,
57
60
  // Use the real Discord snowflake string, not the hashed numeric.
58
61
  // The hash collides with Telegram-style 32-bit IDs and Discord's API
@@ -83,6 +83,7 @@ async function flushQueue(chatId: string): Promise<void> {
83
83
  isGroup: last.isGroup,
84
84
  senderUsername: last.senderUsername,
85
85
  senderId: last.senderId,
86
+ singleSender: messages.every((m) => m.senderId === last.senderId),
86
87
  channel: last.channel,
87
88
  chatTitle: last.chatTitle,
88
89
  });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Auth guard — how the bridge answers credentials that don't check out.
3
+ *
4
+ * The token itself is the security (256 bits when Talon mints it); this
5
+ * module decides how a wrong one is answered so an internet-facing bridge
6
+ * can't be hammered for free, and so the operator hears about it.
7
+ *
8
+ * Three layers, all keyed on the remote address (behind a reverse proxy that
9
+ * is the proxy):
10
+ *
11
+ * 1. Progressive backoff: the first few wrong tokens from an address get an
12
+ * immediate 401; after that each 401 waits longer (base doubling to a
13
+ * cap). Only failures wait. A correct token is never delayed.
14
+ * 2. Lockout: after `lockoutMaxFailures` wrong tokens in the window the
15
+ * address gets 429s (even with the right token) until the window lapses.
16
+ * 3. Global failure budget: if wrong tokens across ALL addresses exceed
17
+ * `globalMaxFailures` in `globalWindowMs`, that is distributed guessing
18
+ * dodging (1) and (2). The bridge enters a cooldown: wrong tokens get
19
+ * 429 at once, tokenless requests are slowed, the operator is alerted
20
+ * once. Authenticated traffic keeps working throughout.
21
+ *
22
+ * Only presented-and-wrong tokens count as failures. Tokenless probes are
23
+ * scanners finding a locked door. Waits are timers, never a blocked event
24
+ * loop, and the number of responses held at once is capped so the delays
25
+ * can't be turned into a socket-exhaustion lever.
26
+ *
27
+ * Every event logs one `bridge.auth event=…` line with the address and a
28
+ * reason. Token material never reaches this module.
29
+ */
30
+
31
+ import { log, logWarn } from "../../../util/log.js";
32
+ import type { AuthState } from "./routes/table.js";
33
+
34
+ export type AuthGuardPolicy = {
35
+ /** Wrong tokens from one address inside the window before 429s. */
36
+ lockoutMaxFailures: number;
37
+ lockoutWindowMs: number;
38
+ /** Hard cap on tracked addresses so the map can't become a memory lever. */
39
+ maxTracked: number;
40
+ /** Wrong tokens answered without delay (typos happen). */
41
+ freeFailures: number;
42
+ backoffBaseMs: number;
43
+ backoffMaxMs: number;
44
+ /** Wrong tokens across every address, per window, before a cooldown. */
45
+ globalMaxFailures: number;
46
+ globalWindowMs: number;
47
+ globalCooldownMs: number;
48
+ /** How long a tokenless request is held during a cooldown. */
49
+ cooldownAnonDelayMs: number;
50
+ /** Most responses held at once; past this, refuse instead of waiting. */
51
+ maxPendingDelays: number;
52
+ };
53
+
54
+ const DEFAULT_AUTH_GUARD_POLICY: AuthGuardPolicy = {
55
+ lockoutMaxFailures: 20,
56
+ lockoutWindowMs: 15 * 60_000,
57
+ maxTracked: 10_000,
58
+ freeFailures: 2,
59
+ backoffBaseMs: 250,
60
+ backoffMaxMs: 8_000,
61
+ globalMaxFailures: 100,
62
+ globalWindowMs: 5 * 60_000,
63
+ globalCooldownMs: 10 * 60_000,
64
+ cooldownAnonDelayMs: 1_000,
65
+ maxPendingDelays: 512,
66
+ };
67
+
68
+ export type AuthVerdict =
69
+ | { kind: "allow" }
70
+ /** Hold the response this long, then carry on as normal. */
71
+ | { kind: "delay"; ms: number }
72
+ | {
73
+ kind: "reject";
74
+ reason: "lockout" | "cooldown";
75
+ retryAfterSec: number;
76
+ };
77
+
78
+ type Entry = { count: number; resetAt: number };
79
+
80
+ export class AuthGuard {
81
+ private readonly policy: AuthGuardPolicy;
82
+ private readonly now: () => number;
83
+ private readonly onAlert: ((message: string) => void) | undefined;
84
+ private readonly failures = new Map<string, Entry>();
85
+ private globalCount = 0;
86
+ private globalWindowStart = 0;
87
+ private cooldownUntil = 0;
88
+ private cooling = false;
89
+ private suppressed = 0;
90
+ private saturatedLogged = false;
91
+ private pending = 0;
92
+
93
+ constructor(
94
+ policy: Partial<AuthGuardPolicy> = {},
95
+ deps: { now?: () => number; onAlert?: (message: string) => void } = {},
96
+ ) {
97
+ this.policy = { ...DEFAULT_AUTH_GUARD_POLICY, ...policy };
98
+ this.now = deps.now ?? Date.now;
99
+ this.onAlert = deps.onAlert;
100
+ }
101
+
102
+ /** Addresses currently tracked (tests and diagnostics). */
103
+ trackedCount(): number {
104
+ return this.failures.size;
105
+ }
106
+
107
+ /** True while the global failure budget is exhausted. */
108
+ inCooldown(): boolean {
109
+ this.refreshCooldown(this.now());
110
+ return this.cooling;
111
+ }
112
+
113
+ /**
114
+ * Decide how to answer a request whose credential has been evaluated.
115
+ * Called once per request, before routing.
116
+ */
117
+ check(remote: string, auth: AuthState): AuthVerdict {
118
+ const now = this.now();
119
+ this.refreshCooldown(now);
120
+ const entry = this.liveEntry(remote, now);
121
+ if (entry && entry.count >= this.policy.lockoutMaxFailures) {
122
+ return {
123
+ kind: "reject",
124
+ reason: "lockout",
125
+ retryAfterSec: Math.max(1, Math.ceil((entry.resetAt - now) / 1000)),
126
+ };
127
+ }
128
+ if (auth === "ok") {
129
+ this.failures.delete(remote);
130
+ return { kind: "allow" };
131
+ }
132
+ if (auth === "anonymous") {
133
+ return this.cooling
134
+ ? { kind: "delay", ms: this.policy.cooldownAnonDelayMs }
135
+ : { kind: "allow" };
136
+ }
137
+ return this.fail(remote, now);
138
+ }
139
+
140
+ /**
141
+ * Wait `ms` on a timer. Resolves false, without waiting, when too many
142
+ * responses are already held — the caller refuses instead.
143
+ */
144
+ async hold(ms: number): Promise<boolean> {
145
+ if (this.pending >= this.policy.maxPendingDelays) return false;
146
+ this.pending++;
147
+ try {
148
+ await new Promise<void>((resolve) => {
149
+ setTimeout(resolve, ms).unref?.();
150
+ });
151
+ return true;
152
+ } finally {
153
+ this.pending--;
154
+ }
155
+ }
156
+
157
+ // ── internals ────────────────────────────────────────────────────────────
158
+
159
+ private fail(remote: string, now: number): AuthVerdict {
160
+ const count = this.recordFailure(remote, now);
161
+ this.recordGlobalFailure(now);
162
+ if (this.cooling) {
163
+ this.suppressed++;
164
+ return {
165
+ kind: "reject",
166
+ reason: "cooldown",
167
+ retryAfterSec: Math.max(
168
+ 1,
169
+ Math.ceil((this.cooldownUntil - now) / 1000),
170
+ ),
171
+ };
172
+ }
173
+ const delay = count === null ? 0 : this.backoffFor(count);
174
+ logWarn(
175
+ "native",
176
+ `bridge.auth event=failure addr=${remote} reason=bad_token` +
177
+ (count === null ? " tracked=no" : ` failures=${count}`) +
178
+ ` delayMs=${delay}`,
179
+ );
180
+ if (count === this.policy.lockoutMaxFailures) {
181
+ logWarn(
182
+ "native",
183
+ `bridge.auth event=lockout addr=${remote} reason=too_many_failures failures=${count} windowMin=${this.policy.lockoutWindowMs / 60_000}`,
184
+ );
185
+ }
186
+ return delay > 0 ? { kind: "delay", ms: delay } : { kind: "allow" };
187
+ }
188
+
189
+ private backoffFor(count: number): number {
190
+ const n = count - this.policy.freeFailures;
191
+ if (n <= 0) return 0;
192
+ // 2^(n-1) overflows nothing useful past ~30 doublings; clamp first.
193
+ const factor = 2 ** Math.min(n - 1, 30);
194
+ return Math.min(
195
+ this.policy.backoffMaxMs,
196
+ this.policy.backoffBaseMs * factor,
197
+ );
198
+ }
199
+
200
+ private liveEntry(remote: string, now: number): Entry | undefined {
201
+ const entry = this.failures.get(remote);
202
+ if (entry && now >= entry.resetAt) {
203
+ this.failures.delete(remote);
204
+ return undefined;
205
+ }
206
+ return entry;
207
+ }
208
+
209
+ /** Bump the address's count; null when the address couldn't be tracked. */
210
+ private recordFailure(remote: string, now: number): number | null {
211
+ const entry = this.liveEntry(remote, now);
212
+ if (entry) return ++entry.count;
213
+ if (this.failures.size >= this.policy.maxTracked) {
214
+ for (const [ip, e] of this.failures) {
215
+ if (now >= e.resetAt) this.failures.delete(ip);
216
+ }
217
+ // Still saturated after pruning live entries — under that much churn
218
+ // dropping the newest address beats unbounded growth. The global
219
+ // budget still counts it.
220
+ if (this.failures.size >= this.policy.maxTracked) {
221
+ if (!this.saturatedLogged) {
222
+ this.saturatedLogged = true;
223
+ logWarn(
224
+ "native",
225
+ `bridge.auth event=tracking_saturated reason=address_cap tracked=${this.failures.size}`,
226
+ );
227
+ }
228
+ return null;
229
+ }
230
+ }
231
+ this.saturatedLogged = false;
232
+ this.failures.set(remote, {
233
+ count: 1,
234
+ resetAt: now + this.policy.lockoutWindowMs,
235
+ });
236
+ return 1;
237
+ }
238
+
239
+ private recordGlobalFailure(now: number): void {
240
+ if (now - this.globalWindowStart >= this.policy.globalWindowMs) {
241
+ this.globalWindowStart = now;
242
+ this.globalCount = 0;
243
+ }
244
+ this.globalCount++;
245
+ if (this.globalCount <= this.policy.globalMaxFailures) return;
246
+ // Sustained guessing keeps pushing the end of the cooldown out.
247
+ this.cooldownUntil = now + this.policy.globalCooldownMs;
248
+ if (this.cooling) return;
249
+ this.cooling = true;
250
+ this.suppressed = 0;
251
+ const message =
252
+ `bridge.auth event=global_cooldown reason=failure_budget failures=${this.globalCount} ` +
253
+ `windowSec=${this.policy.globalWindowMs / 1000} cooldownSec=${this.policy.globalCooldownMs / 1000}`;
254
+ logWarn("native", message);
255
+ try {
256
+ this.onAlert?.(
257
+ `⚠️ Talon bridge: ${this.globalCount} failed auth attempts across all addresses in ` +
258
+ `${this.policy.globalWindowMs / 60_000} min, which looks like distributed token guessing ` +
259
+ "(or a token rotation left devices with a stale token). Failed and tokenless " +
260
+ `requests are being refused or slowed for ${this.policy.globalCooldownMs / 60_000} min; ` +
261
+ "paired clients are unaffected.",
262
+ );
263
+ } catch {
264
+ // An alert that can't be delivered must never break request handling.
265
+ }
266
+ }
267
+
268
+ private refreshCooldown(now: number): void {
269
+ if (!this.cooling || now < this.cooldownUntil) return;
270
+ this.cooling = false;
271
+ log(
272
+ "native",
273
+ `bridge.auth event=global_cooldown_end reason=expired refused=${this.suppressed}`,
274
+ );
275
+ this.suppressed = 0;
276
+ }
277
+ }