@edge-base/server 0.2.9 → 0.3.2
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/admin-build/_app/immutable/chunks/BENT96gS.js +1 -0
- package/admin-build/_app/immutable/chunks/{BfhqI0W8.js → BR-X1vqg.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BYTXdmFz.js} +1 -1
- package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → C-_7bS5w.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → CCB6XzFZ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{39YMyjjq.js → CWTeyOdB.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DllVADtt.js → CX5CmbTG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{0M5txo3l.js → CdgSuiqt.js} +3 -3
- package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Cuny2nPU.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → D15DP4DQ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DBkor_jM.js → DAzRCtch.js} +1 -1
- package/admin-build/_app/immutable/chunks/DYF4byEY.js +1 -0
- package/admin-build/_app/immutable/chunks/{DBniE7GN.js → Dcey5WNo.js} +1 -1
- package/admin-build/_app/immutable/chunks/{C5nMidnK.js → HK54RDdv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → gUtOgqgM.js} +1 -1
- package/admin-build/_app/immutable/chunks/{_xa30BOD.js → pnqPwnFK.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.js → app.DwslmBQo.js} +2 -2
- package/admin-build/_app/immutable/entry/start.Bi51W3Uj.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.CRSQPtos.js → 0.SSaZIC3t.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.CqSv9lq5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.BoQz_717.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.B-yS_gft.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.WsVtLoVx.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.TbqRQJKh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.cldePwj3.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.Bec037-f.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.Dd0yTYhN.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.CoiqH0lF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.BDXxM1Qi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.lo9mTPiR.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.JQer3KPM.js → 20.B7kbGqh_.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.BGm1TcfO.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.DUw7X6z_.js → 22.VOtCuTkf.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.C2Uxx-kO.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.BxdEwsS3.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.CVu2qoCv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.DqYlpX7Z.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.emgbpP8y.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.BVPSPK68.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.BsDQQ16z.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.uc8joqGb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.D4CYqQrE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.DvUuXrVU.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.BqTZ0Uoq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.DAO3--6W.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.CBbGl_Wo.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.s-I8cKAh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.Ct6B-y3f.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.js → 9.Dok_ZoeZ.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +498 -3
- package/package.json +3 -3
- package/src/__tests__/auth-token.test.ts +142 -0
- package/src/__tests__/email-provider.test.ts +82 -0
- package/src/__tests__/error-format.test.ts +21 -0
- package/src/__tests__/functions-context.test.ts +87 -0
- package/src/__tests__/functions-d1-proxy.test.ts +70 -0
- package/src/__tests__/functions-route.test.ts +15 -0
- package/src/__tests__/postgres-api-consistency.test.ts +240 -0
- package/src/__tests__/postgres-batch-compat.test.ts +33 -27
- package/src/__tests__/postgres-batch.test.ts +394 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
- package/src/__tests__/postgres-transact.test.ts +148 -0
- package/src/__tests__/room-access-policy.test.ts +26 -0
- package/src/__tests__/room-handler-context.test.ts +2 -0
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/database-do.ts +455 -15
- package/src/durable-objects/room-runtime-base.ts +16 -5
- package/src/durable-objects/rooms-do.ts +4 -1
- package/src/lib/auth-d1-service.ts +37 -7
- package/src/lib/auth-token.ts +66 -0
- package/src/lib/d1-handler.ts +416 -50
- package/src/lib/email-provider.ts +115 -5
- package/src/lib/errors.ts +8 -4
- package/src/lib/functions.ts +91 -1
- package/src/lib/internal-transport.ts +25 -1
- package/src/lib/jwt.ts +2 -0
- package/src/lib/postgres-handler.ts +562 -84
- package/src/routes/admin.ts +8 -1
- package/src/routes/auth.ts +169 -65
- package/src/routes/room.ts +36 -2
- package/src/routes/storage.ts +446 -62
- package/src/routes/tables.ts +140 -0
- package/src/types.ts +10 -0
- package/admin-build/_app/immutable/chunks/B0zJ5_Wk.js +0 -1
- package/admin-build/_app/immutable/chunks/DZo4s3wn.js +0 -1
- package/admin-build/_app/immutable/entry/start.Bcf0SjPV.js +0 -1
- package/admin-build/_app/immutable/nodes/21.CzGYxjpu.js +0 -1
package/src/routes/admin.ts
CHANGED
|
@@ -1539,7 +1539,14 @@ api.openapi(adminCreateSignedUrl, async (c) => {
|
|
|
1539
1539
|
|
|
1540
1540
|
const expiresInMs = parseDuration(body.expiresIn || '1h');
|
|
1541
1541
|
const expiresAt = Date.now() + expiresInMs;
|
|
1542
|
-
const token = await createSignedToken(body.key, bucketName, expiresAt, secret
|
|
1542
|
+
const token = await createSignedToken(body.key, bucketName, expiresAt, secret, {
|
|
1543
|
+
file: {
|
|
1544
|
+
size: obj.size,
|
|
1545
|
+
contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
|
|
1546
|
+
etag: obj.etag,
|
|
1547
|
+
uploadedAt: obj.uploaded?.toISOString(),
|
|
1548
|
+
},
|
|
1549
|
+
});
|
|
1543
1550
|
|
|
1544
1551
|
// Build signed URL using the public storage endpoint
|
|
1545
1552
|
const url = new URL(c.req.url);
|
package/src/routes/auth.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
buildEmailActionUrl,
|
|
28
28
|
parseClientRedirectInput,
|
|
29
29
|
} from '../lib/auth-redirect.js';
|
|
30
|
+
import { hashOtpSecret, verifyOtpSecret } from '../lib/auth-token.js';
|
|
30
31
|
import {
|
|
31
32
|
signAccessToken, signRefreshToken, verifyRefreshTokenWithFallback,
|
|
32
33
|
parseDuration, TokenExpiredError,
|
|
@@ -176,7 +177,7 @@ function getMaxActiveSessions(env: Env): number {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
function isEmailProviderName(value: unknown): value is EmailConfig['provider'] {
|
|
179
|
-
return value === 'resend' || value === 'sendgrid' || value === 'mailgun' || value === 'ses';
|
|
180
|
+
return value === 'resend' || value === 'sendgrid' || value === 'mailgun' || value === 'ses' || value === 'cloudflare';
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
function getEmailConfig(env: Env): EmailConfig | undefined {
|
|
@@ -187,22 +188,29 @@ function getEmailConfig(env: Env): EmailConfig | undefined {
|
|
|
187
188
|
const provider = configured?.provider
|
|
188
189
|
?? (isEmailProviderName(runtimeEnv.EDGEBASE_EMAIL_PROVIDER) ? runtimeEnv.EDGEBASE_EMAIL_PROVIDER : undefined);
|
|
189
190
|
const apiKey = configured?.apiKey
|
|
190
|
-
?? (typeof runtimeEnv.EDGEBASE_EMAIL_API_KEY === 'string' ? runtimeEnv.EDGEBASE_EMAIL_API_KEY : undefined)
|
|
191
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_API_KEY === 'string' ? runtimeEnv.EDGEBASE_EMAIL_API_KEY : undefined)
|
|
192
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_API_TOKEN === 'string' ? runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_API_TOKEN : undefined);
|
|
191
193
|
const from = configured?.from
|
|
192
194
|
?? (typeof runtimeEnv.EDGEBASE_EMAIL_FROM === 'string' ? runtimeEnv.EDGEBASE_EMAIL_FROM : undefined);
|
|
195
|
+
const cloudflareAccountId = configured?.accountId
|
|
196
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_ACCOUNT_ID === 'string' ? runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_ACCOUNT_ID : undefined);
|
|
197
|
+
const cloudflareBinding = configured?.binding
|
|
198
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_BINDING === 'string' ? runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_BINDING : undefined);
|
|
193
199
|
|
|
194
|
-
if (!provider || !
|
|
200
|
+
if (!provider || !from || (provider !== 'cloudflare' && !apiKey)) {
|
|
195
201
|
return configured;
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
return {
|
|
199
205
|
provider,
|
|
200
|
-
apiKey,
|
|
201
206
|
from,
|
|
207
|
+
...(apiKey ? { apiKey } : {}),
|
|
202
208
|
domain: configured?.domain
|
|
203
209
|
?? (typeof runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN === 'string' ? runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN : undefined),
|
|
204
210
|
region: configured?.region
|
|
205
211
|
?? (typeof runtimeEnv.EDGEBASE_EMAIL_SES_REGION === 'string' ? runtimeEnv.EDGEBASE_EMAIL_SES_REGION : undefined),
|
|
212
|
+
accountId: cloudflareAccountId,
|
|
213
|
+
binding: cloudflareBinding,
|
|
206
214
|
appName: configured?.appName ?? 'EdgeBase Local Auth Harness',
|
|
207
215
|
defaultLocale: configured?.defaultLocale,
|
|
208
216
|
verifyUrl: configured?.verifyUrl
|
|
@@ -539,7 +547,7 @@ async function rotateRefreshTokenFlow(
|
|
|
539
547
|
}
|
|
540
548
|
|
|
541
549
|
const newRefreshToken = await signRefreshToken(
|
|
542
|
-
{ sub: userId, type: 'refresh', jti:
|
|
550
|
+
{ sub: userId, type: 'refresh', jti: session.id as string },
|
|
543
551
|
getUserSecret(env),
|
|
544
552
|
getRefreshTokenTTL(env),
|
|
545
553
|
);
|
|
@@ -781,7 +789,11 @@ async function sendMailWithHook(
|
|
|
781
789
|
}
|
|
782
790
|
}
|
|
783
791
|
|
|
784
|
-
|
|
792
|
+
const result = await provider.send({ to, subject: finalSubject, html: finalHtml });
|
|
793
|
+
if (!result.success) {
|
|
794
|
+
throw new EdgeBaseError(502, 'Email delivery failed.', undefined, 'email-delivery-failed');
|
|
795
|
+
}
|
|
796
|
+
return result;
|
|
785
797
|
}
|
|
786
798
|
|
|
787
799
|
async function sendSmsWithHook(
|
|
@@ -987,15 +999,8 @@ authRoute.openapi(signup, async (c) => {
|
|
|
987
999
|
|
|
988
1000
|
const userId = generateId();
|
|
989
1001
|
const db = getAuthDb(c);
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
try {
|
|
993
|
-
await registerEmailPending(db, body.email, userId);
|
|
994
|
-
} catch (err) {
|
|
995
|
-
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
996
|
-
throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
|
|
997
|
-
}
|
|
998
|
-
throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
|
|
1002
|
+
if (await lookupEmail(db, body.email)) {
|
|
1003
|
+
throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
|
|
999
1004
|
}
|
|
1000
1005
|
|
|
1001
1006
|
// Create user directly in D1
|
|
@@ -1003,10 +1008,27 @@ authRoute.openapi(signup, async (c) => {
|
|
|
1003
1008
|
// Password policy validation
|
|
1004
1009
|
const policyResult = await validatePassword(body.password, getPasswordPolicyConfig(c.env));
|
|
1005
1010
|
if (!policyResult.valid) {
|
|
1006
|
-
await deleteEmailPending(db, body.email).catch(() => {});
|
|
1007
1011
|
throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
|
|
1008
1012
|
}
|
|
1009
1013
|
|
|
1014
|
+
// beforeSignUp hook — blocking, can cancel signup
|
|
1015
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
1016
|
+
id: userId,
|
|
1017
|
+
email: body.email,
|
|
1018
|
+
displayName,
|
|
1019
|
+
avatarUrl,
|
|
1020
|
+
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1021
|
+
|
|
1022
|
+
// Register pending in D1 email index
|
|
1023
|
+
try {
|
|
1024
|
+
await registerEmailPending(db, body.email, userId);
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
1027
|
+
throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
|
|
1028
|
+
}
|
|
1029
|
+
throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1010
1032
|
const passwordHash = await hashPassword(body.password);
|
|
1011
1033
|
|
|
1012
1034
|
await authService.createUser(db, {
|
|
@@ -1021,14 +1043,6 @@ authRoute.openapi(signup, async (c) => {
|
|
|
1021
1043
|
locale,
|
|
1022
1044
|
});
|
|
1023
1045
|
|
|
1024
|
-
// beforeSignUp hook — blocking, can cancel signup
|
|
1025
|
-
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
1026
|
-
id: userId,
|
|
1027
|
-
email: body.email,
|
|
1028
|
-
displayName,
|
|
1029
|
-
avatarUrl,
|
|
1030
|
-
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1031
|
-
|
|
1032
1046
|
// Create session + tokens
|
|
1033
1047
|
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
1034
1048
|
|
|
@@ -1370,19 +1384,24 @@ authRoute.openapi(signinMagicLink, async (c) => {
|
|
|
1370
1384
|
} else if (autoCreate) {
|
|
1371
1385
|
// Auto-create user + send magic link
|
|
1372
1386
|
const userId = generateId();
|
|
1387
|
+
const reqLocale = parseAcceptLanguage(c.req.header('accept-language'));
|
|
1373
1388
|
|
|
1374
1389
|
try {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1390
|
+
// beforeSignUp hook
|
|
1391
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
1392
|
+
id: userId, email: body.email, displayName: null, avatarUrl: null,
|
|
1393
|
+
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1394
|
+
|
|
1395
|
+
try {
|
|
1396
|
+
await registerEmailPending(db, body.email, userId);
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
1399
|
+
return c.json({ ok: true });
|
|
1400
|
+
}
|
|
1401
|
+
throw new EdgeBaseError(500, 'Magic link request failed.', undefined, 'internal-error');
|
|
1379
1402
|
}
|
|
1380
|
-
throw new EdgeBaseError(500, 'Magic link request failed.', undefined, 'internal-error');
|
|
1381
|
-
}
|
|
1382
1403
|
|
|
1383
|
-
try {
|
|
1384
1404
|
// Create user with no password, verified = 1
|
|
1385
|
-
const reqLocale = parseAcceptLanguage(c.req.header('accept-language'));
|
|
1386
1405
|
await authService.createUser(db, {
|
|
1387
1406
|
userId,
|
|
1388
1407
|
email: body.email,
|
|
@@ -1393,11 +1412,6 @@ authRoute.openapi(signinMagicLink, async (c) => {
|
|
|
1393
1412
|
locale: reqLocale ?? 'en',
|
|
1394
1413
|
});
|
|
1395
1414
|
|
|
1396
|
-
// beforeSignUp hook
|
|
1397
|
-
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
1398
|
-
id: userId, email: body.email, displayName: null, avatarUrl: null,
|
|
1399
|
-
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1400
|
-
|
|
1401
1415
|
// Sync to _users_public
|
|
1402
1416
|
const user = await authService.getUserById(db, userId);
|
|
1403
1417
|
if (user) {
|
|
@@ -1452,7 +1466,7 @@ authRoute.openapi(signinMagicLink, async (c) => {
|
|
|
1452
1466
|
await sendMailWithHook(
|
|
1453
1467
|
c.env, c.executionCtx, provider, 'magicLink', body.email,
|
|
1454
1468
|
resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
|
|
1455
|
-
)
|
|
1469
|
+
);
|
|
1456
1470
|
} else {
|
|
1457
1471
|
if (!isReleaseMode(c.env)) {
|
|
1458
1472
|
debugToken = token;
|
|
@@ -1627,10 +1641,12 @@ authRoute.openapi(signinPhone, async (c) => {
|
|
|
1627
1641
|
const code = generateOTP();
|
|
1628
1642
|
if (exposeTestSecrets) devCode = code;
|
|
1629
1643
|
|
|
1644
|
+
const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
|
|
1645
|
+
|
|
1630
1646
|
// Store OTP in KV with 5 min TTL
|
|
1631
1647
|
await c.env.KV.put(
|
|
1632
1648
|
`phone-otp:${phone}`,
|
|
1633
|
-
JSON.stringify({
|
|
1649
|
+
JSON.stringify({ codeHash, userId, attempts: 0 }),
|
|
1634
1650
|
{ expirationTtl: 300 },
|
|
1635
1651
|
);
|
|
1636
1652
|
|
|
@@ -1657,15 +1673,20 @@ authRoute.openapi(signinPhone, async (c) => {
|
|
|
1657
1673
|
const userId = generateId();
|
|
1658
1674
|
|
|
1659
1675
|
try {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1676
|
+
// beforeSignUp hook
|
|
1677
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
1678
|
+
id: userId, email: null, phone, displayName: null, avatarUrl: null,
|
|
1679
|
+
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1680
|
+
|
|
1681
|
+
try {
|
|
1682
|
+
await registerPhonePending(db, phone, userId);
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
if ((err as Error).message === 'PHONE_ALREADY_REGISTERED') {
|
|
1685
|
+
return c.json({ ok: true });
|
|
1686
|
+
}
|
|
1687
|
+
throw new EdgeBaseError(500, 'Phone OTP request failed.', undefined, 'internal-error');
|
|
1664
1688
|
}
|
|
1665
|
-
throw new EdgeBaseError(500, 'Phone OTP request failed.', undefined, 'internal-error');
|
|
1666
|
-
}
|
|
1667
1689
|
|
|
1668
|
-
try {
|
|
1669
1690
|
// Create user with phone in D1
|
|
1670
1691
|
await authService.createUser(db, {
|
|
1671
1692
|
userId,
|
|
@@ -1679,10 +1700,12 @@ authRoute.openapi(signinPhone, async (c) => {
|
|
|
1679
1700
|
const code = generateOTP();
|
|
1680
1701
|
if (exposeTestSecrets) devCode = code;
|
|
1681
1702
|
|
|
1703
|
+
const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
|
|
1704
|
+
|
|
1682
1705
|
// Store OTP in KV
|
|
1683
1706
|
await c.env.KV.put(
|
|
1684
1707
|
`phone-otp:${phone}`,
|
|
1685
|
-
JSON.stringify({
|
|
1708
|
+
JSON.stringify({ codeHash, userId, attempts: 0 }),
|
|
1686
1709
|
{ expirationTtl: 300 },
|
|
1687
1710
|
);
|
|
1688
1711
|
|
|
@@ -1756,7 +1779,7 @@ authRoute.openapi(verifyPhone, async (c) => {
|
|
|
1756
1779
|
|
|
1757
1780
|
// Look up phone → userId via KV OTP data
|
|
1758
1781
|
const otpData = await c.env.KV.get(`phone-otp:${phone}`, 'json') as {
|
|
1759
|
-
code
|
|
1782
|
+
codeHash?: string; code?: string; userId: string; attempts: number;
|
|
1760
1783
|
} | null;
|
|
1761
1784
|
|
|
1762
1785
|
if (!otpData) {
|
|
@@ -1769,8 +1792,11 @@ authRoute.openapi(verifyPhone, async (c) => {
|
|
|
1769
1792
|
throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
|
|
1770
1793
|
}
|
|
1771
1794
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1795
|
+
const submittedCode = body.code.trim();
|
|
1796
|
+
const otpMatches = otpData.codeHash
|
|
1797
|
+
? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
|
|
1798
|
+
: timingSafeEqual(otpData.code ?? '', submittedCode);
|
|
1799
|
+
if (!otpMatches) {
|
|
1774
1800
|
await c.env.KV.put(
|
|
1775
1801
|
`phone-otp:${phone}`,
|
|
1776
1802
|
JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
|
|
@@ -1895,10 +1921,12 @@ authRoute.openapi(linkPhone, async (c) => {
|
|
|
1895
1921
|
const code = generateOTP();
|
|
1896
1922
|
const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
|
|
1897
1923
|
|
|
1924
|
+
const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
|
|
1925
|
+
|
|
1898
1926
|
// Store link OTP in KV (separate key pattern)
|
|
1899
1927
|
await c.env.KV.put(
|
|
1900
1928
|
`phone-link-otp:${phone}`,
|
|
1901
|
-
JSON.stringify({
|
|
1929
|
+
JSON.stringify({ codeHash, userId, attempts: 0 }),
|
|
1902
1930
|
{ expirationTtl: 300 },
|
|
1903
1931
|
);
|
|
1904
1932
|
|
|
@@ -1964,7 +1992,7 @@ authRoute.openapi(verifyLinkPhone, async (c) => {
|
|
|
1964
1992
|
|
|
1965
1993
|
// Verify OTP from KV
|
|
1966
1994
|
const otpData = await c.env.KV.get(`phone-link-otp:${phone}`, 'json') as {
|
|
1967
|
-
code
|
|
1995
|
+
codeHash?: string; code?: string; userId: string; attempts: number;
|
|
1968
1996
|
} | null;
|
|
1969
1997
|
|
|
1970
1998
|
if (!otpData) {
|
|
@@ -1980,7 +2008,11 @@ authRoute.openapi(verifyLinkPhone, async (c) => {
|
|
|
1980
2008
|
throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
|
|
1981
2009
|
}
|
|
1982
2010
|
|
|
1983
|
-
|
|
2011
|
+
const submittedCode = body.code.trim();
|
|
2012
|
+
const otpMatches = otpData.codeHash
|
|
2013
|
+
? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
|
|
2014
|
+
: timingSafeEqual(otpData.code ?? '', submittedCode);
|
|
2015
|
+
if (!otpMatches) {
|
|
1984
2016
|
await c.env.KV.put(
|
|
1985
2017
|
`phone-link-otp:${phone}`,
|
|
1986
2018
|
JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
|
|
@@ -2083,11 +2115,12 @@ authRoute.openapi(signinEmailOtp, async (c) => {
|
|
|
2083
2115
|
|
|
2084
2116
|
const code = generateOTP();
|
|
2085
2117
|
if (exposeTestSecrets) devCode = code;
|
|
2118
|
+
const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
|
|
2086
2119
|
|
|
2087
2120
|
// Store OTP in KV with 5 min TTL
|
|
2088
2121
|
await c.env.KV.put(
|
|
2089
2122
|
`email-otp:${email}`,
|
|
2090
|
-
JSON.stringify({
|
|
2123
|
+
JSON.stringify({ codeHash, userId, attempts: 0 }),
|
|
2091
2124
|
{ expirationTtl: 300 },
|
|
2092
2125
|
);
|
|
2093
2126
|
|
|
@@ -2117,19 +2150,24 @@ authRoute.openapi(signinEmailOtp, async (c) => {
|
|
|
2117
2150
|
}
|
|
2118
2151
|
|
|
2119
2152
|
const userId = generateId();
|
|
2153
|
+
const otpReqLocale = parseAcceptLanguage(c.req.header('accept-language'));
|
|
2120
2154
|
|
|
2121
2155
|
try {
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2156
|
+
// beforeSignUp hook
|
|
2157
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
2158
|
+
id: userId, email, displayName: null, avatarUrl: null,
|
|
2159
|
+
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
2160
|
+
|
|
2161
|
+
try {
|
|
2162
|
+
await registerEmailPending(db, email, userId);
|
|
2163
|
+
} catch (err) {
|
|
2164
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
2165
|
+
return c.json({ ok: true });
|
|
2166
|
+
}
|
|
2167
|
+
throw new EdgeBaseError(500, 'Email OTP request failed.', undefined, 'internal-error');
|
|
2126
2168
|
}
|
|
2127
|
-
throw new EdgeBaseError(500, 'Email OTP request failed.', undefined, 'internal-error');
|
|
2128
|
-
}
|
|
2129
2169
|
|
|
2130
|
-
try {
|
|
2131
2170
|
// Create user with email, verified = 1
|
|
2132
|
-
const otpReqLocale = parseAcceptLanguage(c.req.header('accept-language'));
|
|
2133
2171
|
await authService.createUser(db, {
|
|
2134
2172
|
userId,
|
|
2135
2173
|
email,
|
|
@@ -2141,11 +2179,12 @@ authRoute.openapi(signinEmailOtp, async (c) => {
|
|
|
2141
2179
|
|
|
2142
2180
|
const code = generateOTP();
|
|
2143
2181
|
if (exposeTestSecrets) devCode = code;
|
|
2182
|
+
const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
|
|
2144
2183
|
|
|
2145
2184
|
// Store OTP in KV
|
|
2146
2185
|
await c.env.KV.put(
|
|
2147
2186
|
`email-otp:${email}`,
|
|
2148
|
-
JSON.stringify({
|
|
2187
|
+
JSON.stringify({ codeHash, userId, attempts: 0 }),
|
|
2149
2188
|
{ expirationTtl: 300 },
|
|
2150
2189
|
);
|
|
2151
2190
|
|
|
@@ -2218,7 +2257,7 @@ authRoute.openapi(verifyEmailOtp, async (c) => {
|
|
|
2218
2257
|
|
|
2219
2258
|
// Look up OTP data from KV
|
|
2220
2259
|
const otpData = await c.env.KV.get(`email-otp:${email}`, 'json') as {
|
|
2221
|
-
code
|
|
2260
|
+
codeHash?: string; code?: string; userId: string; attempts: number;
|
|
2222
2261
|
} | null;
|
|
2223
2262
|
|
|
2224
2263
|
if (!otpData) {
|
|
@@ -2230,7 +2269,11 @@ authRoute.openapi(verifyEmailOtp, async (c) => {
|
|
|
2230
2269
|
throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
|
|
2231
2270
|
}
|
|
2232
2271
|
|
|
2233
|
-
|
|
2272
|
+
const submittedCode = body.code.trim();
|
|
2273
|
+
const otpMatches = otpData.codeHash
|
|
2274
|
+
? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
|
|
2275
|
+
: timingSafeEqual(otpData.code ?? '', submittedCode);
|
|
2276
|
+
if (!otpMatches) {
|
|
2234
2277
|
await c.env.KV.put(
|
|
2235
2278
|
`email-otp:${email}`,
|
|
2236
2279
|
JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
|
|
@@ -2577,6 +2620,67 @@ authRoute.openapi(mfaRecovery, async (c) => {
|
|
|
2577
2620
|
});
|
|
2578
2621
|
});
|
|
2579
2622
|
|
|
2623
|
+
// POST /mfa/recovery-codes — regenerate recovery codes (authenticated)
|
|
2624
|
+
const mfaRecoveryCodesRegenerate = createRoute({
|
|
2625
|
+
operationId: 'authMfaRecoveryCodesRegenerate',
|
|
2626
|
+
method: 'post',
|
|
2627
|
+
path: '/mfa/recovery-codes',
|
|
2628
|
+
tags: ['client'],
|
|
2629
|
+
summary: 'Regenerate MFA recovery codes',
|
|
2630
|
+
request: {
|
|
2631
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2632
|
+
password: z.string().optional(),
|
|
2633
|
+
code: z.string().optional(),
|
|
2634
|
+
}).passthrough() } }, required: false },
|
|
2635
|
+
},
|
|
2636
|
+
responses: {
|
|
2637
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2638
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2639
|
+
401: { description: 'Authentication required or verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2640
|
+
},
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
authRoute.openapi(mfaRecoveryCodesRegenerate, async (c) => {
|
|
2644
|
+
const userId = requireAuth(c.get('auth'));
|
|
2645
|
+
const bodyText = await c.req.text();
|
|
2646
|
+
let body: { password?: string; code?: string } = {};
|
|
2647
|
+
try { body = bodyText ? JSON.parse(bodyText) : {}; } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
|
|
2648
|
+
await ensureAuthActionAllowed(c, 'mfaRecoveryCodesRegenerate', {
|
|
2649
|
+
userId,
|
|
2650
|
+
passwordProvided: !!body.password,
|
|
2651
|
+
codeProvided: !!body.code,
|
|
2652
|
+
});
|
|
2653
|
+
const db = getAuthDb(c);
|
|
2654
|
+
|
|
2655
|
+
const user = await authService.getUserById(db, userId);
|
|
2656
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2657
|
+
const factor = await authService.getMfaFactorByUser(db, userId, 'totp');
|
|
2658
|
+
if (!factor || !factor.verified) {
|
|
2659
|
+
throw new EdgeBaseError(400, 'No verified TOTP factor found.', undefined, 'invalid-input');
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
if (body.password) {
|
|
2663
|
+
if (!user.passwordHash) throw new EdgeBaseError(400, 'This account has no password set.', undefined, 'invalid-input');
|
|
2664
|
+
const valid = await verifyPassword(body.password, user.passwordHash as string);
|
|
2665
|
+
if (!valid) throw new EdgeBaseError(401, 'Invalid password.', undefined, 'invalid-password');
|
|
2666
|
+
} else if (body.code) {
|
|
2667
|
+
const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
|
|
2668
|
+
const valid = await verifyTOTP(secret, body.code);
|
|
2669
|
+
if (!valid) throw new EdgeBaseError(401, 'Invalid TOTP code.', undefined, 'invalid-totp');
|
|
2670
|
+
} else {
|
|
2671
|
+
throw new EdgeBaseError(400, 'Either password or TOTP code is required to regenerate recovery codes.', undefined, 'invalid-input');
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
const recoveryCodes = generateRecoveryCodes(8);
|
|
2675
|
+
const hashedCodes: { id: string; codeHash: string }[] = [];
|
|
2676
|
+
for (const code of recoveryCodes) {
|
|
2677
|
+
hashedCodes.push({ id: generateId(), codeHash: await hashRecoveryCode(code) });
|
|
2678
|
+
}
|
|
2679
|
+
await authService.replaceRecoveryCodes(db, userId, hashedCodes);
|
|
2680
|
+
|
|
2681
|
+
return c.json({ recoveryCodes });
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2580
2684
|
// DELETE /mfa/totp — disable TOTP (authenticated)
|
|
2581
2685
|
const mfaTotpDelete = createRoute({
|
|
2582
2686
|
operationId: 'authMfaTotpDelete',
|
package/src/routes/room.ts
CHANGED
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* - KV counter with expirationTtl: 60s
|
|
13
13
|
*/
|
|
14
14
|
import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
|
|
15
|
-
import type { RoomNamespaceConfig } from '@edge-base/shared';
|
|
15
|
+
import type { EdgeBaseConfig, RoomHandlerContext, RoomNamespaceConfig } from '@edge-base/shared';
|
|
16
16
|
import type { Context } from 'hono';
|
|
17
17
|
import { parseConfig } from '../lib/do-router.js';
|
|
18
|
+
import { buildFunctionContext } from '../lib/functions.js';
|
|
18
19
|
import { resolveRoomRuntime } from '../lib/room-runtime.js';
|
|
19
20
|
import { zodDefaultHook, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
|
|
21
|
+
import { resolveRootServiceKey } from '../lib/service-key.js';
|
|
20
22
|
import {
|
|
21
23
|
acquirePendingWebSocketSlot,
|
|
22
24
|
getPendingWebSocketCount,
|
|
@@ -111,6 +113,33 @@ function getRoomAuthContext(
|
|
|
111
113
|
};
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
function buildRoomRouteAccessContext(
|
|
117
|
+
c: Context<HonoEnv>,
|
|
118
|
+
config: EdgeBaseConfig,
|
|
119
|
+
): RoomHandlerContext {
|
|
120
|
+
let executionCtx: ExecutionContext | undefined;
|
|
121
|
+
try {
|
|
122
|
+
executionCtx = c.executionCtx;
|
|
123
|
+
} catch {
|
|
124
|
+
executionCtx = undefined;
|
|
125
|
+
}
|
|
126
|
+
const ctx = buildFunctionContext({
|
|
127
|
+
request: c.req.raw,
|
|
128
|
+
auth: getRoomAuthContext(c.get('auth') as never),
|
|
129
|
+
databaseNamespace: c.env.DATABASE,
|
|
130
|
+
authNamespace: c.env.AUTH,
|
|
131
|
+
d1Database: c.env.AUTH_DB,
|
|
132
|
+
kvNamespace: c.env.KV,
|
|
133
|
+
config,
|
|
134
|
+
env: c.env,
|
|
135
|
+
executionCtx,
|
|
136
|
+
serviceKey: resolveRootServiceKey(config, c.env),
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
admin: ctx.admin as never,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
export async function proxyRoomDoRequest(
|
|
115
144
|
c: Context<HonoEnv>,
|
|
116
145
|
path: string,
|
|
@@ -433,9 +462,11 @@ roomRoute.openapi(getRoomMetadata, async (c) => {
|
|
|
433
462
|
}
|
|
434
463
|
if (metadataAccess) {
|
|
435
464
|
const auth = (c.get('auth') as { id?: string; role?: string; email?: string | null; custom?: Record<string, unknown> | null; isAnonymous?: boolean; meta?: Record<string, unknown> } | null | undefined) ?? null;
|
|
465
|
+
const accessCtx = buildRoomRouteAccessContext(c, config);
|
|
436
466
|
const allowed = await Promise.resolve(metadataAccess(
|
|
437
467
|
getRoomAuthContext(auth),
|
|
438
468
|
roomId,
|
|
469
|
+
accessCtx,
|
|
439
470
|
)).catch(() => false);
|
|
440
471
|
if (!allowed) {
|
|
441
472
|
return c.json({ code: 403, message: 'Denied by room metadata access rule' }, 403);
|
|
@@ -493,9 +524,11 @@ roomRoute.openapi(getRoomSummary, async (c) => {
|
|
|
493
524
|
}
|
|
494
525
|
if (metadataAccess) {
|
|
495
526
|
const auth = (c.get('auth') as { id?: string; role?: string; email?: string | null; custom?: Record<string, unknown> | null; isAnonymous?: boolean; meta?: Record<string, unknown> } | null | undefined) ?? null;
|
|
527
|
+
const accessCtx = buildRoomRouteAccessContext(c, config);
|
|
496
528
|
const allowed = await Promise.resolve(metadataAccess(
|
|
497
529
|
getRoomAuthContext(auth),
|
|
498
530
|
roomId,
|
|
531
|
+
accessCtx,
|
|
499
532
|
)).catch(() => false);
|
|
500
533
|
if (!allowed) {
|
|
501
534
|
return c.json({ code: 403, message: 'Denied by room metadata access rule' }, 403);
|
|
@@ -557,8 +590,9 @@ roomRoute.openapi(getRoomSummaries, async (c) => {
|
|
|
557
590
|
const deniedIds: string[] = [];
|
|
558
591
|
|
|
559
592
|
if (metadataAccess) {
|
|
593
|
+
const accessCtx = buildRoomRouteAccessContext(c, config);
|
|
560
594
|
for (const roomId of roomIds) {
|
|
561
|
-
const allowed = await Promise.resolve(metadataAccess(authContext, roomId)).catch(() => false);
|
|
595
|
+
const allowed = await Promise.resolve(metadataAccess(authContext, roomId, accessCtx)).catch(() => false);
|
|
562
596
|
if (allowed) {
|
|
563
597
|
allowedRoomIds.push(roomId);
|
|
564
598
|
} else {
|