What we keep, what we don’t, and where the code lives.
What we keep
A count of completed scripts, on this device. The count never leaves the browser.
What we don’t
No email, no account, no cookies, no third-party trackers. The rate-limit fingerprint is a SHA-256 hash computed in the browser and never persisted as PII.
Civic record (this device)
Turning this off clears your count and stops new ones.
Where the code lives
Above are civic-record.ts and fingerprint.ts, byte-for-byte as shipped — not a summary of them, not a description of what they do. They are printed here rather than linked because this is the disclosure that matters: these two files are the entire surface that touches anything of yours, and you can read every line of them without leaving this page or trusting anything we say about them.
Codes like CIVIC-02 or D-23 in the comments are internal requirement IDs from the project’s planning documents. The rest of the build is not published; what governs your data is.
src/lib/civic-record.ts
/**
* Civic record (CIVIC-01..05) — on-device-only, anonymous, opt-out-able log of
* completed tactics. Default-on per CIVIC-02; opt-out wipes the count and
* future writes are no-ops until re-enabled.
*
* v3 shape (BUG-02 honesty fix): counts are stored PER TACTIC so copy never has
* to claim a device-wide number came "from this script". The device-wide total
* is kept alongside so a v2 record (which only had a total) migrates without
* losing the user's history — v2 totals cannot be attributed to any tactic, so
* they survive only in the total.
*
* Privacy posture (CIVIC-03, CIVIC-05):
* - localStorage only — no cookies, no server transmission of this record.
* - Anonymous — only the tactic_id and a timestamp are stored locally;
* the rate-limit fingerprint hash is computed fresh on each completion
* by computeFingerprint() (see fingerprint.ts) and never persisted.
* - User-controlled — opt-out toggle on /privacy clears local data.
* - Transparent — /privacy renders the byte-identical source of THIS file
* and of fingerprint.ts (CIVIC-04 snapshot via embed-civic-record-source).
*/
export const CIVIC_RECORD_KEY = '86dilley:civic-record-v2';
export const CIVIC_OPT_OUT_KEY = '86dilley:civic-opt-out';
export interface CivicRecordV3 {
_v: 3;
/** Device-wide total, including migrated v2 counts that predate per-tactic tracking. */
count: number;
/** Per-tactic completion counts (v3+ writes only). */
counts: Record<string, number>;
last_by_tactic: Record<string, string>;
}
function emptyRecord(): CivicRecordV3 {
return { _v: 3, count: 0, counts: {}, last_by_tactic: {} };
}
function readRecord(): CivicRecordV3 {
try {
const raw = globalThis.localStorage?.getItem(CIVIC_RECORD_KEY);
if (!raw) return emptyRecord();
const parsed = JSON.parse(raw);
if (
parsed &&
parsed._v === 3 &&
typeof parsed.count === 'number' &&
parsed.counts &&
parsed.last_by_tactic
) {
return parsed as CivicRecordV3;
}
// Graceful v2 migration: the v2 shape had only a device-wide total, so the
// total carries over and per-tactic counts start empty (honest — we cannot
// attribute old completions to specific tactics).
if (
parsed &&
parsed._v === 2 &&
typeof parsed.count === 'number' &&
parsed.last_by_tactic
) {
return {
_v: 3,
count: parsed.count,
counts: {},
last_by_tactic: parsed.last_by_tactic,
};
}
return emptyRecord();
} catch {
return emptyRecord();
}
}
function writeRecord(record: CivicRecordV3): void {
try {
globalThis.localStorage?.setItem(CIVIC_RECORD_KEY, JSON.stringify(record));
} catch {
// Quota exceeded or storage disabled — silent no-op.
}
}
export function recordCompletion(tacticId: string): void {
if (!isRecordingEnabled()) return;
const record = readRecord();
record.count += 1;
record.counts[tacticId] = (record.counts[tacticId] ?? 0) + 1;
record.last_by_tactic[tacticId] = new Date().toISOString();
writeRecord(record);
}
/** Device-wide completion total (includes migrated v2 history). */
export function getCompletionCount(): number {
return readRecord().count;
}
/** Completions of one specific tactic on this device (v3+ writes only). */
export function getTacticCompletionCount(tacticId: string): number {
return readRecord().counts[tacticId] ?? 0;
}
export function clearRecord(): void {
try {
globalThis.localStorage?.removeItem(CIVIC_RECORD_KEY);
} catch {
// noop
}
}
export function isRecordingEnabled(): boolean {
try {
return globalThis.localStorage?.getItem(CIVIC_OPT_OUT_KEY) !== '1';
} catch {
// If localStorage is unavailable, default to enabled (CIVIC-02 default-on).
// No data is written in this branch, so privacy posture is preserved.
return true;
}
}
export function setRecordingEnabled(value: boolean): void {
try {
if (value) {
globalThis.localStorage?.removeItem(CIVIC_OPT_OUT_KEY);
} else {
globalThis.localStorage?.setItem(CIVIC_OPT_OUT_KEY, '1');
globalThis.localStorage?.removeItem(CIVIC_RECORD_KEY);
}
} catch {
// noop
}
}
src/lib/fingerprint.ts
/**
* Anonymous fingerprint hash for completion rate-limiting (CIVIC-05, D-23).
*
* Algorithm:
* inputs = [navigator.userAgent, navigator.language, `${screen.width}x${screen.height}`]
* payload = inputs.join('|') // literal pipe separator
* hash = SHA-256(UTF-8(payload)) // crypto.subtle.digest
* output = hash bytes -> 64-char lowercase hex
*
* Properties:
* - 3 low-entropy inputs (UA + lang + screen) — stable enough for 1/min rate-limit
* on the same device + same day; blurry across devices/networks/browsers.
* - Never persisted to localStorage.
* - Never sent to server in plain form (only the hash leaves the client).
* - Not reversible to PII (no rainbow-attack path on UA + lang + screen).
* - No canvas, no plugin enumeration, no font probing — off the standard
* browser-fingerprinting tracking surface.
*
* /privacy renders the byte-identical source of THIS file via the
* embed-civic-record-source build script (Plan 01-06, CIVIC-04). Every line
* here is publicly inspectable.
*/
export async function computeFingerprint(): Promise<string> {
if (typeof navigator === 'undefined' || typeof screen === 'undefined') {
throw new Error('fingerprint requires browser environment (navigator + screen)');
}
const inputs = [
navigator.userAgent,
navigator.language,
`${screen.width}x${screen.height}`,
];
const payload = inputs.join('|');
const bytes = new TextEncoder().encode(payload);
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}