···11+/**
22+ * Strict Context Capability Enforcement — Pure module (no Electron deps)
33+ *
44+ * Used by the `tile:context:*` IPC handlers in `tile-ipc.ts` to decide
55+ * whether a capability grant may perform a context-store operation.
66+ *
77+ * Mirrors the extracted pure-module pattern from
88+ * `tile-shortcuts-enforcement.ts` so these checks can be unit-tested
99+ * without pulling in `tile-ipc`'s Electron dependencies (ipcMain,
1010+ * BrowserWindow, dialog, webContents, nativeTheme, etc.).
1111+ *
1212+ * See `docs/tile-preload-trimming-plan.md` §2.5 for the capability
1313+ * shape and §6 for the test strategy.
1414+ */
1515+1616+import type { CapabilityGrant, ContextCapability } from './tile-manifest.js';
1717+1818+/**
1919+ * Which context operation is being checked. Matches the
2020+ * `tile:context:*` IPC channel suffix and the shape of the
2121+ * v1-compat `api.context.*` surface in tile-preload.
2222+ *
2323+ * - `get` / `history` — read a key; gated by `read` allowlist.
2424+ * - `set` — write a key; gated by `write` allowlist. Writes to the
2525+ * well-known `mode` key additionally require `modes: true`.
2626+ * - `snapshot` — requires only that the capability is granted.
2727+ * - `windowsWithValue` / `windowsInSpace` — require `queryWindows`.
2828+ */
2929+export type ContextOp =
3030+ | 'get'
3131+ | 'set'
3232+ | 'history'
3333+ | 'snapshot'
3434+ | 'windowsWithValue'
3535+ | 'windowsInSpace';
3636+3737+export type ContextCheckResult =
3838+ | { ok: true }
3939+ | { ok: false; error: string };
4040+4141+/**
4242+ * Normalise a context-capability grant into a `ContextCapability`
4343+ * object shape. `context: true` becomes `{}` (unrestricted). Any
4444+ * falsy/invalid value returns `null` so the caller can short-circuit
4545+ * with "capability not granted".
4646+ */
4747+function normaliseGrant(grant: CapabilityGrant | null): ContextCapability | null {
4848+ if (!grant) return null;
4949+ const cc = grant.capabilities.context;
5050+ if (!cc) return null;
5151+ if (cc === true) return {};
5252+ if (typeof cc === 'object') return cc;
5353+ return null;
5454+}
5555+5656+/**
5757+ * Validate whether a grant may perform a context operation.
5858+ *
5959+ * For key-scoped ops (`get`, `set`, `history`), the `key` argument is
6060+ * required and checked against the relevant allowlist. For the other
6161+ * ops the `key` is ignored.
6262+ *
6363+ * Order of checks:
6464+ * 1. Grant must be non-null (token resolved).
6565+ * 2. `context` capability must be present.
6666+ * 3. Per-op checks:
6767+ * - `get` / `history`: key must be in `read` allowlist when set.
6868+ * - `set`: key must be in `write` allowlist when set. If key is
6969+ * the well-known `mode` string, `modes: true` is additionally
7070+ * required.
7171+ * - `snapshot`: no additional check.
7272+ * - `windowsWithValue` / `windowsInSpace`: require `queryWindows: true`.
7373+ *
7474+ * Rationale for the `mode`-key special case: the `mode` key drives
7575+ * window/space/group routing across Peek and is power-user territory.
7676+ * Tiles that declare `context: { write: ['mode'] }` without
7777+ * `modes: true` are almost certainly doing something unintended; the
7878+ * explicit flag is a belt-and-braces guard.
7979+ */
8080+export function checkContextAllowed(
8181+ grant: CapabilityGrant | null,
8282+ op: ContextOp,
8383+ key?: string,
8484+): ContextCheckResult {
8585+ if (!grant) return { ok: false, error: 'Invalid token' };
8686+8787+ const cap = normaliseGrant(grant);
8888+ if (!cap) return { ok: false, error: 'context capability not granted' };
8989+9090+ switch (op) {
9191+ case 'get':
9292+ case 'history': {
9393+ if (cap.read) {
9494+ if (typeof key !== 'string' || key.length === 0) {
9595+ return { ok: false, error: 'context key required for read operations' };
9696+ }
9797+ if (!cap.read.includes(key)) {
9898+ return { ok: false, error: `context key "${key}" not in read allowlist` };
9999+ }
100100+ }
101101+ return { ok: true };
102102+ }
103103+104104+ case 'set': {
105105+ if (typeof key !== 'string' || key.length === 0) {
106106+ return { ok: false, error: 'context key required for set operations' };
107107+ }
108108+ if (cap.write && !cap.write.includes(key)) {
109109+ return { ok: false, error: `context key "${key}" not in write allowlist` };
110110+ }
111111+ // Belt-and-braces: writing the `mode` key requires the modes flag.
112112+ if (key === 'mode' && cap.modes !== true) {
113113+ return { ok: false, error: 'context.modes capability required to write "mode" key' };
114114+ }
115115+ return { ok: true };
116116+ }
117117+118118+ case 'snapshot': {
119119+ return { ok: true };
120120+ }
121121+122122+ case 'windowsWithValue':
123123+ case 'windowsInSpace': {
124124+ if (cap.queryWindows !== true) {
125125+ return { ok: false, error: 'context.queryWindows capability required' };
126126+ }
127127+ return { ok: true };
128128+ }
129129+130130+ default: {
131131+ // Exhaustiveness guard.
132132+ const _exhaustive: never = op;
133133+ return { ok: false, error: `unknown context op: ${String(_exhaustive)}` };
134134+ }
135135+ }
136136+}
+71
backend/electron/tile-dialogs-enforcement.ts
···11+/**
22+ * Strict Dialogs Capability Enforcement — Pure module (no Electron deps)
33+ *
44+ * Used by the `tile:dialogs:*` IPC handlers in `tile-ipc.ts` to decide
55+ * whether a capability grant may invoke a native file dialog (save/open).
66+ *
77+ * Mirrors the extracted pure-module pattern from
88+ * `tile-shortcuts-enforcement.ts` so these checks can be unit-tested
99+ * without pulling in `tile-ipc`'s Electron dependencies.
1010+ *
1111+ * Background: the v1-compat surface exposed file dialogs via the
1212+ * `file-save-dialog` / `file-open-dialog` IPC channels and the
1313+ * `api.files.save` / `api.files.open` preload wrappers, with NO
1414+ * capability check. The strict handlers gate the same underlying
1515+ * Electron `dialog.show{Save,Open}Dialog` calls behind a declared
1616+ * `dialogs` capability plus an optional per-type allowlist.
1717+ */
1818+1919+import type { CapabilityGrant, DialogType, DialogsCapability } from './tile-manifest.js';
2020+2121+export type DialogsCheckResult =
2222+ | { ok: true }
2323+ | { ok: false; error: string };
2424+2525+/**
2626+ * Normalise a dialogs-capability grant into a `DialogsCapability`
2727+ * object shape. `dialogs: true` becomes `{}` (unrestricted). Any
2828+ * falsy/invalid value returns `null` so the caller can short-circuit
2929+ * with "capability not granted".
3030+ */
3131+function normaliseGrant(grant: CapabilityGrant | null): DialogsCapability | null {
3232+ if (!grant) return null;
3333+ const dc = grant.capabilities.dialogs;
3434+ if (!dc) return null;
3535+ if (dc === true) return {};
3636+ if (typeof dc === 'object') return dc;
3737+ return null;
3838+}
3939+4040+/**
4141+ * Validate whether a grant may show the given dialog type.
4242+ *
4343+ * Order of checks:
4444+ * 1. Grant must be non-null (token resolved).
4545+ * 2. `dialogs` capability must be present.
4646+ * 3. If the capability is `{ types: [...] }`, the requested type
4747+ * must appear in the list.
4848+ *
4949+ * Treats `dialogs: true` and `dialogs: {}` (object without `types`)
5050+ * as unrestricted. Treats `dialogs: { types: [] }` as fully locked —
5151+ * no dialog type is allowed. This matches the manifest author's
5252+ * intent: an explicit empty allowlist means "I declared the
5353+ * capability but block everything".
5454+ */
5555+export function checkDialogAllowed(
5656+ grant: CapabilityGrant | null,
5757+ type: DialogType,
5858+): DialogsCheckResult {
5959+ if (!grant) return { ok: false, error: 'Invalid token' };
6060+6161+ const cap = normaliseGrant(grant);
6262+ if (!cap) return { ok: false, error: 'dialogs capability not granted' };
6363+6464+ if (cap.types) {
6565+ if (!cap.types.includes(type)) {
6666+ return { ok: false, error: `dialog type "${type}" not in capability types allowlist` };
6767+ }
6868+ }
6969+7070+ return { ok: true };
7171+}