@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/index.js CHANGED
@@ -71,7 +71,7 @@ function randomToken(bytes = 32) {
71
71
  // package.json
72
72
  var package_default = {
73
73
  name: "@supacloud/lite",
74
- version: "0.5.10",
74
+ version: "0.6.0",
75
75
  description: "Bun-native, single-project Supabase-compatible backend powered by PGlite",
76
76
  type: "module",
77
77
  license: "Apache-2.0",
@@ -915,6 +915,10 @@ var DEFAULT_AUTH_SETTINGS = {
915
915
  disabledProviders: [],
916
916
  otpLength: 6,
917
917
  otpExpirySeconds: 3600,
918
+ smsEnabled: true,
919
+ smsSignupEnabled: true,
920
+ smsOtpCooldownSeconds: 60,
921
+ smsTemplate: "Your one-time code is {{ .Code }}",
918
922
  maxEnrolledFactors: 10,
919
923
  totpEnrollEnabled: true,
920
924
  totpVerifyEnabled: true
@@ -939,6 +943,16 @@ function sanitize(raw) {
939
943
  if (typeof raw.otpExpirySeconds === "number" && Number.isFinite(raw.otpExpirySeconds) && raw.otpExpirySeconds > 0) {
940
944
  s.otpExpirySeconds = Math.floor(raw.otpExpirySeconds);
941
945
  }
946
+ if (typeof raw.smsEnabled === "boolean")
947
+ s.smsEnabled = raw.smsEnabled;
948
+ if (typeof raw.smsSignupEnabled === "boolean")
949
+ s.smsSignupEnabled = raw.smsSignupEnabled;
950
+ if (typeof raw.smsOtpCooldownSeconds === "number" && Number.isFinite(raw.smsOtpCooldownSeconds)) {
951
+ s.smsOtpCooldownSeconds = Math.max(0, Math.min(86400, Math.floor(raw.smsOtpCooldownSeconds)));
952
+ }
953
+ if (typeof raw.smsTemplate === "string" && raw.smsTemplate.length > 0 && raw.smsTemplate.length <= 1000 && /\{\{\s*\.Code\s*\}\}/.test(raw.smsTemplate)) {
954
+ s.smsTemplate = raw.smsTemplate;
955
+ }
942
956
  if (typeof raw.maxEnrolledFactors === "number" && Number.isFinite(raw.maxEnrolledFactors) && raw.maxEnrolledFactors > 0) {
943
957
  s.maxEnrolledFactors = Math.floor(raw.maxEnrolledFactors);
944
958
  }
@@ -964,7 +978,9 @@ var DEFAULT_AUTH_RATE_LIMITS = {
964
978
  token: { limit: 30, windowMs: 5 * 60 * 1000 },
965
979
  signup: { limit: 30, windowMs: 60 * 60 * 1000 },
966
980
  otp: { limit: 10, windowMs: 60 * 60 * 1000 },
967
- recover: { limit: 10, windowMs: 60 * 60 * 1000 }
981
+ recover: { limit: 10, windowMs: 60 * 60 * 1000 },
982
+ sms: { limit: 10, windowMs: 60 * 60 * 1000 },
983
+ verify: { limit: 30, windowMs: 5 * 60 * 1000 }
968
984
  };
969
985
 
970
986
  class RateLimiter {
@@ -1088,13 +1104,51 @@ function authError(status, errorCode, msg) {
1088
1104
  }
1089
1105
  function randomOtp(length) {
1090
1106
  const n = Math.max(6, Math.min(10, Math.floor(length)));
1091
- const buf = new Uint32Array(n);
1092
- crypto.getRandomValues(buf);
1093
1107
  let code = "";
1094
- for (let i = 0;i < n; i++)
1095
- code += String(buf[i] % 10);
1108
+ while (code.length < n) {
1109
+ const bytes = crypto.getRandomValues(new Uint8Array(Math.max(16, n - code.length)));
1110
+ for (const byte of bytes) {
1111
+ if (byte >= 250)
1112
+ continue;
1113
+ code += String(byte % 10);
1114
+ if (code.length === n)
1115
+ break;
1116
+ }
1117
+ }
1096
1118
  return code;
1097
1119
  }
1120
+ function normalizePhone(value) {
1121
+ if (typeof value !== "string")
1122
+ return null;
1123
+ const phone = value.trim();
1124
+ return /^\+[1-9]\d{7,14}$/.test(phone) ? phone : null;
1125
+ }
1126
+ function recordValue(value) {
1127
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1128
+ }
1129
+ function databaseDiagnosticCode(error) {
1130
+ const candidate = typeof error === "object" && error !== null && "code" in error ? error.code : null;
1131
+ return typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate) ? candidate : "database_error";
1132
+ }
1133
+ async function keyedDigest(secret, domain, value) {
1134
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
1135
+ const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${domain}\x00${value}`));
1136
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
1137
+ }
1138
+ async function sendSmsUntilAbort(sender, message, signal) {
1139
+ if (signal.aborted)
1140
+ throw new Error("Phone delivery aborted");
1141
+ let rejectForAbort;
1142
+ const aborted = new Promise((_resolve, reject) => {
1143
+ rejectForAbort = () => reject(new Error("Phone delivery aborted"));
1144
+ signal.addEventListener("abort", rejectForAbort, { once: true });
1145
+ });
1146
+ try {
1147
+ await Promise.race([sender.send(message, { signal }), aborted]);
1148
+ } finally {
1149
+ signal.removeEventListener("abort", rejectForAbort);
1150
+ }
1151
+ }
1098
1152
  function json(status, body) {
1099
1153
  return new Response(status === 204 ? null : JSON.stringify(body), {
1100
1154
  status,
@@ -1116,9 +1170,14 @@ function timestampMs(value) {
1116
1170
  class AuthHandler {
1117
1171
  db;
1118
1172
  config;
1173
+ static PHONE_DELIVERY_TIMEOUT_MS = 15000;
1174
+ static PHONE_DELIVERY_DRAIN_MS = 250;
1119
1175
  oauth;
1120
1176
  settings;
1121
1177
  rateLimiter;
1178
+ phoneDeliveries = new Set;
1179
+ phoneDeliveryControllers = new Set;
1180
+ stopping = false;
1122
1181
  constructor(db, config) {
1123
1182
  this.db = db;
1124
1183
  this.config = config;
@@ -1127,16 +1186,41 @@ class AuthHandler {
1127
1186
  this.rateLimiter = config.rateLimiter === undefined ? new RateLimiter : config.rateLimiter;
1128
1187
  }
1129
1188
  limit(action, req) {
1189
+ const client = req.headers.get("x-supacloud-lite-remote-addr") ?? "local";
1190
+ return this.limitKey(action, `ip:${client}`);
1191
+ }
1192
+ limitKey(action, key) {
1130
1193
  if (!this.rateLimiter)
1131
1194
  return null;
1132
- const client = req.headers.get("x-supacloud-lite-remote-addr") ?? "local";
1133
- const retryAfter = this.rateLimiter.check(action, client);
1195
+ const retryAfter = this.rateLimiter.check(action, key);
1134
1196
  if (retryAfter === null)
1135
1197
  return null;
1136
1198
  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) } });
1137
1199
  }
1138
- stop() {
1200
+ async stop() {
1139
1201
  this.rateLimiter?.stop();
1202
+ this.stopping = true;
1203
+ if (await this.phoneDeliveriesSettledBeforeDeadline())
1204
+ return;
1205
+ for (const controller of this.phoneDeliveryControllers)
1206
+ controller.abort();
1207
+ await Promise.all(this.phoneDeliveries);
1208
+ }
1209
+ async phoneDeliveriesSettledBeforeDeadline() {
1210
+ if (this.phoneDeliveries.size === 0)
1211
+ return true;
1212
+ let deadlineTimer;
1213
+ const deadline = new Promise((resolve) => {
1214
+ deadlineTimer = setTimeout(() => resolve(false), AuthHandler.PHONE_DELIVERY_DRAIN_MS);
1215
+ });
1216
+ try {
1217
+ return await Promise.race([
1218
+ Promise.all(this.phoneDeliveries).then(() => true),
1219
+ deadline
1220
+ ]);
1221
+ } finally {
1222
+ clearTimeout(deadlineTimer);
1223
+ }
1140
1224
  }
1141
1225
  async handle(req, ctx, url) {
1142
1226
  const path = url.pathname.replace(/^\/auth\/v1\/?/, "").replace(/\/+$/, "");
@@ -1149,7 +1233,7 @@ class AuthHandler {
1149
1233
  return json(200, {
1150
1234
  external: {
1151
1235
  email: true,
1152
- phone: false,
1236
+ phone: this.settings.smsEnabled && this.config.smsSender != null,
1153
1237
  anonymous_users: this.settings.anonymousUsers,
1154
1238
  ...Object.fromEntries(providers.map((p) => [p, !this.settings.disabledProviders.includes(p)]))
1155
1239
  },
@@ -1170,13 +1254,13 @@ class AuthHandler {
1170
1254
  if (path === "logout" && method === "POST")
1171
1255
  return await this.logout(req, url);
1172
1256
  if (path === "otp" && method === "POST")
1173
- return this.limit("otp", req) ?? await this.sendOtp(req);
1257
+ return await this.sendOtp(req);
1174
1258
  if (path === "recover" && method === "POST")
1175
1259
  return this.limit("recover", req) ?? await this.sendRecovery(req);
1176
1260
  if (["magiclink", "resend"].includes(path) && method === "POST")
1177
- return this.limit("otp", req) ?? await this.sendOtp(req);
1261
+ return await this.sendOtp(req);
1178
1262
  if (path === "verify" && method === "POST")
1179
- return await this.verifyToken(req);
1263
+ return this.limit("verify", req) ?? await this.verifyToken(req);
1180
1264
  if (path === "verify" && method === "GET")
1181
1265
  return await this.verifyLink(url);
1182
1266
  if (path === "factors" && method === "POST")
@@ -1398,9 +1482,170 @@ Or sign in with this link: ${link}`
1398
1482
  }
1399
1483
  async sendOtp(req) {
1400
1484
  const body = await req.json().catch(() => ({}));
1485
+ if (body.email && body.phone)
1486
+ return authError(400, "validation_failed", "email and phone are mutually exclusive");
1487
+ if (body.phone !== undefined) {
1488
+ if (body.channel !== undefined && body.channel !== "sms") {
1489
+ return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
1490
+ }
1491
+ const phone = normalizePhone(body.phone);
1492
+ if (!phone)
1493
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
1494
+ return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
1495
+ }
1401
1496
  if (!body.email)
1402
- return authError(400, "validation_failed", "email is required");
1403
- return this.issueToken(body.email, "otp", body.create_user !== false);
1497
+ return authError(400, "validation_failed", "email or phone is required");
1498
+ return this.limit("otp", req) ?? this.issueToken(body.email, "otp", body.create_user !== false);
1499
+ }
1500
+ async issuePhoneToken(phone, createUser, metadata) {
1501
+ const sender = this.config.smsSender;
1502
+ if (!this.settings.smsEnabled || !sender) {
1503
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
1504
+ }
1505
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
1506
+ const limited = this.limitKey("sms", `phone:${fingerprint}`);
1507
+ if (limited)
1508
+ return limited;
1509
+ const code = randomOtp(this.settings.otpLength);
1510
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
1511
+ const issuanceId = crypto.randomUUID();
1512
+ const prepared = await this.preparePhoneOtp({
1513
+ phone,
1514
+ createUser,
1515
+ metadata,
1516
+ fingerprint,
1517
+ tokenDigest,
1518
+ issuanceId,
1519
+ eligibleBefore: new Date(Date.now() - this.settings.smsOtpCooldownSeconds * 1000).toISOString(),
1520
+ expiry: `${this.settings.otpExpirySeconds} seconds`
1521
+ });
1522
+ if (prepared.state === "cooldown") {
1523
+ return authError(429, "over_sms_send_rate_limit", "SMS can only be requested after the cooldown");
1524
+ }
1525
+ if (prepared.state === "signup_disabled") {
1526
+ return authError(422, "signup_disabled", "Signups not allowed for this instance");
1527
+ }
1528
+ if (prepared.state === "unknown")
1529
+ return json(200, {});
1530
+ const body = this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, code);
1531
+ const delivery = createUser ? this.deliverPhoneOtp(prepared, sender, phone, body) : this.deferPhoneOtpDelivery(prepared, sender, phone, body);
1532
+ this.trackPhoneDelivery(delivery);
1533
+ if (!createUser) {
1534
+ return json(200, {});
1535
+ }
1536
+ return await delivery ? json(200, {}) : authError(502, "sms_provider_failed", "Unable to send the verification code");
1537
+ }
1538
+ async preparePhoneOtp(request) {
1539
+ try {
1540
+ return await this.db.transaction(async (query) => {
1541
+ const cooldown = await query(`insert into auth.phone_otp_cooldowns (phone_fingerprint, issuance_id, last_sent_at)
1542
+ values ($1, $2, now())
1543
+ on conflict (phone_fingerprint) do update
1544
+ set issuance_id = excluded.issuance_id, last_sent_at = excluded.last_sent_at
1545
+ where auth.phone_otp_cooldowns.last_sent_at <= $3::timestamptz
1546
+ returning phone_fingerprint`, [request.fingerprint, request.issuanceId, request.eligibleBefore]);
1547
+ if (cooldown.rows.length === 0)
1548
+ return { state: "cooldown" };
1549
+ const resolved = await this.phoneUser(query, request);
1550
+ if (resolved === "unknown" || resolved === "signup_disabled")
1551
+ return { state: resolved };
1552
+ await this.createPhoneIdentity(query, resolved);
1553
+ await query(`delete from auth.one_time_tokens where phone = $1 and token_type = 'sms'`, [request.phone]);
1554
+ await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
1555
+ values ($1, $2, $3, 'sms', $4, now() + $5::interval)`, [request.issuanceId, resolved.id, request.phone, request.tokenDigest, request.expiry]);
1556
+ return {
1557
+ state: "issued",
1558
+ id: request.issuanceId,
1559
+ fingerprint: request.fingerprint,
1560
+ tokenDigest: request.tokenDigest,
1561
+ releaseCooldownOnFailure: request.createUser
1562
+ };
1563
+ });
1564
+ } catch (error) {
1565
+ this.reportPhoneFailure("issue", databaseDiagnosticCode(error));
1566
+ throw new Error("Unable to issue the verification code", { cause: error });
1567
+ }
1568
+ }
1569
+ async phoneUser(query, request) {
1570
+ const existing = await this.phoneUserForUpdate(query, request.phone);
1571
+ if (existing)
1572
+ return existing.auth_eligible ? existing : "unknown";
1573
+ if (!request.createUser)
1574
+ return "unknown";
1575
+ if (this.settings.disableSignup || !this.settings.smsSignupEnabled)
1576
+ return "signup_disabled";
1577
+ const inserted = await query(`insert into auth.users (aud, role, phone, raw_app_meta_data, raw_user_meta_data)
1578
+ values ('authenticated', 'authenticated', $1, $2::jsonb, $3::jsonb)
1579
+ on conflict (phone) do nothing returning *`, [
1580
+ request.phone,
1581
+ JSON.stringify({ provider: "phone", providers: ["phone"] }),
1582
+ JSON.stringify(request.metadata)
1583
+ ]);
1584
+ if (inserted.rows[0])
1585
+ return inserted.rows[0];
1586
+ const concurrent = await this.phoneUserForUpdate(query, request.phone);
1587
+ if (!concurrent)
1588
+ throw new Error("phone user could not be resolved");
1589
+ return concurrent.auth_eligible ? concurrent : "unknown";
1590
+ }
1591
+ async phoneUserForUpdate(query, phone) {
1592
+ const users = await query(`select *, deleted_at is null and (banned_until is null or banned_until <= now()) as auth_eligible
1593
+ from auth.users where phone = $1 for update`, [phone]);
1594
+ return users.rows[0];
1595
+ }
1596
+ createPhoneIdentity(query, user) {
1597
+ return query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1598
+ values ($1, 'phone', $2, $3::jsonb)
1599
+ 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 })]);
1600
+ }
1601
+ async deliverPhoneOtp(issuance, sender, phone, body) {
1602
+ try {
1603
+ await this.sendPhoneOtp(sender, phone, body);
1604
+ return true;
1605
+ } catch {
1606
+ this.reportPhoneFailure("deliver", "provider_error");
1607
+ await this.cleanupFailedPhoneOtp(issuance);
1608
+ return false;
1609
+ }
1610
+ }
1611
+ async sendPhoneOtp(sender, phone, body) {
1612
+ const controller = new AbortController;
1613
+ this.phoneDeliveryControllers.add(controller);
1614
+ const deliveryTimer = setTimeout(() => controller.abort(), AuthHandler.PHONE_DELIVERY_TIMEOUT_MS);
1615
+ if (this.stopping)
1616
+ controller.abort();
1617
+ try {
1618
+ await sendSmsUntilAbort(sender, { to: phone, body }, controller.signal);
1619
+ } finally {
1620
+ clearTimeout(deliveryTimer);
1621
+ this.phoneDeliveryControllers.delete(controller);
1622
+ }
1623
+ }
1624
+ async cleanupFailedPhoneOtp(issuance) {
1625
+ try {
1626
+ await this.db.transaction(async (query) => {
1627
+ await query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [issuance.id, issuance.tokenDigest]);
1628
+ if (issuance.releaseCooldownOnFailure) {
1629
+ await query(`delete from auth.phone_otp_cooldowns where phone_fingerprint = $1 and issuance_id = $2`, [issuance.fingerprint, issuance.id]);
1630
+ }
1631
+ });
1632
+ } catch (cleanupError) {
1633
+ this.reportPhoneFailure("cleanup", databaseDiagnosticCode(cleanupError));
1634
+ }
1635
+ }
1636
+ async deferPhoneOtpDelivery(issuance, sender, phone, body) {
1637
+ await new Promise((resolve) => setTimeout(resolve, 0));
1638
+ return this.deliverPhoneOtp(issuance, sender, phone, body);
1639
+ }
1640
+ trackPhoneDelivery(delivery) {
1641
+ let tracked;
1642
+ tracked = delivery.then(() => {}).finally(() => this.phoneDeliveries.delete(tracked));
1643
+ this.phoneDeliveries.add(tracked);
1644
+ }
1645
+ reportPhoneFailure(operation, code) {
1646
+ try {
1647
+ this.config.log?.(`[auth] phone_otp_${operation} failed code=${code}`);
1648
+ } catch {}
1404
1649
  }
1405
1650
  async sendRecovery(req) {
1406
1651
  const body = await req.json().catch(() => ({}));
@@ -1434,12 +1679,67 @@ Or sign in with this link: ${link}`
1434
1679
  const body = await req.json().catch(() => ({}));
1435
1680
  if (!body.token)
1436
1681
  return authError(400, "validation_failed", "token is required");
1682
+ if (body.type === "sms" || body.phone !== undefined) {
1683
+ const phone = normalizePhone(body.phone);
1684
+ if (body.type !== "sms" || !phone || !/^\d{6,10}$/.test(body.token)) {
1685
+ return authError(400, "validation_failed", "phone, token, and type=sms are required");
1686
+ }
1687
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
1688
+ const limited = this.limitKey("verify", `phone:${fingerprint}`);
1689
+ if (limited)
1690
+ return limited;
1691
+ let session;
1692
+ try {
1693
+ session = await this.redeemPhoneOtp(phone, body.token);
1694
+ } catch (error) {
1695
+ this.reportPhoneFailure("verify", databaseDiagnosticCode(error));
1696
+ return authError(500, "unexpected_failure", "Unable to verify the code");
1697
+ }
1698
+ if (!session)
1699
+ return authError(403, "otp_expired", "Token has expired or is invalid");
1700
+ return json(200, session);
1701
+ }
1437
1702
  const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : ["otp", "magiclink"];
1438
1703
  const user = await this.redeem(body.token, types, body.email);
1439
1704
  if (!user)
1440
1705
  return authError(403, "otp_expired", "Token has expired or is invalid");
1441
1706
  return json(200, await this.sessionFor(user));
1442
1707
  }
1708
+ async redeemPhoneOtp(phone, code) {
1709
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
1710
+ return this.db.transaction(async (query) => {
1711
+ const claimed = await query(`delete from auth.one_time_tokens
1712
+ where phone = $1 and token_type = 'sms' and token = $2
1713
+ and expires_at > now() and attempts < $3
1714
+ returning user_id`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
1715
+ const userId = claimed.rows[0]?.user_id;
1716
+ if (!userId) {
1717
+ const attempt = await query(`update auth.one_time_tokens set attempts = attempts + 1
1718
+ where phone = $1 and token_type = 'sms' and expires_at > now()
1719
+ returning id, attempts`, [phone]);
1720
+ const row = attempt.rows[0];
1721
+ if (row && row.attempts >= AuthHandler.MAX_OTP_ATTEMPTS) {
1722
+ await query(`delete from auth.one_time_tokens where id = $1`, [row.id]);
1723
+ }
1724
+ await query(`delete from auth.one_time_tokens where phone = $1 and expires_at <= now()`, [phone]);
1725
+ return null;
1726
+ }
1727
+ await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
1728
+ const users = await query(`update auth.users
1729
+ set phone_confirmed_at = coalesce(phone_confirmed_at, now()), last_sign_in_at = now(), updated_at = now()
1730
+ where id = $1 and deleted_at is null
1731
+ and (banned_until is null or banned_until <= now())
1732
+ returning *`, [userId]);
1733
+ const user = users.rows[0];
1734
+ if (!user)
1735
+ return null;
1736
+ await query(`insert into auth.identities (user_id, provider, provider_id, identity_data, last_sign_in_at)
1737
+ values ($1, 'phone', $2, $3::jsonb, now())
1738
+ on conflict (provider, provider_id) do update
1739
+ 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 })]);
1740
+ return this.sessionFor(user, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
1741
+ });
1742
+ }
1443
1743
  async verifyLink(url) {
1444
1744
  const token = url.searchParams.get("token") ?? "";
1445
1745
  const type = url.searchParams.get("type") ?? "magiclink";
@@ -1782,7 +2082,8 @@ Or sign in with this link: ${link}`
1782
2082
  email: u.email ?? "",
1783
2083
  email_confirmed_at: iso(u.email_confirmed_at),
1784
2084
  phone: u.phone ?? "",
1785
- confirmed_at: iso(u.email_confirmed_at),
2085
+ phone_confirmed_at: iso(u.phone_confirmed_at),
2086
+ confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
1786
2087
  last_sign_in_at: iso(u.last_sign_in_at),
1787
2088
  app_metadata: u.raw_app_meta_data ?? {},
1788
2089
  user_metadata: u.raw_user_meta_data ?? {},
@@ -1946,6 +2247,58 @@ var INBOX_HTML = `<!doctype html>
1946
2247
  </body>
1947
2248
  </html>`;
1948
2249
 
2250
+ // src/runtime/auth/sms-inbox.ts
2251
+ var CAP2 = 200;
2252
+
2253
+ class SmsInbox {
2254
+ messages = [];
2255
+ async send(msg) {
2256
+ const id = crypto.randomUUID();
2257
+ this.messages.unshift({
2258
+ ...msg,
2259
+ id,
2260
+ created_at: new Date().toISOString(),
2261
+ code: msg.body.match(/\b\d{6,10}\b/)?.[0] ?? null
2262
+ });
2263
+ if (this.messages.length > CAP2)
2264
+ this.messages.length = CAP2;
2265
+ return { messageId: id };
2266
+ }
2267
+ list() {
2268
+ return this.messages;
2269
+ }
2270
+ clear() {
2271
+ this.messages = [];
2272
+ }
2273
+ serve(req, url) {
2274
+ const method = req.method.toUpperCase();
2275
+ if (url.pathname === "/sms-inbox/api/messages") {
2276
+ if (method === "DELETE") {
2277
+ this.clear();
2278
+ return new Response(null, { status: 204 });
2279
+ }
2280
+ return Response.json({ messages: this.messages });
2281
+ }
2282
+ if (url.pathname === "/sms-inbox" || url.pathname === "/sms-inbox/") {
2283
+ return new Response(SMS_INBOX_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
2284
+ }
2285
+ return Response.json({ error: "not found" }, { status: 404 });
2286
+ }
2287
+ }
2288
+ var SMS_INBOX_HTML = `<!doctype html>
2289
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
2290
+ <title>SupaCloud Lite \xB7 SMS Inbox</title><style>
2291
+ :root{color-scheme:dark}body{margin:0;background:#0a0a0a;color:#fafafa;font:14px/1.5 system-ui,sans-serif}
2292
+ header,main{max-width:760px;margin:auto;padding:20px}.msg{border:1px solid #27272a;border-radius:12px;padding:16px;margin:12px 0}
2293
+ .code{font:20px ui-monospace,monospace;letter-spacing:3px;color:#34d399}.muted{color:#a1a1aa}button{padding:6px 12px}
2294
+ </style></head><body><header><h1>SupaCloud Lite \xB7 SMS Inbox</h1><p class="muted">Loopback local development only</p>
2295
+ <button id="clear">Clear</button></header><main id="list">Loading\u2026</main><script>
2296
+ const esc=s=>s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
2297
+ async function load(){const r=await fetch('/sms-inbox/api/messages');const {messages}=await r.json();
2298
+ 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.'}
2299
+ document.getElementById('clear').onclick=async()=>{await fetch('/sms-inbox/api/messages',{method:'DELETE'});load()};load();setInterval(load,4000)
2300
+ </script></body></html>`;
2301
+
1949
2302
  // src/runtime/log-buffer.ts
1950
2303
  class LogBuffer {
1951
2304
  cap;
@@ -2267,12 +2620,34 @@ create table if not exists auth.refresh_tokens (
2267
2620
  create table if not exists auth.one_time_tokens (
2268
2621
  id uuid primary key default gen_random_uuid(),
2269
2622
  user_id uuid,
2270
- email text not null,
2623
+ email text,
2624
+ phone text,
2271
2625
  token_type text not null,
2272
2626
  token text not null,
2273
2627
  attempts int not null default 0,
2274
2628
  created_at timestamptz default now(),
2275
- expires_at timestamptz not null
2629
+ expires_at timestamptz not null,
2630
+ constraint one_time_tokens_contact_check check (
2631
+ (email is not null and phone is null) or (email is null and phone is not null)
2632
+ )
2633
+ );
2634
+
2635
+ -- Minimal engines can persist a database created by an older Lite version.
2636
+ -- Standard ALTER statements keep that path compatible without requiring plpgsql.
2637
+ alter table auth.one_time_tokens add column if not exists phone text;
2638
+ alter table auth.one_time_tokens alter column email drop not null;
2639
+ alter table auth.one_time_tokens drop constraint if exists one_time_tokens_contact_check;
2640
+ alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
2641
+ (email is not null and phone is null) or (email is null and phone is not null)
2642
+ );
2643
+
2644
+ create unique index if not exists one_time_tokens_phone_type_idx
2645
+ on auth.one_time_tokens(phone, token_type) where phone is not null;
2646
+
2647
+ create table if not exists auth.phone_otp_cooldowns (
2648
+ phone_fingerprint text primary key,
2649
+ issuance_id uuid not null,
2650
+ last_sent_at timestamptz not null default now()
2276
2651
  );
2277
2652
 
2278
2653
  create table if not exists auth.identities (
@@ -2337,6 +2712,7 @@ create table if not exists storage.buckets (
2337
2712
  id text primary key,
2338
2713
  name text not null unique,
2339
2714
  owner uuid,
2715
+ owner_id text,
2340
2716
  public boolean default false,
2341
2717
  file_size_limit bigint,
2342
2718
  allowed_mime_types text[],
@@ -2344,11 +2720,17 @@ create table if not exists storage.buckets (
2344
2720
  updated_at timestamptz default now()
2345
2721
  );
2346
2722
 
2723
+ alter table storage.buckets add column if not exists owner_id text;
2724
+ update storage.buckets
2725
+ set owner_id = owner::text
2726
+ where owner_id is null and owner is not null;
2727
+
2347
2728
  create table if not exists storage.objects (
2348
2729
  id uuid primary key default gen_random_uuid(),
2349
2730
  bucket_id text not null,
2350
2731
  name text not null,
2351
2732
  owner uuid,
2733
+ owner_id text,
2352
2734
  version text,
2353
2735
  metadata jsonb default '{}'::jsonb,
2354
2736
  created_at timestamptz default now(),
@@ -2357,6 +2739,11 @@ create table if not exists storage.objects (
2357
2739
  unique (bucket_id, name)
2358
2740
  );
2359
2741
 
2742
+ alter table storage.objects add column if not exists owner_id text;
2743
+ update storage.objects
2744
+ set owner_id = owner::text
2745
+ where owner_id is null and owner is not null;
2746
+
2360
2747
  create table if not exists supabase_migrations.schema_migrations (
2361
2748
  version text primary key,
2362
2749
  name text,
@@ -2503,12 +2890,43 @@ create index if not exists refresh_tokens_user_id_idx on auth.refresh_tokens(use
2503
2890
  create table if not exists auth.one_time_tokens (
2504
2891
  id uuid primary key default gen_random_uuid(),
2505
2892
  user_id uuid references auth.users(id) on delete cascade,
2506
- email text not null,
2507
- token_type text not null, -- otp | magiclink | recovery
2893
+ email text,
2894
+ phone text,
2895
+ token_type text not null, -- otp | magiclink | recovery | sms
2508
2896
  token text not null,
2509
2897
  attempts int not null default 0,
2510
2898
  created_at timestamptz default now(),
2511
- expires_at timestamptz not null
2899
+ expires_at timestamptz not null,
2900
+ constraint one_time_tokens_contact_check check (
2901
+ (email is not null and phone is null) or (email is null and phone is not null)
2902
+ )
2903
+ );
2904
+
2905
+ -- Upgrade databases created by Lite <=0.5.9 without touching existing email tokens.
2906
+ alter table auth.one_time_tokens add column if not exists phone text;
2907
+ alter table auth.one_time_tokens alter column email drop not null;
2908
+ do $phone_otp_contact_constraint$
2909
+ begin
2910
+ if not exists (
2911
+ select 1 from pg_constraint
2912
+ where conrelid = 'auth.one_time_tokens'::regclass
2913
+ and conname = 'one_time_tokens_contact_check'
2914
+ ) then
2915
+ alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
2916
+ (email is not null and phone is null) or (email is null and phone is not null)
2917
+ );
2918
+ end if;
2919
+ end $phone_otp_contact_constraint$;
2920
+
2921
+ create unique index if not exists one_time_tokens_phone_type_idx
2922
+ on auth.one_time_tokens(phone, token_type) where phone is not null;
2923
+
2924
+ -- Only a keyed phone fingerprint is persisted for cooldown enforcement; the
2925
+ -- normalized phone number never enters this table.
2926
+ create table if not exists auth.phone_otp_cooldowns (
2927
+ phone_fingerprint text primary key,
2928
+ issuance_id uuid not null,
2929
+ last_sent_at timestamptz not null default now()
2512
2930
  );
2513
2931
 
2514
2932
  create table if not exists auth.identities (
@@ -2608,6 +3026,7 @@ create table if not exists storage.buckets (
2608
3026
  id text primary key,
2609
3027
  name text not null unique,
2610
3028
  owner uuid,
3029
+ owner_id text,
2611
3030
  public boolean default false,
2612
3031
  file_size_limit bigint,
2613
3032
  allowed_mime_types text[],
@@ -2615,11 +3034,17 @@ create table if not exists storage.buckets (
2615
3034
  updated_at timestamptz default now()
2616
3035
  );
2617
3036
 
3037
+ alter table storage.buckets add column if not exists owner_id text;
3038
+ update storage.buckets
3039
+ set owner_id = owner::text
3040
+ where owner_id is null and owner is not null;
3041
+
2618
3042
  create table if not exists storage.objects (
2619
3043
  id uuid primary key default gen_random_uuid(),
2620
3044
  bucket_id text not null references storage.buckets(id),
2621
3045
  name text not null,
2622
3046
  owner uuid,
3047
+ owner_id text,
2623
3048
  version text,
2624
3049
  metadata jsonb default '{}'::jsonb,
2625
3050
  created_at timestamptz default now(),
@@ -2628,6 +3053,14 @@ create table if not exists storage.objects (
2628
3053
  unique (bucket_id, name)
2629
3054
  );
2630
3055
 
3056
+ -- storage-api keeps the legacy UUID owner and the current text owner_id in
3057
+ -- parallel. Re-running bootstrap upgrades existing Lite databases and retains
3058
+ -- object ownership for rows created before owner_id support was added.
3059
+ alter table storage.objects add column if not exists owner_id text;
3060
+ update storage.objects
3061
+ set owner_id = owner::text
3062
+ where owner_id is null and owner is not null;
3063
+
2631
3064
  create index if not exists objects_bucket_name_idx on storage.objects(bucket_id, name);
2632
3065
 
2633
3066
  grant usage on schema storage to anon, authenticated, service_role;
@@ -6033,6 +6466,12 @@ function invalidObjectKey(key) {
6033
6466
  return "object key must not contain . or .. segments";
6034
6467
  return null;
6035
6468
  }
6469
+ var LEGACY_OWNER_UUID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
6470
+ function storageObjectOwnership(ctx) {
6471
+ const ownerId = typeof ctx.claims?.sub === "string" ? ctx.claims.sub : null;
6472
+ const legacyOwner = ownerId !== null && LEGACY_OWNER_UUID_PATTERN.test(ownerId) ? ownerId : null;
6473
+ return { legacyOwner, ownerId };
6474
+ }
6036
6475
  function storageError(status, error, message) {
6037
6476
  return json3(status, { statusCode: String(status), error, message });
6038
6477
  }
@@ -6371,16 +6810,18 @@ class StorageHandler {
6371
6810
  }
6372
6811
  async persistObject(ctx, bucketId, key, bytes, contentType, cacheControl, upsert) {
6373
6812
  const metadata = objectMetadata(bytes.length, contentType, cacheControl);
6813
+ const ownership = storageObjectOwnership(ctx);
6374
6814
  const previous = (await this.db.query(`select * from storage.objects where bucket_id = $1 and name = $2`, [bucketId, key])).rows[0];
6375
6815
  const objectId = previous?.id ?? crypto.randomUUID();
6376
6816
  const version = createObjectVersion();
6377
6817
  const stagedKey = await this.stageObjectBytes(version, bytes);
6378
6818
  const conflictClause = upsert ? `on conflict (bucket_id, name) do update
6379
- set metadata = excluded.metadata, owner = excluded.owner, updated_at = now(), version = excluded.version` : "";
6819
+ set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
6820
+ updated_at = now(), version = excluded.version` : "";
6380
6821
  let inserted;
6381
6822
  try {
6382
- const result = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, metadata, version)
6383
- values ($1, $2, $3, $4, $5::jsonb, $6) ${conflictClause} returning *`, [objectId, bucketId, key, ctx.claims?.sub ?? null, JSON.stringify(metadata), version]));
6823
+ const result = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
6824
+ values ($1, $2, $3, $4::uuid, $5, $6::jsonb, $7) ${conflictClause} returning *`, [objectId, bucketId, key, ownership.legacyOwner, ownership.ownerId, JSON.stringify(metadata), version]));
6384
6825
  inserted = result.rows[0];
6385
6826
  } catch (error) {
6386
6827
  if (isRlsDenied(error)) {
@@ -6604,15 +7045,18 @@ class StorageHandler {
6604
7045
  return bucket.file_size_limit != null ? Number(bucket.file_size_limit) : this.config.defaultFileSizeLimit ?? DEFAULT_FILE_SIZE_LIMIT;
6605
7046
  }
6606
7047
  async preflightObjectWrite(ctx, bucketId, key, size, contentType, cacheControl, upsert) {
7048
+ const ownership = storageObjectOwnership(ctx);
6607
7049
  const conflictClause = upsert ? `on conflict (bucket_id, name) do update
6608
- set metadata = excluded.metadata, owner = excluded.owner, updated_at = now(), version = excluded.version` : "";
7050
+ set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
7051
+ updated_at = now(), version = excluded.version` : "";
6609
7052
  try {
6610
7053
  await this.db.withContext(ctx, async (query) => {
6611
- await query(`insert into storage.objects (bucket_id, name, owner, metadata, version)
6612
- values ($1, $2, $3, $4::jsonb, $5) ${conflictClause} returning id`, [
7054
+ await query(`insert into storage.objects (bucket_id, name, owner, owner_id, metadata, version)
7055
+ values ($1, $2, $3::uuid, $4, $5::jsonb, $6) ${conflictClause} returning id`, [
6613
7056
  bucketId,
6614
7057
  key,
6615
- ctx.claims?.sub ?? null,
7058
+ ownership.legacyOwner,
7059
+ ownership.ownerId,
6616
7060
  JSON.stringify(objectMetadata(size, contentType ?? "application/octet-stream", cacheControl ?? "no-cache")),
6617
7061
  createObjectVersion()
6618
7062
  ]);
@@ -6737,11 +7181,21 @@ class StorageHandler {
6737
7181
  return json3(200, { message: "Successfully moved" });
6738
7182
  }
6739
7183
  const copyId = crypto.randomUUID();
7184
+ const ownership = storageObjectOwnership(ctx);
6740
7185
  try {
6741
- const copied = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, metadata, version)
6742
- select $1, $4, $5, $6, metadata, $7
7186
+ const copied = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
7187
+ select $1, $4, $5, $6::uuid, $7, metadata, $8
6743
7188
  from storage.objects where bucket_id = $2 and name = $3
6744
- returning *`, [copyId, body.bucketId, body.sourceKey, dstBucket, body.destinationKey, ctx.claims?.sub ?? null, version]));
7189
+ returning *`, [
7190
+ copyId,
7191
+ body.bucketId,
7192
+ body.sourceKey,
7193
+ dstBucket,
7194
+ body.destinationKey,
7195
+ ownership.legacyOwner,
7196
+ ownership.ownerId,
7197
+ version
7198
+ ]));
6745
7199
  if (copied.rows.length === 0)
6746
7200
  throw new Error("storage copy source disappeared");
6747
7201
  } catch (error) {
@@ -7577,6 +8031,7 @@ class RetentionService {
7577
8031
  async runSweep() {
7578
8032
  const now = this.now();
7579
8033
  await this.run(`delete from auth.one_time_tokens where expires_at < now()`);
8034
+ await this.run(`delete from auth.phone_otp_cooldowns where last_sent_at < now() - interval '1 day'`);
7580
8035
  await this.run(`delete from auth.mfa_challenges where expires_at < now()`);
7581
8036
  await this.run(`delete from auth.flow_state where expires_at < now()`);
7582
8037
  await this.run(`delete from public.supacloud_pgredis_kv where expires_at <= now()`);
@@ -7917,6 +8372,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
7917
8372
  log(`[mail] to=${msg.to} subject="${msg.subject}"`);
7918
8373
  }
7919
8374
  };
8375
+ const smsInbox = config.smsSender || exposed ? null : new SmsInbox;
8376
+ const smsSender = config.smsSender ?? smsInbox;
7920
8377
  const authSettings = await loadAuthSettings(db, config.authSettings);
7921
8378
  const storage = new StorageHandler(db, config.storageDriver ?? new MemoryStorageDriver, {
7922
8379
  jwtSecret,
@@ -7933,6 +8390,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
7933
8390
  sessionTimeboxSeconds: config.sessionTimeboxSeconds,
7934
8391
  sessionInactivitySeconds: config.sessionInactivitySeconds,
7935
8392
  mailer,
8393
+ smsSender,
8394
+ log,
7936
8395
  oauthProviders: config.oauthProviders,
7937
8396
  oauthFetch: config.oauthFetch,
7938
8397
  uriAllowList: config.uriAllowList,
@@ -8021,6 +8480,9 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
8021
8480
  if (inbox && (path === "/inbox" || path.startsWith("/inbox/"))) {
8022
8481
  return withCors(inbox.serve(req, url));
8023
8482
  }
8483
+ if (smsInbox && (path === "/sms-inbox" || path.startsWith("/sms-inbox/"))) {
8484
+ return withCors(smsInbox.serve(req, url));
8485
+ }
8024
8486
  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/")) {
8025
8487
  if (req.method === "GET" || req.method === "HEAD") {
8026
8488
  return withCors(await storage.handle(req, { role: "anon", claims: null }, url));
@@ -8115,6 +8577,7 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
8115
8577
  jwtSecret,
8116
8578
  logs,
8117
8579
  inbox,
8580
+ smsInbox,
8118
8581
  migrate: (migrations, seedSql) => db.runMigrations(migrations, seedSql),
8119
8582
  close: () => {
8120
8583
  closePromise ??= (async () => {
@@ -8511,6 +8974,7 @@ function readAuth(root, env) {
8511
8974
  function readAuthSettings(root) {
8512
8975
  const auth = tableAt(root, "auth");
8513
8976
  const email = tableAt(root, "auth.email");
8977
+ const sms = tableAt(root, "auth.sms");
8514
8978
  const mfa = tableAt(root, "auth.mfa");
8515
8979
  const mfaTotp = tableAt(root, "auth.mfa.totp");
8516
8980
  const out = {};
@@ -8535,6 +8999,18 @@ function readAuthSettings(root) {
8535
8999
  const otpExpiry = getInt(email, "otp_expiry");
8536
9000
  if (otpExpiry !== undefined)
8537
9001
  out.otpExpirySeconds = otpExpiry;
9002
+ const smsEnabled = getBool(sms, "enabled");
9003
+ if (smsEnabled !== undefined)
9004
+ out.smsEnabled = smsEnabled;
9005
+ const smsSignup = getBool(sms, "enable_signup");
9006
+ if (smsSignup !== undefined)
9007
+ out.smsSignupEnabled = smsSignup;
9008
+ const smsFrequency = getDurationSeconds(sms, "max_frequency");
9009
+ if (smsFrequency !== undefined)
9010
+ out.smsOtpCooldownSeconds = smsFrequency;
9011
+ const smsTemplate = getString(sms, "template");
9012
+ if (smsTemplate !== undefined)
9013
+ out.smsTemplate = smsTemplate;
8538
9014
  const maxFactors = getInt(mfa, "max_enrolled_factors");
8539
9015
  if (maxFactors !== undefined)
8540
9016
  out.maxEnrolledFactors = maxFactors;
@@ -8566,6 +9042,9 @@ function readRateLimits(root) {
8566
9042
  out.otp = { limit: email, windowMs: ONE_HOUR };
8567
9043
  out.recover = { limit: email, windowMs: ONE_HOUR };
8568
9044
  }
9045
+ const sms = getInt(rl, "sms_sent");
9046
+ if (sms !== undefined)
9047
+ out.sms = { limit: sms, windowMs: ONE_HOUR };
8569
9048
  return Object.keys(out).length ? out : undefined;
8570
9049
  }
8571
9050
  function readOAuthProviders(root, env) {
@@ -9000,6 +9479,7 @@ async function createProjectBackend(options = {}) {
9000
9479
  sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
9001
9480
  sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
9002
9481
  oauthProviders: config.auth.oauthProviders,
9482
+ smsSender: options.smsSender,
9003
9483
  dbSchemas: config.api.schemas,
9004
9484
  maxRows: config.api.maxRows,
9005
9485
  storageFileSizeLimit: config.storage.fileSizeLimit,