@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/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.
|
|
74
|
+
version: "0.7.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,55 @@ 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
|
-
|
|
1095
|
-
|
|
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
|
+
async function sha256Hex(value) {
|
|
1121
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1122
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1123
|
+
}
|
|
1124
|
+
function normalizePhone(value) {
|
|
1125
|
+
if (typeof value !== "string")
|
|
1126
|
+
return null;
|
|
1127
|
+
const phone = value.trim();
|
|
1128
|
+
return /^\+[1-9]\d{7,14}$/.test(phone) ? phone : null;
|
|
1129
|
+
}
|
|
1130
|
+
function recordValue(value) {
|
|
1131
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1132
|
+
}
|
|
1133
|
+
function databaseDiagnosticCode(error) {
|
|
1134
|
+
const candidate = typeof error === "object" && error !== null && "code" in error ? error.code : null;
|
|
1135
|
+
return typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate) ? candidate : "database_error";
|
|
1136
|
+
}
|
|
1137
|
+
async function keyedDigest(secret, domain, value) {
|
|
1138
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1139
|
+
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${domain}\x00${value}`));
|
|
1140
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1141
|
+
}
|
|
1142
|
+
async function sendSmsUntilAbort(sender, message, signal) {
|
|
1143
|
+
if (signal.aborted)
|
|
1144
|
+
throw new Error("Phone delivery aborted");
|
|
1145
|
+
let rejectForAbort;
|
|
1146
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
1147
|
+
rejectForAbort = () => reject(new Error("Phone delivery aborted"));
|
|
1148
|
+
signal.addEventListener("abort", rejectForAbort, { once: true });
|
|
1149
|
+
});
|
|
1150
|
+
try {
|
|
1151
|
+
await Promise.race([sender.send(message, { signal }), aborted]);
|
|
1152
|
+
} finally {
|
|
1153
|
+
signal.removeEventListener("abort", rejectForAbort);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1098
1156
|
function json(status, body) {
|
|
1099
1157
|
return new Response(status === 204 ? null : JSON.stringify(body), {
|
|
1100
1158
|
status,
|
|
@@ -1116,9 +1174,14 @@ function timestampMs(value) {
|
|
|
1116
1174
|
class AuthHandler {
|
|
1117
1175
|
db;
|
|
1118
1176
|
config;
|
|
1177
|
+
static PHONE_DELIVERY_TIMEOUT_MS = 15000;
|
|
1178
|
+
static PHONE_DELIVERY_DRAIN_MS = 250;
|
|
1119
1179
|
oauth;
|
|
1120
1180
|
settings;
|
|
1121
1181
|
rateLimiter;
|
|
1182
|
+
phoneDeliveries = new Set;
|
|
1183
|
+
phoneDeliveryControllers = new Set;
|
|
1184
|
+
stopping = false;
|
|
1122
1185
|
constructor(db, config) {
|
|
1123
1186
|
this.db = db;
|
|
1124
1187
|
this.config = config;
|
|
@@ -1127,16 +1190,41 @@ class AuthHandler {
|
|
|
1127
1190
|
this.rateLimiter = config.rateLimiter === undefined ? new RateLimiter : config.rateLimiter;
|
|
1128
1191
|
}
|
|
1129
1192
|
limit(action, req) {
|
|
1193
|
+
const client = req.headers.get("x-supacloud-lite-remote-addr") ?? "local";
|
|
1194
|
+
return this.limitKey(action, `ip:${client}`);
|
|
1195
|
+
}
|
|
1196
|
+
limitKey(action, key) {
|
|
1130
1197
|
if (!this.rateLimiter)
|
|
1131
1198
|
return null;
|
|
1132
|
-
const
|
|
1133
|
-
const retryAfter = this.rateLimiter.check(action, client);
|
|
1199
|
+
const retryAfter = this.rateLimiter.check(action, key);
|
|
1134
1200
|
if (retryAfter === null)
|
|
1135
1201
|
return null;
|
|
1136
1202
|
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
1203
|
}
|
|
1138
|
-
stop() {
|
|
1204
|
+
async stop() {
|
|
1139
1205
|
this.rateLimiter?.stop();
|
|
1206
|
+
this.stopping = true;
|
|
1207
|
+
if (await this.phoneDeliveriesSettledBeforeDeadline())
|
|
1208
|
+
return;
|
|
1209
|
+
for (const controller of this.phoneDeliveryControllers)
|
|
1210
|
+
controller.abort();
|
|
1211
|
+
await Promise.all(this.phoneDeliveries);
|
|
1212
|
+
}
|
|
1213
|
+
async phoneDeliveriesSettledBeforeDeadline() {
|
|
1214
|
+
if (this.phoneDeliveries.size === 0)
|
|
1215
|
+
return true;
|
|
1216
|
+
let deadlineTimer;
|
|
1217
|
+
const deadline = new Promise((resolve) => {
|
|
1218
|
+
deadlineTimer = setTimeout(() => resolve(false), AuthHandler.PHONE_DELIVERY_DRAIN_MS);
|
|
1219
|
+
});
|
|
1220
|
+
try {
|
|
1221
|
+
return await Promise.race([
|
|
1222
|
+
Promise.all(this.phoneDeliveries).then(() => true),
|
|
1223
|
+
deadline
|
|
1224
|
+
]);
|
|
1225
|
+
} finally {
|
|
1226
|
+
clearTimeout(deadlineTimer);
|
|
1227
|
+
}
|
|
1140
1228
|
}
|
|
1141
1229
|
async handle(req, ctx, url) {
|
|
1142
1230
|
const path = url.pathname.replace(/^\/auth\/v1\/?/, "").replace(/\/+$/, "");
|
|
@@ -1149,7 +1237,7 @@ class AuthHandler {
|
|
|
1149
1237
|
return json(200, {
|
|
1150
1238
|
external: {
|
|
1151
1239
|
email: true,
|
|
1152
|
-
phone:
|
|
1240
|
+
phone: this.settings.smsEnabled && this.config.smsSender != null,
|
|
1153
1241
|
anonymous_users: this.settings.anonymousUsers,
|
|
1154
1242
|
...Object.fromEntries(providers.map((p) => [p, !this.settings.disabledProviders.includes(p)]))
|
|
1155
1243
|
},
|
|
@@ -1170,13 +1258,13 @@ class AuthHandler {
|
|
|
1170
1258
|
if (path === "logout" && method === "POST")
|
|
1171
1259
|
return await this.logout(req, url);
|
|
1172
1260
|
if (path === "otp" && method === "POST")
|
|
1173
|
-
return
|
|
1261
|
+
return await this.sendOtp(req);
|
|
1174
1262
|
if (path === "recover" && method === "POST")
|
|
1175
1263
|
return this.limit("recover", req) ?? await this.sendRecovery(req);
|
|
1176
1264
|
if (["magiclink", "resend"].includes(path) && method === "POST")
|
|
1177
|
-
return
|
|
1265
|
+
return await this.sendOtp(req);
|
|
1178
1266
|
if (path === "verify" && method === "POST")
|
|
1179
|
-
return await this.verifyToken(req);
|
|
1267
|
+
return this.limit("verify", req) ?? await this.verifyToken(req);
|
|
1180
1268
|
if (path === "verify" && method === "GET")
|
|
1181
1269
|
return await this.verifyLink(url);
|
|
1182
1270
|
if (path === "factors" && method === "POST")
|
|
@@ -1398,9 +1486,170 @@ Or sign in with this link: ${link}`
|
|
|
1398
1486
|
}
|
|
1399
1487
|
async sendOtp(req) {
|
|
1400
1488
|
const body = await req.json().catch(() => ({}));
|
|
1489
|
+
if (body.email && body.phone)
|
|
1490
|
+
return authError(400, "validation_failed", "email and phone are mutually exclusive");
|
|
1491
|
+
if (body.phone !== undefined) {
|
|
1492
|
+
if (body.channel !== undefined && body.channel !== "sms") {
|
|
1493
|
+
return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
|
|
1494
|
+
}
|
|
1495
|
+
const phone = normalizePhone(body.phone);
|
|
1496
|
+
if (!phone)
|
|
1497
|
+
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
1498
|
+
return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
|
|
1499
|
+
}
|
|
1401
1500
|
if (!body.email)
|
|
1402
|
-
return authError(400, "validation_failed", "email is required");
|
|
1403
|
-
return this.issueToken(body.email, "otp", body.create_user !== false);
|
|
1501
|
+
return authError(400, "validation_failed", "email or phone is required");
|
|
1502
|
+
return this.limit("otp", req) ?? this.issueToken(body.email, "otp", body.create_user !== false);
|
|
1503
|
+
}
|
|
1504
|
+
async issuePhoneToken(phone, createUser, metadata) {
|
|
1505
|
+
const sender = this.config.smsSender;
|
|
1506
|
+
if (!this.settings.smsEnabled || !sender) {
|
|
1507
|
+
return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
|
|
1508
|
+
}
|
|
1509
|
+
const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
|
|
1510
|
+
const limited = this.limitKey("sms", `phone:${fingerprint}`);
|
|
1511
|
+
if (limited)
|
|
1512
|
+
return limited;
|
|
1513
|
+
const code = randomOtp(this.settings.otpLength);
|
|
1514
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
1515
|
+
const issuanceId = crypto.randomUUID();
|
|
1516
|
+
const prepared = await this.preparePhoneOtp({
|
|
1517
|
+
phone,
|
|
1518
|
+
createUser,
|
|
1519
|
+
metadata,
|
|
1520
|
+
fingerprint,
|
|
1521
|
+
tokenDigest,
|
|
1522
|
+
issuanceId,
|
|
1523
|
+
eligibleBefore: new Date(Date.now() - this.settings.smsOtpCooldownSeconds * 1000).toISOString(),
|
|
1524
|
+
expiry: `${this.settings.otpExpirySeconds} seconds`
|
|
1525
|
+
});
|
|
1526
|
+
if (prepared.state === "cooldown") {
|
|
1527
|
+
return authError(429, "over_sms_send_rate_limit", "SMS can only be requested after the cooldown");
|
|
1528
|
+
}
|
|
1529
|
+
if (prepared.state === "signup_disabled") {
|
|
1530
|
+
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1531
|
+
}
|
|
1532
|
+
if (prepared.state === "unknown")
|
|
1533
|
+
return json(200, {});
|
|
1534
|
+
const body = this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, code);
|
|
1535
|
+
const delivery = createUser ? this.deliverPhoneOtp(prepared, sender, phone, body) : this.deferPhoneOtpDelivery(prepared, sender, phone, body);
|
|
1536
|
+
this.trackPhoneDelivery(delivery);
|
|
1537
|
+
if (!createUser) {
|
|
1538
|
+
return json(200, {});
|
|
1539
|
+
}
|
|
1540
|
+
return await delivery ? json(200, {}) : authError(502, "sms_provider_failed", "Unable to send the verification code");
|
|
1541
|
+
}
|
|
1542
|
+
async preparePhoneOtp(request) {
|
|
1543
|
+
try {
|
|
1544
|
+
return await this.db.transaction(async (query) => {
|
|
1545
|
+
const cooldown = await query(`insert into auth.phone_otp_cooldowns (phone_fingerprint, issuance_id, last_sent_at)
|
|
1546
|
+
values ($1, $2, now())
|
|
1547
|
+
on conflict (phone_fingerprint) do update
|
|
1548
|
+
set issuance_id = excluded.issuance_id, last_sent_at = excluded.last_sent_at
|
|
1549
|
+
where auth.phone_otp_cooldowns.last_sent_at <= $3::timestamptz
|
|
1550
|
+
returning phone_fingerprint`, [request.fingerprint, request.issuanceId, request.eligibleBefore]);
|
|
1551
|
+
if (cooldown.rows.length === 0)
|
|
1552
|
+
return { state: "cooldown" };
|
|
1553
|
+
const resolved = await this.phoneUser(query, request);
|
|
1554
|
+
if (resolved === "unknown" || resolved === "signup_disabled")
|
|
1555
|
+
return { state: resolved };
|
|
1556
|
+
await this.createPhoneIdentity(query, resolved);
|
|
1557
|
+
await query(`delete from auth.one_time_tokens where phone = $1 and token_type = 'sms'`, [request.phone]);
|
|
1558
|
+
await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
|
|
1559
|
+
values ($1, $2, $3, 'sms', $4, now() + $5::interval)`, [request.issuanceId, resolved.id, request.phone, request.tokenDigest, request.expiry]);
|
|
1560
|
+
return {
|
|
1561
|
+
state: "issued",
|
|
1562
|
+
id: request.issuanceId,
|
|
1563
|
+
fingerprint: request.fingerprint,
|
|
1564
|
+
tokenDigest: request.tokenDigest,
|
|
1565
|
+
releaseCooldownOnFailure: request.createUser
|
|
1566
|
+
};
|
|
1567
|
+
});
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
this.reportPhoneFailure("issue", databaseDiagnosticCode(error));
|
|
1570
|
+
throw new Error("Unable to issue the verification code", { cause: error });
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
async phoneUser(query, request) {
|
|
1574
|
+
const existing = await this.phoneUserForUpdate(query, request.phone);
|
|
1575
|
+
if (existing)
|
|
1576
|
+
return existing.auth_eligible ? existing : "unknown";
|
|
1577
|
+
if (!request.createUser)
|
|
1578
|
+
return "unknown";
|
|
1579
|
+
if (this.settings.disableSignup || !this.settings.smsSignupEnabled)
|
|
1580
|
+
return "signup_disabled";
|
|
1581
|
+
const inserted = await query(`insert into auth.users (aud, role, phone, raw_app_meta_data, raw_user_meta_data)
|
|
1582
|
+
values ('authenticated', 'authenticated', $1, $2::jsonb, $3::jsonb)
|
|
1583
|
+
on conflict (phone) do nothing returning *`, [
|
|
1584
|
+
request.phone,
|
|
1585
|
+
JSON.stringify({ provider: "phone", providers: ["phone"] }),
|
|
1586
|
+
JSON.stringify(request.metadata)
|
|
1587
|
+
]);
|
|
1588
|
+
if (inserted.rows[0])
|
|
1589
|
+
return inserted.rows[0];
|
|
1590
|
+
const concurrent = await this.phoneUserForUpdate(query, request.phone);
|
|
1591
|
+
if (!concurrent)
|
|
1592
|
+
throw new Error("phone user could not be resolved");
|
|
1593
|
+
return concurrent.auth_eligible ? concurrent : "unknown";
|
|
1594
|
+
}
|
|
1595
|
+
async phoneUserForUpdate(query, phone) {
|
|
1596
|
+
const users = await query(`select *, deleted_at is null and (banned_until is null or banned_until <= now()) as auth_eligible
|
|
1597
|
+
from auth.users where phone = $1 for update`, [phone]);
|
|
1598
|
+
return users.rows[0];
|
|
1599
|
+
}
|
|
1600
|
+
createPhoneIdentity(query, user) {
|
|
1601
|
+
return query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1602
|
+
values ($1, 'phone', $2, $3::jsonb)
|
|
1603
|
+
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 })]);
|
|
1604
|
+
}
|
|
1605
|
+
async deliverPhoneOtp(issuance, sender, phone, body) {
|
|
1606
|
+
try {
|
|
1607
|
+
await this.sendPhoneOtp(sender, phone, body);
|
|
1608
|
+
return true;
|
|
1609
|
+
} catch {
|
|
1610
|
+
this.reportPhoneFailure("deliver", "provider_error");
|
|
1611
|
+
await this.cleanupFailedPhoneOtp(issuance);
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
async sendPhoneOtp(sender, phone, body) {
|
|
1616
|
+
const controller = new AbortController;
|
|
1617
|
+
this.phoneDeliveryControllers.add(controller);
|
|
1618
|
+
const deliveryTimer = setTimeout(() => controller.abort(), AuthHandler.PHONE_DELIVERY_TIMEOUT_MS);
|
|
1619
|
+
if (this.stopping)
|
|
1620
|
+
controller.abort();
|
|
1621
|
+
try {
|
|
1622
|
+
await sendSmsUntilAbort(sender, { to: phone, body }, controller.signal);
|
|
1623
|
+
} finally {
|
|
1624
|
+
clearTimeout(deliveryTimer);
|
|
1625
|
+
this.phoneDeliveryControllers.delete(controller);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
async cleanupFailedPhoneOtp(issuance) {
|
|
1629
|
+
try {
|
|
1630
|
+
await this.db.transaction(async (query) => {
|
|
1631
|
+
await query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [issuance.id, issuance.tokenDigest]);
|
|
1632
|
+
if (issuance.releaseCooldownOnFailure) {
|
|
1633
|
+
await query(`delete from auth.phone_otp_cooldowns where phone_fingerprint = $1 and issuance_id = $2`, [issuance.fingerprint, issuance.id]);
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
} catch (cleanupError) {
|
|
1637
|
+
this.reportPhoneFailure("cleanup", databaseDiagnosticCode(cleanupError));
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
async deferPhoneOtpDelivery(issuance, sender, phone, body) {
|
|
1641
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1642
|
+
return this.deliverPhoneOtp(issuance, sender, phone, body);
|
|
1643
|
+
}
|
|
1644
|
+
trackPhoneDelivery(delivery) {
|
|
1645
|
+
let tracked;
|
|
1646
|
+
tracked = delivery.then(() => {}).finally(() => this.phoneDeliveries.delete(tracked));
|
|
1647
|
+
this.phoneDeliveries.add(tracked);
|
|
1648
|
+
}
|
|
1649
|
+
reportPhoneFailure(operation, code) {
|
|
1650
|
+
try {
|
|
1651
|
+
this.config.log?.(`[auth] phone_otp_${operation} failed code=${code}`);
|
|
1652
|
+
} catch {}
|
|
1404
1653
|
}
|
|
1405
1654
|
async sendRecovery(req) {
|
|
1406
1655
|
const body = await req.json().catch(() => ({}));
|
|
@@ -1410,36 +1659,116 @@ Or sign in with this link: ${link}`
|
|
|
1410
1659
|
}
|
|
1411
1660
|
static MAX_OTP_ATTEMPTS = 5;
|
|
1412
1661
|
async redeem(token, types, email) {
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
await this.db.query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [normalizedEmail, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1425
|
-
}
|
|
1662
|
+
const redemption = {
|
|
1663
|
+
token,
|
|
1664
|
+
tokenTypes: `{${types.join(",")}}`,
|
|
1665
|
+
email: email?.toLowerCase().trim() ?? null
|
|
1666
|
+
};
|
|
1667
|
+
return this.db.transaction((query) => this.claimEmailToken(query, redemption));
|
|
1668
|
+
}
|
|
1669
|
+
async claimEmailToken(query, redemption) {
|
|
1670
|
+
const candidate = await this.findEmailTokenCandidate(query, redemption);
|
|
1671
|
+
if (!candidate) {
|
|
1672
|
+
await this.recordFailedEmailTokenAttempt(query, redemption);
|
|
1426
1673
|
return null;
|
|
1427
1674
|
}
|
|
1428
|
-
await this.
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1675
|
+
const claimedToken = await this.lockAndDeleteEmailToken(query, candidate, redemption);
|
|
1676
|
+
return claimedToken ? this.confirmEmailTokenUser(query, claimedToken) : null;
|
|
1677
|
+
}
|
|
1678
|
+
async lockAndDeleteEmailToken(query, candidate, redemption) {
|
|
1679
|
+
await query(`select id from auth.users where id = $1 for update`, [candidate.user_id]);
|
|
1680
|
+
const claimedTokens = await query(`delete from auth.one_time_tokens
|
|
1681
|
+
where id = $1 and token = $2 and token_type = any($3::text[])
|
|
1682
|
+
and expires_at > now() and attempts < $4
|
|
1683
|
+
returning id, user_id, email`, [candidate.id, redemption.token, redemption.tokenTypes, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1684
|
+
return claimedTokens.rows[0] ?? null;
|
|
1685
|
+
}
|
|
1686
|
+
async confirmEmailTokenUser(query, claimedToken) {
|
|
1687
|
+
await query(`delete from auth.one_time_tokens where email = $1`, [claimedToken.email]);
|
|
1688
|
+
const users = await query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
|
|
1689
|
+
where id = $1 returning *`, [claimedToken.user_id]);
|
|
1690
|
+
return users.rows[0] ?? null;
|
|
1691
|
+
}
|
|
1692
|
+
async findEmailTokenCandidate(query, redemption) {
|
|
1693
|
+
const candidates = await query(`select id, user_id, email from auth.one_time_tokens
|
|
1694
|
+
where token = $1 and token_type = any($2::text[])
|
|
1695
|
+
and ($3::text is null or email = $3) and expires_at > now()
|
|
1696
|
+
and attempts < $4`, [redemption.token, redemption.tokenTypes, redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1697
|
+
return candidates.rows[0] ?? null;
|
|
1698
|
+
}
|
|
1699
|
+
async recordFailedEmailTokenAttempt(query, redemption) {
|
|
1700
|
+
if (!redemption.email)
|
|
1701
|
+
return;
|
|
1702
|
+
await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1703
|
+
where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1704
|
+
await query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1432
1705
|
}
|
|
1433
1706
|
async verifyToken(req) {
|
|
1434
1707
|
const body = await req.json().catch(() => ({}));
|
|
1435
|
-
|
|
1436
|
-
|
|
1708
|
+
const token = body.token ?? body.token_hash;
|
|
1709
|
+
if (!token)
|
|
1710
|
+
return authError(400, "validation_failed", "token or token_hash is required");
|
|
1711
|
+
if (body.type === "sms" || body.phone !== undefined) {
|
|
1712
|
+
const phone = normalizePhone(body.phone);
|
|
1713
|
+
if (body.type !== "sms" || !phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
|
|
1714
|
+
return authError(400, "validation_failed", "phone, token, and type=sms are required");
|
|
1715
|
+
}
|
|
1716
|
+
const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
|
|
1717
|
+
const limited = this.limitKey("verify", `phone:${fingerprint}`);
|
|
1718
|
+
if (limited)
|
|
1719
|
+
return limited;
|
|
1720
|
+
let session;
|
|
1721
|
+
try {
|
|
1722
|
+
session = await this.redeemPhoneOtp(phone, body.token);
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
this.reportPhoneFailure("verify", databaseDiagnosticCode(error));
|
|
1725
|
+
return authError(500, "unexpected_failure", "Unable to verify the code");
|
|
1726
|
+
}
|
|
1727
|
+
if (!session)
|
|
1728
|
+
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
1729
|
+
return json(200, session);
|
|
1730
|
+
}
|
|
1437
1731
|
const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : ["otp", "magiclink"];
|
|
1438
|
-
const user = await this.redeem(
|
|
1732
|
+
const user = await this.redeem(token, types, body.email);
|
|
1439
1733
|
if (!user)
|
|
1440
1734
|
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
1441
1735
|
return json(200, await this.sessionFor(user));
|
|
1442
1736
|
}
|
|
1737
|
+
async redeemPhoneOtp(phone, code) {
|
|
1738
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
1739
|
+
return this.db.transaction(async (query) => {
|
|
1740
|
+
const claimed = await query(`delete from auth.one_time_tokens
|
|
1741
|
+
where phone = $1 and token_type = 'sms' and token = $2
|
|
1742
|
+
and expires_at > now() and attempts < $3
|
|
1743
|
+
returning user_id`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1744
|
+
const userId = claimed.rows[0]?.user_id;
|
|
1745
|
+
if (!userId) {
|
|
1746
|
+
const attempt = await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1747
|
+
where phone = $1 and token_type = 'sms' and expires_at > now()
|
|
1748
|
+
returning id, attempts`, [phone]);
|
|
1749
|
+
const row = attempt.rows[0];
|
|
1750
|
+
if (row && row.attempts >= AuthHandler.MAX_OTP_ATTEMPTS) {
|
|
1751
|
+
await query(`delete from auth.one_time_tokens where id = $1`, [row.id]);
|
|
1752
|
+
}
|
|
1753
|
+
await query(`delete from auth.one_time_tokens where phone = $1 and expires_at <= now()`, [phone]);
|
|
1754
|
+
return null;
|
|
1755
|
+
}
|
|
1756
|
+
await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
|
|
1757
|
+
const users = await query(`update auth.users
|
|
1758
|
+
set phone_confirmed_at = coalesce(phone_confirmed_at, now()), last_sign_in_at = now(), updated_at = now()
|
|
1759
|
+
where id = $1 and deleted_at is null
|
|
1760
|
+
and (banned_until is null or banned_until <= now())
|
|
1761
|
+
returning *`, [userId]);
|
|
1762
|
+
const user = users.rows[0];
|
|
1763
|
+
if (!user)
|
|
1764
|
+
return null;
|
|
1765
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data, last_sign_in_at)
|
|
1766
|
+
values ($1, 'phone', $2, $3::jsonb, now())
|
|
1767
|
+
on conflict (provider, provider_id) do update
|
|
1768
|
+
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 })]);
|
|
1769
|
+
return this.sessionFor(user, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1443
1772
|
async verifyLink(url) {
|
|
1444
1773
|
const token = url.searchParams.get("token") ?? "";
|
|
1445
1774
|
const type = url.searchParams.get("type") ?? "magiclink";
|
|
@@ -1458,6 +1787,9 @@ Or sign in with this link: ${link}`
|
|
|
1458
1787
|
}
|
|
1459
1788
|
const idMatch = path.match(/^admin\/users\/([0-9a-f-]{36})$/);
|
|
1460
1789
|
const exportMatch = path.match(/^admin\/users\/([0-9a-f-]{36})\/export$/);
|
|
1790
|
+
if (path === "admin/generate_link" && method === "POST") {
|
|
1791
|
+
return await this.generateAdminMagicLink(req);
|
|
1792
|
+
}
|
|
1461
1793
|
if (path === "admin/audit" && method === "GET") {
|
|
1462
1794
|
const res = await this.db.query(`select id, payload, created_at, ip_address from auth.audit_log_entries
|
|
1463
1795
|
order by created_at desc limit 200`);
|
|
@@ -1528,6 +1860,50 @@ Or sign in with this link: ${link}`
|
|
|
1528
1860
|
}
|
|
1529
1861
|
return authError(404, "not_found", `unknown admin endpoint`);
|
|
1530
1862
|
}
|
|
1863
|
+
async generateAdminMagicLink(req) {
|
|
1864
|
+
const body = await req.json().catch(() => ({}));
|
|
1865
|
+
if (body.type !== "magiclink") {
|
|
1866
|
+
return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
|
|
1867
|
+
}
|
|
1868
|
+
if (!body.email)
|
|
1869
|
+
return authError(400, "validation_failed", "email is required");
|
|
1870
|
+
const email = body.email.toLowerCase().trim();
|
|
1871
|
+
const emailOtp = randomOtp(this.settings.otpLength);
|
|
1872
|
+
const hashedToken = await sha256Hex(randomToken(32));
|
|
1873
|
+
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
1874
|
+
const requestUrl = new URL(req.url);
|
|
1875
|
+
const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
1876
|
+
const user = await this.db.transaction(async (query) => {
|
|
1877
|
+
const result = await query(`insert into auth.users
|
|
1878
|
+
(aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1879
|
+
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
|
|
1880
|
+
on conflict (email) do update set email = excluded.email
|
|
1881
|
+
returning *`, [email, JSON.stringify(body.data ?? {})]);
|
|
1882
|
+
const linkedUser = result.rows[0];
|
|
1883
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1884
|
+
values ($1::uuid, 'email', $1::text, $2::jsonb)
|
|
1885
|
+
on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
|
|
1886
|
+
await query(`delete from auth.one_time_tokens
|
|
1887
|
+
where email = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
|
|
1888
|
+
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
1889
|
+
values ($1, $2, 'otp', $3, now() + $5::interval),
|
|
1890
|
+
($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
|
|
1891
|
+
return linkedUser;
|
|
1892
|
+
});
|
|
1893
|
+
const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
|
|
1894
|
+
actionUrl.searchParams.set("token", hashedToken);
|
|
1895
|
+
actionUrl.searchParams.set("type", "magiclink");
|
|
1896
|
+
actionUrl.searchParams.set("redirect_to", redirectTo);
|
|
1897
|
+
await this.audit("user_magiclink_requested", { actorId: user.id, actorEmail: email, type: "admin" });
|
|
1898
|
+
return json(200, {
|
|
1899
|
+
...this.userJson(user, [], await this.getUserIdentities(user.id)),
|
|
1900
|
+
action_link: actionUrl.toString(),
|
|
1901
|
+
email_otp: emailOtp,
|
|
1902
|
+
hashed_token: hashedToken,
|
|
1903
|
+
redirect_to: redirectTo,
|
|
1904
|
+
verification_type: "magiclink"
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1531
1907
|
async audit(action, opts = {}) {
|
|
1532
1908
|
try {
|
|
1533
1909
|
const payload = {
|
|
@@ -1782,7 +2158,8 @@ Or sign in with this link: ${link}`
|
|
|
1782
2158
|
email: u.email ?? "",
|
|
1783
2159
|
email_confirmed_at: iso(u.email_confirmed_at),
|
|
1784
2160
|
phone: u.phone ?? "",
|
|
1785
|
-
|
|
2161
|
+
phone_confirmed_at: iso(u.phone_confirmed_at),
|
|
2162
|
+
confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
|
|
1786
2163
|
last_sign_in_at: iso(u.last_sign_in_at),
|
|
1787
2164
|
app_metadata: u.raw_app_meta_data ?? {},
|
|
1788
2165
|
user_metadata: u.raw_user_meta_data ?? {},
|
|
@@ -1946,6 +2323,58 @@ var INBOX_HTML = `<!doctype html>
|
|
|
1946
2323
|
</body>
|
|
1947
2324
|
</html>`;
|
|
1948
2325
|
|
|
2326
|
+
// src/runtime/auth/sms-inbox.ts
|
|
2327
|
+
var CAP2 = 200;
|
|
2328
|
+
|
|
2329
|
+
class SmsInbox {
|
|
2330
|
+
messages = [];
|
|
2331
|
+
async send(msg) {
|
|
2332
|
+
const id = crypto.randomUUID();
|
|
2333
|
+
this.messages.unshift({
|
|
2334
|
+
...msg,
|
|
2335
|
+
id,
|
|
2336
|
+
created_at: new Date().toISOString(),
|
|
2337
|
+
code: msg.body.match(/\b\d{6,10}\b/)?.[0] ?? null
|
|
2338
|
+
});
|
|
2339
|
+
if (this.messages.length > CAP2)
|
|
2340
|
+
this.messages.length = CAP2;
|
|
2341
|
+
return { messageId: id };
|
|
2342
|
+
}
|
|
2343
|
+
list() {
|
|
2344
|
+
return this.messages;
|
|
2345
|
+
}
|
|
2346
|
+
clear() {
|
|
2347
|
+
this.messages = [];
|
|
2348
|
+
}
|
|
2349
|
+
serve(req, url) {
|
|
2350
|
+
const method = req.method.toUpperCase();
|
|
2351
|
+
if (url.pathname === "/sms-inbox/api/messages") {
|
|
2352
|
+
if (method === "DELETE") {
|
|
2353
|
+
this.clear();
|
|
2354
|
+
return new Response(null, { status: 204 });
|
|
2355
|
+
}
|
|
2356
|
+
return Response.json({ messages: this.messages });
|
|
2357
|
+
}
|
|
2358
|
+
if (url.pathname === "/sms-inbox" || url.pathname === "/sms-inbox/") {
|
|
2359
|
+
return new Response(SMS_INBOX_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
2360
|
+
}
|
|
2361
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
var SMS_INBOX_HTML = `<!doctype html>
|
|
2365
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2366
|
+
<title>SupaCloud Lite \xB7 SMS Inbox</title><style>
|
|
2367
|
+
:root{color-scheme:dark}body{margin:0;background:#0a0a0a;color:#fafafa;font:14px/1.5 system-ui,sans-serif}
|
|
2368
|
+
header,main{max-width:760px;margin:auto;padding:20px}.msg{border:1px solid #27272a;border-radius:12px;padding:16px;margin:12px 0}
|
|
2369
|
+
.code{font:20px ui-monospace,monospace;letter-spacing:3px;color:#34d399}.muted{color:#a1a1aa}button{padding:6px 12px}
|
|
2370
|
+
</style></head><body><header><h1>SupaCloud Lite \xB7 SMS Inbox</h1><p class="muted">Loopback local development only</p>
|
|
2371
|
+
<button id="clear">Clear</button></header><main id="list">Loading\u2026</main><script>
|
|
2372
|
+
const esc=s=>s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
|
2373
|
+
async function load(){const r=await fetch('/sms-inbox/api/messages');const {messages}=await r.json();
|
|
2374
|
+
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.'}
|
|
2375
|
+
document.getElementById('clear').onclick=async()=>{await fetch('/sms-inbox/api/messages',{method:'DELETE'});load()};load();setInterval(load,4000)
|
|
2376
|
+
</script></body></html>`;
|
|
2377
|
+
|
|
1949
2378
|
// src/runtime/log-buffer.ts
|
|
1950
2379
|
class LogBuffer {
|
|
1951
2380
|
cap;
|
|
@@ -2267,12 +2696,34 @@ create table if not exists auth.refresh_tokens (
|
|
|
2267
2696
|
create table if not exists auth.one_time_tokens (
|
|
2268
2697
|
id uuid primary key default gen_random_uuid(),
|
|
2269
2698
|
user_id uuid,
|
|
2270
|
-
email text
|
|
2699
|
+
email text,
|
|
2700
|
+
phone text,
|
|
2271
2701
|
token_type text not null,
|
|
2272
2702
|
token text not null,
|
|
2273
2703
|
attempts int not null default 0,
|
|
2274
2704
|
created_at timestamptz default now(),
|
|
2275
|
-
expires_at timestamptz not null
|
|
2705
|
+
expires_at timestamptz not null,
|
|
2706
|
+
constraint one_time_tokens_contact_check check (
|
|
2707
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2708
|
+
)
|
|
2709
|
+
);
|
|
2710
|
+
|
|
2711
|
+
-- Minimal engines can persist a database created by an older Lite version.
|
|
2712
|
+
-- Standard ALTER statements keep that path compatible without requiring plpgsql.
|
|
2713
|
+
alter table auth.one_time_tokens add column if not exists phone text;
|
|
2714
|
+
alter table auth.one_time_tokens alter column email drop not null;
|
|
2715
|
+
alter table auth.one_time_tokens drop constraint if exists one_time_tokens_contact_check;
|
|
2716
|
+
alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
|
|
2717
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2718
|
+
);
|
|
2719
|
+
|
|
2720
|
+
create unique index if not exists one_time_tokens_phone_type_idx
|
|
2721
|
+
on auth.one_time_tokens(phone, token_type) where phone is not null;
|
|
2722
|
+
|
|
2723
|
+
create table if not exists auth.phone_otp_cooldowns (
|
|
2724
|
+
phone_fingerprint text primary key,
|
|
2725
|
+
issuance_id uuid not null,
|
|
2726
|
+
last_sent_at timestamptz not null default now()
|
|
2276
2727
|
);
|
|
2277
2728
|
|
|
2278
2729
|
create table if not exists auth.identities (
|
|
@@ -2337,6 +2788,7 @@ create table if not exists storage.buckets (
|
|
|
2337
2788
|
id text primary key,
|
|
2338
2789
|
name text not null unique,
|
|
2339
2790
|
owner uuid,
|
|
2791
|
+
owner_id text,
|
|
2340
2792
|
public boolean default false,
|
|
2341
2793
|
file_size_limit bigint,
|
|
2342
2794
|
allowed_mime_types text[],
|
|
@@ -2344,11 +2796,17 @@ create table if not exists storage.buckets (
|
|
|
2344
2796
|
updated_at timestamptz default now()
|
|
2345
2797
|
);
|
|
2346
2798
|
|
|
2799
|
+
alter table storage.buckets add column if not exists owner_id text;
|
|
2800
|
+
update storage.buckets
|
|
2801
|
+
set owner_id = owner::text
|
|
2802
|
+
where owner_id is null and owner is not null;
|
|
2803
|
+
|
|
2347
2804
|
create table if not exists storage.objects (
|
|
2348
2805
|
id uuid primary key default gen_random_uuid(),
|
|
2349
2806
|
bucket_id text not null,
|
|
2350
2807
|
name text not null,
|
|
2351
2808
|
owner uuid,
|
|
2809
|
+
owner_id text,
|
|
2352
2810
|
version text,
|
|
2353
2811
|
metadata jsonb default '{}'::jsonb,
|
|
2354
2812
|
created_at timestamptz default now(),
|
|
@@ -2357,6 +2815,11 @@ create table if not exists storage.objects (
|
|
|
2357
2815
|
unique (bucket_id, name)
|
|
2358
2816
|
);
|
|
2359
2817
|
|
|
2818
|
+
alter table storage.objects add column if not exists owner_id text;
|
|
2819
|
+
update storage.objects
|
|
2820
|
+
set owner_id = owner::text
|
|
2821
|
+
where owner_id is null and owner is not null;
|
|
2822
|
+
|
|
2360
2823
|
create table if not exists supabase_migrations.schema_migrations (
|
|
2361
2824
|
version text primary key,
|
|
2362
2825
|
name text,
|
|
@@ -2503,12 +2966,43 @@ create index if not exists refresh_tokens_user_id_idx on auth.refresh_tokens(use
|
|
|
2503
2966
|
create table if not exists auth.one_time_tokens (
|
|
2504
2967
|
id uuid primary key default gen_random_uuid(),
|
|
2505
2968
|
user_id uuid references auth.users(id) on delete cascade,
|
|
2506
|
-
email text
|
|
2507
|
-
|
|
2969
|
+
email text,
|
|
2970
|
+
phone text,
|
|
2971
|
+
token_type text not null, -- otp | magiclink | recovery | sms
|
|
2508
2972
|
token text not null,
|
|
2509
2973
|
attempts int not null default 0,
|
|
2510
2974
|
created_at timestamptz default now(),
|
|
2511
|
-
expires_at timestamptz not null
|
|
2975
|
+
expires_at timestamptz not null,
|
|
2976
|
+
constraint one_time_tokens_contact_check check (
|
|
2977
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2978
|
+
)
|
|
2979
|
+
);
|
|
2980
|
+
|
|
2981
|
+
-- Upgrade databases created by Lite <=0.5.9 without touching existing email tokens.
|
|
2982
|
+
alter table auth.one_time_tokens add column if not exists phone text;
|
|
2983
|
+
alter table auth.one_time_tokens alter column email drop not null;
|
|
2984
|
+
do $phone_otp_contact_constraint$
|
|
2985
|
+
begin
|
|
2986
|
+
if not exists (
|
|
2987
|
+
select 1 from pg_constraint
|
|
2988
|
+
where conrelid = 'auth.one_time_tokens'::regclass
|
|
2989
|
+
and conname = 'one_time_tokens_contact_check'
|
|
2990
|
+
) then
|
|
2991
|
+
alter table auth.one_time_tokens add constraint one_time_tokens_contact_check check (
|
|
2992
|
+
(email is not null and phone is null) or (email is null and phone is not null)
|
|
2993
|
+
);
|
|
2994
|
+
end if;
|
|
2995
|
+
end $phone_otp_contact_constraint$;
|
|
2996
|
+
|
|
2997
|
+
create unique index if not exists one_time_tokens_phone_type_idx
|
|
2998
|
+
on auth.one_time_tokens(phone, token_type) where phone is not null;
|
|
2999
|
+
|
|
3000
|
+
-- Only a keyed phone fingerprint is persisted for cooldown enforcement; the
|
|
3001
|
+
-- normalized phone number never enters this table.
|
|
3002
|
+
create table if not exists auth.phone_otp_cooldowns (
|
|
3003
|
+
phone_fingerprint text primary key,
|
|
3004
|
+
issuance_id uuid not null,
|
|
3005
|
+
last_sent_at timestamptz not null default now()
|
|
2512
3006
|
);
|
|
2513
3007
|
|
|
2514
3008
|
create table if not exists auth.identities (
|
|
@@ -2608,6 +3102,7 @@ create table if not exists storage.buckets (
|
|
|
2608
3102
|
id text primary key,
|
|
2609
3103
|
name text not null unique,
|
|
2610
3104
|
owner uuid,
|
|
3105
|
+
owner_id text,
|
|
2611
3106
|
public boolean default false,
|
|
2612
3107
|
file_size_limit bigint,
|
|
2613
3108
|
allowed_mime_types text[],
|
|
@@ -2615,11 +3110,17 @@ create table if not exists storage.buckets (
|
|
|
2615
3110
|
updated_at timestamptz default now()
|
|
2616
3111
|
);
|
|
2617
3112
|
|
|
3113
|
+
alter table storage.buckets add column if not exists owner_id text;
|
|
3114
|
+
update storage.buckets
|
|
3115
|
+
set owner_id = owner::text
|
|
3116
|
+
where owner_id is null and owner is not null;
|
|
3117
|
+
|
|
2618
3118
|
create table if not exists storage.objects (
|
|
2619
3119
|
id uuid primary key default gen_random_uuid(),
|
|
2620
3120
|
bucket_id text not null references storage.buckets(id),
|
|
2621
3121
|
name text not null,
|
|
2622
3122
|
owner uuid,
|
|
3123
|
+
owner_id text,
|
|
2623
3124
|
version text,
|
|
2624
3125
|
metadata jsonb default '{}'::jsonb,
|
|
2625
3126
|
created_at timestamptz default now(),
|
|
@@ -2628,6 +3129,14 @@ create table if not exists storage.objects (
|
|
|
2628
3129
|
unique (bucket_id, name)
|
|
2629
3130
|
);
|
|
2630
3131
|
|
|
3132
|
+
-- storage-api keeps the legacy UUID owner and the current text owner_id in
|
|
3133
|
+
-- parallel. Re-running bootstrap upgrades existing Lite databases and retains
|
|
3134
|
+
-- object ownership for rows created before owner_id support was added.
|
|
3135
|
+
alter table storage.objects add column if not exists owner_id text;
|
|
3136
|
+
update storage.objects
|
|
3137
|
+
set owner_id = owner::text
|
|
3138
|
+
where owner_id is null and owner is not null;
|
|
3139
|
+
|
|
2631
3140
|
create index if not exists objects_bucket_name_idx on storage.objects(bucket_id, name);
|
|
2632
3141
|
|
|
2633
3142
|
grant usage on schema storage to anon, authenticated, service_role;
|
|
@@ -3720,7 +4229,7 @@ class Database {
|
|
|
3720
4229
|
}
|
|
3721
4230
|
}
|
|
3722
4231
|
if (seedSql) {
|
|
3723
|
-
const hash = await
|
|
4232
|
+
const hash = await sha256Hex2(seedSql);
|
|
3724
4233
|
const seen = await this.engine.query(`select 1 from supabase_migrations.seed_files where path = 'supabase/seed.sql' and hash = $1`, [hash]);
|
|
3725
4234
|
if (seen.rows.length === 0) {
|
|
3726
4235
|
await this.engine.transaction(async (tx) => {
|
|
@@ -4013,7 +4522,7 @@ function quoteIdent(name) {
|
|
|
4013
4522
|
function quoteLiteral(value) {
|
|
4014
4523
|
return `'${value.replaceAll("'", "''")}'`;
|
|
4015
4524
|
}
|
|
4016
|
-
async function
|
|
4525
|
+
async function sha256Hex2(text) {
|
|
4017
4526
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
4018
4527
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
4019
4528
|
}
|
|
@@ -6033,6 +6542,12 @@ function invalidObjectKey(key) {
|
|
|
6033
6542
|
return "object key must not contain . or .. segments";
|
|
6034
6543
|
return null;
|
|
6035
6544
|
}
|
|
6545
|
+
var LEGACY_OWNER_UUID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
|
|
6546
|
+
function storageObjectOwnership(ctx) {
|
|
6547
|
+
const ownerId = typeof ctx.claims?.sub === "string" ? ctx.claims.sub : null;
|
|
6548
|
+
const legacyOwner = ownerId !== null && LEGACY_OWNER_UUID_PATTERN.test(ownerId) ? ownerId : null;
|
|
6549
|
+
return { legacyOwner, ownerId };
|
|
6550
|
+
}
|
|
6036
6551
|
function storageError(status, error, message) {
|
|
6037
6552
|
return json3(status, { statusCode: String(status), error, message });
|
|
6038
6553
|
}
|
|
@@ -6371,16 +6886,18 @@ class StorageHandler {
|
|
|
6371
6886
|
}
|
|
6372
6887
|
async persistObject(ctx, bucketId, key, bytes, contentType, cacheControl, upsert) {
|
|
6373
6888
|
const metadata = objectMetadata(bytes.length, contentType, cacheControl);
|
|
6889
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6374
6890
|
const previous = (await this.db.query(`select * from storage.objects where bucket_id = $1 and name = $2`, [bucketId, key])).rows[0];
|
|
6375
6891
|
const objectId = previous?.id ?? crypto.randomUUID();
|
|
6376
6892
|
const version = createObjectVersion();
|
|
6377
6893
|
const stagedKey = await this.stageObjectBytes(version, bytes);
|
|
6378
6894
|
const conflictClause = upsert ? `on conflict (bucket_id, name) do update
|
|
6379
|
-
set metadata = excluded.metadata, owner = excluded.owner,
|
|
6895
|
+
set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
|
|
6896
|
+
updated_at = now(), version = excluded.version` : "";
|
|
6380
6897
|
let inserted;
|
|
6381
6898
|
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, $
|
|
6899
|
+
const result = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
|
|
6900
|
+
values ($1, $2, $3, $4::uuid, $5, $6::jsonb, $7) ${conflictClause} returning *`, [objectId, bucketId, key, ownership.legacyOwner, ownership.ownerId, JSON.stringify(metadata), version]));
|
|
6384
6901
|
inserted = result.rows[0];
|
|
6385
6902
|
} catch (error) {
|
|
6386
6903
|
if (isRlsDenied(error)) {
|
|
@@ -6604,15 +7121,18 @@ class StorageHandler {
|
|
|
6604
7121
|
return bucket.file_size_limit != null ? Number(bucket.file_size_limit) : this.config.defaultFileSizeLimit ?? DEFAULT_FILE_SIZE_LIMIT;
|
|
6605
7122
|
}
|
|
6606
7123
|
async preflightObjectWrite(ctx, bucketId, key, size, contentType, cacheControl, upsert) {
|
|
7124
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6607
7125
|
const conflictClause = upsert ? `on conflict (bucket_id, name) do update
|
|
6608
|
-
set metadata = excluded.metadata, owner = excluded.owner,
|
|
7126
|
+
set metadata = excluded.metadata, owner = excluded.owner, owner_id = excluded.owner_id,
|
|
7127
|
+
updated_at = now(), version = excluded.version` : "";
|
|
6609
7128
|
try {
|
|
6610
7129
|
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, $
|
|
7130
|
+
await query(`insert into storage.objects (bucket_id, name, owner, owner_id, metadata, version)
|
|
7131
|
+
values ($1, $2, $3::uuid, $4, $5::jsonb, $6) ${conflictClause} returning id`, [
|
|
6613
7132
|
bucketId,
|
|
6614
7133
|
key,
|
|
6615
|
-
|
|
7134
|
+
ownership.legacyOwner,
|
|
7135
|
+
ownership.ownerId,
|
|
6616
7136
|
JSON.stringify(objectMetadata(size, contentType ?? "application/octet-stream", cacheControl ?? "no-cache")),
|
|
6617
7137
|
createObjectVersion()
|
|
6618
7138
|
]);
|
|
@@ -6737,11 +7257,21 @@ class StorageHandler {
|
|
|
6737
7257
|
return json3(200, { message: "Successfully moved" });
|
|
6738
7258
|
}
|
|
6739
7259
|
const copyId = crypto.randomUUID();
|
|
7260
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6740
7261
|
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, $
|
|
7262
|
+
const copied = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, owner_id, metadata, version)
|
|
7263
|
+
select $1, $4, $5, $6::uuid, $7, metadata, $8
|
|
6743
7264
|
from storage.objects where bucket_id = $2 and name = $3
|
|
6744
|
-
returning *`, [
|
|
7265
|
+
returning *`, [
|
|
7266
|
+
copyId,
|
|
7267
|
+
body.bucketId,
|
|
7268
|
+
body.sourceKey,
|
|
7269
|
+
dstBucket,
|
|
7270
|
+
body.destinationKey,
|
|
7271
|
+
ownership.legacyOwner,
|
|
7272
|
+
ownership.ownerId,
|
|
7273
|
+
version
|
|
7274
|
+
]));
|
|
6745
7275
|
if (copied.rows.length === 0)
|
|
6746
7276
|
throw new Error("storage copy source disappeared");
|
|
6747
7277
|
} catch (error) {
|
|
@@ -7577,6 +8107,7 @@ class RetentionService {
|
|
|
7577
8107
|
async runSweep() {
|
|
7578
8108
|
const now = this.now();
|
|
7579
8109
|
await this.run(`delete from auth.one_time_tokens where expires_at < now()`);
|
|
8110
|
+
await this.run(`delete from auth.phone_otp_cooldowns where last_sent_at < now() - interval '1 day'`);
|
|
7580
8111
|
await this.run(`delete from auth.mfa_challenges where expires_at < now()`);
|
|
7581
8112
|
await this.run(`delete from auth.flow_state where expires_at < now()`);
|
|
7582
8113
|
await this.run(`delete from public.supacloud_pgredis_kv where expires_at <= now()`);
|
|
@@ -7917,6 +8448,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
7917
8448
|
log(`[mail] to=${msg.to} subject="${msg.subject}"`);
|
|
7918
8449
|
}
|
|
7919
8450
|
};
|
|
8451
|
+
const smsInbox = config.smsSender || exposed ? null : new SmsInbox;
|
|
8452
|
+
const smsSender = config.smsSender ?? smsInbox;
|
|
7920
8453
|
const authSettings = await loadAuthSettings(db, config.authSettings);
|
|
7921
8454
|
const storage = new StorageHandler(db, config.storageDriver ?? new MemoryStorageDriver, {
|
|
7922
8455
|
jwtSecret,
|
|
@@ -7933,6 +8466,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
7933
8466
|
sessionTimeboxSeconds: config.sessionTimeboxSeconds,
|
|
7934
8467
|
sessionInactivitySeconds: config.sessionInactivitySeconds,
|
|
7935
8468
|
mailer,
|
|
8469
|
+
smsSender,
|
|
8470
|
+
log,
|
|
7936
8471
|
oauthProviders: config.oauthProviders,
|
|
7937
8472
|
oauthFetch: config.oauthFetch,
|
|
7938
8473
|
uriAllowList: config.uriAllowList,
|
|
@@ -8021,6 +8556,9 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8021
8556
|
if (inbox && (path === "/inbox" || path.startsWith("/inbox/"))) {
|
|
8022
8557
|
return withCors(inbox.serve(req, url));
|
|
8023
8558
|
}
|
|
8559
|
+
if (smsInbox && (path === "/sms-inbox" || path.startsWith("/sms-inbox/"))) {
|
|
8560
|
+
return withCors(smsInbox.serve(req, url));
|
|
8561
|
+
}
|
|
8024
8562
|
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
8563
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
8026
8564
|
return withCors(await storage.handle(req, { role: "anon", claims: null }, url));
|
|
@@ -8115,6 +8653,7 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8115
8653
|
jwtSecret,
|
|
8116
8654
|
logs,
|
|
8117
8655
|
inbox,
|
|
8656
|
+
smsInbox,
|
|
8118
8657
|
migrate: (migrations, seedSql) => db.runMigrations(migrations, seedSql),
|
|
8119
8658
|
close: () => {
|
|
8120
8659
|
closePromise ??= (async () => {
|
|
@@ -8511,6 +9050,7 @@ function readAuth(root, env) {
|
|
|
8511
9050
|
function readAuthSettings(root) {
|
|
8512
9051
|
const auth = tableAt(root, "auth");
|
|
8513
9052
|
const email = tableAt(root, "auth.email");
|
|
9053
|
+
const sms = tableAt(root, "auth.sms");
|
|
8514
9054
|
const mfa = tableAt(root, "auth.mfa");
|
|
8515
9055
|
const mfaTotp = tableAt(root, "auth.mfa.totp");
|
|
8516
9056
|
const out = {};
|
|
@@ -8535,6 +9075,18 @@ function readAuthSettings(root) {
|
|
|
8535
9075
|
const otpExpiry = getInt(email, "otp_expiry");
|
|
8536
9076
|
if (otpExpiry !== undefined)
|
|
8537
9077
|
out.otpExpirySeconds = otpExpiry;
|
|
9078
|
+
const smsEnabled = getBool(sms, "enabled");
|
|
9079
|
+
if (smsEnabled !== undefined)
|
|
9080
|
+
out.smsEnabled = smsEnabled;
|
|
9081
|
+
const smsSignup = getBool(sms, "enable_signup");
|
|
9082
|
+
if (smsSignup !== undefined)
|
|
9083
|
+
out.smsSignupEnabled = smsSignup;
|
|
9084
|
+
const smsFrequency = getDurationSeconds(sms, "max_frequency");
|
|
9085
|
+
if (smsFrequency !== undefined)
|
|
9086
|
+
out.smsOtpCooldownSeconds = smsFrequency;
|
|
9087
|
+
const smsTemplate = getString(sms, "template");
|
|
9088
|
+
if (smsTemplate !== undefined)
|
|
9089
|
+
out.smsTemplate = smsTemplate;
|
|
8538
9090
|
const maxFactors = getInt(mfa, "max_enrolled_factors");
|
|
8539
9091
|
if (maxFactors !== undefined)
|
|
8540
9092
|
out.maxEnrolledFactors = maxFactors;
|
|
@@ -8566,6 +9118,9 @@ function readRateLimits(root) {
|
|
|
8566
9118
|
out.otp = { limit: email, windowMs: ONE_HOUR };
|
|
8567
9119
|
out.recover = { limit: email, windowMs: ONE_HOUR };
|
|
8568
9120
|
}
|
|
9121
|
+
const sms = getInt(rl, "sms_sent");
|
|
9122
|
+
if (sms !== undefined)
|
|
9123
|
+
out.sms = { limit: sms, windowMs: ONE_HOUR };
|
|
8569
9124
|
return Object.keys(out).length ? out : undefined;
|
|
8570
9125
|
}
|
|
8571
9126
|
function readOAuthProviders(root, env) {
|
|
@@ -9000,6 +9555,7 @@ async function createProjectBackend(options = {}) {
|
|
|
9000
9555
|
sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
|
|
9001
9556
|
sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
|
|
9002
9557
|
oauthProviders: config.auth.oauthProviders,
|
|
9558
|
+
smsSender: options.smsSender,
|
|
9003
9559
|
dbSchemas: config.api.schemas,
|
|
9004
9560
|
maxRows: config.api.maxRows,
|
|
9005
9561
|
storageFileSizeLimit: config.storage.fileSizeLimit,
|