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
@@ -16,7 +16,7 @@ import { randomBytes } from "node:crypto";
16
16
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { resolve } from "node:path";
18
18
  import { dirs } from "../../../util/paths.js";
19
- import { log } from "../../../util/log.js";
19
+ import { log, logWarn } from "../../../util/log.js";
20
20
 
21
21
  const TOKEN_FILE = "bridge-token";
22
22
  /** 32 random bytes → 43 base64url chars; comfortably beyond brute force. */
@@ -50,3 +50,86 @@ export function loadOrCreateBridgeToken(dir: string = dirs.keys): string {
50
50
  );
51
51
  return token;
52
52
  }
53
+
54
+ // ── Token strength ─────────────────────────────────────────────────────────
55
+
56
+ /** Below this estimate a configured token is treated as guessable. */
57
+ export const MIN_TOKEN_BITS = 128;
58
+
59
+ /**
60
+ * Rough entropy estimate for a configured bridge token: length × bits per
61
+ * character, where the per-character figure comes from the alphabet the
62
+ * token visibly uses. It errs low on purpose:
63
+ *
64
+ * - digits only → log2(10) ≈ 3.3 bits/char
65
+ * - hex → 4 bits/char
66
+ * - one letter case, nothing else → log2(26) ≈ 4.7 bits/char
67
+ * - base64 / base64url (≥2 classes) → 6 bits/char (padding ignored)
68
+ * - anything else (spaces, punctuation — i.e. human-typed) → 3 bits/char
69
+ *
70
+ * A token with fewer than 8 distinct characters is capped at log2(distinct)
71
+ * bits/char, so "aaaa…" can't pass on length alone.
72
+ *
73
+ * This is a heuristic for catching `hunter2`-grade secrets, not a
74
+ * dictionary checker; the real fix is to let Talon generate the token.
75
+ */
76
+ export function estimateTokenBits(token: string): number {
77
+ const body = token.replace(/={1,2}$/, "");
78
+ if (body.length === 0) return 0;
79
+ const classes = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((re) =>
80
+ re.test(body),
81
+ ).length;
82
+ let perChar: number;
83
+ if (/^[0-9]+$/.test(body)) perChar = Math.log2(10);
84
+ else if (/^[0-9a-fA-F]+$/.test(body)) perChar = 4;
85
+ else if (/^[a-z]+$/.test(body) || /^[A-Z]+$/.test(body))
86
+ perChar = Math.log2(26);
87
+ else if (/^[A-Za-z0-9+/_-]+$/.test(body) && classes >= 2) perChar = 6;
88
+ else perChar = 3;
89
+ const distinct = new Set(body).size;
90
+ if (distinct < 8) perChar = Math.min(perChar, Math.log2(distinct));
91
+ return Math.floor(body.length * perChar);
92
+ }
93
+
94
+ /**
95
+ * Startup gate for a configured `native.token`.
96
+ *
97
+ * - Strong enough (≥ MIN_TOKEN_BITS), or no token → nothing to say.
98
+ * - Weak on a loopback bind → warn: nothing off-box can reach it.
99
+ * - Weak on any other bind → throw, unless the operator opted in with
100
+ * `native.allowWeakToken: true`, in which case warn loudly every start.
101
+ *
102
+ * Neither the token nor any part of it is ever logged.
103
+ */
104
+ export function checkBridgeTokenStrength(opts: {
105
+ token: string | undefined;
106
+ loopback: boolean;
107
+ allowWeakToken?: boolean;
108
+ }): void {
109
+ if (!opts.token) return;
110
+ const bits = estimateTokenBits(opts.token);
111
+ if (bits >= MIN_TOKEN_BITS) return;
112
+ const fix =
113
+ `Remove native.token so Talon mints a 256-bit token into ${bridgeTokenPath()}, ` +
114
+ "or replace it with the output of `openssl rand -hex 32`.";
115
+ const what = `native.token looks weak (~${bits} bits estimated, want ≥ ${MIN_TOKEN_BITS})`;
116
+ if (opts.loopback) {
117
+ logWarn(
118
+ "native",
119
+ `${what}. The bridge is loopback-only, so continuing. ${fix}`,
120
+ );
121
+ return;
122
+ }
123
+ if (opts.allowWeakToken) {
124
+ logWarn(
125
+ "native",
126
+ `SECURITY: ${what} on a network-reachable bind, allowed by native.allowWeakToken. ` +
127
+ `Anyone who can reach this port can try to guess it. ${fix}`,
128
+ );
129
+ return;
130
+ }
131
+ throw new Error(
132
+ `Refusing to start the bridge: ${what} on a network-reachable bind. ${fix} ` +
133
+ "(To accept the risk anyway, set native.allowWeakToken: true.)",
134
+ );
135
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Device-id claims — the spoofing check.
3
+ *
4
+ * Mesh requests name a device: `?deviceId=` on /events and /devices/file,
5
+ * `id` on /devices/register, `deviceId` on /location and
6
+ * /devices/command-result. A per-device credential may only ever name its
7
+ * own device; an unbound pairing/installer credential is bound by the first
8
+ * id it names (and refused one another credential already holds). The
9
+ * shared token and an open bridge name whatever they like — that is the
10
+ * legacy trust model — but a remote shared-token claim is recorded so the
11
+ * operator can see which devices still need upgrading.
12
+ */
13
+
14
+ import type { BridgeCredentials, BridgePrincipal } from "./principal.js";
15
+
16
+ export type ClaimResult =
17
+ { ok: true; deviceId: string | undefined } | { ok: false; error: string };
18
+
19
+ export function claimDevice(
20
+ principal: BridgePrincipal | null,
21
+ claimed: string | undefined,
22
+ credentials: BridgeCredentials | undefined,
23
+ ): ClaimResult {
24
+ if (principal === null || principal.kind === "open") {
25
+ return { ok: true, deviceId: claimed };
26
+ }
27
+ if (principal.kind === "shared") {
28
+ if (claimed && !principal.local && credentials) {
29
+ credentials.authority.noteLegacy(claimed);
30
+ }
31
+ return { ok: true, deviceId: claimed };
32
+ }
33
+ const bound = principal.deviceId;
34
+ if (bound !== null) {
35
+ return claimed === undefined || claimed === bound
36
+ ? { ok: true, deviceId: bound }
37
+ : {
38
+ ok: false,
39
+ error: `This credential belongs to device ${bound}; it cannot act as ${claimed}.`,
40
+ };
41
+ }
42
+ if (claimed === undefined) return { ok: true, deviceId: undefined };
43
+ const bind = credentials?.authority.bind(principal.credentialId, claimed);
44
+ if (!bind || !bind.ok) {
45
+ return { ok: false, error: bind?.error ?? "Credential cannot be bound" };
46
+ }
47
+ // The rest of this request (and the SSE session it may open) acts as the
48
+ // device it just became.
49
+ principal.deviceId = claimed;
50
+ return { ok: true, deviceId: claimed };
51
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Who is calling the bridge, and what they may do.
3
+ *
4
+ * A request authenticates as one of:
5
+ *
6
+ * open no token is configured (loopback-only bind) — every scope,
7
+ * exactly as before per-device credentials existed.
8
+ * shared the shared `native.token` — every scope. From a non-local
9
+ * client it is the LEGACY path: accepted only while
10
+ * `native.legacySharedToken` is on, and every device that uses
11
+ * it is offered an in-band upgrade to its own credential.
12
+ * device a per-device credential (core/mesh/credentials): the scopes it
13
+ * was granted, bound to one device id.
14
+ *
15
+ * Transport-pure like the server: the credential store is reached through
16
+ * the structural `BridgeCredentialAuthority` slice, injected at startup.
17
+ */
18
+
19
+ import type { IncomingMessage } from "node:http";
20
+ import { isDeviceCredentialToken } from "../../../../core/mesh/credentials/index.js";
21
+ import { logWarn } from "../../../../util/log.js";
22
+
23
+ /**
24
+ * What a credential may do (see core/mesh/credentials/types.ts):
25
+ * device act as ITSELF on the mesh — register, report, answer its own
26
+ * commands, move the files the daemon asked for.
27
+ * client the chat UI — chats, history, sending, non-secret reads.
28
+ * operator config / extension writes, daemon control, logs.
29
+ * The shared `native.token` and an open loopback bridge hold all three.
30
+ */
31
+ export type BridgeScope = "device" | "client" | "operator";
32
+
33
+ /**
34
+ * A route's tier in routes/table.ts. "public": served without a
35
+ * credential — every such entry is gated some other way (a single-use
36
+ * grant minted by the daemon, or for /health by answering only what
37
+ * pairing needs until a token is presented). A scope: the request's
38
+ * credential must hold it. A scope list: it must hold at least one.
39
+ */
40
+ export type BridgeRouteAuth = "public" | BridgeScope | readonly BridgeScope[];
41
+
42
+ export type BridgePrincipal =
43
+ | { kind: "open" }
44
+ | { kind: "shared"; local: boolean }
45
+ | {
46
+ kind: "device";
47
+ credentialId: string;
48
+ /** Null until an unbound pairing/installer credential names a device. */
49
+ deviceId: string | null;
50
+ scopes: readonly BridgeScope[];
51
+ };
52
+
53
+ type AuthenticatedCredential = {
54
+ id: string;
55
+ deviceId: string | null;
56
+ scopes: readonly BridgeScope[];
57
+ };
58
+
59
+ /** The slice of core's DeviceCredentialStore the bridge depends on. */
60
+ type BridgeCredentialAuthority = {
61
+ authenticate(token: string): AuthenticatedCredential | null;
62
+ bind(
63
+ credentialId: string,
64
+ deviceId: string,
65
+ ): { ok: true } | { ok: false; error: string };
66
+ rotationDue(credentialId: string): boolean;
67
+ noteLegacy(deviceId: string): boolean;
68
+ onRevoked(listener: (credentialIds: readonly string[]) => void): () => void;
69
+ mint(input: {
70
+ deviceId: string | null;
71
+ scopes: readonly BridgeScope[];
72
+ origin: "upgrade" | "rotate";
73
+ }): Promise<{ token: string; credential: AuthenticatedCredential }>;
74
+ };
75
+
76
+ type BridgeCredentialPolicy = {
77
+ /** Accept the shared `native.token` from non-local clients. */
78
+ legacySharedToken: boolean;
79
+ /** The most a companion may be granted in-band (`native.companionScopes`). */
80
+ companionScopes: readonly BridgeScope[];
81
+ };
82
+
83
+ /** Per-device credential support, as injected into the bridge server. */
84
+ export type BridgeCredentials = {
85
+ authority: BridgeCredentialAuthority;
86
+ policy: BridgeCredentialPolicy;
87
+ };
88
+
89
+ const ALL_SCOPES: readonly BridgeScope[] = ["device", "client", "operator"];
90
+
91
+ function principalScopes(p: BridgePrincipal): readonly BridgeScope[] {
92
+ return p.kind === "device" ? p.scopes : ALL_SCOPES;
93
+ }
94
+
95
+ export function hasScope(p: BridgePrincipal, scope: BridgeScope): boolean {
96
+ return principalScopes(p).includes(scope);
97
+ }
98
+
99
+ /** Whether `p` clears a route's declared tier. */
100
+ export function routeAllows(
101
+ tier: BridgeRouteAuth,
102
+ p: BridgePrincipal,
103
+ ): boolean {
104
+ if (tier === "public") return true;
105
+ const needed: readonly BridgeScope[] =
106
+ typeof tier === "string" ? [tier] : tier;
107
+ return needed.some((scope) => hasScope(p, scope));
108
+ }
109
+
110
+ /** Human form of a tier for a 403 body: `"operator"` / `"device" or "client"`. */
111
+ export function describeTier(tier: BridgeRouteAuth): string {
112
+ const scopes = typeof tier === "string" ? [tier] : tier;
113
+ return scopes.map((s) => `"${s}"`).join(" or ");
114
+ }
115
+
116
+ /**
117
+ * Same-machine and not relayed. The shared token is how the local desktop
118
+ * app and CLI authenticate (they read it from the 0600 discovery file), so
119
+ * it keeps working for them with legacy mode off. A reverse proxy on the
120
+ * same host also connects from loopback, so any forwarding header makes the
121
+ * request remote — a proxied internet client must never pass as local.
122
+ */
123
+ function isLocalRequest(req: IncomingMessage): boolean {
124
+ const addr = req.socket.remoteAddress ?? "";
125
+ const loopback =
126
+ addr === "127.0.0.1" ||
127
+ addr === "::1" ||
128
+ addr === "::ffff:127.0.0.1" ||
129
+ addr.startsWith("127.");
130
+ if (!loopback) return false;
131
+ const h = req.headers;
132
+ return !(
133
+ h["x-forwarded-for"] ||
134
+ h["forwarded"] ||
135
+ h["x-real-ip"] ||
136
+ h["x-forwarded-host"]
137
+ );
138
+ }
139
+
140
+ /** Addresses already warned about a refused legacy token (bounded). */
141
+ const refusedLegacy = new Set<string>();
142
+
143
+ /**
144
+ * Resolve a presented bearer to a principal, or null when it must be
145
+ * refused. `sharedMatches` is the server's constant-time check against
146
+ * `native.token`.
147
+ */
148
+ export function resolvePrincipal(
149
+ candidate: string,
150
+ req: IncomingMessage,
151
+ sharedMatches: (candidate: string) => boolean,
152
+ credentials: BridgeCredentials | undefined,
153
+ ): BridgePrincipal | null {
154
+ if (credentials && isDeviceCredentialToken(candidate)) {
155
+ const cred = credentials.authority.authenticate(candidate);
156
+ return cred
157
+ ? {
158
+ kind: "device",
159
+ credentialId: cred.id,
160
+ deviceId: cred.deviceId,
161
+ scopes: cred.scopes,
162
+ }
163
+ : null;
164
+ }
165
+ if (!sharedMatches(candidate)) return null;
166
+ const local = isLocalRequest(req);
167
+ if (!local && credentials && !credentials.policy.legacySharedToken) {
168
+ const remote = req.socket.remoteAddress ?? "unknown";
169
+ if (!refusedLegacy.has(remote) && refusedLegacy.size < 256) {
170
+ refusedLegacy.add(remote);
171
+ logWarn(
172
+ "native",
173
+ `Refused the shared bridge token from ${remote}: native.legacySharedToken is off, so remote clients need a per-device credential (pair the device again).`,
174
+ );
175
+ }
176
+ return null;
177
+ }
178
+ return { kind: "shared", local };
179
+ }
180
+
181
+ /** What `GET /auth/whoami` answers. */
182
+ export function describePrincipal(
183
+ p: BridgePrincipal,
184
+ credentials: BridgeCredentials | undefined,
185
+ ): Record<string, unknown> {
186
+ const base = { ok: true, kind: p.kind, scopes: [...principalScopes(p)] };
187
+ if (p.kind !== "device") {
188
+ // A shared-token client can trade up to its own credential; clients
189
+ // that read the token from the local discovery file simply do not.
190
+ const upgrade = p.kind === "shared" && credentials !== undefined;
191
+ return upgrade ? { ...base, action: "upgrade" } : base;
192
+ }
193
+ const rotate = credentials?.authority.rotationDue(p.credentialId) ?? false;
194
+ return {
195
+ ...base,
196
+ credentialId: p.credentialId,
197
+ deviceId: p.deviceId,
198
+ ...(rotate ? { action: "rotate" } : {}),
199
+ };
200
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The in-band credential upgrade — `POST /auth/upgrade`.
3
+ *
4
+ * Two callers, one endpoint:
5
+ *
6
+ * shared token → a per-device credential bound to the device id in the
7
+ * body (the migration path off `native.token`). Scopes are what the
8
+ * client asks for, capped by policy: a node gets `device`; a companion
9
+ * at most `native.companionScopes` (default device + client). Operator
10
+ * is never granted in-band unless the operator put it in that list.
11
+ *
12
+ * per-device credential → a replacement with the same device and scopes
13
+ * (rotation, when the operator asked for it or the client wants one).
14
+ *
15
+ * The replaced credential stays valid until the new one is first used, so a
16
+ * reply lost in transit never strands a device; it is revoked on that first
17
+ * use. The reply carries the only copy of the new token and is `no-store`.
18
+ */
19
+
20
+ import { log } from "../../../../util/log.js";
21
+ import { claimDevice } from "./claims.js";
22
+ import type {
23
+ BridgeCredentials,
24
+ BridgePrincipal,
25
+ BridgeScope,
26
+ } from "./principal.js";
27
+
28
+ export type UpgradeReply = { status: number; body: Record<string, unknown> };
29
+
30
+ const DEVICE_ID_RE = /^[A-Za-z0-9._:@-]{1,128}$/;
31
+ const SCOPES: readonly BridgeScope[] = ["device", "client", "operator"];
32
+
33
+ function fail(status: number, error: string): UpgradeReply {
34
+ return { status, body: { ok: false, error } };
35
+ }
36
+
37
+ function requestedScopes(value: unknown): BridgeScope[] {
38
+ if (!Array.isArray(value)) return [];
39
+ return SCOPES.filter((s) => value.includes(s));
40
+ }
41
+
42
+ /** What a shared-token client may be granted, given what it asked for. */
43
+ function grantFor(
44
+ credentials: BridgeCredentials,
45
+ client: unknown,
46
+ asked: BridgeScope[],
47
+ ): BridgeScope[] {
48
+ const ceiling: readonly BridgeScope[] =
49
+ client === "node" ? ["device"] : credentials.policy.companionScopes;
50
+ if (asked.length === 0) return [...ceiling];
51
+ return asked.filter((s) => ceiling.includes(s));
52
+ }
53
+
54
+ export async function upgradeCredential(
55
+ credentials: BridgeCredentials | undefined,
56
+ principal: BridgePrincipal,
57
+ body: Record<string, unknown>,
58
+ remote: string,
59
+ ): Promise<UpgradeReply> {
60
+ if (!credentials) {
61
+ return fail(404, "Per-device credentials are not enabled on this bridge");
62
+ }
63
+ if (principal.kind === "open") {
64
+ return fail(
65
+ 409,
66
+ "This bridge requires no token; there is nothing to upgrade",
67
+ );
68
+ }
69
+ const deviceId =
70
+ typeof body.deviceId === "string" ? body.deviceId.trim() : "";
71
+ if (!DEVICE_ID_RE.test(deviceId)) {
72
+ return fail(400, "deviceId is required (1-128 of A-Z a-z 0-9 . _ : @ -)");
73
+ }
74
+ const rotating = principal.kind === "device";
75
+ if (rotating) {
76
+ // A credential can only re-issue itself: same device, same scopes.
77
+ const claim = claimDevice(principal, deviceId, credentials);
78
+ if (!claim.ok) return fail(403, claim.error);
79
+ }
80
+
81
+ const scopes = rotating
82
+ ? [...principal.scopes]
83
+ : grantFor(credentials, body.client, requestedScopes(body.scopes));
84
+ if (scopes.length === 0) {
85
+ return fail(403, "None of the requested scopes can be granted in-band");
86
+ }
87
+ const minted = await credentials.authority.mint({
88
+ deviceId,
89
+ scopes,
90
+ origin: rotating ? "rotate" : "upgrade",
91
+ });
92
+ log(
93
+ "native",
94
+ `Issued credential ${minted.credential.id} to device ${deviceId} (${scopes.join(", ")}) — ` +
95
+ (rotating
96
+ ? `rotated from ${principal.credentialId}`
97
+ : `in-band upgrade from the shared token`) +
98
+ ` via ${remote}`,
99
+ );
100
+ return {
101
+ status: 200,
102
+ body: {
103
+ ok: true,
104
+ token: minted.token,
105
+ credentialId: minted.credential.id,
106
+ deviceId,
107
+ scopes,
108
+ },
109
+ };
110
+ }
111
+
112
+ /**
113
+ * The `credential` hint on a /devices/register reply — how a heartbeating
114
+ * device learns it should upgrade or rotate without an extra request.
115
+ */
116
+ export function credentialHint(
117
+ credentials: BridgeCredentials | undefined,
118
+ principal: BridgePrincipal | null,
119
+ ): { action: "upgrade" | "rotate" } | undefined {
120
+ if (!credentials || principal === null) return undefined;
121
+ if (principal.kind === "shared") return { action: "upgrade" };
122
+ if (
123
+ principal.kind === "device" &&
124
+ credentials.authority.rotationDue(principal.credentialId)
125
+ ) {
126
+ return { action: "rotate" };
127
+ }
128
+ return undefined;
129
+ }
@@ -0,0 +1,37 @@
1
+ import type { RouteHost } from "./host.js";
2
+ import type { BridgeRoutes } from "./table.js";
3
+ import { describePrincipal } from "../credentials/principal.js";
4
+ import { upgradeCredential } from "../credentials/upgrade.js";
5
+
6
+ export function authRoutes(
7
+ host: RouteHost,
8
+ ): Pick<BridgeRoutes, "GET /auth/whoami" | "POST /auth/upgrade"> {
9
+ const { json, readJson } = host;
10
+ return {
11
+ // ── Credential self-service ────────────────────────────────────────
12
+
13
+ // Which credential this is, what it may do, and whether the daemon
14
+ // wants it upgraded (shared token) or rotated (operator request).
15
+ "GET /auth/whoami": ({ res, principal }) => {
16
+ if (!principal)
17
+ return json(res, 401, { ok: false, error: "Unauthorized" });
18
+ json(res, 200, describePrincipal(principal, host.credentials));
19
+ },
20
+
21
+ // Trade the shared token (or this credential) for a per-device one.
22
+ // The reply is the only copy of the new token anywhere.
23
+ "POST /auth/upgrade": async ({ req, res, principal }) => {
24
+ if (!principal)
25
+ return json(res, 401, { ok: false, error: "Unauthorized" });
26
+ const body = await readJson(req);
27
+ const reply = await upgradeCredential(
28
+ host.credentials,
29
+ principal,
30
+ body,
31
+ req.socket.remoteAddress ?? "unknown",
32
+ );
33
+ res.setHeader("Cache-Control", "no-store");
34
+ json(res, reply.status, reply.body);
35
+ },
36
+ };
37
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RouteHost } from "./host.js";
2
- import type { BridgeRoutes } from "./table.js";
2
+ import { claimDevice } from "../credentials/claims.js";
3
+ import type { BridgeRoutes, RouteContext } from "./table.js";
3
4
  import {
4
5
  asAttachmentRefs,
5
6
  asPositiveInt,
@@ -8,6 +9,23 @@ import {
8
9
  } from "./params.js";
9
10
  import { log, logWarn } from "../../../../util/log.js";
10
11
 
12
+ /**
13
+ * GET /events. A per-device credential can only name its own device (and
14
+ * is named by it when it omits the claim) — see credentials/claims.ts.
15
+ */
16
+ function openEvents(host: RouteHost, ctx: RouteContext): void {
17
+ const { res, url, principal } = ctx;
18
+ const claim = claimDevice(principal, deviceIdParam(url), host.credentials);
19
+ if (!claim.ok || !principal) {
20
+ host.json(res, 403, {
21
+ ok: false,
22
+ error: claim.ok ? "Forbidden" : claim.error,
23
+ });
24
+ return;
25
+ }
26
+ host.openStream(res, claim.deviceId, principal);
27
+ }
28
+
11
29
  export function chatRoutes(
12
30
  host: RouteHost,
13
31
  ): Pick<
@@ -33,7 +51,7 @@ export function chatRoutes(
33
51
 
34
52
  // A mesh client names itself here so device-addressed events reach
35
53
  // it alone (see sendToDevice); UI clients simply omit it.
36
- "GET /events": ({ res, url }) => host.openStream(res, deviceIdParam(url)),
54
+ "GET /events": (ctx) => openEvents(host, ctx),
37
55
  "GET /chats": ({ res }) => json(res, 200, { chats: h.listChats() }),
38
56
  "POST /chats": async ({ req, res }) => {
39
57
  const body = await readJson(req);
@@ -1,6 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Readable } from "node:stream";
3
3
  import type { AttachmentRef } from "./params.js";
4
+ import type {
5
+ BridgeCredentials,
6
+ BridgePrincipal,
7
+ } from "../credentials/principal.js";
4
8
  import type {
5
9
  BackendOption,
6
10
  ClientAttachment,
@@ -170,6 +174,12 @@ export type RouteHost = {
170
174
  file: { path: string; size: number },
171
175
  ) => void;
172
176
  serveMedia: (res: ServerResponse, id: string) => Promise<void>;
173
- openStream: (res: ServerResponse, deviceId?: string) => void;
177
+ openStream: (
178
+ res: ServerResponse,
179
+ deviceId: string | undefined,
180
+ principal: BridgePrincipal,
181
+ ) => void;
174
182
  unknownProvision: (res: ServerResponse) => void;
183
+ /** Per-device credential support; undefined = shared token only. */
184
+ credentials: BridgeCredentials | undefined;
175
185
  };
@@ -11,6 +11,7 @@ import { memoryRoutes } from "./memory.js";
11
11
  import { modelRoutes } from "./models.js";
12
12
  import { daemonRoutes } from "./daemon.js";
13
13
  import { meshRoutes } from "./mesh.js";
14
+ import { authRoutes } from "./auth.js";
14
15
 
15
16
  export function buildRoutes(host: RouteHost): BridgeRoutes {
16
17
  return {
@@ -20,5 +21,6 @@ export function buildRoutes(host: RouteHost): BridgeRoutes {
20
21
  ...modelRoutes(host),
21
22
  ...daemonRoutes(host),
22
23
  ...meshRoutes(host),
24
+ ...authRoutes(host),
23
25
  };
24
26
  }