From c6ee5c0b3546aa0935918388011642caa2026473 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Mon, 13 Jul 2026 07:22:21 -0700 Subject: [PATCH] add atproto oauth login, sessions, design system, and app shell --- .claude/launch.json | 11 + DESIGN.md | 114 ++++++++ PRODUCT.md | 59 ++++ deno.lock | 227 +++++++++++++++ .../changes/bootstrap-finance-app/tasks.md | 8 +- package.json | 7 + src/app.css | 275 ++++++++++++++++++ src/app.d.ts | 4 +- src/app.html | 9 +- src/hooks.server.ts | 38 ++- src/lib/server/auth/oauth-client.ts | 119 ++++++++ src/lib/server/services/sessions.ts | 41 +++ src/lib/server/services/users.ts | 23 ++ src/routes/(app)/+layout.server.ts | 5 + src/routes/(app)/+layout.svelte | 188 ++++++++++++ src/routes/(app)/+page.svelte | 9 + src/routes/(app)/accounts/+page.svelte | 9 + src/routes/(app)/ledger/+page.svelte | 9 + src/routes/(app)/reports/+page.svelte | 9 + src/routes/(app)/settings/+page.svelte | 9 + src/routes/+layout.svelte | 2 + src/routes/+page.svelte | 2 - src/routes/client-metadata.json/+server.ts | 6 + src/routes/jwks.json/+server.ts | 6 + src/routes/login/+page.server.ts | 30 ++ src/routes/login/+page.svelte | 91 ++++++ src/routes/logout/+server.ts | 12 + src/routes/oauth/callback/+server.ts | 47 +++ 28 files changed, 1357 insertions(+), 12 deletions(-) create mode 100644 .claude/launch.json create mode 100644 DESIGN.md create mode 100644 PRODUCT.md create mode 100644 src/app.css create mode 100644 src/lib/server/auth/oauth-client.ts create mode 100644 src/lib/server/services/sessions.ts create mode 100644 src/lib/server/services/users.ts create mode 100644 src/routes/(app)/+layout.server.ts create mode 100644 src/routes/(app)/+layout.svelte create mode 100644 src/routes/(app)/+page.svelte create mode 100644 src/routes/(app)/accounts/+page.svelte create mode 100644 src/routes/(app)/ledger/+page.svelte create mode 100644 src/routes/(app)/reports/+page.svelte create mode 100644 src/routes/(app)/settings/+page.svelte delete mode 100644 src/routes/+page.svelte create mode 100644 src/routes/client-metadata.json/+server.ts create mode 100644 src/routes/jwks.json/+server.ts create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/logout/+server.ts create mode 100644 src/routes/oauth/callback/+server.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..68f0369 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "deno", + "runtimeArgs": ["task", "dev"], + "port": 5173 + } + ] +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..c52eeb6 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,114 @@ +# Design + +Visual system for Quantum. Register: product. Personality: calm precision — +ink on good paper, composed and legible. All colors OKLCH. + +## Theme + +Scene: a couple reviews the month on a laptop at the kitchen table under +evening lamplight; each also glances at balances on a phone in daylight. Both +themes are first-class: **light is the canonical "paper statement" reading, +dark is the evening companion**. Default follows `prefers-color-scheme`, with a +`data-theme` override on `` persisted per user. + +Identity holds across themes: warm ink on warm paper (light), warm chalk on +warm charcoal (dark). Never pure black or white. + +## Color + +Strategy: **Restrained.** Tinted warm neutrals carry the interface; one spruce +accent under 10% of any surface; semantic and chart colors appear only where +data demands them. + +### Neutrals (warm, hue ≈ 85) + +| Token | Light | Dark | +|---|---|---| +| `--bg` | `oklch(0.975 0.004 85)` | `oklch(0.19 0.006 85)` | +| `--bg-raised` | `oklch(0.99 0.003 85)` | `oklch(0.225 0.007 85)` | +| `--bg-sunken` | `oklch(0.955 0.005 85)` | `oklch(0.165 0.006 85)` | +| `--text` | `oklch(0.245 0.012 85)` | `oklch(0.92 0.008 85)` | +| `--text-muted` | `oklch(0.49 0.012 85)` | `oklch(0.70 0.01 85)` | +| `--text-faint` | `oklch(0.62 0.01 85)` | `oklch(0.55 0.01 85)` | +| `--border` | `oklch(0.90 0.006 85)` | `oklch(0.30 0.008 85)` | +| `--border-strong` | `oklch(0.82 0.008 85)` | `oklch(0.38 0.01 85)` | + +### Accent — spruce (hue ≈ 190, deliberately not money-green, not fintech navy) + +| Token | Light | Dark | +|---|---|---| +| `--accent` | `oklch(0.46 0.07 190)` | `oklch(0.80 0.08 185)` | +| `--accent-hover` | `oklch(0.40 0.075 190)` | `oklch(0.85 0.075 185)` | +| `--accent-bg` | `oklch(0.94 0.02 190)` | `oklch(0.27 0.03 190)` | +| `--on-accent` | `oklch(0.98 0.005 190)` | `oklch(0.20 0.02 190)` | + +### Semantic (paired with sign/shape, never hue alone) + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--pos` | `oklch(0.52 0.10 175)` | `oklch(0.78 0.11 170)` | income, inflow | +| `--neg` | `oklch(0.53 0.13 30)` | `oklch(0.74 0.13 30)` | expense, outflow, destructive | +| `--warn` | `oklch(0.60 0.11 75)` | `oklch(0.80 0.11 80)` | staleness, needs-attention | +| `--warn-bg` | `oklch(0.95 0.03 85)` | `oklch(0.26 0.03 80)` | banner fills | + +Red (`--neg`) appears in chrome only when money or data is at risk; an expense +figure in a table is plain ink, not red. + +### Chart categoricals (colorblind-safe ordering, muted chroma) + +`--chart-1` `oklch(0.55 0.09 190)` · `--chart-2` `oklch(0.65 0.11 75)` · +`--chart-3` `oklch(0.55 0.10 300)` · `--chart-4` `oklch(0.68 0.10 130)` · +`--chart-5` `oklch(0.58 0.11 25)` · `--chart-6` `oklch(0.62 0.08 250)` · +`--chart-7` `oklch(0.70 0.09 55)`. Dark theme: same hues, lightness +0.10. + +## Typography + +- **UI + numerals**: Inter Variable (bundled via `@fontsource-variable/inter`, + self-hosted, no CDN). Fallback `system-ui`. +- **Monospace** (DIDs, rule patterns, tokens): `ui-monospace, 'Cascadia Mono', + Consolas, monospace`. +- **Tabular figures are law**: any column of money or dates sets + `font-variant-numeric: tabular-nums`. +- Scale (1.25 ratio): 13 / 14 (body) / 16 / 20 / 25 / 31 px as + `--text-xs/-sm/-md/-lg/-xl/-2xl`. Weights: 400 body, 500 emphasis & nav, + 600 headings & figures. Line height 1.5 body, 1.2 headings. +- Body measure ≤ 70ch. Money aligns right; descriptions align left. + +## Spacing, shape, elevation + +- 4px base: `--space-1..8` = 4, 8, 12, 16, 24, 32, 48, 64. +- Radius: `--radius-sm` 5px (inputs, chips), `--radius-md` 8px (surfaces, + menus), full for provenance badges. +- Elevation by border first: 1px `--border` + `--bg-raised`. Shadows only on + overlays (menus, dialogs): `0 4px 16px oklch(0 0 0 / 0.10)` light, + `/ 0.35` dark. No decorative shadows on static content. + +## Motion + +120ms (micro: hover, check, chip) and 180ms (panels, menus), both +`cubic-bezier(0.22, 1, 0.36, 1)`. Nothing bounces, nothing floats, no layout +property animation. `prefers-reduced-motion: reduce` collapses all to 0ms. + +## App shell + +- **Desktop (≥880px)**: fixed left sidebar, 216px. Wordmark "Quantum" top; + nav: Dashboard, Ledger, Reports, Accounts, Settings; footer shows sync + status line ("Synced 6:02 AM") and the signed-in user's handle. Content area + scrolls independently; reports/forms cap at 1040px, the ledger runs fluid. +- **Mobile (<880px)**: bottom tab bar with the same five destinations; page + title in a slim top bar. No hamburger menus. +- Login is shell-less: a centered column on `--bg`, wordmark, one input, one + button. + +## Component conventions + +- **Provenance badge**: pill, `--bg-sunken` fill, 12px text: `⚙ rule` / + `person's handle` / `↻ carried`. Click opens event history. +- **Banners** (connection health): full-width strip under the top of content, + `--warn-bg` fill, 1px `--warn` border (all four sides), plain sentence + + one link out. No icons larger than the text. +- **Tables**: row hover `--bg-sunken`, 1px hairline row separators, header + 13px/500/`--text-muted`, uppercase avoided. +- **Empty states**: one sentence + one action, set in `--text-muted`. No + illustrations. +- **Focus**: 2px `--accent` outline, 2px offset, on every interactive element. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..72c65f0 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,59 @@ +# Product + +## Register + +product + +## Users + +A married couple managing their shared household finances. Two named users, no +one else, ever. Contexts: an evening sit-down review of the month's spending at +a laptop, and quick daytime glances at balances or a transaction from a phone. +Both users are technical enough to self-host but the interface must never +require that literacy: categorizing a transaction should feel as light as +archiving an email. + +## Product Purpose + +Quantum is a self-hosted, Mint-style reporting tool fed by SimpleFIN bank data. +It answers two questions: "where did our money go this month?" (income vs. +expense by category) and "are we gaining ground?" (net worth over time). It is +a mirror and a ledger, not a budgeting coach: it never nags, never celebrates, +never advises. Success = both partners trust the numbers and can find the story +behind any figure (every categorization traces to a rule or a person). + +## Brand Personality + +Calm precision. Quiet, ledger-like confidence: numbers first, generous +whitespace, nothing shouting. The feel of a beautifully set financial statement +prepared by someone meticulous. Three words: composed, legible, trustworthy. +Reference points: Linear's restraint and dark-mode discipline; Copilot Money's +approachable categories and charts, without its consumer gloss. + +## Anti-references + +- Neon fintech dark: crypto-app glow, neon-on-black, animated gradients. +- Gamified budgeting: confetti, streaks, mascots, celebration modals, badges. +- Also avoid by implication of the register: gradient hero metrics, identical + card grids, bank-corporate navy-and-gold. + +## Design Principles + +1. **The number is the interface.** Typography and alignment do the work; + decoration never competes with a figure. Tabular numerals everywhere money + or dates column up. +2. **Provenance is a first-class citizen.** Who or what categorized a + transaction is always one glance away, never buried in a detail view. +3. **Calm states, honest states.** Errors (a stale bank connection, an + unclaimed token) are stated plainly with the next action, not dressed up or + alarmed. No red unless money or data is actually at risk. +4. **Two-person software.** Attribution, requests, and shared state are + designed for exactly two known people; nothing generalizes to "teams". +5. **Phone glance, laptop session.** Every surface has a legible narrow + composition; density increases with width instead of shrinking to fit. + +## Accessibility & Inclusion + +Sensible defaults, unaudited: WCAG AA contrast targets, full keyboard +navigability, `prefers-reduced-motion` respected, color-blind-safe chart +palette (never encode income/expense by hue alone; pair with sign and shape). diff --git a/deno.lock b/deno.lock index becf6a4..586bb14 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,11 @@ { "version": "5", "specifiers": { + "npm:@atproto-labs/handle-resolver@~0.4.5": "0.4.5", + "npm:@atproto/jwk-jose@~0.2.4": "0.2.4", + "npm:@atproto/oauth-client-node@~0.4.8": "0.4.8", + "npm:@atproto/oauth-types@~0.7.5": "0.7.5", + "npm:@fontsource-variable/inter@^5.2.8": "5.2.8", "npm:@sveltejs/adapter-node@^5.5.0": "5.5.7_@sveltejs+kit@2.69.2__@sveltejs+vite-plugin-svelte@7.2.0___svelte@5.56.4___vite@8.1.4____@types+node@26.1.1___@types+node@26.1.1__svelte@5.56.4__typescript@6.0.3__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_@types+node@26.1.1", "npm:@sveltejs/kit@*": "2.69.2_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.4__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_svelte@5.56.4_typescript@6.0.3_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1", "npm:@sveltejs/kit@^2.63.0": "2.69.2_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.4__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_svelte@5.56.4_typescript@6.0.3_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1", @@ -14,6 +19,186 @@ "npm:vite@^8.0.16": "8.1.4_@types+node@26.1.1" }, "npm": { + "@atproto-labs/did-resolver@0.3.5": { + "integrity": "sha512-0dMM+hj40VQiD/EJhlC1UMQgPXRwKeqM7NgJte7fVuYMv5b3P0W6+Lu3iDumHULcSjMmMXxJZzoi3i493Y0gCA==", + "dependencies": [ + "@atproto-labs/fetch", + "@atproto-labs/pipe", + "@atproto-labs/simple-store", + "@atproto-labs/simple-store-memory", + "@atproto/did", + "zod" + ] + }, + "@atproto-labs/fetch-node@0.3.5": { + "integrity": "sha512-fVoniRexly08D5Htzmdd6bvS2Y1tFMWVAiZCUbFBRojm5QFfhzYVvZRwBUR9zL3+8Uh+bEikZ66MFvzbwhsx0g==", + "dependencies": [ + "@atproto-labs/fetch", + "@atproto-labs/pipe", + "ipaddr.js", + "undici_v6@npm:undici@6.27.0", + "undici_v7@npm:undici@7.28.0", + "undici_v8@npm:undici@8.7.0" + ] + }, + "@atproto-labs/fetch@0.3.4": { + "integrity": "sha512-YxXwi8HMk2HHDd5rljPGqxZ8bSeU78sFNz511Y222D0FraEq98p2r1ifkIgI23KQOfy0k0VzafeJAiGDh1CvfQ==", + "dependencies": [ + "@atproto-labs/pipe" + ] + }, + "@atproto-labs/handle-resolver-node@0.2.5": { + "integrity": "sha512-C3cKlFOKj/ugBZVHyjqJnm1+px1MqwApuD/eG7Z3yeGaQunDaqqThHaAAW1bmSI58JNoYvmxtnRkRLAasArL2g==", + "dependencies": [ + "@atproto-labs/fetch-node", + "@atproto-labs/handle-resolver", + "@atproto/did" + ] + }, + "@atproto-labs/handle-resolver@0.4.5": { + "integrity": "sha512-+IoQ0WEUGYE0KiiZqCwFXhc48EWn36M97enL1w/3ZtsvCeBS2qojOGpnFwExxfvKjpA3GsD3RJxkN5Gv59kLQw==", + "dependencies": [ + "@atproto-labs/simple-store", + "@atproto-labs/simple-store-memory", + "@atproto/did", + "zod" + ] + }, + "@atproto-labs/identity-resolver@0.4.4": { + "integrity": "sha512-FlBNVU4vtxnYxwT15+cfotOgOug7UtfqF1PeZ8jy41Lix7TjvtiBkyvLsLXIlVjv0+3ZOt/7jxC69l/c2TIZfw==", + "dependencies": [ + "@atproto-labs/did-resolver", + "@atproto-labs/handle-resolver" + ] + }, + "@atproto-labs/pipe@0.2.4": { + "integrity": "sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==" + }, + "@atproto-labs/simple-store-memory@0.2.4": { + "integrity": "sha512-xAAUlOP9etqP9GGmJPq9gY1bRuJlZFdsC6wtAJSwUwMTwLRdenR7s8Qg5nxhbth5XL1XOtGABoD59NW3p+sl4A==", + "dependencies": [ + "@atproto-labs/simple-store", + "lru-cache" + ] + }, + "@atproto-labs/simple-store@0.4.4": { + "integrity": "sha512-3YH03xg99ZUS6Fq/6jcKRiZwUbzDb58xmS7ibg0y4+dukwrHa92cV/md76RpQJkAo9KYBvwLETdvF6fNtwIoGw==" + }, + "@atproto/common-web@0.5.6": { + "integrity": "sha512-5Y4MIK9dpkJPiKiE6u7iEHitxj+g3aAU2GfGL686JlKE2zDKD4y18BJb+uVek6nXQKb5XOdNVvw+7BHarpn8Fw==", + "dependencies": [ + "@atproto/lex-data", + "@atproto/lex-json", + "@atproto/syntax", + "zod" + ] + }, + "@atproto/did@0.5.4": { + "integrity": "sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw==", + "dependencies": [ + "zod" + ] + }, + "@atproto/jwk-jose@0.2.4": { + "integrity": "sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==", + "dependencies": [ + "@atproto/jwk", + "jose" + ] + }, + "@atproto/jwk-webcrypto@0.3.4": { + "integrity": "sha512-UsFIUozqnRecXPo6HgKV4PW4FqYHxX1V3iAe0rRV6Q2RSfYD8V2mZ89pv8NpvJynnaqJArBQ7HZlcg0F4tRYhA==", + "dependencies": [ + "@atproto/jwk", + "@atproto/jwk-jose", + "zod" + ] + }, + "@atproto/jwk@0.7.4": { + "integrity": "sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==", + "dependencies": [ + "multiformats", + "zod" + ] + }, + "@atproto/lex-data@0.1.5": { + "integrity": "sha512-TEM6GHuYpNm4O90LjNgbYq1Gmcr875S+BHrDxkg4PB5w/nlsz4HlbkGQG/WP/xbIV5O8TF5nNHDpCZ5ezTRrFA==", + "dependencies": [ + "multiformats", + "tslib", + "unicode-segmenter" + ] + }, + "@atproto/lex-json@0.1.4": { + "integrity": "sha512-ENR2cWkVrES+UL6TovbCRdX9BJOyHHJUS8jYx3Lxp3j4vEphjn/u+DW7bWloST2O8ID2OuVlt6+28ftNEEjmQQ==", + "dependencies": [ + "@atproto/lex-data", + "tslib" + ] + }, + "@atproto/lexicon@0.7.7": { + "integrity": "sha512-92VH2oEsJdrIVNy7WY8rGn99ANNVglyUffoN7GJc0mxKi+fXN5iVlJ2cOyTfYABhr2ldSiptZWXEPEdBaIR9/A==", + "dependencies": [ + "@atproto/common-web", + "@atproto/syntax", + "multiformats", + "zod" + ] + }, + "@atproto/oauth-client-node@0.4.8": { + "integrity": "sha512-yZSzp795CoLMBWjraf+nF9X8eUFOSikybsX6d8UBlLCP7BIRBwAaxiRkIaPIr9mYVQYCw1T7PoZslHkq4WwdWg==", + "dependencies": [ + "@atproto-labs/did-resolver", + "@atproto-labs/handle-resolver-node", + "@atproto-labs/simple-store", + "@atproto/did", + "@atproto/jwk", + "@atproto/jwk-jose", + "@atproto/jwk-webcrypto", + "@atproto/oauth-client", + "@atproto/oauth-types" + ] + }, + "@atproto/oauth-client@0.7.10": { + "integrity": "sha512-6jf7gi7bketKa89g1aR70TAVY1S6rrhWg6t2heqyJnxxjgswgxnC4+Fsy2232eC3Wjd06Tr4RXeuF+8ixTA9hw==", + "dependencies": [ + "@atproto-labs/did-resolver", + "@atproto-labs/fetch", + "@atproto-labs/handle-resolver", + "@atproto-labs/identity-resolver", + "@atproto-labs/simple-store", + "@atproto-labs/simple-store-memory", + "@atproto/did", + "@atproto/jwk", + "@atproto/oauth-types", + "@atproto/xrpc", + "core-js", + "multiformats", + "zod" + ] + }, + "@atproto/oauth-types@0.7.5": { + "integrity": "sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q==", + "dependencies": [ + "@atproto/did", + "@atproto/jwk", + "zod" + ] + }, + "@atproto/syntax@0.7.2": { + "integrity": "sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w==", + "dependencies": [ + "iso-datestring-validator", + "tslib" + ] + }, + "@atproto/xrpc@0.8.6": { + "integrity": "sha512-yVfKrlwZBBm44Ft9jDvHcTCwQ1ElqSlzXHWhT/KcqE+u24EAhWVFosfABfGbUByB/PmN8eLJ06FsWo5pUQcMNQ==", + "dependencies": [ + "@atproto/lexicon", + "zod" + ] + }, "@emnapi/core@1.11.1": { "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dependencies": [ @@ -33,6 +218,9 @@ "tslib" ] }, + "@fontsource-variable/inter@5.2.8": { + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==" + }, "@jridgewell/gen-mapping@0.3.13": { "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dependencies": [ @@ -452,6 +640,10 @@ "cookie@0.6.0": { "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, + "core-js@3.49.0": { + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "scripts": true + }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -496,6 +688,9 @@ "function-bind" ] }, + "ipaddr.js@2.4.0": { + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==" + }, "is-core-module@2.16.1": { "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dependencies": [ @@ -517,6 +712,12 @@ "@types/estree" ] }, + "iso-datestring-validator@2.2.2": { + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" + }, + "jose@5.10.0": { + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==" + }, "kleur@4.1.5": { "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" }, @@ -597,6 +798,9 @@ "locate-character@3.0.0": { "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "magic-string@0.30.21": { "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dependencies": [ @@ -609,6 +813,9 @@ "mrmime@2.0.1": { "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==" }, + "multiformats@13.4.2": { + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" + }, "nanoid@3.3.15": { "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "bin": true @@ -783,6 +990,18 @@ "undici-types@8.3.0": { "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" }, + "undici@6.27.0": { + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==" + }, + "undici@7.28.0": { + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==" + }, + "undici@8.7.0": { + "integrity": "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==" + }, + "unicode-segmenter@0.14.5": { + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" + }, "vite@8.1.4_@types+node@26.1.1": { "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dependencies": [ @@ -812,11 +1031,19 @@ }, "zimmerframe@1.1.4": { "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" + }, + "zod@3.25.76": { + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } }, "workspace": { "packageJson": { "dependencies": [ + "npm:@atproto-labs/handle-resolver@~0.4.5", + "npm:@atproto/jwk-jose@~0.2.4", + "npm:@atproto/oauth-client-node@~0.4.8", + "npm:@atproto/oauth-types@~0.7.5", + "npm:@fontsource-variable/inter@^5.2.8", "npm:@sveltejs/adapter-node@^5.5.0", "npm:@sveltejs/kit@^2.63.0", "npm:@sveltejs/vite-plugin-svelte@^7.1.2", diff --git a/openspec/changes/bootstrap-finance-app/tasks.md b/openspec/changes/bootstrap-finance-app/tasks.md index 08c694e..fa5c66a 100644 --- a/openspec/changes/bootstrap-finance-app/tasks.md +++ b/openspec/changes/bootstrap-finance-app/tasks.md @@ -8,10 +8,10 @@ ## 2. Authentication (specs/auth) -- [ ] 2.1 Dynamic routes for `/client-metadata.json` and `/jwks.json` generated from `APP_URL` and the signing key -- [ ] 2.2 Integrate `@atproto/oauth-client-node` with SQLite-backed state/session stores; login page with handle input; `/oauth/callback` handler (validates the library works under Deno npm-compat — fallback per design risk if not) -- [ ] 2.3 DID allowlist check on callback: create/update user record for allowlisted DIDs, 403 otherwise -- [ ] 2.4 Application sessions: HTTP-only Secure cookie, SQLite sessions table, hooks guard on all protected routes, logout +- [x] 2.1 Dynamic routes for `/client-metadata.json` and `/jwks.json` generated from `APP_URL` and the signing key +- [x] 2.2 Integrate `@atproto/oauth-client-node` with SQLite-backed state/session stores; login page with handle input; `/oauth/callback` handler (validates the library works under Deno npm-compat — fallback per design risk if not) +- [x] 2.3 DID allowlist check on callback: create/update user record for allowlisted DIDs, 403 otherwise +- [x] 2.4 Application sessions: HTTP-only Secure cookie, SQLite sessions table, hooks guard on all protected routes, logout - [ ] 2.5 Verify full OAuth round-trip end-to-end through the public `APP_URL` origin (both household DIDs) ## 3. SimpleFIN ingestion (specs/simplefin-sync) diff --git a/package.json b/package.json index 7bb9a1c..f645d38 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,13 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, + "dependencies": { + "@atproto-labs/handle-resolver": "^0.4.5", + "@atproto/jwk-jose": "^0.2.4", + "@atproto/oauth-client-node": "^0.4.8", + "@atproto/oauth-types": "^0.7.5", + "@fontsource-variable/inter": "^5.2.8" + }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.0", "@sveltejs/kit": "^2.63.0", diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..add3de8 --- /dev/null +++ b/src/app.css @@ -0,0 +1,275 @@ +/* Quantum design tokens — see DESIGN.md. All colors OKLCH, warm ink on warm paper. */ + +:root { + /* neutrals */ + --bg: oklch(0.975 0.004 85); + --bg-raised: oklch(0.99 0.003 85); + --bg-sunken: oklch(0.955 0.005 85); + --text: oklch(0.245 0.012 85); + --text-muted: oklch(0.49 0.012 85); + --text-faint: oklch(0.62 0.01 85); + --border: oklch(0.9 0.006 85); + --border-strong: oklch(0.82 0.008 85); + + /* accent — spruce */ + --accent: oklch(0.46 0.07 190); + --accent-hover: oklch(0.4 0.075 190); + --accent-bg: oklch(0.94 0.02 190); + --on-accent: oklch(0.98 0.005 190); + + /* semantic */ + --pos: oklch(0.52 0.1 175); + --neg: oklch(0.53 0.13 30); + --warn: oklch(0.6 0.11 75); + --warn-bg: oklch(0.95 0.03 85); + + /* charts */ + --chart-1: oklch(0.55 0.09 190); + --chart-2: oklch(0.65 0.11 75); + --chart-3: oklch(0.55 0.1 300); + --chart-4: oklch(0.68 0.1 130); + --chart-5: oklch(0.58 0.11 25); + --chart-6: oklch(0.62 0.08 250); + --chart-7: oklch(0.7 0.09 55); + + --shadow-overlay: 0 4px 16px oklch(0 0 0 / 0.1); + + /* typography */ + --font-ui: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: ui-monospace, 'Cascadia Mono', Consolas, monospace; + --text-xs: 0.8125rem; + --text-sm: 0.875rem; + --text-md: 1rem; + --text-lg: 1.25rem; + --text-xl: 1.5625rem; + --text-2xl: 1.9375rem; + + /* spacing (4px base) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + + /* shape */ + --radius-sm: 5px; + --radius-md: 8px; + + /* motion */ + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --dur-micro: 120ms; + --dur-panel: 180ms; + + color-scheme: light; +} + +:root[data-theme='dark'] { + --bg: oklch(0.19 0.006 85); + --bg-raised: oklch(0.225 0.007 85); + --bg-sunken: oklch(0.165 0.006 85); + --text: oklch(0.92 0.008 85); + --text-muted: oklch(0.7 0.01 85); + --text-faint: oklch(0.55 0.01 85); + --border: oklch(0.3 0.008 85); + --border-strong: oklch(0.38 0.01 85); + + --accent: oklch(0.8 0.08 185); + --accent-hover: oklch(0.85 0.075 185); + --accent-bg: oklch(0.27 0.03 190); + --on-accent: oklch(0.2 0.02 190); + + --pos: oklch(0.78 0.11 170); + --neg: oklch(0.74 0.13 30); + --warn: oklch(0.8 0.11 80); + --warn-bg: oklch(0.26 0.03 80); + + --chart-1: oklch(0.65 0.09 190); + --chart-2: oklch(0.75 0.11 75); + --chart-3: oklch(0.65 0.1 300); + --chart-4: oklch(0.78 0.1 130); + --chart-5: oklch(0.68 0.11 25); + --chart-6: oklch(0.72 0.08 250); + --chart-7: oklch(0.8 0.09 55); + + --shadow-overlay: 0 4px 16px oklch(0 0 0 / 0.35); + + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --bg: oklch(0.19 0.006 85); + --bg-raised: oklch(0.225 0.007 85); + --bg-sunken: oklch(0.165 0.006 85); + --text: oklch(0.92 0.008 85); + --text-muted: oklch(0.7 0.01 85); + --text-faint: oklch(0.55 0.01 85); + --border: oklch(0.3 0.008 85); + --border-strong: oklch(0.38 0.01 85); + + --accent: oklch(0.8 0.08 185); + --accent-hover: oklch(0.85 0.075 185); + --accent-bg: oklch(0.27 0.03 190); + --on-accent: oklch(0.2 0.02 190); + + --pos: oklch(0.78 0.11 170); + --neg: oklch(0.74 0.13 30); + --warn: oklch(0.8 0.11 80); + --warn-bg: oklch(0.26 0.03 80); + + --chart-1: oklch(0.65 0.09 190); + --chart-2: oklch(0.75 0.11 75); + --chart-3: oklch(0.65 0.1 300); + --chart-4: oklch(0.78 0.1 130); + --chart-5: oklch(0.68 0.11 25); + --chart-6: oklch(0.72 0.08 250); + --chart-7: oklch(0.8 0.09 55); + + --shadow-overlay: 0 4px 16px oklch(0 0 0 / 0.35); + + color-scheme: dark; + } +} + +/* base */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: var(--text-sm); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3 { + line-height: 1.2; + font-weight: 600; + margin: 0; +} + +h1 { + font-size: var(--text-xl); +} + +h2 { + font-size: var(--text-lg); +} + +h3 { + font-size: var(--text-md); +} + +p { + margin: 0; + max-width: 70ch; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +code { + font-family: var(--font-mono); + font-size: 0.9em; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* form controls */ + +button, +input, +select { + font: inherit; + color: inherit; +} + +input[type='text'], +input[type='password'], +select { + background: var(--bg-raised); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + transition: border-color var(--dur-micro) var(--ease-out); +} + +input[type='text']:focus, +select:focus { + border-color: var(--accent); + outline: none; +} + +button { + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--bg-raised); + padding: var(--space-2) var(--space-4); + font-weight: 500; + cursor: pointer; + transition: + background var(--dur-micro) var(--ease-out), + border-color var(--dur-micro) var(--ease-out); +} + +button:hover { + background: var(--bg-sunken); +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); +} + +button.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +/* numerals */ + +.tnum { + font-variant-numeric: tabular-nums; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0ms !important; + animation-duration: 0ms !important; + } +} diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..7c863b3 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,7 +3,9 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user: { did: string; handle: string } | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/app.html b/src/app.html index 6a2bb58..5864e4a 100644 --- a/src/app.html +++ b/src/app.html @@ -3,7 +3,14 @@ - + Quantum + %sveltekit.head% diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 614e89c..625a60a 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,10 +1,40 @@ import { building } from '$app/environment'; +import { redirect, type Handle, type ServerInit } from '@sveltejs/kit'; import { getConfig } from '$lib/server/config'; -import { initDb } from '$lib/server/db'; -import type { ServerInit } from '@sveltejs/kit'; +import { getDb, initDb } from '$lib/server/db'; +import { initOAuthClient } from '$lib/server/auth/oauth-client'; +import { deleteExpiredSessions, getSessionUser } from '$lib/server/services/sessions'; -export const init: ServerInit = () => { +export const init: ServerInit = async () => { if (building) return; const config = getConfig(); // fails fast with a clear error on missing/invalid env - initDb(config.dbPath, 'migrations'); + const db = initDb(config.dbPath, 'migrations'); + deleteExpiredSessions(db); + await initOAuthClient(config, db); +}; + +export const SESSION_COOKIE = 'quantum_session'; + +/** Routes reachable without a session: login, OAuth plumbing, health check. */ +const PUBLIC_PATHS = new Set([ + '/login', + '/oauth/callback', + '/client-metadata.json', + '/jwks.json', + '/healthz' +]); + +export const handle: Handle = async ({ event, resolve }) => { + const token = event.cookies.get(SESSION_COOKIE); + event.locals.user = token ? getSessionUser(getDb(), token) : null; + + const path = event.url.pathname; + if (!event.locals.user && !PUBLIC_PATHS.has(path)) { + redirect(303, '/login'); + } + if (event.locals.user && path === '/login') { + redirect(303, '/'); + } + + return resolve(event); }; diff --git a/src/lib/server/auth/oauth-client.ts b/src/lib/server/auth/oauth-client.ts new file mode 100644 index 0000000..3f226ca --- /dev/null +++ b/src/lib/server/auth/oauth-client.ts @@ -0,0 +1,119 @@ +import { + NodeOAuthClient, + type NodeSavedSession, + type NodeSavedSessionStore, + type NodeSavedState, + type NodeSavedStateStore +} from '@atproto/oauth-client-node'; +import { JoseKey } from '@atproto/jwk-jose'; +import { AtprotoDohHandleResolver } from '@atproto-labs/handle-resolver'; +import { buildAtprotoLoopbackClientMetadata } from '@atproto/oauth-types'; +import type { DatabaseSync } from 'node:sqlite'; +import type { Config } from '../config.ts'; + +async function clientIdentity(config: Config) { + if (config.appUrl.startsWith('https:')) { + const key = await JoseKey.fromJWK(JSON.stringify(config.oauthPrivateKeyJwk)); + return { + clientMetadata: { + client_id: `${config.appUrl}/client-metadata.json`, + client_name: 'Quantum', + client_uri: config.appUrl, + redirect_uris: [`${config.appUrl}/oauth/callback`] as [string], + grant_types: ['authorization_code', 'refresh_token'] as ['authorization_code', 'refresh_token'], + response_types: ['code'] as ['code'], + scope: 'atproto', + application_type: 'web' as const, + token_endpoint_auth_method: 'private_key_jwt' as const, + token_endpoint_auth_signing_alg: 'ES256', + dpop_bound_access_tokens: true as const, + jwks_uri: `${config.appUrl}/jwks.json` + }, + keyset: [key] + }; + } + // Local development (http://localhost:PORT): atproto's loopback-client + // exception. Public client, no keyset; redirect goes to 127.0.0.1. + const port = new URL(config.appUrl).port || '80'; + return { + clientMetadata: buildAtprotoLoopbackClientMetadata({ + scope: 'atproto', + redirect_uris: [`http://127.0.0.1:${port}/oauth/callback`] + }), + keyset: undefined + }; +} + +function stateStore(db: DatabaseSync): NodeSavedStateStore { + return { + set(key: string, state: NodeSavedState) { + db.prepare( + `INSERT INTO oauth_state (key, data, created_at) VALUES (?, ?, ?) + ON CONFLICT (key) DO UPDATE SET data = excluded.data` + ).run(key, JSON.stringify(state), new Date().toISOString()); + }, + get(key: string) { + const row = db.prepare('SELECT data FROM oauth_state WHERE key = ?').get(key) as + | { data: string } + | undefined; + return row ? (JSON.parse(row.data) as NodeSavedState) : undefined; + }, + del(key: string) { + db.prepare('DELETE FROM oauth_state WHERE key = ?').run(key); + } + }; +} + +function sessionStore(db: DatabaseSync): NodeSavedSessionStore { + return { + set(key: string, session: NodeSavedSession) { + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO oauth_session (key, data, created_at, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT (key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at` + ).run(key, JSON.stringify(session), now, now); + }, + get(key: string) { + const row = db.prepare('SELECT data FROM oauth_session WHERE key = ?').get(key) as + | { data: string } + | undefined; + return row ? (JSON.parse(row.data) as NodeSavedSession) : undefined; + }, + del(key: string) { + db.prepare('DELETE FROM oauth_session WHERE key = ?').run(key); + } + }; +} + +export async function createOAuthClient( + config: Config, + db: DatabaseSync +): Promise { + const identity = await clientIdentity(config); + return new NodeOAuthClient({ + // The default Node handle resolver builds an SSRF-protected fetch on top of + // undici internals that Deno does not emulate (process.versions.undici). + // DNS-over-HTTPS resolution is pure fetch and runs fine under Deno. + handleResolver: new AtprotoDohHandleResolver({ + dohEndpoint: 'https://mozilla.cloudflare-dns.com/dns-query' + }), + ...identity, + stateStore: stateStore(db), + sessionStore: sessionStore(db) + }); +} + +let instance: NodeOAuthClient | null = null; +let instancePromise: Promise | null = null; + +export function initOAuthClient(config: Config, db: DatabaseSync): Promise { + instancePromise ??= createOAuthClient(config, db).then((client) => (instance = client)); + return instancePromise; +} + +export function getOAuthClient(): NodeOAuthClient { + if (!instance) { + throw new Error('OAuth client not initialized — initOAuthClient() runs at server startup'); + } + return instance; +} diff --git a/src/lib/server/services/sessions.ts b/src/lib/server/services/sessions.ts new file mode 100644 index 0000000..0009524 --- /dev/null +++ b/src/lib/server/services/sessions.ts @@ -0,0 +1,41 @@ +import { randomBytes } from 'node:crypto'; +import type { DatabaseSync } from 'node:sqlite'; +import type { User } from './users.ts'; + +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +// Sessions are opaque tokens (design D6): delivered via HTTP-only cookie today, +// presentable as a bearer credential by future native clients. + +export function createSession(db: DatabaseSync, did: string): { token: string; expiresAt: Date } { + const token = randomBytes(32).toString('base64url'); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_TTL_MS); + db.prepare( + 'INSERT INTO sessions (token, user_did, created_at, expires_at) VALUES (?, ?, ?, ?)' + ).run(token, did, now.toISOString(), expiresAt.toISOString()); + return { token, expiresAt }; +} + +export function getSessionUser(db: DatabaseSync, token: string): User | null { + const row = db + .prepare( + `SELECT u.did, u.handle, s.expires_at FROM sessions s + JOIN users u ON u.did = s.user_did WHERE s.token = ?` + ) + .get(token) as { did: string; handle: string; expires_at: string } | undefined; + if (!row) return null; + if (new Date(row.expires_at).getTime() <= Date.now()) { + deleteSession(db, token); + return null; + } + return { did: row.did, handle: row.handle }; +} + +export function deleteSession(db: DatabaseSync, token: string): void { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); +} + +export function deleteExpiredSessions(db: DatabaseSync): void { + db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(new Date().toISOString()); +} diff --git a/src/lib/server/services/users.ts b/src/lib/server/services/users.ts new file mode 100644 index 0000000..07f97f2 --- /dev/null +++ b/src/lib/server/services/users.ts @@ -0,0 +1,23 @@ +import type { DatabaseSync } from 'node:sqlite'; + +export interface User { + did: string; + handle: string; +} + +/** Create or refresh the user record for an allowlisted DID at login time. */ +export function upsertUser(db: DatabaseSync, did: string, handle: string): User { + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (did, handle, created_at, last_login_at) VALUES (?, ?, ?, ?) + ON CONFLICT (did) DO UPDATE SET handle = excluded.handle, last_login_at = excluded.last_login_at` + ).run(did, handle, now, now); + return { did, handle }; +} + +export function getUser(db: DatabaseSync, did: string): User | null { + const row = db.prepare('SELECT did, handle FROM users WHERE did = ?').get(did) as + | User + | undefined; + return row ?? null; +} diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..45f4fcc --- /dev/null +++ b/src/routes/(app)/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ locals }) => { + return { user: locals.user }; +}; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..68e0d08 --- /dev/null +++ b/src/routes/(app)/+layout.svelte @@ -0,0 +1,188 @@ + + +
+ + +
+ {@render children()} +
+
+ + diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..475efe6 --- /dev/null +++ b/src/routes/(app)/+page.svelte @@ -0,0 +1,9 @@ +

Dashboard

+

Nothing to show yet. Connect SimpleFIN in Settings to start syncing.

+ + diff --git a/src/routes/(app)/accounts/+page.svelte b/src/routes/(app)/accounts/+page.svelte new file mode 100644 index 0000000..b7ea5c8 --- /dev/null +++ b/src/routes/(app)/accounts/+page.svelte @@ -0,0 +1,9 @@ +

Accounts

+

Accounts discovered from SimpleFIN will appear here.

+ + diff --git a/src/routes/(app)/ledger/+page.svelte b/src/routes/(app)/ledger/+page.svelte new file mode 100644 index 0000000..fd51f2f --- /dev/null +++ b/src/routes/(app)/ledger/+page.svelte @@ -0,0 +1,9 @@ +

Ledger

+

Transactions will appear here after the first sync.

+ + diff --git a/src/routes/(app)/reports/+page.svelte b/src/routes/(app)/reports/+page.svelte new file mode 100644 index 0000000..6a04f49 --- /dev/null +++ b/src/routes/(app)/reports/+page.svelte @@ -0,0 +1,9 @@ +

Reports

+

Monthly income and expense reports will appear here after the first sync.

+ + diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte new file mode 100644 index 0000000..a92c081 --- /dev/null +++ b/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,9 @@ +

Settings

+

SimpleFIN connection setup arrives with the sync engine.

+ + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9cebde5..94cd452 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,4 +1,6 @@ + +
+
+

Quantum

+

The household ledger.

+ +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > + + + {#if form?.message} + + {/if} + +
+
+
+ + diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts new file mode 100644 index 0000000..d557e66 --- /dev/null +++ b/src/routes/logout/+server.ts @@ -0,0 +1,12 @@ +import { redirect } from '@sveltejs/kit'; +import { getDb } from '$lib/server/db'; +import { deleteSession } from '$lib/server/services/sessions'; +import { SESSION_COOKIE } from '../../hooks.server'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = ({ cookies }) => { + const token = cookies.get(SESSION_COOKIE); + if (token) deleteSession(getDb(), token); + cookies.delete(SESSION_COOKIE, { path: '/' }); + redirect(303, '/login'); +}; diff --git a/src/routes/oauth/callback/+server.ts b/src/routes/oauth/callback/+server.ts new file mode 100644 index 0000000..47792dd --- /dev/null +++ b/src/routes/oauth/callback/+server.ts @@ -0,0 +1,47 @@ +import { error, redirect } from '@sveltejs/kit'; +import { getConfig } from '$lib/server/config'; +import { getDb } from '$lib/server/db'; +import { getOAuthClient } from '$lib/server/auth/oauth-client'; +import { upsertUser } from '$lib/server/services/users'; +import { createSession } from '$lib/server/services/sessions'; +import { SESSION_COOKIE } from '../../../hooks.server'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + let did: string; + let appState: string | null; + try { + const result = await getOAuthClient().callback(url.searchParams); + did = result.session.did; + appState = result.state; + } catch { + redirect(303, '/login'); + } + + const config = getConfig(); + if (!config.allowedDids.includes(did)) { + // Not authorized: no session, no user record. + error(403, 'This account is not authorized to use this instance.'); + } + + let handle = did; + try { + const parsed = appState ? (JSON.parse(appState) as { handle?: string }) : null; + if (parsed?.handle) handle = parsed.handle; + } catch { + // state was not ours to parse; fall back to the DID + } + + const db = getDb(); + upsertUser(db, did, handle); + const { token, expiresAt } = createSession(db, did); + cookies.set(SESSION_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: config.appUrl.startsWith('https:'), + expires: expiresAt + }); + + redirect(303, '/'); +}; -- 2.51.2