| 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 |
common/)Goals (technical):
node --test.Non-goals (v1): path-level rules, incognito coverage, other browsers,
blocking chrome:// pages, any server-side component. See PROJECT §3.5 and
§5 (M4+).
| 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.
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:
common/* must not reference chrome.* (grep-tested). All chrome access
lives in service-worker.js, page scripts, and storage.js’s thin adapter.service-worker.js is wiring: subscribe → read state → call common/*
→ persist / mutate DNR rules. Target < 400 LOC.{
"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. |
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.
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.
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.
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.
storage.js: the persistence adapterexport 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 (unblockMinutes → passMinutes, unblockUntil →
passUntil, 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().
theme.css: one palette, three surfacesEvery 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.
ops.js: the mutation vocabularyapplyOp(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.
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 })
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)
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.
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)
}
}
days is
pruned to the newest 60 entries on rollover and on import.session is always consistent with the last flush; a missing session
means “nothing was being counted”.runtime.passUntil entries in the past are garbage-collected on every
tick.passUntil
entry removal; historical usage rows stay (they are facts, not config).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:
permissions.request must be called from an extension page with a user
gesture: popup/options are exactly that; this is by design (§4.3 README).
Caveat: whether the popup survives the consent dialog is an open question
(Q5) with a safe fallback in the options tab.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.
tabs permission: clicking the toolbar
icon opens the popup, which grants activeTab for that tab, so the popup
can read its URL once, prefill the form, and then request the host grant.onAdded/onRemoved listeners keep item.access in sync with reality;
storage is the source of truth for UI, the permissions API for fact.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.
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.
| 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) |
The SW can die any time; correctness must not depend on it staying alive.
session, §6.1) is persisted on every phase
change and on every flush.session; a GRACE session
past its window is promoted first (§8.2), then if phase is counting,
the SW credits now − lastTickAt capped at 6 min (tick interval +
slack) to the pattern (this absorbs long sleeps without crediting hours
of absence), then resumes from now.capCredits caps each credit at 6 min,
and a credit whose window spans local midnight is cut at the boundary
(splitCreditsAtMidnight) and booked to each day separately. Dropping it
whole (the original rule) lost up to a tick of real usage. Normal ticking
never spans midnight: each 5-min tick is credited to its own day.elapsedMs clamps at 0.runtime.onStartup): pending credit from the
previous run is discarded: the browser was closed, so crediting it
would be phantom time. Tracking resumes fresh (through grace) if a
restored tab sits on a granted pattern. The 6-min cap applies only to
SW death within a running browser (manual checklist #6 vs #8,
§12.4).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
flushTick → reconcile → bounceClosedTabs 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.
Two alarms:
tick (periodInMinutes: 5): flush, re-check, GC of runtime.midnight (one-shot when: nextLocalMidnight()): closes the day. It
prunes days to 60, removes DNR rules for all closed-today items (the
site is open again by default), and reschedules itself. DST self-corrects
because the next value is recomputed from wall clock at every fire.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.
{
"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).
| 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.
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).
runtime.passUntil[pattern] = now + passMinutes,
increments today’s passes[pattern], removes the rule, schedules the
re-add alarm.resolvePassRequest):
a stale wall tab or a double click on a site that is already open (live
window, allow override, or allowance left) returns ok without burning
a pass. Only a genuinely closed site spends one.config.passesPerDay (absolute; default 3,
0 = none) is checked in the SW (passesLeftToday); it is a permissive
relaxation, so the options input is challenge-gated too.isOpen()
enforces the effective budget: budgetMinutes + passes × passMinutes
(effectiveBudgetSeconds, via passBonusSeconds). So a burned pass keeps
the site open after its 15-minute window expires, until the extended
allowance is actually spent. The popup, the bar and the wall show that same
effective budget (a real bug: enforcement used the base budget while the
dashboard promised the extended one, so two passes on a 25-min limit showed
~31 min left and then closed on the spot).The daily budget bounds the total, but the stated problem is the infinite scroll: a single unbroken session. Two per-item numbers address it:
sessionLimitMinutes: a counting session longer than this closes the site;cooldownMinutes: how long it stays closed. The session is dropped, so
time stops accruing immediately.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.
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.
| 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.
fetch(/XMLHttpRequest/WebSocket/remote import/remote
<script src> anywhere in the repo.web_accessible_resources exposes exactly src/blocked.html.common/ contains zero chrome.* references (purity + testability).| 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 |
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.
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 |
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.
| # | 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) |
load(); the state object is
threaded through update(preloaded) → rollover → reconcile →
bounceClosedTabs (chrome.storage serializes ops, so extra reads on hot
paths used to queue ahead of the popup’s first read and delay its paint).onChanged
renders are debounced (~100 ms).| # | 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 |
| # | 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 |
| 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 |