experiments in a post-browser web
10
fork

Configure Feed

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

refactor(tile-preload): auto-initialize at module load, remove call-order trap

The preload required every tile entrypoint to call
await api.initialize() before any capability-gated api.method.
Forgetting this was a silent-halt bug class: subscribes no-op'd,
cap checks threw strings that halted module evaluation, and
widgets sat looking fine but dead. We have hit it six times now
(page.js, cmd/panel.js, hud.js, 4 widget tiles) and an audit
surfaced 47 more files that never call initialize at all — every
features background.js, most features home.js, several shared
app/ libs. Fixing them one-by-one is a treadmill.

Root cause: api.initialize was the only call site that kicked
off validateToken, so tokenValid stayed false until the caller
remembered to explicitly await it.

Fix: validation now fires eagerly at preload load (token args
are already on process.additionalArguments, no caller action
needed). Every cap-gated method now awaits the shared
validationPromise internally before doing its work. If
validation fails the method rejects with the existing error.
api.initialize remains as a thin public wrapper so callers
that want to surface init failures explicitly still can — it
just returns the already-running promise.

Method changes (all gated methods converted to async plus await
validationPromise): publishImpl, subscribeImpl, commands register,
commands list, invoke, window surface, datastoreStrict surface,
network fetch, filesystem surface, theme surface, settings get/set
and getExtKey, context surface, izui surface, shortcuts surface,
oauth surface.

subscribeImpl is careful: the local ipcRenderer.on listener
registers synchronously so messages arriving during validation
are not dropped. Upstream tile:pubsub:subscribe defers until
validation completes.

Tests: Module Health 2/2, HUD Extension 10/10, Command Execution
8/8, Cmd State Machine 12/12 all pass. Page Layout 13/14 (one
pre-existing flake unrelated to this change).

+116 -99
+116 -99
backend/electron/tile-preload.cts
··· 48 48 let tokenValid = false; 49 49 let trustedBuiltin = false; 50 50 let grantedCapabilities: Record<string, unknown> = {}; 51 - let validationPromise: Promise<boolean> | undefined; 52 51 53 - function validateToken(): Promise<boolean> { 54 - if (validationPromise !== undefined) return validationPromise; 55 - 56 - const promise = ipcRenderer.invoke('tile:validate-token', { 57 - tileId, 58 - tileEntry, 59 - token: tileToken, 60 - }).then((result: { valid: boolean; capabilities?: Record<string, unknown>; trustedBuiltin?: boolean }) => { 61 - tokenValid = result.valid; 62 - trustedBuiltin = result.trustedBuiltin === true; 63 - if (result.capabilities) { 64 - grantedCapabilities = result.capabilities; 65 - } 66 - return result.valid; 67 - }).catch(() => { 68 - tokenValid = false; 69 - return false; 70 - }); 52 + // Kick off token validation eagerly at module load time. 53 + // Tiles no longer need to call api.initialize() before using cap-gated 54 + // methods — each method awaits this promise internally. api.initialize() 55 + // is kept as a thin backward-compatible wrapper. 56 + const validationPromise: Promise<boolean> = ipcRenderer.invoke('tile:validate-token', { 57 + tileId, 58 + tileEntry, 59 + token: tileToken, 60 + }).then((result: { valid: boolean; capabilities?: Record<string, unknown>; trustedBuiltin?: boolean }) => { 61 + tokenValid = result.valid; 62 + trustedBuiltin = result.trustedBuiltin === true; 63 + if (result.capabilities) { 64 + grantedCapabilities = result.capabilities; 65 + } 66 + return result.valid; 67 + }).catch(() => { 68 + tokenValid = false; 69 + return false; 70 + }); 71 71 72 - validationPromise = promise; 73 - return promise; 72 + function validateToken(): Promise<boolean> { 73 + return validationPromise; 74 74 } 75 75 76 76 // ─── Phase 4: v1-compat violation logger REMOVED ───────────────────── ··· 158 158 // `api.subscribe('editor:open', handler, api.scopes.GLOBAL)`. Scope is only 159 159 // advisory for the client — capability-based enforcement happens in main. 160 160 161 - function publishImpl(topic: string, data: unknown, scope?: number) { 162 - if (!tokenValid) { 163 - console.warn('[tile-preload] publish called before initialize()'); 161 + async function publishImpl(topic: string, data: unknown, scope?: number) { 162 + const valid = await validationPromise; 163 + if (!valid) { 164 + console.warn('[tile-preload] publish called but token validation failed'); 164 165 return; 165 166 } 166 167 ipcRenderer.send('tile:pubsub:publish', { ··· 173 174 } 174 175 175 176 function subscribeImpl(topic: string, callback: (data: unknown) => void, _scope?: number) { 176 - if (!tokenValid) { 177 - console.warn('[tile-preload] subscribe called before initialize()'); 178 - return () => {}; 179 - } 180 - ipcRenderer.send('tile:pubsub:subscribe', { 181 - token: tileToken, 182 - source: sourceAddress, 183 - topic, 184 - }); 185 - 177 + // Register the IPC listener immediately (synchronous) so messages 178 + // received after validation completes are not dropped. 179 + // The upstream tile:pubsub:subscribe is sent only after the token 180 + // validates. If validation fails we tear down the listener. 181 + let unsubscribed = false; 186 182 const handler = (_event: unknown, msg: unknown) => { 187 183 callback(msg); 188 184 }; 189 185 ipcRenderer.on(`pubsub:${topic}`, handler); 190 186 191 - return () => { 192 - ipcRenderer.removeListener(`pubsub:${topic}`, handler); 193 - ipcRenderer.send('tile:pubsub:unsubscribe', { 187 + validationPromise.then((valid) => { 188 + if (!valid) { 189 + console.warn('[tile-preload] subscribe: token validation failed, not subscribing upstream for topic:', topic); 190 + ipcRenderer.removeListener(`pubsub:${topic}`, handler); 191 + return; 192 + } 193 + if (unsubscribed) return; 194 + ipcRenderer.send('tile:pubsub:subscribe', { 194 195 token: tileToken, 195 196 source: sourceAddress, 196 197 topic, 197 198 }); 199 + }); 200 + 201 + return () => { 202 + unsubscribed = true; 203 + ipcRenderer.removeListener(`pubsub:${topic}`, handler); 204 + validationPromise.then((valid) => { 205 + if (!valid) return; 206 + ipcRenderer.send('tile:pubsub:unsubscribe', { 207 + token: tileToken, 208 + source: sourceAddress, 209 + topic, 210 + }); 211 + }); 198 212 }; 199 213 } 200 214 ··· 242 256 } 243 257 244 258 api.commands = { 245 - register: (nameOrCommand: unknown, maybeHandler?: (params: unknown) => unknown) => { 246 - if (!tokenValid) { 247 - console.warn('[tile-preload] commands.register called before initialize()'); 259 + register: async (nameOrCommand: unknown, maybeHandler?: (params: unknown) => unknown) => { 260 + const valid = await validationPromise; 261 + if (!valid) { 262 + console.warn('[tile-preload] commands.register called but token validation failed'); 248 263 return; 249 264 } 250 265 ··· 424 439 * Used by tests (via bgWindow) and by admin UIs that need to 425 440 * inspect the global registry from an outside tile. 426 441 */ 427 - list: () => { 428 - if (!tokenValid) { 442 + list: async () => { 443 + const _valid = await validationPromise; 444 + if (!_valid) { 429 445 return Promise.resolve({ success: false, data: [], error: 'tile not initialized' }); 430 446 } 431 447 ··· 520 536 * declared a `window` capability; otherwise falls back to the 521 537 * un-gated legacy `window-open` channel. 522 538 */ 523 - open: (url: string, options?: Record<string, unknown>) => { 524 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 539 + open: async (url: string, options?: Record<string, unknown>) => { 540 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 525 541 if (hasWindowCapability()) { 526 542 return ipcRenderer.invoke('tile:window:open', { 527 543 token: tileToken, ··· 542 558 * Routes through the strict `tile:window:resize` channel; requires a 543 559 * `window` capability grant (or trustedBuiltin). 544 560 */ 545 - resize: (width: number, height: number) => { 546 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 561 + resize: async (width: number, height: number) => { 562 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 547 563 return ipcRenderer.invoke('tile:window:resize', { 548 564 token: tileToken, 549 565 width, ··· 788 804 * it can only affect the window it runs in. Use for tiles that start 789 805 * hidden at boot (resident: true) and reveal themselves on command. 790 806 */ 791 - showSelf: () => { 792 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 807 + showSelf: async () => { 808 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 793 809 return ipcRenderer.invoke('tile:window:show-self', { 794 810 token: tileToken, 795 811 }); ··· 804 820 * 805 821 * No `window` capability required. The tile's token is the authority. 806 822 */ 807 - hideSelf: () => { 808 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 823 + hideSelf: async () => { 824 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 809 825 return ipcRenderer.invoke('tile:window:hide-self', { 810 826 token: tileToken, 811 827 }); ··· 818 834 * requires `window.manage`. Intended for popup tiles that communicate 819 835 * results back to the window that opened them. 820 836 */ 821 - openerPostMessage: (message: unknown, origin?: string) => { 822 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 837 + openerPostMessage: async (message: unknown, origin?: string) => { 838 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 823 839 return ipcRenderer.invoke('tile:window:opener-postmessage', { 824 840 token: tileToken, 825 841 message, ··· 833 849 * Strict path routes through `tile:window:opener-close` which 834 850 * requires `window.manage`. 835 851 */ 836 - openerClose: () => { 837 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 852 + openerClose: async () => { 853 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 838 854 return ipcRenderer.invoke('tile:window:opener-close', { 839 855 token: tileToken, 840 856 }); ··· 846 862 * Strict path routes through `tile:window:opener-focus` which 847 863 * requires `window.manage`. 848 864 */ 849 - openerFocus: () => { 850 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 865 + openerFocus: async () => { 866 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 851 867 return ipcRenderer.invoke('tile:window:opener-focus', { 852 868 token: tileToken, 853 869 }); ··· 860 876 * capability required — the tile's token is the authority 861 877 * (a tile always knows its own window). 862 878 */ 863 - getId: () => { 864 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 879 + getId: async () => { 880 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 865 881 return ipcRenderer.invoke('tile:window:get-id', { token: tileToken }); 866 882 }, 867 883 868 884 // Legacy alias for getId — several callers still use the older name. 869 - getWindowId: () => { 870 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 885 + getWindowId: async () => { 886 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 871 887 return ipcRenderer.invoke('tile:window:get-id', { token: tileToken }); 872 888 }, 873 889 ··· 877 893 * Routes strictly through `tile:window:get-bounds`. No `window` 878 894 * capability required — a tile always knows its own bounds. 879 895 */ 880 - getBounds: () => { 881 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 896 + getBounds: async () => { 897 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 882 898 return ipcRenderer.invoke('tile:window:get-bounds', { token: tileToken }); 883 899 }, 884 900 ··· 889 905 * Routes strictly through `tile:window:get-display-info`. No `window` 890 906 * capability required — a tile can always query its own display. 891 907 */ 892 - getDisplayInfo: () => { 893 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 908 + getDisplayInfo: async () => { 909 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 894 910 return ipcRenderer.invoke('tile:window:get-display-info', { token: tileToken }); 895 911 }, 896 912 ··· 900 916 * Routes strictly through `tile:window:set-bounds`. No `window` 901 917 * capability required — a tile can always reposition its own window. 902 918 */ 903 - setBounds: (bounds: { x?: number; y?: number; width?: number; height?: number }) => { 904 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 919 + setBounds: async (bounds: { x?: number; y?: number; width?: number; height?: number }) => { 920 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 905 921 return ipcRenderer.invoke('tile:window:set-bounds', { token: tileToken, ...bounds }); 906 922 }, 907 923 }; ··· 916 932 /** 917 933 * Get a value from the tile's scoped storage 918 934 */ 919 - get: (table: string, key: string) => { 920 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 935 + get: async (table: string, key: string) => { 936 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 921 937 return ipcRenderer.invoke('tile:datastore:get', { 922 938 token: tileToken, 923 939 table, ··· 928 944 /** 929 945 * Set a value in the tile's scoped storage 930 946 */ 931 - set: (table: string, key: string, value: unknown) => { 932 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 947 + set: async (table: string, key: string, value: unknown) => { 948 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 933 949 return ipcRenderer.invoke('tile:datastore:set', { 934 950 token: tileToken, 935 951 table, ··· 941 957 /** 942 958 * Query rows from a table 943 959 */ 944 - query: (table: string, filter?: Record<string, unknown>) => { 945 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 960 + query: async (table: string, filter?: Record<string, unknown>) => { 961 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 946 962 return ipcRenderer.invoke('tile:datastore:query', { 947 963 token: tileToken, 948 964 table, ··· 956 972 * from user-owned browsing state and fits the datastore domain. 957 973 * Used by entities to harvest page content for downstream parsing. 958 974 */ 959 - extractPageContent: (url: string) => { 960 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 975 + extractPageContent: async (url: string) => { 976 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 961 977 return ipcRenderer.invoke('tile:datastore:extract-page-content', { 962 978 token: tileToken, 963 979 url, ··· 1084 1100 * Fetch a URL — domain-gated by capability 1085 1101 * The main process validates the domain against the allowlist 1086 1102 */ 1087 - fetch: (url: string, options?: Record<string, unknown>) => { 1088 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1103 + fetch: async (url: string, options?: Record<string, unknown>) => { 1104 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1089 1105 return ipcRenderer.invoke('tile:network:fetch', { 1090 1106 token: tileToken, 1091 1107 url, ··· 1100 1116 /** 1101 1117 * Read a file — path-gated by capability 1102 1118 */ 1103 - read: (filePath: string) => { 1104 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1119 + read: async (filePath: string) => { 1120 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1105 1121 return ipcRenderer.invoke('tile:filesystem:read', { 1106 1122 token: tileToken, 1107 1123 path: filePath, ··· 1111 1127 /** 1112 1128 * Write a file — path-gated by capability 1113 1129 */ 1114 - write: (filePath: string, content: string) => { 1115 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1130 + write: async (filePath: string, content: string) => { 1131 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1116 1132 return ipcRenderer.invoke('tile:filesystem:write', { 1117 1133 token: tileToken, 1118 1134 path: filePath, ··· 1153 1169 * Get current theme settings (id, colorScheme, isDark, effectiveScheme). 1154 1170 * Strict path; requires trustedBuiltin enforcement on the IPC side. 1155 1171 */ 1156 - get: () => { 1157 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1172 + get: async () => { 1173 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1158 1174 return ipcRenderer.invoke('tile:theme:get', { token: tileToken }); 1159 1175 }, 1160 1176 ··· 1162 1178 * Set global color scheme preference (system/light/dark). 1163 1179 * Strict path; requires trustedBuiltin enforcement on the IPC side. 1164 1180 */ 1165 - setColorScheme: (colorScheme: string) => { 1166 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1181 + setColorScheme: async (colorScheme: string) => { 1182 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1167 1183 return ipcRenderer.invoke('tile:theme:setColorScheme', { token: tileToken, colorScheme }); 1168 1184 }, 1169 1185 ··· 1171 1187 * Set color scheme for a specific window only (does not affect global setting). 1172 1188 * Strict path; requires trustedBuiltin enforcement on the IPC side. 1173 1189 */ 1174 - setWindowColorScheme: (windowId: number, colorScheme: string) => { 1175 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1190 + setWindowColorScheme: async (windowId: number, colorScheme: string) => { 1191 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1176 1192 return ipcRenderer.invoke('tile:theme:setWindowColorScheme', { token: tileToken, windowId, colorScheme }); 1177 1193 }, 1178 1194 ··· 1180 1196 * Set the active theme by id. 1181 1197 * Strict path; requires trustedBuiltin enforcement on the IPC side. 1182 1198 */ 1183 - setTheme: (themeId: string) => { 1184 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1199 + setTheme: async (themeId: string) => { 1200 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1185 1201 return ipcRenderer.invoke('tile:theme:setTheme', { token: tileToken, themeId }); 1186 1202 }, 1187 1203 ··· 1189 1205 * List available (builtin) themes with metadata. 1190 1206 * Strict path; requires trustedBuiltin enforcement on the IPC side. 1191 1207 */ 1192 - list: () => { 1193 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1208 + list: async () => { 1209 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1194 1210 return ipcRenderer.invoke('tile:theme:list', { token: tileToken }); 1195 1211 }, 1196 1212 ··· 1198 1214 * Get all themes (builtin + external from DB). 1199 1215 * Strict path; requires trustedBuiltin enforcement on the IPC side. 1200 1216 */ 1201 - getAll: () => { 1202 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1217 + getAll: async () => { 1218 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1203 1219 return ipcRenderer.invoke('tile:theme:getAll', { token: tileToken }); 1204 1220 }, 1205 1221 }; ··· 1222 1238 * error — error string when the call failed, undefined otherwise 1223 1239 */ 1224 1240 get: async (key: string) => { 1225 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1241 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1226 1242 const r = await ipcRenderer.invoke('tile:settings:get', { 1227 1243 token: tileToken, 1228 1244 key, ··· 1238 1254 * so callers that inspect the result get a consistent shape. 1239 1255 */ 1240 1256 set: async (key: string, value: unknown) => { 1241 - if (!tokenValid) return Promise.reject(new Error('Not initialized')); 1257 + const _valid = await validationPromise; if (!_valid) return Promise.reject(new Error('Not initialized')); 1242 1258 const r = await ipcRenderer.invoke('tile:settings:set', { 1243 1259 token: tileToken, 1244 1260 key, ··· 1913 1929 // Two behaviours are possible: 1914 1930 // - trustedBuiltin grant present → forward raw 1915 1931 // - no trustedBuiltin → return failure 1916 - // The boolean is populated by the `tile:validate-token` IPC response, 1917 - // so `api.invoke` must be called after `api.initialize()` resolves 1918 - // (same rule as every other capability API). 1919 - api.invoke = (channel: unknown, data?: unknown) => { 1920 - if (!tokenValid) { 1932 + // The boolean is populated by the `tile:validate-token` IPC response. 1933 + // Validation is now kicked off at module load time; callers no longer 1934 + // need to call api.initialize() before using api.invoke. 1935 + api.invoke = async (channel: unknown, data?: unknown) => { 1936 + const _valid = await validationPromise; 1937 + if (!_valid) { 1921 1938 return Promise.resolve({ 1922 1939 success: false, 1923 1940 error: 'tile not initialized',