granttap-mcp 0.8.54 → 0.8.55

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.
@@ -102,6 +102,6 @@ async function sendScopedPayload(input: {
102
102
  key,
103
103
  purpose,
104
104
  createdAt: Date.now(),
105
- }, to, { ttlMs: options.ttlMs });
105
+ }, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey });
106
106
  await client.sendSession(payload, sessionId, to, options);
107
107
  }
@@ -56,7 +56,7 @@ export async function publishSessionEvents(
56
56
  sessionId: string,
57
57
  status?: SessionsStatus,
58
58
  threadId?: string,
59
- history?: { history?: boolean; historyCursor?: string },
59
+ history?: { history?: boolean; historyCursor?: string; peerPublicKey?: string },
60
60
  ): Promise<boolean> {
61
61
  const session = resolveSession(client, sessionId, status);
62
62
  if (!session) return false;
@@ -66,6 +66,7 @@ export async function publishSessionEvents(
66
66
  await sendSessionPayload(client, activityWithImages(activity), sessionId, "phone", {
67
67
  ttlMs: INTERVAL_MS * 24,
68
68
  reliable: false,
69
+ peerPublicKey: history?.peerPublicKey,
69
70
  });
70
71
  return true;
71
72
  }
@@ -83,9 +83,9 @@ export async function handleMonitorMessage(
83
83
  void publish().catch(() => {});
84
84
  return true;
85
85
  } else if (payload.type === "session.subscribe") {
86
- return handleInboundSubscription(client, payload, subscriptions, publish);
86
+ return handleInboundSubscription(client, payload, subscriptions, publish, input.peerPublicKey);
87
87
  } else if (payload.type === "session.events" || payload.type === "session.image.query") {
88
- return handleSessionRead(client, payload);
88
+ return handleSessionRead(client, payload, input.peerPublicKey);
89
89
  } else if (payload.type === "sessions.refresh") {
90
90
  return handleInboundRefresh(client, payload, publish);
91
91
  } else if (payload.type === "sessions.history.query") {
@@ -266,9 +266,10 @@ async function handleInboundTaskCreate(
266
266
  function handleInboundSubscription(
267
267
  client: RelayClient, payload: Extract<Payload, { type: "session.subscribe" }>,
268
268
  subscriptions: Set<string>, publish: (forceHistory?: boolean) => Promise<void>,
269
+ peerPublicKey?: string,
269
270
  ): boolean {
270
271
  // The transcript goes first, before a potentially slow catalog rescan.
271
- void publishSessionEvents(client, payload.sessionId).catch(() => false);
272
+ void publishSessionEvents(client, payload.sessionId, undefined, undefined, { peerPublicKey }).catch(() => false);
272
273
  if (handleSubscription(subscriptions, payload)) {
273
274
  void publish().catch(() => {});
274
275
  }
@@ -276,8 +277,10 @@ function handleInboundSubscription(
276
277
  }
277
278
 
278
279
  async function handleSessionRead(client: RelayClient,
279
- payload: Extract<Payload, { type: 'session.events' | 'session.image.query' }>): Promise<boolean> {
280
- if (payload.type === 'session.image.query') await publishSessionImage(client, payload);
281
- else await publishSessionEvents(client, payload.sessionId, undefined, payload.threadId, payload).catch(() => false);
280
+ payload: Extract<Payload, { type: 'session.events' | 'session.image.query' }>,
281
+ peerPublicKey?: string): Promise<boolean> {
282
+ if (payload.type === 'session.image.query') await publishSessionImage(client, payload, peerPublicKey);
283
+ else await publishSessionEvents(client, payload.sessionId, undefined, payload.threadId,
284
+ { ...payload, peerPublicKey }).catch(() => false);
282
285
  return true;
283
286
  }
@@ -21,3 +21,5 @@ Engine binding recovery waits for the first connected catalog publication before
21
21
  Codex discovery shares repeated checkout verification only within one synchronous scan. The next scan verifies repository identity again; no long-lived checkout cache can cross a repository change. This keeps startup catalog delivery ahead of repeated Git subprocesses.
22
22
 
23
23
  Subscribed transcript heads use a separate, single-flight transfer loop. A stalled head never occupies the current catalog publication slot; stopping the monitor cancels further queued reads. The iPhone keeps its historical backfill independent of both loops.
24
+
25
+ Authenticated subscription and transcript requests pass the verified requester key to the encrypted reply. A requested page does not wait for copies addressed to other paired controllers. Shared catalogue and chat updates remain available to passive controllers.
@@ -17,3 +17,9 @@ Native image identities remain readable after the chat head advances. Codex
17
17
  attachments are located by timestamp and content block together, so a caption
18
18
  with the same timestamp cannot hide the picture. Original bytes are delivered in
19
19
  bounded encrypted chunks and the Apple client retains them for offline use.
20
+
21
+ An authenticated image query returns its key grant and chunk only to the requesting controller. Shared chat publication still retains every authorized controller, including one receiving passively.
22
+
23
+ Saved native picture identities remain addressable after newer transcript pages append. Lookup walks bounded windows toward the original row, verifies the exact conversation and block identity, and stops at the file start or a non-advancing cursor. There is no page-count expiry for a previously advertised attachment.
24
+
25
+ Workspace screenshots in older replies resolve the original native row and its exact advertised image identity before applying the existing Task, Execution and workspace access checks. A request never supplies a filesystem path, and looking up an older reply does not widen access outside its authorized workspace.
@@ -22,7 +22,8 @@ export function readSessionImage(query: SessionImageQuery, session: SessionInfo,
22
22
  return sessionImageChunk(session, activity, query.imageId, query.offset, query.historyCursor);
23
23
  }
24
24
 
25
- export async function publishSessionImage(client: RelayClient, query: SessionImageQuery): Promise<boolean> {
25
+ export async function publishSessionImage(client: RelayClient, query: SessionImageQuery,
26
+ peerPublicKey?: string): Promise<boolean> {
26
27
  const session = publishedSession(client, query.sessionId)
27
28
  ?? scanSessions().sessions.find(s => s.sessionId === query.sessionId)
28
29
  ?? scanSessionHistory().find(s => s.sessionId === query.sessionId);
@@ -33,6 +34,6 @@ export async function publishSessionImage(client: RelayClient, query: SessionIma
33
34
  requestId: query.requestId, imageId: query.imageId, offset: query.offset,
34
35
  ...(chunk ? { mimeType: chunk.mime_type, totalBytes: chunk.total_bytes,
35
36
  dataBase64: chunk.data_base64 } : { unavailable: true }) }), query.sessionId,
36
- 'phone', { ttlMs: 20_000, reliable: false });
37
+ 'phone', { ttlMs: 20_000, reliable: false, peerPublicKey });
37
38
  return chunk !== undefined;
38
39
  }
@@ -0,0 +1,43 @@
1
+ import type { ActivityEntry, SessionInfo } from "../../../../../packages/protocol/schema";
2
+ import { appendCodexActivity } from "../../sessions/scan/codex/activity";
3
+ import { codexLogPathForSession } from "../../sessions/scan/codex/shared";
4
+ import { appendClaudeActivity, claudeLogPath } from "../../sessions/scan/claude";
5
+ import { safeParse, ts } from "../../sessions/support/common";
6
+ import { previousNativeLines } from "../native-lines";
7
+
8
+ /** Recover only the exact previously advertised row in this native conversation. */
9
+ export function nativeTranscriptEntry(session: SessionInfo, entryId: string,
10
+ cursor?: string): ActivityEntry | undefined {
11
+ if (!entryId.startsWith(`${session.sessionId}:`)) return undefined;
12
+ const path = session.agent === "codex" ? codexLogPathForSession(session.sessionId, session.startedAt)
13
+ : session.agent === "claude" ? claudeLogPath(session.sessionId) : undefined;
14
+ if (!path) return undefined;
15
+ const stamp = session.agent === "codex" ? Number(entryId.slice(session.sessionId.length + 1).split(":")[0]) : undefined;
16
+ if (stamp != null && !Number.isFinite(stamp)) return undefined;
17
+ let pageCursor = cursor;
18
+ try {
19
+ while (true) {
20
+ const window = previousNativeLines(path, session.sessionId, pageCursor);
21
+ if (!window) return undefined;
22
+ let earliest = Infinity;
23
+ for (const line of window.lines) {
24
+ const row = safeParse(line.text), at = ts(row?.timestamp);
25
+ if (at) earliest = Math.min(earliest, at);
26
+ if (stamp != null && at !== stamp) continue;
27
+ const out: ActivityEntry[] = [], seen = new Set<string>(), observations = new Map();
28
+ if (session.agent === "codex") {
29
+ appendCodexActivity({ out, seen, session, lines: [line.text], observations,
30
+ changes: new Map(), details: new Map(), fullText: true });
31
+ } else {
32
+ appendClaudeActivity({ out, seen, session, lines: [line.text], observations, fullText: true });
33
+ }
34
+ const found = out.find(entry => entry.id === entryId);
35
+ if (found) return found;
36
+ }
37
+ if (window.start === 0 || (stamp != null && earliest < stamp)) return undefined;
38
+ const next = window.lines[0]?.offset ?? window.start;
39
+ if (next >= window.end) return undefined;
40
+ pageCursor = window.cursorAt(next);
41
+ }
42
+ } catch { return undefined; }
43
+ }
@@ -22,7 +22,8 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
22
22
  if (!path || !['claude', 'codex'].includes(session.agent)) return undefined;
23
23
  try {
24
24
  let pageCursor = cursor;
25
- for (let page = 0; page < 32; page++) {
25
+ // Old advertised pictures remain readable as the conversation grows.
26
+ while (true) {
26
27
  const window = previousNativeLines(path, session.sessionId, pageCursor);
27
28
  if (!window) return undefined;
28
29
  const lines = window.lines.map(line => line.text);
@@ -49,7 +50,9 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
49
50
  }
50
51
  }
51
52
  if (window.start === 0) break;
52
- pageCursor = window.cursorAt(window.lines[0]?.offset ?? window.start);
53
+ const next = window.lines[0]?.offset ?? window.start;
54
+ if (next >= window.end) break;
55
+ pageCursor = window.cursorAt(next);
53
56
  }
54
57
  } catch { return undefined; }
55
58
  return undefined;
@@ -11,7 +11,9 @@ export function nativeImageLines(sessionId: string, entryId: string): string[] |
11
11
  if (!path || !Number.isFinite(stamp) || !Number.isSafeInteger(blockIndex) || blockIndex < 0) return undefined;
12
12
  let cursor: string | undefined;
13
13
  try {
14
- for (let page = 0; page < 32; page++) {
14
+ // A saved attachment does not expire when newer transcript windows append.
15
+ // Each window stays bounded and its cursor must move toward the file start.
16
+ while (true) {
15
17
  const window = previousNativeLines(path, sessionId, cursor);
16
18
  if (!window) return undefined;
17
19
  let earliest = Infinity;
@@ -24,7 +26,9 @@ export function nativeImageLines(sessionId: string, entryId: string): string[] |
24
26
  && p.content[blockIndex]?.type === "input_image") return [line.text];
25
27
  }
26
28
  if (window.start === 0 || earliest < stamp) return undefined;
27
- cursor = window.cursorAt(window.lines[0]?.offset ?? window.start);
29
+ const next = window.lines[0]?.offset ?? window.start;
30
+ if (next >= window.end) return undefined;
31
+ cursor = window.cursorAt(next);
28
32
  }
29
33
  } catch { return undefined; }
30
34
  return undefined;
@@ -8,6 +8,7 @@ import { recordedToolDetails } from "./tool-details";
8
8
  import { completedTurnChanges } from "./turn-changes";
9
9
  export { nativeImageLines } from "./images";
10
10
  export { nativeTranscriptImage } from './images/native';
11
+ export { nativeTranscriptEntry } from './images/advertised-entry';
11
12
 
12
13
  function boundedEntry(entry: ActivityEntry): ActivityEntry {
13
14
  const result = { ...entry };
@@ -1,7 +1,7 @@
1
1
  import { artifactImages, artifactImageChunk } from "./artifacts";
2
2
  import { desktopTaskActivity } from "../task-activity";
3
3
  import type { ActivityEntry, SessionInfo, SessionActivity } from '../../../../../packages/protocol/schema';
4
- import { nativeTranscriptImage } from '../../../../bridge/src/transcript-history';
4
+ import { nativeTranscriptImage, nativeTranscriptEntry } from '../../../../bridge/src/transcript-history';
5
5
  import { readStoreState } from '../../../../bridge/src/mesh/store/state';
6
6
  import { configDir } from '../../../../bridge/src/config';
7
7
  import { join } from 'node:path';
@@ -10,16 +10,25 @@ export function transcriptImages(entry: ActivityEntry) {
10
10
  return artifactImages(entry).map(({ id, name, markdown }) => ({ id, name, markdown }));
11
11
  }
12
12
 
13
+ function sessionArtifact(session: SessionInfo, activity: { entries: Array<Parameters<typeof artifactImages>[0]> },
14
+ imageId: string, cursor?: string) {
15
+ const current = activity.entries.flatMap(artifactImages).find(image => image.id === imageId);
16
+ if (current) return current;
17
+ const entryId = imageId.match(/^(.*):image:[a-f0-9]{24}$/)?.[1];
18
+ const saved = entryId ? nativeTranscriptEntry(session, entryId, cursor) : undefined;
19
+ return saved ? artifactImages(saved).find(image => image.id === imageId) : undefined;
20
+ }
21
+
13
22
  export function sessionImageChunk(session: SessionInfo, activity: SessionActivity,
14
23
  imageId: string, offset: number, cursor?: string, storePath?: string) {
15
24
  if (activity.sessionId !== session.sessionId) return undefined;
16
25
  const native = activity.entries.some(entry => entry.id === imageId && entry.kind === 'user'
17
26
  && entry.attachments?.includes('Image'));
18
- if (native || imageId.startsWith(`${session.sessionId}:`)) {
27
+ if (!imageId.includes(':image:') && (native || imageId.startsWith(`${session.sessionId}:`))) {
19
28
  const picture = nativeTranscriptImage(session, imageId, offset, cursor);
20
29
  if (picture) return picture;
21
30
  }
22
- const artifact = activity.entries.flatMap(artifactImages).find(image => image.id === imageId);
31
+ const artifact = sessionArtifact(session, activity, imageId, cursor);
23
32
  if (!artifact) return undefined;
24
33
  const loaded = readStoreState(storePath ?? join(configDir(), 'project-mesh.json'));
25
34
  if (loaded.status !== 'ok') return undefined;
@@ -46,7 +55,11 @@ export function desktopTaskImage(input: unknown, storePath?: string,
46
55
  const activity = cachedActivity ?? desktopTaskActivity(query, storePath);
47
56
  if (!activity?.session_id || !activity.agent
48
57
  || activity.project_id !== query.project_id || activity.task_id !== query.task_id) return undefined;
49
- const artifact = activity.entries.flatMap(artifactImages).find((image) => image.id === imageId);
58
+ const session: SessionInfo = { sessionId: activity.session_id, agent: activity.agent,
59
+ state: "idle", startedAt: 0, lastActivityAt: 0, tokensSession: 0, tokensLastTurn: 0 };
60
+ const cursor = query.history_cursor;
61
+ if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 512)) return undefined;
62
+ const artifact = sessionArtifact(session, activity, imageId, cursor as string | undefined);
50
63
  if (artifact) {
51
64
  const chunk = artifactImageChunk({ projectId: activity.project_id, taskId: activity.task_id,
52
65
  sessionId: activity.session_id, provider: activity.agent, image: artifact, offset, storePath });
@@ -55,10 +68,6 @@ export function desktopTaskImage(input: unknown, storePath?: string,
55
68
  if (!["codex", "claude"].includes(activity.agent)
56
69
  || !activity.entries.some((entry) => entry.id === imageId
57
70
  && entry.kind === "user" && entry.attachments?.includes("Image"))) return undefined;
58
- const cursor = query.history_cursor;
59
- if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 512)) return undefined;
60
- const session: SessionInfo = { sessionId: activity.session_id, agent: activity.agent,
61
- state: "idle", startedAt: 0, lastActivityAt: 0, tokensSession: 0, tokensLastTurn: 0 };
62
71
  const chunk = nativeTranscriptImage(session, imageId, offset, cursor as string | undefined);
63
72
  return chunk ? { operation: "desktop.task_image", image_id: imageId, ...chunk } : undefined;
64
73
  }
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
10
10
  Install the supported CLI on this computer, then run:
11
11
 
12
12
  ```bash
13
- npm install -g granttap-mcp@0.8.54
13
+ npm install -g granttap-mcp@0.8.55
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -4,7 +4,7 @@
4
4
  "command": "node",
5
5
  "args": [
6
6
  "-e",
7
- "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.54\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
7
+ "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.55\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
8
8
  ]
9
9
  }
10
10
  }
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
7
7
  const { existsSync } = require("node:fs");
8
8
  const { delimiter, dirname, join } = require("node:path");
9
9
 
10
- const PACKAGE = "granttap-mcp@0.8.54";
10
+ const PACKAGE = "granttap-mcp@0.8.55";
11
11
  const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
12
12
  const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
13
13
  ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.54",
3
+ "version": "0.8.55",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,11 +11,13 @@ Tests live in `tests/`; they include an actual WebSocket server with the relay's
11
11
  rate limit. A socket write is transport evidence, never an approval receipt.
12
12
 
13
13
  MIT License; see the repository LICENSE.
14
- # Active controllers
14
+ # Controller delivery
15
15
 
16
- Authenticated incoming phone traffic marks that controller active for 90 seconds.
17
- Large replaceable encrypted task/project data targets recent controllers while
18
- catalogs, liveness, approval status and reliable messages retain every authorized
19
- recipient. Current catalogs and liveness pass queued bulk frames. A transport with no recent controller falls back to
20
- the complete authorized set for legacy clients and reconnection. Routing never
21
- deletes a pairing or replaces the controller authorization gate.
16
+ Catalogs, liveness and shared conversation updates reach every authorized
17
+ controller, including a phone receiving passively. Silence never removes access
18
+ to its local replica. Authenticated history, subscription and image requests
19
+ return their independent key grant and encrypted result only to the requesting
20
+ controller, with priority over queued periodic snapshots. Older callers without
21
+ a requester retain the complete authorized recipient set. Every explicit target
22
+ passes the pairing and controller authorization checks. Routing never deletes a
23
+ pairing or changes approval policy.
@@ -3,7 +3,6 @@ import type { PeerConfig, SendOptions } from "../relay-client-types";
3
3
  import type { Payload, Role } from "../../protocol/schema";
4
4
  import { peerPublicKeys } from "../crypto";
5
5
  import { sealEnvelope } from "../relay-envelope";
6
- export { ActiveControllerRecipients } from "./active-controllers";
7
6
 
8
7
  type Job = { frame: string; bytes: number; resolve: () => void; reject: (error: Error) => void };
9
8
  const CLOSED = () => new Error("relay connection closed before delivery");
@@ -41,6 +41,8 @@ export type RelayClientOptions = {
41
41
  };
42
42
 
43
43
  export type SendOptions = {
44
+ /** Reply only to the authenticated requester; absent means all authorized peers. */
45
+ peerPublicKey?: string;
44
46
  /** Delivery lifetime for relay hold queues. Omit only for non-expiring hello packets. */
45
47
  ttlMs?: number;
46
48
  deliveryId?: string;
@@ -10,7 +10,7 @@
10
10
  * the Cloudflare Durable Object deployment.
11
11
  */
12
12
  import WebSocket from "ws";
13
- import { OutboundFrameQueue, sendEncryptedPayload, ActiveControllerRecipients } from "./outbound-scheduler";
13
+ import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
14
14
  import { createHash } from "node:crypto";
15
15
  import { Envelope, Payload, type Role } from "../protocol/schema";
16
16
  import { openFromPeersWithIdentity, openWithTransferKey, peerPublicKeys, seal, sealWithTransferKey } from "./crypto";
@@ -34,7 +34,6 @@ export class RelayClient {
34
34
  private livenessTimer?: NodeJS.Timeout;
35
35
  private awaitingPong = false;
36
36
  private readonly sessionKeys = new Map<string, string>();
37
- private readonly activeControllers = new ActiveControllerRecipients();
38
37
  /**
39
38
  * Delivery ids are relay-controlled metadata, so replay protection is bound
40
39
  * to the authenticated ciphertext itself. Keep a bounded process-local set:
@@ -200,9 +199,6 @@ export class RelayClient {
200
199
  this.sessionKeys.set(payload.sessionId, payload.key);
201
200
  }
202
201
  if (this.processingCiphertexts.has(fingerprint)) return;
203
- if (this.cfg.role === "machine" && (payload.type !== "hello" || payload.role === "phone")) {
204
- this.activeControllers.observe(peerPublicKey);
205
- }
206
202
  this.processingCiphertexts.add(fingerprint);
207
203
  try {
208
204
  const results = await Promise.all([...this.listeners].map(async (listener) => {
@@ -238,10 +234,11 @@ export class RelayClient {
238
234
  const ws = this.ws;
239
235
  if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
240
236
  if (!this.frames) throw new Error("relay not connected");
241
- const replaceable = this.cfg.role === "machine" && to === "phone"
242
- && options.reliable === false && payload.type === "session.sealed";
243
- await sendEncryptedPayload(this.frames, this.cfg,
244
- key => this.peerIsAllowed(key) && this.activeControllers.allows(key, replaceable),
237
+ if (options.peerPublicKey) {
238
+ await this.sendToPeer(payload, options.peerPublicKey, options);
239
+ return;
240
+ }
241
+ await sendEncryptedPayload(this.frames, this.cfg, key => this.peerIsAllowed(key),
245
242
  { payload, to, options });
246
243
  }
247
244
 
@@ -1,15 +0,0 @@
1
- /** Activity routes replaceable traffic; authorization remains a separate check. */
2
- export class ActiveControllerRecipients {
3
- private readonly seen = new Map<string, number>();
4
-
5
- observe(peer: string, now = Date.now()): void {
6
- this.seen.set(peer, now);
7
- }
8
-
9
- allows(peer: string, replaceable: boolean, now = Date.now()): boolean {
10
- if (!replaceable) return true;
11
- const active = [...this.seen].filter(([, at]) => now >= at && now - at <= 90_000);
12
- // A newly connected transport has not heard its controllers yet.
13
- return active.length === 0 || active.some(([key]) => key === peer);
14
- }
15
- }