experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(pubsub): Phase 2 — sender-frame cross-check (security hardening)

+827 -277
+127
backend/electron/tile-ipc-sender-check.ts
··· 1 + /** 2 + * Sender-frame cross-check helper (Phase 2 — docs/pubsub-state-machine.md). 3 + * 4 + * This module is intentionally small and Electron-free at runtime so it 5 + * can be unit-tested under `ELECTRON_RUN_AS_NODE=1`. The only Electron 6 + * touch point is a type import for `IpcMainEvent` / `IpcMainInvokeEvent`, 7 + * which the TypeScript compiler erases. 8 + * 9 + * Motivation: every `tile:*` IPC handler takes `payload.token` and 10 + * resolves it to a capability grant. Nothing today checks that 11 + * `event.sender` (the `WebContents` that delivered the frame) is the 12 + * same `WebContents` that owns the token. A tile with an XSS bug that 13 + * leaks its token could be impersonated by any other tile that learned 14 + * the leaked token — the capability gate would trust the token field 15 + * and let the forged frame through. 16 + * 17 + * Binding model: 18 + * - Eager binding: `createTileBrowserWindow` and 19 + * `registerTrustedBuiltinWindow` call `setTokenOwner(token, 20 + * win.webContents.id)` right after `new BrowserWindow()`. This 21 + * covers every regular tile + trustedBuiltin core renderer. 22 + * - Trust-on-first-use fallback: for renderers we can't bind at 23 + * construction time (e.g. `<webview>` guests minted inside 24 + * `will-attach-webview` where Electron doesn't surface the guest 25 + * wc id), the first IPC frame that carries the token binds 26 + * `event.sender.id` atomically. Legitimate renderers always send 27 + * their own first frame (tile:validate-token or tile:ready during 28 + * preload) before any other code can access the token, so TOFU is 29 + * race-free in practice. 30 + * 31 + * Rejection path: 32 + * - `missing-token`: the frame did not carry a token → drop + drift. 33 + * - `invalid-token`: no grant exists for the token → drop + drift. 34 + * - `sender-mismatch`: grant is bound to a different wc id → drop + drift. 35 + * 36 + * Phase 8 will factor the check into the central `tile-ipc-gate.ts` 37 + * chokepoint. For Phase 2 the check is duplicated inline at every 38 + * handler — consistent is simpler than exempt. 39 + */ 40 + 41 + import { getGrantForToken, getTokenOwner, setTokenOwner } from './tile-tokens.js'; 42 + import { publish, scopes, getSystemAddress } from './pubsub.js'; 43 + import type { CapabilityGrant } from './tile-manifest.js'; 44 + 45 + /** 46 + * Emit a `tile:drift` event describing an IPC frame that was dropped 47 + * by the sender-frame cross-check. Logged at warn level so it surfaces 48 + * in `/tmp/test-electron.log`. Rate-limiting and structured topic 49 + * ownership land in Phase 8; for now direct publish is fine. 50 + */ 51 + export function emitTileDrift(reason: string, ctx: Record<string, unknown>): void { 52 + const payload = { reason, ...ctx, ts: Date.now() }; 53 + try { 54 + publish(getSystemAddress(), scopes.GLOBAL, 'tile:drift', payload); 55 + } catch (err) { 56 + // Publishing drift must never throw out of the handler — swallow 57 + // and log so the original rejection path stays clean. 58 + console.error('[tile-ipc] Failed to publish tile:drift:', err); 59 + } 60 + console.warn(`[tile-ipc] tile:drift reason=${reason}`, ctx); 61 + } 62 + 63 + /** 64 + * Minimal shape of the IPC event we read from. Declared inline so this 65 + * module doesn't force a direct `import { type IpcMainEvent } from 66 + * 'electron'` — which would pull the module graph back into Electron 67 + * territory at type-level (harmless but noisy for tests that stub 68 + * `Electron.IpcMainEvent`). 69 + */ 70 + export interface SenderFrameEvent { 71 + sender: { 72 + id: number; 73 + getURL(): string; 74 + }; 75 + } 76 + 77 + /** 78 + * Verify that `event.sender` matches the WebContents bound to 79 + * `token`. Returns the grant on success; returns `null` on failure 80 + * and emits `tile:drift`. On trust-on-first-use (no owner bound 81 + * yet), atomically binds `event.sender.id` as the owner. 82 + * 83 + * Callers should substitute this for `getGrantForToken(token)` at 84 + * every `tile:*` handler entry point. A `null` return means the 85 + * handler MUST NOT perform any side-effect — drop silently. 86 + * 87 + * `opName` is the handler's channel name, used for drift telemetry 88 + * and debugging. Keep it stable across releases so CI can fail on 89 + * unexpected drift events. 90 + */ 91 + export function verifyTokenSender( 92 + event: SenderFrameEvent, 93 + token: string | undefined, 94 + opName: string, 95 + ): CapabilityGrant | null { 96 + if (!token) { 97 + emitTileDrift('missing-token', { op: opName, senderWcId: event.sender.id }); 98 + return null; 99 + } 100 + const grant = getGrantForToken(token); 101 + if (!grant) { 102 + emitTileDrift('invalid-token', { op: opName, senderWcId: event.sender.id }); 103 + return null; 104 + } 105 + const ownerWcId = getTokenOwner(token); 106 + const senderWcId = event.sender.id; 107 + if (ownerWcId === undefined) { 108 + // Trust-on-first-use: bind the first sender we see. Legitimate 109 + // renderers always send their own first frame before leaking the 110 + // token anywhere. 111 + setTokenOwner(token, senderWcId); 112 + return grant; 113 + } 114 + if (ownerWcId !== senderWcId) { 115 + let senderUrl = ''; 116 + try { senderUrl = event.sender.getURL(); } catch { /* wc destroyed */ } 117 + emitTileDrift('sender-mismatch', { 118 + op: opName, 119 + tokenTileId: grant.tileId, 120 + ownerWcId, 121 + senderWcId, 122 + senderUrl, 123 + }); 124 + return null; 125 + } 126 + return grant; 127 + }
+144
backend/electron/tile-ipc.test.ts
··· 1 + /** 2 + * Unit tests for tile-ipc.ts — Phase 2 sender-frame cross-check. 3 + * 4 + * The central invariant tested here: every `tile:*` IPC handler routes 5 + * through `verifyTokenSender()`. If the `event.sender` WebContents id 6 + * differs from the WebContents id bound to `payload.token`, the frame 7 + * is dropped and a `tile:drift` event is published. Without this check, 8 + * a tile with an XSS bug that leaked its token could be impersonated 9 + * by any other tile that learned the leaked token — the capability 10 + * gate would trust the token field and let the forged frame through. 11 + * 12 + * Runs under Electron's Node host (via `yarn test:unit`). `pubsub` 13 + * works under ELECTRON_RUN_AS_NODE because it has no Electron imports; 14 + * `verifyTokenSender` imports `electron` only for IpcMainEvent type 15 + * annotations, which are erased at runtime. 16 + */ 17 + 18 + import { describe, it, beforeEach, afterEach } from 'node:test'; 19 + import * as assert from 'node:assert'; 20 + 21 + import { 22 + generateToken, 23 + setTokenOwner, 24 + clearAllTokens, 25 + getTokenOwner, 26 + } from './tile-tokens.js'; 27 + import { resolveCapabilities } from './tile-manifest.js'; 28 + import { subscribe, unsubscribe, scopes } from './pubsub.js'; 29 + import { verifyTokenSender } from './tile-ipc-sender-check.js'; 30 + 31 + // ─── Helpers ───────────────────────────────────────────────────────── 32 + 33 + /** Fake IpcMainEvent.sender — only the id is load-bearing for the check. */ 34 + function fakeEvent(senderWcId: number): Electron.IpcMainEvent { 35 + return { 36 + sender: { 37 + id: senderWcId, 38 + getURL: () => `peek://fake-sender-${senderWcId}/`, 39 + }, 40 + } as unknown as Electron.IpcMainEvent; 41 + } 42 + 43 + function mintToken(tileId: string): string { 44 + const grant = resolveCapabilities(tileId, { pubsub: { scopes: ['global'] } }, true); 45 + return generateToken(tileId, 'background', grant); 46 + } 47 + 48 + /** 49 + * Subscribe to the `tile:drift` topic and collect every payload 50 + * delivered during the test. Callers inspect `events` after calling 51 + * `verifyTokenSender` to assert drift emission shape. 52 + */ 53 + function captureDriftEvents(): { events: Array<Record<string, unknown>>; stop: () => void } { 54 + const events: Array<Record<string, unknown>> = []; 55 + const source = `test-drift-listener-${Math.random().toString(36).slice(2, 8)}`; 56 + subscribe(source, scopes.GLOBAL, 'tile:drift', (msg) => { 57 + events.push(msg as Record<string, unknown>); 58 + }); 59 + return { 60 + events, 61 + stop: () => { unsubscribe(source, 'tile:drift'); }, 62 + }; 63 + } 64 + 65 + // ─── Tests ─────────────────────────────────────────────────────────── 66 + 67 + describe('verifyTokenSender (Phase 2: sender-frame cross-check)', () => { 68 + let drift: ReturnType<typeof captureDriftEvents>; 69 + 70 + beforeEach(() => { 71 + clearAllTokens(); 72 + drift = captureDriftEvents(); 73 + }); 74 + 75 + afterEach(() => { 76 + drift.stop(); 77 + clearAllTokens(); 78 + }); 79 + 80 + it('accepts a frame when sender matches the bound owner', () => { 81 + const token = mintToken('tile-a'); 82 + setTokenOwner(token, 100); 83 + 84 + const grant = verifyTokenSender(fakeEvent(100), token, 'tile:pubsub:publish'); 85 + assert.ok(grant, 'expected grant returned for legitimate sender'); 86 + assert.strictEqual(grant.tileId, 'tile-a'); 87 + assert.strictEqual(drift.events.length, 0, 'no drift event should have been emitted'); 88 + }); 89 + 90 + it('rejects a frame whose sender WebContents id does not match the token owner', () => { 91 + // Mint a token for tile A. Bind it to wc id 100 (tile A's own window). 92 + const token = mintToken('tile-a'); 93 + setTokenOwner(token, 100); 94 + 95 + // Simulate tile B (wc id 200) sending a tile:pubsub:publish frame 96 + // with tile A's leaked token. 97 + const grant = verifyTokenSender(fakeEvent(200), token, 'tile:pubsub:publish'); 98 + assert.strictEqual(grant, null, 'rejected frame must return null'); 99 + 100 + // One drift event published with reason=sender-mismatch and 101 + // structured context. 102 + assert.strictEqual(drift.events.length, 1, 'exactly one drift event should have been emitted'); 103 + const evt = drift.events[0]; 104 + assert.strictEqual(evt.reason, 'sender-mismatch'); 105 + assert.strictEqual(evt.op, 'tile:pubsub:publish'); 106 + assert.strictEqual(evt.tokenTileId, 'tile-a'); 107 + assert.strictEqual(evt.ownerWcId, 100); 108 + assert.strictEqual(evt.senderWcId, 200); 109 + assert.ok(typeof evt.ts === 'number', 'drift event carries timestamp'); 110 + }); 111 + 112 + it('rejects a frame with a token that does not exist', () => { 113 + const grant = verifyTokenSender(fakeEvent(200), 'nope-token', 'tile:pubsub:publish'); 114 + assert.strictEqual(grant, null); 115 + assert.strictEqual(drift.events.length, 1); 116 + assert.strictEqual(drift.events[0].reason, 'invalid-token'); 117 + }); 118 + 119 + it('rejects a frame with no token', () => { 120 + const grant = verifyTokenSender(fakeEvent(200), undefined, 'tile:pubsub:publish'); 121 + assert.strictEqual(grant, null); 122 + assert.strictEqual(drift.events.length, 1); 123 + assert.strictEqual(drift.events[0].reason, 'missing-token'); 124 + }); 125 + 126 + it('trust-on-first-use: first frame binds the owner when none was set', () => { 127 + // Mint a token without calling setTokenOwner (mimics webview 128 + // guests where we can't bind eagerly). 129 + const token = mintToken('webview-tile'); 130 + assert.strictEqual(getTokenOwner(token), undefined); 131 + 132 + const grant = verifyTokenSender(fakeEvent(300), token, 'tile:pubsub:publish'); 133 + assert.ok(grant, 'TOFU bind must return the grant on first use'); 134 + assert.strictEqual(getTokenOwner(token), 300, 'first sender becomes the bound owner'); 135 + assert.strictEqual(drift.events.length, 0); 136 + 137 + // Second frame from a DIFFERENT wc must now be rejected — the 138 + // TOFU bind made wc 300 the sole legitimate sender. 139 + const grant2 = verifyTokenSender(fakeEvent(400), token, 'tile:pubsub:publish'); 140 + assert.strictEqual(grant2, null); 141 + assert.strictEqual(drift.events.length, 1); 142 + assert.strictEqual(drift.events[0].reason, 'sender-mismatch'); 143 + }); 144 + });
+481 -277
backend/electron/tile-ipc.ts
··· 31 31 launchTile, 32 32 getTileManifest, 33 33 } from './tile-launcher.js'; 34 + import { verifyTokenSender } from './tile-ipc-sender-check.js'; 34 35 import { parseManifestFile } from './tile-manifest.js'; 35 36 import { publish, subscribe, unsubscribe, scopes, type Scope } from './pubsub.js'; 36 37 import { DEBUG, TILE_STRICT, isHeadless } from './config.js'; ··· 176 177 const grant = token ? getGrantForToken(token) : null; 177 178 handleViolation(grant, 'datastore', op, reason, token); 178 179 } 180 + 181 + // ─── Phase 2: Sender-frame cross-check (security hardening) ────────── 182 + // 183 + // Every `tile:*` IPC handler routes through `verifyTokenSender()` 184 + // instead of a bare `getGrantForToken(token)` lookup. The helper lives 185 + // in `./tile-ipc-sender-check.ts` — a small pure module with no 186 + // Electron imports so it can be unit-tested under 187 + // `ELECTRON_RUN_AS_NODE=1`. See that module's header for the full 188 + // threat model and binding rules. 179 189 180 190 /** 181 191 * Convert an Item to an Address-compatible shape for backward-compat shims. ··· 435 445 436 446 // ── Token Validation ── 437 447 438 - ipcMain.handle('tile:validate-token', (_event, args: { 448 + ipcMain.handle('tile:validate-token', (event, args: { 439 449 tileId: string; 440 450 tileEntry: string; 441 451 token: string; 442 452 }) => { 453 + // Sender-frame cross-check: same rules as every other tile:* 454 + // handler. This is also the TOFU bind point for renderers we 455 + // couldn't bind eagerly (webview guests). 456 + const grant = verifyTokenSender(event, args.token, 'tile:validate-token'); 457 + if (!grant) { 458 + return { valid: false }; 459 + } 443 460 const record = validateToken(args.token); 444 461 if (!record) { 445 462 return { valid: false }; ··· 464 481 tileId: string; 465 482 tileEntry: string; 466 483 }) => { 484 + // tile:ready does not carry a capability token (the renderer 485 + // signals readiness by tile id + entry id only — the launcher 486 + // already knows which window sent it via the tileWindows map). 487 + // Phase 8 will tighten this: lifecycle becomes private IPC, not 488 + // a pubsub-reachable surface, at which point the sender-frame 489 + // check applies there too. For Phase 2 there is no token on the 490 + // wire to cross-check against. 467 491 signalTileReady(args.tileId, args.tileEntry); 468 492 }); 469 493 470 494 // ── PubSub ── 471 495 472 - ipcMain.on('tile:pubsub:publish', (_event, args: { 496 + ipcMain.on('tile:pubsub:publish', (event, args: { 473 497 token: string; 474 498 source: string; 475 499 scope: number; 476 500 topic: string; 477 501 data: unknown; 478 502 }) => { 479 - const grant = getGrantForToken(args.token); 503 + const grant = verifyTokenSender(event, args.token, 'tile:pubsub:publish'); 480 504 if (!grant) { 481 - DEBUG && console.log('[tile-ipc] pubsub:publish rejected: invalid token'); 505 + DEBUG && console.log('[tile-ipc] pubsub:publish rejected: invalid token or sender mismatch'); 482 506 return; 483 507 } 484 508 ··· 540 564 publish(args.source, args.scope as Scope, args.topic, args.data); 541 565 }); 542 566 543 - ipcMain.on('tile:pubsub:subscribe', (_event, args: { 567 + ipcMain.on('tile:pubsub:subscribe', (event, args: { 544 568 token: string; 545 569 source: string; 546 570 topic: string; 547 571 }) => { 548 - const grant = getGrantForToken(args.token); 572 + const grant = verifyTokenSender(event, args.token, 'tile:pubsub:subscribe'); 549 573 if (!grant) return; 550 574 551 575 // Subscribe at GLOBAL scope (main process will filter via scopeCheck) ··· 554 578 }); 555 579 }); 556 580 557 - ipcMain.on('tile:pubsub:unsubscribe', (_event, args: { 581 + ipcMain.on('tile:pubsub:unsubscribe', (event, args: { 558 582 token: string; 559 583 source: string; 560 584 topic: string; 561 585 }) => { 562 - const grant = getGrantForToken(args.token); 586 + const grant = verifyTokenSender(event, args.token, 'tile:pubsub:unsubscribe'); 563 587 if (!grant) return; 564 588 565 589 unsubscribe(args.source, args.topic); ··· 567 591 568 592 // ── Commands ── 569 593 570 - ipcMain.on('tile:command:register', (_event, args: { 594 + ipcMain.on('tile:command:register', (event, args: { 571 595 token: string; 572 596 tileId: string; 573 597 name: string; 574 598 }) => { 575 - const grant = getGrantForToken(args.token); 576 - if (!grant || !hasCapability(grant, 'commands')) { 599 + const grant = verifyTokenSender(event, args.token, 'tile:command:register'); 600 + if (!grant) return; 601 + if (!hasCapability(grant, 'commands')) { 577 602 handleViolation(grant, 'commands', 'command:register', 'commands not granted', args.token); 578 603 return; 579 604 } ··· 602 627 ); 603 628 }); 604 629 605 - ipcMain.on('tile:command:result', (_event, args: { 630 + ipcMain.on('tile:command:result', (event, args: { 606 631 token: string; 607 632 name: string; 608 633 result?: unknown; 609 634 error?: string; 610 635 }) => { 611 - const grant = getGrantForToken(args.token); 636 + const grant = verifyTokenSender(event, args.token, 'tile:command:result'); 612 637 if (!grant) return; 613 638 614 639 // Publish result so cmd panel can resolve ··· 667 692 } 668 693 }; 669 694 670 - const grant = getGrantForToken(args.token); 695 + const grant = verifyTokenSender(ev, args.token, 'tile:shortcuts:register'); 696 + if (!grant) { 697 + replyError('invalid token or sender mismatch'); 698 + return; 699 + } 671 700 const check = checkShortcutAllowed(grant, args.shortcut); 672 701 if (!check.ok) { 673 702 handleViolation(grant, 'shortcuts', 'shortcuts:register', check.error, args.token); ··· 702 731 } 703 732 }); 704 733 705 - ipcMain.on('tile:shortcuts:unregister', (_ev, args: { 734 + ipcMain.on('tile:shortcuts:unregister', (ev, args: { 706 735 token: string; 707 736 source?: string; 708 737 shortcut: string; ··· 714 743 const isGlobal = args.global === true || opts.global === true; 715 744 const modeStr = args.mode ?? opts.mode; 716 745 717 - const grant = getGrantForToken(args.token); 746 + const grant = verifyTokenSender(ev, args.token, 'tile:shortcuts:unregister'); 747 + if (!grant) return; 718 748 const check = checkShortcutAllowed(grant, args.shortcut); 719 749 if (!check.ok) { 720 750 handleViolation(grant, 'shortcuts', 'shortcuts:unregister', check.error, args.token); ··· 755 785 key: string; 756 786 windowId?: number | null; 757 787 }) => { 758 - const grant = getGrantForToken(args.token); 788 + const grant = verifyTokenSender(ev, args.token, 'tile:context:get'); 789 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 759 790 const check = checkContextAllowed(grant, 'get', args.key); 760 791 if (!check.ok) { 761 792 handleViolation(grant, 'context', 'context:get', check.error, args.token); ··· 782 813 metadata?: Record<string, unknown>; 783 814 windowId?: number | null; 784 815 }) => { 785 - const grant = getGrantForToken(args.token); 816 + const grant = verifyTokenSender(ev, args.token, 'tile:context:set'); 817 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 786 818 const check = checkContextAllowed(grant, 'set', args.key); 787 819 if (!check.ok) { 788 820 handleViolation(grant, 'context', 'context:set', check.error, args.token); ··· 824 856 } 825 857 }); 826 858 827 - ipcMain.handle('tile:context:history', async (_ev, args: { 859 + ipcMain.handle('tile:context:history', async (ev, args: { 828 860 token: string; 829 861 key?: string; 830 862 windowId?: number | null; ··· 833 865 limit?: number; 834 866 order?: 'asc' | 'desc'; 835 867 }) => { 836 - const grant = getGrantForToken(args.token); 868 + const grant = verifyTokenSender(ev, args.token, 'tile:context:history'); 869 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 837 870 // history has a key-scoped gate only when the caller specified a 838 871 // key. Unscoped history queries still require the capability but 839 872 // not a specific allowlist entry — the datastore query itself ··· 865 898 } 866 899 }); 867 900 868 - ipcMain.handle('tile:context:snapshot', async (_ev, args: { 901 + ipcMain.handle('tile:context:snapshot', async (ev, args: { 869 902 token: string; 870 903 timestamp?: number; 871 904 keys?: string[]; 872 905 }) => { 873 - const grant = getGrantForToken(args.token); 906 + const grant = verifyTokenSender(ev, args.token, 'tile:context:snapshot'); 907 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 874 908 const check = checkContextAllowed(grant, 'snapshot'); 875 909 if (!check.ok) { 876 910 handleViolation(grant, 'context', 'context:snapshot', check.error, args.token); ··· 886 920 } 887 921 }); 888 922 889 - ipcMain.handle('tile:context:windows-with-value', async (_ev, args: { 923 + ipcMain.handle('tile:context:windows-with-value', async (ev, args: { 890 924 token: string; 891 925 key: string; 892 926 value: unknown; 893 927 }) => { 894 - const grant = getGrantForToken(args.token); 928 + const grant = verifyTokenSender(ev, args.token, 'tile:context:windows-with-value'); 929 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 895 930 const check = checkContextAllowed(grant, 'windowsWithValue', args.key); 896 931 if (!check.ok) { 897 932 handleViolation(grant, 'context', 'context:windows-with-value', check.error, args.token); ··· 906 941 } 907 942 }); 908 943 909 - ipcMain.handle('tile:context:windows-in-space', async (_ev, args: { 944 + ipcMain.handle('tile:context:windows-in-space', async (ev, args: { 910 945 token: string; 911 946 spaceId: string; 912 947 }) => { 913 - const grant = getGrantForToken(args.token); 948 + const grant = verifyTokenSender(ev, args.token, 'tile:context:windows-in-space'); 949 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 914 950 const check = checkContextAllowed(grant, 'windowsInSpace'); 915 951 if (!check.ok) { 916 952 handleViolation(grant, 'context', 'context:windows-in-space', check.error, args.token); ··· 943 979 filename?: string; 944 980 mimeType?: string; 945 981 }) => { 946 - const grant = getGrantForToken(args.token); 982 + const grant = verifyTokenSender(ev, args.token, 'tile:dialogs:save'); 983 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 947 984 const check = checkDialogAllowed(grant, 'save'); 948 985 if (!check.ok) { 949 986 handleViolation(grant, 'dialogs', 'dialogs:save', check.error, args.token); ··· 992 1029 ipcMain.handle('tile:dialogs:open', async (ev, args: { 993 1030 token: string; 994 1031 }) => { 995 - const grant = getGrantForToken(args.token); 1032 + const grant = verifyTokenSender(ev, args.token, 'tile:dialogs:open'); 1033 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 996 1034 const check = checkDialogAllowed(grant, 'open'); 997 1035 if (!check.ok) { 998 1036 handleViolation(grant, 'dialogs', 'dialogs:open', check.error, args.token); ··· 1087 1125 // - enable/disable/remove: `manage` (must be true). 1088 1126 // 4. If `sources` allowlist is set, entry/source must be in the list. 1089 1127 1090 - ipcMain.handle('tile:features:list', async (_event, args: { 1128 + ipcMain.handle('tile:features:list', async (event, args: { 1091 1129 token: string; 1092 1130 /** Optional filter by source type (rejected if not in sources allowlist). */ 1093 1131 sourceType?: string; 1094 1132 }) => { 1095 - const grant = getGrantForToken(args.token); 1133 + const grant = verifyTokenSender(event, args.token, 'tile:features:list'); 1134 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1096 1135 const check = checkFeaturesAllowed(grant, 'read'); 1097 1136 if (!check.ok) { 1098 1137 handleViolation(grant, 'features', 'features:list', check.error, args.token); ··· 1130 1169 return { entries }; 1131 1170 }); 1132 1171 1133 - ipcMain.handle('tile:features:get', async (_event, args: { 1172 + ipcMain.handle('tile:features:get', async (event, args: { 1134 1173 token: string; 1135 1174 id: string; 1136 1175 }) => { 1137 - const grant = getGrantForToken(args.token); 1176 + const grant = verifyTokenSender(event, args.token, 'tile:features:get'); 1177 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1138 1178 const check = checkFeaturesAllowed(grant, 'read'); 1139 1179 if (!check.ok) { 1140 1180 handleViolation(grant, 'features', 'features:get', check.error, args.token); ··· 1155 1195 return { entry }; 1156 1196 }); 1157 1197 1158 - ipcMain.handle('tile:features:history', async (_event, args: { 1198 + ipcMain.handle('tile:features:history', async (event, args: { 1159 1199 token: string; 1160 1200 featureId?: string; 1161 1201 limit?: number; 1162 1202 }) => { 1163 - const grant = getGrantForToken(args.token); 1203 + const grant = verifyTokenSender(event, args.token, 'tile:features:history'); 1204 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1164 1205 const check = checkFeaturesAllowed(grant, 'read'); 1165 1206 if (!check.ok) { 1166 1207 handleViolation(grant, 'features', 'features:history', check.error, args.token); ··· 1204 1245 } 1205 1246 }); 1206 1247 1207 - ipcMain.handle('tile:features:enable', async (_event, args: { 1248 + ipcMain.handle('tile:features:enable', async (event, args: { 1208 1249 token: string; 1209 1250 id: string; 1210 1251 tilePreloadPath?: string; 1211 1252 }) => { 1212 - const grant = getGrantForToken(args.token); 1253 + const grant = verifyTokenSender(event, args.token, 'tile:features:enable'); 1254 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1213 1255 const check = checkFeaturesAllowed(grant, 'manage'); 1214 1256 if (!check.ok) { 1215 1257 handleViolation(grant, 'features', 'features:enable', check.error, args.token); ··· 1254 1296 return { success: result }; 1255 1297 }); 1256 1298 1257 - ipcMain.handle('tile:features:disable', async (_event, args: { 1299 + ipcMain.handle('tile:features:disable', async (event, args: { 1258 1300 token: string; 1259 1301 id: string; 1260 1302 }) => { 1261 - const grant = getGrantForToken(args.token); 1303 + const grant = verifyTokenSender(event, args.token, 'tile:features:disable'); 1304 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1262 1305 const check = checkFeaturesAllowed(grant, 'manage'); 1263 1306 if (!check.ok) { 1264 1307 handleViolation(grant, 'features', 'features:disable', check.error, args.token); ··· 1297 1340 return { success: result }; 1298 1341 }); 1299 1342 1300 - ipcMain.handle('tile:features:remove', async (_event, args: { 1343 + ipcMain.handle('tile:features:remove', async (event, args: { 1301 1344 token: string; 1302 1345 id: string; 1303 1346 }) => { 1304 - const grant = getGrantForToken(args.token); 1347 + const grant = verifyTokenSender(event, args.token, 'tile:features:remove'); 1348 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1305 1349 const check = checkFeaturesAllowed(grant, 'manage'); 1306 1350 if (!check.ok) { 1307 1351 handleViolation(grant, 'features', 'features:remove', check.error, args.token); ··· 1360 1404 return { success: result }; 1361 1405 }); 1362 1406 1363 - ipcMain.handle('tile:features:install-resolve', async (_event, args: { 1407 + ipcMain.handle('tile:features:install-resolve', async (event, args: { 1364 1408 token: string; 1365 1409 atUri: string; 1366 1410 }) => { 1367 - const grant = getGrantForToken(args.token); 1411 + const grant = verifyTokenSender(event, args.token, 'tile:features:install-resolve'); 1412 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1368 1413 // Resolve is a pre-install read against the atproto source. Gate on 1369 1414 // `install` since it's only meaningful as a step toward installing. 1370 1415 // Source-type check: always 'atproto' for this endpoint. ··· 1386 1431 } 1387 1432 }); 1388 1433 1389 - ipcMain.handle('tile:features:preview-capabilities', async (_event, args: { 1434 + ipcMain.handle('tile:features:preview-capabilities', async (event, args: { 1390 1435 token: string; 1391 1436 manifestCapabilities: TileCapabilities; 1392 1437 featureId: string; 1393 1438 }) => { 1394 - const grant = getGrantForToken(args.token); 1439 + const grant = verifyTokenSender(event, args.token, 'tile:features:preview-capabilities'); 1440 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1395 1441 const check = checkFeaturesAllowed(grant, 'install'); 1396 1442 if (!check.ok) { 1397 1443 handleViolation(grant, 'features', 'features:preview-capabilities', check.error, args.token); ··· 1407 1453 } 1408 1454 }); 1409 1455 1410 - ipcMain.handle('tile:features:install', async (_event, args: { 1456 + ipcMain.handle('tile:features:install', async (event, args: { 1411 1457 token: string; 1412 1458 atUri: string; 1413 1459 userApproved: boolean; 1414 1460 }) => { 1415 - const grant = getGrantForToken(args.token); 1461 + const grant = verifyTokenSender(event, args.token, 'tile:features:install'); 1462 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1416 1463 const check = checkFeaturesAllowed(grant, 'install', 'atproto'); 1417 1464 if (!check.ok) { 1418 1465 handleViolation(grant, 'features', 'features:install', check.error, args.token); ··· 1469 1516 } 1470 1517 }); 1471 1518 1472 - ipcMain.handle('tile:features:browse-resolve-publisher', async (_event, args: { 1519 + ipcMain.handle('tile:features:browse-resolve-publisher', async (event, args: { 1473 1520 token: string; 1474 1521 query: string; 1475 1522 }) => { 1476 - const grant = getGrantForToken(args.token); 1523 + const grant = verifyTokenSender(event, args.token, 'tile:features:browse-resolve-publisher'); 1524 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1477 1525 const check = checkFeaturesAllowed(grant, 'install', 'atproto'); 1478 1526 if (!check.ok) { 1479 1527 handleViolation(grant, 'features', 'features:browse-resolve-publisher', check.error, args.token); ··· 1524 1572 // that do not take a specific source type, we gate on the per-action 1525 1573 // flag only. For update/apply we gate on `update` + source `atproto`. 1526 1574 1527 - ipcMain.handle('tile:features:update-check-all', async (_event, args: { 1575 + ipcMain.handle('tile:features:update-check-all', async (event, args: { 1528 1576 token: string; 1529 1577 }) => { 1530 - const grant = getGrantForToken(args.token); 1578 + const grant = verifyTokenSender(event, args.token, 'tile:features:update-check-all'); 1579 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1531 1580 const check = checkFeaturesAllowed(grant, 'update'); 1532 1581 if (!check.ok) { 1533 1582 handleViolation(grant, 'features', 'features:update-check-all', check.error, args.token); ··· 1635 1684 } 1636 1685 }); 1637 1686 1638 - ipcMain.handle('tile:features:update-apply', async (_event, args: { 1687 + ipcMain.handle('tile:features:update-apply', async (event, args: { 1639 1688 token: string; 1640 1689 id: string; 1641 1690 userApproved?: boolean; 1642 1691 }) => { 1643 - const grant = getGrantForToken(args.token); 1692 + const grant = verifyTokenSender(event, args.token, 'tile:features:update-apply'); 1693 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1644 1694 const check = checkFeaturesAllowed(grant, 'update', 'atproto'); 1645 1695 if (!check.ok) { 1646 1696 handleViolation(grant, 'features', 'features:update-apply', check.error, args.token); ··· 1730 1780 } 1731 1781 }); 1732 1782 1733 - ipcMain.handle('tile:features:update-set-policy', async (_event, args: { 1783 + ipcMain.handle('tile:features:update-set-policy', async (event, args: { 1734 1784 token: string; 1735 1785 id: string; 1736 1786 policy: 'auto' | 'notify' | 'pinned'; 1737 1787 }) => { 1738 - const grant = getGrantForToken(args.token); 1788 + const grant = verifyTokenSender(event, args.token, 'tile:features:update-set-policy'); 1789 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1739 1790 const check = checkFeaturesAllowed(grant, 'update'); 1740 1791 if (!check.ok) { 1741 1792 handleViolation(grant, 'features', 'features:update-set-policy', check.error, args.token); ··· 1766 1817 return { success: true, policy: entry.updatePolicy, pinnedVersion: entry.pinnedVersion }; 1767 1818 }); 1768 1819 1769 - ipcMain.handle('tile:features:dev-reload', async (_event, args: { 1820 + ipcMain.handle('tile:features:dev-reload', async (event, args: { 1770 1821 token: string; 1771 1822 featureId: string; 1772 1823 }) => { 1773 - const grant = getGrantForToken(args.token); 1824 + const grant = verifyTokenSender(event, args.token, 'tile:features:dev-reload'); 1825 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1774 1826 const check = checkFeaturesAllowed(grant, 'dev'); 1775 1827 if (!check.ok) { 1776 1828 handleViolation(grant, 'features', 'features:dev-reload', check.error, args.token); ··· 1832 1884 } 1833 1885 }); 1834 1886 1835 - ipcMain.handle('tile:features:dev-validate', async (_event, args: { 1887 + ipcMain.handle('tile:features:dev-validate', async (event, args: { 1836 1888 token: string; 1837 1889 featureId: string; 1838 1890 }) => { 1839 - const grant = getGrantForToken(args.token); 1891 + const grant = verifyTokenSender(event, args.token, 'tile:features:dev-validate'); 1892 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1840 1893 const check = checkFeaturesAllowed(grant, 'dev'); 1841 1894 if (!check.ok) { 1842 1895 handleViolation(grant, 'features', 'features:dev-validate', check.error, args.token); ··· 1909 1962 } 1910 1963 }); 1911 1964 1912 - ipcMain.handle('tile:features:dev-pick-directory', async (_event, args: { 1965 + ipcMain.handle('tile:features:dev-pick-directory', async (event, args: { 1913 1966 token: string; 1914 1967 }) => { 1915 - const grant = getGrantForToken(args.token); 1968 + const grant = verifyTokenSender(event, args.token, 'tile:features:dev-pick-directory'); 1969 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1916 1970 const check = checkFeaturesAllowed(grant, 'dev'); 1917 1971 if (!check.ok) { 1918 1972 handleViolation(grant, 'features', 'features:dev-pick-directory', check.error, args.token); ··· 1933 1987 } 1934 1988 }); 1935 1989 1936 - ipcMain.handle('tile:features:dev-create', async (_event, args: { 1990 + ipcMain.handle('tile:features:dev-create', async (event, args: { 1937 1991 token: string; 1938 1992 directoryPath: string; 1939 1993 featureId: string; 1940 1994 featureName: string; 1941 1995 }) => { 1942 - const grant = getGrantForToken(args.token); 1996 + const grant = verifyTokenSender(event, args.token, 'tile:features:dev-create'); 1997 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 1943 1998 const check = checkFeaturesAllowed(grant, 'dev'); 1944 1999 if (!check.ok) { 1945 2000 handleViolation(grant, 'features', 'features:dev-create', check.error, args.token); ··· 2054 2109 } 2055 2110 }); 2056 2111 2057 - ipcMain.handle('tile:features:publish-list-local', async (_event, args: { 2112 + ipcMain.handle('tile:features:publish-list-local', async (event, args: { 2058 2113 token: string; 2059 2114 }) => { 2060 - const grant = getGrantForToken(args.token); 2115 + const grant = verifyTokenSender(event, args.token, 'tile:features:publish-list-local'); 2116 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2061 2117 const check = checkFeaturesAllowed(grant, 'publish'); 2062 2118 if (!check.ok) { 2063 2119 handleViolation(grant, 'features', 'features:publish-list-local', check.error, args.token); ··· 2084 2140 }; 2085 2141 }); 2086 2142 2087 - ipcMain.handle('tile:features:publish-read-feature-files', async (_event, args: { 2143 + ipcMain.handle('tile:features:publish-read-feature-files', async (event, args: { 2088 2144 token: string; 2089 2145 featureId: string; 2090 2146 }) => { 2091 - const grant = getGrantForToken(args.token); 2147 + const grant = verifyTokenSender(event, args.token, 'tile:features:publish-read-feature-files'); 2148 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2092 2149 const check = checkFeaturesAllowed(grant, 'publish'); 2093 2150 if (!check.ok) { 2094 2151 handleViolation(grant, 'features', 'features:publish-read-feature-files', check.error, args.token); ··· 2147 2204 } 2148 2205 }); 2149 2206 2150 - ipcMain.handle('tile:features:devtools-open', async (_event, args: { 2207 + ipcMain.handle('tile:features:devtools-open', async (event, args: { 2151 2208 token: string; 2152 2209 featureId: string; 2153 2210 entryId?: string; 2154 2211 }) => { 2155 - const grant = getGrantForToken(args.token); 2212 + const grant = verifyTokenSender(event, args.token, 'tile:features:devtools-open'); 2213 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2156 2214 const check = checkFeaturesAllowed(grant, 'devtools'); 2157 2215 if (!check.ok) { 2158 2216 handleViolation(grant, 'features', 'features:devtools-open', check.error, args.token); ··· 2206 2264 } 2207 2265 }); 2208 2266 2209 - ipcMain.handle('tile:features:browse-list-by-publisher', async (_event, args: { 2267 + ipcMain.handle('tile:features:browse-list-by-publisher', async (event, args: { 2210 2268 token: string; 2211 2269 did: string; 2212 2270 pdsUrl: string; 2213 2271 cursor?: string; 2214 2272 }) => { 2215 - const grant = getGrantForToken(args.token); 2273 + const grant = verifyTokenSender(event, args.token, 'tile:features:browse-list-by-publisher'); 2274 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2216 2275 const check = checkFeaturesAllowed(grant, 'browse', 'atproto'); 2217 2276 if (!check.ok) { 2218 2277 handleViolation(grant, 'features', 'features:browse-list-by-publisher', check.error, args.token); ··· 2289 2348 // `args.id` — the feature/extension id whose schema to fetch. 2290 2349 // Returns `{ success: true, data: schema }` (data may be null when 2291 2350 // the feature declares no settingsSchema or the file is absent). 2292 - ipcMain.handle('tile:features:settings-schema', async (_event, args: { 2351 + ipcMain.handle('tile:features:settings-schema', async (event, args: { 2293 2352 token: string; 2294 2353 id: string; 2295 2354 }) => { 2296 - const grant = getGrantForToken(args.token); 2355 + const grant = verifyTokenSender(event, args.token, 'tile:features:settings-schema'); 2356 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2297 2357 const check = checkFeaturesAllowed(grant, 'read'); 2298 2358 if (!check.ok) { 2299 2359 handleViolation(grant, 'features', 'features:settings-schema', check.error, args.token); ··· 2337 2397 // that assume Electron is running. 2338 2398 2339 2399 ipcMain.handle('tile:izui:is-transient', async (ev, args: { token: string }) => { 2400 + const _phase2Grant = verifyTokenSender(ev, args?.token, 'tile:izui:is-transient'); 2401 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 2340 2402 const grant = getGrantForToken(args?.token); 2341 2403 const check = checkIzuiAllowed(grant); 2342 2404 if (!check.ok) { ··· 2359 2421 } 2360 2422 }); 2361 2423 2362 - ipcMain.handle('tile:izui:get-effective-mode', async (_event, args: { token: string }) => { 2424 + ipcMain.handle('tile:izui:get-effective-mode', async (event, args: { token: string }) => { 2425 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:izui:get-effective-mode'); 2426 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 2363 2427 const grant = getGrantForToken(args?.token); 2364 2428 const check = checkIzuiAllowed(grant); 2365 2429 if (!check.ok) { handleViolation(grant, 'izui', 'izui:get-effective-mode', check.error, args?.token); return { error: check.error }; } ··· 2372 2436 } 2373 2437 }); 2374 2438 2375 - ipcMain.handle('tile:izui:get-state', async (_event, args: { token: string }) => { 2439 + ipcMain.handle('tile:izui:get-state', async (event, args: { token: string }) => { 2440 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:izui:get-state'); 2441 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 2376 2442 const grant = getGrantForToken(args?.token); 2377 2443 const check = checkIzuiAllowed(grant); 2378 2444 if (!check.ok) { handleViolation(grant, 'izui', 'izui:get-state', check.error, args?.token); return { error: check.error }; } ··· 2385 2451 } 2386 2452 }); 2387 2453 2388 - ipcMain.handle('tile:izui:get-pre-overlay-focus-target', async (_event, args: { token: string }) => { 2454 + ipcMain.handle('tile:izui:get-pre-overlay-focus-target', async (event, args: { token: string }) => { 2455 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:izui:get-pre-overlay-focus-target'); 2456 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 2389 2457 const grant = getGrantForToken(args?.token); 2390 2458 const check = checkIzuiAllowed(grant); 2391 2459 if (!check.ok) { handleViolation(grant, 'izui', 'izui:get-pre-overlay-focus-target', check.error, args?.token); return { error: check.error }; } ··· 2399 2467 }); 2400 2468 2401 2469 ipcMain.handle('tile:izui:close-self', async (ev, args: { token: string }) => { 2470 + const _phase2Grant = verifyTokenSender(ev, args?.token, 'tile:izui:close-self'); 2471 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 2402 2472 const grant = getGrantForToken(args?.token); 2403 2473 const check = checkIzuiAllowed(grant); 2404 2474 if (!check.ok) { handleViolation(grant, 'izui', 'izui:close-self', check.error, args?.token); return { error: check.error }; } ··· 2427 2497 // gate because the op is effectively read-only and entities is the 2428 2498 // only consumer. 2429 2499 2430 - ipcMain.handle('tile:datastore:extract-page-content', async (_event, args: { 2500 + ipcMain.handle('tile:datastore:extract-page-content', async (event, args: { 2431 2501 token: string; 2432 2502 url: string; 2433 2503 }) => { 2434 - if (!args?.token) return { success: false, error: 'Invalid token' }; 2435 - const grant = getGrantForToken(args.token); 2436 - if (!grant) return { success: false, error: 'Invalid token' }; 2504 + const grant = verifyTokenSender(event, args?.token, 'tile:datastore:extract-page-content'); 2505 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2437 2506 // Trusted-builtin core renderers bypass the datastore gate. 2438 2507 if (!grant.trustedBuiltin && !grant.capabilities.datastore) { 2439 2508 return { success: false, error: 'Datastore capability not granted' }; ··· 2528 2597 url: string; 2529 2598 options?: Record<string, unknown>; 2530 2599 }) => { 2531 - const grant = getGrantForToken(args.token); 2600 + const grant = verifyTokenSender(event, args.token, 'tile:window:open'); 2601 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2532 2602 const check = checkWindowAllowed(grant, 'open', { url: args.url }); 2533 2603 if (!check.ok) { 2534 2604 handleViolation(grant, 'window', 'window:open', check.error, args.token); ··· 2569 2639 token: string; 2570 2640 id?: number; 2571 2641 }) => { 2572 - const grant = getGrantForToken(args.token); 2642 + const grant = verifyTokenSender(event, args.token, 'tile:window:close'); 2643 + if (!grant) return; 2573 2644 const senderWin = BrowserWindow.fromWebContents(event.sender); 2574 2645 // ownWindow: closing with no id OR id matching the sender's window 2575 2646 // is treated as the tile closing its own context. ··· 2594 2665 ipcMain.handle('tile:window:info', async (event, args: { 2595 2666 token: string; 2596 2667 }) => { 2597 - const grant = getGrantForToken(args.token); 2668 + const grant = verifyTokenSender(event, args.token, 'tile:window:info'); 2669 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2598 2670 const check = checkWindowAllowed(grant, 'info'); 2599 2671 if (!check.ok) { 2600 2672 handleViolation(grant, 'window', 'window:info', check.error, args.token); ··· 2615 2687 }; 2616 2688 }); 2617 2689 2618 - ipcMain.handle('tile:window:list', async (_event, args: { 2690 + ipcMain.handle('tile:window:list', async (event, args: { 2619 2691 token: string; 2620 2692 includeInternal?: boolean; 2621 2693 }) => { 2622 - const grant = getGrantForToken(args.token); 2694 + const grant = verifyTokenSender(event, args.token, 'tile:window:list'); 2695 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2623 2696 const check = checkWindowAllowed(grant, 'list'); 2624 2697 if (!check.ok) { 2625 2698 handleViolation(grant, 'window', 'window:list', check.error, args.token); ··· 2668 2741 // - `exists.exists` (spaces, groups, slides) 2669 2742 // - `exists.success && exists.data` (pagestream, helpdocs, windows feature) 2670 2743 // Keeping both keys lets us migrate callers without a renaming pass. 2671 - ipcMain.handle('tile:window:exists', async (_event, args: { 2744 + ipcMain.handle('tile:window:exists', async (event, args: { 2672 2745 token: string; 2673 2746 id: number; 2674 2747 }) => { 2675 - const grant = getGrantForToken(args.token); 2748 + const grant = verifyTokenSender(event, args.token, 'tile:window:exists'); 2749 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2676 2750 const check = checkWindowAllowed(grant, 'exists'); 2677 2751 if (!check.ok) { 2678 2752 handleViolation(grant, 'window', 'window:exists', check.error, args.token); ··· 2697 2771 // ── tile:window:show ────────────────────────────────────────────── 2698 2772 // 2699 2773 // Show a hidden window. Gated by `window.manage`. 2700 - ipcMain.handle('tile:window:show', async (_event, args: { 2774 + ipcMain.handle('tile:window:show', async (event, args: { 2701 2775 token: string; 2702 2776 id: number; 2703 2777 }) => { 2704 - const grant = getGrantForToken(args.token); 2778 + const grant = verifyTokenSender(event, args.token, 'tile:window:show'); 2779 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2705 2780 const check = checkWindowAllowed(grant, 'show'); 2706 2781 if (!check.ok) { 2707 2782 handleViolation(grant, 'window', 'window:show', check.error, args.token); ··· 2727 2802 // ── tile:window:hide ────────────────────────────────────────────── 2728 2803 // 2729 2804 // Hide a visible window. Gated by `window.manage`. 2730 - ipcMain.handle('tile:window:hide', async (_event, args: { 2805 + ipcMain.handle('tile:window:hide', async (event, args: { 2731 2806 token: string; 2732 2807 id: number; 2733 2808 }) => { 2734 - const grant = getGrantForToken(args.token); 2809 + const grant = verifyTokenSender(event, args.token, 'tile:window:hide'); 2810 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2735 2811 const check = checkWindowAllowed(grant, 'hide'); 2736 2812 if (!check.ok) { 2737 2813 handleViolation(grant, 'window', 'window:hide', check.error, args.token); ··· 2760 2836 // the token identifies the calling tile, and the tile can only affect its 2761 2837 // own window. This is the primary mechanism for tiles that start hidden at 2762 2838 // boot (resident: true) and reveal themselves on command invocation. 2763 - ipcMain.handle('tile:window:show-self', async (_event, args: { 2839 + ipcMain.handle('tile:window:show-self', async (event, args: { 2764 2840 token: string; 2765 2841 }) => { 2842 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:window:show-self'); 2843 + if (!_phase2Grant) return { success: false, error: 'invalid token or sender mismatch' }; 2766 2844 const record = validateToken(args.token); 2767 2845 if (!record) return { success: false, error: 'Invalid token' }; 2768 2846 ··· 2793 2871 // own window. A hidden tile preserves all state (DOM, JS heap, registered 2794 2872 // commands) — it is not unloaded. Use for "dismiss" UX where re-opening 2795 2873 // should be instant without the cost of re-initializing a new window. 2796 - ipcMain.handle('tile:window:hide-self', async (_event, args: { 2874 + ipcMain.handle('tile:window:hide-self', async (event, args: { 2797 2875 token: string; 2798 2876 }) => { 2877 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:window:hide-self'); 2878 + if (!_phase2Grant) return { success: false, error: 'invalid token or sender mismatch' }; 2799 2879 const record = validateToken(args.token); 2800 2880 if (!record) return { success: false, error: 'Invalid token' }; 2801 2881 ··· 2824 2904 ignore: boolean; 2825 2905 options?: { forward?: boolean }; 2826 2906 }) => { 2827 - const grant = getGrantForToken(args.token); 2907 + const grant = verifyTokenSender(event, args.token, 'tile:window:set-ignore-mouse'); 2908 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2828 2909 const check = checkWindowAllowed(grant, 'set-ignore-mouse'); 2829 2910 if (!check.ok) { 2830 2911 handleViolation(grant, 'window', 'window:set-ignore-mouse', check.error, args.token); ··· 2854 2935 token: string; 2855 2936 id?: number; 2856 2937 }) => { 2857 - const grant = getGrantForToken(args.token); 2938 + const grant = verifyTokenSender(event, args.token, 'tile:window:center'); 2939 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2858 2940 const check = checkWindowAllowed(grant, 'center'); 2859 2941 if (!check.ok) { 2860 2942 handleViolation(grant, 'window', 'window:center', check.error, args.token); ··· 2888 2970 // 2889 2971 // Center every visible window on its nearest display. Gated by 2890 2972 // `window.manage`. 2891 - ipcMain.handle('tile:window:center-all', async (_event, args: { 2973 + ipcMain.handle('tile:window:center-all', async (event, args: { 2892 2974 token: string; 2893 2975 }) => { 2894 - const grant = getGrantForToken(args.token); 2976 + const grant = verifyTokenSender(event, args.token, 'tile:window:center-all'); 2977 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2895 2978 const check = checkWindowAllowed(grant, 'center-all'); 2896 2979 if (!check.ok) { 2897 2980 handleViolation(grant, 'window', 'window:center-all', check.error, args.token); ··· 2928 3011 token: string; 2929 3012 id?: number; 2930 3013 }) => { 2931 - const grant = getGrantForToken(args.token); 3014 + const grant = verifyTokenSender(event, args.token, 'tile:window:maximize'); 3015 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2932 3016 const check = checkWindowAllowed(grant, 'maximize'); 2933 3017 if (!check.ok) { 2934 3018 handleViolation(grant, 'window', 'window:maximize', check.error, args.token); ··· 2961 3045 token: string; 2962 3046 id?: number; 2963 3047 }) => { 2964 - const grant = getGrantForToken(args.token); 3048 + const grant = verifyTokenSender(event, args.token, 'tile:window:fullscreen'); 3049 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 2965 3050 const check = checkWindowAllowed(grant, 'fullscreen'); 2966 3051 if (!check.ok) { 2967 3052 handleViolation(grant, 'window', 'window:fullscreen', check.error, args.token); ··· 2993 3078 // Rather than importing the internal variable, we delegate to the 2994 3079 // un-gated legacy `get-focused-visible-window-id` channel. The strict 2995 3080 // surface just adds the capability gate. 2996 - ipcMain.handle('tile:window:get-focused-visible-id', async (_event, args: { 3081 + ipcMain.handle('tile:window:get-focused-visible-id', async (event, args: { 2997 3082 token: string; 2998 3083 }) => { 2999 - const grant = getGrantForToken(args.token); 3084 + const grant = verifyTokenSender(event, args.token, 'tile:window:get-focused-visible-id'); 3085 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3000 3086 const check = checkWindowAllowed(grant, 'get-focused-visible-id'); 3001 3087 if (!check.ok) { 3002 3088 handleViolation(grant, 'window', 'window:get-focused-visible-id', check.error, args.token); ··· 3045 3131 token: string; 3046 3132 targetWindowId: number; 3047 3133 }) => { 3048 - const grant = getGrantForToken(args.token); 3134 + const grant = verifyTokenSender(event, args.token, 'tile:window:set-overlay-focus-target'); 3135 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3049 3136 const check = checkWindowAllowed(grant, 'set-overlay-focus-target'); 3050 3137 if (!check.ok) { 3051 3138 handleViolation(grant, 'window', 'window:set-overlay-focus-target', check.error, args.token); ··· 3091 3178 visible: boolean; 3092 3179 options?: { visibleOnFullScreen?: boolean; skipTransformProcessType?: boolean }; 3093 3180 }) => { 3094 - const grant = getGrantForToken(args.token); 3181 + const grant = verifyTokenSender(event, args.token, 'tile:window:set-visible-on-all-workspaces'); 3182 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3095 3183 const check = checkWindowAllowed(grant, 'set-visible-on-all-workspaces'); 3096 3184 if (!check.ok) { 3097 3185 handleViolation(grant, 'window', 'window:set-visible-on-all-workspaces', check.error, args.token); ··· 3123 3211 message: unknown; 3124 3212 origin?: string; 3125 3213 }) => { 3126 - const grant = getGrantForToken(args.token); 3214 + const grant = verifyTokenSender(event, args.token, 'tile:window:opener-postmessage'); 3215 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3127 3216 const check = checkWindowAllowed(grant, 'opener-postmessage'); 3128 3217 if (!check.ok) { 3129 3218 handleViolation(grant, 'window', 'window:opener-postmessage', check.error, args.token); ··· 3164 3253 ipcMain.handle('tile:window:opener-close', async (event, args: { 3165 3254 token: string; 3166 3255 }) => { 3167 - const grant = getGrantForToken(args.token); 3256 + const grant = verifyTokenSender(event, args.token, 'tile:window:opener-close'); 3257 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3168 3258 const check = checkWindowAllowed(grant, 'opener-close'); 3169 3259 if (!check.ok) { 3170 3260 handleViolation(grant, 'window', 'window:opener-close', check.error, args.token); ··· 3202 3292 ipcMain.handle('tile:window:opener-focus', async (event, args: { 3203 3293 token: string; 3204 3294 }) => { 3205 - const grant = getGrantForToken(args.token); 3295 + const grant = verifyTokenSender(event, args.token, 'tile:window:opener-focus'); 3296 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3206 3297 const check = checkWindowAllowed(grant, 'opener-focus'); 3207 3298 if (!check.ok) { 3208 3299 handleViolation(grant, 'window', 'window:opener-focus', check.error, args.token); ··· 3234 3325 // to know its own window id (mirrors the un-gated legacy `get-window-id` 3235 3326 // channel). Token validity is still enforced so un-initialised callers 3236 3327 // cannot probe the id. 3237 - ipcMain.handle('tile:window:get-id', (_event, args: { 3328 + ipcMain.handle('tile:window:get-id', (event, args: { 3238 3329 token: string; 3239 3330 }) => { 3240 - const grant = getGrantForToken(args.token); 3331 + const grant = verifyTokenSender(event, args.token, 'tile:window:get-id'); 3241 3332 if (!grant) return { success: false, error: 'Invalid token' }; 3242 3333 3243 - const win = BrowserWindow.fromWebContents(_event.sender); 3334 + const win = BrowserWindow.fromWebContents(event.sender); 3244 3335 return win ? { success: true, id: win.id } : { success: false, error: 'Window not found' }; 3245 3336 }); 3246 3337 ··· 3250 3341 // window. No `window` capability required — a tile always has an inherent 3251 3342 // right to know its own bounds. Token validity is still enforced so 3252 3343 // un-initialised callers cannot probe bounds. 3253 - ipcMain.handle('tile:window:get-bounds', (_event, args: { 3344 + ipcMain.handle('tile:window:get-bounds', (event, args: { 3254 3345 token: string; 3255 3346 }) => { 3256 - const grant = getGrantForToken(args.token); 3347 + const grant = verifyTokenSender(event, args.token, 'tile:window:get-bounds'); 3257 3348 if (!grant) return { success: false, error: 'Invalid token' }; 3258 3349 3259 - const win = BrowserWindow.fromWebContents(_event.sender); 3350 + const win = BrowserWindow.fromWebContents(event.sender); 3260 3351 if (!win || win.isDestroyed()) { 3261 3352 return { success: false, error: 'Window not found' }; 3262 3353 } ··· 3269 3360 // Return display info (workArea, bounds, scaleFactor) for the display 3270 3361 // that contains the calling window. No `window` capability required — 3271 3362 // a tile can always query the display it lives on. 3272 - ipcMain.handle('tile:window:get-display-info', (_event, args: { 3363 + ipcMain.handle('tile:window:get-display-info', (event, args: { 3273 3364 token: string; 3274 3365 }) => { 3275 - const grant = getGrantForToken(args.token); 3366 + const grant = verifyTokenSender(event, args.token, 'tile:window:get-display-info'); 3276 3367 if (!grant) return { success: false, error: 'Invalid token' }; 3277 3368 3278 - const win = BrowserWindow.fromWebContents(_event.sender); 3369 + const win = BrowserWindow.fromWebContents(event.sender); 3279 3370 if (!win || win.isDestroyed()) { 3280 3371 return { success: false, error: 'Window not found' }; 3281 3372 } ··· 3294 3385 // Set the position and/or size of the calling window. No `window` 3295 3386 // capability required — a tile can always reposition its own window. 3296 3387 // (Cannot target other windows through this channel.) 3297 - ipcMain.handle('tile:window:set-bounds', (_event, args: { 3388 + ipcMain.handle('tile:window:set-bounds', (event, args: { 3298 3389 token: string; 3299 3390 x?: number; 3300 3391 y?: number; 3301 3392 width?: number; 3302 3393 height?: number; 3303 3394 }) => { 3304 - const grant = getGrantForToken(args.token); 3395 + const grant = verifyTokenSender(event, args.token, 'tile:window:set-bounds'); 3305 3396 if (!grant) return { success: false, error: 'Invalid token' }; 3306 3397 3307 - const win = BrowserWindow.fromWebContents(_event.sender); 3398 + const win = BrowserWindow.fromWebContents(event.sender); 3308 3399 if (!win || win.isDestroyed()) { 3309 3400 return { success: false, error: 'Window not found' }; 3310 3401 } ··· 3324 3415 // based on content (cmd panel shows results list, HUD widgets adjust to 3325 3416 // content height, etc.). No capability gate — a tile can always resize 3326 3417 // its own window (cannot target other windows through this channel). 3327 - ipcMain.handle('tile:window:resize', (_event, args: { 3418 + ipcMain.handle('tile:window:resize', (event, args: { 3328 3419 token: string; 3329 3420 width: number; 3330 3421 height: number; 3331 3422 }) => { 3332 - const grant = getGrantForToken(args.token); 3423 + const grant = verifyTokenSender(event, args.token, 'tile:window:resize'); 3333 3424 if (!grant) return { success: false, error: 'Invalid token' }; 3334 3425 3335 - const win = BrowserWindow.fromWebContents(_event.sender); 3426 + const win = BrowserWindow.fromWebContents(event.sender); 3336 3427 if (!win || win.isDestroyed()) { 3337 3428 return { success: false, error: 'Window not found' }; 3338 3429 } ··· 3350 3441 // Read-only display metadata. No capability gate — this is equivalent 3351 3442 // to reading a bundled theme token and tiles use it for initial 3352 3443 // window placement. 3353 - ipcMain.handle('tile:screen:get-primary-display', async (_event, args: { 3444 + ipcMain.handle('tile:screen:get-primary-display', async (event, args: { 3354 3445 token: string; 3355 3446 }) => { 3356 3447 // Token presence still validated so we don't serve callers who 3357 3448 // never invoked initialize(), but no capability check is performed. 3358 - const grant = getGrantForToken(args.token); 3449 + const grant = verifyTokenSender(event, args.token, 'tile:screen:get-primary-display'); 3359 3450 if (!grant) { 3360 3451 return { success: false, error: 'Invalid token' }; 3361 3452 } ··· 3392 3483 ipcMain.on('tile:escape:on-escape', (event, args: { 3393 3484 token: string; 3394 3485 }) => { 3395 - const grant = getGrantForToken(args.token); 3486 + const grant = verifyTokenSender(event, args.token, 'tile:escape:on-escape'); 3487 + if (!grant) return; 3396 3488 const check = checkEscapeAllowed(grant); 3397 3489 if (!check.ok) { 3398 3490 handleViolation(grant, 'escape', 'escape:on-escape', check.error, args.token); ··· 3412 3504 // Counterpart to `tile:command:register`. Gated by the same `commands` 3413 3505 // capability. Publishes `cmd:unregister` on GLOBAL scope so the cmd 3414 3506 // panel drops the command from its registry. 3415 - ipcMain.on('tile:commands:unregister', (_event, args: { 3507 + ipcMain.on('tile:commands:unregister', (event, args: { 3416 3508 token: string; 3417 3509 name: string; 3418 3510 }) => { 3419 - const grant = getGrantForToken(args.token); 3511 + const grant = verifyTokenSender(event, args.token, 'tile:commands:unregister'); 3512 + if (!grant) return; 3420 3513 if (!grant || !hasCapability(grant, 'commands')) { 3421 3514 handleViolation(grant, 'commands', 'commands:unregister', 'commands not granted', args.token); 3422 3515 return; ··· 3438 3531 // Persist the per-space window-layout snapshot. Used by 3439 3532 // features/spaces and features/groups. Gated by the new `session` 3440 3533 // capability. 3441 - ipcMain.handle('tile:session:save-space-workspaces', async (_event, args: { 3534 + ipcMain.handle('tile:session:save-space-workspaces', async (event, args: { 3442 3535 token: string; 3443 3536 }) => { 3444 - const grant = getGrantForToken(args.token); 3537 + const grant = verifyTokenSender(event, args.token, 'tile:session:save-space-workspaces'); 3538 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3445 3539 const check = checkSessionAllowed(grant, 'save-space-workspaces'); 3446 3540 if (!check.ok) { 3447 3541 handleViolation(grant, 'session', 'session:save-space-workspaces', check.error, args.token); ··· 3460 3554 3461 3555 // ── Network Fetch ── 3462 3556 3463 - ipcMain.handle('tile:network:fetch', async (_event, args: { 3557 + ipcMain.handle('tile:network:fetch', async (event, args: { 3464 3558 token: string; 3465 3559 url: string; 3466 3560 options?: Record<string, unknown>; 3467 3561 }) => { 3468 - const grant = getGrantForToken(args.token); 3562 + const grant = verifyTokenSender(event, args.token, 'tile:network:fetch'); 3563 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3469 3564 if (!grant || !hasCapability(grant, 'network')) { 3470 3565 handleViolation(grant, 'network', 'network:fetch', 'network capability not granted', args.token); 3471 3566 return { error: 'Network capability not granted' }; ··· 3513 3608 3514 3609 // ── Filesystem ── 3515 3610 3516 - ipcMain.handle('tile:filesystem:read', async (_event, args: { 3611 + ipcMain.handle('tile:filesystem:read', async (event, args: { 3517 3612 token: string; 3518 3613 path: string; 3519 3614 }) => { 3520 - const grant = getGrantForToken(args.token); 3615 + const grant = verifyTokenSender(event, args.token, 'tile:filesystem:read'); 3616 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3521 3617 if (!grant || !hasCapability(grant, 'filesystem')) { 3522 3618 return { error: 'Filesystem capability not granted' }; 3523 3619 } ··· 3534 3630 } 3535 3631 }); 3536 3632 3537 - ipcMain.handle('tile:filesystem:write', async (_event, args: { 3633 + ipcMain.handle('tile:filesystem:write', async (event, args: { 3538 3634 token: string; 3539 3635 path: string; 3540 3636 content: string; 3541 3637 }) => { 3542 - const grant = getGrantForToken(args.token); 3638 + const grant = verifyTokenSender(event, args.token, 'tile:filesystem:write'); 3639 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3543 3640 if (!grant || !hasCapability(grant, 'filesystem')) { 3544 3641 return { error: 'Filesystem capability not granted' }; 3545 3642 } ··· 3558 3655 3559 3656 // ── Theme ── 3560 3657 3561 - ipcMain.handle('tile:theme:info', async (_event, args: { 3658 + ipcMain.handle('tile:theme:info', async (event, args: { 3562 3659 token: string; 3563 3660 }) => { 3564 - const grant = getGrantForToken(args.token); 3661 + const grant = verifyTokenSender(event, args.token, 'tile:theme:info'); 3662 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3565 3663 if (!grant || !hasCapability(grant, 'theme')) { 3566 3664 return { error: 'Theme capability not granted' }; 3567 3665 } ··· 3582 3680 // Strict counterpart of the legacy `theme:get` channel. 3583 3681 // Returns current theme id, color scheme preference and effective scheme. 3584 3682 // Requires trustedBuiltin — non-core tiles use tile:theme:info instead. 3585 - ipcMain.handle('tile:theme:get', (_event, args: { 3683 + ipcMain.handle('tile:theme:get', (event, args: { 3586 3684 token: string; 3587 3685 }) => { 3588 3686 if (!args?.token) return { success: false, error: 'Invalid token' }; 3589 - const grant = getGrantForToken(args.token); 3687 + const grant = verifyTokenSender(event, args.token, 'tile:theme:get'); 3590 3688 if (!grant) return { success: false, error: 'Invalid token' }; 3591 3689 if (!grant.trustedBuiltin) { 3592 3690 handleViolation(grant, 'theme', 'tile:theme:get', 'trustedBuiltin required', args.token); ··· 3609 3707 // Strict counterpart of the legacy `theme:setColorScheme` channel. 3610 3708 // Sets the global color scheme preference (system/light/dark). 3611 3709 // Requires trustedBuiltin. 3612 - ipcMain.handle('tile:theme:setColorScheme', (_event, args: { 3710 + ipcMain.handle('tile:theme:setColorScheme', (event, args: { 3613 3711 token: string; 3614 3712 colorScheme: string; 3615 3713 }) => { 3616 3714 if (!args?.token) return { success: false, error: 'Invalid token' }; 3617 - const grant = getGrantForToken(args.token); 3715 + const grant = verifyTokenSender(event, args.token, 'tile:theme:setColorScheme'); 3618 3716 if (!grant) return { success: false, error: 'Invalid token' }; 3619 3717 if (!grant.trustedBuiltin) { 3620 3718 handleViolation(grant, 'theme', 'tile:theme:setColorScheme', 'trustedBuiltin required', args.token); ··· 3655 3753 // `background.html` URLs). This last branch is the headless-test 3656 3754 // fallback — in headless mode all windows have `show:false` so 3657 3755 // `isVisible()` is false and no focus events fire. 3658 - ipcMain.handle('tile:theme:setWindowColorScheme', (_event, args: { 3756 + ipcMain.handle('tile:theme:setWindowColorScheme', (event, args: { 3659 3757 token: string; 3660 3758 windowId?: number | null; 3661 3759 colorScheme: string; 3662 3760 }) => { 3663 3761 if (!args?.token) return { success: false, error: 'Invalid token' }; 3664 - const grant = getGrantForToken(args.token); 3762 + const grant = verifyTokenSender(event, args.token, 'tile:theme:setWindowColorScheme'); 3665 3763 if (!grant) return { success: false, error: 'Invalid token' }; 3666 3764 if (!grant.trustedBuiltin) { 3667 3765 handleViolation(grant, 'theme', 'tile:theme:setWindowColorScheme', 'trustedBuiltin required', args.token); ··· 3742 3840 // Strict counterpart of the legacy `theme:setTheme` channel. 3743 3841 // Sets the active theme by id and broadcasts to all windows. 3744 3842 // Requires trustedBuiltin. 3745 - ipcMain.handle('tile:theme:setTheme', (_event, args: { 3843 + ipcMain.handle('tile:theme:setTheme', (event, args: { 3746 3844 token: string; 3747 3845 themeId: string; 3748 3846 }) => { 3749 3847 if (!args?.token) return { success: false, error: 'Invalid token' }; 3750 - const grant = getGrantForToken(args.token); 3848 + const grant = verifyTokenSender(event, args.token, 'tile:theme:setTheme'); 3751 3849 if (!grant) return { success: false, error: 'Invalid token' }; 3752 3850 if (!grant.trustedBuiltin) { 3753 3851 handleViolation(grant, 'theme', 'tile:theme:setTheme', 'trustedBuiltin required', args.token); ··· 3772 3870 // Strict counterpart of the legacy `theme:list` channel. 3773 3871 // Returns metadata for all registered (builtin) themes. 3774 3872 // Requires trustedBuiltin. 3775 - ipcMain.handle('tile:theme:list', (_event, args: { 3873 + ipcMain.handle('tile:theme:list', (event, args: { 3776 3874 token: string; 3777 3875 }) => { 3778 3876 if (!args?.token) return { success: false, error: 'Invalid token' }; 3779 - const grant = getGrantForToken(args.token); 3877 + const grant = verifyTokenSender(event, args.token, 'tile:theme:list'); 3780 3878 if (!grant) return { success: false, error: 'Invalid token' }; 3781 3879 if (!grant.trustedBuiltin) { 3782 3880 handleViolation(grant, 'theme', 'tile:theme:list', 'trustedBuiltin required', args.token); ··· 3816 3914 // Strict counterpart of the legacy `theme:getAll` channel. 3817 3915 // Returns all themes (builtin + external from DB). 3818 3916 // Requires trustedBuiltin. 3819 - ipcMain.handle('tile:theme:getAll', async (_event, args: { 3917 + ipcMain.handle('tile:theme:getAll', async (event, args: { 3820 3918 token: string; 3821 3919 }) => { 3822 3920 if (!args?.token) return { success: false, error: 'Invalid token' }; 3823 - const grant = getGrantForToken(args.token); 3921 + const grant = verifyTokenSender(event, args.token, 'tile:theme:getAll'); 3824 3922 if (!grant) return { success: false, error: 'Invalid token' }; 3825 3923 if (!grant.trustedBuiltin) { 3826 3924 handleViolation(grant, 'theme', 'tile:theme:getAll', 'trustedBuiltin required', args.token); ··· 3908 4006 3909 4007 // ── Settings ── 3910 4008 3911 - ipcMain.handle('tile:settings:get', async (_event, args: { 4009 + ipcMain.handle('tile:settings:get', async (event, args: { 3912 4010 token: string; 3913 4011 key: string; 3914 4012 }) => { 3915 - const grant = getGrantForToken(args.token); 4013 + const grant = verifyTokenSender(event, args.token, 'tile:settings:get'); 4014 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3916 4015 if (!grant || !hasCapability(grant, 'settings')) { 3917 4016 return { error: 'Settings capability not granted' }; 3918 4017 } ··· 3942 4041 } 3943 4042 }); 3944 4043 3945 - ipcMain.handle('tile:settings:set', async (_event, args: { 4044 + ipcMain.handle('tile:settings:set', async (event, args: { 3946 4045 token: string; 3947 4046 key: string; 3948 4047 value: unknown; 3949 4048 }) => { 3950 - const grant = getGrantForToken(args.token); 4049 + const grant = verifyTokenSender(event, args.token, 'tile:settings:set'); 4050 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3951 4051 if (!grant || !hasCapability(grant, 'settings')) { 3952 4052 return { error: 'Settings capability not granted' }; 3953 4053 } ··· 3974 4074 // (or the top-level `settingsForeign` fallback). Mirrors the legacy 3975 4075 // `feature-settings-get-key` handler but with capability enforcement. 3976 4076 // See `tile-settings-foreign-enforcement.ts` for the pure check. 3977 - ipcMain.handle('tile:settings:get-foreign', async (_event, args: { 4077 + ipcMain.handle('tile:settings:get-foreign', async (event, args: { 3978 4078 token: string; 3979 4079 foreignExtId: string; 3980 4080 key: string; 3981 4081 }) => { 3982 - const grant = getGrantForToken(args.token); 4082 + const grant = verifyTokenSender(event, args.token, 'tile:settings:get-foreign'); 4083 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 3983 4084 const check = checkSettingsForeignAllowed(grant, args?.foreignExtId); 3984 4085 if (!check.ok) { 3985 4086 handleViolation(grant, 'settings', 'settings:get-foreign', check.error, args.token); ··· 4021 4122 cancel: () => void; 4022 4123 }>(); 4023 4124 4024 - ipcMain.handle('tile:oauth:start-loopback', async (_event, args: { 4125 + ipcMain.handle('tile:oauth:start-loopback', async (event, args: { 4025 4126 token: string; 4026 4127 provider?: string; 4027 4128 callbackPath?: string; 4028 4129 timeoutMs?: number; 4029 4130 }) => { 4030 - const grant = getGrantForToken(args.token); 4131 + const grant = verifyTokenSender(event, args.token, 'tile:oauth:start-loopback'); 4132 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 4031 4133 const check = checkOAuthAllowed(grant, 'start-loopback', args?.provider); 4032 4134 if (!check.ok) { 4033 4135 handleViolation(grant, 'oauth', 'oauth:start-loopback', check.error, args.token); ··· 4049 4151 } 4050 4152 }); 4051 4153 4052 - ipcMain.handle('tile:oauth:await-callback', async (_event, args: { 4154 + ipcMain.handle('tile:oauth:await-callback', async (event, args: { 4053 4155 token: string; 4054 4156 port: number; 4055 4157 }) => { 4056 - const grant = getGrantForToken(args.token); 4158 + const grant = verifyTokenSender(event, args.token, 'tile:oauth:await-callback'); 4159 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 4057 4160 const check = checkOAuthAllowed(grant, 'await-callback'); 4058 4161 if (!check.ok) { 4059 4162 handleViolation(grant, 'oauth', 'oauth:await-callback', check.error, args.token); ··· 4082 4185 // sync.ts — same behaviour as the legacy `sync-full` channel in 4083 4186 // ipc.ts, but capability-gated for the strict tile surface. See 4084 4187 // `tile-sync-enforcement.ts` for the pure check. 4085 - ipcMain.handle('tile:sync:sync-all', async (_event, args: { 4188 + ipcMain.handle('tile:sync:sync-all', async (event, args: { 4086 4189 token: string; 4087 4190 }) => { 4088 - const grant = getGrantForToken(args.token); 4191 + const grant = verifyTokenSender(event, args.token, 'tile:sync:sync-all'); 4192 + if (!grant) return { success: false, error: 'invalid token or sender mismatch' }; 4089 4193 const check = checkSyncAllowed(grant); 4090 4194 if (!check.ok) { 4091 4195 handleViolation(grant, 'sync', 'sync:sync-all', check.error, args.token); ··· 4128 4232 4129 4233 // Generic row/table operations — the operand table must be granted. 4130 4234 4131 - ipcMain.handle('tile:datastore:get-table', async (_event, args: { 4235 + ipcMain.handle('tile:datastore:get-table', async (event, args: { 4132 4236 token: string; 4133 4237 table?: string; 4134 4238 tableName?: string; 4135 4239 }) => { 4240 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-table'); 4241 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4136 4242 const table = args?.tableName ?? args?.table; 4137 4243 const check = validateTileDatastoreRequest(args?.token, [table as string]); 4138 4244 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-table', check.error); return { error: check.error }; } ··· 4144 4250 } 4145 4251 }); 4146 4252 4147 - ipcMain.handle('tile:datastore:get-row', async (_event, args: { 4253 + ipcMain.handle('tile:datastore:get-row', async (event, args: { 4148 4254 token: string; 4149 4255 table?: string; 4150 4256 tableName?: string; 4151 4257 rowId: string; 4152 4258 }) => { 4259 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-row'); 4260 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4153 4261 const table = args?.tableName ?? args?.table; 4154 4262 const check = validateTileDatastoreRequest(args?.token, [table as string]); 4155 4263 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-row', check.error); return { error: check.error }; } ··· 4161 4269 } 4162 4270 }); 4163 4271 4164 - ipcMain.handle('tile:datastore:set-row', async (_event, args: { 4272 + ipcMain.handle('tile:datastore:set-row', async (event, args: { 4165 4273 token: string; 4166 4274 table?: string; 4167 4275 tableName?: string; 4168 4276 rowId: string; 4169 4277 rowData: Record<string, unknown>; 4170 4278 }) => { 4279 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:set-row'); 4280 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4171 4281 const table = args?.tableName ?? args?.table; 4172 4282 const check = validateTileDatastoreRequest(args?.token, [table as string]); 4173 4283 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:set-row', check.error); return { error: check.error }; } ··· 4185 4295 // These exist because tile-preload.ts currently invokes them; the preload 4186 4296 // cleanup is a separate task. 4187 4297 4188 - ipcMain.handle('tile:datastore:get', async (_event, args: { 4298 + ipcMain.handle('tile:datastore:get', async (event, args: { 4189 4299 token: string; 4190 4300 table: string; 4191 4301 key: string; 4192 4302 }) => { 4303 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get'); 4304 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4193 4305 const check = validateTileDatastoreRequest(args?.token, [args?.table]); 4194 4306 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get', check.error); return { error: check.error }; } 4195 4307 try { ··· 4200 4312 } 4201 4313 }); 4202 4314 4203 - ipcMain.handle('tile:datastore:set', async (_event, args: { 4315 + ipcMain.handle('tile:datastore:set', async (event, args: { 4204 4316 token: string; 4205 4317 table: string; 4206 4318 key: string; 4207 4319 value: unknown; 4208 4320 }) => { 4321 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:set'); 4322 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4209 4323 const check = validateTileDatastoreRequest(args?.token, [args?.table]); 4210 4324 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:set', check.error); return { error: check.error }; } 4211 4325 try { ··· 4221 4335 } 4222 4336 }); 4223 4337 4224 - ipcMain.handle('tile:datastore:query', async (_event, args: { 4338 + ipcMain.handle('tile:datastore:query', async (event, args: { 4225 4339 token: string; 4226 4340 table: string; 4227 4341 filter?: Record<string, unknown>; 4228 4342 }) => { 4343 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query'); 4344 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4229 4345 const check = validateTileDatastoreRequest(args?.token, [args?.table]); 4230 4346 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query', check.error); return { error: check.error }; } 4231 4347 try { ··· 4248 4364 4249 4365 // ── Item operations (require 'items' in tables) ── 4250 4366 4251 - ipcMain.handle('tile:datastore:add-item', async (_event, args: { 4367 + ipcMain.handle('tile:datastore:add-item', async (event, args: { 4252 4368 token: string; 4253 4369 type: string; 4254 4370 options?: Record<string, unknown>; 4255 4371 }) => { 4372 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:add-item'); 4373 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4256 4374 const check = validateTileDatastoreRequest(args?.token, ['items']); 4257 4375 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:add-item', check.error); return { error: check.error }; } 4258 4376 try { ··· 4269 4387 } 4270 4388 }); 4271 4389 4272 - ipcMain.handle('tile:datastore:get-item', async (_event, args: { 4390 + ipcMain.handle('tile:datastore:get-item', async (event, args: { 4273 4391 token: string; 4274 4392 id: string; 4275 4393 }) => { 4394 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-item'); 4395 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4276 4396 const check = validateTileDatastoreRequest(args?.token, ['items']); 4277 4397 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-item', check.error); return { error: check.error }; } 4278 4398 try { ··· 4283 4403 } 4284 4404 }); 4285 4405 4286 - ipcMain.handle('tile:datastore:update-item', async (_event, args: { 4406 + ipcMain.handle('tile:datastore:update-item', async (event, args: { 4287 4407 token: string; 4288 4408 id: string; 4289 4409 options: Record<string, unknown>; 4290 4410 }) => { 4411 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:update-item'); 4412 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4291 4413 const check = validateTileDatastoreRequest(args?.token, ['items']); 4292 4414 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:update-item', check.error); return { error: check.error }; } 4293 4415 try { ··· 4306 4428 } 4307 4429 }); 4308 4430 4309 - ipcMain.handle('tile:datastore:delete-item', async (_event, args: { 4431 + ipcMain.handle('tile:datastore:delete-item', async (event, args: { 4310 4432 token: string; 4311 4433 id: string; 4312 4434 }) => { 4435 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:delete-item'); 4436 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4313 4437 const check = validateTileDatastoreRequest(args?.token, ['items']); 4314 4438 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:delete-item', check.error); return { error: check.error }; } 4315 4439 try { ··· 4328 4452 } 4329 4453 }); 4330 4454 4331 - ipcMain.handle('tile:datastore:query-items', async (_event, args: { 4455 + ipcMain.handle('tile:datastore:query-items', async (event, args: { 4332 4456 token: string; 4333 4457 filter?: Record<string, unknown>; 4334 4458 }) => { 4459 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query-items'); 4460 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4335 4461 const check = validateTileDatastoreRequest(args?.token, ['items']); 4336 4462 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query-items', check.error); return { error: check.error }; } 4337 4463 try { ··· 4344 4470 4345 4471 // ── Tag operations ── 4346 4472 4347 - ipcMain.handle('tile:datastore:get-or-create-tag', async (_event, args: { 4473 + ipcMain.handle('tile:datastore:get-or-create-tag', async (event, args: { 4348 4474 token: string; 4349 4475 name: string; 4350 4476 }) => { 4477 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-or-create-tag'); 4478 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4351 4479 const check = validateTileDatastoreRequest(args?.token, ['tags']); 4352 4480 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-or-create-tag', check.error); return { error: check.error }; } 4353 4481 try { ··· 4358 4486 } 4359 4487 }); 4360 4488 4361 - ipcMain.handle('tile:datastore:get-tags-by-frecency', async (_event, args: { 4489 + ipcMain.handle('tile:datastore:get-tags-by-frecency', async (event, args: { 4362 4490 token: string; 4363 4491 domain?: string; 4364 4492 }) => { 4493 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-tags-by-frecency'); 4494 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4365 4495 const check = validateTileDatastoreRequest(args?.token, ['tags']); 4366 4496 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-tags-by-frecency', check.error); return { error: check.error }; } 4367 4497 try { ··· 4374 4504 4375 4505 // ── Item-tag link operations (multi-table) ── 4376 4506 4377 - ipcMain.handle('tile:datastore:tag-item', async (_event, args: { 4507 + ipcMain.handle('tile:datastore:tag-item', async (event, args: { 4378 4508 token: string; 4379 4509 itemId: string; 4380 4510 tagId: string; 4381 4511 }) => { 4512 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:tag-item'); 4513 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4382 4514 // Writes to item_tags, reads/updates tags frequency, reads items implicitly. 4383 4515 // Require both 'tags' and 'item_tags' to be granted (matching feature manifests). 4384 4516 const check = validateTileDatastoreRequest(args?.token, ['tags', 'item_tags']); ··· 4402 4534 } 4403 4535 }); 4404 4536 4405 - ipcMain.handle('tile:datastore:untag-item', async (_event, args: { 4537 + ipcMain.handle('tile:datastore:untag-item', async (event, args: { 4406 4538 token: string; 4407 4539 itemId: string; 4408 4540 tagId: string; 4409 4541 }) => { 4542 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:untag-item'); 4543 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4410 4544 const check = validateTileDatastoreRequest(args?.token, ['tags', 'item_tags']); 4411 4545 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:untag-item', check.error); return { error: check.error }; } 4412 4546 try { ··· 4425 4559 } 4426 4560 }); 4427 4561 4428 - ipcMain.handle('tile:datastore:get-item-tags', async (_event, args: { 4562 + ipcMain.handle('tile:datastore:get-item-tags', async (event, args: { 4429 4563 token: string; 4430 4564 itemId: string; 4431 4565 }) => { 4566 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-item-tags'); 4567 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4432 4568 // Joins tags × item_tags — require both. 4433 4569 const check = validateTileDatastoreRequest(args?.token, ['tags', 'item_tags']); 4434 4570 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-item-tags', check.error); return { error: check.error }; } ··· 4440 4576 } 4441 4577 }); 4442 4578 4443 - ipcMain.handle('tile:datastore:get-items-by-tag', async (_event, args: { 4579 + ipcMain.handle('tile:datastore:get-items-by-tag', async (event, args: { 4444 4580 token: string; 4445 4581 tagId: string; 4446 4582 }) => { 4583 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-items-by-tag'); 4584 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4447 4585 // Joins items × item_tags — require both. 4448 4586 const check = validateTileDatastoreRequest(args?.token, ['items', 'item_tags']); 4449 4587 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-items-by-tag', check.error); return { error: check.error }; } ··· 4457 4595 4458 4596 // ── Item event operations (series & feeds) ── 4459 4597 4460 - ipcMain.handle('tile:datastore:add-item-event', async (_event, args: { 4598 + ipcMain.handle('tile:datastore:add-item-event', async (event, args: { 4461 4599 token: string; 4462 4600 itemId: string; 4463 4601 options?: Record<string, unknown>; 4464 4602 }) => { 4603 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:add-item-event'); 4604 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4465 4605 const check = validateTileDatastoreRequest(args?.token, ['item_events']); 4466 4606 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:add-item-event', check.error); return { error: check.error }; } 4467 4607 try { ··· 4472 4612 } 4473 4613 }); 4474 4614 4475 - ipcMain.handle('tile:datastore:get-item-event', async (_event, args: { 4615 + ipcMain.handle('tile:datastore:get-item-event', async (event, args: { 4476 4616 token: string; 4477 4617 eventId: string; 4478 4618 }) => { 4619 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-item-event'); 4620 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4479 4621 const check = validateTileDatastoreRequest(args?.token, ['item_events']); 4480 4622 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-item-event', check.error); return { error: check.error }; } 4481 4623 try { ··· 4486 4628 } 4487 4629 }); 4488 4630 4489 - ipcMain.handle('tile:datastore:query-item-events', async (_event, args: { 4631 + ipcMain.handle('tile:datastore:query-item-events', async (event, args: { 4490 4632 token: string; 4491 4633 filter?: Record<string, unknown>; 4492 4634 }) => { 4635 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query-item-events'); 4636 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4493 4637 const check = validateTileDatastoreRequest(args?.token, ['item_events']); 4494 4638 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query-item-events', check.error); return { error: check.error }; } 4495 4639 try { ··· 4500 4644 } 4501 4645 }); 4502 4646 4503 - ipcMain.handle('tile:datastore:get-latest-item-event', async (_event, args: { 4647 + ipcMain.handle('tile:datastore:get-latest-item-event', async (event, args: { 4504 4648 token: string; 4505 4649 itemId: string; 4506 4650 }) => { 4651 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-latest-item-event'); 4652 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4507 4653 const check = validateTileDatastoreRequest(args?.token, ['item_events']); 4508 4654 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-latest-item-event', check.error); return { error: check.error }; } 4509 4655 try { ··· 4514 4660 } 4515 4661 }); 4516 4662 4517 - ipcMain.handle('tile:datastore:count-item-events', async (_event, args: { 4663 + ipcMain.handle('tile:datastore:count-item-events', async (event, args: { 4518 4664 token: string; 4519 4665 itemId: string; 4520 4666 filter?: { since?: number; until?: number }; 4521 4667 }) => { 4668 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:count-item-events'); 4669 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4522 4670 const check = validateTileDatastoreRequest(args?.token, ['item_events']); 4523 4671 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:count-item-events', check.error); return { error: check.error }; } 4524 4672 try { ··· 4529 4677 } 4530 4678 }); 4531 4679 4532 - ipcMain.handle('tile:datastore:delete-item-event', async (_event, args: { 4680 + ipcMain.handle('tile:datastore:delete-item-event', async (event, args: { 4533 4681 token: string; 4534 4682 eventId: string; 4535 4683 }) => { 4684 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:delete-item-event'); 4685 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4536 4686 const check = validateTileDatastoreRequest(args?.token, ['item_events']); 4537 4687 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:delete-item-event', check.error); return { error: check.error }; } 4538 4688 try { ··· 4543 4693 } 4544 4694 }); 4545 4695 4546 - ipcMain.handle('tile:datastore:delete-item-events', async (_event, args: { 4696 + ipcMain.handle('tile:datastore:delete-item-events', async (event, args: { 4547 4697 token: string; 4548 4698 itemId: string; 4549 4699 }) => { 4700 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:delete-item-events'); 4701 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4550 4702 const check = validateTileDatastoreRequest(args?.token, ['item_events']); 4551 4703 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:delete-item-events', check.error); return { error: check.error }; } 4552 4704 try { ··· 4559 4711 4560 4712 // ── Additional tag operations ── 4561 4713 4562 - ipcMain.handle('tile:datastore:rename-tag', async (_event, args: { 4714 + ipcMain.handle('tile:datastore:rename-tag', async (event, args: { 4563 4715 token: string; 4564 4716 tagId: string; 4565 4717 newName: string; 4566 4718 }) => { 4719 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:rename-tag'); 4720 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4567 4721 const check = validateTileDatastoreRequest(args?.token, ['tags']); 4568 4722 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:rename-tag', check.error); return { error: check.error }; } 4569 4723 try { ··· 4574 4728 } 4575 4729 }); 4576 4730 4577 - ipcMain.handle('tile:datastore:update-tag-color', async (_event, args: { 4731 + ipcMain.handle('tile:datastore:update-tag-color', async (event, args: { 4578 4732 token: string; 4579 4733 tagId: string; 4580 4734 color: string; 4581 4735 }) => { 4736 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:update-tag-color'); 4737 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4582 4738 const check = validateTileDatastoreRequest(args?.token, ['tags']); 4583 4739 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:update-tag-color', check.error); return { error: check.error }; } 4584 4740 try { ··· 4589 4745 } 4590 4746 }); 4591 4747 4592 - ipcMain.handle('tile:datastore:delete-tag', async (_event, args: { 4748 + ipcMain.handle('tile:datastore:delete-tag', async (event, args: { 4593 4749 token: string; 4594 4750 tagId: string; 4595 4751 }) => { 4752 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:delete-tag'); 4753 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4596 4754 const check = validateTileDatastoreRequest(args?.token, ['tags']); 4597 4755 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:delete-tag', check.error); return { error: check.error }; } 4598 4756 try { ··· 4605 4763 4606 4764 // ── Stats (no table restriction) ── 4607 4765 4608 - ipcMain.handle('tile:datastore:get-stats', async (_event, args: { 4766 + ipcMain.handle('tile:datastore:get-stats', async (event, args: { 4609 4767 token: string; 4610 4768 }) => { 4769 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-stats'); 4770 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4611 4771 const check = validateTileDatastoreRequest(args?.token, []); 4612 4772 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-stats', check.error); return { error: check.error }; } 4613 4773 try { ··· 4620 4780 4621 4781 // ── Additional item operations ── 4622 4782 4623 - ipcMain.handle('tile:datastore:hard-delete-item', async (_event, args: { 4783 + ipcMain.handle('tile:datastore:hard-delete-item', async (event, args: { 4624 4784 token: string; 4625 4785 id: string; 4626 4786 }) => { 4787 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:hard-delete-item'); 4788 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4627 4789 const check = validateTileDatastoreRequest(args?.token, ['items']); 4628 4790 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:hard-delete-item', check.error); return { error: check.error }; } 4629 4791 try { ··· 4634 4796 } 4635 4797 }); 4636 4798 4637 - ipcMain.handle('tile:datastore:update-item-title', async (_event, args: { 4799 + ipcMain.handle('tile:datastore:update-item-title', async (event, args: { 4638 4800 token: string; 4639 4801 url: string; 4640 4802 title: string; 4641 4803 }) => { 4804 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:update-item-title'); 4805 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4642 4806 const check = validateTileDatastoreRequest(args?.token, ['items']); 4643 4807 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:update-item-title', check.error); return { error: check.error }; } 4644 4808 try { ··· 4649 4813 } 4650 4814 }); 4651 4815 4652 - ipcMain.handle('tile:datastore:update-item-favicon', async (_event, args: { 4816 + ipcMain.handle('tile:datastore:update-item-favicon', async (event, args: { 4653 4817 token: string; 4654 4818 url: string; 4655 4819 faviconUrl: string; 4656 4820 }) => { 4821 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:update-item-favicon'); 4822 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4657 4823 const check = validateTileDatastoreRequest(args?.token, ['items']); 4658 4824 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:update-item-favicon', check.error); return { error: check.error }; } 4659 4825 try { ··· 4666 4832 4667 4833 // ── Visit / history operations (require 'items') ── 4668 4834 4669 - ipcMain.handle('tile:datastore:record-item-visit', async (_event, args: { 4835 + ipcMain.handle('tile:datastore:record-item-visit', async (event, args: { 4670 4836 token: string; 4671 4837 itemId: string; 4672 4838 options?: Record<string, unknown>; 4673 4839 }) => { 4840 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:record-item-visit'); 4841 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4674 4842 const check = validateTileDatastoreRequest(args?.token, ['items']); 4675 4843 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:record-item-visit', check.error); return { error: check.error }; } 4676 4844 try { ··· 4681 4849 } 4682 4850 }); 4683 4851 4684 - ipcMain.handle('tile:datastore:get-item-visits', async (_event, args: { 4852 + ipcMain.handle('tile:datastore:get-item-visits', async (event, args: { 4685 4853 token: string; 4686 4854 itemId: string; 4687 4855 filter?: Record<string, unknown>; 4688 4856 }) => { 4857 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-item-visits'); 4858 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4689 4859 const check = validateTileDatastoreRequest(args?.token, ['items']); 4690 4860 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-item-visits', check.error); return { error: check.error }; } 4691 4861 try { ··· 4696 4866 } 4697 4867 }); 4698 4868 4699 - ipcMain.handle('tile:datastore:query-item-visits', async (_event, args: { 4869 + ipcMain.handle('tile:datastore:query-item-visits', async (event, args: { 4700 4870 token: string; 4701 4871 filter?: Record<string, unknown>; 4702 4872 }) => { 4873 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query-item-visits'); 4874 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4703 4875 const check = validateTileDatastoreRequest(args?.token, ['items']); 4704 4876 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query-item-visits', check.error); return { error: check.error }; } 4705 4877 try { ··· 4710 4882 } 4711 4883 }); 4712 4884 4713 - ipcMain.handle('tile:datastore:get-history', async (_event, args: { 4885 + ipcMain.handle('tile:datastore:get-history', async (event, args: { 4714 4886 token: string; 4715 4887 filter?: Record<string, unknown>; 4716 4888 }) => { 4889 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-history'); 4890 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4717 4891 const check = validateTileDatastoreRequest(args?.token, ['items']); 4718 4892 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-history', check.error); return { error: check.error }; } 4719 4893 try { ··· 4737 4911 } 4738 4912 }); 4739 4913 4740 - ipcMain.handle('tile:datastore:track-navigation', async (_event, args: { 4914 + ipcMain.handle('tile:datastore:track-navigation', async (event, args: { 4741 4915 token: string; 4742 4916 uri: string; 4743 4917 options?: Record<string, unknown>; 4744 4918 }) => { 4919 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:track-navigation'); 4920 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4745 4921 const check = validateTileDatastoreRequest(args?.token, ['items']); 4746 4922 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:track-navigation', check.error); return { error: check.error }; } 4747 4923 try { ··· 4752 4928 } 4753 4929 }); 4754 4930 4755 - ipcMain.handle('tile:datastore:query-items-by-frecency', async (_event, args: { 4931 + ipcMain.handle('tile:datastore:query-items-by-frecency', async (event, args: { 4756 4932 token: string; 4757 4933 filter?: Record<string, unknown>; 4758 4934 }) => { 4935 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query-items-by-frecency'); 4936 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4759 4937 const check = validateTileDatastoreRequest(args?.token, ['items']); 4760 4938 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query-items-by-frecency', check.error); return { error: check.error }; } 4761 4939 try { ··· 4768 4946 4769 4947 // ── Address compat shims (redirect to items + backward-compat shape) ── 4770 4948 4771 - ipcMain.handle('tile:datastore:add-address', async (_event, args: { 4949 + ipcMain.handle('tile:datastore:add-address', async (event, args: { 4772 4950 token: string; 4773 4951 uri: string; 4774 4952 options?: Record<string, unknown>; 4775 4953 }) => { 4954 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:add-address'); 4955 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4776 4956 const check = validateTileDatastoreRequest(args?.token, ['items']); 4777 4957 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:add-address', check.error); return { error: check.error }; } 4778 4958 try { ··· 4801 4981 } 4802 4982 }); 4803 4983 4804 - ipcMain.handle('tile:datastore:get-address', async (_event, args: { 4984 + ipcMain.handle('tile:datastore:get-address', async (event, args: { 4805 4985 token: string; 4806 4986 id: string; 4807 4987 }) => { 4988 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-address'); 4989 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4808 4990 const check = validateTileDatastoreRequest(args?.token, ['items']); 4809 4991 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-address', check.error); return { error: check.error }; } 4810 4992 try { ··· 4816 4998 } 4817 4999 }); 4818 5000 4819 - ipcMain.handle('tile:datastore:update-address', async (_event, args: { 5001 + ipcMain.handle('tile:datastore:update-address', async (event, args: { 4820 5002 token: string; 4821 5003 id: string; 4822 5004 updates: Record<string, unknown>; 4823 5005 }) => { 5006 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:update-address'); 5007 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4824 5008 const check = validateTileDatastoreRequest(args?.token, ['items']); 4825 5009 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:update-address', check.error); return { error: check.error }; } 4826 5010 try { ··· 4845 5029 } 4846 5030 }); 4847 5031 4848 - ipcMain.handle('tile:datastore:query-addresses', async (_event, args: { 5032 + ipcMain.handle('tile:datastore:query-addresses', async (event, args: { 4849 5033 token: string; 4850 5034 filter?: Record<string, unknown>; 4851 5035 }) => { 5036 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query-addresses'); 5037 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4852 5038 const check = validateTileDatastoreRequest(args?.token, ['items']); 4853 5039 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query-addresses', check.error); return { error: check.error }; } 4854 5040 try { ··· 4866 5052 } 4867 5053 }); 4868 5054 4869 - ipcMain.handle('tile:datastore:add-visit', async (_event, args: { 5055 + ipcMain.handle('tile:datastore:add-visit', async (event, args: { 4870 5056 token: string; 4871 5057 addressId: string; 4872 5058 options?: Record<string, unknown>; 4873 5059 }) => { 5060 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:add-visit'); 5061 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4874 5062 const check = validateTileDatastoreRequest(args?.token, ['item_visits']); 4875 5063 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:add-visit', check.error); return { error: check.error }; } 4876 5064 try { ··· 4887 5075 } 4888 5076 }); 4889 5077 4890 - ipcMain.handle('tile:datastore:query-visits', async (_event, args: { 5078 + ipcMain.handle('tile:datastore:query-visits', async (event, args: { 4891 5079 token: string; 4892 5080 filter?: Record<string, unknown>; 4893 5081 }) => { 5082 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query-visits'); 5083 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4894 5084 const check = validateTileDatastoreRequest(args?.token, ['item_visits']); 4895 5085 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query-visits', check.error); return { error: check.error }; } 4896 5086 try { ··· 4909 5099 } 4910 5100 }); 4911 5101 4912 - ipcMain.handle('tile:datastore:add-content', async (_event, args: { 5102 + ipcMain.handle('tile:datastore:add-content', async (event, args: { 4913 5103 token: string; 4914 5104 options?: Record<string, unknown>; 4915 5105 }) => { 5106 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:add-content'); 5107 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4916 5108 const check = validateTileDatastoreRequest(args?.token, ['content']); 4917 5109 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:add-content', check.error); return { error: check.error }; } 4918 5110 try { ··· 4923 5115 } 4924 5116 }); 4925 5117 4926 - ipcMain.handle('tile:datastore:query-content', async (_event, args: { 5118 + ipcMain.handle('tile:datastore:query-content', async (event, args: { 4927 5119 token: string; 4928 5120 filter?: Record<string, unknown>; 4929 5121 }) => { 5122 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:query-content'); 5123 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4930 5124 const check = validateTileDatastoreRequest(args?.token, ['content']); 4931 5125 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:query-content', check.error); return { error: check.error }; } 4932 5126 try { ··· 4937 5131 } 4938 5132 }); 4939 5133 4940 - ipcMain.handle('tile:datastore:tag-address', async (_event, args: { 5134 + ipcMain.handle('tile:datastore:tag-address', async (event, args: { 4941 5135 token: string; 4942 5136 addressId: string; 4943 5137 tagId: string; 4944 5138 }) => { 5139 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:tag-address'); 5140 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4945 5141 const check = validateTileDatastoreRequest(args?.token, ['tags', 'item_tags']); 4946 5142 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:tag-address', check.error); return { error: check.error }; } 4947 5143 try { ··· 4958 5154 } 4959 5155 }); 4960 5156 4961 - ipcMain.handle('tile:datastore:untag-address', async (_event, args: { 5157 + ipcMain.handle('tile:datastore:untag-address', async (event, args: { 4962 5158 token: string; 4963 5159 addressId: string; 4964 5160 tagId: string; 4965 5161 }) => { 5162 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:untag-address'); 5163 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4966 5164 const check = validateTileDatastoreRequest(args?.token, ['tags', 'item_tags']); 4967 5165 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:untag-address', check.error); return { error: check.error }; } 4968 5166 try { ··· 4982 5180 } 4983 5181 }); 4984 5182 4985 - ipcMain.handle('tile:datastore:get-address-tags', async (_event, args: { 5183 + ipcMain.handle('tile:datastore:get-address-tags', async (event, args: { 4986 5184 token: string; 4987 5185 addressId: string; 4988 5186 }) => { 5187 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-address-tags'); 5188 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 4989 5189 const check = validateTileDatastoreRequest(args?.token, ['tags', 'item_tags']); 4990 5190 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-address-tags', check.error); return { error: check.error }; } 4991 5191 try { ··· 4996 5196 } 4997 5197 }); 4998 5198 4999 - ipcMain.handle('tile:datastore:get-addresses-by-tag', async (_event, args: { 5199 + ipcMain.handle('tile:datastore:get-addresses-by-tag', async (event, args: { 5000 5200 token: string; 5001 5201 tagId: string; 5002 5202 }) => { 5203 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-addresses-by-tag'); 5204 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 5003 5205 const check = validateTileDatastoreRequest(args?.token, ['items', 'item_tags']); 5004 5206 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-addresses-by-tag', check.error); return { error: check.error }; } 5005 5207 try { ··· 5010 5212 } 5011 5213 }); 5012 5214 5013 - ipcMain.handle('tile:datastore:get-untagged-addresses', async (_event, args: { 5215 + ipcMain.handle('tile:datastore:get-untagged-addresses', async (event, args: { 5014 5216 token: string; 5015 5217 }) => { 5218 + const _phase2Grant = verifyTokenSender(event, args?.token, 'tile:datastore:get-untagged-addresses'); 5219 + if (!_phase2Grant) return { error: 'invalid token or sender mismatch' }; 5016 5220 const check = validateTileDatastoreRequest(args?.token, ['items', 'item_tags']); 5017 5221 if ('error' in check) { handleDatastoreViolation(args?.token, 'datastore:get-untagged-addresses', check.error); return { error: check.error }; } 5018 5222 try { ··· 6220 6424 // Wave 3.6c will flip tile-preload.cts to call these channels and 6221 6425 // remove the legacy `extension-*` invocations. 6222 6426 6223 - ipcMain.handle('tile:extensions:pickFolder', async (_event, args: { 6427 + ipcMain.handle('tile:extensions:pickFolder', async (event, args: { 6224 6428 token: string; 6225 6429 }) => { 6226 6430 if (!args?.token) return { success: false, error: 'Invalid token' }; 6227 - const grant = getGrantForToken(args.token); 6431 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:pickFolder'); 6228 6432 if (!grant) return { success: false, error: 'Invalid token' }; 6229 6433 if (!grant.trustedBuiltin) { 6230 6434 handleViolation(grant, 'extensions', 'tile:extensions:pickFolder', 'trustedBuiltin required', args.token); ··· 6241 6445 } 6242 6446 }); 6243 6447 6244 - ipcMain.handle('tile:extensions:validateFolder', async (_event, args: { 6448 + ipcMain.handle('tile:extensions:validateFolder', async (event, args: { 6245 6449 token: string; 6246 6450 folderPath: string; 6247 6451 }) => { 6248 6452 if (!args?.token) return { success: false, error: 'Invalid token' }; 6249 - const grant = getGrantForToken(args.token); 6453 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:validateFolder'); 6250 6454 if (!grant) return { success: false, error: 'Invalid token' }; 6251 6455 if (!grant.trustedBuiltin) { 6252 6456 handleViolation(grant, 'extensions', 'tile:extensions:validateFolder', 'trustedBuiltin required', args.token); ··· 6273 6477 } 6274 6478 }); 6275 6479 6276 - ipcMain.handle('tile:extensions:add', async (_event, args: { 6480 + ipcMain.handle('tile:extensions:add', async (event, args: { 6277 6481 token: string; 6278 6482 folderPath: string; 6279 6483 manifest?: unknown; 6280 6484 enabled?: boolean; 6281 6485 }) => { 6282 6486 if (!args?.token) return { success: false, error: 'Invalid token' }; 6283 - const grant = getGrantForToken(args.token); 6487 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:add'); 6284 6488 if (!grant) return { success: false, error: 'Invalid token' }; 6285 6489 if (!grant.trustedBuiltin) { 6286 6490 handleViolation(grant, 'extensions', 'tile:extensions:add', 'trustedBuiltin required', args.token); ··· 6321 6525 } 6322 6526 }); 6323 6527 6324 - ipcMain.handle('tile:extensions:remove', async (_event, args: { 6528 + ipcMain.handle('tile:extensions:remove', async (event, args: { 6325 6529 token: string; 6326 6530 id: string; 6327 6531 }) => { 6328 6532 if (!args?.token) return { success: false, error: 'Invalid token' }; 6329 - const grant = getGrantForToken(args.token); 6533 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:remove'); 6330 6534 if (!grant) return { success: false, error: 'Invalid token' }; 6331 6535 if (!grant.trustedBuiltin) { 6332 6536 handleViolation(grant, 'extensions', 'tile:extensions:remove', 'trustedBuiltin required', args.token); ··· 6347 6551 } 6348 6552 }); 6349 6553 6350 - ipcMain.handle('tile:extensions:update', async (_event, args: { 6554 + ipcMain.handle('tile:extensions:update', async (event, args: { 6351 6555 token: string; 6352 6556 id: string; 6353 6557 updates: Record<string, unknown>; 6354 6558 }) => { 6355 6559 if (!args?.token) return { success: false, error: 'Invalid token' }; 6356 - const grant = getGrantForToken(args.token); 6560 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:update'); 6357 6561 if (!grant) return { success: false, error: 'Invalid token' }; 6358 6562 if (!grant.trustedBuiltin) { 6359 6563 handleViolation(grant, 'extensions', 'tile:extensions:update', 'trustedBuiltin required', args.token); ··· 6386 6590 } 6387 6591 }); 6388 6592 6389 - ipcMain.handle('tile:extensions:getAll', async (_event, args: { 6593 + ipcMain.handle('tile:extensions:getAll', async (event, args: { 6390 6594 token: string; 6391 6595 }) => { 6392 6596 if (!args?.token) return { success: false, error: 'Invalid token' }; 6393 - const grant = getGrantForToken(args.token); 6597 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:getAll'); 6394 6598 if (!grant) return { success: false, error: 'Invalid token' }; 6395 6599 if (!grant.trustedBuiltin) { 6396 6600 handleViolation(grant, 'extensions', 'tile:extensions:getAll', 'trustedBuiltin required', args.token); ··· 6405 6609 } 6406 6610 }); 6407 6611 6408 - ipcMain.handle('tile:extensions:get', async (_event, args: { 6612 + ipcMain.handle('tile:extensions:get', async (event, args: { 6409 6613 token: string; 6410 6614 id: string; 6411 6615 }) => { 6412 6616 if (!args?.token) return { success: false, error: 'Invalid token' }; 6413 - const grant = getGrantForToken(args.token); 6617 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:get'); 6414 6618 if (!grant) return { success: false, error: 'Invalid token' }; 6415 6619 if (!grant.trustedBuiltin) { 6416 6620 handleViolation(grant, 'extensions', 'tile:extensions:get', 'trustedBuiltin required', args.token); ··· 6428 6632 } 6429 6633 }); 6430 6634 6431 - ipcMain.handle('tile:extensions:windowList', async (_event, args: { 6635 + ipcMain.handle('tile:extensions:windowList', async (event, args: { 6432 6636 token: string; 6433 6637 }) => { 6434 6638 if (!args?.token) return { success: false, error: 'Invalid token' }; 6435 - const grant = getGrantForToken(args.token); 6639 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:windowList'); 6436 6640 if (!grant) return { success: false, error: 'Invalid token' }; 6437 6641 if (!grant.trustedBuiltin) { 6438 6642 handleViolation(grant, 'extensions', 'tile:extensions:windowList', 'trustedBuiltin required', args.token); ··· 6446 6650 } 6447 6651 }); 6448 6652 6449 - ipcMain.handle('tile:extensions:listAllRegistered', async (_event, args: { 6653 + ipcMain.handle('tile:extensions:listAllRegistered', async (event, args: { 6450 6654 token: string; 6451 6655 }) => { 6452 6656 if (!args?.token) return { success: false, error: 'Invalid token' }; 6453 - const grant = getGrantForToken(args.token); 6657 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:listAllRegistered'); 6454 6658 if (!grant) return { success: false, error: 'Invalid token' }; 6455 6659 if (!grant.trustedBuiltin) { 6456 6660 handleViolation(grant, 'extensions', 'tile:extensions:listAllRegistered', 'trustedBuiltin required', args.token); ··· 6464 6668 } 6465 6669 }); 6466 6670 6467 - ipcMain.handle('tile:extensions:windowDevtools', async (_event, args: { 6671 + ipcMain.handle('tile:extensions:windowDevtools', async (event, args: { 6468 6672 token: string; 6469 6673 id: string; 6470 6674 }) => { 6471 6675 if (!args?.token) return { success: false, error: 'Invalid token' }; 6472 - const grant = getGrantForToken(args.token); 6676 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:windowDevtools'); 6473 6677 if (!grant) return { success: false, error: 'Invalid token' }; 6474 6678 if (!grant.trustedBuiltin) { 6475 6679 handleViolation(grant, 'extensions', 'tile:extensions:windowDevtools', 'trustedBuiltin required', args.token); ··· 6479 6683 return { success: false, error: `Extension ${args.id} is not running as a legacy window` }; 6480 6684 }); 6481 6685 6482 - ipcMain.handle('tile:extensions:reload', async (_event, args: { 6686 + ipcMain.handle('tile:extensions:reload', async (event, args: { 6483 6687 token: string; 6484 6688 id: string; 6485 6689 }) => { 6486 6690 if (!args?.token) return { success: false, error: 'Invalid token' }; 6487 - const grant = getGrantForToken(args.token); 6691 + const grant = verifyTokenSender(event, args.token, 'tile:extensions:reload'); 6488 6692 if (!grant) return { success: false, error: 'Invalid token' }; 6489 6693 if (!grant.trustedBuiltin) { 6490 6694 handleViolation(grant, 'extensions', 'tile:extensions:reload', 'trustedBuiltin required', args.token); ··· 6513 6717 // Wave 3.6d will flip tile-preload.cts to call these channels and 6514 6718 // remove the legacy `chrome-ext:*` invocations. 6515 6719 6516 - ipcMain.handle('tile:chrome-extensions:list', async (_event, args: { 6720 + ipcMain.handle('tile:chrome-extensions:list', async (event, args: { 6517 6721 token: string; 6518 6722 }) => { 6519 6723 if (!args?.token) return { success: false, error: 'Invalid token' }; 6520 - const grant = getGrantForToken(args.token); 6724 + const grant = verifyTokenSender(event, args.token, 'tile:chrome-extensions:list'); 6521 6725 if (!grant) return { success: false, error: 'Invalid token' }; 6522 6726 if (!grant.trustedBuiltin) { 6523 6727 handleViolation(grant, 'chrome-extensions', 'tile:chrome-extensions:list', 'trustedBuiltin required', args.token); ··· 6532 6736 } 6533 6737 }); 6534 6738 6535 - ipcMain.handle('tile:chrome-extensions:enable', async (_event, args: { 6739 + ipcMain.handle('tile:chrome-extensions:enable', async (event, args: { 6536 6740 token: string; 6537 6741 id: string; 6538 6742 }) => { 6539 6743 if (!args?.token) return { success: false, error: 'Invalid token' }; 6540 - const grant = getGrantForToken(args.token); 6744 + const grant = verifyTokenSender(event, args.token, 'tile:chrome-extensions:enable'); 6541 6745 if (!grant) return { success: false, error: 'Invalid token' }; 6542 6746 if (!grant.trustedBuiltin) { 6543 6747 handleViolation(grant, 'chrome-extensions', 'tile:chrome-extensions:enable', 'trustedBuiltin required', args.token); ··· 6552 6756 } 6553 6757 }); 6554 6758 6555 - ipcMain.handle('tile:chrome-extensions:disable', async (_event, args: { 6759 + ipcMain.handle('tile:chrome-extensions:disable', async (event, args: { 6556 6760 token: string; 6557 6761 id: string; 6558 6762 }) => { 6559 6763 if (!args?.token) return { success: false, error: 'Invalid token' }; 6560 - const grant = getGrantForToken(args.token); 6764 + const grant = verifyTokenSender(event, args.token, 'tile:chrome-extensions:disable'); 6561 6765 if (!grant) return { success: false, error: 'Invalid token' }; 6562 6766 if (!grant.trustedBuiltin) { 6563 6767 handleViolation(grant, 'chrome-extensions', 'tile:chrome-extensions:disable', 'trustedBuiltin required', args.token); ··· 6572 6776 } 6573 6777 }); 6574 6778 6575 - ipcMain.handle('tile:chrome-extensions:getStatus', async (_event, args: { 6779 + ipcMain.handle('tile:chrome-extensions:getStatus', async (event, args: { 6576 6780 token: string; 6577 6781 }) => { 6578 6782 if (!args?.token) return { success: false, error: 'Invalid token' }; 6579 - const grant = getGrantForToken(args.token); 6783 + const grant = verifyTokenSender(event, args.token, 'tile:chrome-extensions:getStatus'); 6580 6784 if (!grant) return { success: false, error: 'Invalid token' }; 6581 6785 if (!grant.trustedBuiltin) { 6582 6786 handleViolation(grant, 'chrome-extensions', 'tile:chrome-extensions:getStatus', 'trustedBuiltin required', args.token); ··· 6591 6795 } 6592 6796 }); 6593 6797 6594 - ipcMain.handle('tile:chrome-extensions:getUiEntries', async (_event, args: { 6798 + ipcMain.handle('tile:chrome-extensions:getUiEntries', async (event, args: { 6595 6799 token: string; 6596 6800 }) => { 6597 6801 if (!args?.token) return { success: false, error: 'Invalid token' }; 6598 - const grant = getGrantForToken(args.token); 6802 + const grant = verifyTokenSender(event, args.token, 'tile:chrome-extensions:getUiEntries'); 6599 6803 if (!grant) return { success: false, error: 'Invalid token' }; 6600 6804 if (!grant.trustedBuiltin) { 6601 6805 handleViolation(grant, 'chrome-extensions', 'tile:chrome-extensions:getUiEntries', 'trustedBuiltin required', args.token); ··· 6610 6814 } 6611 6815 }); 6612 6816 6613 - ipcMain.handle('tile:chrome-extensions:openPage', async (_event, args: { 6817 + ipcMain.handle('tile:chrome-extensions:openPage', async (event, args: { 6614 6818 token: string; 6615 6819 id: string; 6616 6820 type: string; 6617 6821 }) => { 6618 6822 if (!args?.token) return { success: false, error: 'Invalid token' }; 6619 - const grant = getGrantForToken(args.token); 6823 + const grant = verifyTokenSender(event, args.token, 'tile:chrome-extensions:openPage'); 6620 6824 if (!grant) return { success: false, error: 'Invalid token' }; 6621 6825 if (!grant.trustedBuiltin) { 6622 6826 handleViolation(grant, 'chrome-extensions', 'tile:chrome-extensions:openPage', 'trustedBuiltin required', args.token); ··· 6640 6844 // Any tile with a valid token may log — no capability check beyond a 6641 6845 // valid token (all tiles can log). Prints forwarded renderer output to 6642 6846 // the main-process terminal, matching the legacy shortSource format. 6643 - ipcMain.on('tile:log:write', (_event, args: { 6847 + ipcMain.on('tile:log:write', (event, args: { 6644 6848 token: string; 6645 6849 args?: unknown[]; 6646 6850 source?: string; 6647 6851 }) => { 6648 6852 if (!args?.token) return; 6649 - const grant = getGrantForToken(args.token); 6853 + const grant = verifyTokenSender(event, args.token, 'tile:log:write'); 6650 6854 if (!grant) { 6651 6855 handleViolation(null, 'log', 'tile:log:write', 'invalid token', args.token); 6652 6856 return; ··· 6661 6865 // 6662 6866 // Strict shim for the legacy `app-quit` channel. Requires trustedBuiltin 6663 6867 // (only core tiles — cmd, hud, page — may quit the application). 6664 - ipcMain.on('tile:app:quit', (_event, args: { 6868 + ipcMain.on('tile:app:quit', (event, args: { 6665 6869 token: string; 6666 6870 source?: string; 6667 6871 }) => { 6668 6872 if (!args?.token) return; 6669 - const grant = getGrantForToken(args.token); 6873 + const grant = verifyTokenSender(event, args.token, 'tile:app:quit'); 6670 6874 if (!grant) { 6671 6875 handleViolation(null, 'app', 'tile:app:quit', 'invalid token', args.token); 6672 6876 return; ··· 6683 6887 // 6684 6888 // Strict shim for the legacy `app-restart` channel. Requires trustedBuiltin. 6685 6889 // Calls app.relaunch() then app.quit() matching the legacy ipc.ts pattern. 6686 - ipcMain.on('tile:app:restart', (_event, args: { 6890 + ipcMain.on('tile:app:restart', (event, args: { 6687 6891 token: string; 6688 6892 source?: string; 6689 6893 }) => { 6690 6894 if (!args?.token) return; 6691 - const grant = getGrantForToken(args.token); 6895 + const grant = verifyTokenSender(event, args.token, 'tile:app:restart'); 6692 6896 if (!grant) { 6693 6897 handleViolation(null, 'app', 'tile:app:restart', 'invalid token', args.token); 6694 6898 return; ··· 6708 6912 // All handlers require trustedBuiltin — settings/diagnostic/page-host 6709 6913 // tiles will consume these once they migrate off preload.js. 6710 6914 6711 - ipcMain.handle('tile:backup:create', async (_event, args: { 6915 + ipcMain.handle('tile:backup:create', async (event, args: { 6712 6916 token: string; 6713 6917 }) => { 6714 6918 if (!args?.token) return { success: false, error: 'Invalid token' }; 6715 - const grant = getGrantForToken(args.token); 6919 + const grant = verifyTokenSender(event, args.token, 'tile:backup:create'); 6716 6920 if (!grant) return { success: false, error: 'Invalid token' }; 6717 6921 if (!grant.trustedBuiltin) { 6718 6922 handleViolation(grant, 'backup', 'tile:backup:create', 'trustedBuiltin required', args.token); ··· 6726 6930 } 6727 6931 }); 6728 6932 6729 - ipcMain.handle('tile:backup:list', async (_event, args: { 6933 + ipcMain.handle('tile:backup:list', async (event, args: { 6730 6934 token: string; 6731 6935 }) => { 6732 6936 if (!args?.token) return { success: false, error: 'Invalid token' }; 6733 - const grant = getGrantForToken(args.token); 6937 + const grant = verifyTokenSender(event, args.token, 'tile:backup:list'); 6734 6938 if (!grant) return { success: false, error: 'Invalid token' }; 6735 6939 if (!grant.trustedBuiltin) { 6736 6940 handleViolation(grant, 'backup', 'tile:backup:list', 'trustedBuiltin required', args.token); ··· 6751 6955 } 6752 6956 }); 6753 6957 6754 - ipcMain.handle('tile:backup:get-config', async (_event, args: { 6958 + ipcMain.handle('tile:backup:get-config', async (event, args: { 6755 6959 token: string; 6756 6960 }) => { 6757 6961 if (!args?.token) return { success: false, error: 'Invalid token' }; 6758 - const grant = getGrantForToken(args.token); 6962 + const grant = verifyTokenSender(event, args.token, 'tile:backup:get-config'); 6759 6963 if (!grant) return { success: false, error: 'Invalid token' }; 6760 6964 if (!grant.trustedBuiltin) { 6761 6965 handleViolation(grant, 'backup', 'tile:backup:get-config', 'trustedBuiltin required', args.token); ··· 6774 6978 // Strict counterpart of the legacy `shell-open-path` channel. 6775 6979 // Requires trustedBuiltin — opens a path in the OS file manager. 6776 6980 6777 - ipcMain.handle('tile:shell:open-path', async (_event, args: { 6981 + ipcMain.handle('tile:shell:open-path', async (event, args: { 6778 6982 token: string; 6779 6983 path: string; 6780 6984 }) => { 6781 6985 if (!args?.token) return { success: false, error: 'Invalid token' }; 6782 - const grant = getGrantForToken(args.token); 6986 + const grant = verifyTokenSender(event, args.token, 'tile:shell:open-path'); 6783 6987 if (!grant) return { success: false, error: 'Invalid token' }; 6784 6988 if (!grant.trustedBuiltin) { 6785 6989 handleViolation(grant, 'shell', 'tile:shell:open-path', 'trustedBuiltin required', args.token); ··· 6804 7008 // `set-default-browser`, and `get-app-prefs` channels. 6805 7009 // All require trustedBuiltin. 6806 7010 6807 - ipcMain.handle('tile:app:default-browser-status', async (_event, args: { 7011 + ipcMain.handle('tile:app:default-browser-status', async (event, args: { 6808 7012 token: string; 6809 7013 }) => { 6810 7014 if (!args?.token) return { success: false, error: 'Invalid token' }; 6811 - const grant = getGrantForToken(args.token); 7015 + const grant = verifyTokenSender(event, args.token, 'tile:app:default-browser-status'); 6812 7016 if (!grant) return { success: false, error: 'Invalid token' }; 6813 7017 if (!grant.trustedBuiltin) { 6814 7018 handleViolation(grant, 'app', 'tile:app:default-browser-status', 'trustedBuiltin required', args.token); ··· 6823 7027 } 6824 7028 }); 6825 7029 6826 - ipcMain.handle('tile:app:set-default-browser', async (_event, args: { 7030 + ipcMain.handle('tile:app:set-default-browser', async (event, args: { 6827 7031 token: string; 6828 7032 }) => { 6829 7033 if (!args?.token) return { success: false, error: 'Invalid token' }; 6830 - const grant = getGrantForToken(args.token); 7034 + const grant = verifyTokenSender(event, args.token, 'tile:app:set-default-browser'); 6831 7035 if (!grant) return { success: false, error: 'Invalid token' }; 6832 7036 if (!grant.trustedBuiltin) { 6833 7037 handleViolation(grant, 'app', 'tile:app:set-default-browser', 'trustedBuiltin required', args.token); ··· 6848 7052 } 6849 7053 }); 6850 7054 6851 - ipcMain.handle('tile:app:get-prefs', (_event, args: { 7055 + ipcMain.handle('tile:app:get-prefs', (event, args: { 6852 7056 token: string; 6853 7057 }) => { 6854 7058 if (!args?.token) return { success: false, error: 'Invalid token' }; 6855 - const grant = getGrantForToken(args.token); 7059 + const grant = verifyTokenSender(event, args.token, 'tile:app:get-prefs'); 6856 7060 if (!grant) return { success: false, error: 'Invalid token' }; 6857 7061 if (!grant.trustedBuiltin) { 6858 7062 handleViolation(grant, 'app', 'tile:app:get-prefs', 'trustedBuiltin required', args.token); ··· 6876 7080 return win; 6877 7081 }; 6878 7082 6879 - ipcMain.handle('tile:nav:back', (_event, args: { token: string; windowId?: number }) => { 7083 + ipcMain.handle('tile:nav:back', (event, args: { token: string; windowId?: number }) => { 6880 7084 if (!args?.token) return { success: false, error: 'Invalid token' }; 6881 - const grant = getGrantForToken(args.token); 7085 + const grant = verifyTokenSender(event, args.token, 'tile:nav:back'); 6882 7086 if (!grant) return { success: false, error: 'Invalid token' }; 6883 7087 if (!grant.trustedBuiltin) { 6884 7088 handleViolation(grant, 'nav', 'tile:nav:back', 'trustedBuiltin required', args.token); ··· 6891 7095 return { success: false, error: 'Cannot go back' }; 6892 7096 }); 6893 7097 6894 - ipcMain.handle('tile:nav:forward', (_event, args: { token: string; windowId?: number }) => { 7098 + ipcMain.handle('tile:nav:forward', (event, args: { token: string; windowId?: number }) => { 6895 7099 if (!args?.token) return { success: false, error: 'Invalid token' }; 6896 - const grant = getGrantForToken(args.token); 7100 + const grant = verifyTokenSender(event, args.token, 'tile:nav:forward'); 6897 7101 if (!grant) return { success: false, error: 'Invalid token' }; 6898 7102 if (!grant.trustedBuiltin) { 6899 7103 handleViolation(grant, 'nav', 'tile:nav:forward', 'trustedBuiltin required', args.token); ··· 6906 7110 return { success: false, error: 'Cannot go forward' }; 6907 7111 }); 6908 7112 6909 - ipcMain.handle('tile:nav:reload', (_event, args: { token: string; windowId?: number }) => { 7113 + ipcMain.handle('tile:nav:reload', (event, args: { token: string; windowId?: number }) => { 6910 7114 if (!args?.token) return { success: false, error: 'Invalid token' }; 6911 - const grant = getGrantForToken(args.token); 7115 + const grant = verifyTokenSender(event, args.token, 'tile:nav:reload'); 6912 7116 if (!grant) return { success: false, error: 'Invalid token' }; 6913 7117 if (!grant.trustedBuiltin) { 6914 7118 handleViolation(grant, 'nav', 'tile:nav:reload', 'trustedBuiltin required', args.token); ··· 6920 7124 return { success: true }; 6921 7125 }); 6922 7126 6923 - ipcMain.handle('tile:nav:state', (_event, args: { token: string; windowId?: number }) => { 7127 + ipcMain.handle('tile:nav:state', (event, args: { token: string; windowId?: number }) => { 6924 7128 if (!args?.token) return { success: false, error: 'Invalid token' }; 6925 - const grant = getGrantForToken(args.token); 7129 + const grant = verifyTokenSender(event, args.token, 'tile:nav:state'); 6926 7130 if (!grant) return { success: false, error: 'Invalid token' }; 6927 7131 if (!grant.trustedBuiltin) { 6928 7132 handleViolation(grant, 'nav', 'tile:nav:state', 'trustedBuiltin required', args.token); ··· 6948 7152 // `session-restore-interactive`, and `window-reopen-last-closed` channels. 6949 7153 // All require trustedBuiltin. 6950 7154 6951 - ipcMain.handle('tile:session:save', async (_event, args: { token: string }) => { 7155 + ipcMain.handle('tile:session:save', async (event, args: { token: string }) => { 6952 7156 if (!args?.token) return { success: false, error: 'Invalid token' }; 6953 - const grant = getGrantForToken(args.token); 7157 + const grant = verifyTokenSender(event, args.token, 'tile:session:save'); 6954 7158 if (!grant) return { success: false, error: 'Invalid token' }; 6955 7159 if (!grant.trustedBuiltin) { 6956 7160 handleViolation(grant, 'session', 'tile:session:save', 'trustedBuiltin required', args.token); ··· 6965 7169 } 6966 7170 }); 6967 7171 6968 - ipcMain.handle('tile:session:restore-interactive', async (_event, args: { token: string }) => { 7172 + ipcMain.handle('tile:session:restore-interactive', async (event, args: { token: string }) => { 6969 7173 if (!args?.token) return { success: false, error: 'Invalid token' }; 6970 - const grant = getGrantForToken(args.token); 7174 + const grant = verifyTokenSender(event, args.token, 'tile:session:restore-interactive'); 6971 7175 if (!grant) return { success: false, error: 'Invalid token' }; 6972 7176 if (!grant.trustedBuiltin) { 6973 7177 handleViolation(grant, 'session', 'tile:session:restore-interactive', 'trustedBuiltin required', args.token); ··· 7003 7207 } 7004 7208 }); 7005 7209 7006 - ipcMain.handle('tile:session:reopen-last-closed', (_event, args: { token: string }) => { 7210 + ipcMain.handle('tile:session:reopen-last-closed', (event, args: { token: string }) => { 7007 7211 if (!args?.token) return { success: false, error: 'Invalid token' }; 7008 - const grant = getGrantForToken(args.token); 7212 + const grant = verifyTokenSender(event, args.token, 'tile:session:reopen-last-closed'); 7009 7213 if (!grant) return { success: false, error: 'Invalid token' }; 7010 7214 if (!grant.trustedBuiltin) { 7011 7215 handleViolation(grant, 'session', 'tile:session:reopen-last-closed', 'trustedBuiltin required', args.token);
+16
backend/electron/tile-launcher.ts
··· 43 43 getGrantForToken, 44 44 revokeTokensForTile, 45 45 clearAllTokens, 46 + setTokenOwner, 46 47 } from './tile-tokens.js'; 47 48 import { scopes, publish, getSystemAddress, unsubscribeAll } from './pubsub.js'; 48 49 import { DEBUG, getTilePreloadPath } from './config.js'; ··· 261 262 262 263 const BrowserWindow = getBrowserWindowCtor(); 263 264 const win = new BrowserWindow(windowOptions); 265 + 266 + // Phase 2 pubsub hardening: bind the capability token to its owning 267 + // WebContents so every `tile:*` IPC handler can cross-check 268 + // `event.sender.id === ownerWebContentsId`. Without this a compromised 269 + // tile that leaks another tile's token could forge IPC frames using 270 + // it. The token was minted before the window existed (it's injected 271 + // via additionalArguments), so binding happens here — the first 272 + // moment `win.webContents.id` is known. 273 + setTokenOwner(token, win.webContents.id); 264 274 265 275 const key = `${tileId}:${entryId}`; 266 276 tileWindows.set(key, win); ··· 537 547 ): void { 538 548 const key = `${tileId}:${entryId}`; 539 549 tileWindows.set(key, win); 550 + 551 + // Phase 2 pubsub hardening: bind the trustedBuiltin token to its 552 + // owning WebContents so the IPC gate's sender-frame cross-check 553 + // passes for this core renderer. See createTileBrowserWindow for 554 + // the full rationale. 555 + setTokenOwner(token, win.webContents.id); 540 556 541 557 win.on('closed', () => { 542 558 tileWindows.delete(key);
+59
backend/electron/tile-tokens.ts
··· 24 24 tileEntryId: string; 25 25 grant: CapabilityGrant; 26 26 createdAt: number; 27 + /** 28 + * Electron `WebContents.id` of the renderer that owns this token. 29 + * 30 + * Set post-hoc — tokens are minted BEFORE the BrowserWindow is 31 + * constructed (the token is injected via `additionalArguments`), so 32 + * the owning wc id is not known at mint time. Callers that have the 33 + * window in hand (`createTileBrowserWindow`, `registerTrustedBuiltinWindow`, 34 + * the `window-open` special-case branches) call `setTokenOwner()` 35 + * right after `new BrowserWindow()`. 36 + * 37 + * For renderers where eager binding isn't possible (e.g. `<webview>` 38 + * guests — `will-attach-webview` doesn't hand back a guest wc id), 39 + * the sender-frame cross-check falls back to trust-on-first-use: 40 + * the first `tile:*` IPC frame seen for a token with no recorded 41 + * owner binds `event.sender.id` atomically; all subsequent frames 42 + * must match. 43 + * 44 + * `undefined` = not yet bound. 45 + */ 46 + ownerWebContentsId?: number; 27 47 } 28 48 29 49 /** Token store: token string -> record */ ··· 76 96 export function getGrantForToken(token: string): CapabilityGrant | null { 77 97 const record = tokenStore.get(token); 78 98 return record ? record.grant : null; 99 + } 100 + 101 + /** 102 + * Bind a token to its owning WebContents id. 103 + * 104 + * Should be called once, immediately after the BrowserWindow (or webview 105 + * guest WebContents) for the token is constructed, by the same code that 106 + * minted the token. Safe to call a second time with the same id 107 + * (idempotent). Calling with a different id after the first binding is 108 + * treated as a drift condition — the caller is re-binding a token that 109 + * belongs to a different frame; we keep the original owner and log. 110 + * 111 + * Returns: 112 + * - `'bound'` — owner was unset; now bound to `webContentsId`. 113 + * - `'already'` — owner was already this id; no change. 114 + * - `'conflict'` — owner was a different id; original preserved. 115 + * - `'unknown'` — no token record exists (caller passed a bad token). 116 + */ 117 + export function setTokenOwner( 118 + token: string, 119 + webContentsId: number, 120 + ): 'bound' | 'already' | 'conflict' | 'unknown' { 121 + const record = tokenStore.get(token); 122 + if (!record) return 'unknown'; 123 + if (record.ownerWebContentsId === undefined) { 124 + record.ownerWebContentsId = webContentsId; 125 + return 'bound'; 126 + } 127 + if (record.ownerWebContentsId === webContentsId) return 'already'; 128 + return 'conflict'; 129 + } 130 + 131 + /** 132 + * Get the owning WebContents id for a token. 133 + * Returns `undefined` if the token doesn't exist OR if the owner hasn't 134 + * been bound yet (trust-on-first-use path — see `setTokenOwner` docs). 135 + */ 136 + export function getTokenOwner(token: string): number | undefined { 137 + return tokenStore.get(token)?.ownerWebContentsId; 79 138 } 80 139 81 140 /**