@supacloud/lite 0.5.10 → 0.6.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 dirname5, join as join9, resolve as resolve4 } from "path";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "@supacloud/lite",
11
- version: "0.5.10",
11
+ version: "0.6.0",
12
12
  description: "Bun-native, single-project Supabase-compatible backend powered by PGlite",
13
13
  type: "module",
14
14
  license: "Apache-2.0",
@@ -910,6 +910,10 @@ var DEFAULT_AUTH_SETTINGS = {
910
910
  disabledProviders: [],
911
911
  otpLength: 6,
912
912
  otpExpirySeconds: 3600,
913
+ smsEnabled: true,
914
+ smsSignupEnabled: true,
915
+ smsOtpCooldownSeconds: 60,
916
+ smsTemplate: "Your one-time code is {{ .Code }}",
913
917
  maxEnrolledFactors: 10,
914
918
  totpEnrollEnabled: true,
915
919
  totpVerifyEnabled: true
@@ -934,6 +938,16 @@ function sanitize(raw) {
934
938
  if (typeof raw.otpExpirySeconds === "number" && Number.isFinite(raw.otpExpirySeconds) && raw.otpExpirySeconds > 0) {
935
939
  s.otpExpirySeconds = Math.floor(raw.otpExpirySeconds);
936
940
  }
941
+ if (typeof raw.smsEnabled === "boolean")
942
+ s.smsEnabled = raw.smsEnabled;
943
+ if (typeof raw.smsSignupEnabled === "boolean")
944
+ s.smsSignupEnabled = raw.smsSignupEnabled;
945
+ if (typeof raw.smsOtpCooldownSeconds === "number" && Number.isFinite(raw.smsOtpCooldownSeconds)) {
946
+ s.smsOtpCooldownSeconds = Math.max(0, Math.min(86400, Math.floor(raw.smsOtpCooldownSeconds)));
947
+ }
948
+ if (typeof raw.smsTemplate === "string" && raw.smsTemplate.length > 0 && raw.smsTemplate.length <= 1000 && /\{\{\s*\.Code\s*\}\}/.test(raw.smsTemplate)) {
949
+ s.smsTemplate = raw.smsTemplate;
950
+ }
937
951
  if (typeof raw.maxEnrolledFactors === "number" && Number.isFinite(raw.maxEnrolledFactors) && raw.maxEnrolledFactors > 0) {
938
952
  s.maxEnrolledFactors = Math.floor(raw.maxEnrolledFactors);
939
953
  }
@@ -959,7 +973,9 @@ var DEFAULT_AUTH_RATE_LIMITS = {
959
973
  token: { limit: 30, windowMs: 5 * 60 * 1000 },
960
974
  signup: { limit: 30, windowMs: 60 * 60 * 1000 },
961
975
  otp: { limit: 10, windowMs: 60 * 60 * 1000 },
962
- recover: { limit: 10, windowMs: 60 * 60 * 1000 }
976
+ recover: { limit: 10, windowMs: 60 * 60 * 1000 },
977
+ sms: { limit: 10, windowMs: 60 * 60 * 1000 },
978
+ verify: { limit: 30, windowMs: 5 * 60 * 1000 }
963
979
  };
964
980
 
965
981
  class RateLimiter {
@@ -1083,13 +1099,51 @@ function authError(status, errorCode, msg) {
1083
1099
  }
1084
1100
  function randomOtp(length) {
1085
1101
  const n = Math.max(6, Math.min(10, Math.floor(length)));
1086
- const buf = new Uint32Array(n);
1087
- crypto.getRandomValues(buf);
1088
1102
  let code = "";
1089
- for (let i = 0;i < n; i++)
1090
- code += String(buf[i] % 10);
1103
+ while (code.length < n) {
1104
+ const bytes = crypto.getRandomValues(new Uint8Array(Math.max(16, n - code.length)));
1105
+ for (const byte of bytes) {
1106
+ if (byte >= 250)
1107
+ continue;
1108
+ code += String(byte % 10);
1109
+ if (code.length === n)
1110
+ break;
1111
+ }
1112
+ }
1091
1113
  return code;
1092
1114
  }
1115
+ function normalizePhone(value) {
1116
+ if (typeof value !== "string")
1117
+ return null;
1118
+ const phone = value.trim();
1119
+ return /^\+[1-9]\d{7,14}$/.test(phone) ? phone : null;
1120
+ }
1121
+ function recordValue(value) {
1122
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1123
+ }
1124
+ function databaseDiagnosticCode(error) {
1125
+ const candidate = typeof error === "object" && error !== null && "code" in error ? error.code : null;
1126
+ return typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate) ? candidate : "database_error";
1127
+ }
1128
+ async function keyedDigest(secret, domain, value) {
1129
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
1130
+ const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${domain}\x00${value}`));
1131
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
1132
+ }
1133
+ async function sendSmsUntilAbort(sender, message, signal) {
1134
+ if (signal.aborted)
1135
+ throw new Error("Phone delivery aborted");
1136
+ let rejectForAbort;
1137
+ const aborted = new Promise((_resolve, reject) => {
1138
+ rejectForAbort = () => reject(new Error("Phone delivery aborted"));
1139
+ signal.addEventListener("abort", rejectForAbort, { once: true });
1140
+ });
1141
+ try {
1142
+ await Promise.race([sender.send(message, { signal }), aborted]);
1143
+ } finally {
1144
+ signal.removeEventListener("abort", rejectForAbort);
1145
+ }
1146
+ }
1093
1147
  function json(status, body) {
1094
1148
  return new Response(status === 204 ? null : JSON.stringify(body), {
1095
1149
  status,
@@ -1111,9 +1165,14 @@ function timestampMs(value) {
1111
1165
  class AuthHandler {
1112
1166
  db;
1113
1167
  config;
1168
+ static PHONE_DELIVERY_TIMEOUT_MS = 15000;
1169
+ static PHONE_DELIVERY_DRAIN_MS = 250;
1114
1170
  oauth;
1115
1171
  settings;
1116
1172
  rateLimiter;
1173
+ phoneDeliveries = new Set;
1174
+ phoneDeliveryControllers = new Set;
1175
+ stopping = false;
1117
1176
  constructor(db, config) {
1118
1177
  this.db = db;
1119
1178
  this.config = config;
@@ -1122,16 +1181,41 @@ class AuthHandler {
1122
1181
  this.rateLimiter = config.rateLimiter === undefined ? new RateLimiter : config.rateLimiter;
1123
1182
  }
1124
1183
  limit(action, req) {
1184
+ const client = req.headers.get("x-supacloud-lite-remote-addr") ?? "local";
1185
+ return this.limitKey(action, `ip:${client}`);
1186
+ }
1187
+ limitKey(action, key) {
1125
1188
  if (!this.rateLimiter)
1126
1189
  return null;
1127
- const client = req.headers.get("x-supacloud-lite-remote-addr") ?? "local";
1128
- const retryAfter = this.rateLimiter.check(action, client);
1190
+ const retryAfter = this.rateLimiter.check(action, key);
1129
1191
  if (retryAfter === null)
1130
1192
  return null;
1131
1193
  return new Response(JSON.stringify({ code: 429, error_code: "over_request_rate_limit", msg: "Request rate limit reached" }), { status: 429, headers: { "content-type": "application/json; charset=utf-8", "retry-after": String(retryAfter) } });
1132
1194
  }
1133
- stop() {
1195
+ async stop() {
1134
1196
  this.rateLimiter?.stop();
1197
+ this.stopping = true;
1198
+ if (await this.phoneDeliveriesSettledBeforeDeadline())
1199
+ return;
1200
+ for (const controller of this.phoneDeliveryControllers)
1201
+ controller.abort();
1202
+ await Promise.all(this.phoneDeliveries);
1203
+ }
1204
+ async phoneDeliveriesSettledBeforeDeadline() {
1205
+ if (this.phoneDeliveries.size === 0)
1206
+ return true;
1207
+ let deadlineTimer;
1208
+ const deadline = new Promise((resolve) => {
1209
+ deadlineTimer = setTimeout(() => resolve(false), AuthHandler.PHONE_DELIVERY_DRAIN_MS);
1210
+ });
1211
+ try {
1212
+ return await Promise.race([
1213
+ Promise.all(this.phoneDeliveries).then(() => true),
1214
+ deadline
1215
+ ]);
1216
+ } finally {
1217
+ clearTimeout(deadlineTimer);
1218
+ }
1135
1219
  }
1136
1220
  async handle(req, ctx, url) {
1137
1221
  const path = url.pathname.replace(/^\/auth\/v1\/?/, "").replace(/\/+$/, "");
@@ -1144,7 +1228,7 @@ class AuthHandler {
1144
1228
  return json(200, {
1145
1229
  external: {
1146
1230
  email: true,
1147
- phone: false,
1231
+ phone: this.settings.smsEnabled && this.config.smsSender != null,
1148
1232
  anonymous_users: this.settings.anonymousUsers,
1149
1233
  ...Object.fromEntries(providers.map((p) => [p, !this.settings.disabledProviders.includes(p)]))
1150
1234
  },
@@ -1165,13 +1249,13 @@ class AuthHandler {
1165
1249
  if (path === "logout" && method === "POST")
1166
1250
  return await this.logout(req, url);
1167
1251
  if (path === "otp" && method === "POST")
1168
- return this.limit("otp", req) ?? await this.sendOtp(req);
1252
+ return await this.sendOtp(req);
1169
1253
  if (path === "recover" && method === "POST")
1170
1254
  return this.limit("recover", req) ?? await this.sendRecovery(req);
1171
1255
  if (["magiclink", "resend"].includes(path) && method === "POST")
1172
- return this.limit("otp", req) ?? await this.sendOtp(req);
1256
+ return await this.sendOtp(req);
1173
1257
  if (path === "verify" && method === "POST")
1174
- return await this.verifyToken(req);
1258
+ return this.limit("verify", req) ?? await this.verifyToken(req);
1175
1259
  if (path === "verify" && method === "GET")
1176
1260
  return await this.verifyLink(url);
1177
1261
  if (path === "factors" && method === "POST")
@@ -1393,9 +1477,170 @@ Or sign in with this link: ${link}`
1393
1477
  }
1394
1478
  async sendOtp(req) {
1395
1479
  const body = await req.json().catch(() => ({}));
1480
+ if (body.email && body.phone)
1481
+ return authError(400, "validation_failed", "email and phone are mutually exclusive");
1482
+ if (body.phone !== undefined) {
1483
+ if (body.channel !== undefined && body.channel !== "sms") {
1484
+ return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
1485
+ }
1486
+ const phone = normalizePhone(body.phone);
1487
+ if (!phone)
1488
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
1489
+ return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
1490
+ }
1396
1491
  if (!body.email)
1397
- return authError(400, "validation_failed", "email is required");
1398
- return this.issueToken(body.email, "otp", body.create_user !== false);
1492
+ return authError(400, "validation_failed", "email or phone is required");
1493
+ return this.limit("otp", req) ?? this.issueToken(body.email, "otp", body.create_user !== false);
1494
+ }
1495
+ async issuePhoneToken(phone, createUser, metadata) {
1496
+ const sender = this.config.smsSender;
1497
+ if (!this.settings.smsEnabled || !sender) {
1498
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
1499
+ }
1500
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
1501
+ const limited = this.limitKey("sms", `phone:${fingerprint}`);
1502
+ if (limited)
1503
+ return limited;
1504
+ const code = randomOtp(this.settings.otpLength);
1505
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
1506
+ const issuanceId = crypto.randomUUID();
1507
+ const prepared = await this.preparePhoneOtp({
1508
+ phone,
1509
+ createUser,
1510
+ metadata,
1511
+ fingerprint,
1512
+ tokenDigest,
1513
+ issuanceId,
1514
+ eligibleBefore: new Date(Date.now() - this.settings.smsOtpCooldownSeconds * 1000).toISOString(),
1515
+ expiry: `${this.settings.otpExpirySeconds} seconds`
1516
+ });
1517
+ if (prepared.state === "cooldown") {
1518
+ return authError(429, "over_sms_send_rate_limit", "SMS can only be requested after the cooldown");
1519
+ }
1520
+ if (prepared.state === "signup_disabled") {
1521
+ return authError(422, "signup_disabled", "Signups not allowed for this instance");
1522
+ }
1523
+ if (prepared.state === "unknown")
1524
+ return json(200, {});
1525
+ const body = this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, code);
1526
+ const delivery = createUser ? this.deliverPhoneOtp(prepared, sender, phone, body) : this.deferPhoneOtpDelivery(prepared, sender, phone, body);
1527
+ this.trackPhoneDelivery(delivery);
1528
+ if (!createUser) {
1529
+ return json(200, {});
1530
+ }
1531
+ return await delivery ? json(200, {}) : authError(502, "sms_provider_failed", "Unable to send the verification code");
1532
+ }
1533
+ async preparePhoneOtp(request) {
1534
+ try {
1535
+ return await this.db.transaction(async (query) => {
1536
+ const cooldown = await query(`insert into auth.phone_otp_cooldowns (phone_fingerprint, issuance_id, last_sent_at)
1537
+ values ($1, $2, now())
1538
+ on conflict (phone_fingerprint) do update
1539
+ set issuance_id = excluded.issuance_id, last_sent_at = excluded.last_sent_at
1540
+ where auth.phone_otp_cooldowns.last_sent_at <= $3::timestamptz
1541
+ returning phone_fingerprint`, [request.fingerprint, request.issuanceId, request.eligibleBefore]);
1542
+ if (cooldown.rows.length === 0)
1543
+ return { state: "cooldown" };
1544
+ const resolved = await this.phoneUser(query, request);
1545
+ if (resolved === "unknown" || resolved === "signup_disabled")
1546
+ return { state: resolved };
1547
+ await this.createPhoneIdentity(query, resolved);
1548
+ await query(`delete from auth.one_time_tokens where phone = $1 and token_type = 'sms'`, [request.phone]);
1549
+ await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
1550
+ values ($1, $2, $3, 'sms', $4, now() + $5::interval)`, [request.issuanceId, resolved.id, request.phone, request.tokenDigest, request.expiry]);
1551
+ return {
1552
+ state: "issued",
1553
+ id: request.issuanceId,
1554
+ fingerprint: request.fingerprint,
1555
+ tokenDigest: request.tokenDigest,
1556
+ releaseCooldownOnFailure: request.createUser
1557
+ };
1558
+ });
1559
+ } catch (error) {
1560
+ this.reportPhoneFailure("issue", databaseDiagnosticCode(error));
1561
+ throw new Error("Unable to issue the verification code", { cause: error });
1562
+ }
1563
+ }
1564
+ async phoneUser(query, request) {
1565
+ const existing = await this.phoneUserForUpdate(query, request.phone);
1566
+ if (existing)
1567
+ return existing.auth_eligible ? existing : "unknown";
1568
+ if (!request.createUser)
1569
+ return "unknown";
1570
+ if (this.settings.disableSignup || !this.settings.smsSignupEnabled)
1571
+ return "signup_disabled";
1572
+ const inserted = await query(`insert into auth.users (aud, role, phone, raw_app_meta_data, raw_user_meta_data)
1573
+ values ('authenticated', 'authenticated', $1, $2::jsonb, $3::jsonb)
1574
+ on conflict (phone) do nothing returning *`, [
1575
+ request.phone,
1576
+ JSON.stringify({ provider: "phone", providers: ["phone"] }),
1577
+ JSON.stringify(request.metadata)
1578
+ ]);
1579
+ if (inserted.rows[0])
1580
+ return inserted.rows[0];
1581
+ const concurrent = await this.phoneUserForUpdate(query, request.phone);
1582
+ if (!concurrent)
1583
+ throw new Error("phone user could not be resolved");
1584
+ return concurrent.auth_eligible ? concurrent : "unknown";
1585
+ }
1586
+ async phoneUserForUpdate(query, phone) {
1587
+ const users = await query(`select *, deleted_at is null and (banned_until is null or banned_until <= now()) as auth_eligible
1588
+ from auth.users where phone = $1 for update`, [phone]);
1589
+ return users.rows[0];
1590
+ }
1591
+ createPhoneIdentity(query, user) {
1592
+ return query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1593
+ values ($1, 'phone', $2, $3::jsonb)
1594
+ on conflict (provider, provider_id) do nothing`, [user.id, user.phone, JSON.stringify({ sub: user.id, phone: user.phone, phone_verified: user.phone_confirmed_at != null })]);
1595
+ }
1596
+ async deliverPhoneOtp(issuance, sender, phone, body) {
1597
+ try {
1598
+ await this.sendPhoneOtp(sender, phone, body);
1599
+ return true;
1600
+ } catch {
1601
+ this.reportPhoneFailure("deliver", "provider_error");
1602
+ await this.cleanupFailedPhoneOtp(issuance);
1603
+ return false;
1604
+ }
1605
+ }
1606
+ async sendPhoneOtp(sender, phone, body) {
1607
+ const controller = new AbortController;
1608
+ this.phoneDeliveryControllers.add(controller);
1609
+ const deliveryTimer = setTimeout(() => controller.abort(), AuthHandler.PHONE_DELIVERY_TIMEOUT_MS);
1610
+ if (this.stopping)
1611
+ controller.abort();
1612
+ try {
1613
+ await sendSmsUntilAbort(sender, { to: phone, body }, controller.signal);
1614
+ } finally {
1615
+ clearTimeout(deliveryTimer);
1616
+ this.phoneDeliveryControllers.delete(controller);
1617
+ }
1618
+ }
1619
+ async cleanupFailedPhoneOtp(issuance) {
1620
+ try {
1621
+ await this.db.transaction(async (query) => {
1622
+ await query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [issuance.id, issuance.tokenDigest]);
1623
+ if (issuance.releaseCooldownOnFailure) {
1624
+ await query(`delete from auth.phone_otp_cooldowns where phone_fingerprint = $1 and issuance_id = $2`, [issuance.fingerprint, issuance.id]);
1625
+ }
1626
+ });
1627
+ } catch (cleanupError) {
1628
+ this.reportPhoneFailure("cleanup", databaseDiagnosticCode(cleanupError));
1629
+ }
1630
+ }
1631
+ async deferPhoneOtpDelivery(issuance, sender, phone, body) {
1632
+ await new Promise((resolve) => setTimeout(resolve, 0));
1633
+ return this.deliverPhoneOtp(issuance, sender, phone, body);
1634
+ }
1635
+ trackPhoneDelivery(delivery) {
1636
+ let tracked;
1637
+ tracked = delivery.then(() => {}).finally(() => this.phoneDeliveries.delete(tracked));
1638
+ this.phoneDeliveries.add(tracked);
1639
+ }
1640
+ reportPhoneFailure(operation, code) {
1641
+ try {
1642
+ this.config.log?.(`[auth] phone_otp_${operation} failed code=${code}`);
1643
+ } catch {}
1399
1644
  }
1400
1645
  async sendRecovery(req) {
1401
1646
  const body = await req.json().catch(() => ({}));
@@ -1429,12 +1674,67 @@ Or sign in with this link: ${link}`
1429
1674
  const body = await req.json().catch(() => ({}));
1430
1675
  if (!body.token)
1431
1676
  return authError(400, "validation_failed", "token is required");
1677
+ if (body.type === "sms" || body.phone !== undefined) {
1678
+ const phone = normalizePhone(body.phone);
1679
+ if (body.type !== "sms" || !phone || !/^\d{6,10}$/.test(body.token)) {
1680
+ return authError(400, "validation_failed", "phone, token, and type=sms are required");
1681
+ }
1682
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
1683
+ const limited = this.limitKey("verify", `phone:${fingerprint}`);
1684
+ if (limited)
1685
+ return limited;
1686
+ let session;
1687
+ try {
1688
+ session = await this.redeemPhoneOtp(phone, body.token);
1689
+ } catch (error) {
1690
+ this.reportPhoneFailure("verify", databaseDiagnosticCode(error));
1691
+ return authError(500, "unexpected_failure", "Unable to verify the code");
1692
+ }
1693
+ if (!session)
1694
+ return authError(403, "otp_expired", "Token has expired or is invalid");
1695
+ return json(200, session);
1696
+ }
1432
1697
  const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : ["otp", "magiclink"];
1433
1698
  const user = await this.redeem(body.token, types, body.email);
1434
1699
  if (!user)
1435
1700
  return authError(403, "otp_expired", "Token has expired or is invalid");
1436
1701
  return json(200, await this.sessionFor(user));
1437
1702
  }
1703
+ async redeemPhoneOtp(phone, code) {
1704
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
1705
+ return this.db.transaction(async (query) => {
1706
+ const claimed = await query(`delete from auth.one_time_tokens
1707
+ where phone = $1 and token_type = 'sms' and token = $2
1708
+ and expires_at > now() and attempts < $3
1709
+ returning user_id`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
1710
+ const userId = claimed.rows[0]?.user_id;
1711
+ if (!userId) {
1712
+ const attempt = await query(`update auth.one_time_tokens set attempts = attempts + 1
1713
+ where phone = $1 and token_type = 'sms' and expires_at > now()
1714
+ returning id, attempts`, [phone]);
1715
+ const row = attempt.rows[0];
1716
+ if (row && row.attempts >= AuthHandler.MAX_OTP_ATTEMPTS) {
1717
+ await query(`delete from auth.one_time_tokens where id = $1`, [row.id]);
1718
+ }
1719
+ await query(`delete from auth.one_time_tokens where phone = $1 and expires_at <= now()`, [phone]);
1720
+ return null;
1721
+ }
1722
+ await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
1723
+ const users = await query(`update auth.users
1724
+ set phone_confirmed_at = coalesce(phone_confirmed_at, now()), last_sign_in_at = now(), updated_at = now()
1725
+ where id = $1 and deleted_at is null
1726
+ and (banned_until is null or banned_until <= now())
1727
+ returning *`, [userId]);
1728
+ const user = users.rows[0];
1729
+ if (!user)
1730
+ return null;
1731
+ await query(`insert into auth.identities (user_id, provider, provider_id, identity_data, last_sign_in_at)
1732
+ values ($1, 'phone', $2, $3::jsonb, now())
1733
+ on conflict (provider, provider_id) do update
1734
+ set identity_data = excluded.identity_data, last_sign_in_at = now(), updated_at = now()`, [user.id, phone, JSON.stringify({ sub: user.id, phone, phone_verified: true })]);
1735
+ return this.sessionFor(user, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
1736
+ });
1737
+ }
1438
1738
  async verifyLink(url) {
1439
1739
  const token = url.searchParams.get("token") ?? "";
1440
1740
  const type = url.searchParams.get("type") ?? "magiclink";
@@ -1777,7 +2077,8 @@ Or sign in with this link: ${link}`
1777
2077
  email: u.email ?? "",
1778
2078
  email_confirmed_at: iso(u.email_confirmed_at),
1779
2079
  phone: u.phone ?? "",
1780
- confirmed_at: iso(u.email_confirmed_at),
2080
+ phone_confirmed_at: iso(u.phone_confirmed_at),
2081
+ confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
1781
2082
  last_sign_in_at: iso(u.last_sign_in_at),
1782
2083
  app_metadata: u.raw_app_meta_data ?? {},
1783
2084
  user_metadata: u.raw_user_meta_data ?? {},
@@ -1941,6 +2242,58 @@ var INBOX_HTML = `<!doctype html>
1941
2242
  </body>
1942
2243
  </html>`;
1943
2244
 
2245
+ // src/runtime/auth/sms-inbox.ts
2246
+ var CAP2 = 200;
2247
+
2248
+ class SmsInbox {
2249
+ messages = [];
2250
+ async send(msg) {
2251
+ const id = crypto.randomUUID();
2252
+ this.messages.unshift({
2253
+ ...msg,
2254
+ id,
2255
+ created_at: new Date().toISOString(),
2256
+ code: msg.body.match(/\b\d{6,10}\b/)?.[0] ?? null
2257
+ });
2258
+ if (this.messages.length > CAP2)
2259
+ this.messages.length = CAP2;
2260
+ return { messageId: id };
2261
+ }
2262
+ list() {
2263
+ return this.messages;
2264
+ }
2265
+ clear() {
2266
+ this.messages = [];
2267
+ }
2268
+ serve(req, url) {
2269
+ const method = req.method.toUpperCase();
2270
+ if (url.pathname === "/sms-inbox/api/messages") {
2271
+ if (method === "DELETE") {
2272
+ this.clear();
2273
+ return new Response(null, { status: 204 });
2274
+ }
2275
+ return Response.json({ messages: this.messages });
2276
+ }
2277
+ if (url.pathname === "/sms-inbox" || url.pathname === "/sms-inbox/") {
2278
+ return new Response(SMS_INBOX_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
2279
+ }
2280
+ return Response.json({ error: "not found" }, { status: 404 });
2281
+ }
2282
+ }
2283
+ var SMS_INBOX_HTML = `<!doctype html>
2284
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
2285
+ <title>SupaCloud Lite \xB7 SMS Inbox</title><style>
2286
+ :root{color-scheme:dark}body{margin:0;background:#0a0a0a;color:#fafafa;font:14px/1.5 system-ui,sans-serif}
2287
+ header,main{max-width:760px;margin:auto;padding:20px}.msg{border:1px solid #27272a;border-radius:12px;padding:16px;margin:12px 0}
2288
+ .code{font:20px ui-monospace,monospace;letter-spacing:3px;color:#34d399}.muted{color:#a1a1aa}button{padding:6px 12px}
2289
+ </style></head><body><header><h1>SupaCloud Lite \xB7 SMS Inbox</h1><p class="muted">Loopback local development only</p>
2290
+ <button id="clear">Clear</button></header><main id="list">Loading\u2026</main><script>
2291
+ const esc=s=>s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
2292
+ async function load(){const r=await fetch('/sms-inbox/api/messages');const {messages}=await r.json();
2293
+ document.getElementById('list').innerHTML=messages.length?messages.map(m=>'<div class="msg"><div class="muted">to '+esc(m.to)+' \xB7 '+esc(new Date(m.created_at).toLocaleTimeString())+'</div><div class="code">'+esc(m.code||'')+'</div></div>').join(''):'No SMS messages yet.'}
2294
+ document.getElementById('clear').onclick=async()=>{await fetch('/sms-inbox/api/messages',{method:'DELETE'});load()};load();setInterval(load,4000)
2295
+ </script></body></html>`;
2296
+
1944
2297
  // src/runtime/log-buffer.ts
1945
2298
  class LogBuffer {
1946
2299
  cap;
@@ -2262,12 +2615,34 @@ create table if not exists auth.refresh_tokens (
2262
2615
  create table if not exists auth.one_time_tokens (
2263
2616
  id uuid primary key default gen_random_uuid(),
2264
2617
  user_id uuid,
2265
- email text not null,
2618
+ email text,
2619
+ phone text,
2266
2620
  token_type text not null,
2267
2621
  token text not null,
2268
2622
  attempts int not null default 0,
2269
2623
  created_at timestamptz default now(),
2270
- expires_at timestamptz not null
2624
+ expires_at timestamptz not null,
2625
+ constraint one_time_tokens_contact_check check (
2626
+ (email is not null and phone is null) or (email is null and phone is not null)
2627
+ )
2628
+ );
2629
+
2630
+ -- Minimal engines can persist a database created by an older Lite version.
2631
+ -- Standard ALTER statements keep that path compatible without requiring plpgsql.
2632
+ alter table auth.one_time_tokens add column if not exists phone text;
2633
+ alter table auth.one_time_tokens alter column email drop not null;
2634
+ alter table auth.one_time_tokens drop constraint if exists one_time_tokens_contact_check;
2635
+ alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
2636
+ (email is not null and phone is null) or (email is null and phone is not null)
2637
+ );
2638
+
2639
+ create unique index if not exists one_time_tokens_phone_type_idx
2640
+ on auth.one_time_tokens(phone, token_type) where phone is not null;
2641
+
2642
+ create table if not exists auth.phone_otp_cooldowns (
2643
+ phone_fingerprint text primary key,
2644
+ issuance_id uuid not null,
2645
+ last_sent_at timestamptz not null default now()
2271
2646
  );
2272
2647
 
2273
2648
  create table if not exists auth.identities (
@@ -2332,6 +2707,7 @@ create table if not exists storage.buckets (
2332
2707
  id text primary key,
2333
2708
  name text not null unique,
2334
2709
  owner uuid,
2710
+ owner_id text,
2335
2711
  public boolean default false,
2336
2712
  file_size_limit bigint,
2337
2713
  allowed_mime_types text[],
@@ -2339,11 +2715,17 @@ create table if not exists storage.buckets (
2339
2715
  updated_at timestamptz default now()
2340
2716
  );
2341
2717
 
2718
+ alter table storage.buckets add column if not exists owner_id text;
2719
+ update storage.buckets
2720
+ set owner_id = owner::text
2721
+ where owner_id is null and owner is not null;
2722
+
2342
2723
  create table if not exists storage.objects (
2343
2724
  id uuid primary key default gen_random_uuid(),
2344
2725
  bucket_id text not null,
2345
2726
  name text not null,
2346
2727
  owner uuid,
2728
+ owner_id text,
2347
2729
  version text,
2348
2730
  metadata jsonb default '{}'::jsonb,
2349
2731
  created_at timestamptz default now(),
@@ -2352,6 +2734,11 @@ create table if not exists storage.objects (
2352
2734
  unique (bucket_id, name)
2353
2735
  );
2354
2736
 
2737
+ alter table storage.objects add column if not exists owner_id text;
2738
+ update storage.objects
2739
+ set owner_id = owner::text
2740
+ where owner_id is null and owner is not null;
2741
+
2355
2742
  create table if not exists supabase_migrations.schema_migrations (
2356
2743
  version text primary key,
2357
2744
  name text,
@@ -2498,12 +2885,43 @@ create index if not exists refresh_tokens_user_id_idx on auth.refresh_tokens(use
2498
2885
  create table if not exists auth.one_time_tokens (
2499
2886
  id uuid primary key default gen_random_uuid(),
2500
2887
  user_id uuid references auth.users(id) on delete cascade,
2501
- email text not null,
2502
- token_type text not null, -- otp | magiclink | recovery
2888
+ email text,
2889
+ phone text,
2890
+ token_type text not null, -- otp | magiclink | recovery | sms
2503
2891
  token text not null,
2504
2892
  attempts int not null default 0,
2505
2893
  created_at timestamptz default now(),
2506
- expires_at timestamptz not null
2894
+ expires_at timestamptz not null,
2895
+ constraint one_time_tokens_contact_check check (
2896
+ (email is not null and phone is null) or (email is null and phone is not null)
2897
+ )
2898
+ );
2899
+
2900
+ -- Upgrade databases created by Lite <=0.5.9 without touching existing email tokens.
2901
+ alter table auth.one_time_tokens add column if not exists phone text;
2902
+ alter table auth.one_time_tokens alter column email drop not null;
2903
+ do $phone_otp_contact_constraint$
2904
+ begin
2905
+ if not exists (
2906
+ select 1 from pg_constraint
2907
+ where conrelid = 'auth.one_time_tokens'::regclass
2908
+ and conname = 'one_time_tokens_contact_check'
2909
+ ) then
2910
+ alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
2911
+ (email is not null and phone is null) or (email is null and phone is not null)
2912
+ );
2913
+ end if;
2914
+ end $phone_otp_contact_constraint$;
2915
+
2916
+ create unique index if not exists one_time_tokens_phone_type_idx
2917
+ on auth.one_time_tokens(phone, token_type) where phone is not null;
2918
+
2919
+ -- Only a keyed phone fingerprint is persisted for cooldown enforcement; the
2920
+ -- normalized phone number never enters this table.
2921
+ create table if not exists auth.phone_otp_cooldowns (
2922
+ phone_fingerprint text primary key,
2923
+ issuance_id uuid not null,
2924
+ last_sent_at timestamptz not null default now()
2507
2925
  );
2508
2926
 
2509
2927
  create table if not exists auth.identities (
@@ -2603,6 +3021,7 @@ create table if not exists storage.buckets (
2603
3021
  id text primary key,
2604
3022
  name text not null unique,
2605
3023
  owner uuid,
3024
+ owner_id text,
2606
3025
  public boolean default false,
2607
3026
  file_size_limit bigint,
2608
3027
  allowed_mime_types text[],
@@ -2610,11 +3029,17 @@ create table if not exists storage.buckets (
2610
3029
  updated_at timestamptz default now()
2611
3030
  );
2612
3031
 
3032
+ alter table storage.buckets add column if not exists owner_id text;
3033
+ update storage.buckets
3034
+ set owner_id = owner::text
3035
+ where owner_id is null and owner is not null;
3036
+
2613
3037
  create table if not exists storage.objects (
2614
3038
  id uuid primary key default gen_random_uuid(),
2615
3039
  bucket_id text not null references storage.buckets(id),
2616
3040
  name text not null,
2617
3041
  owner uuid,
3042
+ owner_id text,
2618
3043
  version text,
2619
3044
  metadata jsonb default '{}'::jsonb,
2620
3045
  created_at timestamptz default now(),
@@ -2623,6 +3048,14 @@ create table if not exists storage.objects (
2623
3048
  unique (bucket_id, name)
2624
3049
  );
2625
3050
 
3051
+ -- storage-api keeps the legacy UUID owner and the current text owner_id in
3052
+ -- parallel. Re-running bootstrap upgrades existing Lite databases and retains
3053
+ -- object ownership for rows created before owner_id support was added.
3054
+ alter table storage.objects add column if not exists owner_id text;
3055
+ update storage.objects
3056
+ set owner_id = owner::text
3057
+ where owner_id is null and owner is not null;
3058
+
2626
3059
  create index if not exists objects_bucket_name_idx on storage.objects(bucket_id, name);
2627
3060
 
2628
3061
  grant usage on schema storage to anon, authenticated, service_role;
@@ -6028,6 +6461,12 @@ function invalidObjectKey(key) {
6028
6461
  return "object key must not contain . or .. segments";
6029
6462
  return null;
6030
6463
  }
6464
+ var LEGACY_OWNER_UUID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
6465
+ function storageObjectOwnership(ctx) {
6466
+ const ownerId = typeof ctx.claims?.sub === "string" ? ctx.claims.sub : null;
6467
+ const legacyOwner = ownerId !== null && LEGACY_OWNER_UUID_PATTERN.test(ownerId) ? ownerId : null;
6468
+ return { legacyOwner, ownerId };
6469
+ }
6031
6470
  function storageError(status, error, message) {
6032
6471
  return json3(status, { statusCode: String(status), error, message });
6033
6472
  }
@@ -6366,16 +6805,18 @@ class StorageHandler {
6366
6805
  }
6367
6806
  async persistObject(ctx, bucketId, key, bytes, contentType, cacheControl, upsert) {
6368
6807
  const metadata = objectMetadata(bytes.length, contentType, cacheControl);
6808
+ const ownership = storageObjectOwnership(ctx);
6369
6809
  const previous = (await this.db.query(`select * from storage.objects where bucket_id = $1 and name = $2`, [bucketId, key])).rows[0];
6370
6810
  const objectId = previous?.id ?? crypto.randomUUID();
6371
6811
  const version = createObjectVersion();
6372
6812
  const stagedKey = await this.stageObjectBytes(version, bytes);
6373
6813
  const conflictClause = upsert ? `on conflict (bucket_id, name) do update
6374
- set metadata = excluded.metadata, owner = excluded.owner, updated_at = now(), version = excluded.version` : "";
6814
+ set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
6815
+ updated_at = now(), version = excluded.version` : "";
6375
6816
  let inserted;
6376
6817
  try {
6377
- const result = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, metadata, version)
6378
- values ($1, $2, $3, $4, $5::jsonb, $6) ${conflictClause} returning *`, [objectId, bucketId, key, ctx.claims?.sub ?? null, JSON.stringify(metadata), version]));
6818
+ const result = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
6819
+ values ($1, $2, $3, $4::uuid, $5, $6::jsonb, $7) ${conflictClause} returning *`, [objectId, bucketId, key, ownership.legacyOwner, ownership.ownerId, JSON.stringify(metadata), version]));
6379
6820
  inserted = result.rows[0];
6380
6821
  } catch (error) {
6381
6822
  if (isRlsDenied(error)) {
@@ -6599,15 +7040,18 @@ class StorageHandler {
6599
7040
  return bucket.file_size_limit != null ? Number(bucket.file_size_limit) : this.config.defaultFileSizeLimit ?? DEFAULT_FILE_SIZE_LIMIT;
6600
7041
  }
6601
7042
  async preflightObjectWrite(ctx, bucketId, key, size, contentType, cacheControl, upsert) {
7043
+ const ownership = storageObjectOwnership(ctx);
6602
7044
  const conflictClause = upsert ? `on conflict (bucket_id, name) do update
6603
- set metadata = excluded.metadata, owner = excluded.owner, updated_at = now(), version = excluded.version` : "";
7045
+ set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
7046
+ updated_at = now(), version = excluded.version` : "";
6604
7047
  try {
6605
7048
  await this.db.withContext(ctx, async (query) => {
6606
- await query(`insert into storage.objects (bucket_id, name, owner, metadata, version)
6607
- values ($1, $2, $3, $4::jsonb, $5) ${conflictClause} returning id`, [
7049
+ await query(`insert into storage.objects (bucket_id, name, owner, owner_id, metadata, version)
7050
+ values ($1, $2, $3::uuid, $4, $5::jsonb, $6) ${conflictClause} returning id`, [
6608
7051
  bucketId,
6609
7052
  key,
6610
- ctx.claims?.sub ?? null,
7053
+ ownership.legacyOwner,
7054
+ ownership.ownerId,
6611
7055
  JSON.stringify(objectMetadata(size, contentType ?? "application/octet-stream", cacheControl ?? "no-cache")),
6612
7056
  createObjectVersion()
6613
7057
  ]);
@@ -6732,11 +7176,21 @@ class StorageHandler {
6732
7176
  return json3(200, { message: "Successfully moved" });
6733
7177
  }
6734
7178
  const copyId = crypto.randomUUID();
7179
+ const ownership = storageObjectOwnership(ctx);
6735
7180
  try {
6736
- const copied = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, metadata, version)
6737
- select $1, $4, $5, $6, metadata, $7
7181
+ const copied = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
7182
+ select $1, $4, $5, $6::uuid, $7, metadata, $8
6738
7183
  from storage.objects where bucket_id = $2 and name = $3
6739
- returning *`, [copyId, body.bucketId, body.sourceKey, dstBucket, body.destinationKey, ctx.claims?.sub ?? null, version]));
7184
+ returning *`, [
7185
+ copyId,
7186
+ body.bucketId,
7187
+ body.sourceKey,
7188
+ dstBucket,
7189
+ body.destinationKey,
7190
+ ownership.legacyOwner,
7191
+ ownership.ownerId,
7192
+ version
7193
+ ]));
6740
7194
  if (copied.rows.length === 0)
6741
7195
  throw new Error("storage copy source disappeared");
6742
7196
  } catch (error) {
@@ -7572,6 +8026,7 @@ class RetentionService {
7572
8026
  async runSweep() {
7573
8027
  const now = this.now();
7574
8028
  await this.run(`delete from auth.one_time_tokens where expires_at < now()`);
8029
+ await this.run(`delete from auth.phone_otp_cooldowns where last_sent_at < now() - interval '1 day'`);
7575
8030
  await this.run(`delete from auth.mfa_challenges where expires_at < now()`);
7576
8031
  await this.run(`delete from auth.flow_state where expires_at < now()`);
7577
8032
  await this.run(`delete from public.supacloud_pgredis_kv where expires_at <= now()`);
@@ -8073,6 +8528,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
8073
8528
  log(`[mail] to=${msg.to} subject="${msg.subject}"`);
8074
8529
  }
8075
8530
  };
8531
+ const smsInbox = config.smsSender || exposed ? null : new SmsInbox;
8532
+ const smsSender = config.smsSender ?? smsInbox;
8076
8533
  const authSettings = await loadAuthSettings(db, config.authSettings);
8077
8534
  const storage = new StorageHandler(db, config.storageDriver ?? new MemoryStorageDriver, {
8078
8535
  jwtSecret,
@@ -8089,6 +8546,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
8089
8546
  sessionTimeboxSeconds: config.sessionTimeboxSeconds,
8090
8547
  sessionInactivitySeconds: config.sessionInactivitySeconds,
8091
8548
  mailer,
8549
+ smsSender,
8550
+ log,
8092
8551
  oauthProviders: config.oauthProviders,
8093
8552
  oauthFetch: config.oauthFetch,
8094
8553
  uriAllowList: config.uriAllowList,
@@ -8177,6 +8636,9 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
8177
8636
  if (inbox && (path === "/inbox" || path.startsWith("/inbox/"))) {
8178
8637
  return withCors(inbox.serve(req, url));
8179
8638
  }
8639
+ if (smsInbox && (path === "/sms-inbox" || path.startsWith("/sms-inbox/"))) {
8640
+ return withCors(smsInbox.serve(req, url));
8641
+ }
8180
8642
  if (path.startsWith("/storage/v1/object/public/") || path.startsWith("/storage/v1/object/sign/") || path.startsWith("/storage/v1/render/image/public/") || path.startsWith("/storage/v1/render/image/sign/")) {
8181
8643
  if (req.method === "GET" || req.method === "HEAD") {
8182
8644
  return withCors(await storage.handle(req, { role: "anon", claims: null }, url));
@@ -8271,6 +8733,7 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
8271
8733
  jwtSecret,
8272
8734
  logs,
8273
8735
  inbox,
8736
+ smsInbox,
8274
8737
  migrate: (migrations, seedSql) => db.runMigrations(migrations, seedSql),
8275
8738
  close: () => {
8276
8739
  closePromise ??= (async () => {
@@ -8724,6 +9187,7 @@ function readAuth(root, env) {
8724
9187
  function readAuthSettings(root) {
8725
9188
  const auth = tableAt(root, "auth");
8726
9189
  const email = tableAt(root, "auth.email");
9190
+ const sms = tableAt(root, "auth.sms");
8727
9191
  const mfa = tableAt(root, "auth.mfa");
8728
9192
  const mfaTotp = tableAt(root, "auth.mfa.totp");
8729
9193
  const out = {};
@@ -8748,6 +9212,18 @@ function readAuthSettings(root) {
8748
9212
  const otpExpiry = getInt(email, "otp_expiry");
8749
9213
  if (otpExpiry !== undefined)
8750
9214
  out.otpExpirySeconds = otpExpiry;
9215
+ const smsEnabled = getBool(sms, "enabled");
9216
+ if (smsEnabled !== undefined)
9217
+ out.smsEnabled = smsEnabled;
9218
+ const smsSignup = getBool(sms, "enable_signup");
9219
+ if (smsSignup !== undefined)
9220
+ out.smsSignupEnabled = smsSignup;
9221
+ const smsFrequency = getDurationSeconds(sms, "max_frequency");
9222
+ if (smsFrequency !== undefined)
9223
+ out.smsOtpCooldownSeconds = smsFrequency;
9224
+ const smsTemplate = getString(sms, "template");
9225
+ if (smsTemplate !== undefined)
9226
+ out.smsTemplate = smsTemplate;
8751
9227
  const maxFactors = getInt(mfa, "max_enrolled_factors");
8752
9228
  if (maxFactors !== undefined)
8753
9229
  out.maxEnrolledFactors = maxFactors;
@@ -8779,6 +9255,9 @@ function readRateLimits(root) {
8779
9255
  out.otp = { limit: email, windowMs: ONE_HOUR };
8780
9256
  out.recover = { limit: email, windowMs: ONE_HOUR };
8781
9257
  }
9258
+ const sms = getInt(rl, "sms_sent");
9259
+ if (sms !== undefined)
9260
+ out.sms = { limit: sms, windowMs: ONE_HOUR };
8782
9261
  return Object.keys(out).length ? out : undefined;
8783
9262
  }
8784
9263
  function readOAuthProviders(root, env) {
@@ -9316,6 +9795,7 @@ async function createProjectBackend(options = {}) {
9316
9795
  sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
9317
9796
  sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
9318
9797
  oauthProviders: config.auth.oauthProviders,
9798
+ smsSender: options.smsSender,
9319
9799
  dbSchemas: config.api.schemas,
9320
9800
  maxRows: config.api.maxRows,
9321
9801
  storageFileSizeLimit: config.storage.fileSizeLimit,