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