[object Object]

← back to AbramsOS

initial scaffold: OwnershipOS v0.1 — Express+PG shell, Gmail OAuth + receipt extractor, 8-table schema, liquid-glass UI, full canonical spec under docs/

36066847b7cf9905d64a1caa28e7b997d4c895db · 2026-05-09 21:33:03 -0700 · Steve

Files touched

Diff

commit 36066847b7cf9905d64a1caa28e7b997d4c895db
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat May 9 21:33:03 2026 -0700

    initial scaffold: OwnershipOS v0.1 — Express+PG shell, Gmail OAuth + receipt extractor, 8-table schema, liquid-glass UI, full canonical spec under docs/
---
 .env.example                  |  28 +++++
 .gitignore                    |  17 +++
 AGENTS.md                     |  54 +++++++++
 README.md                     |  34 ++++++
 db/schema.sql                 | 117 +++++++++++++++++++
 db/seed.sql                   |   4 +
 docs/ARCHITECTURE.md          |  60 ++++++++++
 docs/COMPLIANCE.md            |  44 ++++++++
 docs/DATA-MODEL.md            |  47 ++++++++
 docs/ROADMAP.md               |  28 +++++
 docs/SPEC.md                  | 245 ++++++++++++++++++++++++++++++++++++++++
 ecosystem.config.js           |  19 ++++
 lib/audit.js                  |  11 ++
 lib/crypto.js                 |  27 +++++
 lib/db.js                     |  36 ++++++
 lib/gmail-fetcher.js          |  71 ++++++++++++
 lib/google-oauth.js           |  48 ++++++++
 lib/ids.js                    |  19 ++++
 lib/receipt-extractor.js      | 163 +++++++++++++++++++++++++++
 logs/.gitkeep                 |   0
 package.json                  |  33 ++++++
 public/css/app.css            | 253 ++++++++++++++++++++++++++++++++++++++++++
 public/js/sort-density.js     |  48 ++++++++
 public/js/theme.js            |  10 ++
 routes/auth.js                |  81 ++++++++++++++
 routes/connectors.js          | 162 +++++++++++++++++++++++++++
 routes/health.js              |  14 +++
 routes/home.js                |  26 +++++
 routes/purchases.js           |  36 ++++++
 scripts/deploy-kamatera.sh    |   5 +
 scripts/gen-encryption-key.sh |   4 +
 server.js                     |  60 ++++++++++
 tests/extractor.test.js       |  49 ++++++++
 tests/smoke.test.js           |  50 +++++++++
 views/connectors.ejs          |  61 ++++++++++
 views/error.ejs               |   9 ++
 views/home.ejs                |  47 ++++++++
 views/partials/footer.ejs     |  11 ++
 views/partials/header.ejs     |  35 ++++++
 views/purchases.ejs           |  59 ++++++++++
 40 files changed, 2125 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e46272d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,28 @@
+NODE_ENV=development
+PORT=9931
+BASE_URL=http://localhost:9931
+
+# Postgres (standalone DB — NOT dw_unified)
+PG_HOST=localhost
+PG_PORT=5432
+PG_DATABASE=ownership_os
+PG_USER=stevestudio2
+PG_PASSWORD=
+
+# Sessions
+SESSION_SECRET=change-me-32-bytes-of-random
+
+# Encryption key for refresh-token at-rest (32 bytes hex). Routed via secrets skill.
+ENCRYPTION_KEY=
+
+# Google OAuth — registered separately from george-gmail
+GOOGLE_OAUTH_OWNERSHIPOS_CLIENT_ID=
+GOOGLE_OAUTH_OWNERSHIPOS_CLIENT_SECRET=
+GOOGLE_OAUTH_OWNERSHIPOS_REDIRECT_URI=http://localhost:9931/auth/google/callback
+
+# Local LLM (Mac1 Ollama default per memory)
+OLLAMA_BASE_URL=http://192.168.1.133:11434
+OLLAMA_MODEL=qwen3:14b
+
+# Branded contact
+INFO_EMAIL=info@ownershipos.agentabrams.com
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1b49b15
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+node_modules/
+.env
+.env.*
+!.env.example
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+uploads/
+data/private/
+logs/*
+!logs/.gitkeep
+coverage/
+*.tgz
+.npm/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..fb5a9ff
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,54 @@
+# AGENTS.md — OwnershipOS engineering guardrails
+
+This file is read by Claude Code, Codex, and any other coding agent that touches this repo. It overrides general defaults.
+
+## Approval policy (hard rules)
+
+| Action | Approval |
+|---|---|
+| Read evidence (email, doc, recall feed) | auto |
+| Insert into `purchase`, `purchase_item`, `source_message`, `document` | auto |
+| Draft a letter, claim packet, or registration form | auto (saved as draft only) |
+| Send any email, file any dispute, submit any regulator form | **explicit user approval per action** |
+| Auto-renew or auto-purchase warranty/service contract | **never automated; always user-initiated** |
+| Touch HIPAA-flagged rows | requires `medical_consent_grant` row + audit_log entry |
+| Outbound webhooks to merchants/issuers | destination allowlist + signed; no free-form URLs |
+
+## Hard NOs
+
+- **No Anthropic API key.** Use the `claude` CLI or local Ollama. Never `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` in any process env.
+- **No mocking the database in tests** — use a throwaway `ownership_os_test` DB.
+- **No specs/numbers in marketing body copy.** Editorial prose only; structured fields live in the schema.
+- **No external action without an `audit_log` entry** referencing the consent grant that authorized it.
+- **No PII to model providers** without redaction. Redact name, address, account numbers, MRN, NDC-as-PHI before any prompt.
+- **No public-image stock libraries** (Unsplash/Pexels/Getty). Allowed: Wikimedia, IA, Calisphere, USC, LAPL.
+
+## Code standards
+
+- Node 18+. CommonJS in this repo for parity with `george-gmail`/`ventura-claw-leads`.
+- All DB access through `lib/db.js` (single `pg.Pool`).
+- All Gmail access through `lib/gmail-fetcher.js`. Never call `googleapis` directly from a route.
+- Refresh tokens encrypted with AES-256-GCM (`lib/crypto.js`) before insert.
+- Every route writes one `audit_log` row per state-changing operation.
+- Every new table gets a sequenced migration in `db/migrations/NNNN_*.sql`.
+- Frontend grids inherit `public/js/sort-density.js` (sort + density slider, localStorage).
+- Every page inherits `views/layout.ejs` (liquid-glass shell, dark/light toggle).
+
+## Test gate
+
+- `npm test` must pass before any commit.
+- Every new skill/agent must ship with at least one golden-file fixture.
+- Receipt extractor regressions: keep golden fixtures in `tests/fixtures/receipts/`.
+
+## Compliance posture
+
+OwnershipOS is consumer-only today. Medical features are gated behind a separate consent grant and (eventually) a HIPAA-grade deployment lane. See `docs/COMPLIANCE.md`. Until that lane exists, do **not** ingest provider-sourced PHI; user-uploaded medical receipts are fine.
+
+## Standing rules inherited from Steve's global instructions
+
+- All builds gitified (commit per logical change).
+- Every site needs `info@<domain>` (provisioned via Purelymail).
+- Every product/data grid gets sort + density slider.
+- Dark/light toggle on every B&W build.
+- Always use ElevenLabs for voice (no piper/say/coqui as primary).
+- Default to Browserbase before local headless browsers.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4c4b4d5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# OwnershipOS
+
+A household "rights operating system." Ingests receipts (Gmail), warranties, recall feeds, and service guarantees into a unified ownership graph; runs a case engine that decides what's owned, what risks/deadlines apply, and what next action is worth taking.
+
+**Status:** scaffold + Gmail-connector MVP. Single-user local. Port 9931. Standalone PG (`ownership_os`).
+
+**Canonical spec:** [`docs/SPEC.md`](docs/SPEC.md). Roadmap in [`docs/ROADMAP.md`](docs/ROADMAP.md). Architecture in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Schema in [`docs/DATA-MODEL.md`](docs/DATA-MODEL.md). Compliance posture in [`docs/COMPLIANCE.md`](docs/COMPLIANCE.md). Engineering guardrails in [`AGENTS.md`](AGENTS.md).
+
+## Run locally
+
+```bash
+npm install
+createdb ownership_os                 # one-time
+psql -d ownership_os -f db/schema.sql # idempotent
+cp .env.example .env                  # then fill ENCRYPTION_KEY + Google OAuth via /secrets
+npm start                             # → http://localhost:9931
+```
+
+## Architecture in one paragraph
+
+Connectors (Gmail today, more later) drop raw evidence into `source_message`. The ingestion pipeline normalizes purchases into `purchase` + `purchase_item` and links them to assets. A case engine (later) reads the graph plus a versioned rights-rule corpus and produces drafts for the user to approve. Everything that happens lands in `audit_log`. Side-effecting actions are gated by an approval-policy engine; nothing leaves the system without an explicit consent grant.
+
+## What's built today
+
+- Express server on `:9931` with `/healthz`, `/`, `/connectors`, `/purchases`
+- 8-table PG schema (user, consent, connector, source_message, document, purchase, purchase_item, audit_log)
+- Google OAuth round-trip (`/auth/google/start` → `/auth/google/callback`)
+- Gmail message sync (`POST /api/connectors/:id/sync`) — pulls last 90 days of receipt-shaped mail
+- Heuristic receipt extractor (regex over subject/body); local-Ollama fallback hook (TODO)
+- Liquid-glass UI shell with dark/light toggle and sort+density grid on `/purchases`
+
+## What's deferred
+
+Recall ingestion, medical/DME tables, claim composer, card-benefit corpus, multi-tenant household, mobile, e-sign, Kamatera deploy. See `docs/ROADMAP.md`.
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..f9eb01f
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,117 @@
+-- OwnershipOS — declarative current schema (v0.1, Phase 0)
+-- Apply: psql -d ownership_os -f db/schema.sql
+-- Idempotent. For deltas after this, use db/migrations/NNNN_*.sql.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS user_account (
+  id              text PRIMARY KEY,
+  email           text NOT NULL UNIQUE,
+  display_name    text,
+  locale          text DEFAULT 'en-US',
+  timezone        text DEFAULT 'America/Los_Angeles',
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS consent_grant (
+  id              text PRIMARY KEY,
+  user_id         text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  connector_type  text NOT NULL,            -- 'gmail', 'outlook', 'plaid', etc.
+  scopes_json     jsonb NOT NULL,
+  granted_at      timestamptz NOT NULL DEFAULT now(),
+  revoked_at      timestamptz
+);
+CREATE INDEX IF NOT EXISTS consent_grant_user_idx ON consent_grant (user_id, connector_type);
+
+CREATE TABLE IF NOT EXISTS connector_account (
+  id                       text PRIMARY KEY,
+  user_id                  text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  consent_grant_id         text NOT NULL REFERENCES consent_grant(id) ON DELETE CASCADE,
+  provider                 text NOT NULL,                -- 'google'
+  external_subject_id      text NOT NULL,                -- Google `sub` or email
+  refresh_token_encrypted  bytea NOT NULL,               -- AES-256-GCM
+  refresh_token_iv         bytea NOT NULL,
+  refresh_token_tag        bytea NOT NULL,
+  last_sync_at             timestamptz,
+  created_at               timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (provider, external_subject_id)
+);
+CREATE INDEX IF NOT EXISTS connector_account_user_idx ON connector_account (user_id);
+
+CREATE TABLE IF NOT EXISTS source_message (
+  id              text PRIMARY KEY,
+  connector_id    text NOT NULL REFERENCES connector_account(id) ON DELETE CASCADE,
+  source_type     text NOT NULL,             -- 'gmail'
+  external_id     text NOT NULL,             -- Gmail message id
+  thread_id       text,
+  received_at     timestamptz NOT NULL,
+  subject         text,
+  sender          text,
+  recipient       text,
+  payload_jsonb   jsonb NOT NULL,
+  payload_hash    text NOT NULL,
+  ingested_at     timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (connector_id, source_type, external_id)
+);
+CREATE INDEX IF NOT EXISTS source_message_received_idx ON source_message (received_at DESC);
+CREATE INDEX IF NOT EXISTS source_message_connector_idx ON source_message (connector_id, received_at DESC);
+
+CREATE TABLE IF NOT EXISTS document (
+  id                  text PRIMARY KEY,
+  source_message_id   text REFERENCES source_message(id) ON DELETE CASCADE,
+  user_id             text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  kind                text NOT NULL,        -- 'receipt', 'warranty', 'invoice', 'attachment', 'screenshot'
+  mime                text,
+  hash                text NOT NULL,
+  object_path         text,                 -- relative to uploads/
+  parsed_status       text NOT NULL DEFAULT 'pending',
+  created_at          timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS document_user_idx ON document (user_id, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS purchase (
+  id                  text PRIMARY KEY,
+  user_id             text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  source_message_id   text REFERENCES source_message(id) ON DELETE SET NULL,
+  merchant_name       text NOT NULL,
+  merchant_domain     text,
+  order_number        text,
+  purchase_date       timestamptz NOT NULL,
+  total_amount        numeric(12,2),
+  currency            text DEFAULT 'USD',
+  confidence          numeric(3,2),         -- 0..1
+  raw_extract         jsonb,
+  created_at          timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS purchase_user_date_idx ON purchase (user_id, purchase_date DESC);
+CREATE INDEX IF NOT EXISTS purchase_merchant_idx ON purchase (user_id, merchant_name);
+
+CREATE TABLE IF NOT EXISTS purchase_item (
+  id              text PRIMARY KEY,
+  purchase_id     text NOT NULL REFERENCES purchase(id) ON DELETE CASCADE,
+  category        text,
+  brand           text,
+  model           text,
+  gtin            text,
+  serial          text,
+  quantity        integer DEFAULT 1,
+  unit_price      numeric(12,2),
+  raw_text        text,
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS purchase_item_purchase_idx ON purchase_item (purchase_id);
+
+CREATE TABLE IF NOT EXISTS audit_log (
+  id              bigserial PRIMARY KEY,
+  occurred_at     timestamptz NOT NULL DEFAULT now(),
+  actor_type      text NOT NULL,             -- 'user', 'system', 'agent'
+  actor_id        text,
+  object_type     text NOT NULL,             -- 'consent_grant', 'connector_account', 'source_message', 'purchase', etc.
+  object_id       text,
+  event_type      text NOT NULL,             -- 'oauth_grant', 'sync_started', 'message_persisted', 'purchase_extracted', 'connector_revoked'
+  metadata_jsonb  jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX IF NOT EXISTS audit_log_object_idx ON audit_log (object_type, object_id, occurred_at DESC);
+CREATE INDEX IF NOT EXISTS audit_log_event_idx ON audit_log (event_type, occurred_at DESC);
+
+COMMIT;
diff --git a/db/seed.sql b/db/seed.sql
new file mode 100644
index 0000000..9fa9263
--- /dev/null
+++ b/db/seed.sql
@@ -0,0 +1,4 @@
+-- Seed: single dev user (Steve). Idempotent.
+INSERT INTO user_account (id, email, display_name, locale, timezone)
+VALUES ('user_steve', 'steve@designerwallcoverings.com', 'Steve', 'en-US', 'America/Los_Angeles')
+ON CONFLICT (email) DO NOTHING;
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..b3a3aea
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,60 @@
+# Architecture
+
+See `SPEC.md` for the source-of-truth diagrams. This file holds the runtime view (what's actually deployed today vs. what's planned).
+
+## Today (Phase 0 / scaffold)
+
+```
+                  Browser
+                     │
+                     │ HTTP
+                     ▼
+         ┌──────────────────────┐
+         │  Express server :9931 │
+         │  (server.js)         │
+         └─────────┬────────────┘
+                   │
+       ┌───────────┼─────────────────┐
+       ▼           ▼                 ▼
+   routes/auth  routes/connectors  routes/purchases
+       │           │                 │
+       │           ▼                 ▼
+       │     lib/gmail-fetcher    lib/db (pg.Pool)
+       │     lib/google-oauth         │
+       │           │                  ▼
+       └───────────┴───────────►  PostgreSQL ownership_os
+                                   ├ user_account
+                                   ├ consent_grant
+                                   ├ connector_account (refresh_token AES-256-GCM)
+                                   ├ source_message (jsonb)
+                                   ├ document
+                                   ├ purchase
+                                   ├ purchase_item
+                                   └ audit_log
+```
+
+External:
+- Google OAuth → consent → callback → encrypted refresh token at rest
+- Local Ollama qwen3:14b on Mac1 (`http://192.168.1.133:11434`) — receipt extractor tier-2 fallback
+- pm2 supervisor (Mac2) → autorestart + log rotation
+
+## Future (the SPEC.md target)
+
+The scaffold above grows into the full diagram in `SPEC.md`:
+- **Connector Gateway** layer fronting all per-user connector_accounts (Gmail, Outlook, Calendar, Plaid, Stripe, PayPal, MCP)
+- **Ingestion Pipeline** workers (OCR, ER, Recall Matcher, Coverage Analyst)
+- **Rights Rules Corpus** as a versioned read-side
+- **Case & Claims Service** with the Claim Strategist + Letter Composer
+- **Approval Policy Engine** as a blocking checkpoint for every Action Agent call
+- **Audit Log Service** as an append-only ledger (eventually a Merkle-chained store)
+- **Document / Letter / Messaging Service** for outbound
+
+Migration path is incremental: each milestone in `ROADMAP.md` adds one column of the target diagram.
+
+## Key invariants
+
+1. **No external action without an audit_log row** referencing the consent_grant that authorized it.
+2. **Refresh tokens never leave PG in plaintext.**
+3. **Rights-rule snapshots are immutable** — corrections create new versions, never overwrite.
+4. **Consumer and medical data are separable** — medical_consent_grant is a hard precondition for any medical_* table read.
+5. **No marketplace scraping.** Consumer data comes from inbox + user-authorized exports + official APIs only.
diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md
new file mode 100644
index 0000000..aefa175
--- /dev/null
+++ b/docs/COMPLIANCE.md
@@ -0,0 +1,44 @@
+# Compliance Posture
+
+OwnershipOS is **consumer-only** in v0 and operates as a personal tool for Steve. The compliance bar will rise in lockstep with each milestone in `ROADMAP.md`. Document this file as the system grows.
+
+## Today (Phase 0)
+
+| Area | Posture |
+|---|---|
+| Authentication | local single-user; no public exposure |
+| OAuth | server-side authorization-code flow against Google for Gmail readonly |
+| Tokens | refresh tokens AES-256-GCM-encrypted at rest with `ENCRYPTION_KEY` |
+| Scopes | `gmail.readonly` + `userinfo.email` ONLY (tighter than george-gmail's full-workspace bundle) |
+| Data residency | local PG `ownership_os` on Mac2; nothing in cloud yet |
+| PII handling | no redaction yet — single-user; do NOT add multi-tenant until redaction lands |
+| Model providers | local Ollama qwen3:14b on Mac1 only; **no Anthropic API**, no OpenAI calls |
+| HIPAA | N/A — no PHI ingested in v0; medical_* tables not implemented |
+| Audit log | every connector mutation writes one `audit_log` row |
+| Outbound | none — drafts only; no email send, no API submit, no e-sign |
+| Logging | pm2 stdout/stderr to `logs/`; no remote log shipping |
+| Backups | manual `pg_dump` for now |
+
+## When this changes (and what becomes mandatory)
+
+| Trigger | Required additions |
+|---|---|
+| Add second user | RBAC, per-user data isolation, redaction before model calls, session security review |
+| Add outbound action (send email / file claim) | per-action approval token, destination allowlist, signed audit entry, replay protection |
+| Add Plaid / Stripe / PayPal | PCI-aware logging boundaries, tokenized account references, no PAN/CVV ever |
+| Add medical_asset / pharma_product | medical_consent_grant precondition, BAA-ready vendor posture, PHI boundary tags, breach-notification runbook, encryption-in-transit + at-rest review |
+| Sell to providers / payers / employer benefits | full HIPAA program: Security Rule risk analysis, workforce controls, sanctions policy, business-associate agreements, incident response, contingency plan |
+| Add MCP marketplace (third-party tools) | Connector Security Gateway: URL allowlist, schema validation, content sanitization, prompt-injection scanning, egress restrictions |
+| Public-facing deployment | TLS, CSP, HSTS, rate limits, helmet hardening review, CSRF on all state-changing routes, security contact + disclosure policy |
+
+## Standing rules from Steve's global instructions that apply
+
+- **Never use the Anthropic API.** All Claude inference via `claude` CLI; delete `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` before any spawn.
+- **No CSV attachments in outbound email.** Inline `<table>`, RFC 2047-encoded subjects.
+- **Every site needs `info@<domain>`.** Provisioned via Purelymail at build time.
+- **Cloudflare caches HTML.** Edited Express sites need `Cache-Control: no-store, must-revalidate` on origin.
+- **No stock-image libraries** (Unsplash/Pexels/Getty). Allowed: Wikimedia, IA, Calisphere, USC, LAPL.
+
+## Not legal advice
+
+OwnershipOS produces drafts and educational content. It is not a lawyer, an insurance agent, or a billing service. Output should always carry a "for your review" framing in the UI; do not auto-send anything that asserts a legal claim without the user reading and approving the exact text.
diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md
new file mode 100644
index 0000000..76e8c00
--- /dev/null
+++ b/docs/DATA-MODEL.md
@@ -0,0 +1,47 @@
+# Data Model
+
+Authoritative target schema is in `SPEC.md`. This file mirrors what's actually implemented in `db/schema.sql` and notes deferred tables.
+
+## Implemented today (8 tables)
+
+| Table | Why it exists in v0 |
+|---|---|
+| `user_account` | single-row identity for Steve; will become per-household later |
+| `consent_grant` | every connector binding writes one; revocation marks `revoked_at` |
+| `connector_account` | one row per Gmail account; refresh token AES-256-GCM-encrypted |
+| `source_message` | every fetched Gmail message lands here, raw JSONB payload + hash |
+| `document` | one per email attachment / source_message body PDF (stored in `uploads/`) |
+| `purchase` | one per inferred order; links back to the source_message |
+| `purchase_item` | line items (when extractable); brand/model/GTIN as discovered |
+| `audit_log` | append-only; every state change writes one row |
+
+## Deferred to later milestones
+
+| Table | Milestone |
+|---|---|
+| `org_account` | Foundation (M1) |
+| `admin_account` | Foundation (M1) |
+| `service_commitment` | Rights expansion (M5) |
+| `medical_asset` | Medical expansion (M6) |
+| `pharma_product` | Medical expansion (M6) |
+| `coverage_policy` | Claim MVP (M4) |
+| `registration_requirement` | Ingestion MVP (M2) |
+| `recall_event` | Protection MVP (M3) |
+| `recall_match` | Protection MVP (M3) |
+| `rights_rule_snapshot` | Rights expansion (M5) |
+| `claim_case` | Claim MVP (M4) |
+| `action_queue` | Claim MVP (M4) |
+| `calendar_reminder` | Ingestion MVP (M2) |
+
+## Conventions
+
+- All IDs are ULIDs (`text`), not UUIDs — sortable and shorter to read in logs
+- Timestamps: `timestamptz`, never `timestamp`
+- Money: stored as `numeric(12,2)`, currency as ISO 4217 in a separate column
+- Soft-delete: avoided; use `revoked_at` / `state` columns where lifecycle matters
+- Indexes: every FK; every column used in `WHERE`; every JSONB path used in queries (`gin (payload jsonb_path_ops)`)
+- Encryption: refresh tokens via `lib/crypto.js` (AES-256-GCM, key from `ENCRYPTION_KEY` env)
+
+## Schema migrations
+
+`db/migrations/NNNN_*.sql`. Run forward only via `npm run migrate`. `db/schema.sql` is the **declarative current state** for fresh installs; migrations are the **incremental path** for an existing database. Both must agree after every commit.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..53cfe6b
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,28 @@
+# Roadmap
+
+| # | Milestone | Window | Headline deliverables |
+|---|---|---|---|
+| 0 | **Scaffold** (current) | 1–2 days | Express+PG shell, 8-table schema, Gmail OAuth round-trip, receipt extractor v0, sort+density grid UI, dark/light, info@ email, gitified, pm2-supervised |
+| 1 | **Foundation** | 6–8 weeks | org/user/admin model, full consent ledger, immutable audit log, doc store with object-store backend, Compliance Guardian middleware, Connector Security Gateway |
+| 2 | **Ingestion MVP** | 8–10 weeks | Microsoft Graph (Outlook) connector, calendar connectors, PDF parser, OCR fallback, deduplication, refresh hooks, reminder engine |
+| 3 | **Protection MVP** | 8–10 weeks | CPSC + NHTSA + FDA/openFDA + AccessGUDID + DailyMed ingest workers; recall_event normalization; recall_match engine using the canonical scoring pseudocode; warranty parser + coverage_policy; recall dashboard |
+| 4 | **Claim MVP** | 6–8 weeks | claim_case + action_queue tables, letter composer with cited rules, evidence-packet PDF render, e-sign integration (DocuSign), merchant/manufacturer/issuer/regulator template library, send-approval flow |
+| 5 | **Rights expansion** | 8–12 weeks | card-benefit corpus (Amex/Chase/Visa/Mastercard), airline-rights router (DOT/EU261/UK/Canada APPR/Brazil ANAC + Montreal Convention), service-guarantee graph (FTC + merchant policies), Plaid/Stripe/PayPal connectors |
+| 6 | **Medical expansion** | 8–12 weeks | medical_asset + pharma_product tables, DME/HCPCS taxonomy, Medicare supplier-directory join, FDA Drug Safety Communications subscription, separate medical_consent_grant + HIPAA-grade deployment lane, BAA pipeline |
+| 7 | **Full autonomy** | 10–14 weeks | standing-consent matrix, partial dispute filing, regulator complaint routing, MCP marketplace for user-supplied tools, mobile (iOS/Android), Apple EventKit, Apple Wallet pass output |
+
+## Definition of done per milestone
+
+- All schema migrations sequenced + applied + reversible
+- Golden-file fixtures committed for every parser/extractor
+- Adversarial test for prompt-injection on every new external content source
+- Audit-log invariant holds (every external action ↔ exactly one audit row)
+- Compliance section in `docs/COMPLIANCE.md` updated
+- A reviewed-demo-video lands in `~/Videos/shipped-apps/`
+
+## Out of scope (perpetually, until explicitly re-opened)
+
+- Auto-buying anything (warranties, contracts, products)
+- Auto-sending to any human without explicit per-action approval
+- Scraping social platforms (Reddit / Nextdoor / IG DMs) for personal history
+- Acting as a lawyer or a licensed insurance agent — drafts and education only
diff --git a/docs/SPEC.md b/docs/SPEC.md
new file mode 100644
index 0000000..b589edc
--- /dev/null
+++ b/docs/SPEC.md
@@ -0,0 +1,245 @@
+# OwnershipOS — Canonical Specification
+
+> Captured verbatim from the project kickoff brief, 2026-05-09. Source of truth for product scope. Update only by appending dated revision sections; do not rewrite history.
+
+## Executive summary
+
+OwnershipOS is viable, but only if it is designed as a consent-led, event-driven consumer protection system rather than a generic "receipt manager." The winning architecture is a source-of-truth graph that merges inbox receipts, marketplace exports, card transactions, product identifiers, medical device/drug registries, warranties, service commitments, recall feeds, and jurisdiction-specific rights rules into a single case engine. That engine should decide three things continuously: **what the user owns or purchased, what risks or deadlines now apply, and what action is worth taking next**. The official data landscape is strong for recalls and regulated medical products — especially through the U.S. Consumer Product Safety Commission, NHTSA, FDA / openFDA, AccessGUDID, DailyMed, RxNorm, and Medicare DME supplier directories — but fragmented for retail guarantees, service promises, and card benefits, which are spread across merchant policies, issuer benefit guides, and consumer-protection rules.
+
+The core product insight is that **the hard problem is not AI generation; it is rights-aware orchestration**. Models can gather missing facts, normalize purchases, match recalls, draft letters, assemble claim packets, and prepare disputes — but external side effects must be constrained by user approvals, standing consent, and auditable policy gates. Anthropic's Claude Code and MCP stack are well-aligned with a multi-agent buildout and per-user connector model; OpenAI's tool and function-calling primitives are suitable for production orchestration and selective tool loading.
+
+The biggest product constraints are access and compliance. There is no general published consumer-order API for retail Amazon purchases analogous to seller APIs; the official consumer paths are order history, product-safety alerts, and personal-data requests. Likewise, WhatsApp officially supports chat export and backup/transfer flows for users, but that is not the same as an open personal-history API. Credit-card protections also split between public-law rights and issuer-specific benefits. That means an MVP should focus first on mail/calendar connectors, official recall feeds, user-authorized exports, receipt ingestion, warranty parsing, deadline reminders, and draft-first claim automation. Full autonomy — auto-registering products, sending letters, launching disputes, filing regulator complaints, or submitting medical safety reports — should come later, with explicit approval tiers and stronger legal/compliance controls.
+
+The recommended product positioning is therefore: **"a household rights operating system"** with first-class support for products, services, medical equipment, and pharmaceuticals. It should behave like a blend of asset registry, recall sentinel, warranty manager, service-guarantee tracker, travel-rights assistant, and claim-prep engine. In the early product, every risky action should end as a prepared draft. In the mature product, low-risk actions can run automatically under standing consent while high-risk actions remain user-approved. That sequencing is what makes OwnershipOS ambitious but operable.
+
+## Integration and source strategy
+
+### Required integrations
+
+| Surface | Path | Ingest | Reality check |
+|---|---|---|---|
+| Inbox | Gmail API, Microsoft Graph | order confirmations, invoices, warranty PDFs, service confirmations, card-benefit guides, claim emails | Delegated OAuth per user, minimal scopes, refresh-token vaulting, webhook/subscription re-sync |
+| Calendar | Google Calendar, Microsoft Graph, Apple EventKit | warranty-expiry reminders, service follow-ups, airline claim clocks, registration reminders | iOS/macOS local calendar access is product-side, not multi-tenant cloud OAuth |
+| Commerce marketplaces | Amazon order history help, Amazon product-safety alerts, Amazon data request | historical orders, seller identity, ASIN-linked receipts, recall matches | Amazon's published APIs are seller-facing, not a general consumer purchase API |
+| Messaging and chat | WhatsApp chat export, Twilio | WhatsApp purchase evidence, SMS receipts, support transcripts, delivery notices | Personal-history intake should be export-based or device-local; cloud messaging is for outbound/inbound channels you control |
+| Payments and cards | Plaid, Stripe, PayPal REST | posted transactions, merchant names, statement dates, dispute IDs, refund evidence | Statement-level data alone is insufficient; you still need receipt + benefit/rules corpus |
+| E-sign / comms | DocuSign, Twilio | signed complaint letters, claim attestations, OTPs, outbound notices | Signatures and delivery receipts should be first-class evidence artifacts |
+| Model layer | OpenAI, Anthropic / Claude Code / MCP | multi-agent orchestration, extraction, drafting, case planning | Use at least two providers; keep all side effects behind policy gates |
+
+### Authoritative recall, medical, and product identity sources
+
+| Domain | Primary sources | Store |
+|---|---|---|
+| Consumer products / furniture | CPSC Data hub, CPSC Recall API, Recalls.gov | recall ID, firm, model, hazard, remedy, images, publication date |
+| Vehicles / car seats / tires / equipment | NHTSA recalls + datasets + vPIC | VIN or make-model-year, recall status, campaign IDs, manufacturer remedy, equipment type |
+| Drugs and biologics | openFDA drug enforcement, FDA Drug Recalls + Safety Communications, DailyMed, RxNorm | NDC, RXCUI, labeler, lot/recall class, label text, safety communication links |
+| Medical devices | openFDA device enforcement + recall + registration/listing, AccessGUDID + lookup API, FDA safety communications | UDI/DI, manufacturer, catalog number, device class, establishment/listing, recall/safety notices |
+| DME / medical furniture | Medicare supplier directory, DMEPOS fee schedules, DME coverage, DMEPOS supplier enrollment | HCPCS/DMEPOS categories, supplier identity, CMS enrollment status, coverage notes, replacement/repair rules |
+| Brand / manufacturer graph | Verified by GS1, GS1 company database, GTIN standard | GTIN, brand owner, company prefix, location/contact references |
+
+These are **category-specific master registries**, not a single universal database. Build a unified internal graph above them.
+
+### Credit-card protections, passenger rights, guarantee sources
+
+| Domain | Source of truth | Product use |
+|---|---|---|
+| Billing disputes / claims-and-defenses | CFPB Regulation Z + complaint channel | when card disputes are viable, generate merchant-first or creditor notices, escalate failed cases |
+| Issuer purchase / return / extended warranty benefits | Amex purchase protection guide, Chase guide to benefits, Visa extended warranty guide, Mastercard guide to benefits | benefit value, evidence checklist, claim deadlines, exclusions |
+| Airline disruptions | DOT dashboards/refund rules, EU/UK passenger rights, Canadian APPR, Brazilian ANAC | track coverage by itinerary and jurisdiction, prompt for refund, compensation, care, regulator complaint, or insurer/card claim |
+| International carriage injury / baggage liability | ICAO Montreal Convention | route bodily-injury and baggage matters into legal triage, preserve evidence, show liability limits and escalation |
+| Warranties / "satisfaction guaranteed" / returns | FTC warranty/guarantee guides, merchant return-policy structured data | extract merchant promises from ads/policy pages, compare with receipt date, draft demand letters and refund requests |
+
+Build a **Rights Rules Corpus** with three layers: statutory/regulatory rules, issuer/merchant benefit terms, user-specific evidence. Build a **Guarantee Graph** from FTC guidance + merchant policies + archived screenshots, because no national registry exists.
+
+For airline claims: ask only route, carrier, ticket type, disruption cause, and whether anyone was injured — then route automatically.
+
+## Architecture and data model
+
+Multi-tenant microservice platform with a user-scoped connector plane and a central evidence graph. MCP is the right abstraction for user-specific tool hookups.
+
+```mermaid
+flowchart LR
+    U[End User] --> W[Web / iOS / Android]
+    A[Admin Console] --> W
+    W --> API[API Gateway]
+    API --> AUTH[Auth & Consent Service]
+    API --> CONN[Connector Gateway]
+    API --> ING[Ingestion Pipeline]
+    API --> CASE[Case & Claims Service]
+    API --> CAL[Reminder Engine]
+    API --> COMMS[Document / Letter / Messaging Service]
+
+    CONN --> EMAIL[Email Connectors]
+    CONN --> CALAPI[Calendar Connectors]
+    CONN --> CHAT[Marketplace / Chat Imports]
+    CONN --> PAY[Bank / Card / Payment Connectors]
+    CONN --> MCP[MCP Tool Plane]
+
+    ING --> OCR[Doc Parser / Receipt Extractor]
+    ING --> ER[Entity Resolution]
+    ING --> GRAPH[Ownership Graph]
+    ING --> RULES[Rights Rules Corpus]
+    ING --> RECALL[Recall Matching Service]
+
+    RECALL --> SOURCES[Recall / Device / Drug / DME Sources]
+    CASE --> GRAPH
+    CASE --> RULES
+    CASE --> COMMS
+    CASE --> APPROVAL[Approval Policy Engine]
+    CAL --> GRAPH
+    CAL --> W
+    GRAPH --> DB[(Postgres + Object Store + Search)]
+    RULES --> DB
+    COMMS --> DB
+    APPROVAL --> AUDIT[Audit Log Service]
+    AUTH --> AUDIT
+    CASE --> AUDIT
+```
+
+Principle: **ingest once, normalize once, decide many times**. Every artifact becomes evidence tied to one or more assets, services, or claims.
+
+### Core database schema (full target)
+
+| Table | Purpose | Key fields |
+|---|---|---|
+| `org_account` | tenant root; admin and household account models | org_id, plan, region, policy_profile |
+| `user_account` | end-user identity | user_id, org_id, locale, timezone |
+| `admin_account` | admin/operator identity | admin_id, org_id, role, approval_scope |
+| `consent_grant` | OAuth/MCP/import consent ledger | grant_id, user_id, connector_type, scopes, granted_at, revoked_at |
+| `connector_account` | bound connector instance | connector_id, user_id, provider, external_subject_id, token_ref |
+| `source_message` | raw email/SMS/chat/order artifact | source_id, connector_id, source_type, received_at, payload_ref |
+| `document` | receipt, warranty, invoice, screenshot, policy PDF | document_id, owner_id, kind, hash, object_ref, parsed_status |
+| `purchase` | purchase-level record | purchase_id, user_id, merchant_name, order_number, purchase_date, total_amount, currency |
+| `purchase_item` | line-level asset/service | item_id, purchase_id, category, brand, model, serial, GTIN, quantity, unit_price |
+| `service_commitment` | service booking, guarantee, SLA, promise | service_id, purchase_id, provider_name, guarantee_text, refund_window, promised_outcome |
+| `medical_asset` | DME/device/medical furniture asset | medical_asset_id, item_id, hcpcs_code, udi_di, supplier_id, coverage_status |
+| `pharma_product` | normalized drug/biologic identity | pharma_id, ndc, rxcui, labeler, strength, dosage_form |
+| `coverage_policy` | manufacturer warranty, service contract, card benefit, insurer rule | policy_id, owner_ref, policy_type, start_at, end_at, deductible, exclusions_ref |
+| `registration_requirement` | whether/where/how a product can or should be registered | req_id, item_id, manufacturer_ref, method, data_needed_json |
+| `recall_event` | normalized inbound recall/safety alert | recall_id, authority, external_id, published_at, product_keys_json, remedy |
+| `recall_match` | match between owned asset and recall | match_id, recall_id, asset_ref, confidence, status, last_checked_at |
+| `rights_rule_snapshot` | versioned policy corpus | rule_id, domain, jurisdiction, source_ref, effective_date, rule_json |
+| `claim_case` | claim/dispute lifecycle | case_id, claimant_id, asset_or_service_ref, claim_type, jurisdiction, state, due_at |
+| `action_queue` | draft/send/file workflow | action_id, case_id, action_type, approval_level, scheduled_at, executed_at |
+| `calendar_reminder` | reminder engine | reminder_id, owner_ref, due_at, reason_code, calendar_event_id |
+| `audit_log` | immutable audit trail | audit_id, actor_type, actor_id, object_type, object_id, event_type, metadata, occurred_at |
+
+Two schema decisions matter most: (1) products and services must be peers; (2) medical and consumer records must be separable so the platform supports both consumer and regulated-health deployments from day one.
+
+## Agents and autonomous workflows
+
+Supervisor + specialist skills, not one giant generalist.
+
+| Agent | Mission | Approval |
+|---|---|---|
+| Intake Orchestrator | classify new evidence, route it | read-only |
+| Receipt Extractor | turn docs into structured purchases | read-only |
+| Ownership Resolver | resolve brands, models, GTINs, serials, services | read-only |
+| Recall Sentinel | monitor recall/safety feeds, match owned assets | read-only |
+| Coverage Analyst | infer warranty, service-contract, insurance, card-benefit coverage | read-only |
+| Rights Policy Agent | choose the relevant rights regime | read-only |
+| Claim Strategist | build the best next action plan | draft-only |
+| Letter Composer | draft merchant/manufacturer/issuer/regulator letters | draft-only |
+| Registration Agent | auto-fill product registration forms where allowed | user-approved or standing consent |
+| Action Agent | send letters, submit forms, open disputes, create calendar events | explicit approval except low-risk reminders |
+| Compliance Guardian | enforce consent, HIPAA/privacy boundaries, prompt-injection protections | blocking control |
+| Audit Narrator | explain what happened and why | read-only |
+
+### Workflow
+
+```mermaid
+flowchart TD
+    A[New receipt / email / export / recall feed] --> B[Intake Orchestrator]
+    B --> C[Normalize purchase or service]
+    C --> D[Entity Resolution]
+    D --> E[Ownership Graph update]
+    E --> F{Any active risk or deadline?}
+    F -- No --> G[Store + remind on warranty expiry]
+    F -- Recall --> H[Recall Sentinel opens case]
+    F -- Coverage event --> I[Coverage Analyst opens case]
+    F -- Service failure --> J[Rights Policy Agent opens case]
+    H --> K[Claim Strategist]
+    I --> K
+    J --> K
+    K --> L{Need user approval?}
+    L -- Draft only --> M[Compose letter / packet]
+    L -- Yes --> N[Approval request]
+    N --> O[Action Agent]
+    O --> P[Send / file / calendar / sign]
+    P --> Q[Audit log + case timeline]
+    M --> Q
+    G --> Q
+```
+
+The "merchant, store, or both?" decision should not be a first question. The strategist computes the recommended routing from evidence + remedy + rule set; the UI surfaces "Recommended first send: store. Optional backup: manufacturer."
+
+### Recall matching (canonical pseudocode)
+
+```python
+def match_recall_to_asset(asset, recall):
+    score = 0.0
+    if asset.gtin and asset.gtin in recall.product_keys.gtins: score += 0.45
+    if asset.model and fuzzy(asset.model, recall.product_keys.models) > 0.92: score += 0.25
+    if asset.serial and serial_in_range(asset.serial, recall.product_keys.serial_ranges): score += 0.20
+    if asset.brand and asset.brand == recall.brand: score += 0.05
+    if asset.purchase_date and recall.published_at >= asset.purchase_date: score += 0.05
+    if asset.category == "medical_device" and asset.udi_di == recall.product_keys.udi_di: score += 0.40
+    status = "positive_match" if score >= 0.85 else "needs_user_confirmation" if score >= 0.60 else "no_match"
+    return {"score": round(score, 3), "status": status}
+```
+
+### Coverage-value heuristic
+
+```text
+EV(warranty/insurance) = P(failure during covered period) × expected covered loss
+                       - deductible - exclusions risk - claim friction cost - contract price
+```
+
+Produce a plain-language verdict per item: **worth it / borderline / skip**.
+
+## Security, privacy, compliance
+
+Mirror HIPAA-style safeguards even in consumer deployments because medical features will drift toward PHI. Use OAuth 2.0 authorization-code with PKCE for public clients, short-lived service credentials, refresh-token vault. Anthropic explicitly warns that third-party MCP servers can create prompt-injection risk → OwnershipOS needs a **Connector Security Gateway** with URL allowlists, schema validation, content sanitization, and egress restrictions.
+
+Required posture (non-negotiable):
+
+| Control | Posture |
+|---|---|
+| Consent | explicit per-connector; revocation any time; standing-consent matrix for low-risk only |
+| Access control | strict RBAC; separate admin and user accounts; JIT elevation |
+| Data minimization | scoped tokens; redact PHI/PII before model calls |
+| Model safety | destination allowlists, tool schemas, anti-exfiltration checks, prompt-injection scanning |
+| Auditability | immutable action log, evidence hash chain, decision trace, approval record |
+| Data security | envelope encryption, tenant-scoped keys, signed download URLs |
+| Medical | PHI boundary tags, BAA-ready vendors, breach-notification workflow |
+| Legal | versioned rights-rule snapshots, source references, confidence scores, "not legal advice" copy |
+
+## Product experience
+
+Calm "control room for ownership," not a spreadsheet. Primary nav: **Home, Assets, Recalls, Claims, Medical, Services, Timeline, Settings.** Dual-surface (household app + admin/policy console).
+
+Visual: layered translucent panels (slate base #0f172a, hairline borders rgba(255,255,255,0.08), 24px backdrop-blur), large radii on cards, soft ambient depth, calm blue for monitoring / amber for deadlines / red only for confirmed high-severity. Always provide a "reduce transparency" theme.
+
+## Roadmap
+
+| Milestone | Duration | Deliverables |
+|---|---|---|
+| Foundation | 6–8 weeks | auth, org/user/admin model, consent ledger, doc store, audit log, core UI shell |
+| Ingestion MVP | 8–10 weeks | Gmail + Outlook, document parsing, receipt extraction, purchase graph, reminders |
+| Protection MVP | 8–10 weeks | CPSC/NHTSA/FDA/openFDA ingest, recall matching, warranty parser, case dashboard |
+| Claim MVP | 6–8 weeks | draft letters, evidence bundles, calendar workflows, merchant/manufacturer send approval |
+| Rights expansion | 8–12 weeks | card benefits corpus, airline rights router, service guarantees, guarantee graph |
+| Medical expansion | 8–12 weeks | DME, medical device, pharma identity, supplier registry integration, medical safety center |
+| Full autonomy layer | 10–14 weeks | standing approvals, e-sign, partial dispute filing, regulator complaint routing, MCP marketplace |
+
+MVP realistic at 4–6 months. Full platform 9–15 months. Build cost MVP $0.9M–$1.8M; full $3M–$7M. Ongoing $20k–$80k MVP, $100k–$300k full.
+
+## Open questions / limitations
+
+1. Amazon consumer purchase ingestion — no clean official end-user API; rely on inbox + export.
+2. WhatsApp purchase capture — export/backup/transfer only, not a personal-history API.
+3. Card-benefit coverage is fragmented; need a maintained benefits corpus.
+4. Service guarantees are merchant-specific; depends on policy ingestion + screenshot preservation.
+5. Medical compliance boundary depends on go-to-market lane (consumer vs provider channel).
+
+**Strategic conclusion:** build OwnershipOS as a rights-aware evidence engine with progressive autonomy, not a generic AI concierge.
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..b1944f8
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,19 @@
+module.exports = {
+  apps: [
+    {
+      name: 'ownershipos',
+      script: './server.js',
+      cwd: __dirname,
+      instances: 1,
+      exec_mode: 'fork',
+      autorestart: true,
+      max_memory_restart: '512M',
+      env: { NODE_ENV: 'development' },
+      env_production: { NODE_ENV: 'production' },
+      out_file: './logs/out.log',
+      error_file: './logs/err.log',
+      merge_logs: true,
+      time: true,
+    },
+  ],
+};
diff --git a/lib/audit.js b/lib/audit.js
new file mode 100644
index 0000000..7e1528a
--- /dev/null
+++ b/lib/audit.js
@@ -0,0 +1,11 @@
+const db = require('./db');
+
+async function log({ actorType, actorId = null, objectType, objectId = null, eventType, metadata = {} }) {
+  await db.query(
+    `INSERT INTO audit_log (actor_type, actor_id, object_type, object_id, event_type, metadata_jsonb)
+     VALUES ($1, $2, $3, $4, $5, $6)`,
+    [actorType, actorId, objectType, objectId, eventType, metadata]
+  );
+}
+
+module.exports = { log };
diff --git a/lib/crypto.js b/lib/crypto.js
new file mode 100644
index 0000000..730d2aa
--- /dev/null
+++ b/lib/crypto.js
@@ -0,0 +1,27 @@
+const crypto = require('crypto');
+
+function getKey() {
+  const hex = process.env.ENCRYPTION_KEY;
+  if (!hex || hex.length < 64) {
+    throw new Error('ENCRYPTION_KEY env var must be 32 bytes (64 hex chars). Generate via `openssl rand -hex 32` and route via /secrets.');
+  }
+  return Buffer.from(hex.slice(0, 64), 'hex');
+}
+
+function encrypt(plaintext) {
+  const key = getKey();
+  const iv = crypto.randomBytes(12);
+  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
+  const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
+  const tag = cipher.getAuthTag();
+  return { ciphertext, iv, tag };
+}
+
+function decrypt(ciphertext, iv, tag) {
+  const key = getKey();
+  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
+  decipher.setAuthTag(tag);
+  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
+}
+
+module.exports = { encrypt, decrypt };
diff --git a/lib/db.js b/lib/db.js
new file mode 100644
index 0000000..10cd775
--- /dev/null
+++ b/lib/db.js
@@ -0,0 +1,36 @@
+const { Pool } = require('pg');
+
+const pool = new Pool({
+  host: process.env.PG_HOST || 'localhost',
+  port: parseInt(process.env.PG_PORT || '5432', 10),
+  database: process.env.PG_DATABASE || 'ownership_os',
+  user: process.env.PG_USER || process.env.USER,
+  password: process.env.PG_PASSWORD || undefined,
+  max: 10,
+  idleTimeoutMillis: 30_000,
+});
+
+pool.on('error', (err) => {
+  console.error('[pg] idle client error', err);
+});
+
+async function query(text, params) {
+  return pool.query(text, params);
+}
+
+async function tx(fn) {
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+    const result = await fn(client);
+    await client.query('COMMIT');
+    return result;
+  } catch (err) {
+    await client.query('ROLLBACK');
+    throw err;
+  } finally {
+    client.release();
+  }
+}
+
+module.exports = { pool, query, tx };
diff --git a/lib/gmail-fetcher.js b/lib/gmail-fetcher.js
new file mode 100644
index 0000000..a8b3500
--- /dev/null
+++ b/lib/gmail-fetcher.js
@@ -0,0 +1,71 @@
+const { google } = require('googleapis');
+const { clientFor } = require('./google-oauth');
+
+const RECEIPT_QUERY = 'newer_than:90d (receipt OR order OR invoice OR confirmation OR shipped OR delivered OR purchase) -category:promotions';
+
+function gmailClient(refreshToken) {
+  return google.gmail({ version: 'v1', auth: clientFor(refreshToken) });
+}
+
+async function listReceiptIds(refreshToken, { query = RECEIPT_QUERY, max = 200 } = {}) {
+  const gmail = gmailClient(refreshToken);
+  const ids = [];
+  let pageToken;
+  do {
+    const resp = await gmail.users.messages.list({
+      userId: 'me',
+      q: query,
+      maxResults: Math.min(100, max - ids.length),
+      pageToken,
+    });
+    for (const m of resp.data.messages || []) ids.push(m.id);
+    pageToken = resp.data.nextPageToken;
+  } while (pageToken && ids.length < max);
+  return ids;
+}
+
+async function getMessage(refreshToken, id) {
+  const gmail = gmailClient(refreshToken);
+  const resp = await gmail.users.messages.get({ userId: 'me', id, format: 'full' });
+  return resp.data;
+}
+
+function header(payload, name) {
+  const h = (payload.headers || []).find((x) => x.name.toLowerCase() === name.toLowerCase());
+  return h ? h.value : null;
+}
+
+function decodeBase64Url(data) {
+  if (!data) return '';
+  return Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
+}
+
+function walkParts(payload, out = { text: '', html: '', attachments: [] }) {
+  if (!payload) return out;
+  const mime = payload.mimeType || '';
+  if (mime === 'text/plain' && payload.body?.data) out.text += decodeBase64Url(payload.body.data);
+  else if (mime === 'text/html' && payload.body?.data) out.html += decodeBase64Url(payload.body.data);
+  else if (payload.body?.attachmentId && payload.filename) {
+    out.attachments.push({
+      attachmentId: payload.body.attachmentId,
+      filename: payload.filename,
+      mime,
+      size: payload.body.size || 0,
+    });
+  }
+  for (const p of payload.parts || []) walkParts(p, out);
+  return out;
+}
+
+function summarize(message) {
+  const headers = {
+    subject: header(message.payload, 'Subject'),
+    from: header(message.payload, 'From'),
+    to: header(message.payload, 'To'),
+    date: header(message.payload, 'Date'),
+  };
+  const body = walkParts(message.payload);
+  return { id: message.id, threadId: message.threadId, headers, body, raw: message };
+}
+
+module.exports = { listReceiptIds, getMessage, summarize, RECEIPT_QUERY };
diff --git a/lib/google-oauth.js b/lib/google-oauth.js
new file mode 100644
index 0000000..7e6035c
--- /dev/null
+++ b/lib/google-oauth.js
@@ -0,0 +1,48 @@
+const { google } = require('googleapis');
+
+const SCOPES = [
+  'https://www.googleapis.com/auth/gmail.readonly',
+  'https://www.googleapis.com/auth/userinfo.email',
+];
+
+function client() {
+  const id = process.env.GOOGLE_OAUTH_OWNERSHIPOS_CLIENT_ID;
+  const secret = process.env.GOOGLE_OAUTH_OWNERSHIPOS_CLIENT_SECRET;
+  const redirect = process.env.GOOGLE_OAUTH_OWNERSHIPOS_REDIRECT_URI;
+  if (!id || !secret || !redirect) {
+    throw new Error('Google OAuth env vars not configured. Set GOOGLE_OAUTH_OWNERSHIPOS_{CLIENT_ID,CLIENT_SECRET,REDIRECT_URI} via /secrets.');
+  }
+  return new google.auth.OAuth2(id, secret, redirect);
+}
+
+function authUrl(state) {
+  return client().generateAuthUrl({
+    access_type: 'offline',
+    prompt: 'consent',
+    scope: SCOPES,
+    state,
+  });
+}
+
+async function exchange(code) {
+  const c = client();
+  const { tokens } = await c.getToken(code);
+  c.setCredentials(tokens);
+  const oauth2 = google.oauth2({ version: 'v2', auth: c });
+  const me = await oauth2.userinfo.get();
+  return {
+    refreshToken: tokens.refresh_token,
+    accessToken: tokens.access_token,
+    expiryDate: tokens.expiry_date,
+    email: me.data.email,
+    sub: me.data.id,
+  };
+}
+
+function clientFor(refreshToken) {
+  const c = client();
+  c.setCredentials({ refresh_token: refreshToken });
+  return c;
+}
+
+module.exports = { SCOPES, authUrl, exchange, clientFor };
diff --git a/lib/ids.js b/lib/ids.js
new file mode 100644
index 0000000..c6a9835
--- /dev/null
+++ b/lib/ids.js
@@ -0,0 +1,19 @@
+const { ulid } = require('ulid');
+
+const PREFIX = {
+  user: 'user',
+  consent: 'cnst',
+  connector: 'conn',
+  source: 'src',
+  document: 'doc',
+  purchase: 'pur',
+  item: 'item',
+};
+
+function id(kind) {
+  const prefix = PREFIX[kind];
+  if (!prefix) throw new Error(`Unknown id kind: ${kind}`);
+  return `${prefix}_${ulid().toLowerCase()}`;
+}
+
+module.exports = { id };
diff --git a/lib/receipt-extractor.js b/lib/receipt-extractor.js
new file mode 100644
index 0000000..36d8c30
--- /dev/null
+++ b/lib/receipt-extractor.js
@@ -0,0 +1,163 @@
+// Tier 1: heuristic extractor over Gmail message summaries.
+// Tier 2: local Ollama qwen3:14b fallback on Mac1 (TODO — invoked when confidence < 0.7).
+// Tier 3: PDF parsing — TODO (will need pdfjs-dist or similar).
+
+const MERCHANT_DOMAIN_OVERRIDES = {
+  'amazon.com': 'Amazon',
+  'amazon.co.uk': 'Amazon',
+  'amzn.com': 'Amazon',
+  'shopify.com': null, // shopify is a platform; merchant is in subdomain or display name
+  'doordash.com': 'DoorDash',
+  'uber.com': 'Uber',
+  'ubereats.com': 'Uber Eats',
+  'lyft.com': 'Lyft',
+  'apple.com': 'Apple',
+  'paypal.com': 'PayPal',
+  'stripe.com': null,
+  'squareup.com': null,
+  'ebay.com': 'eBay',
+  'etsy.com': 'Etsy',
+  'walmart.com': 'Walmart',
+  'target.com': 'Target',
+  'bestbuy.com': 'Best Buy',
+  'homedepot.com': 'Home Depot',
+  'lowes.com': "Lowe's",
+};
+
+function parseSenderEmail(fromHeader) {
+  if (!fromHeader) return { displayName: null, email: null, domain: null };
+  const m = fromHeader.match(/(?:"?([^"<]*)"?\s*)?<?([^\s<>]+@[^\s<>]+)>?/);
+  if (!m) return { displayName: fromHeader.trim(), email: null, domain: null };
+  const email = m[2].toLowerCase();
+  const domain = email.split('@')[1] || null;
+  const displayName = (m[1] || '').trim() || null;
+  return { displayName, email, domain };
+}
+
+function rootDomain(domain) {
+  if (!domain) return null;
+  const parts = domain.split('.');
+  if (parts.length <= 2) return domain;
+  return parts.slice(-2).join('.');
+}
+
+function inferMerchant({ from, subject }) {
+  const { displayName, domain } = parseSenderEmail(from);
+  const root = rootDomain(domain);
+  if (root && Object.prototype.hasOwnProperty.call(MERCHANT_DOMAIN_OVERRIDES, root)) {
+    const override = MERCHANT_DOMAIN_OVERRIDES[root];
+    if (override) return { name: override, domain: root, source: 'override' };
+  }
+  if (displayName && displayName.length > 1 && !displayName.includes('@')) {
+    const cleaned = displayName.replace(/\b(team|orders?|receipts?|sales|noreply|no-reply|info|support|hello)\b/gi, '').replace(/[<>"']/g, '').trim();
+    if (cleaned) return { name: cleaned, domain: root, source: 'display_name' };
+  }
+  if (root) {
+    const stem = root.split('.')[0];
+    return { name: stem.charAt(0).toUpperCase() + stem.slice(1), domain: root, source: 'domain_stem' };
+  }
+  if (subject) {
+    const m = subject.match(/from\s+([A-Z][\w& ]{2,40})/);
+    if (m) return { name: m[1].trim(), domain: null, source: 'subject' };
+  }
+  return { name: 'Unknown', domain: null, source: 'fallback' };
+}
+
+const CURRENCY_SYMBOLS = { '$': 'USD', '€': 'EUR', '£': 'GBP', '¥': 'JPY' };
+
+function extractTotal(text) {
+  if (!text) return { amount: null, currency: null };
+  // Look for "total" / "grand total" / "order total" / "amount charged" near a money value
+  const re = /(?:order\s+total|grand\s+total|total\s+charged|amount\s+charged|total)[\s:$£€¥]*([£€¥$])\s*([0-9]{1,3}(?:[,0-9]{0,3})*(?:\.[0-9]{2})?)/i;
+  const m = text.match(re);
+  if (m) {
+    const symbol = m[1];
+    const value = parseFloat(m[2].replace(/,/g, ''));
+    if (!isNaN(value)) return { amount: value, currency: CURRENCY_SYMBOLS[symbol] || 'USD' };
+  }
+  // Fallback: any money value
+  const fallback = text.match(/([£€¥$])\s*([0-9]{1,3}(?:[,0-9]{0,3})*\.[0-9]{2})/);
+  if (fallback) {
+    const symbol = fallback[1];
+    const value = parseFloat(fallback[2].replace(/,/g, ''));
+    if (!isNaN(value)) return { amount: value, currency: CURRENCY_SYMBOLS[symbol] || 'USD' };
+  }
+  return { amount: null, currency: null };
+}
+
+function extractOrderNumber(text) {
+  if (!text) return null;
+  const patterns = [
+    /order\s*(?:number|#|id)?\s*[:#]?\s*([A-Z0-9]{3,4}-?\d{3,}-?[A-Z0-9]+)/i,
+    /order\s*(?:number|#|id)?\s*[:#]?\s*(#?\d{6,})/i,
+    /confirmation\s*(?:number|#|code)?\s*[:#]?\s*([A-Z0-9-]{6,})/i,
+    /invoice\s*(?:number|#)?\s*[:#]?\s*([A-Z0-9-]{4,})/i,
+  ];
+  for (const re of patterns) {
+    const m = text.match(re);
+    if (m) return m[1].replace(/^#/, '').trim();
+  }
+  return null;
+}
+
+function stripHtml(html) {
+  if (!html) return '';
+  return html
+    .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+    .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/g, ' ')
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+/**
+ * Extract a purchase from a Gmail message summary (the output of gmail-fetcher.summarize()).
+ * Returns { merchant, merchantDomain, orderNumber, total, currency, purchaseDate, confidence, items, source: 'heuristic' }
+ * — or null if the message clearly isn't a receipt.
+ */
+function extract(summary) {
+  const subject = summary.headers?.subject || '';
+  const from = summary.headers?.from || '';
+  const dateHeader = summary.headers?.date || null;
+  const purchaseDate = dateHeader ? new Date(dateHeader) : new Date();
+
+  const haystack = [subject, summary.body?.text || '', stripHtml(summary.body?.html || '')].filter(Boolean).join('\n');
+
+  // Quick reject: clearly not a receipt
+  const lowerSubj = subject.toLowerCase();
+  if (/unsubscribe|newsletter|account\s+statement\s+is\s+ready|password\s+reset|verify\s+your\s+email/.test(lowerSubj)) {
+    return null;
+  }
+
+  const { name: merchantName, domain: merchantDomain } = inferMerchant({ from, subject });
+  const { amount: total, currency } = extractTotal(haystack);
+  const orderNumber = extractOrderNumber(haystack);
+
+  // Confidence
+  let confidence = 0.0;
+  if (total != null) confidence += 0.4;
+  if (orderNumber) confidence += 0.25;
+  if (/receipt|order\s+(?:confirmation|placed|shipped|delivered)|invoice|thank\s+you\s+for\s+your\s+order|thanks\s+for\s+your\s+order|purchase\s+confirmation/i.test(haystack)) confidence += 0.25;
+  if (merchantName && merchantName !== 'Unknown') confidence += 0.1;
+  confidence = Math.min(1, Math.round(confidence * 100) / 100);
+
+  if (confidence < 0.3) return null; // probably not a receipt
+
+  return {
+    merchant: merchantName,
+    merchantDomain,
+    orderNumber,
+    total,
+    currency: currency || 'USD',
+    purchaseDate,
+    confidence,
+    items: [],
+    source: 'heuristic',
+  };
+}
+
+module.exports = { extract, inferMerchant, extractTotal, extractOrderNumber, stripHtml };
diff --git a/logs/.gitkeep b/logs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8da5806
--- /dev/null
+++ b/package.json
@@ -0,0 +1,33 @@
+{
+  "name": "ownershipos",
+  "version": "0.1.0",
+  "description": "Household rights operating system — receipts, warranties, recalls, claims",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "nodemon server.js",
+    "test": "node --test tests/",
+    "migrate": "psql -d ownership_os -f db/schema.sql",
+    "seed": "psql -d ownership_os -f db/seed.sql"
+  },
+  "dependencies": {
+    "cookie-session": "^2.1.0",
+    "dotenv": "^16.4.5",
+    "ejs": "^3.1.10",
+    "express": "^4.21.0",
+    "googleapis": "^144.0.0",
+    "helmet": "^8.0.0",
+    "morgan": "^1.10.0",
+    "pg": "^8.13.0",
+    "ulid": "^2.3.0"
+  },
+  "devDependencies": {
+    "nodemon": "^3.1.7"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "author": "steve@designerwallcoverings.com",
+  "license": "UNLICENSED",
+  "private": true
+}
diff --git a/public/css/app.css b/public/css/app.css
new file mode 100644
index 0000000..5dfd4a3
--- /dev/null
+++ b/public/css/app.css
@@ -0,0 +1,253 @@
+/* OwnershipOS — liquid-glass UI tokens */
+
+:root {
+  --bg-0: #0a0f1c;
+  --bg-1: #0f172a;
+  --surface: rgba(255, 255, 255, 0.045);
+  --surface-strong: rgba(255, 255, 255, 0.07);
+  --border: rgba(255, 255, 255, 0.10);
+  --border-strong: rgba(255, 255, 255, 0.18);
+  --text: #e6edf6;
+  --text-dim: rgba(230, 237, 246, 0.66);
+  --accent: #7dd3fc;
+  --accent-strong: #38bdf8;
+  --warn: #fbbf24;
+  --danger: #f87171;
+  --ok: #86efac;
+  --radius: 18px;
+  --radius-sm: 10px;
+  --blur: 22px;
+  --shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 12px 40px rgba(0,0,0,0.35);
+  --space: 14px;
+  --space-lg: 24px;
+  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", "Helvetica Neue", Arial, sans-serif;
+}
+
+html[data-theme="light"] {
+  --bg-0: #f4f6fb;
+  --bg-1: #eaeef6;
+  --surface: rgba(255, 255, 255, 0.65);
+  --surface-strong: rgba(255, 255, 255, 0.85);
+  --border: rgba(15, 23, 42, 0.08);
+  --border-strong: rgba(15, 23, 42, 0.16);
+  --text: #0f172a;
+  --text-dim: rgba(15, 23, 42, 0.6);
+  --accent: #0284c7;
+  --accent-strong: #0369a1;
+  --shadow: 0 1px 0 rgba(255,255,255,0.6) inset, 0 12px 40px rgba(15,23,42,0.08);
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+  margin: 0;
+  padding: 0;
+  background: radial-gradient(1200px 800px at 20% -10%, rgba(125, 211, 252, 0.10), transparent 60%),
+              radial-gradient(1000px 700px at 110% 100%, rgba(168, 85, 247, 0.10), transparent 60%),
+              linear-gradient(180deg, var(--bg-0), var(--bg-1));
+  color: var(--text);
+  min-height: 100vh;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { color: var(--accent-strong); }
+
+main {
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: var(--space-lg);
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-lg);
+}
+
+.glass {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  backdrop-filter: blur(var(--blur));
+  -webkit-backdrop-filter: blur(var(--blur));
+  box-shadow: var(--shadow);
+}
+
+.topbar {
+  display: flex;
+  align-items: center;
+  gap: var(--space-lg);
+  padding: 12px 22px;
+  margin: 14px;
+  border-radius: var(--radius);
+  position: sticky;
+  top: 14px;
+  z-index: 10;
+}
+
+.brand a {
+  font-weight: 600;
+  font-size: 17px;
+  letter-spacing: -0.01em;
+  color: var(--text);
+}
+
+.brand .version {
+  margin-left: 8px;
+  font-size: 11px;
+  padding: 2px 7px;
+  border-radius: 999px;
+  background: var(--surface-strong);
+  color: var(--text-dim);
+}
+
+nav {
+  display: flex;
+  gap: 18px;
+  margin-left: auto;
+}
+nav a {
+  color: var(--text-dim);
+  font-size: 14px;
+  padding: 6px 4px;
+}
+nav a:hover { color: var(--text); }
+
+.theme-toggle {
+  background: var(--surface-strong);
+  color: var(--text);
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  width: 36px; height: 36px;
+  display: inline-flex; align-items: center; justify-content: center;
+  cursor: pointer;
+  transition: transform 120ms ease, background 120ms ease;
+}
+.theme-toggle:hover { transform: rotate(15deg); }
+
+footer {
+  max-width: 1200px;
+  margin: 0 auto var(--space-lg) auto;
+  padding: 14px 22px;
+  display: flex; gap: var(--space-lg);
+  color: var(--text-dim);
+  font-size: 12px;
+  border-radius: var(--radius);
+}
+
+.hero {
+  padding: 36px 28px;
+}
+.hero h1 {
+  margin: 0 0 6px 0;
+  font-size: 32px;
+  letter-spacing: -0.02em;
+  font-weight: 600;
+}
+.hero .subtle { color: var(--text-dim); margin: 0; }
+
+.stat-row {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: var(--space);
+}
+.stat { padding: 18px 20px; }
+.stat .num { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; }
+.stat .label { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
+
+.cta { padding: 22px 24px; }
+.cta h2 { margin: 0 0 6px 0; font-size: 18px; }
+.cta p { color: var(--text-dim); margin: 0 0 14px 0; }
+
+.button {
+  display: inline-flex;
+  align-items: center; justify-content: center;
+  padding: 9px 16px;
+  border-radius: 999px;
+  background: var(--surface-strong);
+  border: 1px solid var(--border-strong);
+  color: var(--text);
+  font-size: 14px;
+  cursor: pointer;
+  transition: transform 80ms ease, background 120ms ease;
+}
+.button:hover { transform: translateY(-1px); background: rgba(255,255,255,0.10); }
+.button.primary {
+  background: linear-gradient(180deg, var(--accent), var(--accent-strong));
+  border-color: transparent;
+  color: #04222f;
+  font-weight: 600;
+}
+.button:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.page-head {
+  display: flex; align-items: center; gap: var(--space);
+  flex-wrap: wrap;
+}
+.page-head h1 { margin: 0; font-size: 22px; flex: 1; }
+
+.grid-controls {
+  display: flex; gap: var(--space-lg); align-items: center;
+  font-size: 12px; color: var(--text-dim);
+}
+.grid-controls label { display: flex; flex-direction: column; gap: 4px; }
+.grid-controls select {
+  background: var(--surface-strong);
+  color: var(--text);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 6px 10px;
+}
+.grid-controls input[type="range"] { width: 160px; accent-color: var(--accent); }
+
+.purchase-grid {
+  display: grid;
+  gap: var(--space);
+}
+.purchase {
+  padding: 16px 18px;
+  display: flex; flex-direction: column; gap: 10px;
+  transition: transform 120ms ease;
+}
+.purchase:hover { transform: translateY(-1px); }
+.purchase header { display: flex; align-items: baseline; justify-content: space-between; }
+.purchase h3 { margin: 0; font-size: 15px; font-weight: 600; }
+.purchase .amount { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
+.purchase .meta dt { font-size: 11px; color: var(--text-dim); }
+.purchase .meta dd { margin: 0 0 6px 0; font-size: 13px; }
+.confidence {
+  font-size: 11px; padding: 2px 7px;
+  border-radius: 999px; background: var(--surface-strong);
+  color: var(--text-dim);
+}
+
+.connector-list { display: grid; gap: var(--space); }
+.connector { padding: 18px 20px; }
+.connector header { display: flex; align-items: center; justify-content: space-between; }
+.connector h3 { margin: 0; font-size: 15px; }
+.connector .meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 12px 0 16px 0; }
+.connector dt { color: var(--text-dim); font-size: 12px; }
+.connector dd { margin: 0; font-size: 13px; }
+.badge { font-size: 11px; padding: 3px 9px; border-radius: 999px; }
+.badge.active { background: rgba(134, 239, 172, 0.15); color: var(--ok); }
+.badge.revoked { background: rgba(248, 113, 113, 0.15); color: var(--danger); }
+.sync-status { font-size: 12px; color: var(--text-dim); margin-left: 12px; }
+
+.empty { padding: 30px; text-align: center; color: var(--text-dim); }
+.error { padding: 24px; }
+.error pre { background: rgba(0,0,0,0.25); padding: 12px; border-radius: var(--radius-sm); overflow-x: auto; }
+
+.recent { padding: 22px 24px; }
+.recent h2 { margin: 0 0 12px 0; font-size: 18px; }
+.recent-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
+.recent-list li { display: flex; gap: 12px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
+.recent-list li:last-child { border-bottom: 0; }
+.recent-list .merchant { flex: 1; font-weight: 500; }
+.recent-list .amount { color: var(--text-dim); font-variant-numeric: tabular-nums; }
+.recent-list .date { color: var(--text-dim); font-size: 12px; min-width: 80px; text-align: right; }
+
+@media (max-width: 640px) {
+  .topbar { flex-wrap: wrap; }
+  nav { order: 2; width: 100%; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  * { transition: none !important; }
+}
diff --git a/public/js/sort-density.js b/public/js/sort-density.js
new file mode 100644
index 0000000..41cb5b1
--- /dev/null
+++ b/public/js/sort-density.js
@@ -0,0 +1,48 @@
+// Canonical sort + density slider for OwnershipOS grids.
+// Inherits the pattern from /sort-skill (corkwallcovering, dw-collections-viewer).
+// Persists choices in localStorage so they survive reloads.
+
+(function () {
+  const grid = document.getElementById('purchaseGrid');
+  if (!grid) return;
+
+  const sortSelect = document.getElementById('sortSelect');
+  const densityRange = document.getElementById('densityRange');
+
+  const SORT_KEY = 'ownershipos.sort.purchase';
+  const DENSITY_KEY = 'ownershipos.density.purchase';
+
+  function applyDensity(px) {
+    grid.style.gridTemplateColumns = `repeat(auto-fill, minmax(${px}px, 1fr))`;
+  }
+
+  function applySort(mode) {
+    const cards = Array.from(grid.children);
+    const cmp = {
+      'date-desc': (a, b) => new Date(b.dataset.date) - new Date(a.dataset.date),
+      'date-asc': (a, b) => new Date(a.dataset.date) - new Date(b.dataset.date),
+      'merchant': (a, b) => a.dataset.merchant.localeCompare(b.dataset.merchant),
+      'amount-desc': (a, b) => Number(b.dataset.amount) - Number(a.dataset.amount),
+      'amount-asc': (a, b) => Number(a.dataset.amount) - Number(b.dataset.amount),
+    }[mode] || (() => 0);
+    cards.sort(cmp).forEach((c) => grid.appendChild(c));
+  }
+
+  // Restore
+  try {
+    const savedSort = localStorage.getItem(SORT_KEY);
+    if (savedSort && sortSelect) { sortSelect.value = savedSort; applySort(savedSort); }
+    const savedDensity = localStorage.getItem(DENSITY_KEY);
+    if (savedDensity && densityRange) { densityRange.value = savedDensity; applyDensity(Number(savedDensity)); }
+  } catch (e) {}
+
+  // Wire
+  sortSelect?.addEventListener('change', (e) => {
+    applySort(e.target.value);
+    try { localStorage.setItem(SORT_KEY, e.target.value); } catch (_) {}
+  });
+  densityRange?.addEventListener('input', (e) => {
+    applyDensity(Number(e.target.value));
+    try { localStorage.setItem(DENSITY_KEY, e.target.value); } catch (_) {}
+  });
+})();
diff --git a/public/js/theme.js b/public/js/theme.js
new file mode 100644
index 0000000..cc47077
--- /dev/null
+++ b/public/js/theme.js
@@ -0,0 +1,10 @@
+(function () {
+  const btn = document.getElementById('themeToggle');
+  if (!btn) return;
+  btn.addEventListener('click', () => {
+    const cur = document.documentElement.dataset.theme || 'dark';
+    const next = cur === 'dark' ? 'light' : 'dark';
+    document.documentElement.dataset.theme = next;
+    try { localStorage.setItem('theme', next); } catch (e) {}
+  });
+})();
diff --git a/routes/auth.js b/routes/auth.js
new file mode 100644
index 0000000..e2dc1d1
--- /dev/null
+++ b/routes/auth.js
@@ -0,0 +1,81 @@
+const express = require('express');
+const crypto = require('crypto');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { encrypt } = require('../lib/crypto');
+const { authUrl, exchange, SCOPES } = require('../lib/google-oauth');
+
+const router = express.Router();
+
+const DEV_USER_ID = 'user_steve';
+
+router.get('/auth/google/start', (req, res) => {
+  try {
+    const state = crypto.randomBytes(16).toString('hex');
+    req.session = req.session || {};
+    req.session.oauthState = state;
+    res.redirect(authUrl(state));
+  } catch (err) {
+    res.status(500).render('error', { error: err.message });
+  }
+});
+
+router.get('/auth/google/callback', async (req, res) => {
+  const { code, state, error } = req.query;
+  if (error) return res.status(400).render('error', { error: `Google returned: ${error}` });
+  if (!code) return res.status(400).render('error', { error: 'Missing authorization code' });
+  if (req.session?.oauthState && req.session.oauthState !== state) {
+    return res.status(400).render('error', { error: 'OAuth state mismatch (possible CSRF)' });
+  }
+
+  try {
+    const tokens = await exchange(code);
+    if (!tokens.refreshToken) {
+      return res.status(400).render('error', {
+        error: 'Google did not return a refresh token. Revoke the app from your Google Account → Security → Third-party access, then try again.',
+      });
+    }
+
+    const consentId = id('consent');
+    const connectorId = id('connector');
+
+    const enc = encrypt(tokens.refreshToken);
+
+    await db.tx(async (client) => {
+      await client.query(
+        `INSERT INTO consent_grant (id, user_id, connector_type, scopes_json)
+         VALUES ($1, $2, $3, $4)`,
+        [consentId, DEV_USER_ID, 'gmail', JSON.stringify(SCOPES)]
+      );
+      await client.query(
+        `INSERT INTO connector_account
+           (id, user_id, consent_grant_id, provider, external_subject_id,
+            refresh_token_encrypted, refresh_token_iv, refresh_token_tag)
+         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+         ON CONFLICT (provider, external_subject_id) DO UPDATE SET
+           refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
+           refresh_token_iv = EXCLUDED.refresh_token_iv,
+           refresh_token_tag = EXCLUDED.refresh_token_tag,
+           consent_grant_id = EXCLUDED.consent_grant_id`,
+        [connectorId, DEV_USER_ID, consentId, 'google', tokens.email, enc.ciphertext, enc.iv, enc.tag]
+      );
+    });
+
+    await audit.log({
+      actorType: 'user',
+      actorId: DEV_USER_ID,
+      objectType: 'connector_account',
+      objectId: connectorId,
+      eventType: 'oauth_grant',
+      metadata: { provider: 'google', email: tokens.email, scopes: SCOPES },
+    });
+
+    res.redirect('/connectors');
+  } catch (err) {
+    console.error('[oauth callback]', err);
+    res.status(500).render('error', { error: err.message });
+  }
+});
+
+module.exports = router;
diff --git a/routes/connectors.js b/routes/connectors.js
new file mode 100644
index 0000000..2829b56
--- /dev/null
+++ b/routes/connectors.js
@@ -0,0 +1,162 @@
+const express = require('express');
+const crypto = require('crypto');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { decrypt } = require('../lib/crypto');
+const fetcher = require('../lib/gmail-fetcher');
+const extractor = require('../lib/receipt-extractor');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+router.get('/connectors', async (_req, res) => {
+  const result = await db.query(
+    `SELECT ca.id, ca.provider, ca.external_subject_id, ca.last_sync_at,
+            cg.granted_at, cg.revoked_at,
+            (SELECT count(*) FROM source_message sm WHERE sm.connector_id = ca.id)::int AS message_count
+       FROM connector_account ca
+       JOIN consent_grant cg ON cg.id = ca.consent_grant_id
+      WHERE ca.user_id = $1
+      ORDER BY cg.granted_at DESC`,
+    [DEV_USER_ID]
+  );
+  res.render('connectors', { connectors: result.rows });
+});
+
+router.get('/api/connectors', async (_req, res) => {
+  const result = await db.query(
+    `SELECT id, provider, external_subject_id, last_sync_at FROM connector_account WHERE user_id = $1`,
+    [DEV_USER_ID]
+  );
+  res.json(result.rows);
+});
+
+router.post('/api/connectors/:id/sync', async (req, res) => {
+  const connectorId = req.params.id;
+  const result = await db.query(
+    `SELECT id, refresh_token_encrypted, refresh_token_iv, refresh_token_tag
+       FROM connector_account WHERE id = $1 AND user_id = $2`,
+    [connectorId, DEV_USER_ID]
+  );
+  if (!result.rows.length) return res.status(404).json({ error: 'connector not found' });
+  const c = result.rows[0];
+
+  let refreshToken;
+  try {
+    refreshToken = decrypt(c.refresh_token_encrypted, c.refresh_token_iv, c.refresh_token_tag);
+  } catch (err) {
+    return res.status(500).json({ error: 'failed to decrypt refresh token: ' + err.message });
+  }
+
+  await audit.log({
+    actorType: 'user',
+    actorId: DEV_USER_ID,
+    objectType: 'connector_account',
+    objectId: connectorId,
+    eventType: 'sync_started',
+  });
+
+  let inserted = 0;
+  let purchases = 0;
+  try {
+    const ids = await fetcher.listReceiptIds(refreshToken, { max: 50 });
+    for (const mid of ids) {
+      // Skip if already ingested
+      const existing = await db.query(
+        `SELECT id FROM source_message WHERE connector_id = $1 AND source_type = 'gmail' AND external_id = $2`,
+        [connectorId, mid]
+      );
+      if (existing.rows.length) continue;
+
+      const message = await fetcher.getMessage(refreshToken, mid);
+      const summary = fetcher.summarize(message);
+      const payloadHash = crypto.createHash('sha256').update(JSON.stringify(message)).digest('hex');
+
+      const sourceId = id('source');
+      await db.query(
+        `INSERT INTO source_message
+           (id, connector_id, source_type, external_id, thread_id, received_at, subject, sender, recipient, payload_jsonb, payload_hash)
+         VALUES ($1, $2, 'gmail', $3, $4, $5, $6, $7, $8, $9, $10)
+         ON CONFLICT (connector_id, source_type, external_id) DO NOTHING`,
+        [
+          sourceId,
+          connectorId,
+          mid,
+          summary.threadId,
+          summary.headers.date ? new Date(summary.headers.date) : new Date(),
+          summary.headers.subject,
+          summary.headers.from,
+          summary.headers.to,
+          message,
+          payloadHash,
+        ]
+      );
+      inserted += 1;
+
+      await audit.log({
+        actorType: 'system',
+        objectType: 'source_message',
+        objectId: sourceId,
+        eventType: 'message_persisted',
+        metadata: { gmail_id: mid, subject: summary.headers.subject?.slice(0, 120) },
+      });
+
+      const extracted = extractor.extract(summary);
+      if (extracted) {
+        const purchaseId = id('purchase');
+        await db.query(
+          `INSERT INTO purchase
+             (id, user_id, source_message_id, merchant_name, merchant_domain, order_number, purchase_date, total_amount, currency, confidence, raw_extract)
+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
+          [
+            purchaseId,
+            DEV_USER_ID,
+            sourceId,
+            extracted.merchant,
+            extracted.merchantDomain,
+            extracted.orderNumber,
+            extracted.purchaseDate,
+            extracted.total,
+            extracted.currency,
+            extracted.confidence,
+            extracted,
+          ]
+        );
+        purchases += 1;
+
+        await audit.log({
+          actorType: 'system',
+          objectType: 'purchase',
+          objectId: purchaseId,
+          eventType: 'purchase_extracted',
+          metadata: { merchant: extracted.merchant, total: extracted.total, source: extracted.source, confidence: extracted.confidence },
+        });
+      }
+    }
+
+    await db.query(`UPDATE connector_account SET last_sync_at = now() WHERE id = $1`, [connectorId]);
+
+    await audit.log({
+      actorType: 'system',
+      objectType: 'connector_account',
+      objectId: connectorId,
+      eventType: 'sync_completed',
+      metadata: { fetched: ids.length, inserted, purchases },
+    });
+
+    res.json({ ok: true, fetched: ids.length, inserted, purchases });
+  } catch (err) {
+    console.error('[sync]', err);
+    await audit.log({
+      actorType: 'system',
+      objectType: 'connector_account',
+      objectId: connectorId,
+      eventType: 'sync_failed',
+      metadata: { error: err.message },
+    });
+    res.status(500).json({ error: err.message });
+  }
+});
+
+module.exports = router;
diff --git a/routes/health.js b/routes/health.js
new file mode 100644
index 0000000..a5f72c4
--- /dev/null
+++ b/routes/health.js
@@ -0,0 +1,14 @@
+const express = require('express');
+const db = require('../lib/db');
+const router = express.Router();
+
+router.get('/healthz', async (_req, res) => {
+  try {
+    const r = await db.query('SELECT 1 AS ok');
+    res.json({ ok: r.rows[0].ok === 1, db: 'up', pid: process.pid });
+  } catch (err) {
+    res.status(500).json({ ok: false, db: 'down', error: err.message });
+  }
+});
+
+module.exports = router;
diff --git a/routes/home.js b/routes/home.js
new file mode 100644
index 0000000..fb663a8
--- /dev/null
+++ b/routes/home.js
@@ -0,0 +1,26 @@
+const express = require('express');
+const db = require('../lib/db');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+router.get('/', async (_req, res) => {
+  const [connectorCount, purchaseCount, lastSync, recent] = await Promise.all([
+    db.query(`SELECT count(*)::int AS n FROM connector_account WHERE user_id = $1`, [DEV_USER_ID]),
+    db.query(`SELECT count(*)::int AS n FROM purchase WHERE user_id = $1`, [DEV_USER_ID]),
+    db.query(`SELECT max(last_sync_at) AS t FROM connector_account WHERE user_id = $1`, [DEV_USER_ID]),
+    db.query(
+      `SELECT id, merchant_name, total_amount, currency, purchase_date
+         FROM purchase WHERE user_id = $1 ORDER BY purchase_date DESC LIMIT 6`,
+      [DEV_USER_ID]
+    ),
+  ]);
+  res.render('home', {
+    connectorCount: connectorCount.rows[0].n,
+    purchaseCount: purchaseCount.rows[0].n,
+    lastSync: lastSync.rows[0].t,
+    recent: recent.rows,
+  });
+});
+
+module.exports = router;
diff --git a/routes/purchases.js b/routes/purchases.js
new file mode 100644
index 0000000..ea87310
--- /dev/null
+++ b/routes/purchases.js
@@ -0,0 +1,36 @@
+const express = require('express');
+const db = require('../lib/db');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+router.get('/purchases', async (_req, res) => {
+  const result = await db.query(
+    `SELECT id, merchant_name, merchant_domain, order_number, purchase_date, total_amount, currency, confidence
+       FROM purchase WHERE user_id = $1 ORDER BY purchase_date DESC LIMIT 500`,
+    [DEV_USER_ID]
+  );
+  res.render('purchases', { purchases: result.rows });
+});
+
+router.get('/api/purchases', async (_req, res) => {
+  const result = await db.query(
+    `SELECT id, merchant_name, merchant_domain, order_number, purchase_date, total_amount, currency, confidence
+       FROM purchase WHERE user_id = $1 ORDER BY purchase_date DESC LIMIT 500`,
+    [DEV_USER_ID]
+  );
+  res.json(result.rows);
+});
+
+router.get('/api/purchases/:id', async (req, res) => {
+  const result = await db.query(
+    `SELECT p.*, sm.subject, sm.sender, sm.received_at AS message_received_at
+       FROM purchase p LEFT JOIN source_message sm ON sm.id = p.source_message_id
+      WHERE p.id = $1 AND p.user_id = $2`,
+    [req.params.id, DEV_USER_ID]
+  );
+  if (!result.rows.length) return res.status(404).json({ error: 'not found' });
+  res.json(result.rows[0]);
+});
+
+module.exports = router;
diff --git a/scripts/deploy-kamatera.sh b/scripts/deploy-kamatera.sh
new file mode 100755
index 0000000..886a3b6
--- /dev/null
+++ b/scripts/deploy-kamatera.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# Deferred. OwnershipOS stays local-only until Steve confirms the Gmail flow ingests his real receipts cleanly.
+# When ready: rsync to /root/Projects/OwnershipOS, install deps, migrate PG, register pm2, point CF + LE.
+echo "[deploy-kamatera] not implemented — local-only by design today. See docs/ROADMAP.md."
+exit 1
diff --git a/scripts/gen-encryption-key.sh b/scripts/gen-encryption-key.sh
new file mode 100755
index 0000000..8c011e9
--- /dev/null
+++ b/scripts/gen-encryption-key.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# Generate a fresh 32-byte encryption key for ENCRYPTION_KEY.
+# Route via /secrets — do NOT paste into committed files.
+openssl rand -hex 32
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..0aa3ac8
--- /dev/null
+++ b/server.js
@@ -0,0 +1,60 @@
+require('dotenv').config();
+const express = require('express');
+const helmet = require('helmet');
+const morgan = require('morgan');
+const path = require('path');
+const cookieSession = require('cookie-session');
+
+const home = require('./routes/home');
+const health = require('./routes/health');
+const auth = require('./routes/auth');
+const connectors = require('./routes/connectors');
+const purchases = require('./routes/purchases');
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9931', 10);
+
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, 'views'));
+
+app.use(helmet({
+  contentSecurityPolicy: false, // dev — tighten in M1 (Foundation)
+  crossOriginEmbedderPolicy: false,
+}));
+app.use(morgan('tiny'));
+app.use(express.json({ limit: '2mb' }));
+app.use(express.urlencoded({ extended: true }));
+
+app.use(cookieSession({
+  name: 'oos.sid',
+  keys: [process.env.SESSION_SECRET || 'dev-only-rotate-me'],
+  maxAge: 24 * 60 * 60 * 1000,
+  sameSite: 'lax',
+  httpOnly: true,
+  secure: false, // dev http
+}));
+
+// Make INFO_EMAIL available to all views
+app.locals.infoEmail = process.env.INFO_EMAIL || 'info@ownershipos.agentabrams.com';
+app.use((req, res, next) => { res.locals.infoEmail = app.locals.infoEmail; next(); });
+
+app.use(express.static(path.join(__dirname, 'public'), { maxAge: '0' }));
+
+app.use(health);
+app.use(home);
+app.use(auth);
+app.use(connectors);
+app.use(purchases);
+
+app.use((err, _req, res, _next) => {
+  console.error('[unhandled]', err);
+  res.status(500).render('error', { error: err.message });
+});
+
+if (require.main === module) {
+  app.listen(PORT, () => {
+    console.log(`[ownershipos] listening on http://localhost:${PORT}`);
+  });
+}
+
+module.exports = app;
diff --git a/tests/extractor.test.js b/tests/extractor.test.js
new file mode 100644
index 0000000..55d62fa
--- /dev/null
+++ b/tests/extractor.test.js
@@ -0,0 +1,49 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const { extract, inferMerchant, extractTotal, extractOrderNumber } = require('../lib/receipt-extractor');
+
+test('extractTotal finds order total', () => {
+  const { amount, currency } = extractTotal('Order Total: $42.99');
+  assert.strictEqual(amount, 42.99);
+  assert.strictEqual(currency, 'USD');
+});
+
+test('extractTotal handles thousands separator', () => {
+  const { amount } = extractTotal('Grand Total: $1,234.56');
+  assert.strictEqual(amount, 1234.56);
+});
+
+test('extractOrderNumber finds Amazon-style', () => {
+  const id = extractOrderNumber('Your order #112-4455667-1234567 has shipped');
+  assert.strictEqual(id, '112-4455667-1234567');
+});
+
+test('inferMerchant uses display name', () => {
+  const r = inferMerchant({ from: '"Best Buy Orders" <orders@bestbuy.com>', subject: '' });
+  assert.match(r.name, /Best Buy/);
+});
+
+test('extract returns null for non-receipt', () => {
+  const summary = {
+    headers: { subject: 'Verify your email', from: 'no-reply@some.com', date: new Date().toISOString() },
+    body: { text: 'Click here to verify your email address.' },
+  };
+  assert.strictEqual(extract(summary), null);
+});
+
+test('extract returns purchase for shaped receipt', () => {
+  const summary = {
+    headers: {
+      subject: 'Your Amazon.com order #112-7654321-7654321',
+      from: '"Amazon.com" <auto-confirm@amazon.com>',
+      date: new Date().toISOString(),
+    },
+    body: { text: 'Thanks for your order. Order Total: $24.99.', html: '' },
+  };
+  const e = extract(summary);
+  assert.ok(e, 'should extract');
+  assert.match(e.merchant, /Amazon/);
+  assert.strictEqual(e.total, 24.99);
+  assert.strictEqual(e.orderNumber, '112-7654321-7654321');
+  assert.ok(e.confidence >= 0.7);
+});
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
new file mode 100644
index 0000000..a07797d
--- /dev/null
+++ b/tests/smoke.test.js
@@ -0,0 +1,50 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+require('dotenv').config();
+const app = require('../server');
+
+let server;
+
+test.before(() => new Promise((resolve) => {
+  server = app.listen(0, resolve);
+}));
+
+test.after(() => new Promise((resolve) => server.close(resolve)));
+
+function get(p) {
+  return new Promise((resolve, reject) => {
+    const port = server.address().port;
+    http.get(`http://127.0.0.1:${port}${p}`, (res) => {
+      let body = '';
+      res.on('data', (c) => (body += c));
+      res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
+    }).on('error', reject);
+  });
+}
+
+test('healthz returns 200', async () => {
+  const r = await get('/healthz');
+  assert.strictEqual(r.status, 200);
+  const j = JSON.parse(r.body);
+  assert.strictEqual(j.ok, true);
+});
+
+test('home renders', async () => {
+  const r = await get('/');
+  assert.strictEqual(r.status, 200);
+  assert.match(r.body, /OwnershipOS/);
+});
+
+test('connectors page renders', async () => {
+  const r = await get('/connectors');
+  assert.strictEqual(r.status, 200);
+  assert.match(r.body, /Connect/i);
+});
+
+test('purchases page renders', async () => {
+  const r = await get('/purchases');
+  assert.strictEqual(r.status, 200);
+  assert.match(r.body, /Purchases/);
+});
diff --git a/views/connectors.ejs b/views/connectors.ejs
new file mode 100644
index 0000000..80907c0
--- /dev/null
+++ b/views/connectors.ejs
@@ -0,0 +1,61 @@
+<%- include('partials/header', { title: 'Connectors' }) %>
+
+<section class="page-head">
+  <h1>Connectors</h1>
+  <a class="button primary" href="/auth/google/start">Connect another Gmail</a>
+</section>
+
+<% if (!connectors.length) { %>
+  <section class="empty glass">
+    <p>No connectors yet. <a href="/auth/google/start">Connect Gmail</a> to start ingesting receipts.</p>
+  </section>
+<% } else { %>
+  <section class="connector-list">
+    <% connectors.forEach(c => { %>
+      <article class="connector glass">
+        <header>
+          <h3><%= c.provider %> · <%= c.external_subject_id %></h3>
+          <span class="badge <%= c.revoked_at ? 'revoked' : 'active' %>">
+            <%= c.revoked_at ? 'Revoked' : 'Active' %>
+          </span>
+        </header>
+        <dl class="meta">
+          <dt>Granted</dt><dd><%= new Date(c.granted_at).toLocaleString() %></dd>
+          <dt>Last sync</dt><dd><%= c.last_sync_at ? new Date(c.last_sync_at).toLocaleString() : 'never' %></dd>
+          <dt>Messages ingested</dt><dd><%= c.message_count %></dd>
+        </dl>
+        <% if (!c.revoked_at) { %>
+          <button class="button" data-sync-id="<%= c.id %>">Sync now</button>
+          <span class="sync-status" data-status-for="<%= c.id %>"></span>
+        <% } %>
+      </article>
+    <% }) %>
+  </section>
+<% } %>
+
+<script>
+  document.querySelectorAll('[data-sync-id]').forEach(btn => {
+    btn.addEventListener('click', async () => {
+      const id = btn.dataset.syncId;
+      const status = document.querySelector('[data-status-for="' + id + '"]');
+      btn.disabled = true;
+      status.textContent = 'Syncing…';
+      try {
+        const r = await fetch('/api/connectors/' + id + '/sync', { method: 'POST' });
+        const j = await r.json();
+        if (j.ok) {
+          status.textContent = `Fetched ${j.fetched}, inserted ${j.inserted}, ${j.purchases} purchases extracted.`;
+          setTimeout(() => location.reload(), 1500);
+        } else {
+          status.textContent = 'Error: ' + (j.error || 'unknown');
+        }
+      } catch (e) {
+        status.textContent = 'Error: ' + e.message;
+      } finally {
+        btn.disabled = false;
+      }
+    });
+  });
+</script>
+
+<%- include('partials/footer') %>
diff --git a/views/error.ejs b/views/error.ejs
new file mode 100644
index 0000000..8c04c96
--- /dev/null
+++ b/views/error.ejs
@@ -0,0 +1,9 @@
+<%- include('partials/header', { title: 'Error' }) %>
+
+<section class="error glass">
+  <h1>Something went wrong</h1>
+  <pre><%= error %></pre>
+  <a class="button" href="/">← Home</a>
+</section>
+
+<%- include('partials/footer') %>
diff --git a/views/home.ejs b/views/home.ejs
new file mode 100644
index 0000000..a288548
--- /dev/null
+++ b/views/home.ejs
@@ -0,0 +1,47 @@
+<%- include('partials/header', { title: 'Home' }) %>
+
+<section class="hero glass">
+  <h1>Your ownership, in one place.</h1>
+  <p class="subtle">OwnershipOS quietly tracks what you own, what risks apply, and what's worth doing about it.</p>
+</section>
+
+<section class="stat-row">
+  <div class="stat glass">
+    <div class="num"><%= connectorCount %></div>
+    <div class="label">Connectors</div>
+  </div>
+  <div class="stat glass">
+    <div class="num"><%= purchaseCount %></div>
+    <div class="label">Purchases ingested</div>
+  </div>
+  <div class="stat glass">
+    <div class="num"><%= lastSync ? new Date(lastSync).toLocaleString() : 'never' %></div>
+    <div class="label">Last sync</div>
+  </div>
+</section>
+
+<% if (connectorCount === 0) { %>
+  <section class="cta glass">
+    <h2>Connect your inbox to begin</h2>
+    <p>OwnershipOS reads receipts only — no sending, no scanning your other mail. You can revoke access any time.</p>
+    <a class="button primary" href="/auth/google/start">Connect Gmail</a>
+  </section>
+<% } %>
+
+<% if (recent && recent.length) { %>
+  <section class="recent glass">
+    <h2>Recent purchases</h2>
+    <ul class="recent-list">
+      <% recent.forEach(p => { %>
+        <li>
+          <span class="merchant"><%= p.merchant_name %></span>
+          <span class="amount"><%= p.total_amount ? (p.currency || 'USD') + ' ' + Number(p.total_amount).toFixed(2) : '—' %></span>
+          <span class="date"><%= new Date(p.purchase_date).toLocaleDateString() %></span>
+        </li>
+      <% }) %>
+    </ul>
+    <a class="link" href="/purchases">View all →</a>
+  </section>
+<% } %>
+
+<%- include('partials/footer') %>
diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs
new file mode 100644
index 0000000..45f8047
--- /dev/null
+++ b/views/partials/footer.ejs
@@ -0,0 +1,11 @@
+  </main>
+  <footer class="glass">
+    <small>Contact: <a href="mailto:<%= infoEmail %>"><%= infoEmail %></a></small>
+    <small>Drafts only — not legal advice. See docs/COMPLIANCE.md.</small>
+  </footer>
+  <script src="/js/theme.js"></script>
+  <% if (typeof gridScript !== 'undefined' && gridScript) { %>
+    <script src="/js/sort-density.js"></script>
+  <% } %>
+</body>
+</html>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
new file mode 100644
index 0000000..311e9b0
--- /dev/null
+++ b/views/partials/header.ejs
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title><%= typeof title !== 'undefined' ? title + ' · OwnershipOS' : 'OwnershipOS' %></title>
+  <link rel="stylesheet" href="/css/app.css">
+  <script>
+    (function () {
+      try {
+        var t = localStorage.getItem('theme') || 'dark';
+        document.documentElement.dataset.theme = t;
+      } catch (e) { document.documentElement.dataset.theme = 'dark'; }
+    })();
+  </script>
+</head>
+<body>
+  <header class="topbar glass">
+    <div class="brand">
+      <a href="/">OwnershipOS</a>
+      <span class="version">v0.1</span>
+    </div>
+    <nav>
+      <a href="/">Home</a>
+      <a href="/connectors">Connectors</a>
+      <a href="/purchases">Purchases</a>
+    </nav>
+    <button id="themeToggle" class="theme-toggle" aria-label="Toggle theme" title="Toggle dark/light">
+      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <circle cx="12" cy="12" r="4"></circle>
+        <path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41"></path>
+      </svg>
+    </button>
+  </header>
+  <main>
diff --git a/views/purchases.ejs b/views/purchases.ejs
new file mode 100644
index 0000000..f882b76
--- /dev/null
+++ b/views/purchases.ejs
@@ -0,0 +1,59 @@
+<%- include('partials/header', { title: 'Purchases' }) %>
+
+<section class="page-head">
+  <h1>Purchases</h1>
+  <div class="grid-controls">
+    <label>Sort
+      <select id="sortSelect">
+        <option value="date-desc">Newest</option>
+        <option value="date-asc">Oldest</option>
+        <option value="merchant">Merchant A→Z</option>
+        <option value="amount-desc">Total ↓</option>
+        <option value="amount-asc">Total ↑</option>
+      </select>
+    </label>
+    <label>Density
+      <input id="densityRange" type="range" min="180" max="420" step="10" value="280">
+    </label>
+  </div>
+</section>
+
+<% if (!purchases.length) { %>
+  <section class="empty glass">
+    <p>No purchases yet. <a href="/connectors">Sync a connector</a> to ingest receipts.</p>
+  </section>
+<% } else { %>
+  <section id="purchaseGrid" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));">
+    <% purchases.forEach(p => { %>
+      <article class="purchase glass"
+               data-merchant="<%= (p.merchant_name || '').toLowerCase() %>"
+               data-amount="<%= p.total_amount || 0 %>"
+               data-date="<%= new Date(p.purchase_date).toISOString() %>">
+        <header>
+          <h3><%= p.merchant_name %></h3>
+          <% if (p.confidence != null) { %>
+            <span class="confidence" title="Extraction confidence"><%= Math.round(Number(p.confidence) * 100) %>%</span>
+          <% } %>
+        </header>
+        <div class="amount">
+          <% if (p.total_amount) { %>
+            <%= p.currency || 'USD' %> <%= Number(p.total_amount).toFixed(2) %>
+          <% } else { %>
+            <span class="subtle">amount unknown</span>
+          <% } %>
+        </div>
+        <dl class="meta">
+          <dt>Date</dt><dd><%= new Date(p.purchase_date).toLocaleDateString() %></dd>
+          <% if (p.order_number) { %>
+            <dt>Order</dt><dd><%= p.order_number %></dd>
+          <% } %>
+          <% if (p.merchant_domain) { %>
+            <dt>Domain</dt><dd><%= p.merchant_domain %></dd>
+          <% } %>
+        </dl>
+      </article>
+    <% }) %>
+  </section>
+<% } %>
+
+<%- include('partials/footer', { gridScript: true }) %>

(oldest)  ·  back to AbramsOS  ·  smoke green: PG via /tmp socket (peer auth), test glob fix, ef35e84 →