@mrgnw/anahtar 0.0.29-diagnostic.0 → 0.0.30

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.
@@ -210,6 +210,9 @@ function handlePasskeySkip() {
210
210
  bind:value={email}
211
211
  required
212
212
  autocomplete="username webauthn"
213
+ autocapitalize="none"
214
+ autocorrect="off"
215
+ spellcheck="false"
213
216
  placeholder={m.emailPlaceholder}
214
217
  class="anahtar-input"
215
218
  />
@@ -60,10 +60,25 @@ let isTouch = $state(false);
60
60
  let hoveredKey = $state<string | null>(null);
61
61
  let passkeyRefresh = $state(0);
62
62
  let passkeyOnboarding = $state(false);
63
- let conditionalAbort: AbortController | null = null;
63
+
64
+ const PASSKEY_TIMEOUT_MS = 8000;
65
+ // Captured from the lazy @simplewebauthn/browser import so onMount cleanup (sync)
66
+ // and handleEmailSubmit can cancel an in-flight ceremony without re-importing.
67
+ let webauthnAbort: { cancelCeremony: () => void } | null = null;
64
68
 
65
69
  const isAuthenticated = $derived(!!user);
66
70
 
71
+ // Best-effort bound on a promise so a WebAuthn ceremony that never settles
72
+ // (e.g. rpID mismatch on preview domains) can't wedge the sign-in flow.
73
+ function raceTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
74
+ let timer: ReturnType<typeof setTimeout> | undefined;
75
+ const timeout = new Promise<never>((_, reject) => {
76
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
77
+ });
78
+ // Promise.race consumes the loser's later settlement, so no unhandled rejection.
79
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
80
+ }
81
+
67
82
  function shortDate(ts?: number): string {
68
83
  if (!ts) return '';
69
84
  const d = new Date(ts);
@@ -77,7 +92,7 @@ const passkeyPromise = $derived(
77
92
  onMount(() => {
78
93
  isTouch = matchMedia('(pointer: coarse)').matches;
79
94
  if (!isAuthenticated) tryConditionalWebAuthn();
80
- return () => conditionalAbort?.abort();
95
+ return () => webauthnAbort?.cancelCeremony();
81
96
  });
82
97
 
83
98
  async function handleSignOut() {
@@ -94,40 +109,45 @@ async function handleSignOut() {
94
109
 
95
110
  async function tryConditionalWebAuthn() {
96
111
  try {
97
- const { startAuthentication } = await import('@simplewebauthn/browser');
112
+ const mod = await import('@simplewebauthn/browser');
113
+ webauthnAbort = mod.WebAuthnAbortService;
98
114
  const res = await fetch(`${apiBase}/passkey/login-start`);
99
115
  if (!res.ok) return;
100
116
  const options = (await res.json()) as any;
101
- conditionalAbort = new AbortController();
102
- const authResponse = await startAuthentication({
117
+ const authResponse = await mod.startAuthentication({
103
118
  optionsJSON: options,
104
119
  useBrowserAutofill: true,
105
120
  });
121
+ // Only reached when the user actually picks a passkey via autofill.
122
+ // Scope `loading` to the verify step so the abort path never touches it
123
+ // (an aborted ceremony throws straight to the catch below).
106
124
  loading = true;
107
- const verifyRes = await fetch(`${apiBase}/passkey/login-finish`, {
108
- method: 'POST',
109
- headers: { 'Content-Type': 'application/json' },
110
- body: JSON.stringify(authResponse),
111
- });
112
- if (verifyRes.ok) await onSuccess?.();
125
+ try {
126
+ const verifyRes = await fetch(`${apiBase}/passkey/login-finish`, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify(authResponse),
130
+ });
131
+ if (verifyRes.ok) await onSuccess?.();
132
+ } finally {
133
+ loading = false;
134
+ }
113
135
  } catch {
114
136
  /* not available or cancelled */
115
- } finally {
116
- loading = false;
117
137
  }
118
138
  }
119
139
 
120
140
  async function handleEmailSubmit(e: SubmitEvent) {
121
141
  e.preventDefault();
122
142
  if (!email.includes('@')) return;
123
- conditionalAbort?.abort();
124
- conditionalAbort = null;
143
+ webauthnAbort?.cancelCeremony();
125
144
  loading = true;
126
145
  error = '';
127
146
  try {
128
147
  // Passkey-first: check if user has passkeys before falling back to OTP
129
148
  try {
130
- const { startAuthentication } = await import('@simplewebauthn/browser');
149
+ const mod = await import('@simplewebauthn/browser');
150
+ webauthnAbort = mod.WebAuthnAbortService;
131
151
  const checkRes = await fetch(`${apiBase}/passkey/check-email`, {
132
152
  method: 'POST',
133
153
  headers: { 'Content-Type': 'application/json' },
@@ -137,7 +157,10 @@ async function handleEmailSubmit(e: SubmitEvent) {
137
157
  const opts = (await checkRes.json()) as any;
138
158
  if ((opts?.allowCredentials?.length ?? 0) > 0) {
139
159
  try {
140
- const authResp = await startAuthentication({ optionsJSON: opts });
160
+ const authResp = await raceTimeout(
161
+ mod.startAuthentication({ optionsJSON: opts }),
162
+ PASSKEY_TIMEOUT_MS,
163
+ );
141
164
  const vRes = await fetch(`${apiBase}/passkey/login-finish`, {
142
165
  method: 'POST',
143
166
  headers: { 'Content-Type': 'application/json' },
@@ -148,7 +171,8 @@ async function handleEmailSubmit(e: SubmitEvent) {
148
171
  return;
149
172
  }
150
173
  } catch {
151
- /* cancelled */
174
+ mod.WebAuthnAbortService.cancelCeremony();
175
+ /* stalled, cancelled, or failed — fall through to OTP */
152
176
  }
153
177
  }
154
178
  }
@@ -412,13 +436,18 @@ async function removePasskey(id: string) {
412
436
  placeholder={m.emailPlaceholder}
413
437
  class="anahtar-pill-email-input"
414
438
  autocomplete="username webauthn"
439
+ autocapitalize="none"
440
+ autocorrect="off"
441
+ spellcheck="false"
415
442
  disabled={loading}
416
443
  />
417
- <button type="submit" class="anahtar-pill-go" disabled={loading || !email.includes('@')}>
444
+ <button type="submit" class="anahtar-pill-go" disabled={loading || !email.includes('@')} aria-label={m.continue}>
418
445
  {#if loading}
419
- ...
446
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="anahtar-spinner" aria-hidden="true">
447
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
448
+ </svg>
420
449
  {:else}
421
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
450
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
422
451
  <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
423
452
  </svg>
424
453
  {/if}
@@ -585,6 +614,11 @@ async function removePasskey(id: string) {
585
614
  .anahtar-pill-go:disabled { opacity: 0.4; cursor: not-allowed; }
586
615
  .anahtar-pill-go:hover:not(:disabled) { opacity: 0.85; }
587
616
 
617
+ @keyframes anahtar-spin {
618
+ to { transform: rotate(360deg); }
619
+ }
620
+ .anahtar-spinner { animation: anahtar-spin 0.8s linear infinite; }
621
+
588
622
  /* OTP */
589
623
  .anahtar-pill-otp-label {
590
624
  font-size: 0.8125rem;
package/dist/db/d1.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeEmail } from '../email.js';
1
2
  export function d1Adapter(db, options = {}) {
2
3
  const p = options.tablePrefix ?? 'auth_';
3
4
  const t = {
@@ -45,8 +46,8 @@ export function d1Adapter(db, options = {}) {
45
46
  },
46
47
  async getUserByEmail(email) {
47
48
  const row = await db
48
- .prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ?`)
49
- .bind(email)
49
+ .prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ? COLLATE NOCASE`)
50
+ .bind(normalizeEmail(email))
50
51
  .first();
51
52
  if (!row)
52
53
  return null;
@@ -59,10 +60,11 @@ export function d1Adapter(db, options = {}) {
59
60
  },
60
61
  async createUser(email) {
61
62
  const id = crypto.randomUUID();
62
- await db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).bind(id, email).run();
63
+ const normalized = normalizeEmail(email);
64
+ await db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).bind(id, normalized).run();
63
65
  return {
64
66
  id,
65
- email,
67
+ email: normalized,
66
68
  skipPasskeyPrompt: false,
67
69
  createdAt: Math.floor(Date.now() / 1000)
68
70
  };
@@ -102,13 +104,13 @@ export function d1Adapter(db, options = {}) {
102
104
  async storeOTP(email, id, code, expiresAt) {
103
105
  await db
104
106
  .prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`)
105
- .bind(id, email, code, expiresAt)
107
+ .bind(id, normalizeEmail(email), code, expiresAt)
106
108
  .run();
107
109
  },
108
110
  async getLatestOTP(email) {
109
111
  const row = await db
110
- .prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? ORDER BY created_at DESC LIMIT 1`)
111
- .bind(email)
112
+ .prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE ORDER BY created_at DESC LIMIT 1`)
113
+ .bind(normalizeEmail(email))
112
114
  .first();
113
115
  if (!row)
114
116
  return null;
@@ -127,7 +129,7 @@ export function d1Adapter(db, options = {}) {
127
129
  await db.prepare(`DELETE FROM ${t.otpCodes} WHERE id = ?`).bind(id).run();
128
130
  },
129
131
  async deleteOTPsForEmail(email) {
130
- await db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ?`).bind(email).run();
132
+ await db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE`).bind(normalizeEmail(email)).run();
131
133
  },
132
134
  async storeChallenge(challenge, userId, expiresAt) {
133
135
  await db.prepare(`DELETE FROM ${t.challenges} WHERE expires_at < ?`).bind(Date.now()).run();
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { normalizeEmail } from '../email.js';
2
3
  export function postgresAdapter(pool, options = {}) {
3
4
  const p = options.tablePrefix ?? 'auth_';
4
5
  const t = {
@@ -58,7 +59,7 @@ export function postgresAdapter(pool, options = {}) {
58
59
  `);
59
60
  },
60
61
  async getUserByEmail(email) {
61
- const row = await queryOne(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = $1`, [email]);
62
+ const row = await queryOne(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE LOWER(email) = $1`, [normalizeEmail(email)]);
62
63
  if (!row)
63
64
  return null;
64
65
  return {
@@ -70,10 +71,11 @@ export function postgresAdapter(pool, options = {}) {
70
71
  },
71
72
  async createUser(email) {
72
73
  const id = randomUUID();
73
- await pool.query(`INSERT INTO ${t.users} (id, email) VALUES ($1, $2)`, [id, email]);
74
+ const normalized = normalizeEmail(email);
75
+ await pool.query(`INSERT INTO ${t.users} (id, email) VALUES ($1, $2)`, [id, normalized]);
74
76
  return {
75
77
  id,
76
- email,
78
+ email: normalized,
77
79
  skipPasskeyPrompt: false,
78
80
  createdAt: Date.now()
79
81
  };
@@ -108,13 +110,13 @@ export function postgresAdapter(pool, options = {}) {
108
110
  async storeOTP(email, id, code, expiresAt) {
109
111
  await pool.query(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES ($1, $2, $3, $4)`, [
110
112
  id,
111
- email,
113
+ normalizeEmail(email),
112
114
  code,
113
115
  expiresAt
114
116
  ]);
115
117
  },
116
118
  async getLatestOTP(email) {
117
- const row = await queryOne(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, [email]);
119
+ const row = await queryOne(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE LOWER(email) = $1 ORDER BY created_at DESC LIMIT 1`, [normalizeEmail(email)]);
118
120
  if (!row)
119
121
  return null;
120
122
  return {
@@ -132,7 +134,7 @@ export function postgresAdapter(pool, options = {}) {
132
134
  await pool.query(`DELETE FROM ${t.otpCodes} WHERE id = $1`, [id]);
133
135
  },
134
136
  async deleteOTPsForEmail(email) {
135
- await pool.query(`DELETE FROM ${t.otpCodes} WHERE email = $1`, [email]);
137
+ await pool.query(`DELETE FROM ${t.otpCodes} WHERE LOWER(email) = $1`, [normalizeEmail(email)]);
136
138
  },
137
139
  async storeChallenge(challenge, userId, expiresAt) {
138
140
  await pool.query(`DELETE FROM ${t.challenges} WHERE expires_at < $1`, [Date.now()]);
package/dist/db/sqlite.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { normalizeEmail } from '../email.js';
2
3
  export function sqliteAdapter(db, options = {}) {
3
4
  const p = options.tablePrefix ?? 'auth_';
4
5
  const t = {
@@ -53,8 +54,8 @@ export function sqliteAdapter(db, options = {}) {
53
54
  },
54
55
  getUserByEmail(email) {
55
56
  const row = db
56
- .prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ?`)
57
- .get(email);
57
+ .prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ? COLLATE NOCASE`)
58
+ .get(normalizeEmail(email));
58
59
  if (!row)
59
60
  return null;
60
61
  return {
@@ -66,10 +67,11 @@ export function sqliteAdapter(db, options = {}) {
66
67
  },
67
68
  createUser(email) {
68
69
  const id = randomUUID();
69
- db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).run(id, email);
70
+ const normalized = normalizeEmail(email);
71
+ db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).run(id, normalized);
70
72
  return {
71
73
  id,
72
- email,
74
+ email: normalized,
73
75
  skipPasskeyPrompt: false,
74
76
  createdAt: Math.floor(Date.now() / 1000)
75
77
  };
@@ -100,12 +102,12 @@ export function sqliteAdapter(db, options = {}) {
100
102
  db.prepare(`DELETE FROM ${t.sessions} WHERE id = ?`).run(tokenHash);
101
103
  },
102
104
  storeOTP(email, id, code, expiresAt) {
103
- db.prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`).run(id, email, code, expiresAt);
105
+ db.prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`).run(id, normalizeEmail(email), code, expiresAt);
104
106
  },
105
107
  getLatestOTP(email) {
106
108
  const row = db
107
- .prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? ORDER BY created_at DESC LIMIT 1`)
108
- .get(email);
109
+ .prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE ORDER BY created_at DESC LIMIT 1`)
110
+ .get(normalizeEmail(email));
109
111
  if (!row)
110
112
  return null;
111
113
  return {
@@ -123,7 +125,7 @@ export function sqliteAdapter(db, options = {}) {
123
125
  db.prepare(`DELETE FROM ${t.otpCodes} WHERE id = ?`).run(id);
124
126
  },
125
127
  deleteOTPsForEmail(email) {
126
- db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ?`).run(email);
128
+ db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE`).run(normalizeEmail(email));
127
129
  },
128
130
  storeChallenge(challenge, userId, expiresAt) {
129
131
  db.prepare(`DELETE FROM ${t.challenges} WHERE expires_at < ?`).run(Date.now());
@@ -0,0 +1 @@
1
+ export declare function normalizeEmail(email: string): string;
package/dist/email.js ADDED
@@ -0,0 +1,6 @@
1
+ // Email is the user's identity key. Normalize it to one canonical form so the
2
+ // same address always maps to a single account regardless of how it was typed
3
+ // (mobile keyboards auto-capitalize, users add stray whitespace, etc.).
4
+ export function normalizeEmail(email) {
5
+ return email.trim().toLowerCase();
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgnw/anahtar",
3
- "version": "0.0.29-diagnostic.0",
3
+ "version": "0.0.30",
4
4
  "description": "Opinionated, reusable auth for SvelteKit. Email+OTP + passkeys.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/db/d1.d.ts",
41
41
  "default": "./dist/db/d1.js"
42
42
  },
43
+ "./device": {
44
+ "types": "./dist/device.d.ts",
45
+ "default": "./dist/device.js"
46
+ },
43
47
  "./components": {
44
48
  "types": "./dist/components/index.d.ts",
45
49
  "svelte": "./dist/components/index.js",