the best lightweight web dev stack built on bun

feat: create base

dunkirk.sh 6b1a8881 aa514321

verified
+3031
+14
.env.example
··· 1 + # WebAuthn/Passkey Configuration 2 + # In development, these default to localhost values 3 + # Only needed when deploying to production 4 + 5 + # Relying Party ID - your domain name 6 + # Must match the domain where your app is hosted 7 + # RP_ID=tacy-stack.app 8 + 9 + # Origin - full URL of your app 10 + # Must match exactly where users access your app 11 + ORIGIN=http://localhost:3000 12 + 13 + # Environment (set to 'production' in production) 14 + NODE_ENV=development
+26
.gitignore
··· 1 + # Bun 2 + node_modules/ 3 + bun.lockb 4 + 5 + # Database 6 + *.db 7 + *.db-shm 8 + *.db-wal 9 + drizzle/ 10 + 11 + # Environment 12 + .env 13 + .env.local 14 + 15 + # IDE 16 + .vscode/ 17 + .idea/ 18 + *.swp 19 + *.swo 20 + 21 + # OS 22 + .DS_Store 23 + Thumbs.db 24 + 25 + # Logs 26 + *.log
+550
AGENTS.md
··· 1 + # Tacy Stack - Agent Guidelines 2 + 3 + This is a minimal full-stack web application starter built on the Bun fullstack pattern. It demonstrates passkey authentication, user-specific data storage, and reactive web components. 4 + 5 + ## Project Overview 6 + 7 + **What is Tacy Stack?** 8 + A TypeScript-based web stack using: 9 + - **Bun** - Fast JavaScript runtime and bundler 10 + - **TypeScript** - Strict typing with decorators enabled 11 + - **Lit** - Lightweight (~8-10KB) web components library 12 + - **Drizzle ORM** - Type-safe SQLite database access 13 + - **Passkeys (WebAuthn)** - Passwordless authentication 14 + 15 + **Demo Application Features:** 16 + - User registration and login via passkeys (no passwords!) 17 + - User-specific click counter stored in database 18 + - Reactive UI with Lit web components 19 + - Session management with cookies 20 + 21 + ## Commands 22 + 23 + ```bash 24 + # Install dependencies 25 + bun install 26 + 27 + # Development server with hot reload (default: localhost:3000) 28 + bun dev 29 + 30 + # Database management 31 + bun run db:generate # Generate migration files from schema changes 32 + bun run db:push # Push schema directly to database (for development) 33 + bun run db:studio # Open Drizzle Studio (visual database browser) 34 + 35 + # Testing 36 + bun test # Run all tests 37 + ``` 38 + 39 + **IMPORTANT:** Never run `bun dev` yourself - the user always has it running already with HMR enabled. 40 + 41 + ## Tech Stack Philosophy 42 + 43 + ### NO FRAMEWORKS 44 + 45 + **Explicitly forbidden:** 46 + - React, React DOM 47 + - Vue 48 + - Svelte 49 + - Angular 50 + - Any framework with virtual DOM or large runtime 51 + 52 + **Why?** This project prioritizes: 53 + - **Speed:** Minimal JavaScript, fast load times 54 + - **Small bundles:** Keep total JS under 50KB 55 + - **Native web platform:** Web standards over framework abstractions 56 + - **Simplicity:** Vanilla HTML, CSS, and TypeScript 57 + 58 + **Allowed:** 59 + - Lit (~8-10KB) for reactive web components 60 + - Native Web Components API 61 + - Plain JavaScript/TypeScript 62 + - Native DOM APIs 63 + 64 + ### When to use Lit 65 + 66 + **Use Lit for:** 67 + - Components with reactive properties (auto-updates on data changes) 68 + - Complex components needing scoped styles 69 + - Form controls with internal state 70 + - Components with lifecycle needs 71 + 72 + **Skip Lit for:** 73 + - Static content (use plain HTML) 74 + - Simple one-off interactions (use vanilla JS) 75 + - Anything without reactive state 76 + 77 + ## Project Structure 78 + 79 + Based on Bun's fullstack pattern where HTML files are imported as modules: 80 + 81 + ``` 82 + src/ 83 + index.ts # Server entry point, imports HTML routes 84 + db/ 85 + db.ts # Drizzle database instance 86 + schema.ts # Database schema (Drizzle tables) 87 + lib/ 88 + auth.ts # User/session management 89 + passkey.ts # WebAuthn passkey logic 90 + counter.ts # Counter business logic 91 + middleware.ts # Auth middleware 92 + client-passkey.ts # Client-side passkey helpers 93 + pages/ 94 + index.html # Route entry point (imports components) 95 + components/ 96 + auth.ts # Login/register Lit component 97 + counter.ts # Counter Lit component 98 + styles/ 99 + main.css # Global styles 100 + public/ # Static assets (if needed) 101 + ``` 102 + 103 + **File flow:** 104 + 1. `src/index.ts` imports HTML: `import indexHTML from "./pages/index.html"` 105 + 2. HTML imports components: `<script type="module" src="../components/auth.ts"></script>` 106 + 3. HTML links styles: `<link rel="stylesheet" href="../styles/main.css">` 107 + 4. Components self-register via `@customElement` decorator 108 + 5. Bun bundles everything automatically during development 109 + 110 + ## Database (Drizzle ORM) 111 + 112 + **Database file:** `tacy-stack.db` (SQLite, auto-created) 113 + 114 + **Schema location:** `src/db/schema.ts` 115 + 116 + **Tables:** 117 + - `users` - User accounts (id, email, name, avatar, created_at) 118 + - `sessions` - Active sessions (id, user_id, ip, user_agent, expires_at) 119 + - `passkeys` - WebAuthn credentials (id, user_id, credential_id, public_key, counter, etc.) 120 + - `counters` - User click counters (user_id, count, updated_at) 121 + 122 + **Making schema changes:** 123 + 1. Edit `src/db/schema.ts` (modify tables using Drizzle syntax) 124 + 2. Run `bun run db:push` to apply changes to database 125 + 3. For production, use `bun run db:generate` to create migrations 126 + 4. Schema changes are type-safe and auto-complete in your IDE 127 + 128 + **Querying patterns:** 129 + ```typescript 130 + import { eq } from "drizzle-orm"; 131 + import db from "../db/db"; 132 + import { users } from "../db/schema"; 133 + 134 + // Select with where clause 135 + const user = db.select().from(users).where(eq(users.id, userId)).get(); 136 + 137 + // Insert 138 + db.insert(users).values({ email, name, avatar }).run(); 139 + 140 + // Update 141 + db.update(users).set({ name: "New Name" }).where(eq(users.id, userId)).run(); 142 + 143 + // Delete 144 + db.delete(users).where(eq(users.id, userId)).run(); 145 + ``` 146 + 147 + **Important:** Use `.get()` for single results, `.all()` for arrays, `.run()` for mutations. 148 + 149 + ## TypeScript Configuration 150 + 151 + **Strict mode enabled:** 152 + - `strict: true` 153 + - `noFallthroughCasesInSwitch: true` 154 + - `noUncheckedIndexedAccess: true` 155 + - `noImplicitOverride: true` 156 + 157 + **Decorators:** 158 + - `experimentalDecorators: true` (required for Lit's `@customElement`, `@property`, etc.) 159 + - `useDefineForClassFields: false` (required for Lit decorators) 160 + 161 + **Module system:** 162 + - `moduleResolution: "bundler"` 163 + - `module: "Preserve"` 164 + - Can import `.ts` extensions directly 165 + - JSX: `preserve` (NOT react-jsx - we don't use React!) 166 + 167 + **Deliberately disabled:** 168 + - `noUnusedLocals: false` 169 + - `noUnusedParameters: false` 170 + - `noPropertyAccessFromIndexSignature: false` 171 + 172 + ## Bun Usage 173 + 174 + **Always use Bun instead of Node.js:** 175 + - ✅ `bun <file>` NOT `node <file>` or `ts-node <file>` 176 + - ✅ `bun test` NOT `jest` or `vitest` 177 + - ✅ `bun build <file>` NOT `webpack` or `esbuild` 178 + - ✅ `bun install` NOT `npm install` 179 + - ✅ `bun run <script>` NOT `npm run <script>` 180 + 181 + **Bun built-in APIs (prefer over npm packages):** 182 + - `Bun.serve()` for HTTP server (don't use Express) 183 + - `bun:sqlite` for SQLite (but we use Drizzle which wraps it) 184 + - `Bun.file()` for file I/O (prefer over `node:fs`) 185 + - `.env` auto-loads (no dotenv package needed) 186 + 187 + ## Server Setup (Bun.serve) 188 + 189 + Use `Bun.serve()` with the `routes` pattern: 190 + 191 + ```typescript 192 + import indexHTML from "./pages/index.html"; 193 + 194 + Bun.serve({ 195 + port: 3000, 196 + routes: { 197 + "/": indexHTML, // HTML route 198 + "/api/users/:id": { // API route with params 199 + GET: (req) => { 200 + return new Response(JSON.stringify({ id: req.params.id })); 201 + }, 202 + }, 203 + }, 204 + development: { 205 + hmr: true, // Hot module reloading 206 + console: true, // Enhanced console output 207 + }, 208 + }); 209 + ``` 210 + 211 + **Route params:** Access via `req.params.paramName` 212 + 213 + **Request helpers:** 214 + - `await req.json()` - Parse JSON body 215 + - `req.headers.get("cookie")` - Get header 216 + - `req.url` - Full URL 217 + 218 + ## Frontend Pattern 219 + 220 + **HTML files import TypeScript modules directly:** 221 + 222 + ```html 223 + <!DOCTYPE html> 224 + <html lang="en"> 225 + <head> 226 + <meta charset="UTF-8"> 227 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 228 + <title>Page Title - Tacy Stack</title> 229 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🥞</text></svg>"> 230 + <link rel="stylesheet" href="../styles/main.css"> 231 + </head> 232 + <body> 233 + <header> 234 + <nav> 235 + <h1>🥞 Tacy Stack</h1> 236 + <auth-component></auth-component> 237 + </nav> 238 + </header> 239 + 240 + <main> 241 + <h1>Page Title</h1> 242 + <counter-component count="0"></counter-component> 243 + </main> 244 + 245 + <script type="module" src="../components/auth.ts"></script> 246 + <script type="module" src="../components/counter.ts"></script> 247 + </body> 248 + </html> 249 + ``` 250 + 251 + **Standard HTML template parts:** 252 + - Pancake emoji favicon 253 + - Proper meta tags (charset, viewport) 254 + - Auth component in header nav 255 + - Main content area 256 + - Module scripts at end of body 257 + 258 + **No build step needed:** Bun transpiles and bundles automatically during development. 259 + 260 + ## Lit Web Components 261 + 262 + **Basic component structure:** 263 + 264 + ```typescript 265 + import { LitElement, html, css } from "lit"; 266 + import { customElement, property, state } from "lit/decorators.js"; 267 + 268 + @customElement("my-component") 269 + export class MyComponent extends LitElement { 270 + // Public reactive properties (can be set via HTML attributes) 271 + @property({ type: String }) name = "World"; 272 + 273 + // Private reactive state (internal only) 274 + @state() private count = 0; 275 + 276 + // Scoped styles using css tagged template 277 + static override styles = css` 278 + :host { 279 + display: block; 280 + padding: 1rem; 281 + } 282 + .greeting { 283 + color: var(--primary); 284 + } 285 + `; 286 + 287 + // Render using html tagged template 288 + override render() { 289 + return html` 290 + <div class="greeting"> 291 + Hello, ${this.name}! 292 + <button @click=${() => this.count++}> 293 + Clicked ${this.count} times 294 + </button> 295 + </div> 296 + `; 297 + } 298 + } 299 + ``` 300 + 301 + **Key Lit features:** 302 + - `@customElement("tag-name")` - Register component 303 + - `@property()` - Public reactive property (triggers re-render) 304 + - `@state()` - Private reactive state (triggers re-render) 305 + - `html` - Template literal for rendering 306 + - `css` - Scoped styles (don't leak out) 307 + - Event handlers: `@click=${handler}` or `@click=${() => ...}` 308 + - Automatic re-rendering when properties/state change 309 + 310 + ## Design System 311 + 312 + **Color palette (CSS variables):** 313 + ```css 314 + :root { 315 + /* Named colors */ 316 + --yale-blue: #033f63ff; /* dark blue */ 317 + --stormy-teal: #28666eff; /* medium teal */ 318 + --muted-teal: #7c9885ff; /* soft teal */ 319 + --dry-sage: #b5b682ff; /* sage green */ 320 + --soft-peach: #fedc97ff; /* warm peach */ 321 + 322 + /* Semantic assignments */ 323 + --text: var(--yale-blue); 324 + --background: var(--soft-peach); 325 + --primary: var(--stormy-teal); 326 + --secondary: var(--muted-teal); 327 + --accent: var(--dry-sage); 328 + } 329 + ``` 330 + 331 + **CRITICAL COLOR RULES:** 332 + - ❌ NEVER hardcode colors: `#4f46e5`, `white`, `red`, etc. 333 + - ✅ ALWAYS use CSS variables: `var(--primary)`, `var(--accent)`, `var(--text)`, etc. 334 + 335 + **Dimension rules:** 336 + - Use `rem` for all sizes (not `px`) 337 + - Base: 16px = 1rem 338 + - Common values: `0.5rem`, `1rem`, `1.5rem`, `2rem`, `3rem` 339 + - Max widths: `48rem` (content), `56rem` (forms/data) 340 + - Spacing scale: `0.25rem`, `0.5rem`, `0.75rem`, `1rem`, `1.5rem`, `2rem`, `3rem` 341 + 342 + ## Authentication & Sessions 343 + 344 + **Flow:** 345 + 1. User enters email/name and clicks "Register" 346 + 2. `POST /api/auth/register` creates user and session 347 + 3. `GET /api/auth/passkey/register/options` gets WebAuthn options 348 + 4. Client calls `startRegistration()` from `@simplewebauthn/browser` 349 + 5. `POST /api/auth/passkey/register/verify` stores passkey 350 + 6. Session cookie set, user logged in 351 + 352 + **Login flow:** 353 + 1. User clicks "Sign In" 354 + 2. `GET /api/auth/passkey/authenticate/options` gets WebAuthn challenge 355 + 3. Client calls `startAuthentication()` from `@simplewebauthn/browser` 356 + 4. `POST /api/auth/passkey/authenticate/verify` verifies and creates session 357 + 5. Session cookie set, user logged in 358 + 359 + **Session management:** 360 + - Sessions stored in `sessions` table 361 + - Cookie name: `session` 362 + - Duration: 7 days 363 + - HTTPOnly, SameSite=Strict 364 + 365 + **Auth helpers:** 366 + - `getSessionFromRequest(req)` - Extract session ID from cookie 367 + - `getUserBySession(sessionId)` - Get user from session 368 + - `requireAuth(req)` - Middleware that throws if not authenticated 369 + 370 + ## API Response Patterns 371 + 372 + **Success:** 373 + ```typescript 374 + return new Response(JSON.stringify({ count: 42 }), { 375 + headers: { "Content-Type": "application/json" }, 376 + }); 377 + ``` 378 + 379 + **Error:** 380 + ```typescript 381 + return new Response(JSON.stringify({ error: "Not authenticated" }), { 382 + status: 401, 383 + }); 384 + ``` 385 + 386 + **With cookie:** 387 + ```typescript 388 + return new Response(JSON.stringify(user), { 389 + headers: { 390 + "Content-Type": "application/json", 391 + "Set-Cookie": `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800`, 392 + }, 393 + }); 394 + ``` 395 + 396 + ## Code Conventions 397 + 398 + **Naming:** 399 + - PascalCase: Components, classes (`AuthComponent`, `CounterComponent`) 400 + - camelCase: Functions, variables (`getUserByEmail`, `sessionId`) 401 + - kebab-case: File names, custom element tags (`auth.ts`, `counter-component`) 402 + 403 + **File organization:** 404 + - Place tests next to code: `foo.ts` → `foo.test.ts` 405 + - Keep related code together (e.g., all auth logic in `lib/auth.ts`) 406 + - Components are self-contained (logic + styles + template) 407 + 408 + **Before writing code:** 409 + 1. Check if library exists (look at imports, package.json) 410 + 2. Read similar code for patterns 411 + 3. Match existing style 412 + 4. Never assume libraries are available - verify first 413 + 414 + ## Testing 415 + 416 + Use `bun test` with Bun's built-in test runner: 417 + 418 + ```typescript 419 + import { test, expect } from "bun:test"; 420 + 421 + test("creates user with avatar", async () => { 422 + const user = await createUser("test@example.com", "Test User"); 423 + expect(user.email).toBe("test@example.com"); 424 + expect(user.avatar).toBeTruthy(); 425 + }); 426 + ``` 427 + 428 + **What to test:** 429 + - ✅ Security-critical functions (auth, sessions, passkeys) 430 + - ✅ Complex business logic (counter operations) 431 + - ✅ Edge cases (empty inputs, missing data) 432 + - ❌ Simple getters/setters 433 + - ❌ Framework/library code 434 + - ❌ One-line utilities 435 + 436 + **Test file naming:** `*.test.ts` (auto-discovered by Bun) 437 + 438 + ## Environment Variables 439 + 440 + Copy `.env.example` to `.env`: 441 + 442 + ```bash 443 + # WebAuthn/Passkey Configuration 444 + RP_ID=localhost # Your domain (localhost for dev) 445 + ORIGIN=http://localhost:3000 # Full app URL 446 + 447 + # Environment 448 + NODE_ENV=development 449 + ``` 450 + 451 + **Production values:** 452 + - `RP_ID` - Your domain (e.g., `tacy-stack.app`) 453 + - `ORIGIN` - Full public URL (e.g., `https://tacy-stack.app`) 454 + 455 + **Important:** Bun auto-loads `.env`, no dotenv package needed. 456 + 457 + ## Common Tasks 458 + 459 + ### Adding a new route 460 + 1. Create HTML file in `src/pages/` (e.g., `about.html`) 461 + 2. Import in `src/index.ts`: `import aboutHTML from "./pages/about.html"` 462 + 3. Add to routes: `"/about": aboutHTML` 463 + 464 + ### Adding a new API endpoint 465 + Add to `routes` object in `src/index.ts`: 466 + ```typescript 467 + "/api/my-endpoint": { 468 + GET: async (req) => { 469 + const userId = await requireAuth(req); 470 + // ... your logic 471 + return new Response(JSON.stringify({ data })); 472 + }, 473 + }, 474 + ``` 475 + 476 + ### Adding a new component 477 + 1. Create `src/components/my-component.ts` 478 + 2. Use `@customElement("my-component")` decorator 479 + 3. Import in HTML: `<script type="module" src="../components/my-component.ts"></script>` 480 + 4. Use in HTML: `<my-component></my-component>` 481 + 482 + ### Adding a database table 483 + 1. Edit `src/db/schema.ts`: 484 + ```typescript 485 + export const myTable = sqliteTable("my_table", { 486 + id: integer("id").primaryKey({ autoIncrement: true }), 487 + name: text("name").notNull(), 488 + }); 489 + ``` 490 + 2. Run `bun run db:push` (pushes schema to database) 491 + 3. Query: `db.select().from(myTable).all()` 492 + 493 + ### Adding styles 494 + - Global styles: Edit `src/styles/main.css` 495 + - Component-scoped: Use `static styles = css\`...\`` in Lit component 496 + - Always use CSS variables for colors 497 + 498 + ## Formatting & Linting 499 + 500 + **Biome** is configured for formatting and linting: 501 + - Indent style: Tabs 502 + - Quote style: Double quotes 503 + - Auto-organize imports enabled 504 + 505 + **LSP support:** Biome provides IDE integration for formatting/linting. 506 + 507 + ## Gotchas 508 + 509 + 1. **Don't use Node.js commands** - Use `bun` not `node`, `npm`, `npx` 510 + 2. **Don't install Express/Vite** - Bun has built-in equivalents 511 + 3. **NEVER use React** - Explicitly forbidden, use Lit or vanilla JS 512 + 4. **Import .ts extensions** - Bun allows direct `.ts` imports 513 + 5. **No dotenv needed** - Bun loads `.env` automatically 514 + 6. **HTML imports are special** - They trigger Bun's bundler 515 + 7. **Bundle size matters** - Measure impact before adding libraries 516 + 8. **Drizzle queries** - Use `.get()` for single row, `.all()` for array, `.run()` for mutations 517 + 9. **Decorators required** - Must enable `experimentalDecorators` for Lit 518 + 10. **Session from cookies** - Use `getSessionFromRequest(req)` to extract session ID 519 + 520 + ## Development Workflow 521 + 522 + 1. Make changes to `.ts`, `.html`, or `.css` files 523 + 2. Bun's HMR automatically reloads in browser 524 + 3. Write tests in `*.test.ts` files 525 + 4. Run `bun test` to verify 526 + 5. Check database with `bun run db:studio` if needed 527 + 528 + **Never run `bun dev` yourself** - user has it running with hot reload already. 529 + 530 + ## Resources 531 + 532 + - [Bun Fullstack Documentation](https://bun.com/docs/bundler/fullstack) 533 + - [Lit Documentation](https://lit.dev/) 534 + - [Drizzle ORM Documentation](https://orm.drizzle.team/) 535 + - [SimpleWebAuthn Documentation](https://simplewebauthn.dev/) 536 + - [Web Components MDN](https://developer.mozilla.org/en-US/docs/Web/Web_Components) 537 + - Bun API docs: `node_modules/bun-types/docs/**.md` 538 + 539 + ## Key Differences from Thistle 540 + 541 + This project is based on Thistle but simplified: 542 + - ✅ **Drizzle ORM** instead of raw `bun:sqlite` queries 543 + - ✅ **Simpler demo** (just counter, no transcription service) 544 + - ❌ **No subscriptions** (no Polar integration) 545 + - ❌ **No email** (no MailChannels, verification codes) 546 + - ❌ **No admin system** (no roles, no admin panel) 547 + - ❌ **No rate limiting** (simplified for demo) 548 + - ❌ **No password auth** (passkeys only) 549 + 550 + Focus is on demonstrating the core pattern: Bun + TypeScript + Lit + Drizzle + Passkeys.
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Kieran Klukas 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+92
README.md
··· 4 4 5 5 It uses Lit, Bun, and Drizzle as the main stack and they all work together to make a wonderful combo. 6 6 7 + ## How do I hack on it? 8 + 9 + ### Development 10 + 11 + Getting started is super straightforward: 12 + 13 + ```bash 14 + bun install 15 + cp .env.example .env 16 + bun run db:push 17 + bun dev 18 + ``` 19 + 20 + Your server will be running at `http://localhost:3000` with hot module reloading. Just edit any `.ts`, `.html`, or `.css` file and watch it update in the browser. 21 + 22 + ## How does it work? 23 + 24 + The development flow is really nice in my opinion. The server imports HTML files as route handlers. Those HTML files import TypeScript components using `<script type="module">`. The components are just Lit web components that self-register as custom elements. Bun sees all this and bundles everything automatically including linked styles. 25 + 26 + ```typescript 27 + // src/index.ts - Server imports HTML as routes 28 + import indexHTML from "./pages/index.html"; 29 + 30 + Bun.serve({ 31 + port: 3000, 32 + routes: { 33 + "/": indexHTML, 34 + }, 35 + development: { 36 + hmr: true, 37 + console: true, 38 + }, 39 + }); 40 + ``` 41 + 42 + ```html 43 + <!-- src/pages/index.html --> 44 + <!DOCTYPE html> 45 + <html lang="en"> 46 + <head> 47 + <link rel="stylesheet" href="../styles/main.css" /> 48 + </head> 49 + <body> 50 + <counter-component count="0"></counter-component> 51 + <script type="module" src="../components/counter.ts"></script> 52 + </body> 53 + </html> 54 + ``` 55 + 56 + ```typescript 57 + // src/components/counter.ts 58 + import { LitElement, html, css } from "lit"; 59 + import { customElement, property } from "lit/decorators.js"; 60 + 61 + @customElement("counter-component") 62 + export class CounterComponent extends LitElement { 63 + @property({ type: Number }) count = 0; 64 + 65 + static styles = css` 66 + :host { 67 + display: block; 68 + padding: 1rem; 69 + } 70 + `; 71 + 72 + render() { 73 + return html` 74 + <div>${this.count}</div> 75 + <button @click=${() => this.count++}>+</button> 76 + `; 77 + } 78 + } 79 + ``` 80 + 81 + The database uses Drizzle ORM for type-safe SQLite access. Schema changes are handled through migrations: 82 + 83 + ```bash 84 + # Make changes to src/db/schema.ts, then: 85 + bun run db:push # Push schema to database 86 + bun run db:studio # Visual database browser 87 + ``` 88 + 89 + ## Commands 90 + 91 + ```bash 92 + bun dev # Development server with hot reload 93 + bun test # Run tests 94 + bun run db:generate # Generate migrations from schema 95 + bun run db:push # Push schema to database 96 + bun run db:studio # Open Drizzle Studio 97 + ``` 98 + 7 99 The canonical repo for this is hosted on tangled over at [`dunkirk.sh/tacy-stack`](https://tangled.org/@dunkirk.sh/tacy-stack) 8 100 9 101 <p align="center">
+34
biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", 3 + "vcs": { 4 + "enabled": false, 5 + "clientKind": "git", 6 + "useIgnoreFile": false 7 + }, 8 + "files": { 9 + "ignoreUnknown": false 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "tab" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true 19 + } 20 + }, 21 + "javascript": { 22 + "formatter": { 23 + "quoteStyle": "double" 24 + } 25 + }, 26 + "assist": { 27 + "enabled": true, 28 + "actions": { 29 + "source": { 30 + "organizeImports": "on" 31 + } 32 + } 33 + } 34 + }
+325
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "tacy-stack", 7 + "dependencies": { 8 + "@simplewebauthn/browser": "^13.2.2", 9 + "@simplewebauthn/server": "^13.2.2", 10 + "drizzle-orm": "^0.38.3", 11 + "lit": "^3.3.1", 12 + "nanoid": "^5.1.6", 13 + }, 14 + "devDependencies": { 15 + "@biomejs/biome": "^2.3.2", 16 + "@simplewebauthn/types": "^12.0.0", 17 + "@types/bun": "latest", 18 + "better-sqlite3": "^12.5.0", 19 + "drizzle-kit": "^0.30.1", 20 + }, 21 + "peerDependencies": { 22 + "typescript": "^5", 23 + }, 24 + }, 25 + }, 26 + "packages": { 27 + "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], 28 + 29 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="], 30 + 31 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="], 32 + 33 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="], 34 + 35 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="], 36 + 37 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="], 38 + 39 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="], 40 + 41 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="], 42 + 43 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="], 44 + 45 + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], 46 + 47 + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], 48 + 49 + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], 50 + 51 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], 52 + 53 + "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], 54 + 55 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], 56 + 57 + "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], 58 + 59 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], 60 + 61 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], 62 + 63 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], 64 + 65 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], 66 + 67 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], 68 + 69 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], 70 + 71 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], 72 + 73 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], 74 + 75 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], 76 + 77 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], 78 + 79 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], 80 + 81 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], 82 + 83 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], 84 + 85 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], 86 + 87 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], 88 + 89 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], 90 + 91 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], 92 + 93 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], 94 + 95 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], 96 + 97 + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], 98 + 99 + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], 100 + 101 + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.4.0", "", {}, "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw=="], 102 + 103 + "@lit/reactive-element": ["@lit/reactive-element@2.1.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0" } }, "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg=="], 104 + 105 + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="], 106 + 107 + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA=="], 108 + 109 + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ=="], 110 + 111 + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw=="], 112 + 113 + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ=="], 114 + 115 + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA=="], 116 + 117 + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pfx": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw=="], 118 + 119 + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w=="], 120 + 121 + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="], 122 + 123 + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA=="], 124 + 125 + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA=="], 126 + 127 + "@peculiar/x509": ["@peculiar/x509@1.14.2", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag=="], 128 + 129 + "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], 130 + 131 + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], 132 + 133 + "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], 134 + 135 + "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 136 + 137 + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 138 + 139 + "@types/node": ["@types/node@25.0.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg=="], 140 + 141 + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], 142 + 143 + "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], 144 + 145 + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], 146 + 147 + "better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="], 148 + 149 + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], 150 + 151 + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], 152 + 153 + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], 154 + 155 + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], 156 + 157 + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 158 + 159 + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], 160 + 161 + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 162 + 163 + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], 164 + 165 + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], 166 + 167 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 168 + 169 + "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="], 170 + 171 + "drizzle-orm": ["drizzle-orm@0.38.4", "", { "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", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q=="], 172 + 173 + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], 174 + 175 + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], 176 + 177 + "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], 178 + 179 + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], 180 + 181 + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], 182 + 183 + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], 184 + 185 + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], 186 + 187 + "gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="], 188 + 189 + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], 190 + 191 + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], 192 + 193 + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], 194 + 195 + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 196 + 197 + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], 198 + 199 + "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], 200 + 201 + "lit": ["lit@3.3.1", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA=="], 202 + 203 + "lit-element": ["lit-element@4.2.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw=="], 204 + 205 + "lit-html": ["lit-html@3.3.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA=="], 206 + 207 + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], 208 + 209 + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], 210 + 211 + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], 212 + 213 + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 214 + 215 + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 216 + 217 + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], 218 + 219 + "node-abi": ["node-abi@3.85.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg=="], 220 + 221 + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], 222 + 223 + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], 224 + 225 + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], 226 + 227 + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 228 + 229 + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], 230 + 231 + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], 232 + 233 + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], 234 + 235 + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], 236 + 237 + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 238 + 239 + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 240 + 241 + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 242 + 243 + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], 244 + 245 + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], 246 + 247 + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], 248 + 249 + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 250 + 251 + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], 252 + 253 + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 254 + 255 + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], 256 + 257 + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], 258 + 259 + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], 260 + 261 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 262 + 263 + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], 264 + 265 + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], 266 + 267 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 268 + 269 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 270 + 271 + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], 272 + 273 + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], 274 + 275 + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], 276 + 277 + "@esbuild-kit/core-utils/esbuild": ["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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], 278 + 279 + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 280 + 281 + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], 282 + 283 + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], 284 + 285 + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], 286 + 287 + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], 288 + 289 + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], 290 + 291 + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], 292 + 293 + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], 294 + 295 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], 296 + 297 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], 298 + 299 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], 300 + 301 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], 302 + 303 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], 304 + 305 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], 306 + 307 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], 308 + 309 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], 310 + 311 + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], 312 + 313 + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], 314 + 315 + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], 316 + 317 + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], 318 + 319 + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], 320 + 321 + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], 322 + 323 + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], 324 + } 325 + }
+10
drizzle.config.ts
··· 1 + import type { Config } from "drizzle-kit"; 2 + 3 + export default { 4 + schema: "./src/db/schema.ts", 5 + out: "./drizzle", 6 + dialect: "sqlite", 7 + dbCredentials: { 8 + url: "tacy-stack.db", 9 + }, 10 + } satisfies Config;
+30
package.json
··· 1 + { 2 + "name": "tacy-stack", 3 + "module": "src/index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "bun run src/index.ts --hot", 8 + "test": "NODE_ENV=test bun test", 9 + "db:generate": "drizzle-kit generate", 10 + "db:push": "drizzle-kit push", 11 + "db:studio": "drizzle-kit studio" 12 + }, 13 + "devDependencies": { 14 + "@biomejs/biome": "^2.3.2", 15 + "@simplewebauthn/types": "^12.0.0", 16 + "@types/bun": "latest", 17 + "better-sqlite3": "^12.5.0", 18 + "drizzle-kit": "^0.30.1" 19 + }, 20 + "peerDependencies": { 21 + "typescript": "^5" 22 + }, 23 + "dependencies": { 24 + "@simplewebauthn/browser": "^13.2.2", 25 + "@simplewebauthn/server": "^13.2.2", 26 + "drizzle-orm": "^0.38.3", 27 + "lit": "^3.3.1", 28 + "nanoid": "^5.1.6" 29 + } 30 + }
+406
src/components/auth.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import { 4 + authenticateWithPasskey, 5 + isPasskeySupported, 6 + registerPasskey, 7 + } from "../lib/client-passkey"; 8 + 9 + interface User { 10 + username: string; 11 + name: string | null; 12 + avatar: string; 13 + } 14 + 15 + @customElement("auth-component") 16 + export class AuthComponent extends LitElement { 17 + @state() user: User | null = null; 18 + @state() loading = true; 19 + @state() showModal = false; 20 + @state() username = ""; 21 + @state() error = ""; 22 + @state() isSubmitting = false; 23 + @state() passkeySupported = false; 24 + @state() showRegisterForm = false; 25 + 26 + static override styles = css` 27 + :host { 28 + display: block; 29 + } 30 + 31 + .auth-container { 32 + position: relative; 33 + } 34 + 35 + .auth-button { 36 + display: flex; 37 + align-items: center; 38 + gap: 0.5rem; 39 + padding: 0.5rem 1rem; 40 + background: var(--primary); 41 + color: white; 42 + border: 2px solid var(--primary); 43 + border-radius: 8px; 44 + cursor: pointer; 45 + font-size: 1rem; 46 + font-weight: 500; 47 + transition: all 0.2s; 48 + font-family: inherit; 49 + } 50 + 51 + .auth-button:hover { 52 + background: transparent; 53 + color: var(--primary); 54 + } 55 + 56 + .user-info { 57 + display: flex; 58 + align-items: center; 59 + gap: 0.75rem; 60 + } 61 + 62 + .email { 63 + font-weight: 500; 64 + color: white; 65 + font-size: 0.875rem; 66 + transition: all 0.2s; 67 + } 68 + 69 + .auth-button:hover .email { 70 + color: var(--primary); 71 + } 72 + 73 + .modal-overlay { 74 + position: fixed; 75 + top: 0; 76 + left: 0; 77 + right: 0; 78 + bottom: 0; 79 + background: rgba(0, 0, 0, 0.5); 80 + display: flex; 81 + align-items: center; 82 + justify-content: center; 83 + z-index: 1000; 84 + } 85 + 86 + .modal { 87 + background: white; 88 + padding: 2rem; 89 + border-radius: 12px; 90 + max-width: 400px; 91 + width: 90%; 92 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 93 + } 94 + 95 + .modal h2 { 96 + margin: 0 0 1.5rem; 97 + color: var(--text); 98 + } 99 + 100 + .form-group { 101 + margin-bottom: 1rem; 102 + } 103 + 104 + label { 105 + display: block; 106 + margin-bottom: 0.5rem; 107 + color: var(--text); 108 + font-weight: 500; 109 + } 110 + 111 + input { 112 + width: 100%; 113 + padding: 0.75rem; 114 + border: 2px solid var(--secondary); 115 + border-radius: 8px; 116 + font-size: 1rem; 117 + font-family: inherit; 118 + box-sizing: border-box; 119 + } 120 + 121 + input:focus { 122 + outline: none; 123 + border-color: var(--primary); 124 + } 125 + 126 + .error { 127 + color: var(--accent); 128 + margin-bottom: 1rem; 129 + font-size: 0.875rem; 130 + } 131 + 132 + .button-group { 133 + display: flex; 134 + gap: 0.5rem; 135 + margin-top: 1.5rem; 136 + } 137 + 138 + button { 139 + flex: 1; 140 + padding: 0.75rem; 141 + border: 2px solid var(--primary); 142 + background: var(--primary); 143 + color: white; 144 + border-radius: 8px; 145 + cursor: pointer; 146 + font-size: 1rem; 147 + font-weight: 500; 148 + transition: all 0.2s; 149 + font-family: inherit; 150 + } 151 + 152 + button:hover { 153 + background: transparent; 154 + color: var(--primary); 155 + } 156 + 157 + button.secondary { 158 + background: transparent; 159 + color: var(--primary); 160 + } 161 + 162 + button.secondary:hover { 163 + background: var(--primary); 164 + color: white; 165 + } 166 + 167 + button:disabled { 168 + opacity: 0.5; 169 + cursor: not-allowed; 170 + } 171 + 172 + .avatar { 173 + width: 32px; 174 + height: 32px; 175 + border-radius: 50%; 176 + } 177 + 178 + .loading { 179 + text-align: center; 180 + color: var(--text); 181 + } 182 + `; 183 + 184 + override connectedCallback() { 185 + super.connectedCallback(); 186 + this.checkAuth(); 187 + this.passkeySupported = isPasskeySupported(); 188 + } 189 + 190 + async checkAuth() { 191 + try { 192 + const response = await fetch("/api/auth/me"); 193 + if (response.ok) { 194 + this.user = await response.json(); 195 + } 196 + } catch (error) { 197 + console.error("Auth check failed:", error); 198 + } finally { 199 + this.loading = false; 200 + } 201 + } 202 + 203 + async handleLogin() { 204 + this.isSubmitting = true; 205 + this.error = ""; 206 + 207 + try { 208 + // Get authentication options 209 + const optionsRes = await fetch("/api/auth/passkey/authenticate/options"); 210 + if (!optionsRes.ok) { 211 + throw new Error("Failed to get authentication options"); 212 + } 213 + const options = await optionsRes.json(); 214 + 215 + // Start authentication 216 + const credential = await authenticateWithPasskey(options); 217 + 218 + // Verify authentication 219 + const verifyRes = await fetch("/api/auth/passkey/authenticate/verify", { 220 + method: "POST", 221 + headers: { "Content-Type": "application/json" }, 222 + body: JSON.stringify({ credential, challenge: options.challenge }), 223 + }); 224 + 225 + if (!verifyRes.ok) { 226 + throw new Error("Authentication failed"); 227 + } 228 + 229 + const user = await verifyRes.json(); 230 + this.user = user; 231 + this.showModal = false; 232 + this.username = ""; 233 + 234 + // Reload to update counter 235 + window.location.reload(); 236 + } catch (error) { 237 + this.error = 238 + error instanceof Error ? error.message : "Authentication failed"; 239 + } finally { 240 + this.isSubmitting = false; 241 + } 242 + } 243 + 244 + async handleRegister() { 245 + this.isSubmitting = true; 246 + this.error = ""; 247 + 248 + try { 249 + if (!this.username.trim()) { 250 + throw new Error("Username required"); 251 + } 252 + 253 + // Get passkey registration options 254 + const optionsRes = await fetch( 255 + `/api/auth/passkey/register/options?username=${encodeURIComponent(this.username)}`, 256 + ); 257 + if (!optionsRes.ok) { 258 + throw new Error("Failed to get registration options"); 259 + } 260 + const options = await optionsRes.json(); 261 + 262 + // Create passkey (this can be cancelled by user) 263 + const credential = await registerPasskey(options); 264 + 265 + // Register user with passkey atomically 266 + const registerRes = await fetch("/api/auth/register", { 267 + method: "POST", 268 + headers: { "Content-Type": "application/json" }, 269 + body: JSON.stringify({ 270 + username: this.username, 271 + credential, 272 + challenge: options.challenge, 273 + }), 274 + }); 275 + 276 + if (!registerRes.ok) { 277 + const data = await registerRes.json(); 278 + throw new Error(data.error || "Registration failed"); 279 + } 280 + 281 + const user = await registerRes.json(); 282 + this.user = user; 283 + this.showModal = false; 284 + this.username = ""; 285 + 286 + // Reload to update counter 287 + window.location.reload(); 288 + } catch (error) { 289 + this.error = error instanceof Error ? error.message : "Registration failed"; 290 + } finally { 291 + this.isSubmitting = false; 292 + } 293 + } 294 + 295 + async handleLogout() { 296 + try { 297 + await fetch("/api/auth/logout", { method: "POST" }); 298 + this.user = null; 299 + window.location.reload(); 300 + } catch (error) { 301 + console.error("Logout failed:", error); 302 + } 303 + } 304 + 305 + override render() { 306 + if (this.loading) { 307 + return html`<div class="loading">Loading...</div>`; 308 + } 309 + 310 + return html` 311 + <div class="auth-container"> 312 + ${this.user 313 + ? html` 314 + <button class="auth-button" @click=${this.handleLogout}> 315 + <div class="user-info"> 316 + <img 317 + class="avatar" 318 + src="https://api.dicebear.com/7.x/shapes/svg?seed=${this.user.avatar}" 319 + alt="Avatar" 320 + /> 321 + <span class="email">${this.user.username}</span> 322 + </div> 323 + </button> 324 + ` 325 + : html` 326 + <button class="auth-button" @click=${() => (this.showModal = true)}> 327 + Sign In 328 + </button> 329 + `} 330 + ${this.showModal 331 + ? html` 332 + <div class="modal-overlay" @click=${() => { 333 + this.showModal = false; 334 + this.showRegisterForm = false; 335 + }}> 336 + <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 337 + <h2>Welcome</h2> 338 + ${this.error ? html`<div class="error">${this.error}</div>` : ""} 339 + ${!this.passkeySupported 340 + ? html` 341 + <div class="error"> 342 + Passkeys are not supported in this browser. 343 + </div> 344 + ` 345 + : ""} 346 + ${this.showRegisterForm 347 + ? html` 348 + <div class="form-group"> 349 + <label for="username">Username</label> 350 + <input 351 + type="text" 352 + id="username" 353 + placeholder="Choose a username" 354 + .value=${this.username} 355 + @input=${(e: Event) => 356 + (this.username = (e.target as HTMLInputElement).value)} 357 + ?disabled=${this.isSubmitting} 358 + /> 359 + </div> 360 + <div class="button-group"> 361 + <button 362 + class="secondary" 363 + @click=${() => { 364 + this.showRegisterForm = false; 365 + this.username = ""; 366 + this.error = ""; 367 + }} 368 + ?disabled=${this.isSubmitting} 369 + > 370 + Back 371 + </button> 372 + <button 373 + @click=${this.handleRegister} 374 + ?disabled=${this.isSubmitting || 375 + !this.username.trim() || 376 + !this.passkeySupported} 377 + > 378 + Register 379 + </button> 380 + </div> 381 + ` 382 + : html` 383 + <div class="button-group"> 384 + <button 385 + @click=${this.handleLogin} 386 + ?disabled=${this.isSubmitting || !this.passkeySupported} 387 + > 388 + Sign In 389 + </button> 390 + <button 391 + class="secondary" 392 + @click=${() => (this.showRegisterForm = true)} 393 + ?disabled=${this.isSubmitting || !this.passkeySupported} 394 + > 395 + Register 396 + </button> 397 + </div> 398 + `} 399 + </div> 400 + </div> 401 + ` 402 + : ""} 403 + </div> 404 + `; 405 + } 406 + }
+139
src/components/counter.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + @customElement("counter-component") 5 + export class CounterComponent extends LitElement { 6 + @property({ type: Number }) count = 0; 7 + @state() loading = false; 8 + @state() error = ""; 9 + 10 + static override styles = css` 11 + :host { 12 + display: block; 13 + margin: 2rem 0; 14 + padding: 2rem; 15 + border: 2px solid var(--secondary); 16 + border-radius: 12px; 17 + text-align: center; 18 + background: white; 19 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 20 + } 21 + 22 + h3 { 23 + margin: 0 0 1rem; 24 + color: var(--text); 25 + font-size: 1.5rem; 26 + } 27 + 28 + .counter-display { 29 + font-size: 4rem; 30 + font-weight: bold; 31 + margin: 1.5rem 0; 32 + color: var(--primary); 33 + font-variant-numeric: tabular-nums; 34 + } 35 + 36 + button { 37 + font-size: 1.25rem; 38 + padding: 0.75rem 1.5rem; 39 + margin: 0 0.5rem; 40 + border: 2px solid var(--primary); 41 + background: var(--background); 42 + color: var(--text); 43 + cursor: pointer; 44 + border-radius: 8px; 45 + transition: all 0.2s ease; 46 + font-weight: 500; 47 + font-family: inherit; 48 + } 49 + 50 + button:hover:not(:disabled) { 51 + background: var(--primary); 52 + color: var(--background); 53 + transform: translateY(-2px); 54 + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); 55 + } 56 + 57 + button:active:not(:disabled) { 58 + transform: translateY(0); 59 + } 60 + 61 + button:disabled { 62 + opacity: 0.5; 63 + cursor: not-allowed; 64 + } 65 + 66 + button.reset { 67 + border-color: var(--accent); 68 + } 69 + 70 + button.reset:hover:not(:disabled) { 71 + background: var(--accent); 72 + color: var(--background); 73 + } 74 + 75 + .error { 76 + color: var(--accent); 77 + margin-top: 1rem; 78 + font-size: 0.875rem; 79 + } 80 + 81 + .loading { 82 + opacity: 0.6; 83 + } 84 + `; 85 + 86 + async updateCounter(action: "increment" | "decrement" | "reset") { 87 + this.loading = true; 88 + this.error = ""; 89 + 90 + try { 91 + const response = await fetch(`/api/counter/${action}`, { 92 + method: "POST", 93 + }); 94 + 95 + if (!response.ok) { 96 + throw new Error("Failed to update counter"); 97 + } 98 + 99 + const data = await response.json(); 100 + this.count = data.count; 101 + } catch (error) { 102 + this.error = 103 + error instanceof Error ? error.message : "Failed to update counter"; 104 + } finally { 105 + this.loading = false; 106 + } 107 + } 108 + 109 + override render() { 110 + return html` 111 + <div class=${this.loading ? "loading" : ""}> 112 + <h3>Your Counter</h3> 113 + <div class="counter-display">${this.count}</div> 114 + <div> 115 + <button 116 + @click=${() => this.updateCounter("decrement")} 117 + ?disabled=${this.loading} 118 + > 119 + - 120 + </button> 121 + <button 122 + class="reset" 123 + @click=${() => this.updateCounter("reset")} 124 + ?disabled=${this.loading} 125 + > 126 + Reset 127 + </button> 128 + <button 129 + @click=${() => this.updateCounter("increment")} 130 + ?disabled=${this.loading} 131 + > 132 + + 133 + </button> 134 + </div> 135 + ${this.error ? html`<div class="error">${this.error}</div>` : ""} 136 + </div> 137 + `; 138 + } 139 + }
+14
src/db/db.ts
··· 1 + import { drizzle } from "drizzle-orm/bun-sqlite"; 2 + import { Database } from "bun:sqlite"; 3 + import * as schema from "./schema"; 4 + 5 + // Use test database when NODE_ENV is test 6 + const dbPath = 7 + process.env.NODE_ENV === "test" ? "tacy-stack.test.db" : "tacy-stack.db"; 8 + 9 + const sqlite = new Database(dbPath); 10 + export const db = drizzle(sqlite, { schema }); 11 + 12 + console.log(`[Database] Using database: ${dbPath}`); 13 + 14 + export default db;
+55
src/db/schema.ts
··· 1 + import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; 2 + import { sql } from "drizzle-orm"; 3 + 4 + // Users table 5 + export const users = sqliteTable("users", { 6 + id: integer("id").primaryKey({ autoIncrement: true }), 7 + username: text("username").unique().notNull(), 8 + name: text("name"), 9 + avatar: text("avatar").default("d"), 10 + created_at: integer("created_at", { mode: "timestamp" }) 11 + .notNull() 12 + .default(sql`(strftime('%s', 'now'))`), 13 + }); 14 + 15 + // Sessions table 16 + export const sessions = sqliteTable("sessions", { 17 + id: text("id").primaryKey(), 18 + user_id: integer("user_id") 19 + .notNull() 20 + .references(() => users.id, { onDelete: "cascade" }), 21 + ip_address: text("ip_address"), 22 + user_agent: text("user_agent"), 23 + created_at: integer("created_at", { mode: "timestamp" }) 24 + .notNull() 25 + .default(sql`(strftime('%s', 'now'))`), 26 + expires_at: integer("expires_at", { mode: "timestamp" }).notNull(), 27 + }); 28 + 29 + // Passkeys table 30 + export const passkeys = sqliteTable("passkeys", { 31 + id: text("id").primaryKey(), 32 + user_id: integer("user_id") 33 + .notNull() 34 + .references(() => users.id, { onDelete: "cascade" }), 35 + credential_id: text("credential_id").notNull().unique(), 36 + public_key: text("public_key").notNull(), 37 + counter: integer("counter").notNull().default(0), 38 + transports: text("transports"), 39 + name: text("name"), 40 + created_at: integer("created_at", { mode: "timestamp" }) 41 + .notNull() 42 + .default(sql`(strftime('%s', 'now'))`), 43 + last_used_at: integer("last_used_at", { mode: "timestamp" }), 44 + }); 45 + 46 + // Click counter table (user-specific counter values) 47 + export const counters = sqliteTable("counters", { 48 + user_id: integer("user_id") 49 + .primaryKey() 50 + .references(() => users.id, { onDelete: "cascade" }), 51 + count: integer("count").notNull().default(0), 52 + updated_at: integer("updated_at", { mode: "timestamp" }) 53 + .notNull() 54 + .default(sql`(strftime('%s', 'now'))`), 55 + });
+365
src/index.ts
··· 1 + import indexHTML from "./pages/index.html"; 2 + import { 3 + createSession, 4 + createUser, 5 + deleteSession, 6 + getUserBySession, 7 + getUserByUsername, 8 + getSessionFromRequest, 9 + } from "./lib/auth"; 10 + import { 11 + createAuthenticationOptions, 12 + createRegistrationOptions, 13 + deletePasskey, 14 + getPasskeysForUser, 15 + updatePasskeyName, 16 + verifyAndAuthenticatePasskey, 17 + verifyAndCreatePasskey, 18 + } from "./lib/passkey"; 19 + import { requireAuth } from "./lib/middleware"; 20 + import { 21 + decrementCounter, 22 + getCounterForUser, 23 + incrementCounter, 24 + resetCounter, 25 + } from "./lib/counter"; 26 + 27 + const port = 3000; 28 + 29 + Bun.serve({ 30 + port, 31 + routes: { 32 + "/": indexHTML, 33 + 34 + // Auth endpoints 35 + "/api/auth/me": { 36 + GET: (req) => { 37 + try { 38 + const sessionId = getSessionFromRequest(req); 39 + if (!sessionId) { 40 + return new Response(JSON.stringify({ error: "Not authenticated" }), { 41 + status: 401, 42 + }); 43 + } 44 + 45 + const user = getUserBySession(sessionId); 46 + if (!user) { 47 + return new Response(JSON.stringify({ error: "Invalid session" }), { 48 + status: 401, 49 + }); 50 + } 51 + 52 + return new Response(JSON.stringify(user), { 53 + headers: { "Content-Type": "application/json" }, 54 + }); 55 + } catch (error) { 56 + return new Response( 57 + JSON.stringify({ 58 + error: error instanceof Error ? error.message : "Unknown error", 59 + }), 60 + { status: 500 }, 61 + ); 62 + } 63 + }, 64 + }, 65 + 66 + "/api/auth/check-email": { 67 + GET: (req) => { 68 + try { 69 + const url = new URL(req.url); 70 + const username = url.searchParams.get("username"); 71 + 72 + if (!username) { 73 + return new Response(JSON.stringify({ error: "Username required" }), { 74 + status: 400, 75 + }); 76 + } 77 + 78 + const existing = getUserByUsername(username); 79 + if (existing) { 80 + return new Response( 81 + JSON.stringify({ error: "Username already taken" }), 82 + { status: 400 }, 83 + ); 84 + } 85 + 86 + return new Response(JSON.stringify({ available: true }), { 87 + headers: { "Content-Type": "application/json" }, 88 + }); 89 + } catch (error) { 90 + return new Response( 91 + JSON.stringify({ 92 + error: error instanceof Error ? error.message : "Unknown error", 93 + }), 94 + { status: 500 }, 95 + ); 96 + } 97 + }, 98 + }, 99 + 100 + "/api/auth/register": { 101 + POST: async (req) => { 102 + try { 103 + const body = await req.json(); 104 + const { username, credential, challenge } = body; 105 + 106 + if (!username || !credential || !challenge) { 107 + return new Response( 108 + JSON.stringify({ error: "Username, credential, and challenge required" }), 109 + { status: 400 }, 110 + ); 111 + } 112 + 113 + // Check if user already exists 114 + const existing = getUserByUsername(username); 115 + if (existing) { 116 + return new Response( 117 + JSON.stringify({ error: "Username already taken" }), 118 + { status: 400 }, 119 + ); 120 + } 121 + 122 + // Create user 123 + const user = await createUser(username); 124 + 125 + // Verify and create passkey 126 + await verifyAndCreatePasskey(user.id, credential, challenge); 127 + 128 + // Create session 129 + const sessionId = createSession(user.id); 130 + 131 + return new Response(JSON.stringify(user), { 132 + headers: { 133 + "Content-Type": "application/json", 134 + "Set-Cookie": `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${7 * 24 * 60 * 60}`, 135 + }, 136 + }); 137 + } catch (error) { 138 + return new Response( 139 + JSON.stringify({ 140 + error: error instanceof Error ? error.message : "Unknown error", 141 + }), 142 + { status: 500 }, 143 + ); 144 + } 145 + }, 146 + }, 147 + 148 + "/api/auth/logout": { 149 + POST: (req) => { 150 + try { 151 + const sessionId = getSessionFromRequest(req); 152 + if (sessionId) { 153 + deleteSession(sessionId); 154 + } 155 + 156 + return new Response(JSON.stringify({ success: true }), { 157 + headers: { 158 + "Content-Type": "application/json", 159 + "Set-Cookie": 160 + "session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", 161 + }, 162 + }); 163 + } catch (error) { 164 + return new Response( 165 + JSON.stringify({ 166 + error: error instanceof Error ? error.message : "Unknown error", 167 + }), 168 + { status: 500 }, 169 + ); 170 + } 171 + }, 172 + }, 173 + 174 + // Passkey endpoints 175 + "/api/auth/passkey/register/options": { 176 + GET: async (req) => { 177 + try { 178 + // For registration, we need username from query params (no session yet) 179 + const url = new URL(req.url); 180 + const username = url.searchParams.get("username"); 181 + 182 + if (!username) { 183 + return new Response(JSON.stringify({ error: "Username required" }), { 184 + status: 400, 185 + }); 186 + } 187 + 188 + // Create temporary user object for registration options 189 + const tempUser = { 190 + id: 0, // Temporary ID 191 + username, 192 + name: null, 193 + avatar: "temp", 194 + created_at: Math.floor(Date.now() / 1000), 195 + }; 196 + 197 + const options = await createRegistrationOptions(tempUser); 198 + 199 + return new Response(JSON.stringify(options), { 200 + headers: { "Content-Type": "application/json" }, 201 + }); 202 + } catch (error) { 203 + return new Response( 204 + JSON.stringify({ 205 + error: error instanceof Error ? error.message : "Unknown error", 206 + }), 207 + { status: 500 }, 208 + ); 209 + } 210 + }, 211 + }, 212 + 213 + 214 + 215 + "/api/auth/passkey/authenticate/options": { 216 + GET: async (req) => { 217 + try { 218 + const options = await createAuthenticationOptions(); 219 + 220 + return new Response(JSON.stringify(options), { 221 + headers: { "Content-Type": "application/json" }, 222 + }); 223 + } catch (error) { 224 + return new Response( 225 + JSON.stringify({ 226 + error: error instanceof Error ? error.message : "Unknown error", 227 + }), 228 + { status: 500 }, 229 + ); 230 + } 231 + }, 232 + }, 233 + 234 + "/api/auth/passkey/authenticate/verify": { 235 + POST: async (req) => { 236 + try { 237 + const body = await req.json(); 238 + const { credential, challenge } = body; 239 + 240 + if (!credential || !challenge) { 241 + return new Response( 242 + JSON.stringify({ error: "Credential and challenge required" }), 243 + { status: 400 }, 244 + ); 245 + } 246 + 247 + const { userId } = await verifyAndAuthenticatePasskey(credential, challenge); 248 + 249 + const user = getUserBySession( 250 + createSession(userId, req.headers.get("x-forwarded-for") || undefined), 251 + ); 252 + 253 + if (!user) { 254 + return new Response(JSON.stringify({ error: "User not found" }), { 255 + status: 404, 256 + }); 257 + } 258 + 259 + const sessionId = createSession(userId); 260 + 261 + return new Response(JSON.stringify(user), { 262 + headers: { 263 + "Content-Type": "application/json", 264 + "Set-Cookie": `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${7 * 24 * 60 * 60}`, 265 + }, 266 + }); 267 + } catch (error) { 268 + return new Response( 269 + JSON.stringify({ 270 + error: error instanceof Error ? error.message : "Unknown error", 271 + }), 272 + { status: 500 }, 273 + ); 274 + } 275 + }, 276 + }, 277 + 278 + // Counter endpoints 279 + "/api/counter": { 280 + GET: async (req) => { 281 + try { 282 + const userId = await requireAuth(req); 283 + const count = getCounterForUser(userId); 284 + 285 + return new Response(JSON.stringify({ count }), { 286 + headers: { "Content-Type": "application/json" }, 287 + }); 288 + } catch (error) { 289 + return new Response( 290 + JSON.stringify({ 291 + error: error instanceof Error ? error.message : "Not authenticated", 292 + }), 293 + { status: 401 }, 294 + ); 295 + } 296 + }, 297 + }, 298 + 299 + "/api/counter/increment": { 300 + POST: async (req) => { 301 + try { 302 + const userId = await requireAuth(req); 303 + const count = incrementCounter(userId); 304 + 305 + return new Response(JSON.stringify({ count }), { 306 + headers: { "Content-Type": "application/json" }, 307 + }); 308 + } catch (error) { 309 + return new Response( 310 + JSON.stringify({ 311 + error: error instanceof Error ? error.message : "Not authenticated", 312 + }), 313 + { status: 401 }, 314 + ); 315 + } 316 + }, 317 + }, 318 + 319 + "/api/counter/decrement": { 320 + POST: async (req) => { 321 + try { 322 + const userId = await requireAuth(req); 323 + const count = decrementCounter(userId); 324 + 325 + return new Response(JSON.stringify({ count }), { 326 + headers: { "Content-Type": "application/json" }, 327 + }); 328 + } catch (error) { 329 + return new Response( 330 + JSON.stringify({ 331 + error: error instanceof Error ? error.message : "Not authenticated", 332 + }), 333 + { status: 401 }, 334 + ); 335 + } 336 + }, 337 + }, 338 + 339 + "/api/counter/reset": { 340 + POST: async (req) => { 341 + try { 342 + const userId = await requireAuth(req); 343 + resetCounter(userId); 344 + 345 + return new Response(JSON.stringify({ count: 0 }), { 346 + headers: { "Content-Type": "application/json" }, 347 + }); 348 + } catch (error) { 349 + return new Response( 350 + JSON.stringify({ 351 + error: error instanceof Error ? error.message : "Not authenticated", 352 + }), 353 + { status: 401 }, 354 + ); 355 + } 356 + }, 357 + }, 358 + }, 359 + development: { 360 + hmr: true, 361 + console: true, 362 + }, 363 + }); 364 + 365 + console.log(`🥞 Tacy Stack running at http://localhost:${port}`);
+146
src/lib/auth.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../db/db"; 3 + import { users, sessions, type User } from "../db/schema"; 4 + 5 + const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds 6 + 7 + export interface User { 8 + id: number; 9 + username: string; 10 + name: string | null; 11 + avatar: string; 12 + created_at: number; 13 + } 14 + 15 + export interface Session { 16 + id: string; 17 + user_id: number; 18 + ip_address: string | null; 19 + user_agent: string | null; 20 + created_at: number; 21 + expires_at: number; 22 + } 23 + 24 + export function createSession( 25 + userId: number, 26 + ipAddress?: string, 27 + userAgent?: string, 28 + ): string { 29 + const sessionId = crypto.randomUUID(); 30 + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION; 31 + 32 + db.insert(sessions).values({ 33 + id: sessionId, 34 + user_id: userId, 35 + ip_address: ipAddress ?? null, 36 + user_agent: userAgent ?? null, 37 + expires_at: new Date(expiresAt * 1000), 38 + }).run(); 39 + 40 + return sessionId; 41 + } 42 + 43 + export function getSession(sessionId: string): Session | null { 44 + const now = Math.floor(Date.now() / 1000); 45 + 46 + const session = db 47 + .select() 48 + .from(sessions) 49 + .where(eq(sessions.id, sessionId)) 50 + .get(); 51 + 52 + if (!session || Math.floor(session.expires_at.getTime() / 1000) <= now) { 53 + return null; 54 + } 55 + 56 + return { 57 + id: session.id, 58 + user_id: session.user_id, 59 + ip_address: session.ip_address, 60 + user_agent: session.user_agent, 61 + created_at: Math.floor(session.created_at.getTime() / 1000), 62 + expires_at: Math.floor(session.expires_at.getTime() / 1000), 63 + }; 64 + } 65 + 66 + export function getUserBySession(sessionId: string): User | null { 67 + const session = getSession(sessionId); 68 + if (!session) return null; 69 + 70 + const user = db.select().from(users).where(eq(users.id, session.user_id)).get(); 71 + 72 + if (!user) return null; 73 + 74 + return { 75 + id: user.id, 76 + username: user.username, 77 + name: user.name, 78 + avatar: user.avatar, 79 + created_at: Math.floor(user.created_at.getTime() / 1000), 80 + }; 81 + } 82 + 83 + export function getUserByUsername(username: string): User | null { 84 + const user = db.select().from(users).where(eq(users.username, username)).get(); 85 + 86 + if (!user) return null; 87 + 88 + return { 89 + id: user.id, 90 + username: user.username, 91 + name: user.name, 92 + avatar: user.avatar, 93 + created_at: Math.floor(user.created_at.getTime() / 1000), 94 + }; 95 + } 96 + 97 + export function deleteSession(sessionId: string): void { 98 + db.delete(sessions).where(eq(sessions.id, sessionId)).run(); 99 + } 100 + 101 + export async function createUser(username: string, name?: string): Promise<User> { 102 + // Generate deterministic avatar from username 103 + const encoder = new TextEncoder(); 104 + const data = encoder.encode(username.toLowerCase()); 105 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); 106 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 107 + const avatar = hashArray 108 + .map((b) => b.toString(16).padStart(2, "0")) 109 + .join("") 110 + .substring(0, 16); 111 + 112 + const result = db 113 + .insert(users) 114 + .values({ 115 + username, 116 + name: name ?? null, 117 + avatar, 118 + }) 119 + .run(); 120 + 121 + const user = db 122 + .select() 123 + .from(users) 124 + .where(eq(users.id, Number(result.lastInsertRowid))) 125 + .get(); 126 + 127 + if (!user) { 128 + throw new Error("Failed to create user"); 129 + } 130 + 131 + return { 132 + id: user.id, 133 + username: user.username, 134 + name: user.name, 135 + avatar: user.avatar, 136 + created_at: Math.floor(user.created_at.getTime() / 1000), 137 + }; 138 + } 139 + 140 + export function getSessionFromRequest(req: Request): string | null { 141 + const cookie = req.headers.get("cookie"); 142 + if (!cookie) return null; 143 + 144 + const sessionMatch = cookie.match(/session=([^;]+)/); 145 + return sessionMatch ? sessionMatch[1] : null; 146 + }
+40
src/lib/client-passkey.ts
··· 1 + /** 2 + * Client-side passkey utilities using SimpleWebAuthn browser 3 + */ 4 + 5 + import { 6 + startAuthentication, 7 + startRegistration, 8 + } from "@simplewebauthn/browser"; 9 + import type { 10 + PublicKeyCredentialCreationOptionsJSON, 11 + PublicKeyCredentialRequestOptionsJSON, 12 + } from "@simplewebauthn/types"; 13 + 14 + /** 15 + * Check if passkeys are supported in this browser 16 + */ 17 + export function isPasskeySupported(): boolean { 18 + return ( 19 + window?.PublicKeyCredential !== undefined && 20 + typeof window.PublicKeyCredential === "function" 21 + ); 22 + } 23 + 24 + /** 25 + * Register a new passkey 26 + */ 27 + export async function registerPasskey( 28 + options: PublicKeyCredentialCreationOptionsJSON, 29 + ) { 30 + return await startRegistration({ optionsJSON: options }); 31 + } 32 + 33 + /** 34 + * Authenticate with a passkey 35 + */ 36 + export async function authenticateWithPasskey( 37 + options: PublicKeyCredentialRequestOptionsJSON, 38 + ) { 39 + return await startAuthentication({ optionsJSON: options }); 40 + }
+63
src/lib/counter.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../db/db"; 3 + import { counters } from "../db/schema"; 4 + 5 + export function getCounterForUser(userId: number): number { 6 + const counter = db 7 + .select() 8 + .from(counters) 9 + .where(eq(counters.user_id, userId)) 10 + .get(); 11 + 12 + return counter?.count ?? 0; 13 + } 14 + 15 + export function incrementCounter(userId: number): number { 16 + // Try to update existing counter 17 + const existing = db 18 + .select() 19 + .from(counters) 20 + .where(eq(counters.user_id, userId)) 21 + .get(); 22 + 23 + if (existing) { 24 + const newCount = existing.count + 1; 25 + db.update(counters) 26 + .set({ count: newCount, updated_at: new Date() }) 27 + .where(eq(counters.user_id, userId)) 28 + .run(); 29 + return newCount; 30 + } 31 + 32 + // Create new counter starting at 1 33 + db.insert(counters).values({ user_id: userId, count: 1 }).run(); 34 + return 1; 35 + } 36 + 37 + export function decrementCounter(userId: number): number { 38 + const existing = db 39 + .select() 40 + .from(counters) 41 + .where(eq(counters.user_id, userId)) 42 + .get(); 43 + 44 + if (existing) { 45 + const newCount = existing.count - 1; 46 + db.update(counters) 47 + .set({ count: newCount, updated_at: new Date() }) 48 + .where(eq(counters.user_id, userId)) 49 + .run(); 50 + return newCount; 51 + } 52 + 53 + // Create new counter starting at -1 54 + db.insert(counters).values({ user_id: userId, count: -1 }).run(); 55 + return -1; 56 + } 57 + 58 + export function resetCounter(userId: number): void { 59 + db.update(counters) 60 + .set({ count: 0, updated_at: new Date() }) 61 + .where(eq(counters.user_id, userId)) 62 + .run(); 63 + }
+18
src/lib/middleware.ts
··· 1 + import { getSessionFromRequest, getUserBySession } from "./auth"; 2 + 3 + /** 4 + * Middleware to require authentication 5 + */ 6 + export async function requireAuth(req: Request): Promise<number> { 7 + const sessionId = getSessionFromRequest(req); 8 + if (!sessionId) { 9 + throw new Error("Not authenticated"); 10 + } 11 + 12 + const user = getUserBySession(sessionId); 13 + if (!user) { 14 + throw new Error("Invalid session"); 15 + } 16 + 17 + return user.id; 18 + }
+333
src/lib/passkey.ts
··· 1 + import { 2 + generateAuthenticationOptions, 3 + generateRegistrationOptions, 4 + type VerifiedAuthenticationResponse, 5 + type VerifiedRegistrationResponse, 6 + verifyAuthenticationResponse, 7 + verifyRegistrationResponse, 8 + } from "@simplewebauthn/server"; 9 + import type { 10 + AuthenticationResponseJSON, 11 + RegistrationResponseJSON, 12 + } from "@simplewebauthn/types"; 13 + import { eq } from "drizzle-orm"; 14 + import db from "../db/db"; 15 + import { passkeys } from "../db/schema"; 16 + import type { User } from "./auth"; 17 + 18 + export interface Passkey { 19 + id: string; 20 + user_id: number; 21 + credential_id: string; 22 + public_key: string; 23 + counter: number; 24 + transports: string | null; 25 + name: string | null; 26 + created_at: number; 27 + last_used_at: number | null; 28 + } 29 + 30 + export interface RegistrationChallenge { 31 + challenge: string; 32 + user_id: number; 33 + expires_at: number; 34 + } 35 + 36 + export interface AuthenticationChallenge { 37 + challenge: string; 38 + expires_at: number; 39 + } 40 + 41 + // In-memory challenge storage 42 + const registrationChallenges = new Map<string, RegistrationChallenge>(); 43 + const authenticationChallenges = new Map<string, AuthenticationChallenge>(); 44 + 45 + // Challenge TTL: 5 minutes 46 + const CHALLENGE_TTL = 5 * 60 * 1000; 47 + 48 + // Cleanup expired challenges every minute 49 + setInterval(() => { 50 + const now = Date.now(); 51 + for (const [challenge, data] of registrationChallenges.entries()) { 52 + if (data.expires_at < now) { 53 + registrationChallenges.delete(challenge); 54 + } 55 + } 56 + for (const [challenge, data] of authenticationChallenges.entries()) { 57 + if (data.expires_at < now) { 58 + authenticationChallenges.delete(challenge); 59 + } 60 + } 61 + }, 60 * 1000); 62 + 63 + /** 64 + * Get RP ID and origin based on environment 65 + */ 66 + function getRPConfig(): { rpID: string; rpName: string; origin: string } { 67 + return { 68 + rpID: process.env.RP_ID || "localhost", 69 + rpName: "Tacy Stack", 70 + origin: process.env.ORIGIN || "http://localhost:3000", 71 + }; 72 + } 73 + 74 + /** 75 + * Generate registration options for a user 76 + */ 77 + export async function createRegistrationOptions(user: User) { 78 + const { rpID, rpName } = getRPConfig(); 79 + 80 + // Get existing credentials to exclude 81 + const existingCredentials = getPasskeysForUser(user.id); 82 + 83 + const options = await generateRegistrationOptions({ 84 + rpName, 85 + rpID, 86 + userName: user.username, 87 + userDisplayName: user.name || user.username, 88 + attestationType: "none", 89 + excludeCredentials: existingCredentials.map((cred) => ({ 90 + id: cred.credential_id, 91 + transports: cred.transports?.split(",") as 92 + | ("usb" | "nfc" | "ble" | "internal" | "hybrid")[] 93 + | undefined, 94 + })), 95 + authenticatorSelection: { 96 + residentKey: "preferred", 97 + userVerification: "preferred", 98 + }, 99 + }); 100 + 101 + // Store challenge 102 + registrationChallenges.set(options.challenge, { 103 + challenge: options.challenge, 104 + user_id: user.id, 105 + expires_at: Date.now() + CHALLENGE_TTL, 106 + }); 107 + 108 + return options; 109 + } 110 + 111 + /** 112 + * Verify registration response and create passkey 113 + */ 114 + export async function verifyAndCreatePasskey( 115 + userId: number, 116 + response: RegistrationResponseJSON, 117 + expectedChallenge: string, 118 + ): Promise<Passkey> { 119 + const { rpID, origin } = getRPConfig(); 120 + 121 + // Get and validate challenge 122 + const challengeData = registrationChallenges.get(expectedChallenge); 123 + if (!challengeData) { 124 + throw new Error("Invalid or expired challenge"); 125 + } 126 + 127 + if (challengeData.expires_at < Date.now()) { 128 + registrationChallenges.delete(expectedChallenge); 129 + throw new Error("Challenge expired"); 130 + } 131 + 132 + // For new registrations, user_id will be 0 (temporary), so skip this check 133 + if (challengeData.user_id !== 0 && challengeData.user_id !== userId) { 134 + throw new Error("User ID mismatch"); 135 + } 136 + 137 + // Verify the registration 138 + const verification: VerifiedRegistrationResponse = 139 + await verifyRegistrationResponse({ 140 + response, 141 + expectedChallenge, 142 + expectedOrigin: origin, 143 + expectedRPID: rpID, 144 + }); 145 + 146 + if (!verification.verified || !verification.registrationInfo) { 147 + throw new Error("Registration verification failed"); 148 + } 149 + 150 + // Remove used challenge 151 + registrationChallenges.delete(expectedChallenge); 152 + 153 + const { credential } = verification.registrationInfo; 154 + 155 + // Store the passkey 156 + const passkeyId = crypto.randomUUID(); 157 + const credentialIdBase64 = credential.id; // Already base64url in v13 158 + const publicKeyBase64 = Buffer.from(credential.publicKey).toString("base64url"); 159 + const transports = response.response.transports?.join(",") || null; 160 + 161 + db.insert(passkeys).values({ 162 + id: passkeyId, 163 + user_id: userId, 164 + credential_id: credentialIdBase64, 165 + public_key: publicKeyBase64, 166 + counter: credential.counter, 167 + transports, 168 + name: null, 169 + }).run(); 170 + 171 + const passkey = db.select().from(passkeys).where(eq(passkeys.id, passkeyId)).get(); 172 + 173 + if (!passkey) { 174 + throw new Error("Failed to create passkey"); 175 + } 176 + 177 + return { 178 + id: passkey.id, 179 + user_id: passkey.user_id, 180 + credential_id: passkey.credential_id, 181 + public_key: passkey.public_key, 182 + counter: passkey.counter, 183 + transports: passkey.transports, 184 + name: passkey.name, 185 + created_at: Math.floor(passkey.created_at.getTime() / 1000), 186 + last_used_at: passkey.last_used_at 187 + ? Math.floor(passkey.last_used_at.getTime() / 1000) 188 + : null, 189 + }; 190 + } 191 + 192 + /** 193 + * Generate authentication options 194 + */ 195 + export async function createAuthenticationOptions() { 196 + const { rpID } = getRPConfig(); 197 + 198 + const options = await generateAuthenticationOptions({ 199 + rpID, 200 + userVerification: "preferred", 201 + }); 202 + 203 + // Store challenge 204 + authenticationChallenges.set(options.challenge, { 205 + challenge: options.challenge, 206 + expires_at: Date.now() + CHALLENGE_TTL, 207 + }); 208 + 209 + return options; 210 + } 211 + 212 + /** 213 + * Verify authentication response and return user 214 + */ 215 + export async function verifyAndAuthenticatePasskey( 216 + response: AuthenticationResponseJSON, 217 + expectedChallenge: string, 218 + ): Promise<{ userId: number; passkeyId: string }> { 219 + const { rpID, origin } = getRPConfig(); 220 + 221 + // Get challenge 222 + const challengeData = authenticationChallenges.get(expectedChallenge); 223 + if (!challengeData) { 224 + throw new Error("Invalid or expired challenge"); 225 + } 226 + 227 + if (challengeData.expires_at < Date.now()) { 228 + authenticationChallenges.delete(expectedChallenge); 229 + throw new Error("Challenge expired"); 230 + } 231 + 232 + // Get passkey by credential ID 233 + const credentialIdBase64 = response.id; // Already base64url in v13 234 + const passkey = db 235 + .select() 236 + .from(passkeys) 237 + .where(eq(passkeys.credential_id, credentialIdBase64)) 238 + .get(); 239 + 240 + if (!passkey) { 241 + throw new Error("Passkey not found"); 242 + } 243 + 244 + // Verify the authentication 245 + const publicKey = Buffer.from(passkey.public_key, "base64url"); 246 + const verification: VerifiedAuthenticationResponse = 247 + await verifyAuthenticationResponse({ 248 + response, 249 + expectedChallenge, 250 + expectedOrigin: origin, 251 + expectedRPID: rpID, 252 + credential: { 253 + id: passkey.credential_id, 254 + publicKey, 255 + counter: passkey.counter, 256 + }, 257 + }); 258 + 259 + if (!verification.verified) { 260 + throw new Error("Authentication verification failed"); 261 + } 262 + 263 + // Remove used challenge 264 + authenticationChallenges.delete(expectedChallenge); 265 + 266 + // Update counter and last used 267 + db.update(passkeys) 268 + .set({ 269 + counter: verification.authenticationInfo.newCounter, 270 + last_used_at: new Date(), 271 + }) 272 + .where(eq(passkeys.id, passkey.id)) 273 + .run(); 274 + 275 + return { 276 + userId: passkey.user_id, 277 + passkeyId: passkey.id, 278 + }; 279 + } 280 + 281 + /** 282 + * Get all passkeys for a user 283 + */ 284 + export function getPasskeysForUser(userId: number): Passkey[] { 285 + const results = db 286 + .select() 287 + .from(passkeys) 288 + .where(eq(passkeys.user_id, userId)) 289 + .all(); 290 + 291 + return results.map((p) => ({ 292 + id: p.id, 293 + user_id: p.user_id, 294 + credential_id: p.credential_id, 295 + public_key: p.public_key, 296 + counter: p.counter, 297 + transports: p.transports, 298 + name: p.name, 299 + created_at: Math.floor(p.created_at.getTime() / 1000), 300 + last_used_at: p.last_used_at 301 + ? Math.floor(p.last_used_at.getTime() / 1000) 302 + : null, 303 + })); 304 + } 305 + 306 + /** 307 + * Delete a passkey 308 + */ 309 + export function deletePasskey(passkeyId: string, userId: number): boolean { 310 + const result = db 311 + .delete(passkeys) 312 + .where(eq(passkeys.id, passkeyId)) 313 + .run(); 314 + 315 + return result.changes > 0; 316 + } 317 + 318 + /** 319 + * Update passkey name 320 + */ 321 + export function updatePasskeyName( 322 + passkeyId: string, 323 + userId: number, 324 + name: string, 325 + ): boolean { 326 + const result = db 327 + .update(passkeys) 328 + .set({ name }) 329 + .where(eq(passkeys.id, passkeyId)) 330 + .run(); 331 + 332 + return result.changes > 0; 333 + }
+78
src/pages/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Tacy Stack</title> 7 + <link 8 + rel="icon" 9 + href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🥞</text></svg>" 10 + /> 11 + <link rel="stylesheet" href="../styles/main.css" /> 12 + <link rel="stylesheet" href="../styles/index.css" /> 13 + </head> 14 + 15 + <body> 16 + <header> 17 + <div class="header-content"> 18 + <a href="/" class="site-title"> 🥞 Tacy Stack </a> 19 + <auth-component></auth-component> 20 + </div> 21 + </header> 22 + 23 + <main> 24 + <h1 class="hero-title">Tacy Stack</h1> 25 + <p class="hero-subtitle"> 26 + The best way to build detailed web apps on top of Bun 27 + </p> 28 + 29 + <counter-component count="0"></counter-component> 30 + 31 + <h2>Features</h2> 32 + <p> 33 + Passkey authentication, user-specific data persistence, reactive UI with 34 + Lit web components, fast development with Bun and hot module reloading, 35 + type-safe database queries with Drizzle ORM, and a clean, minimal design 36 + system. 37 + </p> 38 + </main> 39 + 40 + <script type="module" src="../components/auth.ts"></script> 41 + <script type="module" src="../components/counter.ts"></script> 42 + <script type="module"> 43 + // Load user's counter value and set disabled state 44 + async function loadCounter() { 45 + try { 46 + const authResponse = await fetch("/api/auth/me"); 47 + const counter = document.querySelector("counter-component"); 48 + 49 + if (authResponse.ok) { 50 + // User is logged in, load their counter 51 + const response = await fetch("/api/counter"); 52 + if (response.ok) { 53 + const data = await response.json(); 54 + if (counter) { 55 + counter.count = data.count; 56 + counter.disabled = false; 57 + } 58 + } 59 + } else { 60 + // User not logged in, disable counter 61 + if (counter) { 62 + counter.disabled = true; 63 + counter.count = 0; 64 + } 65 + } 66 + } catch (error) { 67 + console.error("Failed to load counter:", error); 68 + const counter = document.querySelector("counter-component"); 69 + if (counter) { 70 + counter.disabled = true; 71 + } 72 + } 73 + } 74 + 75 + loadCounter(); 76 + </script> 77 + </body> 78 + </html>
+59
src/styles/buttons.css
··· 1 + /* Shared button styles for consistent UI across components */ 2 + 3 + .btn { 4 + padding: 0.75rem 1.5rem; 5 + border-radius: 6px; 6 + font-size: 1rem; 7 + font-weight: 500; 8 + cursor: pointer; 9 + transition: all 0.2s; 10 + font-family: inherit; 11 + border: 2px solid; 12 + } 13 + 14 + .btn:disabled { 15 + opacity: 0.5; 16 + cursor: not-allowed; 17 + } 18 + 19 + /* Affirmative actions (submit, save, confirm) */ 20 + .btn-affirmative { 21 + background: var(--primary); 22 + color: white; 23 + border-color: var(--primary); 24 + } 25 + 26 + .btn-affirmative:hover:not(:disabled) { 27 + background: transparent; 28 + color: var(--primary); 29 + } 30 + 31 + /* Neutral actions (cancel, close) */ 32 + .btn-neutral { 33 + background: transparent; 34 + color: var(--text); 35 + border-color: var(--secondary); 36 + } 37 + 38 + .btn-neutral:hover:not(:disabled) { 39 + border-color: var(--primary); 40 + color: var(--primary); 41 + } 42 + 43 + /* Rejection/destructive actions (delete, logout) */ 44 + .btn-rejection { 45 + background: transparent; 46 + color: var(--accent); 47 + border-color: var(--accent); 48 + } 49 + 50 + .btn-rejection:hover:not(:disabled) { 51 + background: var(--accent); 52 + color: white; 53 + } 54 + 55 + /* Small button variant */ 56 + .btn-small { 57 + padding: 0.5rem 1rem; 58 + font-size: 0.875rem; 59 + }
+33
src/styles/header.css
··· 1 + /* Header styles shared across all pages */ 2 + 3 + header { 4 + position: sticky; 5 + top: 0; 6 + z-index: 1000; 7 + background: var(--background); 8 + border-bottom: 2px solid var(--secondary); 9 + padding: 1rem 2rem; 10 + margin: -2rem -2rem 2rem -2rem; 11 + } 12 + 13 + .header-content { 14 + max-width: 1200px; 15 + margin: 0 auto; 16 + display: flex; 17 + justify-content: space-between; 18 + align-items: center; 19 + } 20 + 21 + .site-title { 22 + font-size: 1.5rem; 23 + font-weight: 600; 24 + color: var(--text); 25 + text-decoration: none; 26 + display: flex; 27 + align-items: center; 28 + gap: 0.5rem; 29 + } 30 + 31 + .site-title:hover { 32 + color: var(--primary); 33 + }
+71
src/styles/index.css
··· 1 + .hero-title { 2 + font-size: 3rem; 3 + font-weight: 700; 4 + color: var(--text); 5 + margin-bottom: 1rem; 6 + } 7 + 8 + .hero-subtitle { 9 + font-size: 1.25rem; 10 + color: var(--text); 11 + opacity: 0.8; 12 + margin-bottom: 2rem; 13 + } 14 + 15 + main { 16 + text-align: center; 17 + padding: 4rem 2rem; 18 + } 19 + 20 + .cta-buttons { 21 + display: flex; 22 + gap: 1rem; 23 + justify-content: center; 24 + margin-top: 2rem; 25 + } 26 + 27 + .btn { 28 + padding: 0.75rem 1.5rem; 29 + border-radius: 6px; 30 + font-size: 1rem; 31 + font-weight: 500; 32 + cursor: pointer; 33 + transition: all 0.2s; 34 + font-family: inherit; 35 + border: 2px solid; 36 + text-decoration: none; 37 + display: inline-block; 38 + } 39 + 40 + .btn-primary { 41 + background: var(--primary); 42 + color: white; 43 + border-color: var(--primary); 44 + } 45 + 46 + .btn-primary:hover { 47 + background: transparent; 48 + color: var(--primary); 49 + } 50 + 51 + .btn-secondary { 52 + background: transparent; 53 + color: var(--text); 54 + border-color: var(--secondary); 55 + } 56 + 57 + .btn-secondary:hover { 58 + border-color: var(--primary); 59 + color: var(--primary); 60 + } 61 + 62 + @media (max-width: 640px) { 63 + .hero-title { 64 + font-size: 2.5rem; 65 + } 66 + 67 + .cta-buttons { 68 + flex-direction: column; 69 + align-items: center; 70 + } 71 + }
+76
src/styles/main.css
··· 1 + @import url("./buttons.css"); 2 + @import url("./header.css"); 3 + 4 + :root { 5 + /* Color palette */ 6 + --gunmetal: #2d3142ff; 7 + --paynes-gray: #4f5d75ff; 8 + --silver: #bfc0c0ff; 9 + --white: #ffffffff; 10 + --off-white: #fcf6f1; 11 + --coral: #ef8354ff; 12 + --success-green: #16a34a; 13 + 14 + /* Semantic color assignments */ 15 + --text: var(--gunmetal); 16 + --background: var(--off-white); 17 + --primary: var(--paynes-gray); 18 + --secondary: var(--silver); 19 + --accent: var(--coral); 20 + --success: var(--success-green); 21 + } 22 + 23 + body { 24 + font-family: "Charter", "Bitstream Charter", "Sitka Text", Cambria, serif; 25 + font-weight: 400; 26 + margin: 0; 27 + padding: 2rem; 28 + line-height: 1.6; 29 + background: var(--background); 30 + color: var(--text); 31 + } 32 + 33 + h1, 34 + h2, 35 + h3, 36 + h4, 37 + h5 { 38 + font-family: "Charter", "Bitstream Charter", "Sitka Text", Cambria, serif; 39 + font-weight: 600; 40 + line-height: 1.2; 41 + color: var(--text); 42 + } 43 + 44 + html { 45 + font-size: 100%; 46 + } 47 + 48 + h1 { 49 + font-size: 4.21rem; 50 + margin-top: 0; 51 + } 52 + 53 + h2 { 54 + font-size: 3.158rem; 55 + } 56 + 57 + h3 { 58 + font-size: 2.369rem; 59 + } 60 + 61 + h4 { 62 + font-size: 1.777rem; 63 + } 64 + 65 + h5 { 66 + font-size: 1.333rem; 67 + } 68 + 69 + small { 70 + font-size: 0.75rem; 71 + } 72 + 73 + main { 74 + margin: 0 auto; 75 + max-width: 48rem; 76 + }
+33
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext", "DOM", "DOM.Iterable"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "preserve", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Decorators 18 + "experimentalDecorators": true, 19 + "useDefineForClassFields": false, 20 + 21 + // Best practices 22 + "strict": true, 23 + "skipLibCheck": true, 24 + "noFallthroughCasesInSwitch": true, 25 + "noUncheckedIndexedAccess": true, 26 + "noImplicitOverride": true, 27 + 28 + // Some stricter flags (disabled by default) 29 + "noUnusedLocals": false, 30 + "noUnusedParameters": false, 31 + "noPropertyAccessFromIndexSignature": false 32 + } 33 + }