@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/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,
|
|
@@ -2438,14 +2825,15 @@ alter role service_role set search_path to "$user", public, extensions;
|
|
|
2438
2825
|
grant usage on schema public to anon, authenticated, service_role;
|
|
2439
2826
|
grant all on all tables in schema public to anon, authenticated, service_role;
|
|
2440
2827
|
grant all on all sequences in schema public to anon, authenticated, service_role;
|
|
2441
|
-
grant execute on all functions in schema public to anon, authenticated, service_role;
|
|
2442
2828
|
|
|
2443
2829
|
alter default privileges in schema public
|
|
2444
2830
|
grant all on tables to anon, authenticated, service_role;
|
|
2445
2831
|
alter default privileges in schema public
|
|
2446
2832
|
grant all on sequences to anon, authenticated, service_role;
|
|
2447
|
-
|
|
2448
|
-
|
|
2833
|
+
|
|
2834
|
+
-- PostgreSQL already grants EXECUTE on new functions to PUBLIC. Keep that
|
|
2835
|
+
-- default so project migrations can revoke PUBLIC and grant only selected
|
|
2836
|
+
-- roles without hidden direct grants from Lite. Existing ACLs are left intact.
|
|
2449
2837
|
|
|
2450
2838
|
-- \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
|
|
2451
2839
|
create schema if not exists auth;
|
|
@@ -2502,12 +2890,43 @@ create index if not exists refresh_tokens_user_id_idx on auth.refresh_tokens(use
|
|
|
2502
2890
|
create table if not exists auth.one_time_tokens (
|
|
2503
2891
|
id uuid primary key default gen_random_uuid(),
|
|
2504
2892
|
user_id uuid references auth.users(id) on delete cascade,
|
|
2505
|
-
email text
|
|
2506
|
-
|
|
2893
|
+
email text,
|
|
2894
|
+
phone text,
|
|
2895
|
+
token_type text not null, -- otp | magiclink | recovery | sms
|
|
2507
2896
|
token text not null,
|
|
2508
2897
|
attempts int not null default 0,
|
|
2509
2898
|
created_at timestamptz default now(),
|
|
2510
|
-
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()
|
|
2511
2930
|
);
|
|
2512
2931
|
|
|
2513
2932
|
create table if not exists auth.identities (
|
|
@@ -2607,6 +3026,7 @@ create table if not exists storage.buckets (
|
|
|
2607
3026
|
id text primary key,
|
|
2608
3027
|
name text not null unique,
|
|
2609
3028
|
owner uuid,
|
|
3029
|
+
owner_id text,
|
|
2610
3030
|
public boolean default false,
|
|
2611
3031
|
file_size_limit bigint,
|
|
2612
3032
|
allowed_mime_types text[],
|
|
@@ -2614,11 +3034,17 @@ create table if not exists storage.buckets (
|
|
|
2614
3034
|
updated_at timestamptz default now()
|
|
2615
3035
|
);
|
|
2616
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
|
+
|
|
2617
3042
|
create table if not exists storage.objects (
|
|
2618
3043
|
id uuid primary key default gen_random_uuid(),
|
|
2619
3044
|
bucket_id text not null references storage.buckets(id),
|
|
2620
3045
|
name text not null,
|
|
2621
3046
|
owner uuid,
|
|
3047
|
+
owner_id text,
|
|
2622
3048
|
version text,
|
|
2623
3049
|
metadata jsonb default '{}'::jsonb,
|
|
2624
3050
|
created_at timestamptz default now(),
|
|
@@ -2627,6 +3053,14 @@ create table if not exists storage.objects (
|
|
|
2627
3053
|
unique (bucket_id, name)
|
|
2628
3054
|
);
|
|
2629
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
|
+
|
|
2630
3064
|
create index if not exists objects_bucket_name_idx on storage.objects(bucket_id, name);
|
|
2631
3065
|
|
|
2632
3066
|
grant usage on schema storage to anon, authenticated, service_role;
|
|
@@ -3443,6 +3877,25 @@ function isProcessAlive(pid) {
|
|
|
3443
3877
|
var STANDALONE_PGLITE_ASSETS = Symbol.for("supacloud-lite.pglite-standalone-assets");
|
|
3444
3878
|
|
|
3445
3879
|
// src/runtime/db/pglite-engine.ts
|
|
3880
|
+
var INITIALIZE_TIMEZONE_SQL = `
|
|
3881
|
+
do $$
|
|
3882
|
+
declare configured_timezone text;
|
|
3883
|
+
begin
|
|
3884
|
+
select split_part(setting, '=', 2) into configured_timezone
|
|
3885
|
+
from pg_db_role_setting
|
|
3886
|
+
cross join lateral unnest(setconfig) as setting
|
|
3887
|
+
where setdatabase = (select oid from pg_database where datname = current_database())
|
|
3888
|
+
and setrole = 0
|
|
3889
|
+
and lower(split_part(setting, '=', 1)) = 'timezone'
|
|
3890
|
+
limit 1;
|
|
3891
|
+
|
|
3892
|
+
if configured_timezone is null then
|
|
3893
|
+
configured_timezone := 'UTC';
|
|
3894
|
+
execute format('alter database %I set timezone to %L', current_database(), configured_timezone);
|
|
3895
|
+
end if;
|
|
3896
|
+
perform set_config('TimeZone', configured_timezone, false);
|
|
3897
|
+
end $$;
|
|
3898
|
+
`;
|
|
3446
3899
|
async function createPgliteEngine(dataDir) {
|
|
3447
3900
|
const releaseLock = await acquireDataDirLock(dataDir);
|
|
3448
3901
|
let PGlite, extensions;
|
|
@@ -3497,6 +3950,15 @@ async function createPgliteEngine(dataDir) {
|
|
|
3497
3950
|
} : {}
|
|
3498
3951
|
});
|
|
3499
3952
|
await pg.waitReady;
|
|
3953
|
+
try {
|
|
3954
|
+
await pg.exec(INITIALIZE_TIMEZONE_SQL);
|
|
3955
|
+
} catch (error) {
|
|
3956
|
+
const [cleanup] = await Promise.allSettled([pg.close()]);
|
|
3957
|
+
if (cleanup.status === "rejected") {
|
|
3958
|
+
throw new AggregateError([error, cleanup.reason], "PGlite timezone initialization and cleanup failed");
|
|
3959
|
+
}
|
|
3960
|
+
throw error;
|
|
3961
|
+
}
|
|
3500
3962
|
} catch (error) {
|
|
3501
3963
|
await releaseLock();
|
|
3502
3964
|
throw error;
|
|
@@ -6004,6 +6466,12 @@ function invalidObjectKey(key) {
|
|
|
6004
6466
|
return "object key must not contain . or .. segments";
|
|
6005
6467
|
return null;
|
|
6006
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
|
+
}
|
|
6007
6475
|
function storageError(status, error, message) {
|
|
6008
6476
|
return json3(status, { statusCode: String(status), error, message });
|
|
6009
6477
|
}
|
|
@@ -6342,16 +6810,18 @@ class StorageHandler {
|
|
|
6342
6810
|
}
|
|
6343
6811
|
async persistObject(ctx, bucketId, key, bytes, contentType, cacheControl, upsert) {
|
|
6344
6812
|
const metadata = objectMetadata(bytes.length, contentType, cacheControl);
|
|
6813
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6345
6814
|
const previous = (await this.db.query(`select * from storage.objects where bucket_id = $1 and name = $2`, [bucketId, key])).rows[0];
|
|
6346
6815
|
const objectId = previous?.id ?? crypto.randomUUID();
|
|
6347
6816
|
const version = createObjectVersion();
|
|
6348
6817
|
const stagedKey = await this.stageObjectBytes(version, bytes);
|
|
6349
6818
|
const conflictClause = upsert ? `on conflict (bucket_id, name) do update
|
|
6350
|
-
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` : "";
|
|
6351
6821
|
let inserted;
|
|
6352
6822
|
try {
|
|
6353
|
-
const result = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, metadata, version)
|
|
6354
|
-
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]));
|
|
6355
6825
|
inserted = result.rows[0];
|
|
6356
6826
|
} catch (error) {
|
|
6357
6827
|
if (isRlsDenied(error)) {
|
|
@@ -6575,15 +7045,18 @@ class StorageHandler {
|
|
|
6575
7045
|
return bucket.file_size_limit != null ? Number(bucket.file_size_limit) : this.config.defaultFileSizeLimit ?? DEFAULT_FILE_SIZE_LIMIT;
|
|
6576
7046
|
}
|
|
6577
7047
|
async preflightObjectWrite(ctx, bucketId, key, size, contentType, cacheControl, upsert) {
|
|
7048
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6578
7049
|
const conflictClause = upsert ? `on conflict (bucket_id, name) do update
|
|
6579
|
-
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` : "";
|
|
6580
7052
|
try {
|
|
6581
7053
|
await this.db.withContext(ctx, async (query) => {
|
|
6582
|
-
await query(`insert into storage.objects (bucket_id, name, owner, metadata, version)
|
|
6583
|
-
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`, [
|
|
6584
7056
|
bucketId,
|
|
6585
7057
|
key,
|
|
6586
|
-
|
|
7058
|
+
ownership.legacyOwner,
|
|
7059
|
+
ownership.ownerId,
|
|
6587
7060
|
JSON.stringify(objectMetadata(size, contentType ?? "application/octet-stream", cacheControl ?? "no-cache")),
|
|
6588
7061
|
createObjectVersion()
|
|
6589
7062
|
]);
|
|
@@ -6708,11 +7181,21 @@ class StorageHandler {
|
|
|
6708
7181
|
return json3(200, { message: "Successfully moved" });
|
|
6709
7182
|
}
|
|
6710
7183
|
const copyId = crypto.randomUUID();
|
|
7184
|
+
const ownership = storageObjectOwnership(ctx);
|
|
6711
7185
|
try {
|
|
6712
|
-
const copied = await this.db.withContext(ctx, (query) => query(`insert into storage.objects (id, bucket_id, name, owner, metadata, version)
|
|
6713
|
-
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
|
|
6714
7188
|
from storage.objects where bucket_id = $2 and name = $3
|
|
6715
|
-
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
|
+
]));
|
|
6716
7199
|
if (copied.rows.length === 0)
|
|
6717
7200
|
throw new Error("storage copy source disappeared");
|
|
6718
7201
|
} catch (error) {
|
|
@@ -7548,6 +8031,7 @@ class RetentionService {
|
|
|
7548
8031
|
async runSweep() {
|
|
7549
8032
|
const now = this.now();
|
|
7550
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'`);
|
|
7551
8035
|
await this.run(`delete from auth.mfa_challenges where expires_at < now()`);
|
|
7552
8036
|
await this.run(`delete from auth.flow_state where expires_at < now()`);
|
|
7553
8037
|
await this.run(`delete from public.supacloud_pgredis_kv where expires_at <= now()`);
|
|
@@ -7888,6 +8372,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
7888
8372
|
log(`[mail] to=${msg.to} subject="${msg.subject}"`);
|
|
7889
8373
|
}
|
|
7890
8374
|
};
|
|
8375
|
+
const smsInbox = config.smsSender || exposed ? null : new SmsInbox;
|
|
8376
|
+
const smsSender = config.smsSender ?? smsInbox;
|
|
7891
8377
|
const authSettings = await loadAuthSettings(db, config.authSettings);
|
|
7892
8378
|
const storage = new StorageHandler(db, config.storageDriver ?? new MemoryStorageDriver, {
|
|
7893
8379
|
jwtSecret,
|
|
@@ -7904,6 +8390,8 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
7904
8390
|
sessionTimeboxSeconds: config.sessionTimeboxSeconds,
|
|
7905
8391
|
sessionInactivitySeconds: config.sessionInactivitySeconds,
|
|
7906
8392
|
mailer,
|
|
8393
|
+
smsSender,
|
|
8394
|
+
log,
|
|
7907
8395
|
oauthProviders: config.oauthProviders,
|
|
7908
8396
|
oauthFetch: config.oauthFetch,
|
|
7909
8397
|
uriAllowList: config.uriAllowList,
|
|
@@ -7977,6 +8465,9 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
7977
8465
|
async function handle(req) {
|
|
7978
8466
|
const url = new URL(req.url);
|
|
7979
8467
|
const path = url.pathname;
|
|
8468
|
+
if (req.method === "OPTIONS" && (path === "/functions/v1" || path.startsWith("/functions/v1/"))) {
|
|
8469
|
+
return functions.handle(req, { role: "anon", claims: null }, url);
|
|
8470
|
+
}
|
|
7980
8471
|
if (req.method === "OPTIONS") {
|
|
7981
8472
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
7982
8473
|
}
|
|
@@ -7989,6 +8480,9 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
7989
8480
|
if (inbox && (path === "/inbox" || path.startsWith("/inbox/"))) {
|
|
7990
8481
|
return withCors(inbox.serve(req, url));
|
|
7991
8482
|
}
|
|
8483
|
+
if (smsInbox && (path === "/sms-inbox" || path.startsWith("/sms-inbox/"))) {
|
|
8484
|
+
return withCors(smsInbox.serve(req, url));
|
|
8485
|
+
}
|
|
7992
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/")) {
|
|
7993
8487
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
7994
8488
|
return withCors(await storage.handle(req, { role: "anon", claims: null }, url));
|
|
@@ -8083,6 +8577,7 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8083
8577
|
jwtSecret,
|
|
8084
8578
|
logs,
|
|
8085
8579
|
inbox,
|
|
8580
|
+
smsInbox,
|
|
8086
8581
|
migrate: (migrations, seedSql) => db.runMigrations(migrations, seedSql),
|
|
8087
8582
|
close: () => {
|
|
8088
8583
|
closePromise ??= (async () => {
|
|
@@ -8479,6 +8974,7 @@ function readAuth(root, env) {
|
|
|
8479
8974
|
function readAuthSettings(root) {
|
|
8480
8975
|
const auth = tableAt(root, "auth");
|
|
8481
8976
|
const email = tableAt(root, "auth.email");
|
|
8977
|
+
const sms = tableAt(root, "auth.sms");
|
|
8482
8978
|
const mfa = tableAt(root, "auth.mfa");
|
|
8483
8979
|
const mfaTotp = tableAt(root, "auth.mfa.totp");
|
|
8484
8980
|
const out = {};
|
|
@@ -8503,6 +8999,18 @@ function readAuthSettings(root) {
|
|
|
8503
8999
|
const otpExpiry = getInt(email, "otp_expiry");
|
|
8504
9000
|
if (otpExpiry !== undefined)
|
|
8505
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;
|
|
8506
9014
|
const maxFactors = getInt(mfa, "max_enrolled_factors");
|
|
8507
9015
|
if (maxFactors !== undefined)
|
|
8508
9016
|
out.maxEnrolledFactors = maxFactors;
|
|
@@ -8534,6 +9042,9 @@ function readRateLimits(root) {
|
|
|
8534
9042
|
out.otp = { limit: email, windowMs: ONE_HOUR };
|
|
8535
9043
|
out.recover = { limit: email, windowMs: ONE_HOUR };
|
|
8536
9044
|
}
|
|
9045
|
+
const sms = getInt(rl, "sms_sent");
|
|
9046
|
+
if (sms !== undefined)
|
|
9047
|
+
out.sms = { limit: sms, windowMs: ONE_HOUR };
|
|
8537
9048
|
return Object.keys(out).length ? out : undefined;
|
|
8538
9049
|
}
|
|
8539
9050
|
function readOAuthProviders(root, env) {
|
|
@@ -8968,6 +9479,7 @@ async function createProjectBackend(options = {}) {
|
|
|
8968
9479
|
sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
|
|
8969
9480
|
sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
|
|
8970
9481
|
oauthProviders: config.auth.oauthProviders,
|
|
9482
|
+
smsSender: options.smsSender,
|
|
8971
9483
|
dbSchemas: config.api.schemas,
|
|
8972
9484
|
maxRows: config.api.maxRows,
|
|
8973
9485
|
storageFileSizeLimit: config.storage.fileSizeLimit,
|