@sylphx/sdk 0.10.5 → 0.10.7

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.
@@ -6,6 +6,7 @@ import { AuthTokensResponse } from '@sylphx/contract';
6
6
  *
7
7
  * ONE middleware function handles EVERYTHING:
8
8
  * - Auth routes (mounted automatically, zero manual API routes)
9
+ * - BaaS routes (same-origin proxy, no browser bearer-token exposure)
9
10
  * - Token refresh (automatic, every request)
10
11
  * - Route protection
11
12
  * - Cookie management
@@ -63,15 +64,36 @@ interface SylphxMiddlewareConfig {
63
64
  afterSignInUrl?: string;
64
65
  /**
65
66
  * Auth routes prefix. Routes are mounted at:
67
+ * - {prefix}/register — email/password registration handler
66
68
  * - {prefix}/login — credentials login handler
69
+ * - {prefix}/verify-email — email verification handler
67
70
  * - {prefix}/oauth-providers — enabled social login providers
68
71
  * - {prefix}/oauth/authorize — social login start handler
69
72
  * - {prefix}/callback — OAuth callback handler
73
+ * - {prefix}/passkey/options — passkey login challenge handler
74
+ * - {prefix}/passkey/authenticate — passkey login verification handler
75
+ * - {prefix}/verify-2fa — TOTP/backup-code verification handler
76
+ * - {prefix}/forgot-password — password reset email handler
77
+ * - {prefix}/reset-password — password reset verification handler
78
+ * - {prefix}/session — safe session metadata handler
70
79
  * - {prefix}/signout — Sign out handler
71
80
  *
72
81
  * @default '/auth'
73
82
  */
74
83
  authPrefix?: string;
84
+ /**
85
+ * Same-origin BaaS proxy prefix.
86
+ *
87
+ * Mounted routes are allowlisted SDK-owned BaaS surfaces such as
88
+ * `{prefix}/security/*` and `{prefix}/challenge/*`. The middleware converts
89
+ * the HttpOnly session cookie into an upstream bearer token, so browser code
90
+ * can use BaaS features without ever reading Platform access tokens.
91
+ *
92
+ * Set to `false` to disable the proxy.
93
+ *
94
+ * @default '/sylphx'
95
+ */
96
+ baasPrefix?: string | false;
75
97
  /**
76
98
  * Enable debug logging.
77
99
  * @default false
@@ -455,6 +455,19 @@ function resolveNextjsSecretKey(options) {
455
455
  // src/nextjs/middleware.ts
456
456
  var OAUTH_PKCE_TTL_SECONDS = 10 * 60;
457
457
  var OAUTH_PKCE_TTL_MS = OAUTH_PKCE_TTL_SECONDS * 1e3;
458
+ var DEFAULT_BAAS_PREFIX = "/sylphx";
459
+ var BAAS_PROXY_AUTH_REQUIRED_PATHS = ["/challenge/", "/security/"];
460
+ var BAAS_PROXY_PUBLIC_PATHS = ["/app"];
461
+ var BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
462
+ var RESPONSE_HEADER_ALLOWLIST = [
463
+ "cache-control",
464
+ "content-language",
465
+ "content-type",
466
+ "etag",
467
+ "expires",
468
+ "last-modified",
469
+ "retry-after"
470
+ ];
458
471
  function isTokenResponse(data) {
459
472
  return typeof data === "object" && data !== null && "accessToken" in data && "refreshToken" in data && "user" in data && typeof data.accessToken === "string" && typeof data.refreshToken === "string";
460
473
  }
@@ -491,6 +504,46 @@ function matchesPattern(pathname, pattern) {
491
504
  function matchesAny(pathname, patterns) {
492
505
  return patterns.some((p) => matchesPattern(pathname, p));
493
506
  }
507
+ function normalizeRoutePrefix(prefix) {
508
+ const normalized = prefix.trim().replace(/\/+$/u, "");
509
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
510
+ }
511
+ function stripRoutePrefix(pathname, prefix) {
512
+ if (pathname === prefix) return "/";
513
+ if (!pathname.startsWith(`${prefix}/`)) return null;
514
+ return pathname.slice(prefix.length);
515
+ }
516
+ function isPublicBaasProxyPath(pathname) {
517
+ return BAAS_PROXY_PUBLIC_PATHS.some((path) => pathname === path);
518
+ }
519
+ function isAuthenticatedBaasProxyPath(pathname) {
520
+ return BAAS_PROXY_AUTH_REQUIRED_PATHS.some((prefix) => pathname.startsWith(prefix));
521
+ }
522
+ function isAllowedBaasProxyPath(pathname) {
523
+ return isPublicBaasProxyPath(pathname) || isAuthenticatedBaasProxyPath(pathname);
524
+ }
525
+ function copyRequestHeader(source, target, name) {
526
+ const value = source.get(name);
527
+ if (value) target.set(name, value);
528
+ }
529
+ function buildUpstreamProxyHeaders(request, ctx, sessionToken) {
530
+ const headers = new Headers();
531
+ copyRequestHeader(request.headers, headers, "accept");
532
+ copyRequestHeader(request.headers, headers, "content-type");
533
+ copyRequestHeader(request.headers, headers, "user-agent");
534
+ copyRequestHeader(request.headers, headers, "x-correlation-id");
535
+ headers.set("x-app-secret", ctx.secretKey);
536
+ if (sessionToken) headers.set("authorization", `Bearer ${sessionToken}`);
537
+ return headers;
538
+ }
539
+ function copyResponseHeaders(source) {
540
+ const headers = new Headers();
541
+ for (const name of RESPONSE_HEADER_ALLOWLIST) {
542
+ const value = source.get(name);
543
+ if (value) headers.set(name, value);
544
+ }
545
+ return headers;
546
+ }
494
547
  function requestPathWithSearch(request) {
495
548
  const { pathname, search } = request.nextUrl;
496
549
  return `${pathname}${search}`;
@@ -503,6 +556,22 @@ function resolveSafeRelativeRedirectPath(value, fallback) {
503
556
  if (isSafeRelativeRedirectPath(fallback)) return fallback;
504
557
  return "/";
505
558
  }
559
+ function resolveSameOriginUrl(request, value, fallbackPath) {
560
+ if (!value) return new URL(fallbackPath, request.url).toString();
561
+ if (isSafeRelativeRedirectPath(value)) {
562
+ const url = new URL(value, request.url);
563
+ if (url.hash) return null;
564
+ return url.toString();
565
+ }
566
+ try {
567
+ const url = new URL(value);
568
+ if (url.origin !== new URL(request.url).origin) return null;
569
+ if (url.hash) return null;
570
+ return url.toString();
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
506
575
  function bytesToBase64Url(bytes) {
507
576
  let binary = "";
508
577
  for (const byte of bytes) binary += String.fromCharCode(byte);
@@ -565,12 +634,30 @@ function isTwoFactorLoginResponse(data) {
565
634
  function isOAuthAuthorizeResponse(data) {
566
635
  return typeof data === "object" && data !== null && typeof data.authorization_url === "string";
567
636
  }
637
+ function authCookieJsonResponse(ctx, data) {
638
+ const response = NextResponse.json({ success: true, user: data.user });
639
+ setAuthCookiesMiddleware(response, ctx.namespace, data);
640
+ return response;
641
+ }
568
642
  function headersForPlatformAuth(ctx) {
569
643
  return {
570
644
  "Content-Type": "application/json",
571
645
  "x-app-secret": ctx.secretKey
572
646
  };
573
647
  }
648
+ async function handleRegister(request, ctx) {
649
+ if (request.method !== "POST") {
650
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
651
+ }
652
+ const body = await parseJsonObject(request);
653
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/register`, {
654
+ method: "POST",
655
+ headers: headersForPlatformAuth(ctx),
656
+ body: JSON.stringify(body ?? {})
657
+ });
658
+ const data = await res.json().catch(() => ({}));
659
+ return NextResponse.json(data, { status: res.status });
660
+ }
574
661
  async function handleLogin(request, ctx) {
575
662
  if (request.method !== "POST") {
576
663
  return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
@@ -596,9 +683,78 @@ async function handleLogin(request, ctx) {
596
683
  if (!isTokenResponse(data)) {
597
684
  return NextResponse.json({ error: "invalid_response" }, { status: 502 });
598
685
  }
599
- const response = NextResponse.json({ success: true, user: data.user });
600
- setAuthCookiesMiddleware(response, ctx.namespace, data);
601
- return response;
686
+ return authCookieJsonResponse(ctx, data);
687
+ }
688
+ async function handleVerifyEmail(request, ctx) {
689
+ if (request.method !== "POST") {
690
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
691
+ }
692
+ const body = await parseJsonObject(request);
693
+ const token = typeof body?.token === "string" ? body.token : null;
694
+ if (!token) {
695
+ return NextResponse.json({ error: "token is required" }, { status: 400 });
696
+ }
697
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/verify-email`, {
698
+ method: "POST",
699
+ headers: headersForPlatformAuth(ctx),
700
+ body: JSON.stringify({ token })
701
+ });
702
+ const data = await res.json().catch(() => ({}));
703
+ return NextResponse.json(data, { status: res.status });
704
+ }
705
+ async function handleVerifyTwoFactor(request, ctx) {
706
+ if (request.method !== "POST") {
707
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
708
+ }
709
+ const body = await parseJsonObject(request);
710
+ const userId = typeof body?.userId === "string" ? body.userId : null;
711
+ const code = typeof body?.code === "string" ? body.code : null;
712
+ if (!userId || !code) {
713
+ return NextResponse.json({ error: "userId and code are required" }, { status: 400 });
714
+ }
715
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/verify-2fa`, {
716
+ method: "POST",
717
+ headers: headersForPlatformAuth(ctx),
718
+ body: JSON.stringify({ userId, code })
719
+ });
720
+ const data = await res.json().catch(() => ({}));
721
+ if (!res.ok) {
722
+ return NextResponse.json(data, { status: res.status });
723
+ }
724
+ if (!isTokenResponse(data)) {
725
+ return NextResponse.json({ error: "invalid_response" }, { status: 502 });
726
+ }
727
+ return authCookieJsonResponse(ctx, data);
728
+ }
729
+ function handleSession(request, ctx) {
730
+ if (request.method !== "GET") {
731
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
732
+ }
733
+ const sessionToken = request.cookies.get(ctx.cookieNames.SESSION)?.value;
734
+ const userCookieValue = request.cookies.get(ctx.cookieNames.USER)?.value;
735
+ let userCookie = userCookieValue ? parseUserCookie2(userCookieValue) : null;
736
+ if (!userCookie && userCookieValue) {
737
+ try {
738
+ userCookie = parseUserCookie2(decodeURIComponent(userCookieValue));
739
+ } catch {
740
+ userCookie = null;
741
+ }
742
+ }
743
+ if (!sessionToken || isTokenExpired(sessionToken) || !userCookie?.user) {
744
+ return NextResponse.json({ success: true, session: null, user: null });
745
+ }
746
+ const payload = decodeJwtPayload(sessionToken);
747
+ const userId = payload?.sub ?? userCookie.user.id;
748
+ const expiresAt = typeof payload?.exp === "number" ? new Date(payload.exp * 1e3).toISOString() : new Date(userCookie.expiresAt).toISOString();
749
+ return NextResponse.json({
750
+ success: true,
751
+ session: {
752
+ id: `platform:${userId}`,
753
+ userId,
754
+ expiresAt
755
+ },
756
+ user: userCookie.user
757
+ });
602
758
  }
603
759
  async function handleOAuthProviders(ctx) {
604
760
  const res = await fetch(`${ctx.platformUrl}/v1/auth/oauth-providers`, {
@@ -648,6 +804,78 @@ async function handleOAuthAuthorize(request, ctx) {
648
804
  setOAuthPkceCookie(response, ctx, { verifier, createdAt: Date.now() });
649
805
  return response;
650
806
  }
807
+ async function handlePasskeyOptions(request, ctx) {
808
+ if (request.method !== "POST") {
809
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
810
+ }
811
+ const body = await parseJsonObject(request);
812
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/passkey/options`, {
813
+ method: "POST",
814
+ headers: headersForPlatformAuth(ctx),
815
+ body: JSON.stringify(body ?? {})
816
+ });
817
+ const data = await res.json().catch(() => ({}));
818
+ return NextResponse.json(data, { status: res.status });
819
+ }
820
+ async function handlePasskeyAuthenticate(request, ctx) {
821
+ if (request.method !== "POST") {
822
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
823
+ }
824
+ const body = await parseJsonObject(request);
825
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/passkey/authenticate`, {
826
+ method: "POST",
827
+ headers: headersForPlatformAuth(ctx),
828
+ body: JSON.stringify(body ?? {})
829
+ });
830
+ const data = await res.json().catch(() => ({}));
831
+ if (!res.ok) {
832
+ return NextResponse.json(data, { status: res.status });
833
+ }
834
+ if (!isTokenResponse(data)) {
835
+ return NextResponse.json({ error: "invalid_response" }, { status: 502 });
836
+ }
837
+ return authCookieJsonResponse(ctx, data);
838
+ }
839
+ async function handleForgotPassword(request, ctx) {
840
+ if (request.method !== "POST") {
841
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
842
+ }
843
+ const body = await parseJsonObject(request);
844
+ const email = typeof body?.email === "string" ? body.email : null;
845
+ const rawRedirect = typeof body?.redirectUrl === "string" ? body.redirectUrl : typeof body?.redirectTo === "string" ? body.redirectTo : null;
846
+ const redirectUrl = resolveSameOriginUrl(request, rawRedirect, "/reset-password");
847
+ if (!email) {
848
+ return NextResponse.json({ error: "email is required" }, { status: 400 });
849
+ }
850
+ if (!redirectUrl) {
851
+ return NextResponse.json({ error: "invalid_redirect_url" }, { status: 400 });
852
+ }
853
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/forgot-password`, {
854
+ method: "POST",
855
+ headers: headersForPlatformAuth(ctx),
856
+ body: JSON.stringify({ email, redirectUrl })
857
+ });
858
+ const data = await res.json().catch(() => ({}));
859
+ return NextResponse.json(data, { status: res.status });
860
+ }
861
+ async function handleResetPassword(request, ctx) {
862
+ if (request.method !== "POST") {
863
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
864
+ }
865
+ const body = await parseJsonObject(request);
866
+ const token = typeof body?.token === "string" ? body.token : null;
867
+ const password = typeof body?.password === "string" ? body.password : typeof body?.newPassword === "string" ? body.newPassword : null;
868
+ if (!token || !password) {
869
+ return NextResponse.json({ error: "token and password are required" }, { status: 400 });
870
+ }
871
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/reset-password`, {
872
+ method: "POST",
873
+ headers: headersForPlatformAuth(ctx),
874
+ body: JSON.stringify({ token, password })
875
+ });
876
+ const data = await res.json().catch(() => ({}));
877
+ return NextResponse.json(data, { status: res.status });
878
+ }
651
879
  async function handleCallback(request, ctx) {
652
880
  const { searchParams } = request.nextUrl;
653
881
  const code = searchParams.get("code");
@@ -948,12 +1176,15 @@ async function handleSwitchOrg(request, ctx) {
948
1176
  }
949
1177
  const expiresIn = resolveOrgScopedTokenExpiresIn(data);
950
1178
  const expiresAt = Date.now() + expiresIn * 1e3;
1179
+ const payload = decodeJwtPayload(accessToken);
1180
+ const activeOrganization = {
1181
+ id: payload?.org_id ?? orgId,
1182
+ slug: payload?.org_slug ?? requestedOrgSlug
1183
+ };
951
1184
  const response = NextResponse.json({
952
- accessToken,
953
- token: accessToken,
954
- expiresIn,
955
- tokenType: resolveOrgScopedTokenType(data),
956
- user: data.user ?? null
1185
+ success: true,
1186
+ user: data.user ?? null,
1187
+ organization: activeOrganization
957
1188
  });
958
1189
  response.cookies.set(ctx.cookieNames.SESSION, accessToken, {
959
1190
  ...SECURE_COOKIE_OPTIONS,
@@ -974,11 +1205,7 @@ async function handleSwitchOrg(request, ctx) {
974
1205
  }
975
1206
  );
976
1207
  }
977
- const payload = decodeJwtPayload(accessToken);
978
- setActiveOrganizationCookies(response, ctx, {
979
- id: payload?.org_id ?? orgId,
980
- slug: payload?.org_slug ?? requestedOrgSlug
981
- });
1208
+ setActiveOrganizationCookies(response, ctx, activeOrganization);
982
1209
  ctx.log("Switch org success", { orgId });
983
1210
  return response;
984
1211
  } catch (err) {
@@ -986,6 +1213,30 @@ async function handleSwitchOrg(request, ctx) {
986
1213
  return NextResponse.json({ error: "internal_error" }, { status: 500 });
987
1214
  }
988
1215
  }
1216
+ async function handleBaasProxy(request, ctx, sessionToken) {
1217
+ const prefix = ctx.config.baasPrefix;
1218
+ if (!prefix) return NextResponse.json({ error: "Not found" }, { status: 404 });
1219
+ const upstreamPath = stripRoutePrefix(request.nextUrl.pathname, prefix);
1220
+ if (!upstreamPath || !isAllowedBaasProxyPath(upstreamPath)) {
1221
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
1222
+ }
1223
+ if (isAuthenticatedBaasProxyPath(upstreamPath) && !sessionToken) {
1224
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
1225
+ }
1226
+ const upstreamUrl = new URL(`${ctx.platformUrl}/v1${upstreamPath}`);
1227
+ upstreamUrl.search = request.nextUrl.search;
1228
+ const method = request.method.toUpperCase();
1229
+ const body = BODYLESS_METHODS.has(method) ? void 0 : await request.arrayBuffer();
1230
+ const res = await fetch(upstreamUrl, {
1231
+ method,
1232
+ headers: buildUpstreamProxyHeaders(request, ctx, sessionToken),
1233
+ body
1234
+ });
1235
+ return new NextResponse(res.body, {
1236
+ status: res.status,
1237
+ headers: copyResponseHeaders(res.headers)
1238
+ });
1239
+ }
989
1240
  async function refreshTokens(refreshToken, ctx) {
990
1241
  ctx.log("Refreshing tokens");
991
1242
  try {
@@ -1050,6 +1301,7 @@ function createSylphxMiddleware(userConfig = {}) {
1050
1301
  afterSignOutUrl: userConfig.afterSignOutUrl ?? "/",
1051
1302
  afterSignInUrl: userConfig.afterSignInUrl ?? "/dashboard",
1052
1303
  authPrefix: userConfig.authPrefix ?? "/auth",
1304
+ baasPrefix: userConfig.baasPrefix === false ? null : normalizeRoutePrefix(userConfig.baasPrefix ?? DEFAULT_BAAS_PREFIX),
1053
1305
  debug: userConfig.debug ?? false,
1054
1306
  orgRequired: userConfig.orgRequired ?? false,
1055
1307
  orgSelectionUrl: userConfig.orgSelectionUrl ?? "/select-organization",
@@ -1065,7 +1317,8 @@ function createSylphxMiddleware(userConfig = {}) {
1065
1317
  ...config.publicRoutes,
1066
1318
  config.signInUrl,
1067
1319
  "/signup",
1068
- `${config.authPrefix}/*`
1320
+ `${config.authPrefix}/*`,
1321
+ ...config.baasPrefix ? [`${config.baasPrefix}/*`] : []
1069
1322
  ];
1070
1323
  const log = (msg, data) => {
1071
1324
  if (config.debug) {
@@ -1098,18 +1351,42 @@ function createSylphxMiddleware(userConfig = {}) {
1098
1351
  if (pathname.includes(".") || pathname.startsWith("/_next")) {
1099
1352
  return NextResponse.next();
1100
1353
  }
1354
+ if (pathname === `${config.authPrefix}/register`) {
1355
+ return handleRegister(request, ctx);
1356
+ }
1101
1357
  if (pathname === `${config.authPrefix}/callback`) {
1102
1358
  return handleCallback(request, ctx);
1103
1359
  }
1104
1360
  if (pathname === `${config.authPrefix}/login`) {
1105
1361
  return handleLogin(request, ctx);
1106
1362
  }
1363
+ if (pathname === `${config.authPrefix}/verify-email`) {
1364
+ return handleVerifyEmail(request, ctx);
1365
+ }
1107
1366
  if (pathname === `${config.authPrefix}/oauth-providers`) {
1108
1367
  return handleOAuthProviders(ctx);
1109
1368
  }
1110
1369
  if (pathname === `${config.authPrefix}/oauth/authorize`) {
1111
1370
  return handleOAuthAuthorize(request, ctx);
1112
1371
  }
1372
+ if (pathname === `${config.authPrefix}/passkey/options`) {
1373
+ return handlePasskeyOptions(request, ctx);
1374
+ }
1375
+ if (pathname === `${config.authPrefix}/passkey/authenticate`) {
1376
+ return handlePasskeyAuthenticate(request, ctx);
1377
+ }
1378
+ if (pathname === `${config.authPrefix}/verify-2fa`) {
1379
+ return handleVerifyTwoFactor(request, ctx);
1380
+ }
1381
+ if (pathname === `${config.authPrefix}/forgot-password`) {
1382
+ return handleForgotPassword(request, ctx);
1383
+ }
1384
+ if (pathname === `${config.authPrefix}/reset-password`) {
1385
+ return handleResetPassword(request, ctx);
1386
+ }
1387
+ if (pathname === `${config.authPrefix}/session`) {
1388
+ return handleSession(request, ctx);
1389
+ }
1113
1390
  if (pathname === `${config.authPrefix}/signout`) {
1114
1391
  return handleSignOut(request, ctx);
1115
1392
  }
@@ -1147,6 +1424,9 @@ function createSylphxMiddleware(userConfig = {}) {
1147
1424
  activeSessionToken = null;
1148
1425
  }
1149
1426
  }
1427
+ if (config.baasPrefix && stripRoutePrefix(pathname, config.baasPrefix)) {
1428
+ return handleBaasProxy(request, ctx, activeSessionToken);
1429
+ }
1150
1430
  const isPublic = matchesAny(pathname, publicRoutes);
1151
1431
  if (!isPublic && !isAuthenticated) {
1152
1432
  log("Redirecting to sign-in");