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