feat(permissions): Phase 3 — persist per-origin decisions
Builds on Phase 2's prompt UI. The user can now check "Remember this
decision" alongside Allow/Deny; the choice is persisted to the
`feature_settings` table and applied automatically on subsequent
permission requests for the same `(origin, permission)` pair — no
re-prompt.
New module: `backend/electron/permission-store.ts`
* Pure SQL wrapper around `feature_settings` with featureId=
`page-permissions`, key=`{permission}:{origin}`, value=JSON
`{allowed, timestamp}`. No electron imports — exposes a
`PermissionDb` interface so unit tests pass an in-memory
better-sqlite3.
* Operations: `getDecision`, `setDecision`, `forgetDecision`,
`forgetAllDecisions`, `listDecisions`. Phase 4 settings UI will
consume `listDecisions` + `forgetDecision` to render the revocation
page.
* `listDecisions` splits the stored key on the FIRST colon to
recover (permission, origin) — handles origins that themselves
contain colons (e.g. `https://a.com:8080`). Malformed JSON entries
are skipped silently rather than throwing.
`permission-handler.ts` integration:
* Stored-decision lookup happens AFTER `resolveDecision` returns
`'prompt'` and BEFORE the deferred-callback flow. Stored decisions
only override `'prompt'` — never `'allow'` (chrome-extension://,
peek://) or `'deny'` (unknown/risky permissions). A site that's
hard-allowed by policy never gets a stale stored deny applied; a
site that's hard-denied never gets a stale stored allow.
* `lookupStoredDecision()` wraps `getDb()` in try/catch — permission
requests can fire before the datastore is fully initialized
(e.g. an extension's background page during early app boot); a
miss falls through to the prompt UI rather than crashing.
* `persistStoredDecision()` is called only when the response includes
`remember:true` AND the origin isn't synthetic (`(unknown)`) — no
point persisting decisions for origins that can't match a future
request.
Renderer UI (app/page/page.js + index.html):
* `.permission-prompt-remember` — a labeled checkbox between the
message and the action buttons. Default unchecked.
* `respond()` includes `remember: rememberCheckbox.checked` in the
`page:permission-response` publish.
Tests:
* `backend/electron/permission-store.test.ts` (NEW, 17/17) — covers
every operation: getDecision returns null/value, distinguishes by
both origin AND permission, handles malformed JSON, setDecision
overwrites + uses provided timestamp, forgetDecision removes only
the targeted entry, listDecisions parses keys correctly (incl.
colon-bearing origins) and skips malformed entries, forgetAll
clears page-permissions but preserves other featureIds.
* `tests/desktop/permission-prompt.spec.ts` (was 2/2, now 3/3) —
new "Remember-this-decision" test opens a page-host, denies a
geolocation request with the checkbox ticked, opens a SECOND
page-host on the same origin, fires geolocation again, and
asserts (a) the promise rejects with PERMISSION_DENIED and (b) NO
`.permission-prompt` element is rendered.
* Regression: page-host-fsm 5/5; full unit suite 1706 (electron-as-
node) + 641 (plain node), 0 failures.
Tasks doc updated; Phase 4 (settings UI for revocation + favicon
polish + multi-prompt queueing UX) is the next slice.