Architecture
One Cloudflare Worker serves the web app, the API and the auth flows. D1 stores everything, Workers AI answers margin questions, and the browser extension talks to the same API with a personal access token. Obsidian is written from the reader's own machine.
flowchart LR
subgraph Browser
SPA[Web app<br/>React SPA]
EXT[Extension<br/>content script + service worker]
end
subgraph Machine[Reader's machine]
OBS[(Obsidian vault<br/>Local REST API)]
HOST[Lumen helper<br/>native messaging]
CLAUDE[claude -p]
OLLAMA[Ollama]
end
subgraph Cloudflare
W[Worker: Hono<br/>/auth /api/v1 + static assets]
D1[(D1: lumen-db)]
AI[Workers AI]
CRON[Cron: purge demos]
end
NOTION[Notion API]
HOOK[Your webhook]
IdP[Google / GitHub OAuth]
SPA -- cookie session --> W
EXT -- Bearer token --> W
W --> D1
W --> AI
CRON --> W
W -- push --> NOTION
W -- signed POST --> HOOK
W <-- OAuth 2 + PKCE --> IdP
SPA -- pending, write, ack --> OBS
EXT -- pending, write, ack --> OBS
EXT -- connectNative --> HOST --> CLAUDE
EXT --> OLLAMARepository layout
packages/core shared types, anchoring, citation, markdown formats, glow rules, prompts
apps/web Worker API (src/worker) + React SPA (src/client) + D1 migrations
apps/extension WXT Manifest V3 extension (content, background, popup, options)
apps/native-host Node helper that runs claude -p for the extension
docs plan, architecture, states, API, data model, deploy@lumen/core is imported as TypeScript source by every app, so the highlight anchoring, the
citation format and the markdown written to destinations are identical everywhere.
@lumen/core/dom holds the DOM parts and is never imported by the Worker.
Key decisions
| Decision | Why |
|---|---|
| One Worker with static assets, not Pages + Functions | One deploy, one origin (so the session cookie is first party), run_worker_first for /api and /auth only |
| D1 with plain SQL, no ORM | Small schema, batch writes, keyset pagination; nothing to generate |
| Sessions in D1, token hashed | Cookie holds a random token; the database stores only its SHA-256, so a database leak does not leak sessions |
| Personal access tokens for the extension | Simple, revocable, scoped to one user; CORS allows only extension origins |
CSRF: Origin check + X-Lumen header |
Cookie writes must come from our origin; tokens are immune by design |
| Text quote selectors (quote + prefix + suffix + position) | Survives reflowed markup and works the same in the page, the reader and the server; a W3C Web Annotation model |
| Delivery ledger table | The (highlight_id, destination_id) primary key makes "append only new highlights" a database guarantee, idempotent on retries |
| Obsidian written client side | The vault lives on the reader's machine; the Local REST API key never reaches the server |
| Claude through native messaging | Extensions cannot spawn processes; the helper passes the prompt on stdin, never through a shell |
| Workers AI with daily quotas | No keys to manage for the default experience; heavy users switch to local models |
| Demo sandbox | Lets anyone explore the dashboard without SSO; rows cascade delete after 3 days via Cron |
Request flows
Save and highlight from the extension
sequenceDiagram
participant P as Page (content script)
participant B as Service worker
participant W as Worker API
participant O as Obsidian
P->>P: user selects text, toolbar appears (selection untouched)
P->>B: ADD_HIGHLIGHT {quote, prefix, suffix, position, color, page html}
B->>W: GET /items/lookup?url=
alt not saved yet
B->>W: POST /items {url, html, collectionId}
W->>W: Readability extraction, glow +1
end
B->>W: POST /items/:id/highlights
W-->>B: highlight (glow +2)
B-->>P: highlight, paint mark
opt target collection has an Obsidian destination
B->>W: GET /destinations/:id/pending
W-->>B: rendered markdown + highlightIds (only undelivered)
B->>O: POST /vault/<note path> (append)
B->>W: POST /destinations/:id/ack {highlightIds}
endSign in with SSO
sequenceDiagram
participant U as Browser
participant W as Worker
participant G as Google / GitHub
U->>W: GET /auth/google/start?next=/app
W-->>U: 302 to provider, cookie lumen_oauth {state, verifier, next}
U->>G: consent
G-->>U: 302 /auth/google/callback?code&state
U->>W: callback
W->>W: check state, exchange code with PKCE verifier
W->>G: token + userinfo
W->>W: find account, else link by verified email, else create user + default collection
W-->>U: 302 next, Set-Cookie lumen_session (HttpOnly, Lax, 30 days)Security notes
- All rows carry
user_id; every query filters by it. Foreign keys cascade on user delete. - Article HTML is stored as extracted and sanitised with DOMPurify at render time.
- Webhooks are https only and signed with HMAC SHA-256 (
X-Lumen-Signature). - Notion tokens live in
destinations.secretand are never returned by the API. - Demo users cannot push to external services.
- The native helper only accepts
haiku,sonnet,opusas models and passes text on stdin.
Observability
Workers observability is on (observability.enabled). Errors are logged with context and
returned as { error: { code, message } }; the client never sees stack traces.