commit 60f870ece88a5d7167a0f6016eac49756a735eee Author: Fredrik Jensen Date: Sat Feb 7 17:53:23 2026 +0100 first commit diff --git a/.claude/plans/goofy-beaming-kurzweil.md b/.claude/plans/goofy-beaming-kurzweil.md new file mode 100644 index 0000000..d1229e3 --- /dev/null +++ b/.claude/plans/goofy-beaming-kurzweil.md @@ -0,0 +1,61 @@ +# Plan: Add Testing to Eyrun Auth System + +## Context +The project has zero test infrastructure. We need to add a test framework and write tests covering the auth system we just built, plus the lib utilities. The `buildApp()` factory pattern in `src/app.ts` is ideal for integration testing with Fastify's `.inject()` method. + +## Setup + +**Install dependencies:** +- `vitest` — test runner (ESM + TypeScript native support, no config hassle) + +**Config files:** +- `vitest.config.ts` at project root (minimal — just point at src) +- Add `"test": "vitest run"` script to `package.json` + +## Test Structure + +``` +src/ +├── lib/ +│ ├── jwt.test.ts # Unit tests +│ ├── otp.test.ts # Unit tests +│ └── tokens.test.ts # Unit tests +└── routes/ + └── auth.test.ts # Integration tests (full auth flow) +``` + +## Unit Tests + +**`src/lib/jwt.test.ts`** — sign returns JWT string, verify decodes correct `sub`, verify throws on invalid/expired tokens + +**`src/lib/otp.test.ts`** — generates 6-digit string, produces varying codes + +**`src/lib/tokens.test.ts`** — returns non-empty string, produces unique tokens + +## Integration Tests — `src/routes/auth.test.ts` + +Uses `buildApp()` + `app.inject()` against the real local DB. Cleans up test data (otp_codes, sessions, users by test email) in afterAll. + +**Test cases:** +1. `POST /auth/login` — returns message, creates OTP row in DB +2. `POST /auth/login` — rate limits after 3 requests per email/hour +3. `POST /auth/verify` — valid code → returns accessToken + refreshToken +4. `POST /auth/verify` — wrong code → 400 +5. `POST /auth/verify` — creates user if email not in users table +6. `POST /auth/refresh` — valid refresh token → rotated tokens +7. `POST /auth/refresh` — reuse old token → theft detection, all sessions revoked +8. `GET /auth/me` — valid Bearer → returns user +9. `GET /auth/me` — no token → 401 +10. `POST /auth/logout` — revokes sessions, refresh afterward fails + +## Files to Create/Modify +- `package.json` — add vitest devDep + `test` script +- `vitest.config.ts` — new +- `src/lib/jwt.test.ts` — new +- `src/lib/otp.test.ts` — new +- `src/lib/tokens.test.ts` — new +- `src/routes/auth.test.ts` — new + +## Verification +- `pnpm test` passes all tests +- `pnpm build` still compiles diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..defa98d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm init:*)", + "Bash(pnpm add:*)", + "Bash(npx --yes json:*)", + "Bash(pnpm install:*)", + "Bash(pnpm build:*)", + "Bash(pg_isready:*)", + "Bash(pnpm db:migrate:*)", + "Bash(psql:*)", + "Bash(pnpm test:*)", + "Bash(pnpm vitest run:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..993955f --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL=postgresql://user:password@localhost:5432/eyrun +JWT_SECRET=change-me-to-a-random-string-at-least-32-chars +PORT=3000 +HOST=0.0.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e661554 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cfe8110 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Eyrun is an API-only service built with Node.js, TypeScript, Fastify, and PostgreSQL (via Drizzle ORM). + +## Commands + +```bash +pnpm dev # Start dev server with hot reload (tsx watch) +pnpm build # Compile TypeScript to dist/ +pnpm start # Run compiled output (node dist/server.js) +pnpm db:generate # Generate migration files from schema changes +pnpm db:migrate # Apply pending migrations to the database +pnpm db:studio # Open Drizzle Studio (visual DB browser) +``` + +## Architecture + +**Entry point**: `src/server.ts` creates the app and starts listening. + +**App factory**: `src/app.ts` — `buildApp()` creates a Fastify instance, registers plugins and routes. This pattern makes it easy to create separate instances for testing. + +**Config**: `src/config.ts` loads `.env` and validates with Zod. Required env vars: `DATABASE_URL`, `PORT`, `HOST`. + +**Database**: Drizzle ORM with `postgres` (postgres.js) driver. +- `src/db/schema.ts` — all table definitions go here +- `src/db/index.ts` — exports the `db` client with schema attached +- `src/db/migrate.ts` — standalone migration runner +- `drizzle.config.ts` — Drizzle Kit config at project root + +**Routes**: `src/routes/` — each file exports an async Fastify plugin function. Register new route files in `src/routes/index.ts`. + +**Plugins**: `src/plugins/` — Fastify plugins (error handling, etc). Registered in `app.ts`. + +## Conventions + +- ESM (`"type": "module"` in package.json) — use `.js` extensions in all imports +- All Drizzle table definitions go in `src/db/schema.ts` +- After modifying the schema, run `pnpm db:generate` then `pnpm db:migrate` diff --git a/README.md b/README.md new file mode 100644 index 0000000..a12ed03 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# eyrun-api diff --git a/coverage/base.css b/coverage/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/clover.xml b/coverage/clover.xml new file mode 100644 index 0000000..8bfffd4 --- /dev/null +++ b/coverage/clover.xml @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/coverage/coverage-final.json b/coverage/coverage-final.json new file mode 100644 index 0000000..8ae4a24 --- /dev/null +++ b/coverage/coverage-final.json @@ -0,0 +1,21 @@ +{"/Users/fedjens/projects/eyrun/src/app.ts": {"path":"/Users/fedjens/projects/eyrun/src/app.ts","statementMap":{"0":{"start":{"line":8,"column":8},"end":{"line":10,"column":null}},"1":{"start":{"line":12,"column":2},"end":{"line":12,"column":null}},"2":{"start":{"line":13,"column":2},"end":{"line":13,"column":null}},"3":{"start":{"line":14,"column":2},"end":{"line":14,"column":null}},"4":{"start":{"line":15,"column":2},"end":{"line":15,"column":null}},"5":{"start":{"line":17,"column":2},"end":{"line":17,"column":null}}},"fnMap":{"0":{"name":"buildApp","decl":{"start":{"line":7,"column":16},"end":{"line":7,"column":27}},"loc":{"start":{"line":7,"column":27},"end":{"line":18,"column":null}},"line":7}},"branchMap":{},"s":{"0":7,"1":7,"2":7,"3":7,"4":7,"5":7},"f":{"0":7},"b":{},"meta":{"lastBranch":0,"lastFunction":1,"lastStatement":6,"seen":{"f:7:16:7:27":0,"s:8:8:10:Infinity":0,"s:12:2:12:Infinity":1,"s:13:2:13:Infinity":2,"s:14:2:14:Infinity":3,"s:15:2:15:Infinity":4,"s:17:2:17:Infinity":5}}} +,"/Users/fedjens/projects/eyrun/src/config.ts": {"path":"/Users/fedjens/projects/eyrun/src/config.ts","statementMap":{"0":{"start":{"line":4,"column":18},"end":{"line":9,"column":null}},"1":{"start":{"line":11,"column":15},"end":{"line":11,"column":null}},"2":{"start":{"line":13,"column":0},"end":{"line":16,"column":null}},"3":{"start":{"line":14,"column":2},"end":{"line":14,"column":null}},"4":{"start":{"line":15,"column":2},"end":{"line":15,"column":null}},"5":{"start":{"line":18,"column":22},"end":{"line":18,"column":null}}},"fnMap":{},"branchMap":{"0":{"loc":{"start":{"line":13,"column":0},"end":{"line":16,"column":null}},"type":"if","locations":[{"start":{"line":13,"column":0},"end":{"line":16,"column":null}},{"start":{},"end":{}}],"line":13}},"s":{"0":7,"1":7,"2":7,"3":0,"4":0,"5":7},"f":{},"b":{"0":[0,7]},"meta":{"lastBranch":1,"lastFunction":0,"lastStatement":6,"seen":{"s:4:18:9:Infinity":0,"s:11:15:11:Infinity":1,"b:13:0:16:Infinity:undefined:undefined:undefined:undefined":0,"s:13:0:16:Infinity":2,"s:14:2:14:Infinity":3,"s:15:2:15:Infinity":4,"s:18:22:18:Infinity":5}}} +,"/Users/fedjens/projects/eyrun/src/db/index.ts": {"path":"/Users/fedjens/projects/eyrun/src/db/index.ts","statementMap":{"0":{"start":{"line":6,"column":6},"end":{"line":6,"column":null}},"1":{"start":{"line":8,"column":13},"end":{"line":8,"column":null}}},"fnMap":{},"branchMap":{},"s":{"0":7,"1":7},"f":{},"b":{},"meta":{"lastBranch":0,"lastFunction":0,"lastStatement":2,"seen":{"s:6:6:6:Infinity":0,"s:8:13:8:Infinity":1}}} +,"/Users/fedjens/projects/eyrun/src/db/schema.ts": {"path":"/Users/fedjens/projects/eyrun/src/db/schema.ts","statementMap":{"0":{"start":{"line":3,"column":13},"end":{"line":9,"column":null}},"1":{"start":{"line":11,"column":13},"end":{"line":19,"column":null}},"2":{"start":{"line":21,"column":13},"end":{"line":26,"column":null}},"3":{"start":{"line":28,"column":13},"end":{"line":42,"column":null}},"4":{"start":{"line":34,"column":24},"end":{"line":34,"column":32}},"5":{"start":{"line":37,"column":24},"end":{"line":37,"column":35}},"6":{"start":{"line":41,"column":9},"end":{"line":41,"column":null}},"7":{"start":{"line":44,"column":13},"end":{"line":53,"column":null}},"8":{"start":{"line":48,"column":22},"end":{"line":48,"column":30}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":34,"column":18},"end":{"line":34,"column":24}},"loc":{"start":{"line":34,"column":24},"end":{"line":34,"column":32}},"line":34},"1":{"name":"(anonymous_1)","decl":{"start":{"line":37,"column":18},"end":{"line":37,"column":24}},"loc":{"start":{"line":37,"column":24},"end":{"line":37,"column":35}},"line":37},"2":{"name":"(anonymous_2)","decl":{"start":{"line":41,"column":2},"end":{"line":41,"column":3}},"loc":{"start":{"line":41,"column":9},"end":{"line":41,"column":null}},"line":41},"3":{"name":"(anonymous_3)","decl":{"start":{"line":48,"column":16},"end":{"line":48,"column":22}},"loc":{"start":{"line":48,"column":22},"end":{"line":48,"column":30}},"line":48}},"branchMap":{},"s":{"0":7,"1":7,"2":7,"3":7,"4":0,"5":0,"6":7,"7":7,"8":0},"f":{"0":0,"1":0,"2":7,"3":0},"b":{},"meta":{"lastBranch":0,"lastFunction":4,"lastStatement":9,"seen":{"s:3:13:9:Infinity":0,"s:11:13:19:Infinity":1,"s:21:13:26:Infinity":2,"s:28:13:42:Infinity":3,"f:34:18:34:24":0,"s:34:24:34:32":4,"f:37:18:37:24":1,"s:37:24:37:35":5,"f:41:2:41:3":2,"s:41:9:41:Infinity":6,"s:44:13:53:Infinity":7,"f:48:16:48:22":3,"s:48:22:48:30":8}}} +,"/Users/fedjens/projects/eyrun/src/lib/accounts.ts": {"path":"/Users/fedjens/projects/eyrun/src/lib/accounts.ts","statementMap":{"0":{"start":{"line":6,"column":2},"end":{"line":14,"column":null}}},"fnMap":{"0":{"name":"getUserAccounts","decl":{"start":{"line":5,"column":22},"end":{"line":5,"column":38}},"loc":{"start":{"line":5,"column":54},"end":{"line":15,"column":null}},"line":5}},"branchMap":{},"s":{"0":22},"f":{"0":22},"b":{},"meta":{"lastBranch":0,"lastFunction":1,"lastStatement":1,"seen":{"f:5:22:5:38":0,"s:6:2:14:Infinity":0}}} +,"/Users/fedjens/projects/eyrun/src/lib/jwt.ts": {"path":"/Users/fedjens/projects/eyrun/src/lib/jwt.ts","statementMap":{"0":{"start":{"line":9,"column":2},"end":{"line":9,"column":null}},"1":{"start":{"line":13,"column":2},"end":{"line":13,"column":null}}},"fnMap":{"0":{"name":"signAccessToken","decl":{"start":{"line":8,"column":16},"end":{"line":8,"column":32}},"loc":{"start":{"line":8,"column":56},"end":{"line":10,"column":null}},"line":8},"1":{"name":"verifyAccessToken","decl":{"start":{"line":12,"column":16},"end":{"line":12,"column":34}},"loc":{"start":{"line":12,"column":69},"end":{"line":14,"column":null}},"line":12}},"branchMap":{},"s":{"0":19,"1":11},"f":{"0":19,"1":11},"b":{},"meta":{"lastBranch":0,"lastFunction":2,"lastStatement":2,"seen":{"f:8:16:8:32":0,"s:9:2:9:Infinity":0,"f:12:16:12:34":1,"s:13:2:13:Infinity":1}}} +,"/Users/fedjens/projects/eyrun/src/lib/otp.ts": {"path":"/Users/fedjens/projects/eyrun/src/lib/otp.ts","statementMap":{"0":{"start":{"line":6,"column":25},"end":{"line":6,"column":null}},"1":{"start":{"line":7,"column":24},"end":{"line":7,"column":null}},"2":{"start":{"line":8,"column":32},"end":{"line":8,"column":null}},"3":{"start":{"line":12,"column":21},"end":{"line":12,"column":null}},"4":{"start":{"line":13,"column":22},"end":{"line":16,"column":null}},"5":{"start":{"line":18,"column":2},"end":{"line":20,"column":null}},"6":{"start":{"line":19,"column":4},"end":{"line":19,"column":null}},"7":{"start":{"line":22,"column":15},"end":{"line":22,"column":null}},"8":{"start":{"line":23,"column":20},"end":{"line":23,"column":null}},"9":{"start":{"line":25,"column":2},"end":{"line":25,"column":null}},"10":{"start":{"line":26,"column":2},"end":{"line":26,"column":null}},"11":{"start":{"line":30,"column":2},"end":{"line":30,"column":null}},"12":{"start":{"line":35,"column":16},"end":{"line":46,"column":null}},"13":{"start":{"line":48,"column":2},"end":{"line":50,"column":null}},"14":{"start":{"line":49,"column":4},"end":{"line":49,"column":null}},"15":{"start":{"line":52,"column":2},"end":{"line":55,"column":null}},"16":{"start":{"line":53,"column":4},"end":{"line":53,"column":null}},"17":{"start":{"line":54,"column":4},"end":{"line":54,"column":null}},"18":{"start":{"line":57,"column":2},"end":{"line":60,"column":null}},"19":{"start":{"line":62,"column":2},"end":{"line":64,"column":null}},"20":{"start":{"line":63,"column":4},"end":{"line":63,"column":null}},"21":{"start":{"line":66,"column":2},"end":{"line":66,"column":null}},"22":{"start":{"line":71,"column":4},"end":{"line":71,"column":null}},"23":{"start":{"line":77,"column":4},"end":{"line":77,"column":null}}},"fnMap":{"0":{"name":"requestOtp","decl":{"start":{"line":11,"column":22},"end":{"line":11,"column":33}},"loc":{"start":{"line":11,"column":63},"end":{"line":27,"column":null}},"line":11},"1":{"name":"sendOtp","decl":{"start":{"line":29,"column":9},"end":{"line":29,"column":17}},"loc":{"start":{"line":29,"column":52},"end":{"line":31,"column":null}},"line":29},"2":{"name":"verifyOtp","decl":{"start":{"line":34,"column":22},"end":{"line":34,"column":32}},"loc":{"start":{"line":34,"column":76},"end":{"line":67,"column":null}},"line":34},"3":{"name":"(anonymous_3)","decl":{"start":{"line":70,"column":2},"end":{"line":70,"column":14}},"loc":{"start":{"line":70,"column":31},"end":{"line":72,"column":null}},"line":70},"4":{"name":"(anonymous_4)","decl":{"start":{"line":76,"column":2},"end":{"line":76,"column":14}},"loc":{"start":{"line":76,"column":31},"end":{"line":78,"column":null}},"line":76}},"branchMap":{"0":{"loc":{"start":{"line":18,"column":2},"end":{"line":20,"column":null}},"type":"if","locations":[{"start":{"line":18,"column":2},"end":{"line":20,"column":null}},{"start":{},"end":{}}],"line":18},"1":{"loc":{"start":{"line":48,"column":2},"end":{"line":50,"column":null}},"type":"if","locations":[{"start":{"line":48,"column":2},"end":{"line":50,"column":null}},{"start":{},"end":{}}],"line":48},"2":{"loc":{"start":{"line":52,"column":2},"end":{"line":55,"column":null}},"type":"if","locations":[{"start":{"line":52,"column":2},"end":{"line":55,"column":null}},{"start":{},"end":{}}],"line":52},"3":{"loc":{"start":{"line":62,"column":2},"end":{"line":64,"column":null}},"type":"if","locations":[{"start":{"line":62,"column":2},"end":{"line":64,"column":null}},{"start":{},"end":{}}],"line":62}},"s":{"0":7,"1":7,"2":7,"3":25,"4":25,"5":25,"6":1,"7":24,"8":24,"9":24,"10":24,"11":24,"12":21,"13":21,"14":1,"15":20,"16":0,"17":0,"18":20,"19":20,"20":1,"21":19,"22":2,"23":1},"f":{"0":25,"1":24,"2":21,"3":2,"4":1},"b":{"0":[1,24],"1":[1,20],"2":[0,20],"3":[1,19]},"meta":{"lastBranch":4,"lastFunction":5,"lastStatement":24,"seen":{"s:6:25:6:Infinity":0,"s:7:24:7:Infinity":1,"s:8:32:8:Infinity":2,"f:11:22:11:33":0,"s:12:21:12:Infinity":3,"s:13:22:16:Infinity":4,"b:18:2:20:Infinity:undefined:undefined:undefined:undefined":0,"s:18:2:20:Infinity":5,"s:19:4:19:Infinity":6,"s:22:15:22:Infinity":7,"s:23:20:23:Infinity":8,"s:25:2:25:Infinity":9,"s:26:2:26:Infinity":10,"f:29:9:29:17":1,"s:30:2:30:Infinity":11,"f:34:22:34:32":2,"s:35:16:46:Infinity":12,"b:48:2:50:Infinity:undefined:undefined:undefined:undefined":1,"s:48:2:50:Infinity":13,"s:49:4:49:Infinity":14,"b:52:2:55:Infinity:undefined:undefined:undefined:undefined":2,"s:52:2:55:Infinity":15,"s:53:4:53:Infinity":16,"s:54:4:54:Infinity":17,"s:57:2:60:Infinity":18,"b:62:2:64:Infinity:undefined:undefined:undefined:undefined":3,"s:62:2:64:Infinity":19,"s:63:4:63:Infinity":20,"s:66:2:66:Infinity":21,"f:70:2:70:14":3,"s:71:4:71:Infinity":22,"f:76:2:76:14":4,"s:77:4:77:Infinity":23}}} +,"/Users/fedjens/projects/eyrun/src/lib/sessions.ts": {"path":"/Users/fedjens/projects/eyrun/src/lib/sessions.ts","statementMap":{"0":{"start":{"line":7,"column":25},"end":{"line":7,"column":null}},"1":{"start":{"line":13,"column":15},"end":{"line":13,"column":null}},"2":{"start":{"line":14,"column":8},"end":{"line":14,"column":null}},"3":{"start":{"line":15,"column":20},"end":{"line":15,"column":null}},"4":{"start":{"line":17,"column":2},"end":{"line":21,"column":null}},"5":{"start":{"line":23,"column":8},"end":{"line":23,"column":null}},"6":{"start":{"line":25,"column":2},"end":{"line":25,"column":null}}},"fnMap":{"0":{"name":"createSession","decl":{"start":{"line":9,"column":22},"end":{"line":9,"column":null}},"loc":{"start":{"line":12,"column":2},"end":{"line":26,"column":null}},"line":12}},"branchMap":{"0":{"loc":{"start":{"line":13,"column":15},"end":{"line":13,"column":null}},"type":"binary-expr","locations":[{"start":{"line":13,"column":15},"end":{"line":13,"column":21}},{"start":{"line":13,"column":21},"end":{"line":13,"column":null}}],"line":13}},"s":{"0":7,"1":19,"2":19,"3":19,"4":19,"5":19,"6":19},"f":{"0":19},"b":{"0":[19,3]},"meta":{"lastBranch":1,"lastFunction":1,"lastStatement":7,"seen":{"s:7:25:7:Infinity":0,"f:9:22:9:Infinity":0,"s:13:15:13:Infinity":1,"b:13:15:13:21:13:21:13:Infinity":0,"s:14:8:14:Infinity":2,"s:15:20:15:Infinity":3,"s:17:2:21:Infinity":4,"s:23:8:23:Infinity":5,"s:25:2:25:Infinity":6}}} +,"/Users/fedjens/projects/eyrun/src/lib/tokens.ts": {"path":"/Users/fedjens/projects/eyrun/src/lib/tokens.ts","statementMap":{"0":{"start":{"line":4,"column":2},"end":{"line":4,"column":null}}},"fnMap":{"0":{"name":"generateRefreshToken","decl":{"start":{"line":3,"column":16},"end":{"line":3,"column":47}},"loc":{"start":{"line":3,"column":47},"end":{"line":5,"column":null}},"line":3}},"branchMap":{},"s":{"0":19},"f":{"0":19},"b":{},"meta":{"lastBranch":0,"lastFunction":1,"lastStatement":1,"seen":{"f:3:16:3:47":0,"s:4:2:4:Infinity":0}}} +,"/Users/fedjens/projects/eyrun/src/plugins/account-context.ts": {"path":"/Users/fedjens/projects/eyrun/src/plugins/account-context.ts","statementMap":{"0":{"start":{"line":18,"column":2},"end":{"line":18,"column":null}},"1":{"start":{"line":19,"column":2},"end":{"line":19,"column":null}},"2":{"start":{"line":21,"column":2},"end":{"line":38,"column":null}},"3":{"start":{"line":22,"column":22},"end":{"line":22,"column":null}},"4":{"start":{"line":23,"column":4},"end":{"line":25,"column":null}},"5":{"start":{"line":24,"column":6},"end":{"line":24,"column":null}},"6":{"start":{"line":27,"column":25},"end":{"line":30,"column":null}},"7":{"start":{"line":32,"column":4},"end":{"line":34,"column":null}},"8":{"start":{"line":33,"column":6},"end":{"line":33,"column":null}},"9":{"start":{"line":36,"column":4},"end":{"line":36,"column":null}},"10":{"start":{"line":37,"column":4},"end":{"line":37,"column":null}},"11":{"start":{"line":41,"column":13},"end":{"line":44,"column":null}}},"fnMap":{"0":{"name":"accountContextPlugin","decl":{"start":{"line":17,"column":15},"end":{"line":17,"column":36}},"loc":{"start":{"line":17,"column":58},"end":{"line":39,"column":null}},"line":17},"1":{"name":"(anonymous_1)","decl":{"start":{"line":21,"column":33},"end":{"line":21,"column":40}},"loc":{"start":{"line":21,"column":89},"end":{"line":38,"column":3}},"line":21}},"branchMap":{"0":{"loc":{"start":{"line":23,"column":4},"end":{"line":25,"column":null}},"type":"if","locations":[{"start":{"line":23,"column":4},"end":{"line":25,"column":null}},{"start":{},"end":{}}],"line":23},"1":{"loc":{"start":{"line":23,"column":8},"end":{"line":23,"column":53}},"type":"binary-expr","locations":[{"start":{"line":23,"column":8},"end":{"line":23,"column":22}},{"start":{"line":23,"column":22},"end":{"line":23,"column":53}}],"line":23},"2":{"loc":{"start":{"line":32,"column":4},"end":{"line":34,"column":null}},"type":"if","locations":[{"start":{"line":32,"column":4},"end":{"line":34,"column":null}},{"start":{},"end":{}}],"line":32}},"s":{"0":7,"1":7,"2":7,"3":3,"4":3,"5":1,"6":2,"7":2,"8":1,"9":1,"10":1,"11":7},"f":{"0":7,"1":3},"b":{"0":[1,2],"1":[3,2],"2":[1,1]},"meta":{"lastBranch":3,"lastFunction":2,"lastStatement":12,"seen":{"f:17:15:17:36":0,"s:18:2:18:Infinity":0,"s:19:2:19:Infinity":1,"s:21:2:38:Infinity":2,"f:21:33:21:40":1,"s:22:22:22:Infinity":3,"b:23:4:25:Infinity:undefined:undefined:undefined:undefined":0,"s:23:4:25:Infinity":4,"b:23:8:23:22:23:22:23:53":1,"s:24:6:24:Infinity":5,"s:27:25:30:Infinity":6,"b:32:4:34:Infinity:undefined:undefined:undefined:undefined":2,"s:32:4:34:Infinity":7,"s:33:6:33:Infinity":8,"s:36:4:36:Infinity":9,"s:37:4:37:Infinity":10,"s:41:13:44:Infinity":11}}} +,"/Users/fedjens/projects/eyrun/src/plugins/authenticate.ts": {"path":"/Users/fedjens/projects/eyrun/src/plugins/authenticate.ts","statementMap":{"0":{"start":{"line":15,"column":2},"end":{"line":15,"column":null}},"1":{"start":{"line":17,"column":2},"end":{"line":28,"column":null}},"2":{"start":{"line":18,"column":19},"end":{"line":18,"column":null}},"3":{"start":{"line":19,"column":4},"end":{"line":21,"column":null}},"4":{"start":{"line":20,"column":6},"end":{"line":20,"column":null}},"5":{"start":{"line":23,"column":4},"end":{"line":27,"column":null}},"6":{"start":{"line":24,"column":6},"end":{"line":24,"column":null}},"7":{"start":{"line":26,"column":6},"end":{"line":26,"column":null}},"8":{"start":{"line":31,"column":13},"end":{"line":31,"column":null}}},"fnMap":{"0":{"name":"authenticatePlugin","decl":{"start":{"line":14,"column":15},"end":{"line":14,"column":34}},"loc":{"start":{"line":14,"column":56},"end":{"line":29,"column":null}},"line":14},"1":{"name":"(anonymous_1)","decl":{"start":{"line":17,"column":31},"end":{"line":17,"column":38}},"loc":{"start":{"line":17,"column":87},"end":{"line":28,"column":3}},"line":17}},"branchMap":{"0":{"loc":{"start":{"line":19,"column":4},"end":{"line":21,"column":null}},"type":"if","locations":[{"start":{"line":19,"column":4},"end":{"line":21,"column":null}},{"start":{},"end":{}}],"line":19}},"s":{"0":7,"1":7,"2":15,"3":15,"4":4,"5":11,"6":11,"7":0,"8":7},"f":{"0":7,"1":15},"b":{"0":[4,11]},"meta":{"lastBranch":1,"lastFunction":2,"lastStatement":9,"seen":{"f:14:15:14:34":0,"s:15:2:15:Infinity":0,"s:17:2:28:Infinity":1,"f:17:31:17:38":1,"s:18:19:18:Infinity":2,"b:19:4:21:Infinity:undefined:undefined:undefined:undefined":0,"s:19:4:21:Infinity":3,"s:20:6:20:Infinity":4,"s:23:4:27:Infinity":5,"s:24:6:24:Infinity":6,"s:26:6:26:Infinity":7,"s:31:13:31:Infinity":8}}} +,"/Users/fedjens/projects/eyrun/src/plugins/error-handler.ts": {"path":"/Users/fedjens/projects/eyrun/src/plugins/error-handler.ts","statementMap":{"0":{"start":{"line":4,"column":2},"end":{"line":14,"column":null}},"1":{"start":{"line":5,"column":23},"end":{"line":5,"column":null}},"2":{"start":{"line":7,"column":4},"end":{"line":9,"column":null}},"3":{"start":{"line":8,"column":6},"end":{"line":8,"column":null}},"4":{"start":{"line":11,"column":4},"end":{"line":13,"column":null}}},"fnMap":{"0":{"name":"errorHandler","decl":{"start":{"line":3,"column":22},"end":{"line":3,"column":35}},"loc":{"start":{"line":3,"column":57},"end":{"line":15,"column":null}},"line":3},"1":{"name":"(anonymous_1)","decl":{"start":{"line":4,"column":22},"end":{"line":4,"column":23}},"loc":{"start":{"line":4,"column":64},"end":{"line":14,"column":3}},"line":4}},"branchMap":{"0":{"loc":{"start":{"line":5,"column":23},"end":{"line":5,"column":null}},"type":"binary-expr","locations":[{"start":{"line":5,"column":23},"end":{"line":5,"column":43}},{"start":{"line":5,"column":43},"end":{"line":5,"column":null}}],"line":5},"1":{"loc":{"start":{"line":7,"column":4},"end":{"line":9,"column":null}},"type":"if","locations":[{"start":{"line":7,"column":4},"end":{"line":9,"column":null}},{"start":{},"end":{}}],"line":7},"2":{"loc":{"start":{"line":12,"column":13},"end":{"line":12,"column":null}},"type":"cond-expr","locations":[{"start":{"line":12,"column":33},"end":{"line":12,"column":59}},{"start":{"line":12,"column":59},"end":{"line":12,"column":null}}],"line":12}},"s":{"0":7,"1":0,"2":0,"3":0,"4":0},"f":{"0":7,"1":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0]},"meta":{"lastBranch":3,"lastFunction":2,"lastStatement":5,"seen":{"f:3:22:3:35":0,"s:4:2:14:Infinity":0,"f:4:22:4:23":1,"s:5:23:5:Infinity":1,"b:5:23:5:43:5:43:5:Infinity":0,"b:7:4:9:Infinity:undefined:undefined:undefined:undefined":1,"s:7:4:9:Infinity":2,"s:8:6:8:Infinity":3,"s:11:4:13:Infinity":4,"b:12:33:12:59:12:59:12:Infinity":2}}} +,"/Users/fedjens/projects/eyrun/src/routes/accounts.ts": {"path":"/Users/fedjens/projects/eyrun/src/routes/accounts.ts","statementMap":{"0":{"start":{"line":8,"column":2},"end":{"line":10,"column":null}},"1":{"start":{"line":9,"column":4},"end":{"line":9,"column":null}},"2":{"start":{"line":13,"column":2},"end":{"line":36,"column":null}},"3":{"start":{"line":17,"column":21},"end":{"line":17,"column":null}},"4":{"start":{"line":18,"column":23},"end":{"line":18,"column":null}},"5":{"start":{"line":20,"column":6},"end":{"line":22,"column":null}},"6":{"start":{"line":21,"column":8},"end":{"line":21,"column":null}},"7":{"start":{"line":24,"column":22},"end":{"line":32,"column":null}},"8":{"start":{"line":25,"column":26},"end":{"line":25,"column":null}},"9":{"start":{"line":26,"column":8},"end":{"line":30,"column":null}},"10":{"start":{"line":31,"column":8},"end":{"line":31,"column":null}},"11":{"start":{"line":34,"column":6},"end":{"line":34,"column":null}}},"fnMap":{"0":{"name":"accountRoutes","decl":{"start":{"line":6,"column":22},"end":{"line":6,"column":36}},"loc":{"start":{"line":6,"column":58},"end":{"line":37,"column":null}},"line":6},"1":{"name":"(anonymous_1)","decl":{"start":{"line":8,"column":59},"end":{"line":8,"column":66}},"loc":{"start":{"line":8,"column":78},"end":{"line":10,"column":3}},"line":8},"2":{"name":"(anonymous_2)","decl":{"start":{"line":16,"column":4},"end":{"line":16,"column":11}},"loc":{"start":{"line":16,"column":30},"end":{"line":35,"column":null}},"line":16},"3":{"name":"(anonymous_3)","decl":{"start":{"line":24,"column":43},"end":{"line":24,"column":50}},"loc":{"start":{"line":24,"column":57},"end":{"line":32,"column":7}},"line":24}},"branchMap":{"0":{"loc":{"start":{"line":20,"column":6},"end":{"line":22,"column":null}},"type":"if","locations":[{"start":{"line":20,"column":6},"end":{"line":22,"column":null}},{"start":{},"end":{}}],"line":20},"1":{"loc":{"start":{"line":20,"column":10},"end":{"line":20,"column":45}},"type":"binary-expr","locations":[{"start":{"line":20,"column":10},"end":{"line":20,"column":19}},{"start":{"line":20,"column":19},"end":{"line":20,"column":45}}],"line":20}},"s":{"0":7,"1":2,"2":7,"3":2,"4":2,"5":2,"6":1,"7":1,"8":1,"9":1,"10":1,"11":1},"f":{"0":7,"1":2,"2":2,"3":1},"b":{"0":[1,1],"1":[2,1]},"meta":{"lastBranch":2,"lastFunction":4,"lastStatement":12,"seen":{"f:6:22:6:36":0,"s:8:2:10:Infinity":0,"f:8:59:8:66":1,"s:9:4:9:Infinity":1,"s:13:2:36:Infinity":2,"f:16:4:16:11":2,"s:17:21:17:Infinity":3,"s:18:23:18:Infinity":4,"b:20:6:22:Infinity:undefined:undefined:undefined:undefined":0,"s:20:6:22:Infinity":5,"b:20:10:20:19:20:19:20:45":1,"s:21:8:21:Infinity":6,"s:24:22:32:Infinity":7,"f:24:43:24:50":3,"s:25:26:25:Infinity":8,"s:26:8:30:Infinity":9,"s:31:8:31:Infinity":10,"s:34:6:34:Infinity":11}}} +,"/Users/fedjens/projects/eyrun/src/routes/health.ts": {"path":"/Users/fedjens/projects/eyrun/src/routes/health.ts","statementMap":{"0":{"start":{"line":4,"column":2},"end":{"line":6,"column":null}},"1":{"start":{"line":5,"column":4},"end":{"line":5,"column":null}}},"fnMap":{"0":{"name":"healthRoutes","decl":{"start":{"line":3,"column":22},"end":{"line":3,"column":35}},"loc":{"start":{"line":3,"column":57},"end":{"line":7,"column":null}},"line":3},"1":{"name":"(anonymous_1)","decl":{"start":{"line":4,"column":21},"end":{"line":4,"column":33}},"loc":{"start":{"line":4,"column":33},"end":{"line":6,"column":3}},"line":4}},"branchMap":{},"s":{"0":7,"1":1},"f":{"0":7,"1":1},"b":{},"meta":{"lastBranch":0,"lastFunction":2,"lastStatement":2,"seen":{"f:3:22:3:35":0,"s:4:2:6:Infinity":0,"f:4:21:4:33":1,"s:5:4:5:Infinity":1}}} +,"/Users/fedjens/projects/eyrun/src/routes/index.ts": {"path":"/Users/fedjens/projects/eyrun/src/routes/index.ts","statementMap":{"0":{"start":{"line":10,"column":2},"end":{"line":10,"column":null}},"1":{"start":{"line":11,"column":2},"end":{"line":11,"column":null}},"2":{"start":{"line":12,"column":2},"end":{"line":12,"column":null}},"3":{"start":{"line":13,"column":2},"end":{"line":13,"column":null}},"4":{"start":{"line":14,"column":2},"end":{"line":14,"column":null}},"5":{"start":{"line":15,"column":2},"end":{"line":15,"column":null}}},"fnMap":{"0":{"name":"registerRoutes","decl":{"start":{"line":9,"column":22},"end":{"line":9,"column":37}},"loc":{"start":{"line":9,"column":59},"end":{"line":16,"column":null}},"line":9}},"branchMap":{},"s":{"0":7,"1":7,"2":7,"3":7,"4":7,"5":7},"f":{"0":7},"b":{},"meta":{"lastBranch":0,"lastFunction":1,"lastStatement":6,"seen":{"f:9:22:9:37":0,"s:10:2:10:Infinity":0,"s:11:2:11:Infinity":1,"s:12:2:12:Infinity":2,"s:13:2:13:Infinity":3,"s:14:2:14:Infinity":4,"s:15:2:15:Infinity":5}}} +,"/Users/fedjens/projects/eyrun/src/routes/login.ts": {"path":"/Users/fedjens/projects/eyrun/src/routes/login.ts","statementMap":{"0":{"start":{"line":6,"column":2},"end":{"line":23,"column":null}},"1":{"start":{"line":7,"column":22},"end":{"line":7,"column":null}},"2":{"start":{"line":9,"column":4},"end":{"line":11,"column":null}},"3":{"start":{"line":10,"column":6},"end":{"line":10,"column":null}},"4":{"start":{"line":13,"column":4},"end":{"line":20,"column":null}},"5":{"start":{"line":14,"column":6},"end":{"line":14,"column":null}},"6":{"start":{"line":16,"column":6},"end":{"line":18,"column":null}},"7":{"start":{"line":17,"column":8},"end":{"line":17,"column":null}},"8":{"start":{"line":19,"column":6},"end":{"line":19,"column":null}},"9":{"start":{"line":22,"column":4},"end":{"line":22,"column":null}}},"fnMap":{"0":{"name":"loginRoutes","decl":{"start":{"line":4,"column":22},"end":{"line":4,"column":34}},"loc":{"start":{"line":4,"column":56},"end":{"line":24,"column":null}},"line":4},"1":{"name":"(anonymous_1)","decl":{"start":{"line":6,"column":50},"end":{"line":6,"column":57}},"loc":{"start":{"line":6,"column":76},"end":{"line":23,"column":3}},"line":6}},"branchMap":{"0":{"loc":{"start":{"line":9,"column":4},"end":{"line":11,"column":null}},"type":"if","locations":[{"start":{"line":9,"column":4},"end":{"line":11,"column":null}},{"start":{},"end":{}}],"line":9},"1":{"loc":{"start":{"line":9,"column":8},"end":{"line":9,"column":45}},"type":"binary-expr","locations":[{"start":{"line":9,"column":8},"end":{"line":9,"column":18}},{"start":{"line":9,"column":18},"end":{"line":9,"column":45}}],"line":9},"2":{"loc":{"start":{"line":16,"column":6},"end":{"line":18,"column":null}},"type":"if","locations":[{"start":{"line":16,"column":6},"end":{"line":18,"column":null}},{"start":{},"end":{}}],"line":16}},"s":{"0":7,"1":26,"2":26,"3":1,"4":25,"5":25,"6":1,"7":1,"8":0,"9":24},"f":{"0":7,"1":26},"b":{"0":[1,25],"1":[26,25],"2":[1,0]},"meta":{"lastBranch":3,"lastFunction":2,"lastStatement":10,"seen":{"f:4:22:4:34":0,"s:6:2:23:Infinity":0,"f:6:50:6:57":1,"s:7:22:7:Infinity":1,"b:9:4:11:Infinity:undefined:undefined:undefined:undefined":0,"s:9:4:11:Infinity":2,"b:9:8:9:18:9:18:9:45":1,"s:10:6:10:Infinity":3,"s:13:4:20:Infinity":4,"s:14:6:14:Infinity":5,"b:16:6:18:Infinity:undefined:undefined:undefined:undefined":2,"s:16:6:18:Infinity":6,"s:17:8:17:Infinity":7,"s:19:6:19:Infinity":8,"s:22:4:22:Infinity":9}}} +,"/Users/fedjens/projects/eyrun/src/routes/me.ts": {"path":"/Users/fedjens/projects/eyrun/src/routes/me.ts","statementMap":{"0":{"start":{"line":9,"column":2},"end":{"line":20,"column":null}},"1":{"start":{"line":10,"column":19},"end":{"line":10,"column":null}},"2":{"start":{"line":12,"column":19},"end":{"line":12,"column":null}},"3":{"start":{"line":13,"column":4},"end":{"line":15,"column":null}},"4":{"start":{"line":14,"column":6},"end":{"line":14,"column":null}},"5":{"start":{"line":17,"column":25},"end":{"line":17,"column":null}},"6":{"start":{"line":19,"column":4},"end":{"line":19,"column":null}},"7":{"start":{"line":23,"column":2},"end":{"line":46,"column":null}},"8":{"start":{"line":27,"column":21},"end":{"line":27,"column":null}},"9":{"start":{"line":28,"column":30},"end":{"line":28,"column":null}},"10":{"start":{"line":30,"column":6},"end":{"line":32,"column":null}},"11":{"start":{"line":31,"column":8},"end":{"line":31,"column":null}},"12":{"start":{"line":34,"column":21},"end":{"line":38,"column":null}},"13":{"start":{"line":40,"column":6},"end":{"line":42,"column":null}},"14":{"start":{"line":41,"column":8},"end":{"line":41,"column":null}},"15":{"start":{"line":44,"column":6},"end":{"line":44,"column":null}}},"fnMap":{"0":{"name":"meRoutes","decl":{"start":{"line":7,"column":22},"end":{"line":7,"column":31}},"loc":{"start":{"line":7,"column":53},"end":{"line":47,"column":null}},"line":7},"1":{"name":"(anonymous_1)","decl":{"start":{"line":9,"column":53},"end":{"line":9,"column":60}},"loc":{"start":{"line":9,"column":79},"end":{"line":20,"column":3}},"line":9},"2":{"name":"(anonymous_2)","decl":{"start":{"line":26,"column":4},"end":{"line":26,"column":11}},"loc":{"start":{"line":26,"column":30},"end":{"line":45,"column":null}},"line":26}},"branchMap":{"0":{"loc":{"start":{"line":13,"column":4},"end":{"line":15,"column":null}},"type":"if","locations":[{"start":{"line":13,"column":4},"end":{"line":15,"column":null}},{"start":{},"end":{}}],"line":13},"1":{"loc":{"start":{"line":30,"column":6},"end":{"line":32,"column":null}},"type":"if","locations":[{"start":{"line":30,"column":6},"end":{"line":32,"column":null}},{"start":{},"end":{}}],"line":30},"2":{"loc":{"start":{"line":30,"column":10},"end":{"line":30,"column":27}},"type":"binary-expr","locations":[{"start":{"line":30,"column":10},"end":{"line":30,"column":20}},{"start":{"line":30,"column":20},"end":{"line":30,"column":27}}],"line":30},"3":{"loc":{"start":{"line":36,"column":19},"end":{"line":36,"column":40}},"type":"binary-expr","locations":[{"start":{"line":36,"column":19},"end":{"line":36,"column":28}},{"start":{"line":36,"column":28},"end":{"line":36,"column":40}}],"line":36},"4":{"loc":{"start":{"line":36,"column":44},"end":{"line":36,"column":63}},"type":"binary-expr","locations":[{"start":{"line":36,"column":44},"end":{"line":36,"column":52}},{"start":{"line":36,"column":52},"end":{"line":36,"column":63}}],"line":36},"5":{"loc":{"start":{"line":40,"column":6},"end":{"line":42,"column":null}},"type":"if","locations":[{"start":{"line":40,"column":6},"end":{"line":42,"column":null}},{"start":{},"end":{}}],"line":40}},"s":{"0":7,"1":1,"2":1,"3":1,"4":0,"5":1,"6":1,"7":7,"8":2,"9":2,"10":2,"11":1,"12":1,"13":1,"14":0,"15":1},"f":{"0":7,"1":1,"2":2},"b":{"0":[0,1],"1":[1,1],"2":[2,2],"3":[1,0],"4":[2,1],"5":[0,1]},"meta":{"lastBranch":6,"lastFunction":3,"lastStatement":16,"seen":{"f:7:22:7:31":0,"s:9:2:20:Infinity":0,"f:9:53:9:60":1,"s:10:19:10:Infinity":1,"s:12:19:12:Infinity":2,"b:13:4:15:Infinity:undefined:undefined:undefined:undefined":0,"s:13:4:15:Infinity":3,"s:14:6:14:Infinity":4,"s:17:25:17:Infinity":5,"s:19:4:19:Infinity":6,"s:23:2:46:Infinity":7,"f:26:4:26:11":2,"s:27:21:27:Infinity":8,"s:28:30:28:Infinity":9,"b:30:6:32:Infinity:undefined:undefined:undefined:undefined":1,"s:30:6:32:Infinity":10,"b:30:10:30:20:30:20:30:27":2,"s:31:8:31:Infinity":11,"s:34:21:38:Infinity":12,"b:36:19:36:28:36:28:36:40":3,"b:36:44:36:52:36:52:36:63":4,"b:40:6:42:Infinity:undefined:undefined:undefined:undefined":5,"s:40:6:42:Infinity":13,"s:41:8:41:Infinity":14,"s:44:6:44:Infinity":15}}} +,"/Users/fedjens/projects/eyrun/src/routes/sessions.ts": {"path":"/Users/fedjens/projects/eyrun/src/routes/sessions.ts","statementMap":{"0":{"start":{"line":11,"column":2},"end":{"line":36,"column":null}},"1":{"start":{"line":12,"column":28},"end":{"line":12,"column":null}},"2":{"start":{"line":14,"column":4},"end":{"line":16,"column":null}},"3":{"start":{"line":15,"column":6},"end":{"line":15,"column":null}},"4":{"start":{"line":18,"column":4},"end":{"line":25,"column":null}},"5":{"start":{"line":19,"column":6},"end":{"line":19,"column":null}},"6":{"start":{"line":21,"column":6},"end":{"line":23,"column":null}},"7":{"start":{"line":22,"column":8},"end":{"line":22,"column":null}},"8":{"start":{"line":24,"column":6},"end":{"line":24,"column":null}},"9":{"start":{"line":27,"column":19},"end":{"line":27,"column":null}},"10":{"start":{"line":28,"column":4},"end":{"line":30,"column":null}},"11":{"start":{"line":29,"column":6},"end":{"line":29,"column":null}},"12":{"start":{"line":32,"column":19},"end":{"line":32,"column":null}},"13":{"start":{"line":33,"column":25},"end":{"line":33,"column":null}},"14":{"start":{"line":35,"column":4},"end":{"line":35,"column":null}},"15":{"start":{"line":39,"column":2},"end":{"line":81,"column":null}},"16":{"start":{"line":40,"column":29},"end":{"line":40,"column":null}},"17":{"start":{"line":42,"column":4},"end":{"line":44,"column":null}},"18":{"start":{"line":43,"column":6},"end":{"line":43,"column":null}},"19":{"start":{"line":46,"column":22},"end":{"line":49,"column":null}},"20":{"start":{"line":51,"column":4},"end":{"line":53,"column":null}},"21":{"start":{"line":52,"column":6},"end":{"line":52,"column":null}},"22":{"start":{"line":56,"column":4},"end":{"line":62,"column":null}},"23":{"start":{"line":57,"column":6},"end":{"line":60,"column":null}},"24":{"start":{"line":61,"column":6},"end":{"line":61,"column":null}},"25":{"start":{"line":65,"column":4},"end":{"line":67,"column":null}},"26":{"start":{"line":66,"column":6},"end":{"line":66,"column":null}},"27":{"start":{"line":70,"column":4},"end":{"line":73,"column":null}},"28":{"start":{"line":75,"column":19},"end":{"line":75,"column":null}},"29":{"start":{"line":77,"column":19},"end":{"line":77,"column":null}},"30":{"start":{"line":78,"column":25},"end":{"line":78,"column":null}},"31":{"start":{"line":80,"column":4},"end":{"line":80,"column":null}},"32":{"start":{"line":84,"column":2},"end":{"line":93,"column":null}},"33":{"start":{"line":85,"column":19},"end":{"line":85,"column":null}},"34":{"start":{"line":87,"column":4},"end":{"line":90,"column":null}},"35":{"start":{"line":92,"column":4},"end":{"line":92,"column":null}}},"fnMap":{"0":{"name":"sessionRoutes","decl":{"start":{"line":9,"column":22},"end":{"line":9,"column":36}},"loc":{"start":{"line":9,"column":58},"end":{"line":94,"column":null}},"line":9},"1":{"name":"(anonymous_1)","decl":{"start":{"line":11,"column":59},"end":{"line":11,"column":66}},"loc":{"start":{"line":11,"column":85},"end":{"line":36,"column":3}},"line":11},"2":{"name":"(anonymous_2)","decl":{"start":{"line":39,"column":59},"end":{"line":39,"column":66}},"loc":{"start":{"line":39,"column":85},"end":{"line":81,"column":3}},"line":39},"3":{"name":"(anonymous_3)","decl":{"start":{"line":84,"column":58},"end":{"line":84,"column":65}},"loc":{"start":{"line":84,"column":84},"end":{"line":93,"column":3}},"line":84}},"branchMap":{"0":{"loc":{"start":{"line":14,"column":4},"end":{"line":16,"column":null}},"type":"if","locations":[{"start":{"line":14,"column":4},"end":{"line":16,"column":null}},{"start":{},"end":{}}],"line":14},"1":{"loc":{"start":{"line":14,"column":8},"end":{"line":14,"column":25}},"type":"binary-expr","locations":[{"start":{"line":14,"column":8},"end":{"line":14,"column":18}},{"start":{"line":14,"column":18},"end":{"line":14,"column":25}}],"line":14},"2":{"loc":{"start":{"line":21,"column":6},"end":{"line":23,"column":null}},"type":"if","locations":[{"start":{"line":21,"column":6},"end":{"line":23,"column":null}},{"start":{},"end":{}}],"line":21},"3":{"loc":{"start":{"line":28,"column":4},"end":{"line":30,"column":null}},"type":"if","locations":[{"start":{"line":28,"column":4},"end":{"line":30,"column":null}},{"start":{},"end":{}}],"line":28},"4":{"loc":{"start":{"line":42,"column":4},"end":{"line":44,"column":null}},"type":"if","locations":[{"start":{"line":42,"column":4},"end":{"line":44,"column":null}},{"start":{},"end":{}}],"line":42},"5":{"loc":{"start":{"line":51,"column":4},"end":{"line":53,"column":null}},"type":"if","locations":[{"start":{"line":51,"column":4},"end":{"line":53,"column":null}},{"start":{},"end":{}}],"line":51},"6":{"loc":{"start":{"line":56,"column":4},"end":{"line":62,"column":null}},"type":"if","locations":[{"start":{"line":56,"column":4},"end":{"line":62,"column":null}},{"start":{},"end":{}}],"line":56},"7":{"loc":{"start":{"line":65,"column":4},"end":{"line":67,"column":null}},"type":"if","locations":[{"start":{"line":65,"column":4},"end":{"line":67,"column":null}},{"start":{},"end":{}}],"line":65}},"s":{"0":7,"1":4,"2":4,"3":1,"4":3,"5":3,"6":1,"7":1,"8":0,"9":2,"10":2,"11":1,"12":1,"13":1,"14":1,"15":7,"16":7,"17":7,"18":1,"19":6,"20":6,"21":1,"22":5,"23":2,"24":2,"25":3,"26":1,"27":2,"28":2,"29":2,"30":2,"31":2,"32":7,"33":1,"34":1,"35":1},"f":{"0":7,"1":4,"2":7,"3":1},"b":{"0":[1,3],"1":[4,4],"2":[1,0],"3":[1,1],"4":[1,6],"5":[1,5],"6":[2,3],"7":[1,2]},"meta":{"lastBranch":8,"lastFunction":4,"lastStatement":36,"seen":{"f:9:22:9:36":0,"s:11:2:36:Infinity":0,"f:11:59:11:66":1,"s:12:28:12:Infinity":1,"b:14:4:16:Infinity:undefined:undefined:undefined:undefined":0,"s:14:4:16:Infinity":2,"b:14:8:14:18:14:18:14:25":1,"s:15:6:15:Infinity":3,"s:18:4:25:Infinity":4,"s:19:6:19:Infinity":5,"b:21:6:23:Infinity:undefined:undefined:undefined:undefined":2,"s:21:6:23:Infinity":6,"s:22:8:22:Infinity":7,"s:24:6:24:Infinity":8,"s:27:19:27:Infinity":9,"b:28:4:30:Infinity:undefined:undefined:undefined:undefined":3,"s:28:4:30:Infinity":10,"s:29:6:29:Infinity":11,"s:32:19:32:Infinity":12,"s:33:25:33:Infinity":13,"s:35:4:35:Infinity":14,"s:39:2:81:Infinity":15,"f:39:59:39:66":2,"s:40:29:40:Infinity":16,"b:42:4:44:Infinity:undefined:undefined:undefined:undefined":4,"s:42:4:44:Infinity":17,"s:43:6:43:Infinity":18,"s:46:22:49:Infinity":19,"b:51:4:53:Infinity:undefined:undefined:undefined:undefined":5,"s:51:4:53:Infinity":20,"s:52:6:52:Infinity":21,"b:56:4:62:Infinity:undefined:undefined:undefined:undefined":6,"s:56:4:62:Infinity":22,"s:57:6:60:Infinity":23,"s:61:6:61:Infinity":24,"b:65:4:67:Infinity:undefined:undefined:undefined:undefined":7,"s:65:4:67:Infinity":25,"s:66:6:66:Infinity":26,"s:70:4:73:Infinity":27,"s:75:19:75:Infinity":28,"s:77:19:77:Infinity":29,"s:78:25:78:Infinity":30,"s:80:4:80:Infinity":31,"s:84:2:93:Infinity":32,"f:84:58:84:65":3,"s:85:19:85:Infinity":33,"s:87:4:90:Infinity":34,"s:92:4:92:Infinity":35}}} +,"/Users/fedjens/projects/eyrun/src/routes/signup.ts": {"path":"/Users/fedjens/projects/eyrun/src/routes/signup.ts","statementMap":{"0":{"start":{"line":11,"column":2},"end":{"line":60,"column":null}},"1":{"start":{"line":14,"column":43},"end":{"line":14,"column":null}},"2":{"start":{"line":16,"column":6},"end":{"line":20,"column":null}},"3":{"start":{"line":17,"column":8},"end":{"line":19,"column":null}},"4":{"start":{"line":22,"column":6},"end":{"line":29,"column":null}},"5":{"start":{"line":23,"column":8},"end":{"line":23,"column":null}},"6":{"start":{"line":25,"column":8},"end":{"line":27,"column":null}},"7":{"start":{"line":26,"column":10},"end":{"line":26,"column":null}},"8":{"start":{"line":28,"column":8},"end":{"line":28,"column":null}},"9":{"start":{"line":32,"column":29},"end":{"line":32,"column":null}},"10":{"start":{"line":33,"column":6},"end":{"line":35,"column":null}},"11":{"start":{"line":34,"column":8},"end":{"line":34,"column":null}},"12":{"start":{"line":37,"column":31},"end":{"line":52,"column":null}},"13":{"start":{"line":38,"column":26},"end":{"line":41,"column":null}},"14":{"start":{"line":43,"column":26},"end":{"line":43,"column":null}},"15":{"start":{"line":44,"column":8},"end":{"line":48,"column":null}},"16":{"start":{"line":50,"column":18},"end":{"line":50,"column":null}},"17":{"start":{"line":51,"column":8},"end":{"line":51,"column":null}},"18":{"start":{"line":54,"column":27},"end":{"line":54,"column":null}},"19":{"start":{"line":56,"column":6},"end":{"line":58,"column":null}}},"fnMap":{"0":{"name":"signupRoutes","decl":{"start":{"line":9,"column":22},"end":{"line":9,"column":35}},"loc":{"start":{"line":9,"column":57},"end":{"line":61,"column":null}},"line":9},"1":{"name":"(anonymous_1)","decl":{"start":{"line":13,"column":4},"end":{"line":13,"column":11}},"loc":{"start":{"line":13,"column":30},"end":{"line":59,"column":null}},"line":13},"2":{"name":"(anonymous_2)","decl":{"start":{"line":37,"column":52},"end":{"line":37,"column":59}},"loc":{"start":{"line":37,"column":66},"end":{"line":52,"column":7}},"line":37}},"branchMap":{"0":{"loc":{"start":{"line":16,"column":6},"end":{"line":20,"column":null}},"type":"if","locations":[{"start":{"line":16,"column":6},"end":{"line":20,"column":null}},{"start":{},"end":{}}],"line":16},"1":{"loc":{"start":{"line":16,"column":10},"end":{"line":16,"column":43}},"type":"binary-expr","locations":[{"start":{"line":16,"column":10},"end":{"line":16,"column":20}},{"start":{"line":16,"column":20},"end":{"line":16,"column":29}},{"start":{"line":16,"column":29},"end":{"line":16,"column":43}}],"line":16},"2":{"loc":{"start":{"line":25,"column":8},"end":{"line":27,"column":null}},"type":"if","locations":[{"start":{"line":25,"column":8},"end":{"line":27,"column":null}},{"start":{},"end":{}}],"line":25},"3":{"loc":{"start":{"line":33,"column":6},"end":{"line":35,"column":null}},"type":"if","locations":[{"start":{"line":33,"column":6},"end":{"line":35,"column":null}},{"start":{},"end":{}}],"line":33}},"s":{"0":7,"1":19,"2":19,"3":1,"4":18,"5":18,"6":1,"7":1,"8":0,"9":17,"10":17,"11":1,"12":16,"13":16,"14":16,"15":16,"16":16,"17":16,"18":16,"19":16},"f":{"0":7,"1":19,"2":16},"b":{"0":[1,18],"1":[19,19,18],"2":[1,0],"3":[1,16]},"meta":{"lastBranch":4,"lastFunction":3,"lastStatement":20,"seen":{"f:9:22:9:35":0,"s:11:2:60:Infinity":0,"f:13:4:13:11":1,"s:14:43:14:Infinity":1,"b:16:6:20:Infinity:undefined:undefined:undefined:undefined":0,"s:16:6:20:Infinity":2,"b:16:10:16:20:16:20:16:29:16:29:16:43":1,"s:17:8:19:Infinity":3,"s:22:6:29:Infinity":4,"s:23:8:23:Infinity":5,"b:25:8:27:Infinity:undefined:undefined:undefined:undefined":2,"s:25:8:27:Infinity":6,"s:26:10:26:Infinity":7,"s:28:8:28:Infinity":8,"s:32:29:32:Infinity":9,"b:33:6:35:Infinity:undefined:undefined:undefined:undefined":3,"s:33:6:35:Infinity":10,"s:34:8:34:Infinity":11,"s:37:31:52:Infinity":12,"f:37:52:37:59":2,"s:38:26:41:Infinity":13,"s:43:26:43:Infinity":14,"s:44:8:48:Infinity":15,"s:50:18:50:Infinity":16,"s:51:8:51:Infinity":17,"s:54:27:54:Infinity":18,"s:56:6:58:Infinity":19}}} +,"/Users/fedjens/projects/eyrun/tests/helpers.ts": {"path":"/Users/fedjens/projects/eyrun/tests/helpers.ts","statementMap":{"0":{"start":{"line":8,"column":8},"end":{"line":8,"column":null}},"1":{"start":{"line":9,"column":2},"end":{"line":9,"column":null}},"2":{"start":{"line":10,"column":2},"end":{"line":10,"column":null}},"3":{"start":{"line":14,"column":2},"end":{"line":14,"column":null}},"4":{"start":{"line":15,"column":2},"end":{"line":15,"column":null}},"5":{"start":{"line":16,"column":2},"end":{"line":16,"column":null}},"6":{"start":{"line":17,"column":2},"end":{"line":17,"column":null}},"7":{"start":{"line":18,"column":2},"end":{"line":18,"column":null}},"8":{"start":{"line":23,"column":2},"end":{"line":23,"column":null}},"9":{"start":{"line":25,"column":16},"end":{"line":30,"column":null}},"10":{"start":{"line":32,"column":2},"end":{"line":32,"column":null}},"11":{"start":{"line":41,"column":15},"end":{"line":41,"column":null}},"12":{"start":{"line":42,"column":14},"end":{"line":46,"column":null}},"13":{"start":{"line":47,"column":2},"end":{"line":47,"column":null}},"14":{"start":{"line":52,"column":15},"end":{"line":52,"column":null}},"15":{"start":{"line":53,"column":14},"end":{"line":57,"column":null}},"16":{"start":{"line":58,"column":2},"end":{"line":58,"column":null}}},"fnMap":{"0":{"name":"createTestApp","decl":{"start":{"line":7,"column":22},"end":{"line":7,"column":64}},"loc":{"start":{"line":7,"column":64},"end":{"line":11,"column":null}},"line":7},"1":{"name":"cleanDb","decl":{"start":{"line":13,"column":22},"end":{"line":13,"column":32}},"loc":{"start":{"line":13,"column":32},"end":{"line":19,"column":null}},"line":13},"2":{"name":"requestOtpCode","decl":{"start":{"line":22,"column":22},"end":{"line":22,"column":37}},"loc":{"start":{"line":22,"column":91},"end":{"line":33,"column":null}},"line":22},"3":{"name":"signupUser","decl":{"start":{"line":36,"column":22},"end":{"line":36,"column":null}},"loc":{"start":{"line":40,"column":2},"end":{"line":48,"column":null}},"line":40},"4":{"name":"loginUser","decl":{"start":{"line":51,"column":22},"end":{"line":51,"column":32}},"loc":{"start":{"line":51,"column":69},"end":{"line":59,"column":null}},"line":51}},"branchMap":{},"s":{"0":6,"1":6,"2":6,"3":30,"4":30,"5":30,"6":30,"7":30,"8":20,"9":20,"10":20,"11":16,"12":16,"13":16,"14":1,"15":1,"16":1},"f":{"0":6,"1":30,"2":20,"3":16,"4":1},"b":{},"meta":{"lastBranch":0,"lastFunction":5,"lastStatement":17,"seen":{"f:7:22:7:64":0,"s:8:8:8:Infinity":0,"s:9:2:9:Infinity":1,"s:10:2:10:Infinity":2,"f:13:22:13:32":1,"s:14:2:14:Infinity":3,"s:15:2:15:Infinity":4,"s:16:2:16:Infinity":5,"s:17:2:17:Infinity":6,"s:18:2:18:Infinity":7,"f:22:22:22:37":2,"s:23:2:23:Infinity":8,"s:25:16:30:Infinity":9,"s:32:2:32:Infinity":10,"f:36:22:36:Infinity":3,"s:41:15:41:Infinity":11,"s:42:14:46:Infinity":12,"s:47:2:47:Infinity":13,"f:51:22:51:32":4,"s:52:15:52:Infinity":14,"s:53:14:57:Infinity":15,"s:58:2:58:Infinity":16}}} +} diff --git a/coverage/favicon.png b/coverage/favicon.png new file mode 100644 index 0000000..c1525b8 Binary files /dev/null and b/coverage/favicon.png differ diff --git a/coverage/index.html b/coverage/index.html new file mode 100644 index 0000000..10d12c8 --- /dev/null +++ b/coverage/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 91.62% + Statements + 186/203 +
+ + +
+ 80.82% + Branches + 59/73 +
+ + +
+ 91.11% + Functions + 41/45 +
+ + +
+ 91.62% + Lines + 186/203 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
83.33%10/1250%1/2100%1/183.33%10/12
src/db +
+
72.72%8/11100%0/025%1/472.72%8/11
src/lib +
+
94.28%33/3590%9/10100%10/1094.28%33/35
src/plugins +
+
80.76%21/2657.14%8/1483.33%5/680.76%21/26
src/routes +
+
95.09%97/10287.23%41/47100%19/1995.09%97/102
tests +
+
100%17/17100%0/0100%5/5100%17/17
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/prettify.js b/coverage/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png new file mode 100644 index 0000000..6ed6831 Binary files /dev/null and b/coverage/sort-arrow-sprite.png differ diff --git a/coverage/sorter.js b/coverage/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/coverage/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/coverage/src/app.ts.html b/coverage/src/app.ts.html new file mode 100644 index 0000000..2fb0764 --- /dev/null +++ b/coverage/src/app.ts.html @@ -0,0 +1,139 @@ + + + + + + Code coverage report for src/app.ts + + + + + + + + + +
+
+

All files / src app.ts

+
+ +
+ 100% + Statements + 6/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 6/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19  +  +  +  +  +  +  +7x +  +  +  +7x +7x +7x +7x +  +7x +  + 
import Fastify from "fastify";
+import { errorHandler } from "./plugins/error-handler.js";
+import { authenticate } from "./plugins/authenticate.js";
+import { accountContext } from "./plugins/account-context.js";
+import { registerRoutes } from "./routes/index.js";
+ 
+export function buildApp() {
+  const app = Fastify({
+    logger: true,
+  });
+ 
+  app.register(errorHandler);
+  app.register(authenticate);
+  app.register(accountContext);
+  app.register(registerRoutes);
+ 
+  return app;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/config.ts.html b/coverage/src/config.ts.html new file mode 100644 index 0000000..8745096 --- /dev/null +++ b/coverage/src/config.ts.html @@ -0,0 +1,139 @@ + + + + + + Code coverage report for src/config.ts + + + + + + + + + +
+
+

All files / src config.ts

+
+ +
+ 66.66% + Statements + 4/6 +
+ + +
+ 50% + Branches + 1/2 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 66.66% + Lines + 4/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19  +  +  +7x +  +  +  +  +  +  +7x +  +7x +  +  +  +  +7x + 
import "dotenv/config";
+import { z } from "zod/v4";
+ 
+const envSchema = z.object({
+  DATABASE_URL: z.url(),
+  JWT_SECRET: z.string().min(32),
+  PORT: z.coerce.number().default(3000),
+  HOST: z.string().default("0.0.0.0"),
+});
+ 
+const parsed = envSchema.safeParse(process.env);
+ 
+Iif (!parsed.success) {
+  console.error("Invalid environment variables:", z.prettifyError(parsed.error));
+  process.exit(1);
+}
+ 
+export const config = parsed.data;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/db/index.html b/coverage/src/db/index.html new file mode 100644 index 0000000..eb08e29 --- /dev/null +++ b/coverage/src/db/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/db + + + + + + + + + +
+
+

All files src/db

+
+ +
+ 72.72% + Statements + 8/11 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 25% + Functions + 1/4 +
+ + +
+ 72.72% + Lines + 8/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
index.ts +
+
100%2/2100%0/0100%0/0100%2/2
schema.ts +
+
66.66%6/9100%0/025%1/466.66%6/9
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/db/index.ts.html b/coverage/src/db/index.ts.html new file mode 100644 index 0000000..352cbdf --- /dev/null +++ b/coverage/src/db/index.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/db/index.ts + + + + + + + + + +
+
+

All files / src/db index.ts

+
+ +
+ 100% + Statements + 2/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 100% + Lines + 2/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +7x +  +7x + 
import { drizzle } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+import { config } from "../config.js";
+import * as schema from "./schema.js";
+ 
+const client = postgres(config.DATABASE_URL);
+ 
+export const db = drizzle(client, { schema });
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/db/schema.ts.html b/coverage/src/db/schema.ts.html new file mode 100644 index 0000000..9766482 --- /dev/null +++ b/coverage/src/db/schema.ts.html @@ -0,0 +1,244 @@ + + + + + + Code coverage report for src/db/schema.ts + + + + + + + + + +
+
+

All files / src/db schema.ts

+
+ +
+ 66.66% + Statements + 6/9 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 25% + Functions + 1/4 +
+ + +
+ 66.66% + Lines + 6/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54  +  +7x +  +  +  +  +  +  +  +7x +  +  +  +  +  +  +  +  +  +7x +  +  +  +  +  +  +7x +  +  +  +  +  +  +  +  +  +  +  +  +7x +  +  +7x +  +  +  +  +  +  +  +  +  + 
import { boolean, integer, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core";
+ 
+export const users = pgTable("users", {
+  id: uuid().defaultRandom().primaryKey(),
+  email: text().notNull().unique(),
+  name: text().notNull(),
+  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+});
+ 
+export const otpCodes = pgTable("otp_codes", {
+  id: uuid().defaultRandom().primaryKey(),
+  email: text().notNull(),
+  code: text().notNull(),
+  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+  used: boolean().default(false).notNull(),
+  attempts: integer().default(0).notNull(),
+  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+});
+ 
+export const accounts = pgTable("accounts", {
+  id: uuid().defaultRandom().primaryKey(),
+  name: text().notNull(),
+  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+});
+ 
+export const memberships = pgTable(
+  "memberships",
+  {
+    id: uuid().defaultRandom().primaryKey(),
+    userId: uuid("user_id")
+      .notNull()
+      .references(() => users.id),
+    accountId: uuid("account_id")
+      .notNull()
+      .references(() => accounts.id),
+    role: text().notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+  },
+  (t) => [unique().on(t.userId, t.accountId)],
+);
+ 
+export const sessions = pgTable("sessions", {
+  id: uuid().defaultRandom().primaryKey(),
+  userId: uuid("user_id")
+    .notNull()
+    .references(() => users.id),
+  refreshToken: text("refresh_token").notNull().unique(),
+  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+  revokedAt: timestamp("revoked_at", { withTimezone: true }),
+  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+});
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/index.html b/coverage/src/index.html new file mode 100644 index 0000000..19c09b2 --- /dev/null +++ b/coverage/src/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 83.33% + Statements + 10/12 +
+ + +
+ 50% + Branches + 1/2 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 83.33% + Lines + 10/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
app.ts +
+
100%6/6100%0/0100%1/1100%6/6
config.ts +
+
66.66%4/650%1/2100%0/066.66%4/6
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/lib/accounts.ts.html b/coverage/src/lib/accounts.ts.html new file mode 100644 index 0000000..dd64ac5 --- /dev/null +++ b/coverage/src/lib/accounts.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/lib/accounts.ts + + + + + + + + + +
+
+

All files / src/lib accounts.ts

+
+ +
+ 100% + Statements + 1/1 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 1/1 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16  +  +  +  +  +22x +  +  +  +  +  +  +  +  +  + 
import { eq } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { accounts, memberships } from "../db/schema.js";
+ 
+export async function getUserAccounts(userId: string) {
+  return db
+    .select({
+      id: accounts.id,
+      name: accounts.name,
+      role: memberships.role,
+    })
+    .from(memberships)
+    .innerJoin(accounts, eq(memberships.accountId, accounts.id))
+    .where(eq(memberships.userId, userId));
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/lib/index.html b/coverage/src/lib/index.html new file mode 100644 index 0000000..4b64558 --- /dev/null +++ b/coverage/src/lib/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for src/lib + + + + + + + + + +
+
+

All files src/lib

+
+ +
+ 94.28% + Statements + 33/35 +
+ + +
+ 90% + Branches + 9/10 +
+ + +
+ 100% + Functions + 10/10 +
+ + +
+ 94.28% + Lines + 33/35 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
accounts.ts +
+
100%1/1100%0/0100%1/1100%1/1
jwt.ts +
+
100%2/2100%0/0100%2/2100%2/2
otp.ts +
+
91.66%22/2487.5%7/8100%5/591.66%22/24
sessions.ts +
+
100%7/7100%2/2100%1/1100%7/7
tokens.ts +
+
100%1/1100%0/0100%1/1100%1/1
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/lib/jwt.ts.html b/coverage/src/lib/jwt.ts.html new file mode 100644 index 0000000..4e790fe --- /dev/null +++ b/coverage/src/lib/jwt.ts.html @@ -0,0 +1,127 @@ + + + + + + Code coverage report for src/lib/jwt.ts + + + + + + + + + +
+
+

All files / src/lib jwt.ts

+
+ +
+ 100% + Statements + 2/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 100% + Lines + 2/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15  +  +  +  +  +  +  +  +19x +  +  +  +11x +  + 
import jwt from "jsonwebtoken";
+import { config } from "../config.js";
+ 
+export interface AccessTokenPayload {
+  sub: string;
+}
+ 
+export function signAccessToken(userId: string): string {
+  return jwt.sign({ sub: userId }, config.JWT_SECRET, { expiresIn: "1h" });
+}
+ 
+export function verifyAccessToken(token: string): AccessTokenPayload {
+  return jwt.verify(token, config.JWT_SECRET) as AccessTokenPayload;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/lib/otp.ts.html b/coverage/src/lib/otp.ts.html new file mode 100644 index 0000000..3789cea --- /dev/null +++ b/coverage/src/lib/otp.ts.html @@ -0,0 +1,322 @@ + + + + + + Code coverage report for src/lib/otp.ts + + + + + + + + + +
+
+

All files / src/lib otp.ts

+
+ +
+ 91.66% + Statements + 22/24 +
+ + +
+ 87.5% + Branches + 7/8 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 91.66% + Lines + 22/24 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80  +  +  +  +  +7x +7x +7x +  +  +  +25x +25x +  +  +  +  +25x +1x +  +  +24x +24x +  +24x +24x +  +  +  +24x +  +  +  +  +21x +  +  +  +  +  +  +  +  +  +  +  +  +21x +1x +  +  +20x +  +  +  +  +20x +  +  +  +  +20x +1x +  +  +19x +  +  +  +  +2x +  +  +  +  +  +1x +  +  + 
import crypto from "node:crypto";
+import { eq, and, gt } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { otpCodes } from "../db/schema.js";
+ 
+const OTP_MAX_ATTEMPTS = 5;
+const OTP_TTL_MINUTES = 10;
+const OTP_RATE_LIMIT_PER_HOUR = 3;
+ 
+/** Rate-limit, generate, store, and send an OTP for the given email. */
+export async function requestOtp(email: string): Promise<void> {
+  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
+  const recentCodes = await db
+    .select()
+    .from(otpCodes)
+    .where(and(eq(otpCodes.email, email), gt(otpCodes.createdAt, oneHourAgo)));
+ 
+  if (recentCodes.length >= OTP_RATE_LIMIT_PER_HOUR) {
+    throw new OtpRateLimitError("Too many OTP requests. Try again later.");
+  }
+ 
+  const code = crypto.randomInt(100_000, 999_999).toString();
+  const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000);
+ 
+  await db.insert(otpCodes).values({ email, code, expiresAt });
+  sendOtp(email, code);
+}
+ 
+function sendOtp(email: string, code: string): void {
+  console.log(`[OTP] Code for ${email}: ${code}`);
+}
+ 
+/** Validate an OTP code for the given email. Returns the email on success, throws on failure. */
+export async function verifyOtp(email: string, code: string): Promise<void> {
+  const [otp] = await db
+    .select()
+    .from(otpCodes)
+    .where(
+      and(
+        eq(otpCodes.email, email),
+        eq(otpCodes.used, false),
+        gt(otpCodes.expiresAt, new Date()),
+      ),
+    )
+    .orderBy(otpCodes.createdAt)
+    .limit(1);
+ 
+  if (!otp) {
+    throw new OtpError("Invalid or expired code");
+  }
+ 
+  Iif (otp.attempts >= OTP_MAX_ATTEMPTS) {
+    await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id));
+    throw new OtpError("Too many attempts. Request a new code.");
+  }
+ 
+  await db
+    .update(otpCodes)
+    .set({ attempts: otp.attempts + 1 })
+    .where(eq(otpCodes.id, otp.id));
+ 
+  if (otp.code !== code) {
+    throw new OtpError("Invalid or expired code");
+  }
+ 
+  await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id));
+}
+ 
+export class OtpError extends Error {
+  constructor(message: string) {
+    super(message);
+  }
+}
+ 
+export class OtpRateLimitError extends Error {
+  constructor(message: string) {
+    super(message);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/lib/sessions.ts.html b/coverage/src/lib/sessions.ts.html new file mode 100644 index 0000000..f50468e --- /dev/null +++ b/coverage/src/lib/sessions.ts.html @@ -0,0 +1,163 @@ + + + + + + Code coverage report for src/lib/sessions.ts + + + + + + + + + +
+
+

All files / src/lib sessions.ts

+
+ +
+ 100% + Statements + 7/7 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 7/7 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27  +  +  +  +  +  +7x +  +  +  +  +  +19x +19x +19x +  +19x +  +  +  +  +  +19x +  +19x +  + 
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
+import { db } from "../db/index.js";
+import * as schema from "../db/schema.js";
+import { signAccessToken } from "./jwt.js";
+import { generateRefreshToken } from "./tokens.js";
+ 
+const SESSION_TTL_DAYS = 30;
+ 
+export async function createSession(
+  userId: string,
+  tx?: PostgresJsDatabase<typeof schema>,
+) {
+  const conn = tx ?? db;
+  const refreshToken = generateRefreshToken();
+  const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000);
+ 
+  await conn.insert(schema.sessions).values({
+    userId,
+    refreshToken,
+    expiresAt,
+  });
+ 
+  const accessToken = signAccessToken(userId);
+ 
+  return { accessToken, refreshToken };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/lib/tokens.ts.html b/coverage/src/lib/tokens.ts.html new file mode 100644 index 0000000..30fd8c2 --- /dev/null +++ b/coverage/src/lib/tokens.ts.html @@ -0,0 +1,100 @@ + + + + + + Code coverage report for src/lib/tokens.ts + + + + + + + + + +
+
+

All files / src/lib tokens.ts

+
+ +
+ 100% + Statements + 1/1 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 1/1 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6  +  +  +19x +  + 
import crypto from "node:crypto";
+ 
+export function generateRefreshToken(): string {
+  return crypto.randomBytes(48).toString("base64url");
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/plugins/account-context.ts.html b/coverage/src/plugins/account-context.ts.html new file mode 100644 index 0000000..74e5a31 --- /dev/null +++ b/coverage/src/plugins/account-context.ts.html @@ -0,0 +1,217 @@ + + + + + + Code coverage report for src/plugins/account-context.ts + + + + + + + + + +
+
+

All files / src/plugins account-context.ts

+
+ +
+ 100% + Statements + 12/12 +
+ + +
+ 100% + Branches + 6/6 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 100% + Lines + 12/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +7x +7x +  +7x +3x +3x +1x +  +  +2x +  +  +  +  +2x +1x +  +  +1x +1x +  +  +  +7x +  +  +  + 
import fp from "fastify-plugin";
+import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
+import { eq, and } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { memberships } from "../db/schema.js";
+ 
+declare module "fastify" {
+  interface FastifyInstance {
+    requireAccount: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
+  }
+  interface FastifyRequest {
+    accountId: string;
+    membership: { role: string };
+  }
+}
+ 
+async function accountContextPlugin(app: FastifyInstance) {
+  app.decorateRequest("accountId", "");
+  app.decorateRequest("membership", null as unknown as { role: string });
+ 
+  app.decorate("requireAccount", async (request: FastifyRequest, reply: FastifyReply) => {
+    const accountId = request.headers["x-account-id"];
+    if (!accountId || typeof accountId !== "string") {
+      return reply.status(400).send({ error: "X-Account-Id header is required" });
+    }
+ 
+    const [membership] = await db
+      .select()
+      .from(memberships)
+      .where(and(eq(memberships.userId, request.user.sub), eq(memberships.accountId, accountId)));
+ 
+    if (!membership) {
+      return reply.status(403).send({ error: "You are not a member of this account" });
+    }
+ 
+    request.accountId = accountId;
+    request.membership = { role: membership.role };
+  });
+}
+ 
+export const accountContext = fp(accountContextPlugin, {
+  name: "account-context",
+  dependencies: ["authenticate"],
+});
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/plugins/authenticate.ts.html b/coverage/src/plugins/authenticate.ts.html new file mode 100644 index 0000000..88e05a4 --- /dev/null +++ b/coverage/src/plugins/authenticate.ts.html @@ -0,0 +1,178 @@ + + + + + + Code coverage report for src/plugins/authenticate.ts + + + + + + + + + +
+
+

All files / src/plugins authenticate.ts

+
+ +
+ 88.88% + Statements + 8/9 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 88.88% + Lines + 8/9 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32  +  +  +  +  +  +  +  +  +  +  +  +  +  +7x +  +7x +15x +15x +4x +  +  +11x +11x +  +  +  +  +  +  +7x + 
import fp from "fastify-plugin";
+import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
+import { verifyAccessToken, type AccessTokenPayload } from "../lib/jwt.js";
+ 
+declare module "fastify" {
+  interface FastifyInstance {
+    authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
+  }
+  interface FastifyRequest {
+    user: AccessTokenPayload;
+  }
+}
+ 
+async function authenticatePlugin(app: FastifyInstance) {
+  app.decorateRequest("user", null as unknown as AccessTokenPayload);
+ 
+  app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply) => {
+    const header = request.headers.authorization;
+    if (!header?.startsWith("Bearer ")) {
+      return reply.status(401).send({ error: "Missing or invalid authorization header" });
+    }
+ 
+    try {
+      request.user = verifyAccessToken(header.slice(7));
+    } catch {
+      return reply.status(401).send({ error: "Invalid or expired token" });
+    }
+  });
+}
+ 
+export const authenticate = fp(authenticatePlugin, { name: "authenticate" });
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/plugins/error-handler.ts.html b/coverage/src/plugins/error-handler.ts.html new file mode 100644 index 0000000..e57cf90 --- /dev/null +++ b/coverage/src/plugins/error-handler.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/plugins/error-handler.ts + + + + + + + + + +
+
+

All files / src/plugins error-handler.ts

+
+ +
+ 20% + Statements + 1/5 +
+ + +
+ 0% + Branches + 0/6 +
+ + +
+ 50% + Functions + 1/2 +
+ + +
+ 20% + Lines + 1/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16  +  +  +7x +  +  +  +  +  +  +  +  +  +  +  + 
import type { FastifyInstance, FastifyError } from "fastify";
+ 
+export async function errorHandler(app: FastifyInstance) {
+  app.setErrorHandler((error: FastifyError, _request, reply) => {
+    const statusCode = error.statusCode ?? 500;
+ 
+    if (statusCode >= 500) {
+      app.log.error(error);
+    }
+ 
+    reply.status(statusCode).send({
+      error: statusCode >= 500 ? "Internal Server Error" : error.message,
+    });
+  });
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/plugins/index.html b/coverage/src/plugins/index.html new file mode 100644 index 0000000..cb9b748 --- /dev/null +++ b/coverage/src/plugins/index.html @@ -0,0 +1,146 @@ + + + + + + Code coverage report for src/plugins + + + + + + + + + +
+
+

All files src/plugins

+
+ +
+ 80.76% + Statements + 21/26 +
+ + +
+ 57.14% + Branches + 8/14 +
+ + +
+ 83.33% + Functions + 5/6 +
+ + +
+ 80.76% + Lines + 21/26 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
account-context.ts +
+
100%12/12100%6/6100%2/2100%12/12
authenticate.ts +
+
88.88%8/9100%2/2100%2/288.88%8/9
error-handler.ts +
+
20%1/50%0/650%1/220%1/5
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/accounts.ts.html b/coverage/src/routes/accounts.ts.html new file mode 100644 index 0000000..19ef8ea --- /dev/null +++ b/coverage/src/routes/accounts.ts.html @@ -0,0 +1,196 @@ + + + + + + Code coverage report for src/routes/accounts.ts + + + + + + + + + +
+
+

All files / src/routes accounts.ts

+
+ +
+ 100% + Statements + 12/12 +
+ + +
+ 100% + Branches + 4/4 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 100% + Lines + 12/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38  +  +  +  +  +  +  +7x +2x +  +  +  +7x +  +  +  +2x +2x +  +2x +1x +  +  +1x +1x +1x +  +  +  +  +1x +  +  +1x +  +  +  + 
import type { FastifyInstance } from "fastify";
+import { db } from "../db/index.js";
+import { accounts, memberships } from "../db/schema.js";
+import { getUserAccounts } from "../lib/accounts.js";
+ 
+export async function accountRoutes(app: FastifyInstance) {
+  // GET /accounts — list user's accounts with role
+  app.get("/accounts", { preHandler: [app.authenticate] }, async (request) => {
+    return getUserAccounts(request.user.sub);
+  });
+ 
+  // POST /accounts — create a new account (user becomes owner)
+  app.post<{ Body: { name: string } }>(
+    "/accounts",
+    { preHandler: [app.authenticate] },
+    async (request, reply) => {
+      const userId = request.user.sub;
+      const { name } = request.body;
+ 
+      if (!name || typeof name !== "string") {
+        return reply.status(400).send({ error: "Account name is required" });
+      }
+ 
+      const account = await db.transaction(async (tx) => {
+        const [created] = await tx.insert(accounts).values({ name }).returning();
+        await tx.insert(memberships).values({
+          userId,
+          accountId: created.id,
+          role: "owner",
+        });
+        return created;
+      });
+ 
+      return reply.status(201).send({ id: account.id, name: account.name, role: "owner" });
+    },
+  );
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/health.ts.html b/coverage/src/routes/health.ts.html new file mode 100644 index 0000000..71b24cb --- /dev/null +++ b/coverage/src/routes/health.ts.html @@ -0,0 +1,106 @@ + + + + + + Code coverage report for src/routes/health.ts + + + + + + + + + +
+
+

All files / src/routes health.ts

+
+ +
+ 100% + Statements + 2/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 100% + Lines + 2/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8  +  +  +7x +1x +  +  + 
import type { FastifyInstance } from "fastify";
+ 
+export async function healthRoutes(app: FastifyInstance) {
+  app.get("/health", async () => {
+    return { status: "ok" };
+  });
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/index.html b/coverage/src/routes/index.html new file mode 100644 index 0000000..bd406f8 --- /dev/null +++ b/coverage/src/routes/index.html @@ -0,0 +1,206 @@ + + + + + + Code coverage report for src/routes + + + + + + + + + +
+
+

All files src/routes

+
+ +
+ 95.09% + Statements + 97/102 +
+ + +
+ 87.23% + Branches + 41/47 +
+ + +
+ 100% + Functions + 19/19 +
+ + +
+ 95.09% + Lines + 97/102 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
accounts.ts +
+
100%12/12100%4/4100%4/4100%12/12
health.ts +
+
100%2/2100%0/0100%2/2100%2/2
index.ts +
+
100%6/6100%0/0100%1/1100%6/6
login.ts +
+
90%9/1083.33%5/6100%2/290%9/10
me.ts +
+
87.5%14/1675%9/12100%3/387.5%14/16
sessions.ts +
+
97.22%35/3693.75%15/16100%4/497.22%35/36
signup.ts +
+
95%19/2088.88%8/9100%3/395%19/20
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/index.ts.html b/coverage/src/routes/index.ts.html new file mode 100644 index 0000000..1fbac55 --- /dev/null +++ b/coverage/src/routes/index.ts.html @@ -0,0 +1,133 @@ + + + + + + Code coverage report for src/routes/index.ts + + + + + + + + + +
+
+

All files / src/routes index.ts

+
+ +
+ 100% + Statements + 6/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 6/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17  +  +  +  +  +  +  +  +  +7x +7x +7x +7x +7x +7x +  + 
import type { FastifyInstance } from "fastify";
+import { healthRoutes } from "./health.js";
+import { loginRoutes } from "./login.js";
+import { signupRoutes } from "./signup.js";
+import { sessionRoutes } from "./sessions.js";
+import { meRoutes } from "./me.js";
+import { accountRoutes } from "./accounts.js";
+ 
+export async function registerRoutes(app: FastifyInstance) {
+  app.register(healthRoutes);
+  app.register(loginRoutes);
+  app.register(signupRoutes);
+  app.register(sessionRoutes, { prefix: "/sessions" });
+  app.register(meRoutes);
+  app.register(accountRoutes);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/login.ts.html b/coverage/src/routes/login.ts.html new file mode 100644 index 0000000..36b0df6 --- /dev/null +++ b/coverage/src/routes/login.ts.html @@ -0,0 +1,157 @@ + + + + + + Code coverage report for src/routes/login.ts + + + + + + + + + +
+
+

All files / src/routes login.ts

+
+ +
+ 90% + Statements + 9/10 +
+ + +
+ 83.33% + Branches + 5/6 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 90% + Lines + 9/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25  +  +  +  +  +7x +26x +  +26x +1x +  +  +25x +25x +  +1x +1x +  +  +  +  +24x +  +  + 
import type { FastifyInstance } from "fastify";
+import { requestOtp, OtpRateLimitError } from "../lib/otp.js";
+ 
+export async function loginRoutes(app: FastifyInstance) {
+  // POST /login — send OTP to email
+  app.post<{ Body: { email: string } }>("/login", async (request, reply) => {
+    const { email } = request.body;
+ 
+    if (!email || typeof email !== "string") {
+      return reply.status(400).send({ error: "Email is required" });
+    }
+ 
+    try {
+      await requestOtp(email);
+    } catch (err) {
+      Eif (err instanceof OtpRateLimitError) {
+        return reply.status(429).send({ error: err.message });
+      }
+      throw err;
+    }
+ 
+    return { message: "OTP sent to your email" };
+  });
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/me.ts.html b/coverage/src/routes/me.ts.html new file mode 100644 index 0000000..9216e6e --- /dev/null +++ b/coverage/src/routes/me.ts.html @@ -0,0 +1,226 @@ + + + + + + Code coverage report for src/routes/me.ts + + + + + + + + + +
+
+

All files / src/routes me.ts

+
+ +
+ 87.5% + Statements + 14/16 +
+ + +
+ 75% + Branches + 9/12 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 87.5% + Lines + 14/16 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48  +  +  +  +  +  +  +  +7x +1x +  +1x +1x +  +  +  +1x +  +1x +  +  +  +7x +  +  +  +2x +2x +  +2x +1x +  +  +1x +  +  +  +  +  +1x +  +  +  +1x +  +  +  + 
import type { FastifyInstance } from "fastify";
+import { eq } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { users } from "../db/schema.js";
+import { getUserAccounts } from "../lib/accounts.js";
+ 
+export async function meRoutes(app: FastifyInstance) {
+  // GET /me — return current user with accounts (authenticated)
+  app.get("/me", { preHandler: [app.authenticate] }, async (request, reply) => {
+    const userId = request.user.sub;
+ 
+    const [user] = await db.select().from(users).where(eq(users.id, userId));
+    Iif (!user) {
+      return reply.status(404).send({ error: "User not found" });
+    }
+ 
+    const userAccounts = await getUserAccounts(userId);
+ 
+    return { ...user, accounts: userAccounts };
+  });
+ 
+  // PATCH /me — update current user's profile (authenticated)
+  app.patch<{ Body: { email?: string; name?: string } }>(
+    "/me",
+    { preHandler: [app.authenticate] },
+    async (request, reply) => {
+      const userId = request.user.sub;
+      const { email, name } = request.body;
+ 
+      if (!email && !name) {
+        return reply.status(400).send({ error: "Nothing to update" });
+      }
+ 
+      const [user] = await db
+        .update(users)
+        .set({ ...(email && { email }), ...(name && { name }), updatedAt: new Date() })
+        .where(eq(users.id, userId))
+        .returning();
+ 
+      Iif (!user) {
+        return reply.status(404).send({ error: "User not found" });
+      }
+ 
+      return user;
+    },
+  );
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/sessions.ts.html b/coverage/src/routes/sessions.ts.html new file mode 100644 index 0000000..c9b8fff --- /dev/null +++ b/coverage/src/routes/sessions.ts.html @@ -0,0 +1,367 @@ + + + + + + Code coverage report for src/routes/sessions.ts + + + + + + + + + +
+
+

All files / src/routes sessions.ts

+
+ +
+ 97.22% + Statements + 35/36 +
+ + +
+ 93.75% + Branches + 15/16 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 97.22% + Lines + 35/36 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95  +  +  +  +  +  +  +  +  +  +7x +4x +  +4x +1x +  +  +3x +3x +  +1x +1x +  +  +  +  +2x +2x +1x +  +  +1x +1x +  +1x +  +  +  +7x +7x +  +7x +1x +  +  +6x +  +  +  +  +6x +1x +  +  +  +5x +2x +  +  +  +2x +  +  +  +3x +1x +  +  +  +2x +  +  +  +  +2x +  +2x +2x +  +2x +  +  +  +7x +1x +  +1x +  +  +  +  +1x +  +  + 
import type { FastifyInstance } from "fastify";
+import { eq, and, isNull } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { users, sessions } from "../db/schema.js";
+import { verifyOtp, OtpError } from "../lib/otp.js";
+import { createSession } from "../lib/sessions.js";
+import { getUserAccounts } from "../lib/accounts.js";
+ 
+export async function sessionRoutes(app: FastifyInstance) {
+  // POST /sessions — validate OTP, create session for existing user
+  app.post<{ Body: { email: string; code: string } }>("/", async (request, reply) => {
+    const { email, code } = request.body;
+ 
+    if (!email || !code) {
+      return reply.status(400).send({ error: "Email and code are required" });
+    }
+ 
+    try {
+      await verifyOtp(email, code);
+    } catch (err) {
+      Eif (err instanceof OtpError) {
+        return reply.status(400).send({ error: err.message });
+      }
+      throw err;
+    }
+ 
+    const [user] = await db.select().from(users).where(eq(users.email, email));
+    if (!user) {
+      return reply.status(401).send({ error: "No account found. Please sign up." });
+    }
+ 
+    const tokens = await createSession(user.id);
+    const userAccounts = await getUserAccounts(user.id);
+ 
+    return { ...tokens, user, accounts: userAccounts };
+  });
+ 
+  // POST /sessions/refresh — rotate refresh token, return new tokens
+  app.post<{ Body: { refreshToken: string } }>("/refresh", async (request, reply) => {
+    const { refreshToken } = request.body;
+ 
+    if (!refreshToken) {
+      return reply.status(400).send({ error: "Refresh token is required" });
+    }
+ 
+    const [session] = await db
+      .select()
+      .from(sessions)
+      .where(eq(sessions.refreshToken, refreshToken));
+ 
+    if (!session) {
+      return reply.status(401).send({ error: "Invalid refresh token" });
+    }
+ 
+    // If session was already revoked, this is a reuse attempt — revoke all sessions for this user (theft detection)
+    if (session.revokedAt) {
+      await db
+        .update(sessions)
+        .set({ revokedAt: new Date() })
+        .where(and(eq(sessions.userId, session.userId), isNull(sessions.revokedAt)));
+      return reply.status(401).send({ error: "Token reuse detected. All sessions revoked." });
+    }
+ 
+    // Check expiry
+    if (session.expiresAt < new Date()) {
+      return reply.status(401).send({ error: "Refresh token expired" });
+    }
+ 
+    // Revoke old session
+    await db
+      .update(sessions)
+      .set({ revokedAt: new Date() })
+      .where(eq(sessions.id, session.id));
+ 
+    const tokens = await createSession(session.userId);
+ 
+    const [user] = await db.select().from(users).where(eq(users.id, session.userId));
+    const userAccounts = await getUserAccounts(session.userId);
+ 
+    return { ...tokens, user, accounts: userAccounts };
+  });
+ 
+  // POST /sessions/logout — revoke all sessions (authenticated)
+  app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => {
+    const userId = request.user.sub;
+ 
+    await db
+      .update(sessions)
+      .set({ revokedAt: new Date() })
+      .where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)));
+ 
+    return reply.status(204).send();
+  });
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/routes/signup.ts.html b/coverage/src/routes/signup.ts.html new file mode 100644 index 0000000..18306f0 --- /dev/null +++ b/coverage/src/routes/signup.ts.html @@ -0,0 +1,268 @@ + + + + + + Code coverage report for src/routes/signup.ts + + + + + + + + + +
+
+

All files / src/routes signup.ts

+
+ +
+ 95% + Statements + 19/20 +
+ + +
+ 88.88% + Branches + 8/9 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 95% + Lines + 19/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62  +  +  +  +  +  +  +  +  +  +7x +  +  +19x +  +19x +1x +  +  +  +  +18x +18x +  +1x +1x +  +  +  +  +  +17x +17x +1x +  +  +16x +16x +  +  +  +  +16x +16x +  +  +  +  +  +16x +16x +  +  +16x +  +16x +  +  +  +  +  + 
import type { FastifyInstance } from "fastify";
+import { eq } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { users, accounts, memberships } from "../db/schema.js";
+import { verifyOtp, OtpError } from "../lib/otp.js";
+import { createSession } from "../lib/sessions.js";
+import { getUserAccounts } from "../lib/accounts.js";
+ 
+export async function signupRoutes(app: FastifyInstance) {
+  // POST /signup — validate OTP, create user + account, return tokens
+  app.post<{ Body: { email: string; code: string; accountName: string } }>(
+    "/signup",
+    async (request, reply) => {
+      const { email, code, accountName } = request.body;
+ 
+      if (!email || !code || !accountName) {
+        return reply
+          .status(400)
+          .send({ error: "Email, code, and accountName are required" });
+      }
+ 
+      try {
+        await verifyOtp(email, code);
+      } catch (err) {
+        Eif (err instanceof OtpError) {
+          return reply.status(400).send({ error: err.message });
+        }
+        throw err;
+      }
+ 
+      // Check that user does not already exist
+      const [existingUser] = await db.select().from(users).where(eq(users.email, email));
+      if (existingUser) {
+        return reply.status(409).send({ error: "User already exists. Please log in." });
+      }
+ 
+      const { user, tokens } = await db.transaction(async (tx) => {
+        const [created] = await tx
+          .insert(users)
+          .values({ email, name: email.split("@")[0] })
+          .returning();
+ 
+        const [account] = await tx.insert(accounts).values({ name: accountName }).returning();
+        await tx.insert(memberships).values({
+          userId: created.id,
+          accountId: account.id,
+          role: "owner",
+        });
+ 
+        const t = await createSession(created.id, tx);
+        return { user: created, tokens: t };
+      });
+ 
+      const userAccounts = await getUserAccounts(user.id);
+ 
+      return reply
+        .status(201)
+        .send({ ...tokens, user, accounts: userAccounts });
+    },
+  );
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/tests/helpers.ts.html b/coverage/tests/helpers.ts.html new file mode 100644 index 0000000..d4adc11 --- /dev/null +++ b/coverage/tests/helpers.ts.html @@ -0,0 +1,262 @@ + + + + + + Code coverage report for tests/helpers.ts + + + + + + + + + +
+
+

All files / tests helpers.ts

+
+ +
+ 100% + Statements + 17/17 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 17/17 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60  +  +  +  +  +  +  +6x +6x +6x +  +  +  +30x +30x +30x +30x +30x +  +  +  +  +20x +  +20x +  +  +  +  +  +  +20x +  +  +  +  +  +  +  +  +16x +16x +  +  +  +  +16x +  +  +  +  +1x +1x +  +  +  +  +1x +  + 
import { type FastifyInstance } from "fastify";
+import { buildApp } from "../src/app.js";
+import { db } from "../src/db/index.js";
+import { users, otpCodes, sessions, accounts, memberships } from "../src/db/schema.js";
+import { sql } from "drizzle-orm";
+ 
+export async function createTestApp(): Promise<FastifyInstance> {
+  const app = buildApp();
+  await app.ready();
+  return app;
+}
+ 
+export async function cleanDb() {
+  await db.delete(sessions);
+  await db.delete(memberships);
+  await db.delete(accounts);
+  await db.delete(otpCodes);
+  await db.delete(users);
+}
+ 
+/** Request an OTP and return the code from the DB */
+export async function requestOtpCode(app: FastifyInstance, email: string): Promise<string> {
+  await app.inject({ method: "POST", url: "/login", payload: { email } });
+ 
+  const [otp] = await db
+    .select()
+    .from(otpCodes)
+    .where(sql`${otpCodes.email} = ${email}`)
+    .orderBy(sql`${otpCodes.createdAt} desc`)
+    .limit(1);
+ 
+  return otp.code;
+}
+ 
+/** Full signup flow: request OTP → signup → return response */
+export async function signupUser(
+  app: FastifyInstance,
+  email: string,
+  accountName: string,
+) {
+  const code = await requestOtpCode(app, email);
+  const res = await app.inject({
+    method: "POST",
+    url: "/signup",
+    payload: { email, code, accountName },
+  });
+  return res;
+}
+ 
+/** Full login flow for an existing user: request OTP → create session → return response */
+export async function loginUser(app: FastifyInstance, email: string) {
+  const code = await requestOtpCode(app, email);
+  const res = await app.inject({
+    method: "POST",
+    url: "/sessions",
+    payload: { email, code },
+  });
+  return res;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/tests/index.html b/coverage/tests/index.html new file mode 100644 index 0000000..e19edf8 --- /dev/null +++ b/coverage/tests/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for tests + + + + + + + + + +
+
+

All files tests

+
+ +
+ 100% + Statements + 17/17 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 17/17 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
helpers.ts +
+
100%17/17100%0/0100%5/5100%17/17
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..412a576 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + out: "./drizzle", + schema: "./src/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/drizzle/0000_acoustic_sphinx.sql b/drizzle/0000_acoustic_sphinx.sql new file mode 100644 index 0000000..746fea2 --- /dev/null +++ b/drizzle/0000_acoustic_sphinx.sql @@ -0,0 +1,8 @@ +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); diff --git a/drizzle/0001_magical_luckman.sql b/drizzle/0001_magical_luckman.sql new file mode 100644 index 0000000..e208c1b --- /dev/null +++ b/drizzle/0001_magical_luckman.sql @@ -0,0 +1,21 @@ +CREATE TABLE "otp_codes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "code" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "used" boolean DEFAULT false NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "refresh_token" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token") +); +--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0002_fast_tarot.sql b/drizzle/0002_fast_tarot.sql new file mode 100644 index 0000000..2a9d8d4 --- /dev/null +++ b/drizzle/0002_fast_tarot.sql @@ -0,0 +1,18 @@ +CREATE TABLE "accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "memberships" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "account_id" uuid NOT NULL, + "role" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "memberships_user_id_account_id_unique" UNIQUE("user_id","account_id") +); +--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..0e757d1 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,73 @@ +{ + "id": "656259be-3409-4073-a15d-e8f1a0fd168f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..70faaf2 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,205 @@ +{ + "id": "dd48cf1d-0d6c-44dc-90c1-235f683eee6b", + "prevId": "656259be-3409-4073-a15d-e8f1a0fd168f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..8fb587b --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,326 @@ +{ + "id": "aff5e3b9-ae84-4896-8198-eec50eb41dd9", + "prevId": "dd48cf1d-0d6c-44dc-90c1-235f683eee6b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memberships_user_id_account_id_unique": { + "name": "memberships_user_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..d0a2d83 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1770478964463, + "tag": "0000_acoustic_sphinx", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1770479859824, + "tag": "0001_magical_luckman", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1770481466355, + "tag": "0002_fast_tarot", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..21ed673 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "eyrun", + "version": "1.0.0", + "description": "Eyrun API", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.18.3", + "dependencies": { + "dotenv": "^17.2.4", + "drizzle-orm": "^0.45.1", + "fastify": "^5.7.4", + "fastify-plugin": "^5.1.0", + "jsonwebtoken": "^9.0.3", + "postgres": "^3.4.8", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^25.2.1", + "@vitest/coverage-v8": "^4.0.18", + "drizzle-kit": "^0.31.8", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f273284 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2258 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + dotenv: + specifier: ^17.2.4 + version: 17.2.4 + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(postgres@3.4.8) + fastify: + specifier: ^5.7.4 + version: 5.7.4 + fastify-plugin: + specifier: ^5.1.0 + version: 5.1.0 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + postgres: + specifier: ^3.4.8 + version: 3.4.8 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/node': + specifier: ^25.2.1 + version: 25.2.1 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0)) + drizzle-kit: + specifier: ^0.31.8 + version: 0.31.8 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.1)(tsx@4.21.0) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.2.1': + resolution: {integrity: sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==} + + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} + engines: {node: '>=12'} + + drizzle-kit@0.31.8: + resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} + hasBin: true + + drizzle-orm@0.45.1: + resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.7.4: + resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-my-way@9.4.0: + resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.0: + resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} + hasBin: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + postgres@3.4.8: + resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} + engines: {node: '>=12'} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.6 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@pinojs/redact@0.4.0': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.2.1 + + '@types/ms@2.1.0': {} + + '@types/node@25.2.1': + dependencies: + undici-types: 7.16.0 + + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.2.1)(tsx@4.21.0) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.1)(tsx@4.21.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + atomic-sleep@1.0.0: {} + + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + chai@6.2.2: {} + + cookie@1.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dequal@2.0.3: {} + + dotenv@17.2.4: {} + + drizzle-kit@0.31.8: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.45.1(postgres@3.4.8): + optionalDependencies: + postgres: 3.4.8 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + es-module-lexer@1.7.0: {} + + esbuild-register@3.6.0(esbuild@0.25.12): + dependencies: + debug: 4.4.3 + esbuild: 0.25.12 + transitivePeerDependencies: + - supports-color + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.7.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.4.0 + light-my-request: 6.6.0 + pino: 10.3.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + find-my-way@9.4.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + ipaddr.js@2.3.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + on-exit-leak-free@2.1.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 4.0.0 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres@3.4.8: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.7.4: {} + + set-cookie-parser@2.7.2: {} + + siginfo@2.0.0: {} + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + toad-cache@3.7.0: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.2.1 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.2.1)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@4.3.6: {} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..535f921 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,18 @@ +import Fastify from "fastify"; +import { errorHandler } from "./plugins/error-handler.js"; +import { authenticate } from "./plugins/authenticate.js"; +import { accountContext } from "./plugins/account-context.js"; +import { registerRoutes } from "./routes/index.js"; + +export function buildApp() { + const app = Fastify({ + logger: true, + }); + + app.register(errorHandler); + app.register(authenticate); + app.register(accountContext); + app.register(registerRoutes); + + return app; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..53f798c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,18 @@ +import "dotenv/config"; +import { z } from "zod/v4"; + +const envSchema = z.object({ + DATABASE_URL: z.url(), + JWT_SECRET: z.string().min(32), + PORT: z.coerce.number().default(3000), + HOST: z.string().default("0.0.0.0"), +}); + +const parsed = envSchema.safeParse(process.env); + +if (!parsed.success) { + console.error("Invalid environment variables:", z.prettifyError(parsed.error)); + process.exit(1); +} + +export const config = parsed.data; diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..549f74a --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,8 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { config } from "../config.js"; +import * as schema from "./schema.js"; + +const client = postgres(config.DATABASE_URL); + +export const db = drizzle(client, { schema }); diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..bb6ec84 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,19 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; +import { config } from "../config.js"; + +const client = postgres(config.DATABASE_URL, { max: 1 }); +const db = drizzle(client); + +async function main() { + console.log("Running migrations..."); + await migrate(db, { migrationsFolder: "./drizzle" }); + console.log("Migrations complete."); + await client.end(); +} + +main().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..f27b7bb --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,53 @@ +import { boolean, integer, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; + +export const users = pgTable("users", { + id: uuid().defaultRandom().primaryKey(), + email: text().notNull().unique(), + name: text().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const otpCodes = pgTable("otp_codes", { + id: uuid().defaultRandom().primaryKey(), + email: text().notNull(), + code: text().notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + used: boolean().default(false).notNull(), + attempts: integer().default(0).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const accounts = pgTable("accounts", { + id: uuid().defaultRandom().primaryKey(), + name: text().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const memberships = pgTable( + "memberships", + { + id: uuid().defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id), + role: text().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (t) => [unique().on(t.userId, t.accountId)], +); + +export const sessions = pgTable("sessions", { + id: uuid().defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + refreshToken: text("refresh_token").notNull().unique(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); diff --git a/src/lib/accounts.ts b/src/lib/accounts.ts new file mode 100644 index 0000000..0e36a95 --- /dev/null +++ b/src/lib/accounts.ts @@ -0,0 +1,15 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { accounts, memberships } from "../db/schema.js"; + +export async function getUserAccounts(userId: string) { + return db + .select({ + id: accounts.id, + name: accounts.name, + role: memberships.role, + }) + .from(memberships) + .innerJoin(accounts, eq(memberships.accountId, accounts.id)) + .where(eq(memberships.userId, userId)); +} diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..44e85a7 --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,14 @@ +import jwt from "jsonwebtoken"; +import { config } from "../config.js"; + +export interface AccessTokenPayload { + sub: string; +} + +export function signAccessToken(userId: string): string { + return jwt.sign({ sub: userId }, config.JWT_SECRET, { expiresIn: "1h" }); +} + +export function verifyAccessToken(token: string): AccessTokenPayload { + return jwt.verify(token, config.JWT_SECRET) as AccessTokenPayload; +} diff --git a/src/lib/otp.ts b/src/lib/otp.ts new file mode 100644 index 0000000..31348ac --- /dev/null +++ b/src/lib/otp.ts @@ -0,0 +1,79 @@ +import crypto from "node:crypto"; +import { eq, and, gt } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { otpCodes } from "../db/schema.js"; + +const OTP_MAX_ATTEMPTS = 5; +const OTP_TTL_MINUTES = 10; +const OTP_RATE_LIMIT_PER_HOUR = 3; + +/** Rate-limit, generate, store, and send an OTP for the given email. */ +export async function requestOtp(email: string): Promise { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const recentCodes = await db + .select() + .from(otpCodes) + .where(and(eq(otpCodes.email, email), gt(otpCodes.createdAt, oneHourAgo))); + + if (recentCodes.length >= OTP_RATE_LIMIT_PER_HOUR) { + throw new OtpRateLimitError("Too many OTP requests. Try again later."); + } + + const code = crypto.randomInt(100_000, 999_999).toString(); + const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000); + + await db.insert(otpCodes).values({ email, code, expiresAt }); + sendOtp(email, code); +} + +function sendOtp(email: string, code: string): void { + console.log(`[OTP] Code for ${email}: ${code}`); +} + +/** Validate an OTP code for the given email. Returns the email on success, throws on failure. */ +export async function verifyOtp(email: string, code: string): Promise { + const [otp] = await db + .select() + .from(otpCodes) + .where( + and( + eq(otpCodes.email, email), + eq(otpCodes.used, false), + gt(otpCodes.expiresAt, new Date()), + ), + ) + .orderBy(otpCodes.createdAt) + .limit(1); + + if (!otp) { + throw new OtpError("Invalid or expired code"); + } + + if (otp.attempts >= OTP_MAX_ATTEMPTS) { + await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id)); + throw new OtpError("Too many attempts. Request a new code."); + } + + await db + .update(otpCodes) + .set({ attempts: otp.attempts + 1 }) + .where(eq(otpCodes.id, otp.id)); + + if (otp.code !== code) { + throw new OtpError("Invalid or expired code"); + } + + await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id)); +} + +export class OtpError extends Error { + constructor(message: string) { + super(message); + } +} + +export class OtpRateLimitError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts new file mode 100644 index 0000000..009a153 --- /dev/null +++ b/src/lib/sessions.ts @@ -0,0 +1,26 @@ +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { db } from "../db/index.js"; +import * as schema from "../db/schema.js"; +import { signAccessToken } from "./jwt.js"; +import { generateRefreshToken } from "./tokens.js"; + +const SESSION_TTL_DAYS = 30; + +export async function createSession( + userId: string, + tx?: PostgresJsDatabase, +) { + const conn = tx ?? db; + const refreshToken = generateRefreshToken(); + const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000); + + await conn.insert(schema.sessions).values({ + userId, + refreshToken, + expiresAt, + }); + + const accessToken = signAccessToken(userId); + + return { accessToken, refreshToken }; +} diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts new file mode 100644 index 0000000..9013d17 --- /dev/null +++ b/src/lib/tokens.ts @@ -0,0 +1,5 @@ +import crypto from "node:crypto"; + +export function generateRefreshToken(): string { + return crypto.randomBytes(48).toString("base64url"); +} diff --git a/src/plugins/account-context.ts b/src/plugins/account-context.ts new file mode 100644 index 0000000..dace4e0 --- /dev/null +++ b/src/plugins/account-context.ts @@ -0,0 +1,44 @@ +import fp from "fastify-plugin"; +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { eq, and } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { memberships } from "../db/schema.js"; + +declare module "fastify" { + interface FastifyInstance { + requireAccount: (request: FastifyRequest, reply: FastifyReply) => Promise; + } + interface FastifyRequest { + accountId: string; + membership: { role: string }; + } +} + +async function accountContextPlugin(app: FastifyInstance) { + app.decorateRequest("accountId", ""); + app.decorateRequest("membership", null as unknown as { role: string }); + + app.decorate("requireAccount", async (request: FastifyRequest, reply: FastifyReply) => { + const accountId = request.headers["x-account-id"]; + if (!accountId || typeof accountId !== "string") { + return reply.status(400).send({ error: "X-Account-Id header is required" }); + } + + const [membership] = await db + .select() + .from(memberships) + .where(and(eq(memberships.userId, request.user.sub), eq(memberships.accountId, accountId))); + + if (!membership) { + return reply.status(403).send({ error: "You are not a member of this account" }); + } + + request.accountId = accountId; + request.membership = { role: membership.role }; + }); +} + +export const accountContext = fp(accountContextPlugin, { + name: "account-context", + dependencies: ["authenticate"], +}); diff --git a/src/plugins/authenticate.ts b/src/plugins/authenticate.ts new file mode 100644 index 0000000..ba1942c --- /dev/null +++ b/src/plugins/authenticate.ts @@ -0,0 +1,31 @@ +import fp from "fastify-plugin"; +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { verifyAccessToken, type AccessTokenPayload } from "../lib/jwt.js"; + +declare module "fastify" { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } + interface FastifyRequest { + user: AccessTokenPayload; + } +} + +async function authenticatePlugin(app: FastifyInstance) { + app.decorateRequest("user", null as unknown as AccessTokenPayload); + + app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply) => { + const header = request.headers.authorization; + if (!header?.startsWith("Bearer ")) { + return reply.status(401).send({ error: "Missing or invalid authorization header" }); + } + + try { + request.user = verifyAccessToken(header.slice(7)); + } catch { + return reply.status(401).send({ error: "Invalid or expired token" }); + } + }); +} + +export const authenticate = fp(authenticatePlugin, { name: "authenticate" }); diff --git a/src/plugins/error-handler.ts b/src/plugins/error-handler.ts new file mode 100644 index 0000000..339bcab --- /dev/null +++ b/src/plugins/error-handler.ts @@ -0,0 +1,15 @@ +import type { FastifyInstance, FastifyError } from "fastify"; + +export async function errorHandler(app: FastifyInstance) { + app.setErrorHandler((error: FastifyError, _request, reply) => { + const statusCode = error.statusCode ?? 500; + + if (statusCode >= 500) { + app.log.error(error); + } + + reply.status(statusCode).send({ + error: statusCode >= 500 ? "Internal Server Error" : error.message, + }); + }); +} diff --git a/src/routes/accounts.ts b/src/routes/accounts.ts new file mode 100644 index 0000000..525454c --- /dev/null +++ b/src/routes/accounts.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance } from "fastify"; +import { db } from "../db/index.js"; +import { accounts, memberships } from "../db/schema.js"; +import { getUserAccounts } from "../lib/accounts.js"; + +export async function accountRoutes(app: FastifyInstance) { + // GET /accounts — list user's accounts with role + app.get("/accounts", { preHandler: [app.authenticate] }, async (request) => { + return getUserAccounts(request.user.sub); + }); + + // POST /accounts — create a new account (user becomes owner) + app.post<{ Body: { name: string } }>( + "/accounts", + { preHandler: [app.authenticate] }, + async (request, reply) => { + const userId = request.user.sub; + const { name } = request.body; + + if (!name || typeof name !== "string") { + return reply.status(400).send({ error: "Account name is required" }); + } + + const account = await db.transaction(async (tx) => { + const [created] = await tx.insert(accounts).values({ name }).returning(); + await tx.insert(memberships).values({ + userId, + accountId: created.id, + role: "owner", + }); + return created; + }); + + return reply.status(201).send({ id: account.id, name: account.name, role: "owner" }); + }, + ); +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..befaf7b --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,7 @@ +import type { FastifyInstance } from "fastify"; + +export async function healthRoutes(app: FastifyInstance) { + app.get("/health", async () => { + return { status: "ok" }; + }); +} diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..e02ff0a --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from "fastify"; +import { healthRoutes } from "./health.js"; +import { loginRoutes } from "./login.js"; +import { signupRoutes } from "./signup.js"; +import { sessionRoutes } from "./sessions.js"; +import { meRoutes } from "./me.js"; +import { accountRoutes } from "./accounts.js"; + +export async function registerRoutes(app: FastifyInstance) { + app.register(healthRoutes); + app.register(loginRoutes); + app.register(signupRoutes); + app.register(sessionRoutes, { prefix: "/sessions" }); + app.register(meRoutes); + app.register(accountRoutes); +} diff --git a/src/routes/login.ts b/src/routes/login.ts new file mode 100644 index 0000000..06b8cf5 --- /dev/null +++ b/src/routes/login.ts @@ -0,0 +1,24 @@ +import type { FastifyInstance } from "fastify"; +import { requestOtp, OtpRateLimitError } from "../lib/otp.js"; + +export async function loginRoutes(app: FastifyInstance) { + // POST /login — send OTP to email + app.post<{ Body: { email: string } }>("/login", async (request, reply) => { + const { email } = request.body; + + if (!email || typeof email !== "string") { + return reply.status(400).send({ error: "Email is required" }); + } + + try { + await requestOtp(email); + } catch (err) { + if (err instanceof OtpRateLimitError) { + return reply.status(429).send({ error: err.message }); + } + throw err; + } + + return { message: "OTP sent to your email" }; + }); +} diff --git a/src/routes/me.ts b/src/routes/me.ts new file mode 100644 index 0000000..786606a --- /dev/null +++ b/src/routes/me.ts @@ -0,0 +1,47 @@ +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { getUserAccounts } from "../lib/accounts.js"; + +export async function meRoutes(app: FastifyInstance) { + // GET /me — return current user with accounts (authenticated) + app.get("/me", { preHandler: [app.authenticate] }, async (request, reply) => { + const userId = request.user.sub; + + const [user] = await db.select().from(users).where(eq(users.id, userId)); + if (!user) { + return reply.status(404).send({ error: "User not found" }); + } + + const userAccounts = await getUserAccounts(userId); + + return { ...user, accounts: userAccounts }; + }); + + // PATCH /me — update current user's profile (authenticated) + app.patch<{ Body: { email?: string; name?: string } }>( + "/me", + { preHandler: [app.authenticate] }, + async (request, reply) => { + const userId = request.user.sub; + const { email, name } = request.body; + + if (!email && !name) { + return reply.status(400).send({ error: "Nothing to update" }); + } + + const [user] = await db + .update(users) + .set({ ...(email && { email }), ...(name && { name }), updatedAt: new Date() }) + .where(eq(users.id, userId)) + .returning(); + + if (!user) { + return reply.status(404).send({ error: "User not found" }); + } + + return user; + }, + ); +} diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts new file mode 100644 index 0000000..524b61e --- /dev/null +++ b/src/routes/sessions.ts @@ -0,0 +1,94 @@ +import type { FastifyInstance } from "fastify"; +import { eq, and, isNull } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users, sessions } from "../db/schema.js"; +import { verifyOtp, OtpError } from "../lib/otp.js"; +import { createSession } from "../lib/sessions.js"; +import { getUserAccounts } from "../lib/accounts.js"; + +export async function sessionRoutes(app: FastifyInstance) { + // POST /sessions — validate OTP, create session for existing user + app.post<{ Body: { email: string; code: string } }>("/", async (request, reply) => { + const { email, code } = request.body; + + if (!email || !code) { + return reply.status(400).send({ error: "Email and code are required" }); + } + + try { + await verifyOtp(email, code); + } catch (err) { + if (err instanceof OtpError) { + return reply.status(400).send({ error: err.message }); + } + throw err; + } + + const [user] = await db.select().from(users).where(eq(users.email, email)); + if (!user) { + return reply.status(401).send({ error: "No account found. Please sign up." }); + } + + const tokens = await createSession(user.id); + const userAccounts = await getUserAccounts(user.id); + + return { ...tokens, user, accounts: userAccounts }; + }); + + // POST /sessions/refresh — rotate refresh token, return new tokens + app.post<{ Body: { refreshToken: string } }>("/refresh", async (request, reply) => { + const { refreshToken } = request.body; + + if (!refreshToken) { + return reply.status(400).send({ error: "Refresh token is required" }); + } + + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.refreshToken, refreshToken)); + + if (!session) { + return reply.status(401).send({ error: "Invalid refresh token" }); + } + + // If session was already revoked, this is a reuse attempt — revoke all sessions for this user (theft detection) + if (session.revokedAt) { + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.userId, session.userId), isNull(sessions.revokedAt))); + return reply.status(401).send({ error: "Token reuse detected. All sessions revoked." }); + } + + // Check expiry + if (session.expiresAt < new Date()) { + return reply.status(401).send({ error: "Refresh token expired" }); + } + + // Revoke old session + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(eq(sessions.id, session.id)); + + const tokens = await createSession(session.userId); + + const [user] = await db.select().from(users).where(eq(users.id, session.userId)); + const userAccounts = await getUserAccounts(session.userId); + + return { ...tokens, user, accounts: userAccounts }; + }); + + // POST /sessions/logout — revoke all sessions (authenticated) + app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => { + const userId = request.user.sub; + + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt))); + + return reply.status(204).send(); + }); +} diff --git a/src/routes/signup.ts b/src/routes/signup.ts new file mode 100644 index 0000000..42b620c --- /dev/null +++ b/src/routes/signup.ts @@ -0,0 +1,61 @@ +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users, accounts, memberships } from "../db/schema.js"; +import { verifyOtp, OtpError } from "../lib/otp.js"; +import { createSession } from "../lib/sessions.js"; +import { getUserAccounts } from "../lib/accounts.js"; + +export async function signupRoutes(app: FastifyInstance) { + // POST /signup — validate OTP, create user + account, return tokens + app.post<{ Body: { email: string; code: string; accountName: string } }>( + "/signup", + async (request, reply) => { + const { email, code, accountName } = request.body; + + if (!email || !code || !accountName) { + return reply + .status(400) + .send({ error: "Email, code, and accountName are required" }); + } + + try { + await verifyOtp(email, code); + } catch (err) { + if (err instanceof OtpError) { + return reply.status(400).send({ error: err.message }); + } + throw err; + } + + // Check that user does not already exist + const [existingUser] = await db.select().from(users).where(eq(users.email, email)); + if (existingUser) { + return reply.status(409).send({ error: "User already exists. Please log in." }); + } + + const { user, tokens } = await db.transaction(async (tx) => { + const [created] = await tx + .insert(users) + .values({ email, name: email.split("@")[0] }) + .returning(); + + const [account] = await tx.insert(accounts).values({ name: accountName }).returning(); + await tx.insert(memberships).values({ + userId: created.id, + accountId: account.id, + role: "owner", + }); + + const t = await createSession(created.id, tx); + return { user: created, tokens: t }; + }); + + const userAccounts = await getUserAccounts(user.id); + + return reply + .status(201) + .send({ ...tokens, user, accounts: userAccounts }); + }, + ); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..b418254 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,11 @@ +import { buildApp } from "./app.js"; +import { config } from "./config.js"; + +const app = buildApp(); + +app.listen({ port: config.PORT, host: config.HOST }, (err) => { + if (err) { + app.log.error(err); + process.exit(1); + } +}); diff --git a/tests/account-context.test.ts b/tests/account-context.test.ts new file mode 100644 index 0000000..99fb606 --- /dev/null +++ b/tests/account-context.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../src/app.js"; +import { cleanDb, signupUser } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = buildApp(); + + // Register a test-only account-scoped route as a plugin + // so it runs after authenticate + accountContext are ready + app.register(async (instance) => { + instance.get( + "/test-account-route", + { preHandler: [instance.authenticate, instance.requireAccount] }, + async (request) => { + return { accountId: request.accountId, role: request.membership.role }; + }, + ); + }); + + await app.ready(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +describe("requireAccount plugin", () => { + it("returns 400 if X-Account-Id header is missing", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "GET", + url: "/test-account-route", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/X-Account-Id/); + }); + + it("returns 403 if user is not a member of the account", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "GET", + url: "/test-account-route", + headers: { + authorization: `Bearer ${accessToken}`, + "x-account-id": "00000000-0000-0000-0000-000000000000", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().error).toMatch(/not a member/); + }); + + it("decorates request with accountId and role on success", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken, accounts } = signup.json(); + + const res = await app.inject({ + method: "GET", + url: "/test-account-route", + headers: { + authorization: `Bearer ${accessToken}`, + "x-account-id": accounts[0].id, + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + accountId: accounts[0].id, + role: "owner", + }); + }); + + it("returns 401 without auth header", async () => { + const res = await app.inject({ + method: "GET", + url: "/test-account-route", + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts new file mode 100644 index 0000000..a0df010 --- /dev/null +++ b/tests/accounts.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp, cleanDb, signupUser } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +describe("GET /accounts", () => { + it("returns user's accounts", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "GET", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveLength(1); + expect(body[0].name).toBe("Org"); + expect(body[0].role).toBe("owner"); + }); + + it("returns 401 without auth", async () => { + const res = await app.inject({ + method: "GET", + url: "/accounts", + }); + + expect(res.statusCode).toBe(401); + }); +}); + +describe("POST /accounts", () => { + it("creates a new account and makes user owner", async () => { + const signup = await signupUser(app, "user@example.com", "Org 1"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "Org 2" }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().name).toBe("Org 2"); + expect(res.json().role).toBe("owner"); + + // User should now have 2 accounts + const list = await app.inject({ + method: "GET", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(list.json()).toHaveLength(2); + }); + + it("returns 400 if name is missing", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "POST", + url: "/accounts", + headers: { authorization: `Bearer ${accessToken}` }, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..ed4d825 --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +describe("GET /health", () => { + it("returns ok", async () => { + const res = await app.inject({ method: "GET", url: "/health" }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "ok" }); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..765fdd3 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,59 @@ +import { type FastifyInstance } from "fastify"; +import { buildApp } from "../src/app.js"; +import { db } from "../src/db/index.js"; +import { users, otpCodes, sessions, accounts, memberships } from "../src/db/schema.js"; +import { sql } from "drizzle-orm"; + +export async function createTestApp(): Promise { + const app = buildApp(); + await app.ready(); + return app; +} + +export async function cleanDb() { + await db.delete(sessions); + await db.delete(memberships); + await db.delete(accounts); + await db.delete(otpCodes); + await db.delete(users); +} + +/** Request an OTP and return the code from the DB */ +export async function requestOtpCode(app: FastifyInstance, email: string): Promise { + await app.inject({ method: "POST", url: "/login", payload: { email } }); + + const [otp] = await db + .select() + .from(otpCodes) + .where(sql`${otpCodes.email} = ${email}`) + .orderBy(sql`${otpCodes.createdAt} desc`) + .limit(1); + + return otp.code; +} + +/** Full signup flow: request OTP → signup → return response */ +export async function signupUser( + app: FastifyInstance, + email: string, + accountName: string, +) { + const code = await requestOtpCode(app, email); + const res = await app.inject({ + method: "POST", + url: "/signup", + payload: { email, code, accountName }, + }); + return res; +} + +/** Full login flow for an existing user: request OTP → create session → return response */ +export async function loginUser(app: FastifyInstance, email: string) { + const code = await requestOtpCode(app, email); + const res = await app.inject({ + method: "POST", + url: "/sessions", + payload: { email, code }, + }); + return res; +} diff --git a/tests/login.test.ts b/tests/login.test.ts new file mode 100644 index 0000000..df9a362 --- /dev/null +++ b/tests/login.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp, cleanDb } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +describe("POST /login", () => { + it("sends OTP for a valid email", async () => { + const res = await app.inject({ + method: "POST", + url: "/login", + payload: { email: "test@example.com" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: "OTP sent to your email" }); + }); + + it("returns 400 if email is missing", async () => { + const res = await app.inject({ + method: "POST", + url: "/login", + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rate limits after 3 requests", async () => { + const email = "ratelimit@example.com"; + + for (let i = 0; i < 3; i++) { + await app.inject({ method: "POST", url: "/login", payload: { email } }); + } + + const res = await app.inject({ + method: "POST", + url: "/login", + payload: { email }, + }); + + expect(res.statusCode).toBe(429); + }); +}); diff --git a/tests/me.test.ts b/tests/me.test.ts new file mode 100644 index 0000000..09c4592 --- /dev/null +++ b/tests/me.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp, cleanDb, signupUser } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +describe("GET /me", () => { + it("returns user with accounts", async () => { + const signup = await signupUser(app, "user@example.com", "My Org"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "GET", + url: "/me", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.email).toBe("user@example.com"); + expect(body.accounts).toHaveLength(1); + expect(body.accounts[0].name).toBe("My Org"); + }); + + it("returns 401 without auth", async () => { + const res = await app.inject({ method: "GET", url: "/me" }); + expect(res.statusCode).toBe(401); + }); +}); + +describe("PATCH /me", () => { + it("updates user name", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "PATCH", + url: "/me", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { name: "New Name" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe("New Name"); + }); + + it("returns 400 if nothing to update", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken } = signup.json(); + + const res = await app.inject({ + method: "PATCH", + url: "/me", + headers: { authorization: `Bearer ${accessToken}` }, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/sessions.test.ts b/tests/sessions.test.ts new file mode 100644 index 0000000..eea034d --- /dev/null +++ b/tests/sessions.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { db } from "../src/db/index.js"; +import { sessions } from "../src/db/schema.js"; +import { createTestApp, cleanDb, requestOtpCode, signupUser, loginUser } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +describe("POST /sessions", () => { + it("creates session for existing user", async () => { + await signupUser(app, "user@example.com", "Org"); + + const res = await loginUser(app, "user@example.com"); + const body = res.json(); + + expect(res.statusCode).toBe(200); + expect(body.accessToken).toBeDefined(); + expect(body.refreshToken).toBeDefined(); + expect(body.user.email).toBe("user@example.com"); + expect(body.accounts).toHaveLength(1); + }); + + it("returns 401 for unknown email", async () => { + const code = await requestOtpCode(app, "unknown@example.com"); + const res = await app.inject({ + method: "POST", + url: "/sessions", + payload: { email: "unknown@example.com", code }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toMatch(/sign up/i); + }); + + it("returns 400 with invalid OTP", async () => { + const res = await app.inject({ + method: "POST", + url: "/sessions", + payload: { email: "user@example.com", code: "000000" }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("returns 400 if email or code is missing", async () => { + const res = await app.inject({ + method: "POST", + url: "/sessions", + payload: { email: "user@example.com" }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/required/i); + }); +}); + +describe("POST /sessions/refresh", () => { + it("rotates tokens and returns user + accounts", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { refreshToken } = signup.json(); + + const res = await app.inject({ + method: "POST", + url: "/sessions/refresh", + payload: { refreshToken }, + }); + const body = res.json(); + + expect(res.statusCode).toBe(200); + expect(body.accessToken).toBeDefined(); + expect(body.refreshToken).not.toBe(refreshToken); + expect(body.user.email).toBe("user@example.com"); + expect(body.accounts).toHaveLength(1); + }); + + it("returns 400 if refreshToken is missing", async () => { + const res = await app.inject({ + method: "POST", + url: "/sessions/refresh", + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/required/i); + }); + + it("returns 401 for invalid refresh token", async () => { + const res = await app.inject({ + method: "POST", + url: "/sessions/refresh", + payload: { refreshToken: "invalid-token" }, + }); + + expect(res.statusCode).toBe(401); + }); + + it("returns 401 for expired refresh token", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { refreshToken } = signup.json(); + + // Expire the session manually + await db + .update(sessions) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(sessions.refreshToken, refreshToken)); + + const res = await app.inject({ + method: "POST", + url: "/sessions/refresh", + payload: { refreshToken }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toMatch(/expired/i); + }); + + it("detects token reuse and revokes all sessions", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { refreshToken } = signup.json(); + + // First refresh succeeds + await app.inject({ + method: "POST", + url: "/sessions/refresh", + payload: { refreshToken }, + }); + + // Reuse the old token — should be detected as theft + const res = await app.inject({ + method: "POST", + url: "/sessions/refresh", + payload: { refreshToken }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toMatch(/reuse/i); + }); +}); + +describe("POST /sessions/logout", () => { + it("revokes all sessions", async () => { + const signup = await signupUser(app, "user@example.com", "Org"); + const { accessToken, refreshToken } = signup.json(); + + const res = await app.inject({ + method: "POST", + url: "/sessions/logout", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(res.statusCode).toBe(204); + + // Refresh token should no longer work + const refresh = await app.inject({ + method: "POST", + url: "/sessions/refresh", + payload: { refreshToken }, + }); + + expect(refresh.statusCode).toBe(401); + }); + + it("returns 401 without auth header", async () => { + const res = await app.inject({ + method: "POST", + url: "/sessions/logout", + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..3dfd298 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,5 @@ +// Set env vars before any app code is imported +process.env.DATABASE_URL = "postgresql://fedjens@localhost:5432/eyrun_test"; +process.env.JWT_SECRET = "test-secret-that-is-at-least-32-characters-long"; +process.env.PORT = "0"; +process.env.HOST = "127.0.0.1"; diff --git a/tests/signup.test.ts b/tests/signup.test.ts new file mode 100644 index 0000000..f5440e4 --- /dev/null +++ b/tests/signup.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { createTestApp, cleanDb, requestOtpCode, signupUser } from "./helpers.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + await cleanDb(); +}); + +describe("POST /signup", () => { + it("creates user, account, and returns tokens", async () => { + const res = await signupUser(app, "new@example.com", "My Org"); + const body = res.json(); + + expect(res.statusCode).toBe(201); + expect(body.accessToken).toBeDefined(); + expect(body.refreshToken).toBeDefined(); + expect(body.user.email).toBe("new@example.com"); + expect(body.accounts).toHaveLength(1); + expect(body.accounts[0].name).toBe("My Org"); + expect(body.accounts[0].role).toBe("owner"); + }); + + it("returns 409 if user already exists", async () => { + await signupUser(app, "existing@example.com", "Org 1"); + + const code = await requestOtpCode(app, "existing@example.com"); + const res = await app.inject({ + method: "POST", + url: "/signup", + payload: { email: "existing@example.com", code, accountName: "Org 2" }, + }); + + expect(res.statusCode).toBe(409); + }); + + it("returns 400 with invalid OTP code", async () => { + await requestOtpCode(app, "test@example.com"); + + const res = await app.inject({ + method: "POST", + url: "/signup", + payload: { email: "test@example.com", code: "000000", accountName: "Org" }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("returns 400 if fields are missing", async () => { + const res = await app.inject({ + method: "POST", + url: "/signup", + payload: { email: "test@example.com" }, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..459e4f3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..16dbea7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./tests/setup.ts"], + fileParallelism: false, + }, +});