diff --git a/api/dashboard.mjs b/api/dashboard.mjs index 03d5135..8dde4b9 100644 --- a/api/dashboard.mjs +++ b/api/dashboard.mjs @@ -3,6 +3,7 @@ import { supabase } from '../lib/db.mjs' import { parseRange } from '../lib/range.mjs' import { summarizeDaily, dayKey } from '../lib/analytics.mjs' import { getUserSync, getCoverage } from '../lib/sync.mjs' +import { syncErrorMessage } from '../lib/sync-errors.mjs' import { rangeCoverageStatus } from '../lib/coverage.mjs' import { requestQuery } from '../lib/request-query.mjs' @@ -144,7 +145,7 @@ export default async function handler(req, res) { lastSyncedAt: sync.last_synced_at, resumeAt: sync.resume_at || null, updatedAt: sync.updated_at, - error: sync.error, + error: syncErrorMessage(sync.error), } : { status: 'idle', progress: 0 }, rangeCoverage, diff --git a/api/sync.mjs b/api/sync.mjs index 4904ce5..968b2a8 100644 --- a/api/sync.mjs +++ b/api/sync.mjs @@ -2,6 +2,7 @@ import { waitUntil } from '@vercel/functions' import { requireUser } from '../lib/require-user.mjs' import { forbidCrossSite } from '../lib/same-origin.mjs' import { runSync, getUserSync } from '../lib/sync.mjs' +import { syncErrorMessage } from '../lib/sync-errors.mjs' import { requestQuery } from '../lib/request-query.mjs' const shape = (s) => @@ -16,7 +17,7 @@ const shape = (s) => resumeAt: s.resume_at, lastSyncedAt: s.last_synced_at, updatedAt: s.updated_at, - error: s.error, + error: syncErrorMessage(s.error), } : { status: 'idle', progress: 0 } diff --git a/api/user.mjs b/api/user.mjs index 9fc6192..46415ed 100644 --- a/api/user.mjs +++ b/api/user.mjs @@ -2,6 +2,7 @@ import { getSession } from '../lib/auth.mjs' import { github } from '../lib/config.mjs' import { supabase } from '../lib/db.mjs' import { getUserSync } from '../lib/sync.mjs' +import { syncErrorMessage } from '../lib/sync-errors.mjs' import { revokeAllSessions, isSessionLive } from '../lib/sessions.mjs' import { requestQuery } from '../lib/request-query.mjs' import { forbidCrossSite } from '../lib/same-origin.mjs' @@ -175,7 +176,7 @@ export default async function handler(req, res) { progress: sync.progress, detail: sync.detail || null, lastSyncedAt: sync.last_synced_at, - error: sync.error, + error: syncErrorMessage(sync.error), } : null, }) diff --git a/api/webhooks/github.mjs b/api/webhooks/github.mjs index ae8d52d..58d4259 100644 --- a/api/webhooks/github.mjs +++ b/api/webhooks/github.mjs @@ -135,7 +135,7 @@ async function onInstallation(payload) { .eq('installation_id', inst.id) await supabase.from('github_installations').delete().eq('installation_id', inst.id) for (const r of rows || []) { - await setUserSync(r.user_id, { status: 'revoked', error: 'GitHub access was revoked' }) + await setUserSync(r.user_id, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED' }) } return } @@ -221,12 +221,8 @@ async function onPullRequest(payload) { user_id: repo.user_id, repository_id: repo.id, github_pr_id: pr.id, - number: pr.number, - author_user_id: pr.user.id, - author_login: pr.user.login, state: pr.merged_at ? 'merged' : pr.state, created_at: pr.created_at, - closed_at: pr.closed_at, merged_at: pr.merged_at, // No pr.title — 008_data_minimization dropped the column; PR titles are // user-authored text Dev Ledger deliberately does not persist. diff --git a/docs/architecture.md b/docs/architecture.md index 86ad398..26dd058 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ Webhook requests require a valid HMAC signature and use delivery IDs to prevent Supabase Postgres stores normalized records for users, installations, repositories, languages, commits, pull requests, coverage, sync state, sessions, and limited operational telemetry. -The application intentionally does not persist repository source code, commit messages, pull-request titles, GitHub email addresses, OAuth tokens, installation tokens, or raw webhook payload bodies. +The application intentionally does not persist repository source code, commit messages, pull-request titles, commit or pull-request author identifiers (every stored row is already scoped to the signed-in user), raw provider exception text (sync errors persist closed taxonomy codes only), GitHub email addresses, OAuth tokens, installation tokens, or raw webhook payload bodies. Application tables use row-level security and browser database roles have no direct table access. Server-side application queries use the backend credential and scope records to the authenticated internal user. diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md index 58d7e99..16723bf 100644 --- a/docs/disaster-recovery.md +++ b/docs/disaster-recovery.md @@ -72,7 +72,7 @@ schema/table is *not* backed up until explicitly classified in | `repositories` | core | yes | tracked repos + disconnect state | | `repository_languages` | core | yes | language byte-share stats | | `commits` | core | yes | ingested history — expensive to rebuild | -| `pull_requests` | core | yes | ingested PR rows (no titles by design) | +| `pull_requests` | core | yes | ingested PR rows (no titles/numbers/authors by design) | | `repo_coverage` | operational | yes | derived but needs full re-ingest to rebuild — cheap to keep | | `repo_sync` | operational | yes | phase markers; self-healing hints | | `user_sync` | operational | yes | stale `syncing`/`locked` rows are taken over by cron's stale-lock logic | @@ -257,7 +257,14 @@ it — zero production impact. select/insert/delete + sequence usage) - [ ] `dash_*` functions are SECURITY INVOKER; dashboard views `security_invoker` -- [ ] `pull_requests.title` absent (privacy removal intact) +- [ ] minimized columns absent: `pull_requests.title`, `pull_requests.number`, + `pull_requests.author_user_id`, `pull_requests.author_login`, + `pull_requests.closed_at`, `commits.message`, `commits.author_user_id`, + `commits.author_login`, `commits.authored_at`, `commits.files_changed`, + `commits.is_merge` +- [ ] `user_sync.error` / `repo_sync.error` contain only closed taxonomy + codes (`GITHUB_RATE_LIMIT`, `GITHUB_ACCESS_REVOKED`, + `SYNC_HISTORY_FAILED`, `SYNC_PULLS_FAILED`, `SYNC_INTERNAL_ERROR`) - [ ] `auth_sessions` empty (re-auth forced), `webhook_deliveries` empty, `security_events` fresh - [ ] a post-restore insert into `security_events` works (identity live) diff --git a/lib/sessions.mjs b/lib/sessions.mjs index abf4bf5..5a1b34f 100644 --- a/lib/sessions.mjs +++ b/lib/sessions.mjs @@ -4,12 +4,15 @@ import { persistentTtl } from './config.mjs' // Server-side session records. Every issued Dev Ledger session carries a // random `sid` sealed inside the iron-session cookie; requireUser validates -// it against auth_sessions before trusting the identity. Revocation is a -// single row update — a stolen but well-formed cookie dies immediately. +// it against auth_sessions before trusting the identity. Revocation DELETES +// the row — a missing sid fails validation identically, and a stolen but +// well-formed cookie dies immediately with nothing retained. // // Backward compatibility: cookies issued before this shipped carry no sid. // They fail validation → one forced re-login. Intentional: a pre-migration // cookie has no server-side kill switch, so it must not stay trusted. +// Rows with revoked_at set are legacy (pre-delete semantics): the liveness +// filter still excludes them, and GC purges them outright. const TABLE = 'auth_sessions' @@ -44,14 +47,14 @@ export async function isSessionLive(sid) { return Boolean(data) } -// Revoke one session (logout). +// Revoke one session (logout): delete the row. A missing sid fails +// isSessionLive exactly like a revoked one — nothing is retained. export async function revokeSession(sid) { if (!supabase || !sid) return await supabase .from(TABLE) - .update({ revoked_at: new Date().toISOString() }) + .delete() .eq('sid', sid) - .is('revoked_at', null) } // Revoke every session for a user (DELETE MY DATA, credential rotation). @@ -59,21 +62,22 @@ export async function revokeAllSessions(userId) { if (!supabase || !userId) return await supabase .from(TABLE) - .update({ revoked_at: new Date().toISOString() }) + .delete() .eq('user_id', userId) - .is('revoked_at', null) } -// Bounded retention: expired/revoked rows older than 30 days can be -// garbage-collected. Called opportunistically (login), not per request. +// Bounded retention: dead rows are removed outright. A row is dead once +// expires_at has passed (isSessionLive rejects it) or when a legacy revoked +// marker is still present (revocation is a delete now — no new row ever +// carries revoked_at). Called opportunistically (login), not per request. export async function gcAuthSessions() { if (!supabase) return - const cutoff = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString() + const now = new Date().toISOString() try { await supabase .from(TABLE) .delete() - .or(`expires_at.lt.${cutoff},revoked_at.lt.${cutoff}`) + .or(`expires_at.lt.${now},revoked_at.gte.1970-01-01T00:00:00.000Z`) } catch { /* GC is best-effort — next call retries */ } diff --git a/lib/sync-errors.mjs b/lib/sync-errors.mjs new file mode 100644 index 0000000..4752bd0 --- /dev/null +++ b/lib/sync-errors.mjs @@ -0,0 +1,46 @@ +import { RateLimitError, GitHubAuthError } from './github.mjs' + +// Closed taxonomy for persisted sync errors. +// +// repo_sync.error / user_sync.error carry ONLY these codes. Raw exception +// text, provider response bodies, URLs and stack traces are never persisted: +// a thrown value is classified here at the write boundary and everything +// else collapses to SYNC_INTERNAL_ERROR. The presentation layer translates +// codes back to fixed human-readable strings via syncErrorMessage(). + +export const SYNC_ERROR_CODES = Object.freeze([ + 'GITHUB_RATE_LIMIT', + 'GITHUB_ACCESS_REVOKED', + 'SYNC_HISTORY_FAILED', + 'SYNC_PULLS_FAILED', + 'SYNC_INTERNAL_ERROR', +]) + +const MESSAGES = Object.freeze({ + GITHUB_RATE_LIMIT: 'GitHub rate limit reached — will resume automatically', + GITHUB_ACCESS_REVOKED: 'GitHub access was revoked', + SYNC_HISTORY_FAILED: 'Repository history sync failed — will retry automatically', + SYNC_PULLS_FAILED: 'Pull request sync failed — will retry automatically', + SYNC_INTERNAL_ERROR: 'Sync failed — will retry automatically', +}) + +export function isSyncErrorCode(value) { + return SYNC_ERROR_CODES.includes(value) +} + +// Any thrown value → a taxonomy code. `fallback` distinguishes the phase the +// failure escaped from (e.g. 'SYNC_PULLS_FAILED'); unknown throws always map +// to SYNC_INTERNAL_ERROR — no exception property is ever returned. +export function syncErrorCode(err, fallback = 'SYNC_INTERNAL_ERROR') { + if (err instanceof RateLimitError) return 'GITHUB_RATE_LIMIT' + if (err instanceof GitHubAuthError) return 'GITHUB_ACCESS_REVOKED' + return isSyncErrorCode(fallback) ? fallback : 'SYNC_INTERNAL_ERROR' +} + +// Presentation boundary: stored code → fixed human-readable string. Legacy +// free-text values (pre-taxonomy rows) and anything unrecognized collapse to +// the generic message — the stored value is never echoed verbatim. +export function syncErrorMessage(stored) { + if (stored == null || stored === '') return null + return MESSAGES[stored] || MESSAGES.SYNC_INTERNAL_ERROR +} diff --git a/lib/sync.mjs b/lib/sync.mjs index c9686fa..5711b8f 100644 --- a/lib/sync.mjs +++ b/lib/sync.mjs @@ -2,6 +2,7 @@ import { supabase } from './db.mjs' import { getInstallationOctokit, withBackoff, RateLimitError, GitHubAuthError } from './github.mjs' import { isAttributedCommit } from './analytics.mjs' import { coversRange, computeSyncProgress } from './coverage.mjs' +import { syncErrorCode } from './sync-errors.mjs' // Staged, resumable GitHub ingestion. // @@ -30,12 +31,9 @@ query ($owner: String!, $name: String!, $author: ID!, $cursor: String, $since: G nodes { oid committedDate - authoredDate additions deletions - changedFilesIfAvailable - parents { totalCount } - author { user { id login } } + author { user { id } } } } } @@ -316,7 +314,7 @@ export async function runSync(userId, { budgetMs = 20000, force = false, resume if (authFailed && !seenRepoIds.size) { // Every installation is gone on GitHub's side — the app was uninstalled. - await setUserSync(userId, { status: 'revoked', error: 'GitHub access was revoked', locked_at: null }) + await setUserSync(userId, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED', locked_at: null }) return { ...(await getUserSync(userId)), ran: true } } @@ -427,7 +425,16 @@ export async function runSync(userId, { budgetMs = 20000, force = false, resume // ---- phase: pulls — one installation-scoped search, runs alongside --- const pullsTask = (async () => { if (expired()) return - detail.pulls.count = await syncPullRequests({ userId, user, installs, octokitFor }) + try { + detail.pulls.count = await syncPullRequests({ userId, user, installs, octokitFor }) + } catch (err) { + // Fatal errors rethrow unchanged so the outer handler keeps their + // dedicated status path; anything else is tagged so the persisted + // record carries the phase code, never provider text. + if (isFatal(err)) throw err + if (err && typeof err === 'object') err.syncCode = 'SYNC_PULLS_FAILED' + throw err + } detail.pulls.done = true await beat() })() @@ -455,11 +462,11 @@ export async function runSync(userId, { budgetMs = 20000, force = false, resume } catch (err) { const release = { locked_at: null } if (err instanceof RateLimitError) { - await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GitHub rate limit reached — will resume automatically', ...release }) + await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GITHUB_RATE_LIMIT', ...release }) } else if (err instanceof GitHubAuthError) { - await setUserSync(userId, { status: 'revoked', error: 'GitHub access was revoked', ...release }) + await setUserSync(userId, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED', ...release }) } else { - await setUserSync(userId, { status: 'error', error: String(err?.message || err).slice(0, 400), ...release }) + await setUserSync(userId, { status: 'error', error: syncErrorCode(err, err?.syncCode), ...release }) } return { ...(await getUserSync(userId)), ran: true } } @@ -515,11 +522,11 @@ export async function runRangeSync(userId, from, to, { budgetMs = 20000 } = {}) } } catch (err) { if (err instanceof RateLimitError) { - await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GitHub rate limit reached — will resume automatically' }) + await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GITHUB_RATE_LIMIT' }) return { ok: false, error: 'rate_limited' } } if (err instanceof GitHubAuthError) { - await setUserSync(userId, { status: 'revoked', error: 'GitHub access was revoked' }) + await setUserSync(userId, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED' }) return { ok: false, error: 'revoked' } } // Non-fatal: fall through and report whatever coverage was achieved @@ -662,7 +669,7 @@ async function syncRepoCommits({ userId, user, rs, repo, octokitFor, expired }) const rows = [] for (const node of nodes) { if (!isAttributedCommit(node, user.github_node_id)) continue - rows.push(commitRow(node, userId, repo.id, user)) + rows.push(commitRow(node, userId, repo.id)) } if (rows.length) { await supabase.from('commits').upsert(rows, { onConflict: 'user_id,github_sha', ignoreDuplicates: true }) @@ -729,7 +736,7 @@ async function syncRepoRange({ userId, user, repo, octokit, fromIso, toIso, expi onPage: async (nodes) => { const rows = nodes .filter((node) => isAttributedCommit(node, user.github_node_id)) - .map((node) => commitRow(node, userId, repo.id, user)) + .map((node) => commitRow(node, userId, repo.id)) if (rows.length) { await supabase.from('commits').upsert(rows, { onConflict: 'user_id,github_sha', ignoreDuplicates: true }) commits += rows.length @@ -756,19 +763,14 @@ async function syncRepoRange({ userId, user, repo, octokit, fromIso, toIso, expi return { commits } } -function commitRow(node, userId, repoId, user) { +function commitRow(node, userId, repoId) { return { user_id: userId, repository_id: repoId, github_sha: node.oid, - author_user_id: user.github_user_id, - author_login: node.author.user.login, - authored_at: node.authoredDate, committed_at: node.committedDate, additions: node.additions || 0, deletions: node.deletions || 0, - files_changed: node.changedFilesIfAvailable || 0, - is_merge: (node.parents?.totalCount || 0) > 1, } } @@ -779,7 +781,7 @@ function isFatal(err) { async function markRepoError(userId, repositoryId, err) { await supabase .from('repo_sync') - .update({ phase: 'error', error: String(err?.message || err).slice(0, 300), updated_at: new Date().toISOString() }) + .update({ phase: 'error', error: syncErrorCode(err, 'SYNC_HISTORY_FAILED'), updated_at: new Date().toISOString() }) .eq('user_id', userId) .eq('repository_id', repositoryId) } @@ -822,12 +824,8 @@ async function syncPullRequests({ userId, user, installs, octokitFor, window = n user_id: userId, repository_id: await repoRowByFullName(fullName), github_pr_id: item.id, - number: item.number, - author_user_id: item.user.id, - author_login: item.user.login, state: item.pull_request?.merged_at ? 'merged' : item.state, created_at: item.created_at, - closed_at: item.closed_at, merged_at: item.pull_request?.merged_at || null, }) } diff --git a/migrations/011_persistence_minimization.sql b/migrations/011_persistence_minimization.sql new file mode 100644 index 0000000..e004a48 --- /dev/null +++ b/migrations/011_persistence_minimization.sql @@ -0,0 +1,71 @@ +-- 011 — persistence minimization hardening (forward-only; idempotent). +-- +-- Deploy application code that no longer writes these columns BEFORE +-- applying this migration (same order discipline as 008): +-- +-- commits.author_user_id / commits.author_login +-- Stored commit rows are restricted to the signed-in user — the author +-- columns duplicated user_id information. GitHub author identity is +-- still verified transiently during ingestion (isAttributedCommit); +-- it is simply no longer persisted. +-- +-- commits.authored_at / commits.files_changed / commits.is_merge +-- Write-only fields: no dashboard function, view, API response, share +-- card, or UI reads them. committed_at is the canonical timestamp. +-- +-- pull_requests.author_user_id / author_login / number / closed_at +-- Same reasoning — PR rows are restricted to the signed-in user, the +-- dashboard reads only state/created_at/merged_at, and github_pr_id is +-- the dedup key. PR numbers are never displayed or linked. +-- +-- Sync error fields (user_sync.error, repo_sync.error) +-- now carry closed taxonomy codes only. Any legacy free-text value is +-- rewritten to the matching code or SYNC_INTERNAL_ERROR — raw provider +-- exception text is never retained. +-- +-- auth_sessions revocation residue +-- revoke = row deletion now; purge legacy revoked/expired rows so no +-- dead session record accumulates. revoked_at stays as a defensive +-- filter column (nothing writes it anymore). + +alter table public.commits + drop column if exists author_user_id, + drop column if exists author_login, + drop column if exists authored_at, + drop column if exists files_changed, + drop column if exists is_merge; + +alter table public.pull_requests + drop column if exists author_user_id, + drop column if exists author_login, + drop column if exists number, + drop column if exists closed_at; + +-- Legacy free-text error values → closed codes. Recognized phrasing maps to +-- its code; anything else (including arbitrary provider text) collapses to +-- SYNC_INTERNAL_ERROR rather than being carried forward. +update public.user_sync set error = 'GITHUB_RATE_LIMIT' + where error is not null and error ilike '%rate limit%'; +update public.repo_sync set error = 'GITHUB_RATE_LIMIT' + where error is not null and error ilike '%rate limit%'; + +update public.user_sync set error = 'GITHUB_ACCESS_REVOKED' + where error is not null and error ilike '%revok%'; +update public.repo_sync set error = 'GITHUB_ACCESS_REVOKED' + where error is not null and error ilike '%revok%'; + +update public.user_sync set error = 'SYNC_INTERNAL_ERROR' + where error is not null + and error not in ('GITHUB_RATE_LIMIT', 'GITHUB_ACCESS_REVOKED', + 'SYNC_HISTORY_FAILED', 'SYNC_PULLS_FAILED', + 'SYNC_INTERNAL_ERROR'); +update public.repo_sync set error = 'SYNC_INTERNAL_ERROR' + where error is not null + and error not in ('GITHUB_RATE_LIMIT', 'GITHUB_ACCESS_REVOKED', + 'SYNC_HISTORY_FAILED', 'SYNC_PULLS_FAILED', + 'SYNC_INTERNAL_ERROR'); + +-- Dead session records retain nothing useful: revoked rows are legacy +-- (revocation deletes now) and expired rows can never authenticate again. +delete from public.auth_sessions + where revoked_at is not null or expires_at < now(); diff --git a/scripts/backup-scope.mjs b/scripts/backup-scope.mjs index 2ee5ccd..b2be2f0 100644 --- a/scripts/backup-scope.mjs +++ b/scripts/backup-scope.mjs @@ -16,7 +16,7 @@ export const CORE_TABLES = [ 'repositories', // tracked repos incl. disconnect state 'repository_languages', // byte-share language stats 'commits', // ingested commit rows — the expensive history - 'pull_requests', // ingested PR rows (no titles — data minimization) + 'pull_requests', // ingested PR rows (no titles/numbers/authors — data minimization) 'feedback', // user-submitted transmissions — not re-ingestable ] diff --git a/scripts/recovery-drill.mjs b/scripts/recovery-drill.mjs index c5c5208..bac0f5e 100644 --- a/scripts/recovery-drill.mjs +++ b/scripts/recovery-drill.mjs @@ -83,8 +83,8 @@ async function seed(sql) { await sql`insert into repository_languages (repository_id, language, bytes) values (${repoId}, 'TypeScript', 12345)` await sql`insert into commits (user_id, repository_id, github_sha, committed_at, additions, deletions) values (${uid}, ${repoId}, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', now(), 10, 2)` - await sql`insert into pull_requests (user_id, repository_id, github_pr_id, number, author_user_id, author_login, state, created_at) - values (${uid}, ${repoId}, 777, 3, 42424242, 'drill-user', 'merged', now())` + await sql`insert into pull_requests (user_id, repository_id, github_pr_id, state, created_at) + values (${uid}, ${repoId}, 777, 'merged', now())` await sql`insert into repo_sync (user_id, repository_id, phase) values (${uid}, ${repoId}, 'done')` await sql`insert into user_sync (user_id, status, phase, progress) values (${uid}, 'complete', 'done', 1)` await sql`insert into repo_coverage (user_id, repository_id, covered_from, covered_to, complete) @@ -221,9 +221,16 @@ async function verify(src, dst, ids, extraProblems = []) { select relname, reloptions from pg_class where relkind = 'v' and relname in ('dash_repo_monthly', 'dash_span')` for (const v of views) check((v.reloptions || []).join(',').includes('security_invoker'), `${v.relname} missing security_invoker`) - // privacy-removed column stays removed; identity sequence live post-restore - const [col] = await dst`select count(*)::int c from information_schema.columns where table_name = 'pull_requests' and column_name = 'title'` - check(col.c === 0, 'pull_requests.title exists — privacy removal lost') + // privacy-removed columns stay removed; identity sequence live post-restore + for (const [tbl, col] of [ + ['pull_requests', 'title'], ['pull_requests', 'number'], ['pull_requests', 'author_user_id'], + ['pull_requests', 'author_login'], ['pull_requests', 'closed_at'], + ['commits', 'message'], ['commits', 'author_user_id'], ['commits', 'author_login'], + ['commits', 'authored_at'], ['commits', 'files_changed'], ['commits', 'is_merge'], + ]) { + const [c] = await dst`select count(*)::int c from information_schema.columns where table_name = ${tbl} and column_name = ${col}` + check(c.c === 0, `${tbl}.${col} exists — privacy removal lost`) + } await dst`insert into security_events (event, severity) values ('security_internal_error', 'warning')` const [se] = await dst`select count(*)::int c from security_events` check(se.c === 1, 'security_events insert failed — identity sequence broken?') diff --git a/src/legal/PrivacyPolicyPage.tsx b/src/legal/PrivacyPolicyPage.tsx index 3bde31a..e428f37 100644 --- a/src/legal/PrivacyPolicyPage.tsx +++ b/src/legal/PrivacyPolicyPage.tsx @@ -44,8 +44,8 @@ export function PrivacyPolicyPage() {