@ursalock/server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1048 @@
1
+ // src/env.ts
2
+ import { z } from "zod";
3
+ var numeric = z.string().transform((val) => Number.parseInt(val, 10));
4
+ var envSchema = {
5
+ NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
6
+ PORT: numeric.default("3456"),
7
+ /** SQLite database file path */
8
+ DATABASE_PATH: z.string().default("./data/vault.db"),
9
+ /** JWT secret for signing tokens (32+ bytes recommended) */
10
+ JWT_SECRET: z.string().min(32),
11
+ /** JWT token expiry in seconds (default: 7 days) */
12
+ JWT_EXPIRY: numeric.default("604800"),
13
+ /** Relying Party ID for WebAuthn (your domain) */
14
+ RP_ID: z.string().default("localhost"),
15
+ /** Relying Party name shown during passkey registration */
16
+ RP_NAME: z.string().default("zod-vault"),
17
+ /** Expected origin for WebAuthn (e.g., https://app.example.com) */
18
+ RP_ORIGIN: z.string().url().default("http://localhost:5173")
19
+ };
20
+ var skipEnvValidation = process.env["NODE_ENV"] === "test";
21
+ var env = (() => {
22
+ if (skipEnvValidation) {
23
+ return Object.fromEntries(
24
+ Object.entries(envSchema).map(([key, schema]) => {
25
+ const result = schema.safeParse(process.env[key]);
26
+ return [key, result.success ? result.data : void 0];
27
+ })
28
+ );
29
+ }
30
+ const parsed = z.object(envSchema).safeParse(process.env);
31
+ if (!parsed.success) {
32
+ const errors2 = parsed.error.errors.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
33
+ throw new Error(`Invalid environment variables:
34
+ ${errors2}`);
35
+ }
36
+ return parsed.data;
37
+ })();
38
+
39
+ // src/db/client.ts
40
+ import Database from "better-sqlite3";
41
+
42
+ // src/db/schema.ts
43
+ var CREATE_TABLES_SQL = `
44
+ -- Users table
45
+ CREATE TABLE IF NOT EXISTS users (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ uid TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
48
+ email TEXT UNIQUE,
49
+ password_hash TEXT,
50
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
51
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
55
+ CREATE INDEX IF NOT EXISTS idx_users_uid ON users(uid);
56
+
57
+ -- Passkeys table (WebAuthn credentials)
58
+ CREATE TABLE IF NOT EXISTS passkeys (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
61
+ credential_id TEXT NOT NULL UNIQUE,
62
+ public_key TEXT NOT NULL,
63
+ counter INTEGER NOT NULL DEFAULT 0,
64
+ device_type TEXT NOT NULL DEFAULT 'singleDevice',
65
+ backed_up INTEGER NOT NULL DEFAULT 0,
66
+ transports TEXT,
67
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id);
71
+ CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id);
72
+
73
+ -- Sessions table
74
+ CREATE TABLE IF NOT EXISTS sessions (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
77
+ token_hash TEXT NOT NULL UNIQUE,
78
+ expires_at INTEGER NOT NULL,
79
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
80
+ );
81
+
82
+ CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
83
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
84
+
85
+ -- Vaults table (encrypted blobs)
86
+ CREATE TABLE IF NOT EXISTS vaults (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ uid TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
89
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
90
+ name TEXT NOT NULL,
91
+ data TEXT NOT NULL,
92
+ salt TEXT NOT NULL,
93
+ version INTEGER NOT NULL DEFAULT 1,
94
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
95
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
96
+ );
97
+
98
+ CREATE INDEX IF NOT EXISTS idx_vaults_uid ON vaults(uid);
99
+ CREATE INDEX IF NOT EXISTS idx_vaults_user_id ON vaults(user_id);
100
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_vaults_user_name ON vaults(user_id, name);
101
+ `;
102
+
103
+ // src/db/client.ts
104
+ var _db = null;
105
+ function getDb() {
106
+ if (!_db) {
107
+ _db = new Database(env.DATABASE_PATH);
108
+ _db.pragma("journal_mode = WAL");
109
+ _db.pragma("foreign_keys = ON");
110
+ _db.exec(CREATE_TABLES_SQL);
111
+ }
112
+ return _db;
113
+ }
114
+ function closeDb() {
115
+ if (_db) {
116
+ _db.close();
117
+ _db = null;
118
+ }
119
+ }
120
+ function createUser(input) {
121
+ const db = getDb();
122
+ const stmt = db.prepare(`
123
+ INSERT INTO users (email, password_hash)
124
+ VALUES (?, ?)
125
+ RETURNING id, uid, email, password_hash as passwordHash, created_at as createdAt, updated_at as updatedAt
126
+ `);
127
+ return stmt.get(input.email ?? null, input.passwordHash ?? null);
128
+ }
129
+ function getUserByEmail(email) {
130
+ const db = getDb();
131
+ const stmt = db.prepare(`
132
+ SELECT id, uid, email, password_hash as passwordHash, created_at as createdAt, updated_at as updatedAt
133
+ FROM users WHERE email = ?
134
+ `);
135
+ return stmt.get(email);
136
+ }
137
+ function createPasskey(input) {
138
+ const db = getDb();
139
+ const stmt = db.prepare(`
140
+ INSERT INTO passkeys (user_id, credential_id, public_key, counter, device_type, backed_up, transports)
141
+ VALUES (?, ?, ?, ?, ?, ?, ?)
142
+ RETURNING id, user_id as userId, credential_id as credentialId, public_key as publicKey,
143
+ counter, device_type as deviceType, backed_up as backedUp, transports, created_at as createdAt
144
+ `);
145
+ const result = stmt.get(
146
+ input.userId,
147
+ input.credentialId,
148
+ input.publicKey,
149
+ input.counter,
150
+ input.deviceType,
151
+ input.backedUp ? 1 : 0,
152
+ input.transports?.join(",") ?? null
153
+ );
154
+ return { ...result, backedUp: Boolean(result.backedUp) };
155
+ }
156
+ function getPasskeyByCredentialId(credentialId) {
157
+ const db = getDb();
158
+ const stmt = db.prepare(`
159
+ SELECT
160
+ p.id, p.user_id as userId, p.credential_id as credentialId, p.public_key as publicKey,
161
+ p.counter, p.device_type as deviceType, p.backed_up as backedUp, p.transports, p.created_at as createdAt,
162
+ u.id as "user.id", u.uid as "user.uid", u.email as "user.email"
163
+ FROM passkeys p
164
+ JOIN users u ON p.user_id = u.id
165
+ WHERE p.credential_id = ?
166
+ `);
167
+ const row = stmt.get(credentialId);
168
+ if (!row) return void 0;
169
+ return {
170
+ id: row["id"],
171
+ userId: row["userId"],
172
+ credentialId: row["credentialId"],
173
+ publicKey: row["publicKey"],
174
+ counter: row["counter"],
175
+ deviceType: row["deviceType"],
176
+ backedUp: Boolean(row["backedUp"]),
177
+ transports: row["transports"],
178
+ createdAt: row["createdAt"],
179
+ user: {
180
+ id: row["user.id"],
181
+ uid: row["user.uid"],
182
+ email: row["user.email"],
183
+ passwordHash: null,
184
+ createdAt: 0,
185
+ updatedAt: 0
186
+ }
187
+ };
188
+ }
189
+ function getPasskeysByUserId(userId) {
190
+ const db = getDb();
191
+ const stmt = db.prepare(`
192
+ SELECT id, user_id as userId, credential_id as credentialId, public_key as publicKey,
193
+ counter, device_type as deviceType, backed_up as backedUp, transports, created_at as createdAt
194
+ FROM passkeys WHERE user_id = ?
195
+ `);
196
+ return stmt.all(userId).map((p) => ({ ...p, backedUp: Boolean(p.backedUp) }));
197
+ }
198
+ function updatePasskeyCounter(credentialId, counter) {
199
+ const db = getDb();
200
+ const stmt = db.prepare(`UPDATE passkeys SET counter = ? WHERE credential_id = ?`);
201
+ stmt.run(counter, credentialId);
202
+ }
203
+ function createSession(input) {
204
+ const db = getDb();
205
+ const stmt = db.prepare(`
206
+ INSERT INTO sessions (user_id, token_hash, expires_at)
207
+ VALUES (?, ?, ?)
208
+ RETURNING id, user_id as userId, token_hash as tokenHash, expires_at as expiresAt, created_at as createdAt
209
+ `);
210
+ return stmt.get(input.userId, input.tokenHash, input.expiresAt);
211
+ }
212
+ function getSessionByTokenHash(tokenHash) {
213
+ const db = getDb();
214
+ const stmt = db.prepare(`
215
+ SELECT
216
+ s.id, s.user_id as userId, s.token_hash as tokenHash, s.expires_at as expiresAt, s.created_at as createdAt,
217
+ u.id as "user.id", u.uid as "user.uid", u.email as "user.email"
218
+ FROM sessions s
219
+ JOIN users u ON s.user_id = u.id
220
+ WHERE s.token_hash = ? AND s.expires_at > unixepoch()
221
+ `);
222
+ const row = stmt.get(tokenHash);
223
+ if (!row) return void 0;
224
+ return {
225
+ id: row["id"],
226
+ userId: row["userId"],
227
+ tokenHash: row["tokenHash"],
228
+ expiresAt: row["expiresAt"],
229
+ createdAt: row["createdAt"],
230
+ user: {
231
+ id: row["user.id"],
232
+ uid: row["user.uid"],
233
+ email: row["user.email"],
234
+ passwordHash: null,
235
+ createdAt: 0,
236
+ updatedAt: 0
237
+ }
238
+ };
239
+ }
240
+ function deleteSession(tokenHash) {
241
+ const db = getDb();
242
+ const stmt = db.prepare(`DELETE FROM sessions WHERE token_hash = ?`);
243
+ stmt.run(tokenHash);
244
+ }
245
+ function createVault(input) {
246
+ const db = getDb();
247
+ const stmt = db.prepare(`
248
+ INSERT INTO vaults (user_id, name, data, salt, version)
249
+ VALUES (?, ?, ?, ?, ?)
250
+ RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
251
+ `);
252
+ return stmt.get(input.userId, input.name, input.data, input.salt, input.version ?? 1);
253
+ }
254
+ function getVaultByUid(uid, userId) {
255
+ const db = getDb();
256
+ const stmt = db.prepare(`
257
+ SELECT id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
258
+ FROM vaults WHERE uid = ? AND user_id = ?
259
+ `);
260
+ return stmt.get(uid, userId);
261
+ }
262
+ function getVaultByName(name, userId) {
263
+ const db = getDb();
264
+ const stmt = db.prepare(`
265
+ SELECT id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
266
+ FROM vaults WHERE name = ? AND user_id = ?
267
+ `);
268
+ return stmt.get(name, userId);
269
+ }
270
+ function getVaultsByUserId(userId) {
271
+ const db = getDb();
272
+ const stmt = db.prepare(`
273
+ SELECT id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
274
+ FROM vaults WHERE user_id = ?
275
+ ORDER BY updated_at DESC
276
+ `);
277
+ return stmt.all(userId);
278
+ }
279
+ function updateVault(uid, userId, input) {
280
+ const db = getDb();
281
+ const stmt = db.prepare(`
282
+ UPDATE vaults SET data = ?, salt = ?, version = COALESCE(?, version), updated_at = unixepoch()
283
+ WHERE uid = ? AND user_id = ?
284
+ RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
285
+ `);
286
+ return stmt.get(input.data, input.salt, input.version ?? null, uid, userId);
287
+ }
288
+ function deleteVault(uid, userId) {
289
+ const db = getDb();
290
+ const stmt = db.prepare(`DELETE FROM vaults WHERE uid = ? AND user_id = ?`);
291
+ return stmt.run(uid, userId).changes > 0;
292
+ }
293
+
294
+ // src/features/auth/jwt.ts
295
+ import { createHash } from "crypto";
296
+ import { SignJWT, jwtVerify } from "jose";
297
+ function getSecretKey() {
298
+ return new TextEncoder().encode(env.JWT_SECRET);
299
+ }
300
+ async function createToken(payload) {
301
+ const jti = crypto.randomUUID();
302
+ const jwt = new SignJWT({ email: payload.email }).setSubject(payload.userId).setJti(jti).setIssuedAt().setExpirationTime(`${env.JWT_EXPIRY}s`).setProtectedHeader({ alg: "HS256" });
303
+ return jwt.sign(getSecretKey());
304
+ }
305
+ async function verifyToken(token) {
306
+ try {
307
+ const { payload } = await jwtVerify(token, getSecretKey());
308
+ if (!payload.sub) {
309
+ return null;
310
+ }
311
+ return payload;
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+ function hashToken(token) {
317
+ return createHash("sha256").update(token).digest("hex");
318
+ }
319
+
320
+ // src/errors.ts
321
+ import { z as z2 } from "zod";
322
+ var ErrorCode = z2.enum([
323
+ // Auth errors
324
+ "unauthorized",
325
+ "invalid_credentials",
326
+ "email_already_exists",
327
+ "passkey_not_found",
328
+ "session_expired",
329
+ // Vault errors
330
+ "vault_not_found",
331
+ "vault_already_exists",
332
+ "invalid_vault_data",
333
+ // Validation errors
334
+ "validation_error",
335
+ "invalid_request",
336
+ // Server errors
337
+ "internal_error"
338
+ ]);
339
+ var ApiException = class extends Error {
340
+ constructor(error, status = 400) {
341
+ super(error.message);
342
+ this.error = error;
343
+ this.status = status;
344
+ this.name = "ApiException";
345
+ }
346
+ };
347
+ var errors = {
348
+ // Auth errors
349
+ unauthorized: { code: "unauthorized", message: "Unauthorized" },
350
+ invalid_credentials: { code: "invalid_credentials", message: "Invalid email or password" },
351
+ email_already_exists: { code: "email_already_exists", message: "Email already registered" },
352
+ passkey_not_found: { code: "passkey_not_found", message: "Passkey not found" },
353
+ session_expired: { code: "session_expired", message: "Session expired" },
354
+ // Vault errors
355
+ vault_not_found: { code: "vault_not_found", message: "Vault not found" },
356
+ vault_already_exists: (name) => ({
357
+ code: "vault_already_exists",
358
+ message: `Vault "${name}" already exists`
359
+ }),
360
+ invalid_vault_data: { code: "invalid_vault_data", message: "Invalid vault data" },
361
+ // Validation errors
362
+ validation_error: (details) => ({
363
+ code: "validation_error",
364
+ message: details
365
+ }),
366
+ invalid_request: { code: "invalid_request", message: "Invalid request" },
367
+ // Server errors
368
+ internal_error: { code: "internal_error", message: "Internal server error" }
369
+ };
370
+ function getError(code, arg) {
371
+ const factory = errors[code];
372
+ if (typeof factory === "function") {
373
+ return factory(arg ?? "");
374
+ }
375
+ return factory;
376
+ }
377
+
378
+ // src/features/auth/middleware.ts
379
+ import { createMiddleware } from "hono/factory";
380
+ var optionalAuthMiddleware = createMiddleware(async (c, next) => {
381
+ const authHeader = c.req.header("Authorization");
382
+ if (!authHeader?.startsWith("Bearer ")) {
383
+ c.set("session", null);
384
+ return next();
385
+ }
386
+ const token = authHeader.slice(7);
387
+ const payload = await verifyToken(token);
388
+ if (!payload?.sub) {
389
+ c.set("session", null);
390
+ return next();
391
+ }
392
+ const tokenHash = hashToken(token);
393
+ const session = getSessionByTokenHash(tokenHash);
394
+ if (!session) {
395
+ c.set("session", null);
396
+ return next();
397
+ }
398
+ c.set("session", {
399
+ user: {
400
+ id: session.user.id,
401
+ uid: session.user.uid,
402
+ email: session.user.email
403
+ },
404
+ sessionId: session.id
405
+ });
406
+ return next();
407
+ });
408
+ var requireAuthMiddleware = createMiddleware(async (c, next) => {
409
+ const authHeader = c.req.header("Authorization");
410
+ if (!authHeader?.startsWith("Bearer ")) {
411
+ throw new ApiException(getError("unauthorized"), 401);
412
+ }
413
+ const token = authHeader.slice(7);
414
+ const payload = await verifyToken(token);
415
+ if (!payload?.sub) {
416
+ throw new ApiException(getError("unauthorized"), 401);
417
+ }
418
+ const tokenHash = hashToken(token);
419
+ const session = getSessionByTokenHash(tokenHash);
420
+ if (!session) {
421
+ throw new ApiException(getError("session_expired"), 401);
422
+ }
423
+ c.set("session", {
424
+ user: {
425
+ id: session.user.id,
426
+ uid: session.user.uid,
427
+ email: session.user.email
428
+ },
429
+ sessionId: session.id
430
+ });
431
+ return next();
432
+ });
433
+
434
+ // src/api/schemas.ts
435
+ import { z as z3 } from "zod";
436
+ var UserResponse = z3.object({
437
+ id: z3.string(),
438
+ email: z3.string().email().nullable(),
439
+ createdAt: z3.number()
440
+ });
441
+ var EmailRegisterRequest = z3.object({
442
+ email: z3.string().email(),
443
+ password: z3.string().min(8, "Password must be at least 8 characters")
444
+ });
445
+ var EmailLoginRequest = z3.object({
446
+ email: z3.string().email(),
447
+ password: z3.string().min(1)
448
+ });
449
+ var AuthResponse = z3.object({
450
+ user: UserResponse,
451
+ token: z3.string(),
452
+ recoveryKey: z3.string().optional()
453
+ });
454
+ var RefreshResponse = z3.object({
455
+ token: z3.string(),
456
+ expiresIn: z3.number()
457
+ });
458
+ var MeResponse = z3.object({
459
+ user: UserResponse
460
+ });
461
+ var PasskeyRegisterOptionsRequest = z3.object({
462
+ email: z3.string().email().optional()
463
+ });
464
+ var PasskeyRegisterVerifyRequest = z3.object({
465
+ email: z3.string().email().optional(),
466
+ credential: z3.record(z3.unknown())
467
+ });
468
+ var PasskeyLoginOptionsRequest = z3.object({
469
+ email: z3.string().email().optional()
470
+ });
471
+ var PasskeyLoginVerifyRequest = z3.object({
472
+ credential: z3.record(z3.unknown())
473
+ });
474
+ var PasskeyCheckResponse = z3.object({
475
+ hasPasskey: z3.boolean()
476
+ });
477
+ var CreateVaultRequest = z3.object({
478
+ name: z3.string().min(1).max(255),
479
+ data: z3.string(),
480
+ // Encrypted blob (base64)
481
+ salt: z3.string()
482
+ // Salt (base64)
483
+ });
484
+ var UpdateVaultRequest = z3.object({
485
+ data: z3.string(),
486
+ salt: z3.string(),
487
+ version: z3.number().optional()
488
+ });
489
+ var VaultResponse = z3.object({
490
+ uid: z3.string(),
491
+ name: z3.string(),
492
+ data: z3.string(),
493
+ salt: z3.string(),
494
+ version: z3.number(),
495
+ updatedAt: z3.number()
496
+ });
497
+ var VaultsListResponse = z3.object({
498
+ vaults: z3.array(VaultResponse)
499
+ });
500
+
501
+ // src/app.ts
502
+ import { Hono as Hono4 } from "hono";
503
+ import { cors } from "hono/cors";
504
+ import { logger } from "hono/logger";
505
+ import { ZodError } from "zod";
506
+
507
+ // src/api/auth/router.ts
508
+ import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
509
+ import { Hono as Hono2 } from "hono";
510
+ import { zValidator as zValidator2 } from "@hono/zod-validator";
511
+
512
+ // src/api/auth/passkey.ts
513
+ import { Hono } from "hono";
514
+ import { zValidator } from "@hono/zod-validator";
515
+ import { z as z4 } from "zod";
516
+ import {
517
+ generateRegistrationOptions,
518
+ verifyRegistrationResponse,
519
+ generateAuthenticationOptions,
520
+ verifyAuthenticationResponse
521
+ } from "@simplewebauthn/server";
522
+
523
+ // src/features/crypto/recovery.ts
524
+ import { randomBytes } from "crypto";
525
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
526
+ function generateRecoveryKey() {
527
+ const bytes = randomBytes(32);
528
+ const raw = base32Encode(bytes);
529
+ return formatWithDashes(raw);
530
+ }
531
+ function base32Encode(bytes) {
532
+ let result = "";
533
+ let bits = 0;
534
+ let value = 0;
535
+ for (const byte of bytes) {
536
+ value = value << 8 | byte;
537
+ bits += 8;
538
+ while (bits >= 5) {
539
+ bits -= 5;
540
+ result += BASE32_ALPHABET[value >> bits & 31];
541
+ }
542
+ }
543
+ if (bits > 0) {
544
+ result += BASE32_ALPHABET[value << 5 - bits & 31];
545
+ }
546
+ return result;
547
+ }
548
+ function formatWithDashes(raw) {
549
+ const chunks = [];
550
+ for (let i = 0; i < raw.length; i += 4) {
551
+ chunks.push(raw.slice(i, i + 4));
552
+ }
553
+ return chunks.join("-");
554
+ }
555
+
556
+ // src/api/auth/passkey.ts
557
+ var challengeStore = /* @__PURE__ */ new Map();
558
+ setInterval(() => {
559
+ const now = Date.now();
560
+ for (const [key, value] of challengeStore) {
561
+ if (value.expiresAt < now) {
562
+ challengeStore.delete(key);
563
+ }
564
+ }
565
+ }, 6e4);
566
+ var PasskeyRegisterOptionsRequest2 = z4.object({
567
+ email: z4.string().email().optional()
568
+ });
569
+ var PasskeyRegisterVerifyRequest2 = z4.object({
570
+ email: z4.string().email().optional(),
571
+ credential: z4.any()
572
+ // RegistrationResponseJSON
573
+ });
574
+ var PasskeyLoginOptionsRequest2 = z4.object({
575
+ email: z4.string().email().optional()
576
+ });
577
+ var PasskeyLoginVerifyRequest2 = z4.object({
578
+ credential: z4.any()
579
+ // AuthenticationResponseJSON
580
+ });
581
+ var PasskeyCheckRequest = z4.object({
582
+ email: z4.string().email()
583
+ });
584
+ var passkeyRouter = new Hono().post(
585
+ "/register/options",
586
+ zValidator("json", PasskeyRegisterOptionsRequest2),
587
+ async (c) => {
588
+ const { email } = c.req.valid("json");
589
+ if (email) {
590
+ const existing = getUserByEmail(email);
591
+ if (existing) {
592
+ throw new ApiException(errors.email_already_exists, 409);
593
+ }
594
+ }
595
+ const tempUserId = crypto.randomUUID();
596
+ const options = await generateRegistrationOptions({
597
+ rpName: env.RP_NAME,
598
+ rpID: env.RP_ID,
599
+ userName: email ?? tempUserId,
600
+ userDisplayName: email ?? "Anonymous User",
601
+ attestationType: "none",
602
+ authenticatorSelection: {
603
+ residentKey: "preferred",
604
+ userVerification: "preferred",
605
+ authenticatorAttachment: "platform"
606
+ },
607
+ timeout: 6e4
608
+ });
609
+ const challengeKey = email ?? tempUserId;
610
+ challengeStore.set(challengeKey, {
611
+ challenge: options.challenge,
612
+ expiresAt: Date.now() + 12e4
613
+ // 2 min expiry
614
+ });
615
+ return c.json(options);
616
+ }
617
+ ).post(
618
+ "/register/verify",
619
+ zValidator("json", PasskeyRegisterVerifyRequest2),
620
+ async (c) => {
621
+ const { email, credential } = c.req.valid("json");
622
+ const response = credential;
623
+ const challengeKey = email ?? response.id;
624
+ const stored = challengeStore.get(challengeKey);
625
+ if (!stored) {
626
+ throw new ApiException(errors.invalid_credentials, 401);
627
+ }
628
+ if (email) {
629
+ const existing = getUserByEmail(email);
630
+ if (existing) {
631
+ throw new ApiException(errors.email_already_exists, 409);
632
+ }
633
+ }
634
+ try {
635
+ const verification = await verifyRegistrationResponse({
636
+ response,
637
+ expectedChallenge: stored.challenge,
638
+ expectedOrigin: env.RP_ORIGIN,
639
+ expectedRPID: env.RP_ID
640
+ });
641
+ if (!verification.verified || !verification.registrationInfo) {
642
+ throw new ApiException(errors.invalid_credentials, 401);
643
+ }
644
+ const { credentialID, credentialPublicKey, counter, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
645
+ const user = createUser({ email: email ?? void 0, passwordHash: void 0 });
646
+ createPasskey({
647
+ userId: user.id,
648
+ credentialId: credentialID,
649
+ publicKey: Buffer.from(credentialPublicKey).toString("base64"),
650
+ counter,
651
+ deviceType: credentialDeviceType,
652
+ backedUp: credentialBackedUp,
653
+ transports: response.response.transports
654
+ });
655
+ const recoveryKey = generateRecoveryKey();
656
+ const token = await createToken({ userId: user.uid, email: email ?? void 0 });
657
+ const tokenHash = hashToken(token);
658
+ const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
659
+ createSession({ userId: user.id, tokenHash, expiresAt });
660
+ challengeStore.delete(challengeKey);
661
+ return c.json({
662
+ user: {
663
+ id: user.uid,
664
+ email: user.email,
665
+ createdAt: user.createdAt
666
+ },
667
+ token,
668
+ recoveryKey
669
+ });
670
+ } catch (error) {
671
+ if (error instanceof ApiException) throw error;
672
+ console.error("Passkey registration error:", error);
673
+ throw new ApiException(errors.invalid_credentials, 401);
674
+ }
675
+ }
676
+ ).post(
677
+ "/login/options",
678
+ zValidator("json", PasskeyLoginOptionsRequest2),
679
+ async (c) => {
680
+ const { email } = c.req.valid("json");
681
+ let allowCredentials;
682
+ if (email) {
683
+ const user = getUserByEmail(email);
684
+ if (user) {
685
+ const passkeys = getPasskeysByUserId(user.id);
686
+ allowCredentials = passkeys.map((p) => ({
687
+ id: p.credentialId,
688
+ transports: p.transports?.split(",")
689
+ }));
690
+ }
691
+ }
692
+ const options = await generateAuthenticationOptions({
693
+ rpID: env.RP_ID,
694
+ allowCredentials,
695
+ userVerification: "preferred",
696
+ timeout: 6e4
697
+ });
698
+ const challengeKey = `auth:${options.challenge}`;
699
+ challengeStore.set(challengeKey, {
700
+ challenge: options.challenge,
701
+ expiresAt: Date.now() + 12e4
702
+ });
703
+ return c.json(options);
704
+ }
705
+ ).post(
706
+ "/login/verify",
707
+ zValidator("json", PasskeyLoginVerifyRequest2),
708
+ async (c) => {
709
+ const { credential } = c.req.valid("json");
710
+ const response = credential;
711
+ const passkey = getPasskeyByCredentialId(response.id);
712
+ if (!passkey) {
713
+ throw new ApiException(errors.passkey_not_found, 401);
714
+ }
715
+ let storedChallenge = null;
716
+ for (const [key, value] of challengeStore) {
717
+ if (key.startsWith("auth:") && value.expiresAt > Date.now()) {
718
+ storedChallenge = value.challenge;
719
+ break;
720
+ }
721
+ }
722
+ if (!storedChallenge) {
723
+ throw new ApiException(errors.invalid_credentials, 401);
724
+ }
725
+ try {
726
+ const verification = await verifyAuthenticationResponse({
727
+ response,
728
+ expectedChallenge: storedChallenge,
729
+ expectedOrigin: env.RP_ORIGIN,
730
+ expectedRPID: env.RP_ID,
731
+ authenticator: {
732
+ credentialID: passkey.credentialId,
733
+ credentialPublicKey: Buffer.from(passkey.publicKey, "base64"),
734
+ counter: passkey.counter,
735
+ transports: passkey.transports?.split(",")
736
+ }
737
+ });
738
+ if (!verification.verified) {
739
+ throw new ApiException(errors.invalid_credentials, 401);
740
+ }
741
+ updatePasskeyCounter(passkey.credentialId, verification.authenticationInfo.newCounter);
742
+ const token = await createToken({
743
+ userId: passkey.user.uid,
744
+ email: passkey.user.email ?? void 0
745
+ });
746
+ const tokenHash = hashToken(token);
747
+ const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
748
+ createSession({ userId: passkey.user.id, tokenHash, expiresAt });
749
+ challengeStore.delete(`auth:${storedChallenge}`);
750
+ return c.json({
751
+ user: {
752
+ id: passkey.user.uid,
753
+ email: passkey.user.email,
754
+ createdAt: passkey.user.createdAt
755
+ },
756
+ token
757
+ });
758
+ } catch (error) {
759
+ if (error instanceof ApiException) throw error;
760
+ console.error("Passkey authentication error:", error);
761
+ throw new ApiException(errors.invalid_credentials, 401);
762
+ }
763
+ }
764
+ ).post(
765
+ "/check",
766
+ zValidator("json", PasskeyCheckRequest),
767
+ (c) => {
768
+ const { email } = c.req.valid("json");
769
+ const user = getUserByEmail(email);
770
+ if (!user) {
771
+ return c.json({ hasPasskey: false });
772
+ }
773
+ const passkeys = getPasskeysByUserId(user.id);
774
+ return c.json({ hasPasskey: passkeys.length > 0 });
775
+ }
776
+ );
777
+
778
+ // src/api/auth/router.ts
779
+ function hashPassword(password, salt) {
780
+ const crypto2 = createHash2("sha256");
781
+ return crypto2.update(password + salt).digest("hex");
782
+ }
783
+ function generateSalt() {
784
+ return randomBytes2(16).toString("hex");
785
+ }
786
+ var authRouter = new Hono2().post(
787
+ "/email/register",
788
+ zValidator2("json", EmailRegisterRequest),
789
+ async (c) => {
790
+ const { email, password } = c.req.valid("json");
791
+ const existing = getUserByEmail(email);
792
+ if (existing) {
793
+ throw new ApiException(errors.email_already_exists, 409);
794
+ }
795
+ const salt = generateSalt();
796
+ const passwordHash = hashPassword(password, salt) + ":" + salt;
797
+ const user = createUser({ email, passwordHash });
798
+ const token = await createToken({ userId: user.uid, email });
799
+ const tokenHash = hashToken(token);
800
+ const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
801
+ createSession({ userId: user.id, tokenHash, expiresAt });
802
+ return c.json({
803
+ user: {
804
+ id: user.uid,
805
+ email: user.email,
806
+ createdAt: user.createdAt
807
+ },
808
+ token
809
+ });
810
+ }
811
+ ).post(
812
+ "/email/login",
813
+ zValidator2("json", EmailLoginRequest),
814
+ async (c) => {
815
+ const { email, password } = c.req.valid("json");
816
+ const user = getUserByEmail(email);
817
+ if (!user || !user.passwordHash) {
818
+ throw new ApiException(errors.invalid_credentials, 401);
819
+ }
820
+ const [storedHash, salt] = user.passwordHash.split(":");
821
+ if (!salt) {
822
+ throw new ApiException(errors.invalid_credentials, 401);
823
+ }
824
+ const inputHash = hashPassword(password, salt);
825
+ if (inputHash !== storedHash) {
826
+ throw new ApiException(errors.invalid_credentials, 401);
827
+ }
828
+ const token = await createToken({ userId: user.uid, email: user.email ?? void 0 });
829
+ const tokenHash = hashToken(token);
830
+ const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
831
+ createSession({ userId: user.id, tokenHash, expiresAt });
832
+ return c.json({
833
+ user: {
834
+ id: user.uid,
835
+ email: user.email,
836
+ createdAt: user.createdAt
837
+ },
838
+ token
839
+ });
840
+ }
841
+ ).get(
842
+ "/me",
843
+ requireAuthMiddleware,
844
+ (c) => {
845
+ const session = c.get("session");
846
+ return c.json({
847
+ user: {
848
+ id: session.user.uid,
849
+ email: session.user.email,
850
+ createdAt: 0
851
+ // Not needed for /me
852
+ }
853
+ });
854
+ }
855
+ ).post(
856
+ "/refresh",
857
+ requireAuthMiddleware,
858
+ async (c) => {
859
+ const session = c.get("session");
860
+ const token = await createToken({
861
+ userId: session.user.uid,
862
+ email: session.user.email ?? void 0
863
+ });
864
+ const tokenHash = hashToken(token);
865
+ const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
866
+ const oldToken = c.req.header("Authorization")?.slice(7);
867
+ if (oldToken) {
868
+ deleteSession(hashToken(oldToken));
869
+ }
870
+ createSession({ userId: session.user.id, tokenHash, expiresAt });
871
+ return c.json({
872
+ token,
873
+ expiresIn: env.JWT_EXPIRY
874
+ });
875
+ }
876
+ ).post(
877
+ "/logout",
878
+ requireAuthMiddleware,
879
+ (c) => {
880
+ const token = c.req.header("Authorization")?.slice(7);
881
+ if (token) {
882
+ deleteSession(hashToken(token));
883
+ }
884
+ return c.json({ success: true });
885
+ }
886
+ ).route("/passkey", passkeyRouter);
887
+
888
+ // src/api/vault/router.ts
889
+ import { Hono as Hono3 } from "hono";
890
+ import { zValidator as zValidator3 } from "@hono/zod-validator";
891
+ function toVaultResponse(vault) {
892
+ return {
893
+ uid: vault.uid,
894
+ name: vault.name,
895
+ data: vault.data,
896
+ salt: vault.salt,
897
+ version: vault.version,
898
+ updatedAt: vault.updatedAt
899
+ };
900
+ }
901
+ var vaultRouter = new Hono3().use("/*", requireAuthMiddleware).get(
902
+ "/",
903
+ (c) => {
904
+ const session = c.get("session");
905
+ const vaults = getVaultsByUserId(session.user.id);
906
+ return c.json({
907
+ vaults: vaults.map(toVaultResponse)
908
+ });
909
+ }
910
+ ).get(
911
+ "/:uid",
912
+ (c) => {
913
+ const session = c.get("session");
914
+ const { uid } = c.req.param();
915
+ const vault = getVaultByUid(uid, session.user.id);
916
+ if (!vault) {
917
+ throw new ApiException(errors.vault_not_found, 404);
918
+ }
919
+ return c.json(toVaultResponse(vault));
920
+ }
921
+ ).post(
922
+ "/",
923
+ zValidator3("json", CreateVaultRequest),
924
+ (c) => {
925
+ const session = c.get("session");
926
+ const { name, data, salt } = c.req.valid("json");
927
+ const existing = getVaultByName(name, session.user.id);
928
+ if (existing) {
929
+ throw new ApiException(errors.vault_already_exists(name), 409);
930
+ }
931
+ const vault = createVault({
932
+ userId: session.user.id,
933
+ name,
934
+ data,
935
+ salt
936
+ });
937
+ return c.json(toVaultResponse(vault), 201);
938
+ }
939
+ ).put(
940
+ "/:uid",
941
+ zValidator3("json", UpdateVaultRequest),
942
+ (c) => {
943
+ const session = c.get("session");
944
+ const { uid } = c.req.param();
945
+ const { data, salt, version } = c.req.valid("json");
946
+ const vault = updateVault(uid, session.user.id, { data, salt, version });
947
+ if (!vault) {
948
+ throw new ApiException(errors.vault_not_found, 404);
949
+ }
950
+ return c.json(toVaultResponse(vault));
951
+ }
952
+ ).delete(
953
+ "/:uid",
954
+ (c) => {
955
+ const session = c.get("session");
956
+ const { uid } = c.req.param();
957
+ const deleted = deleteVault(uid, session.user.id);
958
+ if (!deleted) {
959
+ throw new ApiException(errors.vault_not_found, 404);
960
+ }
961
+ return c.json({ success: true });
962
+ }
963
+ );
964
+
965
+ // src/app.ts
966
+ var errorHandler = (error, c) => {
967
+ const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
968
+ if (error instanceof ApiException) {
969
+ return c.json(
970
+ { error: error.error, requestId },
971
+ error.status
972
+ );
973
+ }
974
+ if (error instanceof ZodError) {
975
+ const message = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
976
+ return c.json(
977
+ {
978
+ error: {
979
+ code: "validation_error",
980
+ message,
981
+ details: { errors: error.errors }
982
+ },
983
+ requestId
984
+ },
985
+ 400
986
+ );
987
+ }
988
+ console.error(`[${requestId}] Unhandled error:`, error);
989
+ return c.json(
990
+ { error: errors.internal_error, requestId },
991
+ 500
992
+ );
993
+ };
994
+ function createApp() {
995
+ const app = new Hono4();
996
+ app.use("*", logger());
997
+ app.use(
998
+ "*",
999
+ cors({
1000
+ origin: "*",
1001
+ // Configure for production
1002
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
1003
+ allowHeaders: ["Content-Type", "Authorization"],
1004
+ exposeHeaders: ["X-Request-Id"]
1005
+ })
1006
+ );
1007
+ app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
1008
+ app.route("/auth", authRouter);
1009
+ app.route("/vault", vaultRouter);
1010
+ app.onError(errorHandler);
1011
+ app.notFound((c) => {
1012
+ return c.json(
1013
+ { error: { code: "not_found", message: "Endpoint not found" } },
1014
+ 404
1015
+ );
1016
+ });
1017
+ return app;
1018
+ }
1019
+
1020
+ export {
1021
+ env,
1022
+ getDb,
1023
+ closeDb,
1024
+ createToken,
1025
+ verifyToken,
1026
+ hashToken,
1027
+ ApiException,
1028
+ errors,
1029
+ getError,
1030
+ optionalAuthMiddleware,
1031
+ requireAuthMiddleware,
1032
+ UserResponse,
1033
+ EmailRegisterRequest,
1034
+ EmailLoginRequest,
1035
+ AuthResponse,
1036
+ RefreshResponse,
1037
+ MeResponse,
1038
+ PasskeyRegisterOptionsRequest,
1039
+ PasskeyRegisterVerifyRequest,
1040
+ PasskeyLoginOptionsRequest,
1041
+ PasskeyLoginVerifyRequest,
1042
+ PasskeyCheckResponse,
1043
+ CreateVaultRequest,
1044
+ UpdateVaultRequest,
1045
+ VaultResponse,
1046
+ VaultsListResponse,
1047
+ createApp
1048
+ };