@supacloud/lite 0.29.4 → 0.30.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 +25 -0
- package/dist/cli.js +182 -51
- package/dist/index.js +182 -51
- package/dist/project-runtime.d.ts.map +1 -1
- package/dist/runtime/auth/handler.d.ts +1 -0
- package/dist/runtime/auth/handler.d.ts.map +1 -1
- package/dist/runtime/auth/oauth.d.ts +6 -0
- package/dist/runtime/auth/oauth.d.ts.map +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/node/load-config.d.ts +3 -0
- package/dist/runtime/node/load-config.d.ts.map +1 -1
- package/dist/runtime/storage/handler.d.ts +3 -0
- package/dist/runtime/storage/handler.d.ts.map +1 -1
- package/dist/runtime/storage/image-transform.d.ts +7 -1
- package/dist/runtime/storage/image-transform.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/package.json +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.0](https://github.com/vibeunion/supacloud/compare/supacloud-lite-v0.29.5...supacloud-lite-v0.30.0) (2026-10-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add configurable image-transformation input and output limits with a 500 MiB default and maximum ([#2027](https://github.com/vibeunion/supacloud/issues/2027)) ([5c07903](https://github.com/vibeunion/supacloud/commit/5c07903f20bfb069229cf3a829886fe96c78c288))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* enforce account eligibility consistently for bearer, email, OAuth and refresh authentication ([#2027](https://github.com/vibeunion/supacloud/issues/2027)) ([5c07903](https://github.com/vibeunion/supacloud/commit/5c07903f20bfb069229cf3a829886fe96c78c288))
|
|
14
|
+
* prepare isolated package artifacts against the current database dependency ([#2027](https://github.com/vibeunion/supacloud/issues/2027)) ([5c07903](https://github.com/vibeunion/supacloud/commit/5c07903f20bfb069229cf3a829886fe96c78c288))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Dependencies
|
|
18
|
+
|
|
19
|
+
* align the published @supacloud/db dependency to 0.18.1.
|
|
20
|
+
|
|
21
|
+
## [0.29.5](https://github.com/vibeunion/supacloud/compare/supacloud-lite-v0.29.4...supacloud-lite-v0.29.5) (2026-10-16)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **lite:** reject ineligible password logins ([#1998](https://github.com/vibeunion/supacloud/issues/1998)) ([479e967](https://github.com/vibeunion/supacloud/commit/479e96719209f7aed26c44663d78e1442a7097f0))
|
|
27
|
+
|
|
3
28
|
## [0.29.4](https://github.com/vibeunion/supacloud/compare/supacloud-lite-v0.29.3...supacloud-lite-v0.29.4) (2026-10-16)
|
|
4
29
|
|
|
5
30
|
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { dirname as dirname8, join as join10, resolve as resolve6 } from "path";
|
|
|
8
8
|
// package.json
|
|
9
9
|
var package_default = {
|
|
10
10
|
name: "@supacloud/lite",
|
|
11
|
-
version: "0.
|
|
11
|
+
version: "0.30.0",
|
|
12
12
|
description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
|
|
13
13
|
type: "module",
|
|
14
14
|
license: "Apache-2.0",
|
|
@@ -67,16 +67,16 @@ var package_default = {
|
|
|
67
67
|
"@electric-sql/pglite-pgmq": "0.0.8",
|
|
68
68
|
"@electric-sql/pglite-pgvector": "0.0.11",
|
|
69
69
|
"@electric-sql/pglite-postgis": "0.2.10",
|
|
70
|
-
"@supacloud/db": "
|
|
70
|
+
"@supacloud/db": "0.18.1",
|
|
71
71
|
"exact-mirror": "1.2.6",
|
|
72
72
|
tar: "^7.5.22",
|
|
73
73
|
typebox: "1.3.37"
|
|
74
74
|
},
|
|
75
75
|
devDependencies: {
|
|
76
|
-
"@supacloud/app": "
|
|
77
|
-
"@supacloud/contracts": "
|
|
78
|
-
"@supacloud/compiler": "
|
|
79
|
-
"@supacloud/elysia": "
|
|
76
|
+
"@supacloud/app": "0.27.2",
|
|
77
|
+
"@supacloud/contracts": "0.11.2",
|
|
78
|
+
"@supacloud/compiler": "0.46.7",
|
|
79
|
+
"@supacloud/elysia": "0.33.2",
|
|
80
80
|
"@supabase/supabase-js": "^2.117.3",
|
|
81
81
|
"@types/bun": "^1.4.3",
|
|
82
82
|
effect: "4.0.5",
|
|
@@ -85,13 +85,13 @@ var package_default = {
|
|
|
85
85
|
typescript: "^7.0.2"
|
|
86
86
|
},
|
|
87
87
|
overrides: {
|
|
88
|
-
"@supacloud/db": "
|
|
89
|
-
"@supacloud/app": "
|
|
90
|
-
"@supacloud/compiler": "
|
|
91
|
-
"@supacloud/elysia": "
|
|
92
|
-
"@supacloud/contracts": "
|
|
93
|
-
"@supacloud/commands": "
|
|
94
|
-
"@supacloud/delivery": "
|
|
88
|
+
"@supacloud/db": "0.18.1",
|
|
89
|
+
"@supacloud/app": "0.27.2",
|
|
90
|
+
"@supacloud/compiler": "0.46.7",
|
|
91
|
+
"@supacloud/elysia": "0.33.2",
|
|
92
|
+
"@supacloud/contracts": "0.11.2",
|
|
93
|
+
"@supacloud/commands": "0.9.4",
|
|
94
|
+
"@supacloud/delivery": "0.19.1",
|
|
95
95
|
"@graphql-tools/utils": "^12.0.4"
|
|
96
96
|
},
|
|
97
97
|
engines: {
|
|
@@ -242,6 +242,13 @@ function isAllowedRedirect(requested, siteUrl, allowList = []) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// src/runtime/auth/oauth.ts
|
|
245
|
+
class OAuthAccountIneligibleError extends Error {
|
|
246
|
+
code;
|
|
247
|
+
constructor(code) {
|
|
248
|
+
super(code);
|
|
249
|
+
this.code = code;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
245
252
|
var PRESETS = {
|
|
246
253
|
google: {
|
|
247
254
|
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
@@ -364,14 +371,30 @@ class OAuthService {
|
|
|
364
371
|
const msg = encodeURIComponent(e instanceof Error ? e.message : "oauth_error");
|
|
365
372
|
return redirect(`${flow.redirect_to}#error=server_error&error_description=${msg}`);
|
|
366
373
|
}
|
|
367
|
-
|
|
374
|
+
let userId;
|
|
375
|
+
try {
|
|
376
|
+
userId = await this.upsertUser(provider.name, profile);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
if (e instanceof OAuthAccountIneligibleError) {
|
|
379
|
+
return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
|
|
380
|
+
}
|
|
381
|
+
throw e;
|
|
382
|
+
}
|
|
368
383
|
if (flow.code_challenge) {
|
|
369
384
|
const authCode = randomToken(24);
|
|
370
385
|
await this.db.query(`insert into auth.flow_state (provider, provider_state, redirect_to, code_challenge, code_challenge_method, auth_code, user_id, expires_at)
|
|
371
386
|
values ($1, $2, $3, $4, $5, $6, $7, now() + interval '5 minutes')`, [flow.provider, randomToken(16), flow.redirect_to, flow.code_challenge, flow.code_challenge_method, authCode, userId]);
|
|
372
387
|
return redirect(`${flow.redirect_to}?code=${authCode}`);
|
|
373
388
|
}
|
|
374
|
-
|
|
389
|
+
let session;
|
|
390
|
+
try {
|
|
391
|
+
session = await createSession(userId);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
if (e instanceof OAuthAccountIneligibleError) {
|
|
394
|
+
return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
|
|
395
|
+
}
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
375
398
|
const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&provider_token=`;
|
|
376
399
|
return redirect(`${flow.redirect_to}${hash}`);
|
|
377
400
|
}
|
|
@@ -422,31 +445,37 @@ class OAuthService {
|
|
|
422
445
|
return profile;
|
|
423
446
|
}
|
|
424
447
|
async upsertUser(provider, profile) {
|
|
425
|
-
|
|
448
|
+
return this.db.transaction((query) => this.upsertUserInTransaction(query, provider, profile));
|
|
449
|
+
}
|
|
450
|
+
async upsertUserInTransaction(query, provider, profile) {
|
|
451
|
+
const existing = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
|
|
426
452
|
if (existing.rows[0]) {
|
|
427
|
-
await this.
|
|
453
|
+
await this.assertUserEligible(query, existing.rows[0].user_id);
|
|
454
|
+
await query(`update auth.identities set last_sign_in_at = now(), identity_data = $3 where provider = $1 and provider_id = $2`, [
|
|
428
455
|
provider,
|
|
429
456
|
profile.id,
|
|
430
457
|
JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata })
|
|
431
458
|
]);
|
|
432
|
-
await
|
|
459
|
+
await query(`update auth.users set last_sign_in_at = now() where id = $1`, [existing.rows[0].user_id]);
|
|
433
460
|
return existing.rows[0].user_id;
|
|
434
461
|
}
|
|
435
462
|
let userId;
|
|
436
463
|
const email = profile.email?.toLowerCase() ?? null;
|
|
437
464
|
const verified = !!profile.email && profile.emailVerified === true;
|
|
438
465
|
if (email && verified) {
|
|
439
|
-
const byEmail = await
|
|
466
|
+
const byEmail = await query(`select id from auth.users where email = $1`, [email]);
|
|
440
467
|
userId = byEmail.rows[0]?.id;
|
|
468
|
+
if (userId)
|
|
469
|
+
await this.assertUserEligible(query, userId);
|
|
441
470
|
}
|
|
442
471
|
if (!userId) {
|
|
443
472
|
let userEmail = verified ? email : null;
|
|
444
473
|
if (userEmail) {
|
|
445
|
-
const taken = await
|
|
474
|
+
const taken = await query(`select 1 from auth.users where email = $1`, [userEmail]);
|
|
446
475
|
if (taken.rows.length > 0)
|
|
447
476
|
userEmail = null;
|
|
448
477
|
}
|
|
449
|
-
const created = await
|
|
478
|
+
const created = await query(`insert into auth.users (aud, role, email, email_confirmed_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data)
|
|
450
479
|
values ('authenticated','authenticated',$1, $2, now(),
|
|
451
480
|
$3::jsonb, $4::jsonb) returning id`, [
|
|
452
481
|
userEmail,
|
|
@@ -459,12 +488,22 @@ class OAuthService {
|
|
|
459
488
|
throw new Error("OAuth user creation returned no row");
|
|
460
489
|
userId = user.id;
|
|
461
490
|
}
|
|
462
|
-
await
|
|
491
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
463
492
|
values ($1, $2, $3, $4::jsonb)`, [userId, provider, profile.id, JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata })]);
|
|
464
493
|
if (!userId)
|
|
465
494
|
throw new Error("OAuth identity has no user ID");
|
|
466
495
|
return userId;
|
|
467
496
|
}
|
|
497
|
+
async assertUserEligible(query, userId) {
|
|
498
|
+
const users = await query(`select deleted_at is not null as is_deleted,
|
|
499
|
+
coalesce(banned_until > now(), false) as is_banned
|
|
500
|
+
from auth.users where id = $1 for update`, [userId]);
|
|
501
|
+
const user = users.rows[0];
|
|
502
|
+
if (!user || user.is_deleted)
|
|
503
|
+
throw new OAuthAccountIneligibleError("user_not_found");
|
|
504
|
+
if (user.is_banned)
|
|
505
|
+
throw new OAuthAccountIneligibleError("user_banned");
|
|
506
|
+
}
|
|
468
507
|
}
|
|
469
508
|
function redirect(location) {
|
|
470
509
|
return new Response(null, { status: 303, headers: { location } });
|
|
@@ -1350,6 +1389,8 @@ class AuthHandler {
|
|
|
1350
1389
|
return await this.admin(req, ctx, path, method);
|
|
1351
1390
|
return authError(404, "not_found", `unknown auth endpoint: ${path}`);
|
|
1352
1391
|
} catch (e) {
|
|
1392
|
+
if (e instanceof OAuthAccountIneligibleError)
|
|
1393
|
+
return authError(400, e.code, e.message);
|
|
1353
1394
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1354
1395
|
return authError(500, "unexpected_failure", msg);
|
|
1355
1396
|
}
|
|
@@ -1413,8 +1454,12 @@ class AuthHandler {
|
|
|
1413
1454
|
const body = await req.json().catch(() => ({}));
|
|
1414
1455
|
if (grantType === "password") {
|
|
1415
1456
|
const email = (body.email ?? "").toLowerCase().trim();
|
|
1416
|
-
const res = await this.db.query(`select
|
|
1457
|
+
const res = await this.db.query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
1458
|
+
from auth.users where email = $1 and deleted_at is null`, [email]);
|
|
1417
1459
|
const user = res.rows[0];
|
|
1460
|
+
if (user?.encrypted_password && user.is_banned) {
|
|
1461
|
+
return authError(400, "user_banned", "User is banned");
|
|
1462
|
+
}
|
|
1418
1463
|
if (!user || !user.encrypted_password || !await verifyPassword(body.password ?? "", user.encrypted_password)) {
|
|
1419
1464
|
await this.audit("login_failed", { actorEmail: email, traits: { grant_type: "password" } });
|
|
1420
1465
|
return authError(400, "invalid_credentials", "Invalid login credentials");
|
|
@@ -1440,8 +1485,7 @@ class AuthHandler {
|
|
|
1440
1485
|
const userId = await this.oauth.exchangePkce(authCode, verifier);
|
|
1441
1486
|
if (!userId)
|
|
1442
1487
|
return authError(403, "flow_state_not_found", "invalid or expired auth code");
|
|
1443
|
-
|
|
1444
|
-
return json(200, await this.sessionForOAuth(ures.rows[0]));
|
|
1488
|
+
return json(200, await this.sessionForOAuth(userId));
|
|
1445
1489
|
}
|
|
1446
1490
|
return authError(400, "invalid_grant", `unsupported grant_type: ${grantType}`);
|
|
1447
1491
|
}
|
|
@@ -1736,7 +1780,10 @@ Or sign in with this link: ${link}`
|
|
|
1736
1780
|
tokenTypes: `{${types.join(",")}}`,
|
|
1737
1781
|
email: email?.toLowerCase().trim() ?? null
|
|
1738
1782
|
};
|
|
1739
|
-
return this.db.transaction((query) =>
|
|
1783
|
+
return this.db.transaction(async (query) => {
|
|
1784
|
+
const user = await this.claimEmailToken(query, redemption);
|
|
1785
|
+
return user ? this.sessionFor(user, undefined, undefined, query) : null;
|
|
1786
|
+
});
|
|
1740
1787
|
}
|
|
1741
1788
|
async claimEmailToken(query, redemption) {
|
|
1742
1789
|
const candidate = await this.findEmailTokenCandidate(query, redemption);
|
|
@@ -1748,7 +1795,10 @@ Or sign in with this link: ${link}`
|
|
|
1748
1795
|
return claimedToken ? this.confirmEmailTokenUser(query, claimedToken) : null;
|
|
1749
1796
|
}
|
|
1750
1797
|
async lockAndDeleteEmailToken(query, candidate, redemption) {
|
|
1751
|
-
await query(`select id from auth.users where id = $1
|
|
1798
|
+
const eligible = await query(`select id from auth.users where id = $1 and deleted_at is null
|
|
1799
|
+
and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id]);
|
|
1800
|
+
if (eligible.rows.length === 0)
|
|
1801
|
+
return null;
|
|
1752
1802
|
const claimedTokens = await query(`delete from auth.one_time_tokens
|
|
1753
1803
|
where id = $1 and token = $2 and token_type = any($3::text[])
|
|
1754
1804
|
and expires_at > now() and attempts < $4
|
|
@@ -1801,10 +1851,10 @@ Or sign in with this link: ${link}`
|
|
|
1801
1851
|
return json(200, session);
|
|
1802
1852
|
}
|
|
1803
1853
|
const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : ["otp", "magiclink"];
|
|
1804
|
-
const
|
|
1805
|
-
if (!
|
|
1854
|
+
const session = await this.redeem(token, types, body.email);
|
|
1855
|
+
if (!session)
|
|
1806
1856
|
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
1807
|
-
return json(200,
|
|
1857
|
+
return json(200, session);
|
|
1808
1858
|
}
|
|
1809
1859
|
async redeemPhoneOtp(phone, code) {
|
|
1810
1860
|
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
@@ -1845,11 +1895,10 @@ Or sign in with this link: ${link}`
|
|
|
1845
1895
|
const token = url.searchParams.get("token") ?? "";
|
|
1846
1896
|
const type = url.searchParams.get("type") ?? "magiclink";
|
|
1847
1897
|
const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
1848
|
-
const
|
|
1849
|
-
if (!
|
|
1898
|
+
const session = await this.redeem(token, [type]);
|
|
1899
|
+
if (!session) {
|
|
1850
1900
|
return new Response(null, { status: 303, headers: { location: `${redirectTo}#error=access_denied&error_code=otp_expired` } });
|
|
1851
1901
|
}
|
|
1852
|
-
const session = await this.sessionFor(user);
|
|
1853
1902
|
const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&type=${type}`;
|
|
1854
1903
|
return new Response(null, { status: 303, headers: { location: `${redirectTo}${hash}` } });
|
|
1855
1904
|
}
|
|
@@ -2150,6 +2199,11 @@ Or sign in with this link: ${link}`
|
|
|
2150
2199
|
return authError(422, "mfa_verification_failed", "Invalid TOTP code entered");
|
|
2151
2200
|
}
|
|
2152
2201
|
const session = await this.db.transaction(async (query) => {
|
|
2202
|
+
const currentUsers = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2203
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2204
|
+
const currentUser = currentUsers.rows[0];
|
|
2205
|
+
if (!currentUser)
|
|
2206
|
+
return null;
|
|
2153
2207
|
const claimed = await query(`update auth.mfa_challenges set verified_at = now()
|
|
2154
2208
|
where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
|
|
2155
2209
|
returning id`, [challenge.id, factorId]);
|
|
@@ -2158,7 +2212,7 @@ Or sign in with this link: ${link}`
|
|
|
2158
2212
|
if (factor.status !== "verified") {
|
|
2159
2213
|
await query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
|
|
2160
2214
|
}
|
|
2161
|
-
return this.sessionFor(
|
|
2215
|
+
return this.sessionFor(currentUser, undefined, {
|
|
2162
2216
|
aal: "aal2",
|
|
2163
2217
|
amr: [
|
|
2164
2218
|
{ method: "password", timestamp: Math.floor(Date.now() / 1000) },
|
|
@@ -2219,6 +2273,18 @@ Or sign in with this link: ${link}`
|
|
|
2219
2273
|
}
|
|
2220
2274
|
async rotateRefreshToken(token) {
|
|
2221
2275
|
return this.db.transaction(async (query) => {
|
|
2276
|
+
const availableToken = await this.findAvailableRefreshToken(query, token);
|
|
2277
|
+
if (!availableToken)
|
|
2278
|
+
return authError(400, "refresh_token_not_found", "Invalid Refresh Token: Refresh Token Not Found");
|
|
2279
|
+
const userResult = await query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
2280
|
+
from auth.users where id = $1 for update`, [availableToken.user_id]);
|
|
2281
|
+
const user = userResult.rows[0];
|
|
2282
|
+
if (!user || user.deleted_at !== null) {
|
|
2283
|
+
return authError(400, "refresh_token_not_found", "Invalid Refresh Token: Refresh Token Not Found");
|
|
2284
|
+
}
|
|
2285
|
+
if (user.is_banned) {
|
|
2286
|
+
return authError(400, "user_banned", "Invalid Refresh Token: User Banned");
|
|
2287
|
+
}
|
|
2222
2288
|
const claimedToken = await this.claimRefreshToken(query, token);
|
|
2223
2289
|
if (!claimedToken)
|
|
2224
2290
|
return authError(400, "refresh_token_not_found", "Invalid Refresh Token: Refresh Token Not Found");
|
|
@@ -2226,14 +2292,19 @@ Or sign in with this link: ${link}`
|
|
|
2226
2292
|
const expiryError = this.refreshExpiryError(claimedToken, sessionStartedAt);
|
|
2227
2293
|
if (expiryError)
|
|
2228
2294
|
return expiryError;
|
|
2229
|
-
const
|
|
2230
|
-
const session = await this.sessionFor(userResult.rows[0], token, {
|
|
2295
|
+
const session = await this.sessionFor(user, token, {
|
|
2231
2296
|
sessionId: claimedToken.session_id ?? undefined,
|
|
2232
2297
|
sessionStartedAt: sessionStartedAt === null ? undefined : Math.floor(sessionStartedAt / 1000)
|
|
2233
2298
|
}, query);
|
|
2234
2299
|
return json(200, session);
|
|
2235
2300
|
});
|
|
2236
2301
|
}
|
|
2302
|
+
async findAvailableRefreshToken(query, token) {
|
|
2303
|
+
const available = await query(`select user_id, session_id, created_at
|
|
2304
|
+
from auth.refresh_tokens
|
|
2305
|
+
where token = $1 and revoked is not true`, [token]);
|
|
2306
|
+
return available.rows[0];
|
|
2307
|
+
}
|
|
2237
2308
|
async claimRefreshToken(query, token) {
|
|
2238
2309
|
const claimed = await query(`update auth.refresh_tokens set revoked = true, updated_at = now()
|
|
2239
2310
|
where token = $1 and revoked is not true
|
|
@@ -2261,7 +2332,8 @@ Or sign in with this link: ${link}`
|
|
|
2261
2332
|
const claims = await this.claimsFromBearer(req);
|
|
2262
2333
|
if (!claims?.sub)
|
|
2263
2334
|
return null;
|
|
2264
|
-
const res = await this.db.query(`select * from auth.users where id = $1
|
|
2335
|
+
const res = await this.db.query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2336
|
+
and (banned_until is null or banned_until <= now())`, [claims.sub]);
|
|
2265
2337
|
return res.rows[0] ?? null;
|
|
2266
2338
|
}
|
|
2267
2339
|
async claimsFromBearer(req) {
|
|
@@ -2290,14 +2362,22 @@ Or sign in with this link: ${link}`
|
|
|
2290
2362
|
is_anonymous: u.is_anonymous ?? false
|
|
2291
2363
|
};
|
|
2292
2364
|
}
|
|
2293
|
-
sessionForOAuth(
|
|
2294
|
-
return this.
|
|
2295
|
-
|
|
2365
|
+
sessionForOAuth(userId) {
|
|
2366
|
+
return this.db.transaction(async (query) => {
|
|
2367
|
+
const res = await query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
2368
|
+
from auth.users where id = $1 for update`, [userId]);
|
|
2369
|
+
const user = res.rows[0];
|
|
2370
|
+
if (!user || user.deleted_at !== null)
|
|
2371
|
+
throw new OAuthAccountIneligibleError("user_not_found");
|
|
2372
|
+
if (user.is_banned)
|
|
2373
|
+
throw new OAuthAccountIneligibleError("user_banned");
|
|
2374
|
+
return this.sessionFor(user, undefined, {
|
|
2375
|
+
amr: [{ method: "oauth", timestamp: Math.floor(Date.now() / 1000) }]
|
|
2376
|
+
}, query);
|
|
2296
2377
|
});
|
|
2297
2378
|
}
|
|
2298
2379
|
async oauthSessionTokensFor(userId) {
|
|
2299
|
-
const
|
|
2300
|
-
const session = await this.sessionForOAuth(res.rows[0]);
|
|
2380
|
+
const session = await this.sessionForOAuth(userId);
|
|
2301
2381
|
return { access_token: session.access_token, refresh_token: session.refresh_token, expires_in: session.expires_in };
|
|
2302
2382
|
}
|
|
2303
2383
|
async sessionFor(user, parentToken, opts, query = (sql, params) => this.db.query(sql, params)) {
|
|
@@ -8974,8 +9054,18 @@ var IMAGE_FORMAT_CONTENT_TYPES = {
|
|
|
8974
9054
|
gif: "image/gif"
|
|
8975
9055
|
};
|
|
8976
9056
|
var MAX_TRANSFORM_DIMENSION = 2500;
|
|
8977
|
-
var MAX_TRANSFORM_BYTES =
|
|
9057
|
+
var MAX_TRANSFORM_BYTES = 500 * 1024 * 1024;
|
|
8978
9058
|
var MAX_TRANSFORM_PIXELS = 50000000;
|
|
9059
|
+
function resolveImageTransformLimits(options = {}) {
|
|
9060
|
+
const maxInputBytes = options.maxInputBytes === undefined ? MAX_TRANSFORM_BYTES : options.maxInputBytes;
|
|
9061
|
+
const maxOutputBytes = options.maxOutputBytes === undefined ? MAX_TRANSFORM_BYTES : options.maxOutputBytes;
|
|
9062
|
+
for (const [name, value] of Object.entries({ maxInputBytes, maxOutputBytes })) {
|
|
9063
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_TRANSFORM_BYTES) {
|
|
9064
|
+
throw new Error(`Invalid image transformation ${name}: expected 1..${MAX_TRANSFORM_BYTES} bytes`);
|
|
9065
|
+
}
|
|
9066
|
+
}
|
|
9067
|
+
return { maxInputBytes, maxOutputBytes };
|
|
9068
|
+
}
|
|
8979
9069
|
function invalid(message) {
|
|
8980
9070
|
return { ok: false, status: 400, error: "InvalidTransformation", message };
|
|
8981
9071
|
}
|
|
@@ -9033,7 +9123,16 @@ function parseImageTransform(query) {
|
|
|
9033
9123
|
if (quality !== undefined && format === "png") {
|
|
9034
9124
|
return invalid("quality is supported only for jpeg and webp output");
|
|
9035
9125
|
}
|
|
9036
|
-
return {
|
|
9126
|
+
return {
|
|
9127
|
+
ok: true,
|
|
9128
|
+
value: {
|
|
9129
|
+
resize,
|
|
9130
|
+
format,
|
|
9131
|
+
...width === undefined ? {} : { width },
|
|
9132
|
+
...height === undefined ? {} : { height },
|
|
9133
|
+
...quality === undefined ? {} : { quality }
|
|
9134
|
+
}
|
|
9135
|
+
};
|
|
9037
9136
|
}
|
|
9038
9137
|
function imageTransformToSearchParams(options) {
|
|
9039
9138
|
const query = new URLSearchParams;
|
|
@@ -9107,15 +9206,16 @@ function applyResize(image, metadata, options) {
|
|
|
9107
9206
|
const proportionalWidth = Math.max(1, Math.round(metadata.width * height / metadata.height));
|
|
9108
9207
|
image.resize(proportionalWidth);
|
|
9109
9208
|
}
|
|
9110
|
-
async function transformImage(source, options, knownSourceSize) {
|
|
9209
|
+
async function transformImage(source, options, knownSourceSize, configuredLimits = {}) {
|
|
9210
|
+
const limits = resolveImageTransformLimits(configuredLimits);
|
|
9111
9211
|
const actualSourceSize = source instanceof Uint8Array ? source.byteLength : await Promise.resolve(source.size);
|
|
9112
9212
|
const sourceSize = Math.max(knownSourceSize ?? 0, actualSourceSize);
|
|
9113
|
-
if (sourceSize >
|
|
9213
|
+
if (sourceSize > limits.maxInputBytes) {
|
|
9114
9214
|
return {
|
|
9115
9215
|
ok: false,
|
|
9116
9216
|
status: 413,
|
|
9117
9217
|
error: "ImageTooLarge",
|
|
9118
|
-
message:
|
|
9218
|
+
message: `The source image exceeds the transformation limit (${limits.maxInputBytes} bytes)`
|
|
9119
9219
|
};
|
|
9120
9220
|
}
|
|
9121
9221
|
const image = new Bun.Image(source, { maxPixels: MAX_TRANSFORM_PIXELS });
|
|
@@ -9148,12 +9248,12 @@ async function transformImage(source, options, knownSourceSize) {
|
|
|
9148
9248
|
}
|
|
9149
9249
|
try {
|
|
9150
9250
|
const bytes = await image.bytes();
|
|
9151
|
-
if (bytes.byteLength >
|
|
9251
|
+
if (bytes.byteLength > limits.maxOutputBytes) {
|
|
9152
9252
|
return {
|
|
9153
9253
|
ok: false,
|
|
9154
9254
|
status: 413,
|
|
9155
9255
|
error: "ImageTooLarge",
|
|
9156
|
-
message:
|
|
9256
|
+
message: `The transformed image exceeds the transformation limit (${limits.maxOutputBytes} bytes)`
|
|
9157
9257
|
};
|
|
9158
9258
|
}
|
|
9159
9259
|
return { ok: true, bytes, contentType };
|
|
@@ -9356,10 +9456,12 @@ class StorageHandler {
|
|
|
9356
9456
|
tusUploads = new Map;
|
|
9357
9457
|
mutationTail = Promise.resolve();
|
|
9358
9458
|
imageTransforms = new ImageTransformCache;
|
|
9459
|
+
transformLimits;
|
|
9359
9460
|
constructor(db, driver, config) {
|
|
9360
9461
|
this.db = db;
|
|
9361
9462
|
this.driver = driver;
|
|
9362
9463
|
this.config = config;
|
|
9464
|
+
this.transformLimits = resolveImageTransformLimits(config.imageTransformLimits);
|
|
9363
9465
|
}
|
|
9364
9466
|
async ensureBuckets(buckets) {
|
|
9365
9467
|
for (const b of buckets) {
|
|
@@ -9635,7 +9737,7 @@ class StorageHandler {
|
|
|
9635
9737
|
const source = await this.readObjectSource(row);
|
|
9636
9738
|
if (source === null)
|
|
9637
9739
|
throw new StorageObjectMissingError;
|
|
9638
|
-
return transformImage(source, parsed.value, objectSize(row));
|
|
9740
|
+
return transformImage(source, parsed.value, objectSize(row), this.transformLimits);
|
|
9639
9741
|
});
|
|
9640
9742
|
} catch (error) {
|
|
9641
9743
|
if (error instanceof StorageObjectMissingError) {
|
|
@@ -11731,6 +11833,7 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
11731
11833
|
const storage = new StorageHandler(db, config.storageDriver ?? new MemoryStorageDriver, {
|
|
11732
11834
|
jwtSecret,
|
|
11733
11835
|
defaultFileSizeLimit: config.storageFileSizeLimit,
|
|
11836
|
+
imageTransformLimits: config.storageImageTransformLimits,
|
|
11734
11837
|
log
|
|
11735
11838
|
});
|
|
11736
11839
|
if (config.buckets?.length) {
|
|
@@ -13270,8 +13373,22 @@ import { readFile as readFile2, stat } from "fs/promises";
|
|
|
13270
13373
|
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
13271
13374
|
import { pathToFileURL } from "url";
|
|
13272
13375
|
|
|
13273
|
-
//
|
|
13376
|
+
// ../db/dist/index.js
|
|
13274
13377
|
import { createHash as createHash3 } from "crypto";
|
|
13378
|
+
|
|
13379
|
+
// ../contracts/dist/index.mjs
|
|
13380
|
+
var commandErrorBrand = Symbol.for("@supacloud/contracts/CommandError/v1");
|
|
13381
|
+
var commandErrorCodes = new Set([
|
|
13382
|
+
"COMMAND_INPUT_INVALID",
|
|
13383
|
+
"COMMAND_REJECTED",
|
|
13384
|
+
"COMMAND_UNAVAILABLE",
|
|
13385
|
+
"COMMAND_IDEMPOTENCY_CONFLICT",
|
|
13386
|
+
"COMMAND_RECEIPT_INVALID",
|
|
13387
|
+
"COMMAND_OUTCOME_UNKNOWN",
|
|
13388
|
+
"COMMAND_INPUT_EXPIRED"
|
|
13389
|
+
]);
|
|
13390
|
+
|
|
13391
|
+
// ../db/dist/index.js
|
|
13275
13392
|
var TABLES_SQL = `
|
|
13276
13393
|
SELECT n.nspname AS schema,
|
|
13277
13394
|
c.relname AS name,
|
|
@@ -14680,6 +14797,19 @@ function readStorage(root) {
|
|
|
14680
14797
|
const limit = getByteSize(storage, "file_size_limit");
|
|
14681
14798
|
if (limit !== undefined)
|
|
14682
14799
|
out.fileSizeLimit = limit;
|
|
14800
|
+
const image = tableAt(root, "storage.image_transformation");
|
|
14801
|
+
if (image) {
|
|
14802
|
+
const limits = {};
|
|
14803
|
+
for (const [key, field] of [["max_input_bytes", "maxInputBytes"], ["max_output_bytes", "maxOutputBytes"]]) {
|
|
14804
|
+
if (!image.values.has(key))
|
|
14805
|
+
continue;
|
|
14806
|
+
const value = getByteSize(image, key);
|
|
14807
|
+
if (value === undefined)
|
|
14808
|
+
throw new Error(`Invalid image transformation ${key}`);
|
|
14809
|
+
limits[field] = value;
|
|
14810
|
+
}
|
|
14811
|
+
out.imageTransformLimits = resolveImageTransformLimits(limits);
|
|
14812
|
+
}
|
|
14683
14813
|
const buckets = tableAt(root, "storage.buckets");
|
|
14684
14814
|
if (buckets && buckets.children.size > 0) {
|
|
14685
14815
|
out.buckets = [...buckets.children].map(([id, t]) => ({
|
|
@@ -15517,6 +15647,7 @@ async function createProjectBackend(options = {}) {
|
|
|
15517
15647
|
dbSchemas: config.api.schemas,
|
|
15518
15648
|
maxRows: config.api.maxRows,
|
|
15519
15649
|
storageFileSizeLimit: config.storage.fileSizeLimit,
|
|
15650
|
+
storageImageTransformLimits: config.storage.imageTransformLimits,
|
|
15520
15651
|
buckets: config.storage.buckets,
|
|
15521
15652
|
migrations: options.applyMigrations === false ? [] : project.migrations,
|
|
15522
15653
|
seedSql: options.applyMigrations === false || options.includeSeed === false ? undefined : project.seedSql,
|