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