curfew-extension

Curfew: Technical Design

   
Status Draft → implementation target for M0-M2
Supersedes PROJECT §4 details (PROJECT stays the working product doc + summary)
Privacy posture Zero install warnings · per-site consent · zero network · local-only
Stack Vanilla ES2022 modules on Chrome MV3 · no build step · zero npm deps

Contents

  1. Goals & non-goals
  2. Technology stack
  3. Repository layout
  4. Manifest
  5. Core module contracts (common/)
  6. Storage design
  7. Permission & consent lifecycle
  8. Time tracking
  9. Enforcement (DNR rules)
  10. UI surfaces & messaging
  11. Security & privacy model
  12. Testing strategy
  13. Service worker lifecycle & performance
  14. Decision log (ADR)
  15. Open questions
  16. Milestone traceability

1. Goals & non-goals

Goals (technical):

Non-goals (v1): path-level rules, incognito coverage, other browsers, blocking chrome:// pages, any server-side component. See PROJECT §3.5 and §5 (M4+).


2. Technology stack

Layer Choice Why Constraint it imposes
Platform Chrome MV3, minimum_chrome_version: "121" DNR dynamic rules mature; 5k dynamic-rule floor is plenty; modern SW behavior No Chromium < 121 support
Language Vanilla JavaScript, ES2022 modules Auditability + longevity (PROJECT principle 2); types via JSDoc where they pay off No TS compiler, no polyfills
Service worker background.type: "module" ES imports of common/* directly Top-level event registration only (no async setup before listeners)
Storage chrome.storage.local, one versioned key Atomic read-modify-write; trivial export 10 MB quota, fine for 60-day rolling usage
Enforcement declarativeNetRequest dynamic rules, redirect → extensionPath Browser-enforced at request boundary; works while SW is dead blocked.html must be web-accessible
Time semantics chrome.idle (60 s) + tab/window events Precise “active time” definition idle permission (silent)
Periodic work chrome.alarms: 5-min tick + one-shot midnight SW-survivable scheduling; exact local-midnight rollover minimum period applies (30 s since Chrome 120; we use 5 min)
Access growth optional_host_permissions + chrome.permissions.request Per-site consent, no install warnings Requests must come from extension pages with user gesture
UI Plain HTML/CSS/JS pages (popup, options, blocked) No framework; each page < 300 LOC Manual DOM code
i18n chrome.i18n, _locales/{en,ru} Store-ready, strings centralized All UI strings via getMessage
Tests node --test (built-in runner), Node ≥ 20 Zero deps; stable test harness Only pure modules are unit-tested
Tooling ESLint flat config + tsc --noEmit over JSDoc (dev-only; no bundler, nothing ships in the package) Principle 2 holds: types are checked, never compiled Discipline: lint + typecheck + invariant tests (§12.3)
Distribution zip of the repo (M3), Chrome Web Store n/a Store review: §11.3 checklist

Explicitly banned (invariant-tested, §12.3): fetch, XMLHttpRequest, WebSocket, remote <script>, remote import, analytics, error reporting.


3. Repository layout

curfew-extension/
  manifest.json
  docs/
    TECHNICAL_DESIGN.md       # this document
  icons/                      # generated by `npm run gen:icons` (scripts/gen-icons.mjs)
  _locales/
    en/messages.json
    ru/messages.json
  src/
    service-worker.js         # event wiring only; logic delegated to common/
    blocked.html/.js/.css     # the Curfew page (web-accessible, single resource)
    popup.html/.js/.css       # dashboard + quick actions
    options.html/.js/.css     # budgets editor, export/import, data controls
    common/
      time.js                 # day key, midnight math, clamps (pure)
      patterns.js             # pattern grammar: parse/match/mappings (pure)
      limits.js               # every tunable number, one copy (pure)
      budget.js               # decide, state machine, usage rows, passes (pure)
      status.js               # describeItem: the ONE per-site status (pure)
      tracking.js             # the accounting entry points, clock-injected (pure)
      view.js                 # popup presentation math: live countdown (pure)
      ops.js                  # the mutation vocabulary the SW applies (pure)
      rules.js                # desired DNR rule set (pure projection)
      transfer.js             # export/import envelope, day pruning (pure)
      puzzle.js               # 15-puzzle: solvable shuffle, moves (pure)
      equation.js             # bracketed equation generator + evaluator (pure)
      storage.js              # the ONLY module that touches chrome.storage
  src/service-worker.js       # composition root: listeners + boot only
  src/sw/                     # worker-private modules (may write the document)
    reconciler.js             # DNR rules + alarms projection
    tracker.js                # the tracking loop (probe -> tracking.js)
    messaging.js              # every page message, applied in the serial queue
    probe.js                  # active tab / focus / idle facts
  src/protect.js              # shared challenge dialogs + guarded() for pages
  src/ui/                     # page-side helpers (chrome.i18n, permissions)
    i18n.js                   # msg() + applyI18n()
    access.js                 # requestAccess / syncAccessFlags / grantItem
    site-form.js              # addSite(): parse, clamp, write, grant
  tests/                      # node --test, mirrors common/ + invariant tests
    time.test.js
    patterns.test.js
    budget.test.js
    storage.test.js           # runs against a fake chrome.storage shim
    invariants.test.js        # security posture tests (§12.3)
  package.json                # { "scripts": { "lint": "eslint src tests", "test": "npm run lint && node --test tests/*.test.js" } }
  eslint.config.js            # dev-only linter (flat config)
  README.md

Rules:


4. Manifest

{
  "manifest_version": 3,
  "name": "Curfew",
  "version": "1.0.0",
  "minimum_chrome_version": "121",
  "default_locale": "en",

  "permissions": [
    "storage",
    "idle",
    "alarms",
    "declarativeNetRequestWithHostAccess",
    "contextMenus"
  ],
  "optional_host_permissions": ["*://*/*"],
  "incognito": "not_allowed",

  "background": { "service_worker": "src/service-worker.js", "type": "module" },
  "action": { "default_popup": "src/popup.html" },
  "options_page": "src/options.html",

  "web_accessible_resources": [
    { "resources": ["src/blocked.html"], "matches": ["*://*/*"] }
  ]
}

Key-by-key rationale:

Key Rationale
permissions All five are silent (no install warning). No tabs, no static host_permissions, no webNavigation/scripting/cookies/notifications.
declarativeNetRequestWithHostAccess Chosen over declarativeNetRequest (the latter triggers an install warning for implicit block-anywhere power). With host-access variant, rules act only on hosts the user granted, which is exactly our consent model.
optional_host_permissions: ["*://*/*"] Declares nothing granted, only what may be asked. Each permissions.request shows its own per-site prompt. Nothing at install.
contextMenus Silent. Powers the right-click “Add this site to Curfew”, the reliable way to learn the current site.
incognito: "not_allowed" Privacy posture: the extension literally cannot run or see incognito. Trade-off (incognito = escape hatch) is already accepted in PROJECT §3.5.
web_accessible_resources Required for DNR redirect to an extension path. Exactly one resource exposed; it ships bundled JS only, no remote anything.
no declarative_net_request key We use dynamic rules only; that manifest key is for static rulesets.

5. Core module contracts (common/)

All modules are pure ES modules: no chrome.*, no DOM, no I/O. Deterministic in → deterministic out. storage.js is the sole exception (thin async adapter over chrome.storage.local), and it is tested against a shim.

5.1 time.js: calendar semantics

/** Local calendar day key, "YYYY-MM-DD". */
export function dayKey(date = new Date())

/** Next local midnight as a Date (for the one-shot alarm). */
export function nextLocalMidnight(now = new Date())

/** Milliseconds elapsed between two epoch values, clamped at ≥ 0. */
export function elapsedMs(fromEpochMs, toEpochMs)

/** Cap helper for SW-restart recovery. */
export function capMs(ms, maxMs)

DST/timezone: day key is derived from local wall-clock at call time; the midnight alarm is rescheduled after every fire, so DST shifts self-correct. Travel across timezones can only shorten “today”. This is accepted in PROJECT §4.2.

5.2 patterns.js: the pattern grammar (single source of truth)

Grammar (PROJECT §3.1): hostname with optional leading *.; no port/path.

/** "x.com" -> {wildcard:false, host:"x.com"};  "*.x.com" -> {wildcard:true,...}
 *  Returns {ok:false, error} for anything else (ports, paths, schemes). */
export function parsePattern(input)

/** Pure grammar decision. wildcard: host === apex or any subdomain.
 *  exact: host === apex only. */
export function matchesHost(pattern, host)

/** Mapping for chrome.permissions.request (browser match-patterns).
 *  "*.reddit.com" -> ["*://*.reddit.com/*"]     (apex + subdomains)
 *  "x.com"        -> ["*://x.com/*"]            (exact host only)  */
export function toMatchOrigins(pattern)

/** Mapping for DNR rule conditions.
 *  wildcard -> { urlFilter: "||reddit.com/" }   (apex + subdomains)
 *  exact    -> { regexFilter: "^https?://x\\.com/" }  (exact, both schemes) */
export function toDnrCondition(pattern)

Why two mappings differ: browser match-patterns and DNR urlFilter anchors have different subdomain semantics; patterns.js normalizes both so that the user-visible grammar (what the budget covers) is identical for consent prompt, tracking, and enforcement. This module carries the densest test suite: it is the contract between three Chrome subsystems.

5.3 budget.js: state decisions

/** "open" | "closed" for an item given today's usage.
 *  closed ⇔ enabled && masterEnabled && secondsUsed ≥ budgetMinutes*60. */
export function decide(item, secondsUsed, masterEnabled)

/** Pure reducer for the tracking state machine (§8): given previous state
 *  + an input event, produce next state + side effects description. */
export function transition(state, event, config, nowMs)

transition returns a plain description of effects ({count: ms} | {startGrace} | {discard}): the SW interprets it against real APIs. This keeps the entire state machine unit-testable without chrome mocks.

5.4 storage.js: the persistence adapter

export async function load()              // -> full state, migrated to current schema
export async function update(mutatorFn)   // read → mutate → write under one key (SW only)
export async function mutate(op, payload) // page-side write: sends state:apply to the SW
export function onChanged(handler)        // wraps chrome.storage.onChanged

Version-scoped key. Documents live under curfew:v<schema>; the bare curfew key is read once (that is where every build up to schema 2 left its document) and never written again. A page left over from an older build keeps reading and writing curfew (its own idea of the document) and can no longer fight the current build for the same bytes. That is not hypothetical: a stale page from the pre-rename build rewrote the whole document on every render and reset a field install’s sites, passes and protection.

Invariants: every write is a whole-state write (values are small, §6.3); writes are skipped when nothing actually changed (prevents write spam and spurious onChanged events); migration function per schema version, pure and tested: MIGRATIONS[1] renames the v1 pass vocabulary to v2 (unblockMinutespassMinutes, unblockUntilpassUntil, usage.days[].unblocks.passes).

Single writer. chrome.storage has no transactions, so two contexts doing read-modify-write on the whole document can lose each other’s fields (a page toggling a checkbox while the SW credits a tick would drop the credit). Pages therefore never write: they send {type: "state:apply", op, payload} and the service worker applies the named op (common/ops.js) inside its serialized mutation queue (§8.5). An invariant test (§12.3) fails the build if any other src/ file touches chrome.storage.local or imports the low-level update().

5.5 theme.css: one palette, three surfaces

Every colour lives in src/theme.css as a custom property (dark by default). System is the real default and needs no JavaScript: the prefers-color-scheme media query answers it before any script runs, so a light-mode user never sees a dark flash. An explicit choice in Settings sets data-theme on <html> (ui/theme.js), which wins over the system.

The challenge dialogs in protect.js reference the same variables instead of hardcoded hex, so the equation and the 15-puzzle follow the choice too.

5.6 ops.js: the mutation vocabulary

applyOp(state, op, payload) is the complete set of state mutations, pure and unit-tested: item.add, item.update (enabled / budgetMinutes / access only), item.accessBatch, item.remove, master.set, passes.set, protection.set, state.import (migrated and pruned by the SW, never trusted raw). Each returns a JSON-serializable result: the page gets the created item’s id back from item.add. Unknown ops throw.

5.7 rules.js: enforcement projection

/** No-rule-means-open, from the enforcement point of view. Precedence:
 *  access-denied -> open; an ACTIVE UNBLOCK WINDOW wins over everything
 *  (the user's explicit "stay anyway" from the wall); then a day override
 *  for TODAY (only "block" is produced by the UI; "allow" is retained
 *  for import compatibility); then the budget decision. */
export function isOpen(state, item, day, nowMs)

/** The desired set of dynamic DNR rules: pure projection of storage.
 *  blockedPageFor(host) lets the SW inject the domain query (Q1). */
export function desiredRules(state, { day, nowMs, blockedPageFor })

5.8 transfer.js: export/import + retention

/** One-file export: { kind, version, exportedAt, state } (§3.6). */
export function encodeExport(state, exportedAt)

/** Parse + validate + migrate an export into importable state. Machine-local
 *  fields (session, pass windows, day overrides) are never imported. */
export function decodeExport(text)   // -> { ok, state } | { ok: false, error }

/** Keep only the newest `keep` day rows (rollover + import retention). */
export function pruneDays(state, keep)

5.9 tracking.js: the accounting entry points

/** One environment observation: wake recovery + transition + credit guards. */
export function trackEnvironment(state, event, nowMs, { fromWake, maxCreditMs })
/** One tick/flush of the accruing session. */
export function trackTick(state, nowMs, { maxCreditMs })
/** Drop expired pass windows. */
export function pruneRuntime(state, nowMs)
/** Close the local day once (prune + clear runtime). */
export function applyRollover(state, nowMs, keepDays)

Clock-injected and pure: the service worker owns the browser APIs and passes Date.now(), so a whole day (worker deaths, ticks, midnight, tab switches) is replayed in tests/tracking.test.js without a browser. Each entry point mutates the document (the update() mutator convention) and RETURNS the credits that landed, so the tests assert the accounting instead of guessing.


6. Storage design

6.1 Schema v2 (full)

Extends PROJECT §4.2: this section is authoritative.

{
  "schema": 2,
    "config": {
      "masterEnabled": true,
      "graceSeconds": 10,
      "passMinutes": 15,
      "passesPerDay": 3,
      "items": [
      {
        "id": "u1",                    // stable short id, generated once
        "ruleId": 1001,                // DNR dynamic rule id, from itemSeq
        "pattern": "*.reddit.com",     // grammar: §5.2
        "budgetMinutes": 30,
        "sessionLimitMinutes": 0,      // anti-infinite-scroll cap; 0 = off (§9.6)
        "cooldownMinutes": 0,          // break after a long session; 0 = off
        "enabled": true,
        "access": "granted"            // granted | denied (§7)
      }
    ]
  },

  "usage": {
    "days": {
      "2026-09-02": {
        "patternSeconds": { "*.reddit.com": 772 },
        "passes": { "*.reddit.com": 1 },   // per-pattern (dashboard-ready)
        "bySite": { "reddit.com": 772 }      // resolved apex for display
      }
    }
  },

  "session": {                         // SW-restart recovery (§8.4)
    "pattern": "*.reddit.com",
    "phase": "counting",               // "grace" | "counting"
    "phaseStartedAt": 1725273600000,   // epoch ms
    "lastTickAt": 1725273650000,
    "activeMs": 0                      // credited ms in THIS unbroken session (§9.6)
  },

  "runtime": {                         // pass windows, cooldowns, overrides, rollover
    "passUntil": { "*.reddit.com": 1725274500000 },
    "cooldownUntil": { "*.x.com": 1725274800000 },
    "dayOverrides": { "*.x.com": { "day": "2026-09-02", "action": "block" } },
    "lastRolloverDay": "2026-09-02"
  },

  "settings": {
    "version": 1, "itemSeq": 1000,
    "protection": { "kind": "equation" },
    "theme": "system"                 // "system" | "light" | "dark" (§10)
  }
}

6.2 Invariants

6.3 Quota math

A day row with 5 patterns ≈ 300 bytes → 60 days ≈ 18 KB. Three orders of magnitude under the 10 MB quota. Whole-state writes are therefore safe and keep the adapter trivial.


install ──► nothing granted, zero warnings
   │
   │  user adds site (popup "Add current tab" or options form)
   ▼
permissions.request({origins: toMatchOrigins(pattern)})
   │                    │
   granted              denied
   │                    │
   ▼                    ▼
item.access="granted"  item.access="denied"
tracking + DNR armed   kept in config, badge "no access",
                       nothing tracked, nothing blocked
   │
   │  chrome.permissions.onRemoved (revoked in chrome://extensions)
   ▼
item.access="denied"; remove its DNR rules; keep historical rows

Rules and mechanics:

Lesson (verified against docs + empirics): opening the popup is NOT an activeTab-invoking gesture. Per the activeTab docs, the permission is granted by: executing an action (only when NO popup is declared), executing a context menu item, a commands shortcut, or an omnibox suggestion. Therefore the popup can never read the URL of an ungranted tab. The first-time add flow goes through the page context menu instead: the menu click delivers info.pageUrl to the SW with zero host access, the item is created immediately (access “denied”), and the options page opens pre-filled (?add=<host>) where the user grants access with one click.


8. Time counting

8.1 Definition of “active time”

Time counts iff: the tab is active in its window AND the window is focused AND the browser is not idle (≥ 60 s). URL visibility comes from the per-site grant: ungranted hosts are invisible and therefore never counted.

8.2 State machine (pure, in budget.js)

                 ┌────────────────────────────────────────────┐
                 │ IDLE/PAUSED: no granted pattern active,    │
                 │ window unfocused, or browser idle          │
                 └──────┬─────────────────────────────────────┘
                        │ granted pattern becomes active
                        ▼
                 ┌────────────────┐  grace elapsed   ┌───────────────┐
                 │ GRACE (10 s,   │ ───────────────► │ COUNTING      │
                 │ not counted)   │                  │ (accumulates) │
                 └──────┬─────────┘                  └──────┬────────┘
                        │ leave before grace:               │ leave/idle/blur:
                        ▼ discard elapsed                   ▼ flush elapsed
                              back to IDLE/PAUSED ◄─────────┘

Grace (default 10 s, configurable) absorbs accidental navigations. Note: grace never shields an already-closed site: DNR fires before any page loads (§9), so there is nothing to “grace” into.

Promotion is a first-class step (promoteGrace()): whenever any event (tick, same-pattern event, departure, or SW wake) observes a GRACE session whose window has already elapsed, it is promoted to COUNTING with lastTickAt backfilled to the end of the grace window. Promotion runs before the pattern branch, so a visit that ends before the next tick (open a site, read for 40 s, switch away) still credits its post-grace part instead of being discarded whole. The promoting tick credits that remainder in the same step, so the dashboard is truthful on the first flush after a short visit.

8.3 Inputs → effects (SW wiring)

Event Effect
tabs.onActivated re-evaluate active tab → transition
tabs.onUpdated (status/url of active tab) pattern entered/left → transition
windows.onFocusChanged focus lost → pause; focus gained → re-evaluate
idle.onStateChanged (idle 60 s) locked/idle → pause; active → re-evaluate
alarms tick (5 min) flush + open-tab budget re-check (§8.5)
alarms midnight (one-shot) day rollover (§8.6)

8.4 SW-restart recovery

The SW can die any time; correctness must not depend on it staying alive.

8.5 Open-tab re-check (the “sat past the budget” gap)

The tick alarm queries the active tabs (URLs readable only for granted hosts, exactly the ones we track), recomputes decide() per item, and if an open tab’s budget is exhausted: installs the DNR rule (§9.2) and redirects that tab via chrome.tabs.update (no permission required per tabs API).

The 5-min tick alone leaves the dashboard stale for up to 5 minutes, which reads as a frozen counter (“stuck at 1 min”) while the wall is already due. So every surface that shows a number flushes first: popup.js and blocked.js send {type: "flush"} on open, which runs the same flushTickreconcilebounceClosedTabs path as the tick. The counter the user reads and the rule the browser enforces are therefore the same snapshot. All state mutations in the SW are serialized through one promise chain (serial()), because chrome.storage has no transactions and two overlapping read-modify-write cycles would otherwise drop a credit.

8.6 Day rollover

Two alarms:

A lazy rollover also happens in tick if the midnight alarm was missed (e.g. the browser was off at midnight): first flush of a new day key does the same work.


9. Enforcement (DNR rules)

9.1 Rule anatomy (one dynamic rule per closed item)

{
  "id": 1001,                        // 1000 + numeric suffix of item id
  "priority": 1,
  "action": {
    "type": "redirect",
    "redirect": { "extensionPath": "/src/blocked.html" }
  },
  "condition": {
    "urlFilter": "||reddit.com/",    // from toDnrCondition(); or regexFilter
    "resourceTypes": ["main_frame"]
  }
}

Loop safety: the condition matches only the site’s host; blocked.html is a chrome-extension:// URL and can never match. No allow-rules are needed: absence of a rule = open site (positivity is structural).

9.2 Lifecycle

Trigger Action
decide() flips to closed (tick, flush, event, “block now”) add rule
midnight / item disabled/deleted remove rule
pass window opens (“stay anyway”) rule absent while window open; one-shot alarm re-adds at exact expiry; every tick self-heals if the alarm was missed
extension updated/reloaded getDynamicRules() diff vs desired set → reconcile (idempotent, never assume)
worker boot the same reconcile runs as soon as the worker starts, so a rule left over from the previous browser session is removed BEFORE a restored tab can hit it
a wall that outlived its rule the wall re-evaluates closeReason() on every storage change and leaves by itself, but only once getDynamicRules() proves the rule is gone (navigating into a live rule bounces straight back: a site <-> wall flicker). Bounded retry (~12 s), then the clickable link stays. It also states WHY it is up (budget vs cooldown vs “block now”)

Reconciliation rule: the SW always computes the desired rule set from storage and diffs it against chrome.declarativeNetRequest.getDynamicRules(). Storage is the source of truth; rules are a projection. This makes crashes and missed events self-correcting.

9.3 Pattern → condition mapping

patterns.toDnrCondition() (§5.2): wildcard → urlFilter "||domain/"; exact → regexFilter "^https?://domain\.com/". Both schemes covered; both forms tested against tricky hosts (||x.company/ must not match x.company.example).

9.4 Pass window (“stay anyway”)

9.6 Session limit & cooldown (anti-infinite-scroll)

The daily budget bounds the total, but the stated problem is the infinite scroll: a single unbroken session. Two per-item numbers address it:

tracking.js (applySessionLimit) starts the cooldown when the counting session has ACCUMULATED its limit of credited time (session.activeMs, grown by every booked credit), sets runtime.cooldownUntil[pattern] and nulls the session. Credited time, not wall clock: a machine sleep or a closed browser would otherwise look like a 10-minute scroll the moment the user came back. The SW arms a one-shot cooldown:<ruleId> alarm that re-opens the site at the deadline; pruneRuntime drops expired entries and applyRollover clears them at midnight.

Precedence in isOpen (§5.6): a cooldown closes the site even under budget, and only two things lift it: an explicit pass (the user decided to stay) or an allow day override. Overshoot past the limit is booked honestly rather than truncated, so the dashboard still tells the truth. nextExhaustionAt returns the earlier of budget exhaustion and the session limit, so the same one-shot alarm lands the wall on time.

9.5 Blocked page domain resolution (M1 implementation)

Q1 is resolved at runtime by feature detection: the SW builds rules with extensionPath: "/src/blocked.html?domain=<host>" first; if updateDynamicRules rejects the rule, it flips to the bare path and reconciles again (degraded = generic wall, cosmetic only). The detection is per-SW-lifetime; a later reconcile re-checks after every restart. The blocked page falls back to the generic wall whenever ?domain= is absent.


10. UI surfaces & messaging

Surface Responsibilities Talks to
Popup today’s total, top domains, per-item remaining bars, block now, remove (gated) storage reads + runtime.sendMessage
Options budgets editor (pattern + minutes + enabled + access badge), export/import, “wipe all data”, master switch storage
Blocked page domain (when resolvable, §9.5), time spent today, “stay anyway” inside the window with honest counter storage reads + one message

Message protocol (SW wakes on runtime.onMessage; no persistent connection):

Message From Effect
{type: "blockNow", itemId} popup mark closed now → reconcile rules + redirect open tabs
{type: "pass:request", itemId} blocked page passes-limit check + policy (§9.4) → open window (no challenge: planned use)
{type: "flush"} any page force usage flush

UI refresh: pages subscribe to storage.onChanged; no polling, no push from SW needed.


11. Security & privacy model

11.1 Hard invariants (each enforced by a test, §12.3)

  1. Manifest permissions match the allowlist exactly; additions fail CI.
  2. No fetch(/XMLHttpRequest/WebSocket/remote import/remote <script src> anywhere in the repo.
  3. web_accessible_resources exposes exactly src/blocked.html.
  4. common/ contains zero chrome.* references (purity + testability).
  5. MV3 default CSP stays untouched (it already forbids remote code).

11.2 What the extension can and cannot see

   
Can see URLs (and time on them) of granted hosts only, while the grant exists; its own storage
Cannot see other sites’ URLs, history, cookies, page content (no scripting), network traffic, incognito (not_allowed)
Sends nothing, ever. The only bytes out are a manual export file

11.3 Store review self-check (feeds M3)


12. Testing strategy

12.1 Static checks

npm test runs, in order: eslint src tests; tsc -p tsconfig.json (allowJs + checkJs + noEmit: the JSDoc annotations and the CurfewState typedef in common/storage.js are the schema’s executable documentation, and nothing is ever emitted); then the unit tests.

12.2 Unit tests (node --test, no deps)

Suite Covers
time.test.js day key across month/year boundaries, DST Sunday, midnight math, clamps
patterns.test.js grammar (reject ports/paths/schemes), apex-vs-subdomain table, both mappings, hostile hosts (x.company vs x.company.example)
budget.test.js open/closed matrix, full state-machine transitions incl. grace edges, recovery crediting with caps
storage.test.js load/update/migrations against an in-memory chrome.storage shim

12.3 Invariant tests (the security posture as code)

invariants.test.js reads the repo itself and asserts §11.1 items 1-5. These are ordinary node --test files (no tooling needed), and they are the mechanism that keeps the “zero network, minimal permissions” promise from eroding.

12.4 Manual MV3 checklist (per milestone, not automatable)

# Scenario Expected
1 install → chrome shows no warnings §4 manifest
2 add site → single per-site prompt; deny → “no access” badge, no tracking §7
3 browse granted site 10 min with budget 5 wall appears at ~5 min, no site flash
4 budget exhausted while tab stays open wall lands within ≤ 5 min tick (§8.5)
5 stay anyway → counter; window end → wall returns exactly §9.4
6 system sleep 1 h on a granted site recovery credits ≤ 6 min, not the sleep (§8.4)
6b computer off overnight with the wall open morning: ≤ 6 min for yesterday, 0 for today, limits refreshed (§8.4, §8.6)
7 local midnight with browser open rules removed, day resets (§8.6)
8 browser off across midnight → open browser on site first tick rolls the day, site open
9 revoke grant in chrome://extensions mid-session rules dropped, tracking stops (§7)
10 SW kill (chrome://serviceworker-internals) mid-count no double counting after restart (§8.4)

13. Service worker lifecycle & performance


14. Decision log (ADR)

# Decision Alternatives considered Why this one
D1 DNR dynamic rules for enforcement tabs.update loop; webNavigation + redirect Request-level, SW-sleep-proof, no flash; cost is one web-accessible file
D2 Per-site optional host grants tabs permission; static <all_urls> Zero install warnings; browser-enforced scoping; revocable; matches the “chosen places” curfew metaphor
D3 No webNavigation permission listen to committed navigations DNR + tabs.onUpdated cover top-frame needs; fewer permissions
D4 Vanilla JS + no build TypeScript, bundlers Auditability/longevity (PROJECT principle 2); JSDoc where useful
D5 node --test jest/vitest Zero dependencies; stable runner in Node ≥ 20
D6 One versioned storage key many keys Atomic read-modify-write; export = copy one value
D7 Two alarms (tick + midnight) tick-only with lazy rollover Exact rollover at local midnight; lazy path kept as backstop
D8 Grace 10 s, not counted grace 0 Accidental navigations must not burn budget; configurable
D9 Pass = remove rule + timed re-add allow-rule override with priority Fewer live rules; expiry is exact; self-heals on tick
D10 Persist session for SW recovery in-memory accumulator only SW death must not double-count or lose count (§8.4)
D11 incognito: "not_allowed" spanning/split Cannot see incognito = strongest honest privacy claim; escape hatch accepted (PROJECT §3.5)
D12 declarativeNetRequestWithHostAccess declarativeNetRequest (static warning-free block power) Rules act only on granted hosts; matches consent model; no install warning

15. Open questions

# Question Owner Resolution path
Q1 Does DNR extensionPath tolerate query/hash (?domain=)? M1 5-min real-browser check; fallback = generic wall (§9.5)
Q2 Exact-domain regexFilter performance on cold start M1 Non-issue expected (< 1000 regex rules); revisit only if measurable
Q3 Firefox port: optional_host_permissions equivalents, DNR parity M4 Spike deferred; grammar/mappings in patterns.js are designed to port
Q4 Path-level rules (github.com/...) M4 Requires rethinking consent mapping (one origin per pattern); keep out of v1
Q5 Does permissions.request survive being called from the popup? (popups close on focus loss, and the consent dialog takes focus) M1 Verify in real browser; fallback = consent flow lives in the options tab (a full page cannot blur away), popup deep-links to it

16. Milestone traceability

Milestone Sections of this doc
M0 skeleton §3, §4, §6, §7, §12.3 (invariants from day one)
M1 tracker + quota + interstitial §5, §8, §9, §12.4 (scenarios 1-6, 10)
M2 dashboard + export/import §6, §10, §12.4 (7-9)
M3 store package §11.3, PROJECT §7