@supacloud/lite 0.31.3 → 0.32.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.
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { dirname as dirname8, join as join10, resolve as resolve6 } from "path";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "@supacloud/lite",
11
- version: "0.31.3",
11
+ version: "0.32.0",
12
12
  description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
13
13
  type: "module",
14
14
  license: "Apache-2.0",
@@ -67,16 +67,16 @@ var package_default = {
67
67
  "@electric-sql/pglite-pgmq": "0.0.8",
68
68
  "@electric-sql/pglite-pgvector": "0.0.11",
69
69
  "@electric-sql/pglite-postgis": "0.2.10",
70
- "@supacloud/db": "0.18.4",
70
+ "@supacloud/db": "0.19.0",
71
71
  "exact-mirror": "1.2.6",
72
72
  tar: "^7.5.22",
73
73
  typebox: "1.3.37"
74
74
  },
75
75
  devDependencies: {
76
- "@supacloud/app": "0.28.3",
77
- "@supacloud/contracts": "0.11.5",
78
- "@supacloud/compiler": "0.47.3",
79
- "@supacloud/elysia": "0.33.5",
76
+ "@supacloud/app": "0.29.0",
77
+ "@supacloud/contracts": "0.12.0",
78
+ "@supacloud/compiler": "0.48.0",
79
+ "@supacloud/elysia": "0.34.0",
80
80
  "@supabase/supabase-js": "^2.117.3",
81
81
  "@types/bun": "^1.4.3",
82
82
  effect: "4.0.5",
@@ -85,13 +85,13 @@ var package_default = {
85
85
  typescript: "^7.0.2"
86
86
  },
87
87
  overrides: {
88
- "@supacloud/db": "0.18.4",
89
- "@supacloud/app": "0.28.3",
90
- "@supacloud/compiler": "0.47.3",
91
- "@supacloud/elysia": "0.33.5",
92
- "@supacloud/contracts": "0.11.5",
93
- "@supacloud/commands": "0.9.7",
94
- "@supacloud/delivery": "0.19.4",
88
+ "@supacloud/db": "0.19.0",
89
+ "@supacloud/app": "0.29.0",
90
+ "@supacloud/compiler": "0.48.0",
91
+ "@supacloud/elysia": "0.34.0",
92
+ "@supacloud/contracts": "0.12.0",
93
+ "@supacloud/commands": "0.10.0",
94
+ "@supacloud/delivery": "0.20.0",
95
95
  "@graphql-tools/utils": "^12.0.4"
96
96
  },
97
97
  engines: {
@@ -367,9 +367,8 @@ class OAuthService {
367
367
  try {
368
368
  const token = await this.exchangeCode(provider, code);
369
369
  profile = await this.fetchProfile(provider, token);
370
- } catch (e) {
371
- const msg = encodeURIComponent(e instanceof Error ? e.message : "oauth_error");
372
- return redirect(`${flow.redirect_to}#error=server_error&error_description=${msg}`);
370
+ } catch {
371
+ return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth provider request failed")}`);
373
372
  }
374
373
  let userId;
375
374
  try {
@@ -378,7 +377,7 @@ class OAuthService {
378
377
  if (e instanceof OAuthAccountIneligibleError) {
379
378
  return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
380
379
  }
381
- throw e;
380
+ return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
382
381
  }
383
382
  if (flow.code_challenge) {
384
383
  const authCode = randomToken(24);
@@ -393,13 +392,13 @@ class OAuthService {
393
392
  if (e instanceof OAuthAccountIneligibleError) {
394
393
  return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
395
394
  }
396
- throw e;
395
+ return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
397
396
  }
398
397
  const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&provider_token=`;
399
398
  return redirect(`${flow.redirect_to}${hash}`);
400
399
  }
401
400
  async exchangePkce(authCode, verifier) {
402
- const res = await this.db.query(`delete from auth.flow_state where auth_code = $1 and expires_at > now() returning *`, [authCode]);
401
+ const res = await this.db.query(`select * from auth.flow_state where auth_code = $1 and expires_at > now()`, [authCode]);
403
402
  const flow = res.rows[0];
404
403
  if (!flow || !flow.user_id)
405
404
  return null;
@@ -411,7 +410,11 @@ class OAuthService {
411
410
  } else if (flow.code_challenge && flow.code_challenge !== verifier) {
412
411
  return null;
413
412
  }
414
- return flow.user_id;
413
+ const claimed = await this.db.transaction(async (query) => {
414
+ const consumed = await query(`delete from auth.flow_state where auth_code = $1 and expires_at > now() returning *`, [authCode]);
415
+ return consumed.rows[0] ?? null;
416
+ });
417
+ return claimed?.user_id ?? null;
415
418
  }
416
419
  async exchangeCode(provider, code) {
417
420
  const body = new URLSearchParams({
@@ -460,39 +463,65 @@ class OAuthService {
460
463
  return existing.rows[0].user_id;
461
464
  }
462
465
  let userId;
463
- const email = profile.email?.toLowerCase() ?? null;
466
+ const email = profile.email?.toLowerCase().trim() ?? null;
464
467
  const verified = !!profile.email && profile.emailVerified === true;
465
468
  if (email && verified) {
466
- const byEmail = await query(`select id from auth.users where email = $1`, [email]);
469
+ const byEmail = await query(`select id from auth.users where lower(email) = $1`, [email]);
467
470
  userId = byEmail.rows[0]?.id;
468
471
  if (userId)
469
472
  await this.assertUserEligible(query, userId);
470
473
  }
474
+ let createdUserId;
471
475
  if (!userId) {
472
476
  let userEmail = verified ? email : null;
473
477
  if (userEmail) {
474
- const taken = await query(`select 1 from auth.users where email = $1`, [userEmail]);
478
+ const taken = await query(`select 1 from auth.users where lower(email) = $1`, [userEmail]);
475
479
  if (taken.rows.length > 0)
476
480
  userEmail = null;
477
481
  }
478
482
  const created = await query(`insert into auth.users (aud, role, email, email_confirmed_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data)
479
483
  values ('authenticated','authenticated',$1, $2, now(),
480
- $3::jsonb, $4::jsonb) returning id`, [
484
+ $3::jsonb, $4::jsonb)
485
+ on conflict (email) do nothing
486
+ returning id`, [
481
487
  userEmail,
482
488
  userEmail ? new Date().toISOString() : null,
483
489
  JSON.stringify({ provider, providers: [provider] }),
484
490
  JSON.stringify({ sub: profile.id, email: profile.email, full_name: profile.name, ...profile.metadata })
485
491
  ]);
486
492
  const user = created.rows[0];
487
- if (!user)
493
+ if (user) {
494
+ userId = user.id;
495
+ createdUserId = user.id;
496
+ } else if (userEmail) {
497
+ const existingEmail = await query(`select id from auth.users where lower(email) = $1 for update`, [userEmail]);
498
+ userId = existingEmail.rows[0]?.id;
499
+ if (!userId)
500
+ throw new Error("OAuth email conflict has no winner");
501
+ await this.assertUserEligible(query, userId);
502
+ } else {
488
503
  throw new Error("OAuth user creation returned no row");
489
- userId = user.id;
504
+ }
490
505
  }
491
- await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
492
- values ($1, $2, $3, $4::jsonb)`, [userId, provider, profile.id, JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata })]);
493
- if (!userId)
494
- throw new Error("OAuth identity has no user ID");
495
- return userId;
506
+ const identityData = JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata });
507
+ const linked = await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
508
+ values ($1, $2, $3, $4::jsonb)
509
+ on conflict (provider, provider_id) do nothing
510
+ returning user_id`, [userId, provider, profile.id, identityData]);
511
+ if (linked.rows[0])
512
+ return linked.rows[0].user_id;
513
+ const raced = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
514
+ const racedUserId = raced.rows[0]?.user_id;
515
+ if (!racedUserId)
516
+ throw new Error("OAuth identity conflict has no winner");
517
+ if (createdUserId && createdUserId !== racedUserId) {
518
+ await query(`delete from auth.users where id = $1`, [createdUserId]);
519
+ }
520
+ await this.assertUserEligible(query, racedUserId);
521
+ await query(`update auth.identities set last_sign_in_at = now(), identity_data = $3
522
+ where provider = $1 and provider_id = $2`, [provider, profile.id, identityData]);
523
+ await query(`update auth.users set last_sign_in_at = now() where id = $1`, [racedUserId]);
524
+ return racedUserId;
496
525
  }
497
526
  async assertUserEligible(query, userId) {
498
527
  const users = await query(`select deleted_at is not null as is_deleted,
@@ -1201,6 +1230,12 @@ async function verifyTotp(secretBase32, code, window = 1, atMs = Date.now()) {
1201
1230
  function authError(status, errorCode, msg) {
1202
1231
  return json(status, { code: status, error_code: errorCode, msg });
1203
1232
  }
1233
+ function normalizeEmailInput(value) {
1234
+ if (typeof value !== "string")
1235
+ return null;
1236
+ const email = value.trim().toLowerCase();
1237
+ return email || null;
1238
+ }
1204
1239
  function randomOtp(length) {
1205
1240
  const n = Math.max(6, Math.min(10, Math.floor(length)));
1206
1241
  let code = "";
@@ -1391,8 +1426,10 @@ class AuthHandler {
1391
1426
  } catch (e) {
1392
1427
  if (e instanceof OAuthAccountIneligibleError)
1393
1428
  return authError(400, e.code, e.message);
1394
- const msg = e instanceof Error ? e.message : String(e);
1395
- return authError(500, "unexpected_failure", msg);
1429
+ try {
1430
+ this.config.log?.(`[auth] request_failed code=${databaseDiagnosticCode(e)}`);
1431
+ } catch {}
1432
+ return authError(500, "unexpected_failure", "Unable to complete authentication request");
1396
1433
  }
1397
1434
  }
1398
1435
  async oauthCallbackUrl(req, url) {
@@ -1407,7 +1444,7 @@ class AuthHandler {
1407
1444
  }
1408
1445
  async signup(req) {
1409
1446
  const body = await req.json().catch(() => ({}));
1410
- if (!body.email && !body.password) {
1447
+ if (!("email" in body) && !("password" in body)) {
1411
1448
  if (!this.settings.anonymousUsers) {
1412
1449
  return authError(422, "anonymous_provider_disabled", "Anonymous sign-ins are disabled");
1413
1450
  }
@@ -1419,29 +1456,34 @@ class AuthHandler {
1419
1456
  if (this.settings.disableSignup) {
1420
1457
  return authError(422, "signup_disabled", "Signups not allowed for this instance");
1421
1458
  }
1422
- if (!body.email || !body.password) {
1459
+ const email = normalizeEmailInput(body.email);
1460
+ if (!email || !body.password) {
1423
1461
  return authError(400, "validation_failed", "Signup requires a valid email and password");
1424
1462
  }
1425
1463
  if (body.password.length < this.settings.minPasswordLength) {
1426
1464
  return authError(422, "weak_password", `Password should be at least ${this.settings.minPasswordLength} characters.`);
1427
1465
  }
1428
- const email = body.email.toLowerCase().trim();
1429
- const existing = await this.db.query(`select id from auth.users where email = $1`, [email]);
1430
- if (existing.rows.length > 0) {
1431
- return authError(422, "user_already_exists", "User already registered");
1432
- }
1433
1466
  const hashed = await hashPassword(body.password);
1434
1467
  const autoconfirm = this.settings.autoconfirm;
1435
- const res = await this.db.query(`insert into auth.users
1436
- (aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
1437
- raw_app_meta_data, raw_user_meta_data)
1438
- values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end, now(),
1439
- '{"provider":"email","providers":["email"]}', $3)
1440
- returning *`, [email, hashed, JSON.stringify(body.data ?? {}), autoconfirm]);
1441
- const newUser = res.rows[0];
1442
- await this.db.query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1443
- values ($1, 'email', $2, $3)
1444
- on conflict (provider, provider_id) do nothing`, [newUser.id, newUser.id, JSON.stringify({ sub: newUser.id, email: newUser.email })]);
1468
+ const newUser = await this.db.transaction(async (query) => {
1469
+ const res = await query(`insert into auth.users
1470
+ (aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
1471
+ raw_app_meta_data, raw_user_meta_data)
1472
+ values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end,
1473
+ case when $4 then now() else null end,
1474
+ '{"provider":"email","providers":["email"]}', $3)
1475
+ on conflict (email) do nothing
1476
+ returning *`, [email, hashed, JSON.stringify(body.data ?? {}), autoconfirm]);
1477
+ const user = res.rows[0];
1478
+ if (!user)
1479
+ return null;
1480
+ await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1481
+ values ($1, 'email', $2, $3)
1482
+ on conflict (provider, provider_id) do nothing`, [user.id, user.id, JSON.stringify({ sub: user.id, email: user.email })]);
1483
+ return user;
1484
+ });
1485
+ if (!newUser)
1486
+ return authError(422, "user_already_exists", "User already registered");
1445
1487
  await this.audit("user_signedup", { actorId: newUser.id, actorEmail: email });
1446
1488
  if (!autoconfirm) {
1447
1489
  await this.issueToken(email, "otp", false, "confirm");
@@ -1455,7 +1497,7 @@ class AuthHandler {
1455
1497
  if (grantType === "password") {
1456
1498
  const email = (body.email ?? "").toLowerCase().trim();
1457
1499
  const res = await this.db.query(`select *, coalesce(banned_until > now(), false) as is_banned
1458
- from auth.users where email = $1 and deleted_at is null`, [email]);
1500
+ from auth.users where lower(email) = $1 and deleted_at is null`, [email]);
1459
1501
  const user = res.rows[0];
1460
1502
  if (user?.encrypted_password && user.is_banned) {
1461
1503
  return authError(400, "user_banned", "User is banned");
@@ -1468,7 +1510,7 @@ class AuthHandler {
1468
1510
  const currentResult = await query(`select *, coalesce(banned_until > now(), false) as is_banned
1469
1511
  from auth.users where id = $1 for update`, [user.id]);
1470
1512
  const current = currentResult.rows[0];
1471
- if (!current || current.deleted_at !== null || !current.encrypted_password || current.email !== email) {
1513
+ if (!current || current.deleted_at !== null || !current.encrypted_password || current.email?.toLowerCase().trim() !== email) {
1472
1514
  return authError(400, "invalid_credentials", "Invalid login credentials");
1473
1515
  }
1474
1516
  if (current.is_banned) {
@@ -1523,7 +1565,11 @@ class AuthHandler {
1523
1565
  const body = await req.json().catch(() => ({}));
1524
1566
  const sets = [];
1525
1567
  const params = [];
1526
- if (body.email && !this.settings.autoconfirm) {
1568
+ const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
1569
+ if (body.email !== undefined && !requestedEmail) {
1570
+ return authError(400, "validation_failed", "email must not be empty");
1571
+ }
1572
+ if (requestedEmail && !this.settings.autoconfirm) {
1527
1573
  return authError(501, "email_change_confirmation_not_supported", "Email changes that require confirmation are not supported by SupaCloud Lite V1");
1528
1574
  }
1529
1575
  if (body.password) {
@@ -1537,40 +1583,48 @@ class AuthHandler {
1537
1583
  params.push(JSON.stringify(body.data));
1538
1584
  sets.push(`raw_user_meta_data = coalesce(raw_user_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
1539
1585
  }
1540
- const response = await this.db.transaction(async (query) => {
1541
- const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
1542
- and (banned_until is null or banned_until <= now()) for update`, [user.id]);
1543
- const current = currentResult.rows[0];
1544
- if (!current)
1545
- return authError(401, "no_authorization", "Invalid or expired token");
1546
- const transactionSets = [...sets];
1547
- const transactionParams = [...params];
1548
- const upgradingAnon = (current.is_anonymous ?? false) && !!body.email;
1549
- if (body.email) {
1550
- const email = body.email.toLowerCase().trim();
1551
- const clash = await query(`select id from auth.users where email = $1 and id <> $2`, [email, current.id]);
1552
- if (clash.rows.length > 0) {
1553
- return authError(422, "email_exists", "A user with this email address has already been registered");
1586
+ let response;
1587
+ try {
1588
+ response = await this.db.transaction(async (query) => {
1589
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
1590
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
1591
+ const current = currentResult.rows[0];
1592
+ if (!current)
1593
+ return authError(401, "no_authorization", "Invalid or expired token");
1594
+ const transactionSets = [...sets];
1595
+ const transactionParams = [...params];
1596
+ const upgradingAnon = (current.is_anonymous ?? false) && requestedEmail !== undefined;
1597
+ if (requestedEmail) {
1598
+ const email = requestedEmail;
1599
+ const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [email, current.id]);
1600
+ if (clash.rows.length > 0) {
1601
+ return authError(422, "email_exists", "A user with this email address has already been registered");
1602
+ }
1603
+ transactionParams.push(email);
1604
+ transactionSets.push(`email = $${transactionParams.length}, email_confirmed_at = now()`);
1554
1605
  }
1555
- transactionParams.push(email);
1556
- transactionSets.push(`email = $${transactionParams.length}, email_confirmed_at = now()`);
1557
- }
1558
- if (upgradingAnon) {
1559
- transactionSets.push(`is_anonymous = false`);
1560
- transactionSets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
1561
- }
1562
- if (transactionSets.length === 0) {
1563
- return json(200, await this.userJsonWithRelations(current, query));
1606
+ if (upgradingAnon) {
1607
+ transactionSets.push(`is_anonymous = false`);
1608
+ transactionSets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
1609
+ }
1610
+ if (transactionSets.length === 0) {
1611
+ return json(200, await this.userJsonWithRelations(current, query));
1612
+ }
1613
+ transactionParams.push(current.id);
1614
+ const updatedResult = await query(`update auth.users set ${transactionSets.join(", ")}, updated_at = now() where id = $${transactionParams.length} returning *`, transactionParams);
1615
+ const updated = updatedResult.rows[0];
1616
+ if (!updated)
1617
+ return authError(401, "no_authorization", "Invalid or expired token");
1618
+ if (requestedEmail)
1619
+ await this.ensureEmailIdentity(updated, query);
1620
+ return json(200, await this.userJsonWithRelations(updated, query));
1621
+ });
1622
+ } catch (error) {
1623
+ if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
1624
+ return authError(422, "email_exists", "A user with this email address has already been registered");
1564
1625
  }
1565
- transactionParams.push(current.id);
1566
- const updatedResult = await query(`update auth.users set ${transactionSets.join(", ")}, updated_at = now() where id = $${transactionParams.length} returning *`, transactionParams);
1567
- const updated = updatedResult.rows[0];
1568
- if (!updated)
1569
- return authError(401, "no_authorization", "Invalid or expired token");
1570
- if (body.email)
1571
- await this.ensureEmailIdentity(updated, query);
1572
- return json(200, await this.userJsonWithRelations(updated, query));
1573
- });
1626
+ throw error;
1627
+ }
1574
1628
  return response;
1575
1629
  }
1576
1630
  async logout(req, url) {
@@ -1597,24 +1651,39 @@ class AuthHandler {
1597
1651
  }
1598
1652
  async issueToken(email, tokenType, createUser, flavor = "login") {
1599
1653
  const normalized = email.toLowerCase().trim();
1600
- let res = await this.db.query(`select * from auth.users where email = $1`, [normalized]);
1601
- let user = res.rows[0];
1602
- if (!user) {
1603
- if (!createUser)
1604
- return json(200, {});
1605
- if (this.settings.disableSignup)
1606
- return authError(422, "signup_disabled", "Signups not allowed for this instance");
1607
- res = await this.db.query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
1608
- values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
1609
- returning *`, [normalized]);
1610
- user = res.rows[0];
1611
- }
1612
- const code = randomOtp(this.settings.otpLength);
1613
- const linkToken = randomToken(24);
1614
1654
  const expiry = `${this.settings.otpExpirySeconds} seconds`;
1615
- await this.db.query(`delete from auth.one_time_tokens where email = $1 and token_type = $2`, [normalized, tokenType]);
1616
- await this.db.query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
1617
- values ($1, $2, $3, $4, now() + $7::interval), ($1, $2, $5, $6, now() + $7::interval)`, [user.id, normalized, tokenType, code, tokenType === "otp" ? "magiclink" : tokenType, linkToken, expiry]);
1655
+ const prepared = await this.db.transaction(async (query) => {
1656
+ let user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
1657
+ if (!user) {
1658
+ if (!createUser)
1659
+ return { kind: "unknown" };
1660
+ if (this.settings.disableSignup)
1661
+ return { kind: "signup_disabled" };
1662
+ const inserted = await query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
1663
+ values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
1664
+ on conflict (email) do nothing
1665
+ returning *`, [normalized]);
1666
+ user = inserted.rows[0];
1667
+ if (!user) {
1668
+ user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
1669
+ }
1670
+ }
1671
+ if (!user)
1672
+ return { kind: "unknown" };
1673
+ const code = randomOtp(this.settings.otpLength);
1674
+ const linkToken = randomToken(24);
1675
+ const tokenTypes = tokenType === "otp" ? ["otp", "magiclink"] : [tokenType];
1676
+ await query(`delete from auth.one_time_tokens where lower(email) = $1 and token_type = any($2::text[])`, [normalized, tokenTypes]);
1677
+ await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
1678
+ values ($1, $2, $3, $4, now() + $7::interval), ($1, $2, $5, $6, now() + $7::interval)`, [user.id, normalized, tokenType, code, tokenType === "otp" ? "magiclink" : tokenType, linkToken, expiry]);
1679
+ return { kind: "issued", user, code, linkToken };
1680
+ });
1681
+ if (prepared.kind === "unknown")
1682
+ return json(200, {});
1683
+ if (prepared.kind === "signup_disabled") {
1684
+ return authError(422, "signup_disabled", "Signups not allowed for this instance");
1685
+ }
1686
+ const { user, code, linkToken } = prepared;
1618
1687
  const kind = tokenType === "otp" ? "magiclink" : tokenType;
1619
1688
  const link = `${this.config.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
1620
1689
  await this.config.mailer.send({
@@ -1643,9 +1712,10 @@ Or sign in with this link: ${link}`
1643
1712
  return authError(400, "validation_failed", "phone must be a valid E.164 number");
1644
1713
  return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
1645
1714
  }
1646
- if (!body.email)
1715
+ const email = normalizeEmailInput(body.email);
1716
+ if (!email)
1647
1717
  return authError(400, "validation_failed", "email or phone is required");
1648
- return this.limit("otp", req) ?? this.issueToken(body.email, "otp", body.create_user !== false);
1718
+ return this.limit("otp", req) ?? this.issueToken(email, "otp", body.create_user !== false);
1649
1719
  }
1650
1720
  async issuePhoneToken(phone, createUser, metadata) {
1651
1721
  const sender = this.config.smsSender;
@@ -1799,9 +1869,10 @@ Or sign in with this link: ${link}`
1799
1869
  }
1800
1870
  async sendRecovery(req) {
1801
1871
  const body = await req.json().catch(() => ({}));
1802
- if (!body.email)
1872
+ const email = normalizeEmailInput(body.email);
1873
+ if (!email)
1803
1874
  return authError(400, "validation_failed", "email is required");
1804
- return this.issueToken(body.email, "recovery", false);
1875
+ return this.issueToken(email, "recovery", false);
1805
1876
  }
1806
1877
  static MAX_OTP_ATTEMPTS = 5;
1807
1878
  async redeem(token, types, email) {
@@ -1836,15 +1907,19 @@ Or sign in with this link: ${link}`
1836
1907
  return claimedTokens.rows[0] ?? null;
1837
1908
  }
1838
1909
  async confirmEmailTokenUser(query, claimedToken) {
1839
- await query(`delete from auth.one_time_tokens where email = $1`, [claimedToken.email]);
1910
+ await query(`delete from auth.one_time_tokens where lower(email) = lower($1)`, [claimedToken.email]);
1840
1911
  const users = await query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
1841
1912
  where id = $1 returning *`, [claimedToken.user_id]);
1842
- return users.rows[0] ?? null;
1913
+ const user = users.rows[0];
1914
+ if (!user)
1915
+ return null;
1916
+ await this.ensureEmailIdentity(user, query);
1917
+ return user;
1843
1918
  }
1844
1919
  async findEmailTokenCandidate(query, redemption) {
1845
1920
  const candidates = await query(`select id, user_id, email from auth.one_time_tokens
1846
1921
  where token = $1 and token_type = any($2::text[])
1847
- and ($3::text is null or email = $3) and expires_at > now()
1922
+ and ($3::text is null or lower(email) = lower($3)) and expires_at > now()
1848
1923
  and attempts < $4`, [redemption.token, redemption.tokenTypes, redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
1849
1924
  return candidates.rows[0] ?? null;
1850
1925
  }
@@ -1852,8 +1927,8 @@ Or sign in with this link: ${link}`
1852
1927
  if (!redemption.email)
1853
1928
  return;
1854
1929
  await query(`update auth.one_time_tokens set attempts = attempts + 1
1855
- where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
1856
- await query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
1930
+ where lower(email) = lower($1) and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
1931
+ await query(`delete from auth.one_time_tokens where lower(email) = lower($1) and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
1857
1932
  }
1858
1933
  async verifyToken(req) {
1859
1934
  const body = await req.json().catch(() => ({}));
@@ -1984,14 +2059,18 @@ Or sign in with this link: ${link}`
1984
2059
  }
1985
2060
  if (path === "admin/users" && method === "POST") {
1986
2061
  const body = await req.json().catch(() => ({}));
1987
- if (!body.email)
2062
+ const email = normalizeEmailInput(body.email);
2063
+ if (!email)
1988
2064
  return authError(400, "validation_failed", "email is required");
1989
- const email = body.email;
1990
2065
  const hashed = body.password ? await hashPassword(body.password) : null;
1991
2066
  const created = await this.db.transaction(async (query) => {
2067
+ const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email.toLowerCase().trim()]);
2068
+ if (existing.rows.length > 0)
2069
+ return null;
1992
2070
  const res = await query(`insert into auth.users
1993
2071
  (aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
1994
2072
  values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
2073
+ on conflict (email) do nothing
1995
2074
  returning *`, [
1996
2075
  email.toLowerCase().trim(),
1997
2076
  hashed,
@@ -2000,9 +2079,13 @@ Or sign in with this link: ${link}`
2000
2079
  JSON.stringify(body.user_metadata ?? {})
2001
2080
  ]);
2002
2081
  const user = res.rows[0];
2082
+ if (!user)
2083
+ return null;
2003
2084
  await this.ensureEmailIdentity(user, query);
2004
2085
  return this.userJsonWithRelations(user, query);
2005
2086
  });
2087
+ if (!created)
2088
+ return authError(422, "email_exists", "A user with this email address has already been registered");
2006
2089
  return json(200, created);
2007
2090
  }
2008
2091
  if (idMatch && method === "GET") {
@@ -2015,8 +2098,11 @@ Or sign in with this link: ${link}`
2015
2098
  const body = await req.json().catch(() => ({}));
2016
2099
  const sets = [];
2017
2100
  const params = [];
2018
- if (typeof body.email === "string") {
2019
- params.push(body.email.toLowerCase().trim());
2101
+ const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
2102
+ if (body.email !== undefined && !requestedEmail)
2103
+ return authError(400, "validation_failed", "email must not be empty");
2104
+ if (requestedEmail) {
2105
+ params.push(requestedEmail);
2020
2106
  sets.push(`email = $${params.length}`);
2021
2107
  }
2022
2108
  if (typeof body.password === "string") {
@@ -2036,15 +2122,33 @@ Or sign in with this link: ${link}`
2036
2122
  if (sets.length === 0)
2037
2123
  return authError(400, "validation_failed", "nothing to update");
2038
2124
  params.push(idMatch[1]);
2039
- const updated = await this.db.transaction(async (query) => {
2040
- const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
2041
- const user = res.rows[0];
2042
- if (!user)
2043
- return null;
2044
- if (typeof body.email === "string")
2045
- await this.ensureEmailIdentity(user, query);
2046
- return this.userJsonWithRelations(user, query);
2047
- });
2125
+ let updated;
2126
+ let emailConflict = false;
2127
+ try {
2128
+ updated = await this.db.transaction(async (query) => {
2129
+ if (requestedEmail) {
2130
+ const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [requestedEmail, idMatch[1]]);
2131
+ if (clash.rows.length > 0) {
2132
+ emailConflict = true;
2133
+ return null;
2134
+ }
2135
+ }
2136
+ const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
2137
+ const user = res.rows[0];
2138
+ if (!user)
2139
+ return null;
2140
+ if (requestedEmail)
2141
+ await this.ensureEmailIdentity(user, query);
2142
+ return this.userJsonWithRelations(user, query);
2143
+ });
2144
+ } catch (error) {
2145
+ if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
2146
+ return authError(422, "email_exists", "A user with this email address has already been registered");
2147
+ }
2148
+ throw error;
2149
+ }
2150
+ if (emailConflict)
2151
+ return authError(422, "email_exists", "A user with this email address has already been registered");
2048
2152
  if (!updated)
2049
2153
  return authError(404, "user_not_found", "User not found");
2050
2154
  return json(200, updated);
@@ -2059,26 +2163,30 @@ Or sign in with this link: ${link}`
2059
2163
  if (body.type !== "magiclink") {
2060
2164
  return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
2061
2165
  }
2062
- if (!body.email)
2166
+ const email = normalizeEmailInput(body.email);
2167
+ if (!email)
2063
2168
  return authError(400, "validation_failed", "email is required");
2064
- const email = body.email.toLowerCase().trim();
2065
2169
  const emailOtp = randomOtp(this.settings.otpLength);
2066
2170
  const hashedToken = await sha256Hex(randomToken(32));
2067
2171
  const expiry = `${this.settings.otpExpirySeconds} seconds`;
2068
2172
  const requestUrl = new URL(req.url);
2069
2173
  const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
2070
2174
  const user = await this.db.transaction(async (query) => {
2071
- const result = await query(`insert into auth.users
2072
- (aud, role, email, raw_app_meta_data, raw_user_meta_data)
2073
- values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
2074
- on conflict (email) do update set email = excluded.email
2075
- returning *`, [email, JSON.stringify(body.data ?? {})]);
2076
- const linkedUser = result.rows[0];
2175
+ const existing = await query(`select * from auth.users where lower(email) = $1 for update`, [email]);
2176
+ let linkedUser = existing.rows[0];
2177
+ if (!linkedUser) {
2178
+ const result = await query(`insert into auth.users
2179
+ (aud, role, email, raw_app_meta_data, raw_user_meta_data)
2180
+ values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
2181
+ on conflict (email) do update set email = excluded.email
2182
+ returning *`, [email, JSON.stringify(body.data ?? {})]);
2183
+ linkedUser = result.rows[0];
2184
+ }
2077
2185
  await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
2078
2186
  values ($1::uuid, 'email', $1::text, $2::jsonb)
2079
2187
  on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
2080
2188
  await query(`delete from auth.one_time_tokens
2081
- where email = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
2189
+ where lower(email) = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
2082
2190
  await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
2083
2191
  values ($1, $2, 'otp', $3, now() + $5::interval),
2084
2192
  ($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
@@ -2170,17 +2278,7 @@ Or sign in with this link: ${link}`
2170
2278
  if (!this.settings.totpEnrollEnabled) {
2171
2279
  return authError(422, "mfa_totp_enroll_disabled", "TOTP enrollment is disabled");
2172
2280
  }
2173
- const enrolled = await this.db.query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [user.id]);
2174
- if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
2175
- return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
2176
- }
2177
2281
  const friendlyName = body.friendly_name ?? null;
2178
- if (friendlyName) {
2179
- const dup = await this.db.query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
2180
- if (dup.rows.length > 0) {
2181
- return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
2182
- }
2183
- }
2184
2282
  const secret = generateTotpSecret();
2185
2283
  let issuer = body.issuer;
2186
2284
  if (!issuer) {
@@ -2190,16 +2288,33 @@ Or sign in with this link: ${link}`
2190
2288
  issuer = "supacloud-lite";
2191
2289
  }
2192
2290
  }
2193
- const uri = otpauthUri({ secret, account: user.email || user.id, issuer });
2194
- const ins = await this.db.query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status, secret)
2195
- values ($1, $2, 'totp', 'unverified', $3) returning id`, [user.id, friendlyName, secret]);
2196
- const id = ins.rows[0].id;
2197
- return json(200, {
2198
- id,
2199
- type: "totp",
2200
- friendly_name: friendlyName,
2201
- status: "unverified",
2202
- totp: { qr_code: qrSvgDataUri(uri), secret, uri }
2291
+ return this.db.transaction(async (query) => {
2292
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
2293
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2294
+ const current = currentResult.rows[0];
2295
+ if (!current)
2296
+ return authError(401, "no_authorization", "Invalid or expired token");
2297
+ const uri = otpauthUri({ secret, account: current.email || current.id, issuer });
2298
+ const enrolled = await query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [current.id]);
2299
+ if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
2300
+ return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
2301
+ }
2302
+ if (friendlyName) {
2303
+ const dup = await query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [current.id, friendlyName]);
2304
+ if (dup.rows.length > 0) {
2305
+ return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
2306
+ }
2307
+ }
2308
+ const ins = await query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status, secret)
2309
+ values ($1, $2, 'totp', 'unverified', $3) returning id`, [current.id, friendlyName, secret]);
2310
+ const id = ins.rows[0].id;
2311
+ return json(200, {
2312
+ id,
2313
+ type: "totp",
2314
+ friendly_name: friendlyName,
2315
+ status: "unverified",
2316
+ totp: { qr_code: qrSvgDataUri(uri), secret, uri }
2317
+ });
2203
2318
  });
2204
2319
  }
2205
2320
  async challengeFactor(req, factorId) {
@@ -2209,16 +2324,22 @@ Or sign in with this link: ${link}`
2209
2324
  if (!this.settings.totpVerifyEnabled) {
2210
2325
  return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
2211
2326
  }
2212
- const fr = await this.db.query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
2213
- if (fr.rows.length === 0)
2214
- return authError(404, "mfa_factor_not_found", "MFA factor not found");
2215
2327
  const expiresAt = new Date(Date.now() + 300000).toISOString();
2216
- const ins = await this.db.query(`insert into auth.mfa_challenges (factor_id, expires_at) values ($1, $2) returning id, expires_at`, [factorId, expiresAt]);
2217
- const c = ins.rows[0];
2218
- return json(200, {
2219
- id: c.id,
2220
- type: "totp",
2221
- expires_at: Math.floor(new Date(c.expires_at).getTime() / 1000)
2328
+ return this.db.transaction(async (query) => {
2329
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
2330
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2331
+ if (!currentResult.rows[0])
2332
+ return authError(401, "no_authorization", "Invalid or expired token");
2333
+ const fr = await query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
2334
+ if (fr.rows.length === 0)
2335
+ return authError(404, "mfa_factor_not_found", "MFA factor not found");
2336
+ const ins = await query(`insert into auth.mfa_challenges (factor_id, expires_at) values ($1, $2) returning id, expires_at`, [factorId, expiresAt]);
2337
+ const c = ins.rows[0];
2338
+ return json(200, {
2339
+ id: c.id,
2340
+ type: "totp",
2341
+ expires_at: Math.floor(new Date(c.expires_at).getTime() / 1000)
2342
+ });
2222
2343
  });
2223
2344
  }
2224
2345
  async verifyFactor(req, factorId) {
@@ -2252,12 +2373,17 @@ Or sign in with this link: ${link}`
2252
2373
  const currentUser = currentUsers.rows[0];
2253
2374
  if (!currentUser)
2254
2375
  return null;
2376
+ const currentFactors = await query(`select id, secret, status from auth.mfa_factors
2377
+ where id = $1 and user_id = $2 for update`, [factorId, user.id]);
2378
+ const currentFactor = currentFactors.rows[0];
2379
+ if (!currentFactor || !await verifyTotp(currentFactor.secret, body.code ?? ""))
2380
+ return null;
2255
2381
  const claimed = await query(`update auth.mfa_challenges set verified_at = now()
2256
2382
  where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
2257
2383
  returning id`, [challenge.id, factorId]);
2258
2384
  if (claimed.rows.length === 0)
2259
2385
  return null;
2260
- if (factor.status !== "verified") {
2386
+ if (currentFactor.status !== "verified") {
2261
2387
  await query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
2262
2388
  }
2263
2389
  return this.sessionFor(currentUser, undefined, {
@@ -2277,10 +2403,16 @@ Or sign in with this link: ${link}`
2277
2403
  const user = await this.userFromBearer(req);
2278
2404
  if (!user)
2279
2405
  return authError(401, "no_authorization", "This endpoint requires a Bearer token");
2280
- const del = await this.db.query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
2281
- if (del.rows.length === 0)
2282
- return authError(404, "mfa_factor_not_found", "MFA factor not found");
2283
- return json(200, { id: factorId });
2406
+ return this.db.transaction(async (query) => {
2407
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
2408
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2409
+ if (!currentResult.rows[0])
2410
+ return authError(401, "no_authorization", "Invalid or expired token");
2411
+ const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
2412
+ if (del.rows.length === 0)
2413
+ return authError(404, "mfa_factor_not_found", "MFA factor not found");
2414
+ return json(200, { id: factorId });
2415
+ });
2284
2416
  }
2285
2417
  async getUserFactors(userId, query = (sql, params) => this.db.query(sql, params)) {
2286
2418
  const res = await query(`select id, friendly_name, factor_type, status, created_at, updated_at