[READ-ONLY] Mirror of https://github.com/mrgnw/anahtar. Svelte auth with passkey / email+otp
0

Configure Feed

Select the types of activity you want to include in your feed.

Create phase1-scaffold.md

+250
+250
.opencode/plans/phase1-scaffold.md
··· 1 + # Phase 1: Scaffold + Types 2 + 3 + ## Summary 4 + Set up the package structure, all shared types, and config. This is the foundation that all other phases depend on. 5 + 6 + ## Files to create 7 + 8 + ### package.json 9 + - name: `@mrgnw/anahtar` 10 + - type: module 11 + - scripts: build (svelte-package), check (svelte-check), test (vitest) 12 + - exports: `.` (main), `./sqlite`, `./postgres`, `./components` 13 + - deps: `@oslojs/crypto`, `@oslojs/encoding`, `@simplewebauthn/server` 14 + - peerDeps: `svelte ^5`, `@sveltejs/kit ^2` 15 + - devDeps: `@sveltejs/package`, `svelte-check`, `typescript`, `vitest`, `@types/better-sqlite3` 16 + 17 + ### tsconfig.json 18 + - extends `.svelte-kit/tsconfig.json` 19 + - strict, ESNext module/target, bundler moduleResolution 20 + 21 + ### svelte.config.js 22 + - vitePreprocess, kit.files.lib = 'src/lib' 23 + 24 + ### vite.config.ts 25 + - sveltekit plugin, vitest include `src/**/*.test.ts` 26 + 27 + ### src/lib/types.ts 28 + 29 + ```ts 30 + export interface AuthUser { 31 + id: string; 32 + email: string; 33 + skipPasskeyPrompt: boolean; 34 + createdAt: number; 35 + } 36 + 37 + export interface SessionRecord { 38 + id: string; 39 + userId: string; 40 + expiresAt: number; 41 + } 42 + 43 + export interface OTPRecord { 44 + id: string; 45 + email: string; 46 + code: string; 47 + attempts: number; 48 + expiresAt: number; 49 + } 50 + 51 + export interface PasskeyRecord { 52 + id: string; 53 + credentialId: string; 54 + publicKey: Uint8Array; 55 + counter: number; 56 + transports: string | null; 57 + createdAt: number; 58 + } 59 + 60 + export interface FullPasskeyRecord extends PasskeyRecord { 61 + userId: string; 62 + email: string; 63 + } 64 + 65 + export interface NewPasskey { 66 + id: string; 67 + userId: string; 68 + credentialId: string; 69 + publicKey: Uint8Array; 70 + counter: number; 71 + transports: string | null; 72 + } 73 + 74 + export type OtpResult = 75 + | { ok: true } 76 + | { ok: false; error: 'invalid' } 77 + | { ok: false; error: 'expired' } 78 + | { ok: false; error: 'rate_limited'; attemptsLeft: number }; 79 + 80 + export interface AuthDB { 81 + init(): void; 82 + 83 + // Users 84 + getUserByEmail(email: string): AuthUser | null; 85 + createUser(email: string): AuthUser; 86 + setSkipPasskeyPrompt(userId: string, skip: boolean): void; 87 + 88 + // Sessions 89 + createSession(tokenHash: string, userId: string, expiresAt: number): void; 90 + getSession(tokenHash: string): (SessionRecord & { email: string }) | null; 91 + deleteSession(tokenHash: string): void; 92 + 93 + // OTP 94 + storeOTP(email: string, id: string, code: string, expiresAt: number): void; 95 + getLatestOTP(email: string): OTPRecord | null; 96 + updateOTPAttempts(id: string, attempts: number): void; 97 + deleteOTP(id: string): void; 98 + deleteOTPsForEmail(email: string): void; 99 + 100 + // Passkeys + challenges 101 + storeChallenge(challenge: string, userId: string, expiresAt: number): void; 102 + consumeChallenge(challenge: string): { userId: string } | null; 103 + getPasskeyByCredentialId(credentialId: string): FullPasskeyRecord | null; 104 + getUserPasskeys(userId: string): PasskeyRecord[]; 105 + storePasskey(passkey: NewPasskey): void; 106 + updatePasskeyCounter(id: string, counter: number): void; 107 + deletePasskey(id: string, userId: string): boolean; 108 + } 109 + 110 + export interface AuthConfig { 111 + db: AuthDB; 112 + tablePrefix?: string; 113 + cookie?: string; 114 + sessionDuration?: number; 115 + otpExpiry?: number; 116 + otpLength?: number; 117 + otpMaxAttempts?: number; 118 + rpName?: string; 119 + onSendOTP: (email: string, code: string) => Promise<void>; 120 + } 121 + 122 + export interface ResolvedConfig extends Required<Omit<AuthConfig, 'onSendOTP'>> { 123 + onSendOTP: (email: string, code: string) => Promise<void>; 124 + } 125 + ``` 126 + 127 + ### src/lib/config.ts 128 + 129 + ```ts 130 + import type { AuthConfig, ResolvedConfig } from './types.js'; 131 + 132 + const DEFAULTS = { 133 + tablePrefix: 'auth_', 134 + cookie: 'session', 135 + sessionDuration: 30 * 24 * 60 * 60 * 1000, 136 + otpExpiry: 30 * 60 * 1000, 137 + otpLength: 5, 138 + otpMaxAttempts: 5, 139 + rpName: 'anahtar', 140 + } as const; 141 + 142 + export function resolveConfig(config: AuthConfig): ResolvedConfig { 143 + return { 144 + ...DEFAULTS, 145 + ...config, 146 + }; 147 + } 148 + ``` 149 + 150 + ### src/lib/index.ts 151 + 152 + ```ts 153 + export type { 154 + AuthUser, 155 + AuthDB, 156 + AuthConfig, 157 + SessionRecord, 158 + OTPRecord, 159 + PasskeyRecord, 160 + OtpResult, 161 + } from './types.js'; 162 + 163 + // createAuth() will be added in Phase 4 164 + ``` 165 + 166 + ## Verify 167 + ```sh 168 + pnpm install 169 + pnpm build 170 + pnpm check 171 + ``` 172 + 173 + --- 174 + 175 + ## Phase 2: Core Logic (3 parallel agents) 176 + 177 + ### Agent A: src/lib/otp.ts 178 + - `generateOTP(db: AuthDB, email: string, config: ResolvedConfig)` — returns `{ id, code }` 179 + - `verifyOTP(db: AuthDB, email: string, code: string, config: ResolvedConfig)` — returns `OtpResult` 180 + - Uses `config.otpExpiry`, `config.otpLength`, `config.otpMaxAttempts` 181 + - Extracted from anani `auth.ts:11-74` 182 + 183 + ### Agent B: src/lib/session.ts 184 + - `createSession(db: AuthDB, userId: string, config: ResolvedConfig)` — returns `{ sessionToken, expiresAt }` 185 + - `validateSession(db: AuthDB, token: string)` — returns `{ user, session } | null` 186 + - `invalidateSession(db: AuthDB, sessionId: string)` — void 187 + - Token hashing: `sha256(tokenBytes)` via `@oslojs/crypto` 188 + - Extracted from anani `auth.ts:97-168` 189 + 190 + ### Agent C: src/lib/passkey.ts 191 + - `getWebAuthnConfig(requestUrl: URL)` — derives rpID/origin from URL or ORIGIN env 192 + - `generateRegistrationChallenge(db, user, requestUrl, config)` 193 + - `verifyRegistrationResponse(db, userId, response, requestUrl, config)` 194 + - `generateAuthenticationChallenge(db, requestUrl, config)` 195 + - `verifyAuthenticationResponse(db, response, requestUrl, config)` 196 + - `getUserPasskeys(db, userId)` 197 + - `removePasskey(db, passkeyId, userId)` 198 + - Extracted from anani `passkey.ts` (250 lines) 199 + 200 + Each agent also writes its own `*.test.ts` using a mock AuthDB. 201 + 202 + --- 203 + 204 + ## Phase 3: DB Adapters 205 + 206 + ### src/lib/db/sqlite.ts — `sqliteAdapter(db: BetterSqlite3.Database, options?)` 207 + Schema (with table prefix): 208 + ```sql 209 + CREATE TABLE IF NOT EXISTS {prefix}users ( 210 + id TEXT PRIMARY KEY, 211 + email TEXT UNIQUE NOT NULL, 212 + skip_passkey_prompt INTEGER DEFAULT 0, 213 + created_at INTEGER DEFAULT (unixepoch()) 214 + ); 215 + CREATE TABLE IF NOT EXISTS {prefix}sessions (...); 216 + CREATE TABLE IF NOT EXISTS {prefix}otp_codes (...); 217 + CREATE TABLE IF NOT EXISTS {prefix}passkeys (...); 218 + CREATE TABLE IF NOT EXISTS {prefix}challenges (...); 219 + ``` 220 + 221 + ### src/lib/db/postgres.ts — `postgresAdapter(pool: pg.Pool, options?)` 222 + Same interface, PostgreSQL syntax. 223 + 224 + --- 225 + 226 + ## Phase 4: SvelteKit Integration 227 + 228 + ### src/lib/kit/handle.ts 229 + Factory returning SvelteKit `Handle` — reads cookie, validates session, sets `event.locals.user`. 230 + 231 + ### src/lib/kit/handlers.ts 232 + Factory returning `{ GET, POST }` for catch-all route. Parses path to dispatch: 233 + - POST start → generateOTP + onSendOTP 234 + - POST verify → verifyOTP + createUser + createSession + set cookie 235 + - POST logout → invalidateSession + delete cookie 236 + - GET passkey/login-start → generateAuthenticationChallenge 237 + - POST passkey/login-finish → verifyAuthenticationResponse + createSession 238 + - POST passkey/register-start → generateRegistrationChallenge (requires auth) 239 + - POST passkey/register-finish → verifyRegistrationResponse (requires auth) 240 + - POST passkey/remove → removePasskey (requires auth) 241 + - POST skip-passkey → setSkipPasskeyPrompt (requires auth) 242 + 243 + ### src/lib/index.ts — `createAuth(config)` returns `{ handle, handlers }` 244 + 245 + --- 246 + 247 + ## Phase 5: Svelte UI Components 248 + - AuthFlow.svelte — full email→OTP→passkey flow 249 + - OtpInput.svelte — 5-digit input with auto-advance 250 + - PasskeyPrompt.svelte — countdown + registration trigger