@supacloud/lite 0.5.10 → 0.7.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/CHANGELOG.md +19 -0
- package/README.md +8 -0
- package/dist/cli.js +609 -53
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +609 -53
- package/dist/project-runtime.d.ts +2 -1
- package/dist/project-runtime.d.ts.map +1 -1
- package/dist/runtime/auth/handler.d.ts +33 -2
- package/dist/runtime/auth/handler.d.ts.map +1 -1
- package/dist/runtime/auth/rate-limit.d.ts.map +1 -1
- package/dist/runtime/auth/settings.d.ts +8 -0
- package/dist/runtime/auth/settings.d.ts.map +1 -1
- package/dist/runtime/auth/sms-inbox.d.ts +19 -0
- package/dist/runtime/auth/sms-inbox.d.ts.map +1 -0
- package/dist/runtime/db/bootstrap.d.ts +2 -2
- package/dist/runtime/db/bootstrap.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/node/load-config.d.ts +1 -1
- package/dist/runtime/retention/service.d.ts.map +1 -1
- package/dist/runtime/storage/handler.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +16 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/package.json +1 -1
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.
|
|
11
|
+
version: "0.7.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,55 @@ 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
|
-
|
|
1090
|
-
|
|
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
|
+
async function sha256Hex(value) {
|
|
1116
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1117
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1118
|
+
}
|
|
1119
|
+
function normalizePhone(value) {
|
|
1120
|
+
if (typeof value !== "string")
|
|
1121
|
+
return null;
|
|
1122
|
+
const phone = value.trim();
|
|
1123
|
+
return /^\+[1-9]\d{7,14}$/.test(phone) ? phone : null;
|
|
1124
|
+
}
|
|
1125
|
+
function recordValue(value) {
|
|
1126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1127
|
+
}
|
|
1128
|
+
function databaseDiagnosticCode(error) {
|
|
1129
|
+
const candidate = typeof error === "object" && error !== null && "code" in error ? error.code : null;
|
|
1130
|
+
return typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate) ? candidate : "database_error";
|
|
1131
|
+
}
|
|
1132
|
+
async function keyedDigest(secret, domain, value) {
|
|
1133
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1134
|
+
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${domain}\x00${value}`));
|
|
1135
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1136
|
+
}
|
|
1137
|
+
async function sendSmsUntilAbort(sender, message, signal) {
|
|
1138
|
+
if (signal.aborted)
|
|
1139
|
+
throw new Error("Phone delivery aborted");
|
|
1140
|
+
let rejectForAbort;
|
|
1141
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
1142
|
+
rejectForAbort = () => reject(new Error("Phone delivery aborted"));
|
|
1143
|
+
signal.addEventListener("abort", rejectForAbort, { once: true });
|
|
1144
|
+
});
|
|
1145
|
+
try {
|
|
1146
|
+
await Promise.race([sender.send(message, { signal }), aborted]);
|
|
1147
|
+
} finally {
|
|
1148
|
+
signal.removeEventListener("abort", rejectForAbort);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1093
1151
|
function json(status, body) {
|
|
1094
1152
|
return new Response(status === 204 ? null : JSON.stringify(body), {
|
|
1095
1153
|
status,
|
|
@@ -1111,9 +1169,14 @@ function timestampMs(value) {
|
|
|
1111
1169
|
class AuthHandler {
|
|
1112
1170
|
db;
|
|
1113
1171
|
config;
|
|
1172
|
+
static PHONE_DELIVERY_TIMEOUT_MS = 15000;
|
|
1173
|
+
static PHONE_DELIVERY_DRAIN_MS = 250;
|
|
1114
1174
|
oauth;
|
|
1115
1175
|
settings;
|
|
1116
1176
|
rateLimiter;
|
|
1177
|
+
phoneDeliveries = new Set;
|
|
1178
|
+
phoneDeliveryControllers = new Set;
|
|
1179
|
+
stopping = false;
|
|
1117
1180
|
constructor(db, config) {
|
|
1118
1181
|
this.db = db;
|
|
1119
1182
|
this.config = config;
|
|
@@ -1122,16 +1185,41 @@ class AuthHandler {
|
|
|
1122
1185
|
this.rateLimiter = config.rateLimiter === undefined ? new RateLimiter : config.rateLimiter;
|
|
1123
1186
|
}
|
|
1124
1187
|
limit(action, req) {
|
|
1188
|
+
const client = req.headers.get("x-supacloud-lite-remote-addr") ?? "local";
|
|
1189
|
+
return this.limitKey(action, `ip:${client}`);
|
|
1190
|
+
}
|
|
1191
|
+
limitKey(action, key) {
|
|
1125
1192
|
if (!this.rateLimiter)
|
|
1126
1193
|
return null;
|
|
1127
|
-
const
|
|
1128
|
-
const retryAfter = this.rateLimiter.check(action, client);
|
|
1194
|
+
const retryAfter = this.rateLimiter.check(action, key);
|
|
1129
1195
|
if (retryAfter === null)
|
|
1130
1196
|
return null;
|
|
1131
1197
|
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
1198
|
}
|
|
1133
|
-
stop() {
|
|
1199
|
+
async stop() {
|
|
1134
1200
|
this.rateLimiter?.stop();
|
|
1201
|
+
this.stopping = true;
|
|
1202
|
+
if (await this.phoneDeliveriesSettledBeforeDeadline())
|
|
1203
|
+
return;
|
|
1204
|
+
for (const controller of this.phoneDeliveryControllers)
|
|
1205
|
+
controller.abort();
|
|
1206
|
+
await Promise.all(this.phoneDeliveries);
|
|
1207
|
+
}
|
|
1208
|
+
async phoneDeliveriesSettledBeforeDeadline() {
|
|
1209
|
+
if (this.phoneDeliveries.size === 0)
|
|
1210
|
+
return true;
|
|
1211
|
+
let deadlineTimer;
|
|
1212
|
+
const deadline = new Promise((resolve) => {
|
|
1213
|
+
deadlineTimer = setTimeout(() => resolve(false), AuthHandler.PHONE_DELIVERY_DRAIN_MS);
|
|
1214
|
+
});
|
|
1215
|
+
try {
|
|
1216
|
+
return await Promise.race([
|
|
1217
|
+
Promise.all(this.phoneDeliveries).then(() => true),
|
|
1218
|
+
deadline
|
|
1219
|
+
]);
|
|
1220
|
+
} finally {
|
|
1221
|
+
clearTimeout(deadlineTimer);
|
|
1222
|
+
}
|
|
1135
1223
|
}
|
|
1136
1224
|
async handle(req, ctx, url) {
|
|
1137
1225
|
const path = url.pathname.replace(/^\/auth\/v1\/?/, "").replace(/\/+$/, "");
|
|
@@ -1144,7 +1232,7 @@ class AuthHandler {
|
|
|
1144
1232
|
return json(200, {
|
|
1145
1233
|
external: {
|
|
1146
1234
|
email: true,
|
|
1147
|
-
phone:
|
|
1235
|
+
phone: this.settings.smsEnabled && this.config.smsSender != null,
|
|
1148
1236
|
anonymous_users: this.settings.anonymousUsers,
|
|
1149
1237
|
...Object.fromEntries(providers.map((p) => [p, !this.settings.disabledProviders.includes(p)]))
|
|
1150
1238
|
},
|
|
@@ -1165,13 +1253,13 @@ class AuthHandler {
|
|
|
1165
1253
|
if (path === "logout" && method === "POST")
|
|
1166
1254
|
return await this.logout(req, url);
|
|
1167
1255
|
if (path === "otp" && method === "POST")
|
|
1168
|
-
return
|
|
1256
|
+
return await this.sendOtp(req);
|
|
1169
1257
|
if (path === "recover" && method === "POST")
|
|
1170
1258
|
return this.limit("recover", req) ?? await this.sendRecovery(req);
|
|
1171
1259
|
if (["magiclink", "resend"].includes(path) && method === "POST")
|
|
1172
|
-
return
|
|
1260
|
+
return await this.sendOtp(req);
|
|
1173
1261
|
if (path === "verify" && method === "POST")
|
|
1174
|
-
return await this.verifyToken(req);
|
|
1262
|
+
return this.limit("verify", req) ?? await this.verifyToken(req);
|
|
1175
1263
|
if (path === "verify" && method === "GET")
|
|
1176
1264
|
return await this.verifyLink(url);
|
|
1177
1265
|
if (path === "factors" && method === "POST")
|
|
@@ -1393,9 +1481,170 @@ Or sign in with this link: ${link}`
|
|
|
1393
1481
|
}
|
|
1394
1482
|
async sendOtp(req) {
|
|
1395
1483
|
const body = await req.json().catch(() => ({}));
|
|
1484
|
+
if (body.email && body.phone)
|
|
1485
|
+
return authError(400, "validation_failed", "email and phone are mutually exclusive");
|
|
1486
|
+
if (body.phone !== undefined) {
|
|
1487
|
+
if (body.channel !== undefined && body.channel !== "sms") {
|
|
1488
|
+
return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
|
|
1489
|
+
}
|
|
1490
|
+
const phone = normalizePhone(body.phone);
|
|
1491
|
+
if (!phone)
|
|
1492
|
+
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
1493
|
+
return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
|
|
1494
|
+
}
|
|
1396
1495
|
if (!body.email)
|
|
1397
|
-
return authError(400, "validation_failed", "email is required");
|
|
1398
|
-
return this.issueToken(body.email, "otp", body.create_user !== false);
|
|
1496
|
+
return authError(400, "validation_failed", "email or phone is required");
|
|
1497
|
+
return this.limit("otp", req) ?? this.issueToken(body.email, "otp", body.create_user !== false);
|
|
1498
|
+
}
|
|
1499
|
+
async issuePhoneToken(phone, createUser, metadata) {
|
|
1500
|
+
const sender = this.config.smsSender;
|
|
1501
|
+
if (!this.settings.smsEnabled || !sender) {
|
|
1502
|
+
return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
|
|
1503
|
+
}
|
|
1504
|
+
const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
|
|
1505
|
+
const limited = this.limitKey("sms", `phone:${fingerprint}`);
|
|
1506
|
+
if (limited)
|
|
1507
|
+
return limited;
|
|
1508
|
+
const code = randomOtp(this.settings.otpLength);
|
|
1509
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
1510
|
+
const issuanceId = crypto.randomUUID();
|
|
1511
|
+
const prepared = await this.preparePhoneOtp({
|
|
1512
|
+
phone,
|
|
1513
|
+
createUser,
|
|
1514
|
+
metadata,
|
|
1515
|
+
fingerprint,
|
|
1516
|
+
tokenDigest,
|
|
1517
|
+
issuanceId,
|
|
1518
|
+
eligibleBefore: new Date(Date.now() - this.settings.smsOtpCooldownSeconds * 1000).toISOString(),
|
|
1519
|
+
expiry: `${this.settings.otpExpirySeconds} seconds`
|
|
1520
|
+
});
|
|
1521
|
+
if (prepared.state === "cooldown") {
|
|
1522
|
+
return authError(429, "over_sms_send_rate_limit", "SMS can only be requested after the cooldown");
|
|
1523
|
+
}
|
|
1524
|
+
if (prepared.state === "signup_disabled") {
|
|
1525
|
+
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1526
|
+
}
|
|
1527
|
+
if (prepared.state === "unknown")
|
|
1528
|
+
return json(200, {});
|
|
1529
|
+
const body = this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, code);
|
|
1530
|
+
const delivery = createUser ? this.deliverPhoneOtp(prepared, sender, phone, body) : this.deferPhoneOtpDelivery(prepared, sender, phone, body);
|
|
1531
|
+
this.trackPhoneDelivery(delivery);
|
|
1532
|
+
if (!createUser) {
|
|
1533
|
+
return json(200, {});
|
|
1534
|
+
}
|
|
1535
|
+
return await delivery ? json(200, {}) : authError(502, "sms_provider_failed", "Unable to send the verification code");
|
|
1536
|
+
}
|
|
1537
|
+
async preparePhoneOtp(request) {
|
|
1538
|
+
try {
|
|
1539
|
+
return await this.db.transaction(async (query) => {
|
|
1540
|
+
const cooldown = await query(`insert into auth.phone_otp_cooldowns (phone_fingerprint, issuance_id, last_sent_at)
|
|
1541
|
+
values ($1, $2, now())
|
|
1542
|
+
on conflict (phone_fingerprint) do update
|
|
1543
|
+
set issuance_id = excluded.issuance_id, last_sent_at = excluded.last_sent_at
|
|
1544
|
+
where auth.phone_otp_cooldowns.last_sent_at <= $3::timestamptz
|
|
1545
|
+
returning phone_fingerprint`, [request.fingerprint, request.issuanceId, request.eligibleBefore]);
|
|
1546
|
+
if (cooldown.rows.length === 0)
|
|
1547
|
+
return { state: "cooldown" };
|
|
1548
|
+
const resolved = await this.phoneUser(query, request);
|
|
1549
|
+
if (resolved === "unknown" || resolved === "signup_disabled")
|
|
1550
|
+
return { state: resolved };
|
|
1551
|
+
await this.createPhoneIdentity(query, resolved);
|
|
1552
|
+
await query(`delete from auth.one_time_tokens where phone = $1 and token_type = 'sms'`, [request.phone]);
|
|
1553
|
+
await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
|
|
1554
|
+
values ($1, $2, $3, 'sms', $4, now() + $5::interval)`, [request.issuanceId, resolved.id, request.phone, request.tokenDigest, request.expiry]);
|
|
1555
|
+
return {
|
|
1556
|
+
state: "issued",
|
|
1557
|
+
id: request.issuanceId,
|
|
1558
|
+
fingerprint: request.fingerprint,
|
|
1559
|
+
tokenDigest: request.tokenDigest,
|
|
1560
|
+
releaseCooldownOnFailure: request.createUser
|
|
1561
|
+
};
|
|
1562
|
+
});
|
|
1563
|
+
} catch (error) {
|
|
1564
|
+
this.reportPhoneFailure("issue", databaseDiagnosticCode(error));
|
|
1565
|
+
throw new Error("Unable to issue the verification code", { cause: error });
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
async phoneUser(query, request) {
|
|
1569
|
+
const existing = await this.phoneUserForUpdate(query, request.phone);
|
|
1570
|
+
if (existing)
|
|
1571
|
+
return existing.auth_eligible ? existing : "unknown";
|
|
1572
|
+
if (!request.createUser)
|
|
1573
|
+
return "unknown";
|
|
1574
|
+
if (this.settings.disableSignup || !this.settings.smsSignupEnabled)
|
|
1575
|
+
return "signup_disabled";
|
|
1576
|
+
const inserted = await query(`insert into auth.users (aud, role, phone, raw_app_meta_data, raw_user_meta_data)
|
|
1577
|
+
values ('authenticated', 'authenticated', $1, $2::jsonb, $3::jsonb)
|
|
1578
|
+
on conflict (phone) do nothing returning *`, [
|
|
1579
|
+
request.phone,
|
|
1580
|
+
JSON.stringify({ provider: "phone", providers: ["phone"] }),
|
|
1581
|
+
JSON.stringify(request.metadata)
|
|
1582
|
+
]);
|
|
1583
|
+
if (inserted.rows[0])
|
|
1584
|
+
return inserted.rows[0];
|
|
1585
|
+
const concurrent = await this.phoneUserForUpdate(query, request.phone);
|
|
1586
|
+
if (!concurrent)
|
|
1587
|
+
throw new Error("phone user could not be resolved");
|
|
1588
|
+
return concurrent.auth_eligible ? concurrent : "unknown";
|
|
1589
|
+
}
|
|
1590
|
+
async phoneUserForUpdate(query, phone) {
|
|
1591
|
+
const users = await query(`select *, deleted_at is null and (banned_until is null or banned_until <= now()) as auth_eligible
|
|
1592
|
+
from auth.users where phone = $1 for update`, [phone]);
|
|
1593
|
+
return users.rows[0];
|
|
1594
|
+
}
|
|
1595
|
+
createPhoneIdentity(query, user) {
|
|
1596
|
+
return query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1597
|
+
values ($1, 'phone', $2, $3::jsonb)
|
|
1598
|
+
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 })]);
|
|
1599
|
+
}
|
|
1600
|
+
async deliverPhoneOtp(issuance, sender, phone, body) {
|
|
1601
|
+
try {
|
|
1602
|
+
await this.sendPhoneOtp(sender, phone, body);
|
|
1603
|
+
return true;
|
|
1604
|
+
} catch {
|
|
1605
|
+
this.reportPhoneFailure("deliver", "provider_error");
|
|
1606
|
+
await this.cleanupFailedPhoneOtp(issuance);
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async sendPhoneOtp(sender, phone, body) {
|
|
1611
|
+
const controller = new AbortController;
|
|
1612
|
+
this.phoneDeliveryControllers.add(controller);
|
|
1613
|
+
const deliveryTimer = setTimeout(() => controller.abort(), AuthHandler.PHONE_DELIVERY_TIMEOUT_MS);
|
|
1614
|
+
if (this.stopping)
|
|
1615
|
+
controller.abort();
|
|
1616
|
+
try {
|
|
1617
|
+
await sendSmsUntilAbort(sender, { to: phone, body }, controller.signal);
|
|
1618
|
+
} finally {
|
|
1619
|
+
clearTimeout(deliveryTimer);
|
|
1620
|
+
this.phoneDeliveryControllers.delete(controller);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async cleanupFailedPhoneOtp(issuance) {
|
|
1624
|
+
try {
|
|
1625
|
+
await this.db.transaction(async (query) => {
|
|
1626
|
+
await query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [issuance.id, issuance.tokenDigest]);
|
|
1627
|
+
if (issuance.releaseCooldownOnFailure) {
|
|
1628
|
+
await query(`delete from auth.phone_otp_cooldowns where phone_fingerprint = $1 and issuance_id = $2`, [issuance.fingerprint, issuance.id]);
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
} catch (cleanupError) {
|
|
1632
|
+
this.reportPhoneFailure("cleanup", databaseDiagnosticCode(cleanupError));
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
async deferPhoneOtpDelivery(issuance, sender, phone, body) {
|
|
1636
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1637
|
+
return this.deliverPhoneOtp(issuance, sender, phone, body);
|
|
1638
|
+
}
|
|
1639
|
+
trackPhoneDelivery(delivery) {
|
|
1640
|
+
let tracked;
|
|
1641
|
+
tracked = delivery.then(() => {}).finally(() => this.phoneDeliveries.delete(tracked));
|
|
1642
|
+
this.phoneDeliveries.add(tracked);
|
|
1643
|
+
}
|
|
1644
|
+
reportPhoneFailure(operation, code) {
|
|
1645
|
+
try {
|
|
1646
|
+
this.config.log?.(`[auth] phone_otp_${operation} failed code=${code}`);
|
|
1647
|
+
} catch {}
|
|
1399
1648
|
}
|
|
1400
1649
|
async sendRecovery(req) {
|
|
1401
1650
|
const body = await req.json().catch(() => ({}));
|
|
@@ -1405,36 +1654,116 @@ Or sign in with this link: ${link}`
|
|
|
1405
1654
|
}
|
|
1406
1655
|
static MAX_OTP_ATTEMPTS = 5;
|
|
1407
1656
|
async redeem(token, types, email) {
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
await this.db.query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [normalizedEmail, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1420
|
-
}
|
|
1657
|
+
const redemption = {
|
|
1658
|
+
token,
|
|
1659
|
+
tokenTypes: `{${types.join(",")}}`,
|
|
1660
|
+
email: email?.toLowerCase().trim() ?? null
|
|
1661
|
+
};
|
|
1662
|
+
return this.db.transaction((query) => this.claimEmailToken(query, redemption));
|
|
1663
|
+
}
|
|
1664
|
+
async claimEmailToken(query, redemption) {
|
|
1665
|
+
const candidate = await this.findEmailTokenCandidate(query, redemption);
|
|
1666
|
+
if (!candidate) {
|
|
1667
|
+
await this.recordFailedEmailTokenAttempt(query, redemption);
|
|
1421
1668
|
return null;
|
|
1422
1669
|
}
|
|
1423
|
-
await this.
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1670
|
+
const claimedToken = await this.lockAndDeleteEmailToken(query, candidate, redemption);
|
|
1671
|
+
return claimedToken ? this.confirmEmailTokenUser(query, claimedToken) : null;
|
|
1672
|
+
}
|
|
1673
|
+
async lockAndDeleteEmailToken(query, candidate, redemption) {
|
|
1674
|
+
await query(`select id from auth.users where id = $1 for update`, [candidate.user_id]);
|
|
1675
|
+
const claimedTokens = await query(`delete from auth.one_time_tokens
|
|
1676
|
+
where id = $1 and token = $2 and token_type = any($3::text[])
|
|
1677
|
+
and expires_at > now() and attempts < $4
|
|
1678
|
+
returning id, user_id, email`, [candidate.id, redemption.token, redemption.tokenTypes, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1679
|
+
return claimedTokens.rows[0] ?? null;
|
|
1680
|
+
}
|
|
1681
|
+
async confirmEmailTokenUser(query, claimedToken) {
|
|
1682
|
+
await query(`delete from auth.one_time_tokens where email = $1`, [claimedToken.email]);
|
|
1683
|
+
const users = await query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
|
|
1684
|
+
where id = $1 returning *`, [claimedToken.user_id]);
|
|
1685
|
+
return users.rows[0] ?? null;
|
|
1686
|
+
}
|
|
1687
|
+
async findEmailTokenCandidate(query, redemption) {
|
|
1688
|
+
const candidates = await query(`select id, user_id, email from auth.one_time_tokens
|
|
1689
|
+
where token = $1 and token_type = any($2::text[])
|
|
1690
|
+
and ($3::text is null or email = $3) and expires_at > now()
|
|
1691
|
+
and attempts < $4`, [redemption.token, redemption.tokenTypes, redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1692
|
+
return candidates.rows[0] ?? null;
|
|
1693
|
+
}
|
|
1694
|
+
async recordFailedEmailTokenAttempt(query, redemption) {
|
|
1695
|
+
if (!redemption.email)
|
|
1696
|
+
return;
|
|
1697
|
+
await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1698
|
+
where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1699
|
+
await query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1427
1700
|
}
|
|
1428
1701
|
async verifyToken(req) {
|
|
1429
1702
|
const body = await req.json().catch(() => ({}));
|
|
1430
|
-
|
|
1431
|
-
|
|
1703
|
+
const token = body.token ?? body.token_hash;
|
|
1704
|
+
if (!token)
|
|
1705
|
+
return authError(400, "validation_failed", "token or token_hash is required");
|
|
1706
|
+
if (body.type === "sms" || body.phone !== undefined) {
|
|
1707
|
+
const phone = normalizePhone(body.phone);
|
|
1708
|
+
if (body.type !== "sms" || !phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
|
|
1709
|
+
return authError(400, "validation_failed", "phone, token, and type=sms are required");
|
|
1710
|
+
}
|
|
1711
|
+
const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
|
|
1712
|
+
const limited = this.limitKey("verify", `phone:${fingerprint}`);
|
|
1713
|
+
if (limited)
|
|
1714
|
+
return limited;
|
|
1715
|
+
let session;
|
|
1716
|
+
try {
|
|
1717
|
+
session = await this.redeemPhoneOtp(phone, body.token);
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
this.reportPhoneFailure("verify", databaseDiagnosticCode(error));
|
|
1720
|
+
return authError(500, "unexpected_failure", "Unable to verify the code");
|
|
1721
|
+
}
|
|
1722
|
+
if (!session)
|
|
1723
|
+
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
1724
|
+
return json(200, session);
|
|
1725
|
+
}
|
|
1432
1726
|
const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : ["otp", "magiclink"];
|
|
1433
|
-
const user = await this.redeem(
|
|
1727
|
+
const user = await this.redeem(token, types, body.email);
|
|
1434
1728
|
if (!user)
|
|
1435
1729
|
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
1436
1730
|
return json(200, await this.sessionFor(user));
|
|
1437
1731
|
}
|
|
1732
|
+
async redeemPhoneOtp(phone, code) {
|
|
1733
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
1734
|
+
return this.db.transaction(async (query) => {
|
|
1735
|
+
const claimed = await query(`delete from auth.one_time_tokens
|
|
1736
|
+
where phone = $1 and token_type = 'sms' and token = $2
|
|
1737
|
+
and expires_at > now() and attempts < $3
|
|
1738
|
+
returning user_id`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1739
|
+
const userId = claimed.rows[0]?.user_id;
|
|
1740
|
+
if (!userId) {
|
|
1741
|
+
const attempt = await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1742
|
+
where phone = $1 and token_type = 'sms' and expires_at > now()
|
|
1743
|
+
returning id, attempts`, [phone]);
|
|
1744
|
+
const row = attempt.rows[0];
|
|
1745
|
+
if (row && row.attempts >= AuthHandler.MAX_OTP_ATTEMPTS) {
|
|
1746
|
+
await query(`delete from auth.one_time_tokens where id = $1`, [row.id]);
|
|
1747
|
+
}
|
|
1748
|
+
await query(`delete from auth.one_time_tokens where phone = $1 and expires_at <= now()`, [phone]);
|
|
1749
|
+
return null;
|
|
1750
|
+
}
|
|
1751
|
+
await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
|
|
1752
|
+
const users = await query(`update auth.users
|
|
1753
|
+
set phone_confirmed_at = coalesce(phone_confirmed_at, now()), last_sign_in_at = now(), updated_at = now()
|
|
1754
|
+
where id = $1 and deleted_at is null
|
|
1755
|
+
and (banned_until is null or banned_until <= now())
|
|
1756
|
+
returning *`, [userId]);
|
|
1757
|
+
const user = users.rows[0];
|
|
1758
|
+
if (!user)
|
|
1759
|
+
return null;
|
|
1760
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data, last_sign_in_at)
|
|
1761
|
+
values ($1, 'phone', $2, $3::jsonb, now())
|
|
1762
|
+
on conflict (provider, provider_id) do update
|
|
1763
|
+
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 })]);
|
|
1764
|
+
return this.sessionFor(user, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1438
1767
|
async verifyLink(url) {
|
|
1439
1768
|
const token = url.searchParams.get("token") ?? "";
|
|
1440
1769
|
const type = url.searchParams.get("type") ?? "magiclink";
|
|
@@ -1453,6 +1782,9 @@ Or sign in with this link: ${link}`
|
|
|
1453
1782
|
}
|
|
1454
1783
|
const idMatch = path.match(/^admin\/users\/([0-9a-f-]{36})$/);
|
|
1455
1784
|
const exportMatch = path.match(/^admin\/users\/([0-9a-f-]{36})\/export$/);
|
|
1785
|
+
if (path === "admin/generate_link" && method === "POST") {
|
|
1786
|
+
return await this.generateAdminMagicLink(req);
|
|
1787
|
+
}
|
|
1456
1788
|
if (path === "admin/audit" && method === "GET") {
|
|
1457
1789
|
const res = await this.db.query(`select id, payload, created_at, ip_address from auth.audit_log_entries
|
|
1458
1790
|
order by created_at desc limit 200`);
|
|
@@ -1523,6 +1855,50 @@ Or sign in with this link: ${link}`
|
|
|
1523
1855
|
}
|
|
1524
1856
|
return authError(404, "not_found", `unknown admin endpoint`);
|
|
1525
1857
|
}
|
|
1858
|
+
async generateAdminMagicLink(req) {
|
|
1859
|
+
const body = await req.json().catch(() => ({}));
|
|
1860
|
+
if (body.type !== "magiclink") {
|
|
1861
|
+
return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
|
|
1862
|
+
}
|
|
1863
|
+
if (!body.email)
|
|
1864
|
+
return authError(400, "validation_failed", "email is required");
|
|
1865
|
+
const email = body.email.toLowerCase().trim();
|
|
1866
|
+
const emailOtp = randomOtp(this.settings.otpLength);
|
|
1867
|
+
const hashedToken = await sha256Hex(randomToken(32));
|
|
1868
|
+
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
1869
|
+
const requestUrl = new URL(req.url);
|
|
1870
|
+
const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
1871
|
+
const user = await this.db.transaction(async (query) => {
|
|
1872
|
+
const result = await query(`insert into auth.users
|
|
1873
|
+
(aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1874
|
+
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
|
|
1875
|
+
on conflict (email) do update set email = excluded.email
|
|
1876
|
+
returning *`, [email, JSON.stringify(body.data ?? {})]);
|
|
1877
|
+
const linkedUser = result.rows[0];
|
|
1878
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1879
|
+
values ($1::uuid, 'email', $1::text, $2::jsonb)
|
|
1880
|
+
on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
|
|
1881
|
+
await query(`delete from auth.one_time_tokens
|
|
1882
|
+
where email = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
|
|
1883
|
+
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
1884
|
+
values ($1, $2, 'otp', $3, now() + $5::interval),
|
|
1885
|
+
($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
|
|
1886
|
+
return linkedUser;
|
|
1887
|
+
});
|
|
1888
|
+
const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
|
|
1889
|
+
actionUrl.searchParams.set("token", hashedToken);
|
|
1890
|
+
actionUrl.searchParams.set("type", "magiclink");
|
|
1891
|
+
actionUrl.searchParams.set("redirect_to", redirectTo);
|
|
1892
|
+
await this.audit("user_magiclink_requested", { actorId: user.id, actorEmail: email, type: "admin" });
|
|
1893
|
+
return json(200, {
|
|
1894
|
+
...this.userJson(user, [], await this.getUserIdentities(user.id)),
|
|
1895
|
+
action_link: actionUrl.toString(),
|
|
1896
|
+
email_otp: emailOtp,
|
|
1897
|
+
hashed_token: hashedToken,
|
|
1898
|
+
redirect_to: redirectTo,
|
|
1899
|
+
verification_type: "magiclink"
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1526
1902
|
async audit(action, opts = {}) {
|
|
1527
1903
|
try {
|
|
1528
1904
|
const payload = {
|
|
@@ -1777,7 +2153,8 @@ Or sign in with this link: ${link}`
|
|
|
1777
2153
|
email: u.email ?? "",
|
|
1778
2154
|
email_confirmed_at: iso(u.email_confirmed_at),
|
|
1779
2155
|
phone: u.phone ?? "",
|
|
1780
|
-
|
|
2156
|
+
phone_confirmed_at: iso(u.phone_confirmed_at),
|
|
2157
|
+
confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
|
|
1781
2158
|
last_sign_in_at: iso(u.last_sign_in_at),
|
|
1782
2159
|
app_metadata: u.raw_app_meta_data ?? {},
|
|
1783
2160
|
user_metadata: u.raw_user_meta_data ?? {},
|
|
@@ -1941,6 +2318,58 @@ var INBOX_HTML = `<!doctype html>
|
|
|
1941
2318
|
</body>
|
|
1942
2319
|
</html>`;
|
|
1943
2320
|
|
|
2321
|
+
// src/runtime/auth/sms-inbox.ts
|
|
2322
|
+
var CAP2 = 200;
|
|
2323
|
+
|
|
2324
|
+
class SmsInbox {
|
|
2325
|
+
messages = [];
|
|
2326
|
+
async send(msg) {
|
|
2327
|
+
const id = crypto.randomUUID();
|
|
2328
|
+
this.messages.unshift({
|
|
2329
|
+
...msg,
|
|
2330
|
+
id,
|
|
2331
|
+
created_at: new Date().toISOString(),
|
|
2332
|
+
code: msg.body.match(/\b\d{6,10}\b/)?.[0] ?? null
|
|
2333
|
+
});
|
|
2334
|
+
if (this.messages.length > CAP2)
|
|
2335
|
+
this.messages.length = CAP2;
|
|
2336
|
+
return { messageId: id };
|
|
2337
|
+
}
|
|
2338
|
+
list() {
|
|
2339
|
+
return this.messages;
|
|
2340
|
+
}
|
|
2341
|
+
clear() {
|
|
2342
|
+
this.messages = [];
|
|
2343
|
+
}
|
|
2344
|
+
serve(req, url) {
|
|
2345
|
+
const method = req.method.toUpperCase();
|
|
2346
|
+
if (url.pathname === "/sms-inbox/api/messages") {
|
|
2347
|
+
if (method === "DELETE") {
|
|
2348
|
+
this.clear();
|
|
2349
|
+
return new Response(null, { status: 204 });
|
|
2350
|
+
}
|
|
2351
|
+
return Response.json({ messages: this.messages });
|
|
2352
|
+
}
|
|
2353
|
+
if (url.pathname === "/sms-inbox" || url.pathname === "/sms-inbox/") {
|
|
2354
|
+
return new Response(SMS_INBOX_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
2355
|
+
}
|
|
2356
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
var SMS_INBOX_HTML = `<!doctype html>
|
|
2360
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2361
|
+
<title>SupaCloud Lite \xB7 SMS Inbox</title><style>
|
|
2362
|
+
:root{color-scheme:dark}body{margin:0;background:#0a0a0a;color:#fafafa;font:14px/1.5 system-ui,sans-serif}
|
|
2363
|
+
header,main{max-width:760px;margin:auto;padding:20px}.msg{border:1px solid #27272a;border-radius:12px;padding:16px;margin:12px 0}
|
|
2364
|
+
.code{font:20px ui-monospace,monospace;letter-spacing:3px;color:#34d399}.muted{color:#a1a1aa}button{padding:6px 12px}
|
|
2365
|
+
</style></head><body><header><h1>SupaCloud Lite \xB7 SMS Inbox</h1><p class="muted">Loopback local development only</p>
|
|
2366
|
+
<button id="clear">Clear</button></header><main id="list">Loading\u2026</main><script>
|
|
2367
|
+
const esc=s=>s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
|
2368
|
+
async function load(){const r=await fetch('/sms-inbox/api/messages');const {messages}=await r.json();
|
|
2369
|
+
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.'}
|
|
2370
|
+
document.getElementById('clear').onclick=async()=>{await fetch('/sms-inbox/api/messages',{method:'DELETE'});load()};load();setInterval(load,4000)
|
|
2371
|
+
</script></body></html>`;
|
|
2372
|
+
|
|
1944
2373
|
// src/runtime/log-buffer.ts
|
|
1945
2374
|
class LogBuffer {
|
|
1946
2375
|
cap;
|
|
@@ -2262,12 +2691,34 @@ create table if not exists auth.refresh_tokens (
|
|
|
2262
2691
|
create table if not exists auth.one_time_tokens (
|
|
2263
2692
|
id uuid primary key default gen_random_uuid(),
|
|
2264
2693
|
user_id uuid,
|
|
2265
|
-
email text
|
|
2694
|
+
email text,
|
|
2695
|
+
phone text,
|
|
2266
2696
|
token_type text not null,
|
|
2267
2697
|
token text not null,
|
|
2268
2698
|
attempts int not null default 0,
|
|
2269
2699
|
created_at timestamptz default now(),
|
|
2270
|
-
expires_at timestamptz not null
|
|
2700
|
+
expires_at timestamptz not null,
|
|
2701
|
+
constraint one_time_tokens_contact_check check (
|
|
2702
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2703
|
+
)
|
|
2704
|
+
);
|
|
2705
|
+
|
|
2706
|
+
-- Minimal engines can persist a database created by an older Lite version.
|
|
2707
|
+
-- Standard ALTER statements keep that path compatible without requiring plpgsql.
|
|
2708
|
+
alter table auth.one_time_tokens add column if not exists phone text;
|
|
2709
|
+
alter table auth.one_time_tokens alter column email drop not null;
|
|
2710
|
+
alter table auth.one_time_tokens drop constraint if exists one_time_tokens_contact_check;
|
|
2711
|
+
alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
|
|
2712
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2713
|
+
);
|
|
2714
|
+
|
|
2715
|
+
create unique index if not exists one_time_tokens_phone_type_idx
|
|
2716
|
+
on auth.one_time_tokens(phone, token_type) where phone is not null;
|
|
2717
|
+
|
|
2718
|
+
create table if not exists auth.phone_otp_cooldowns (
|
|
2719
|
+
phone_fingerprint text primary key,
|
|
2720
|
+
issuance_id uuid not null,
|
|
2721
|
+
last_sent_at timestamptz not null default now()
|
|
2271
2722
|
);
|
|
2272
2723
|
|
|
2273
2724
|
create table if not exists auth.identities (
|
|
@@ -2332,6 +2783,7 @@ create table if not exists storage.buckets (
|
|
|
2332
2783
|
id text primary key,
|
|
2333
2784
|
name text not null unique,
|
|
2334
2785
|
owner uuid,
|
|
2786
|
+
owner_id text,
|
|
2335
2787
|
public boolean default false,
|
|
2336
2788
|
file_size_limit bigint,
|
|
2337
2789
|
allowed_mime_types text[],
|
|
@@ -2339,11 +2791,17 @@ create table if not exists storage.buckets (
|
|
|
2339
2791
|
updated_at timestamptz default now()
|
|
2340
2792
|
);
|
|
2341
2793
|
|
|
2794
|
+
alter table storage.buckets add column if not exists owner_id text;
|
|
2795
|
+
update storage.buckets
|
|
2796
|
+
set owner_id = owner::text
|
|
2797
|
+
where owner_id is null and owner is not null;
|
|
2798
|
+
|
|
2342
2799
|
create table if not exists storage.objects (
|
|
2343
2800
|
id uuid primary key default gen_random_uuid(),
|
|
2344
2801
|
bucket_id text not null,
|
|
2345
2802
|
name text not null,
|
|
2346
2803
|
owner uuid,
|
|
2804
|
+
owner_id text,
|
|
2347
2805
|
version text,
|
|
2348
2806
|
metadata jsonb default '{}'::jsonb,
|
|
2349
2807
|
created_at timestamptz default now(),
|
|
@@ -2352,6 +2810,11 @@ create table if not exists storage.objects (
|
|
|
2352
2810
|
unique (bucket_id, name)
|
|
2353
2811
|
);
|
|
2354
2812
|
|
|
2813
|
+
alter table storage.objects add column if not exists owner_id text;
|
|
2814
|
+
update storage.objects
|
|
2815
|
+
set owner_id = owner::text
|
|
2816
|
+
where owner_id is null and owner is not null;
|
|
2817
|
+
|
|
2355
2818
|
create table if not exists supabase_migrations.schema_migrations (
|
|
2356
2819
|
version text primary key,
|
|
2357
2820
|
name text,
|
|
@@ -2498,12 +2961,43 @@ create index if not exists refresh_tokens_user_id_idx on auth.refresh_tokens(use
|
|
|
2498
2961
|
create table if not exists auth.one_time_tokens (
|
|
2499
2962
|
id uuid primary key default gen_random_uuid(),
|
|
2500
2963
|
user_id uuid references auth.users(id) on delete cascade,
|
|
2501
|
-
email text
|
|
2502
|
-
|
|
2964
|
+
email text,
|
|
2965
|
+
phone text,
|
|
2966
|
+
token_type text not null, -- otp | magiclink | recovery | sms
|
|
2503
2967
|
token text not null,
|
|
2504
2968
|
attempts int not null default 0,
|
|
2505
2969
|
created_at timestamptz default now(),
|
|
2506
|
-
expires_at timestamptz not null
|
|
2970
|
+
expires_at timestamptz not null,
|
|
2971
|
+
constraint one_time_tokens_contact_check check (
|
|
2972
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2973
|
+
)
|
|
2974
|
+
);
|
|
2975
|
+
|
|
2976
|
+
-- Upgrade databases created by Lite <=0.5.9 without touching existing email tokens.
|
|
2977
|
+
alter table auth.one_time_tokens add column if not exists phone text;
|
|
2978
|
+
alter table auth.one_time_tokens alter column email drop not null;
|
|
2979
|
+
do $phone_otp_contact_constraint$
|
|
2980
|
+
begin
|
|
2981
|
+
if not exists (
|
|
2982
|
+
select 1 from pg_constraint
|
|
2983
|
+
where conrelid = 'auth.one_time_tokens'::regclass
|
|
2984
|
+
and conname = 'one_time_tokens_contact_check'
|
|
2985
|
+
) then
|
|
2986
|
+
alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
|
|
2987
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2988
|
+
);
|
|
2989
|
+
end if;
|
|
2990
|
+
end $phone_otp_contact_constraint$;
|
|
2991
|
+
|
|
2992
|
+
create unique index if not exists one_time_tokens_phone_type_idx
|
|
2993
|
+
on auth.one_time_tokens(phone, token_type) where phone is not null;
|
|
2994
|
+
|
|
2995
|
+
-- Only a keyed phone fingerprint is persisted for cooldown enforcement; the
|
|
2996
|
+
-- normalized phone number never enters this table.
|
|
2997
|
+
create table if not exists auth.phone_otp_cooldowns (
|
|
2998
|
+
phone_fingerprint text primary key,
|
|
2999
|
+
issuance_id uuid not null,
|
|
3000
|
+
last_sent_at timestamptz not null default now()
|
|
2507
3001
|
);
|
|
2508
3002
|
|
|
2509
3003
|
create table if not exists auth.identities (
|
|
@@ -2603,6 +3097,7 @@ create table if not exists storage.buckets (
|
|
|
2603
3097
|
id text primary key,
|
|
2604
3098
|
name text not null unique,
|
|
2605
3099
|
owner uuid,
|
|
3100
|
+
owner_id text,
|
|
2606
3101
|
public boolean default false,
|
|
2607
3102
|
file_size_limit bigint,
|
|
2608
3103
|
allowed_mime_types text[],
|
|
@@ -2610,11 +3105,17 @@ create table if not exists storage.buckets (
|
|
|
2610
3105
|
updated_at timestamptz default now()
|
|
2611
3106
|
);
|
|
2612
3107
|
|
|
3108
|
+
alter table storage.buckets add column if not exists owner_id text;
|
|
3109
|
+
update storage.buckets
|
|
3110
|
+
set owner_id = owner::text
|
|
3111
|
+
where owner_id is null and owner is not null;
|
|
3112
|
+
|
|
2613
3113
|
create table if not exists storage.objects (
|
|
2614
3114
|
id uuid primary key default gen_random_uuid(),
|
|
2615
3115
|
bucket_id text not null references storage.buckets(id),
|
|
2616
3116
|
name text not null,
|
|
2617
3117
|
owner uuid,
|
|
3118
|
+
owner_id text,
|
|
2618
3119
|
version text,
|
|
2619
3120
|
metadata jsonb default '{}'::jsonb,
|
|
2620
3121
|
created_at timestamptz default now(),
|
|
@@ -2623,6 +3124,14 @@ create table if not exists storage.objects (
|
|
|
2623
3124
|
unique (bucket_id, name)
|
|
2624
3125
|
);
|
|
2625
3126
|
|
|
3127
|
+
-- storage-api keeps the legacy UUID owner and the current text owner_id in
|
|
3128
|
+
-- parallel. Re-running bootstrap upgrades existing Lite databases and retains
|
|
3129
|
+
-- object ownership for rows created before owner_id support was added.
|
|
3130
|
+
alter table storage.objects add column if not exists owner_id text;
|
|
3131
|
+
update storage.objects
|
|
3132
|
+
set owner_id = owner::text
|
|
3133
|
+
where owner_id is null and owner is not null;
|
|
3134
|
+
|
|
2626
3135
|
create index if not exists objects_bucket_name_idx on storage.objects(bucket_id, name);
|
|
2627
3136
|
|
|
2628
3137
|
grant usage on schema storage to anon, authenticated, service_role;
|
|
@@ -3715,7 +4224,7 @@ class Database {
|
|
|
3715
4224
|
}
|
|
3716
4225
|
}
|
|
3717
4226
|
if (seedSql) {
|
|
3718
|
-
const hash = await
|
|
4227
|
+
const hash = await sha256Hex2(seedSql);
|
|
3719
4228
|
const seen = await this.engine.query(`select 1 from supabase_migrations.seed_files where path = 'supabase/seed.sql' and hash = $1`, [hash]);
|
|
3720
4229
|
if (seen.rows.length === 0) {
|
|
3721
4230
|
await this.engine.transaction(async (tx) => {
|
|
@@ -4008,7 +4517,7 @@ function quoteIdent(name) {
|
|
|
4008
4517
|
function quoteLiteral(value) {
|
|
4009
4518
|
return `'${value.replaceAll("'", "''")}'`;
|
|
4010
4519
|
}
|
|
4011
|
-
async function
|
|
4520
|
+
async function sha256Hex2(text) {
|
|
4012
4521
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
4013
4522
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
4014
4523
|
}
|
|
@@ -6028,6 +6537,12 @@ function invalidObjectKey(key) {
|
|
|
6028
6537
|
return "object key must not contain . or .. segments";
|
|
6029
6538
|
return null;
|
|
6030
6539
|
}
|
|
6540
|
+
var LEGACY_OWNER_UUID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
|
|
6541
|
+
function storageObjectOwnership(ctx) {
|
|
6542
|
+
const ownerId = typeof ctx.claims?.sub === "string" ? ctx.claims.sub : null;
|
|
6543
|
+
const legacyOwner = ownerId !== null && LEGACY_OWNER_UUID_PATTERN.test(ownerId) ? ownerId : null;
|
|
6544
|
+
return { legacyOwner, ownerId };
|
|
6545
|
+
}
|
|
6031
6546
|
function storageError(status, error, message) {
|
|
6032
6547
|
return json3(status, { statusCode: String(status), error, message });
|
|
6033
6548
|
}
|
|
@@ -6366,16 +6881,18 @@ class StorageHandler {
|
|
|
6366
6881
|
}
|
|
6367
6882
|
async persistObject(ctx, bucketId, key, bytes, contentType, cacheControl, upsert) {
|
|
6368
6883
|
const metadata = objectMetadata(bytes.length, contentType, cacheControl);
|
|
6884
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6369
6885
|
const previous = (await this.db.query(`select * from storage.objects where bucket_id = $1 and name = $2`, [bucketId, key])).rows[0];
|
|
6370
6886
|
const objectId = previous?.id ?? crypto.randomUUID();
|
|
6371
6887
|
const version = createObjectVersion();
|
|
6372
6888
|
const stagedKey = await this.stageObjectBytes(version, bytes);
|
|
6373
6889
|
const conflictClause = upsert ? `on conflict (bucket_id, name) do update
|
|
6374
|
-
set metadata = excluded.metadata, owner = excluded.owner,
|
|
6890
|
+
set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
|
|
6891
|
+
updated_at = now(), version = excluded.version` : "";
|
|
6375
6892
|
let inserted;
|
|
6376
6893
|
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, $
|
|
6894
|
+
const result = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
|
|
6895
|
+
values ($1, $2, $3, $4::uuid, $5, $6::jsonb, $7) ${conflictClause} returning *`, [objectId, bucketId, key, ownership.legacyOwner, ownership.ownerId, JSON.stringify(metadata), version]));
|
|
6379
6896
|
inserted = result.rows[0];
|
|
6380
6897
|
} catch (error) {
|
|
6381
6898
|
if (isRlsDenied(error)) {
|
|
@@ -6599,15 +7116,18 @@ class StorageHandler {
|
|
|
6599
7116
|
return bucket.file_size_limit != null ? Number(bucket.file_size_limit) : this.config.defaultFileSizeLimit ?? DEFAULT_FILE_SIZE_LIMIT;
|
|
6600
7117
|
}
|
|
6601
7118
|
async preflightObjectWrite(ctx, bucketId, key, size, contentType, cacheControl, upsert) {
|
|
7119
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6602
7120
|
const conflictClause = upsert ? `on conflict (bucket_id, name) do update
|
|
6603
|
-
set metadata = excluded.metadata, owner = excluded.owner,
|
|
7121
|
+
set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
|
|
7122
|
+
updated_at = now(), version = excluded.version` : "";
|
|
6604
7123
|
try {
|
|
6605
7124
|
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, $
|
|
7125
|
+
await query(`insert into storage.objects (bucket_id, name, owner, owner_id, metadata, version)
|
|
7126
|
+
values ($1, $2, $3::uuid, $4, $5::jsonb, $6) ${conflictClause} returning id`, [
|
|
6608
7127
|
bucketId,
|
|
6609
7128
|
key,
|
|
6610
|
-
|
|
7129
|
+
ownership.legacyOwner,
|
|
7130
|
+
ownership.ownerId,
|
|
6611
7131
|
JSON.stringify(objectMetadata(size, contentType ?? "application/octet-stream", cacheControl ?? "no-cache")),
|
|
6612
7132
|
createObjectVersion()
|
|
6613
7133
|
]);
|
|
@@ -6732,11 +7252,21 @@ class StorageHandler {
|
|
|
6732
7252
|
return json3(200, { message: "Successfully moved" });
|
|
6733
7253
|
}
|
|
6734
7254
|
const copyId = crypto.randomUUID();
|
|
7255
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6735
7256
|
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, $
|
|
7257
|
+
const copied = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
|
|
7258
|
+
select $1, $4, $5, $6::uuid, $7, metadata, $8
|
|
6738
7259
|
from storage.objects where bucket_id = $2 and name = $3
|
|
6739
|
-
returning *`, [
|
|
7260
|
+
returning *`, [
|
|
7261
|
+
copyId,
|
|
7262
|
+
body.bucketId,
|
|
7263
|
+
body.sourceKey,
|
|
7264
|
+
dstBucket,
|
|
7265
|
+
body.destinationKey,
|
|
7266
|
+
ownership.legacyOwner,
|
|
7267
|
+
ownership.ownerId,
|
|
7268
|
+
version
|
|
7269
|
+
]));
|
|
6740
7270
|
if (copied.rows.length === 0)
|
|
6741
7271
|
throw new Error("storage copy source disappeared");
|
|
6742
7272
|
} catch (error) {
|
|
@@ -7572,6 +8102,7 @@ class RetentionService {
|
|
|
7572
8102
|
async runSweep() {
|
|
7573
8103
|
const now = this.now();
|
|
7574
8104
|
await this.run(`delete from auth.one_time_tokens where expires_at < now()`);
|
|
8105
|
+
await this.run(`delete from auth.phone_otp_cooldowns where last_sent_at < now() - interval '1 day'`);
|
|
7575
8106
|
await this.run(`delete from auth.mfa_challenges where expires_at < now()`);
|
|
7576
8107
|
await this.run(`delete from auth.flow_state where expires_at < now()`);
|
|
7577
8108
|
await this.run(`delete from public.supacloud_pgredis_kv where expires_at <= now()`);
|
|
@@ -8073,6 +8604,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8073
8604
|
log(`[mail] to=${msg.to} subject="${msg.subject}"`);
|
|
8074
8605
|
}
|
|
8075
8606
|
};
|
|
8607
|
+
const smsInbox = config.smsSender || exposed ? null : new SmsInbox;
|
|
8608
|
+
const smsSender = config.smsSender ?? smsInbox;
|
|
8076
8609
|
const authSettings = await loadAuthSettings(db, config.authSettings);
|
|
8077
8610
|
const storage = new StorageHandler(db, config.storageDriver ?? new MemoryStorageDriver, {
|
|
8078
8611
|
jwtSecret,
|
|
@@ -8089,6 +8622,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8089
8622
|
sessionTimeboxSeconds: config.sessionTimeboxSeconds,
|
|
8090
8623
|
sessionInactivitySeconds: config.sessionInactivitySeconds,
|
|
8091
8624
|
mailer,
|
|
8625
|
+
smsSender,
|
|
8626
|
+
log,
|
|
8092
8627
|
oauthProviders: config.oauthProviders,
|
|
8093
8628
|
oauthFetch: config.oauthFetch,
|
|
8094
8629
|
uriAllowList: config.uriAllowList,
|
|
@@ -8177,6 +8712,9 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8177
8712
|
if (inbox && (path === "/inbox" || path.startsWith("/inbox/"))) {
|
|
8178
8713
|
return withCors(inbox.serve(req, url));
|
|
8179
8714
|
}
|
|
8715
|
+
if (smsInbox && (path === "/sms-inbox" || path.startsWith("/sms-inbox/"))) {
|
|
8716
|
+
return withCors(smsInbox.serve(req, url));
|
|
8717
|
+
}
|
|
8180
8718
|
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
8719
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
8182
8720
|
return withCors(await storage.handle(req, { role: "anon", claims: null }, url));
|
|
@@ -8271,6 +8809,7 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8271
8809
|
jwtSecret,
|
|
8272
8810
|
logs,
|
|
8273
8811
|
inbox,
|
|
8812
|
+
smsInbox,
|
|
8274
8813
|
migrate: (migrations, seedSql) => db.runMigrations(migrations, seedSql),
|
|
8275
8814
|
close: () => {
|
|
8276
8815
|
closePromise ??= (async () => {
|
|
@@ -8724,6 +9263,7 @@ function readAuth(root, env) {
|
|
|
8724
9263
|
function readAuthSettings(root) {
|
|
8725
9264
|
const auth = tableAt(root, "auth");
|
|
8726
9265
|
const email = tableAt(root, "auth.email");
|
|
9266
|
+
const sms = tableAt(root, "auth.sms");
|
|
8727
9267
|
const mfa = tableAt(root, "auth.mfa");
|
|
8728
9268
|
const mfaTotp = tableAt(root, "auth.mfa.totp");
|
|
8729
9269
|
const out = {};
|
|
@@ -8748,6 +9288,18 @@ function readAuthSettings(root) {
|
|
|
8748
9288
|
const otpExpiry = getInt(email, "otp_expiry");
|
|
8749
9289
|
if (otpExpiry !== undefined)
|
|
8750
9290
|
out.otpExpirySeconds = otpExpiry;
|
|
9291
|
+
const smsEnabled = getBool(sms, "enabled");
|
|
9292
|
+
if (smsEnabled !== undefined)
|
|
9293
|
+
out.smsEnabled = smsEnabled;
|
|
9294
|
+
const smsSignup = getBool(sms, "enable_signup");
|
|
9295
|
+
if (smsSignup !== undefined)
|
|
9296
|
+
out.smsSignupEnabled = smsSignup;
|
|
9297
|
+
const smsFrequency = getDurationSeconds(sms, "max_frequency");
|
|
9298
|
+
if (smsFrequency !== undefined)
|
|
9299
|
+
out.smsOtpCooldownSeconds = smsFrequency;
|
|
9300
|
+
const smsTemplate = getString(sms, "template");
|
|
9301
|
+
if (smsTemplate !== undefined)
|
|
9302
|
+
out.smsTemplate = smsTemplate;
|
|
8751
9303
|
const maxFactors = getInt(mfa, "max_enrolled_factors");
|
|
8752
9304
|
if (maxFactors !== undefined)
|
|
8753
9305
|
out.maxEnrolledFactors = maxFactors;
|
|
@@ -8779,6 +9331,9 @@ function readRateLimits(root) {
|
|
|
8779
9331
|
out.otp = { limit: email, windowMs: ONE_HOUR };
|
|
8780
9332
|
out.recover = { limit: email, windowMs: ONE_HOUR };
|
|
8781
9333
|
}
|
|
9334
|
+
const sms = getInt(rl, "sms_sent");
|
|
9335
|
+
if (sms !== undefined)
|
|
9336
|
+
out.sms = { limit: sms, windowMs: ONE_HOUR };
|
|
8782
9337
|
return Object.keys(out).length ? out : undefined;
|
|
8783
9338
|
}
|
|
8784
9339
|
function readOAuthProviders(root, env) {
|
|
@@ -9316,6 +9871,7 @@ async function createProjectBackend(options = {}) {
|
|
|
9316
9871
|
sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
|
|
9317
9872
|
sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
|
|
9318
9873
|
oauthProviders: config.auth.oauthProviders,
|
|
9874
|
+
smsSender: options.smsSender,
|
|
9319
9875
|
dbSchemas: config.api.schemas,
|
|
9320
9876
|
maxRows: config.api.maxRows,
|
|
9321
9877
|
storageFileSizeLimit: config.storage.fileSizeLimit,
|