@sylphx/sdk 0.10.5 → 0.10.6

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
@@ -67,11 +68,28 @@ interface SylphxMiddlewareConfig {
67
68
  * - {prefix}/oauth-providers — enabled social login providers
68
69
  * - {prefix}/oauth/authorize — social login start handler
69
70
  * - {prefix}/callback — OAuth callback handler
71
+ * - {prefix}/passkey/options — passkey login challenge handler
72
+ * - {prefix}/passkey/authenticate — passkey login verification handler
73
+ * - {prefix}/forgot-password — password reset email handler
74
+ * - {prefix}/reset-password — password reset verification handler
70
75
  * - {prefix}/signout — Sign out handler
71
76
  *
72
77
  * @default '/auth'
73
78
  */
74
79
  authPrefix?: string;
80
+ /**
81
+ * Same-origin BaaS proxy prefix.
82
+ *
83
+ * Mounted routes are allowlisted SDK-owned BaaS surfaces such as
84
+ * `{prefix}/security/*` and `{prefix}/challenge/*`. The middleware converts
85
+ * the HttpOnly session cookie into an upstream bearer token, so browser code
86
+ * can use BaaS features without ever reading Platform access tokens.
87
+ *
88
+ * Set to `false` to disable the proxy.
89
+ *
90
+ * @default '/sylphx'
91
+ */
92
+ baasPrefix?: string | false;
75
93
  /**
76
94
  * Enable debug logging.
77
95
  * @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);
@@ -648,6 +717,80 @@ async function handleOAuthAuthorize(request, ctx) {
648
717
  setOAuthPkceCookie(response, ctx, { verifier, createdAt: Date.now() });
649
718
  return response;
650
719
  }
720
+ async function handlePasskeyOptions(request, ctx) {
721
+ if (request.method !== "POST") {
722
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
723
+ }
724
+ const body = await parseJsonObject(request);
725
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/passkey/options`, {
726
+ method: "POST",
727
+ headers: headersForPlatformAuth(ctx),
728
+ body: JSON.stringify(body ?? {})
729
+ });
730
+ const data = await res.json().catch(() => ({}));
731
+ return NextResponse.json(data, { status: res.status });
732
+ }
733
+ async function handlePasskeyAuthenticate(request, ctx) {
734
+ if (request.method !== "POST") {
735
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
736
+ }
737
+ const body = await parseJsonObject(request);
738
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/passkey/authenticate`, {
739
+ method: "POST",
740
+ headers: headersForPlatformAuth(ctx),
741
+ body: JSON.stringify(body ?? {})
742
+ });
743
+ const data = await res.json().catch(() => ({}));
744
+ if (!res.ok) {
745
+ return NextResponse.json(data, { status: res.status });
746
+ }
747
+ if (!isTokenResponse(data)) {
748
+ return NextResponse.json({ error: "invalid_response" }, { status: 502 });
749
+ }
750
+ const response = NextResponse.json({ success: true, user: data.user });
751
+ setAuthCookiesMiddleware(response, ctx.namespace, data);
752
+ return response;
753
+ }
754
+ async function handleForgotPassword(request, ctx) {
755
+ if (request.method !== "POST") {
756
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
757
+ }
758
+ const body = await parseJsonObject(request);
759
+ const email = typeof body?.email === "string" ? body.email : null;
760
+ const rawRedirect = typeof body?.redirectUrl === "string" ? body.redirectUrl : typeof body?.redirectTo === "string" ? body.redirectTo : null;
761
+ const redirectUrl = resolveSameOriginUrl(request, rawRedirect, "/reset-password");
762
+ if (!email) {
763
+ return NextResponse.json({ error: "email is required" }, { status: 400 });
764
+ }
765
+ if (!redirectUrl) {
766
+ return NextResponse.json({ error: "invalid_redirect_url" }, { status: 400 });
767
+ }
768
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/forgot-password`, {
769
+ method: "POST",
770
+ headers: headersForPlatformAuth(ctx),
771
+ body: JSON.stringify({ email, redirectUrl })
772
+ });
773
+ const data = await res.json().catch(() => ({}));
774
+ return NextResponse.json(data, { status: res.status });
775
+ }
776
+ async function handleResetPassword(request, ctx) {
777
+ if (request.method !== "POST") {
778
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
779
+ }
780
+ const body = await parseJsonObject(request);
781
+ const token = typeof body?.token === "string" ? body.token : null;
782
+ const password = typeof body?.password === "string" ? body.password : typeof body?.newPassword === "string" ? body.newPassword : null;
783
+ if (!token || !password) {
784
+ return NextResponse.json({ error: "token and password are required" }, { status: 400 });
785
+ }
786
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/reset-password`, {
787
+ method: "POST",
788
+ headers: headersForPlatformAuth(ctx),
789
+ body: JSON.stringify({ token, password })
790
+ });
791
+ const data = await res.json().catch(() => ({}));
792
+ return NextResponse.json(data, { status: res.status });
793
+ }
651
794
  async function handleCallback(request, ctx) {
652
795
  const { searchParams } = request.nextUrl;
653
796
  const code = searchParams.get("code");
@@ -986,6 +1129,30 @@ async function handleSwitchOrg(request, ctx) {
986
1129
  return NextResponse.json({ error: "internal_error" }, { status: 500 });
987
1130
  }
988
1131
  }
1132
+ async function handleBaasProxy(request, ctx, sessionToken) {
1133
+ const prefix = ctx.config.baasPrefix;
1134
+ if (!prefix) return NextResponse.json({ error: "Not found" }, { status: 404 });
1135
+ const upstreamPath = stripRoutePrefix(request.nextUrl.pathname, prefix);
1136
+ if (!upstreamPath || !isAllowedBaasProxyPath(upstreamPath)) {
1137
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
1138
+ }
1139
+ if (isAuthenticatedBaasProxyPath(upstreamPath) && !sessionToken) {
1140
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
1141
+ }
1142
+ const upstreamUrl = new URL(`${ctx.platformUrl}/v1${upstreamPath}`);
1143
+ upstreamUrl.search = request.nextUrl.search;
1144
+ const method = request.method.toUpperCase();
1145
+ const body = BODYLESS_METHODS.has(method) ? void 0 : await request.arrayBuffer();
1146
+ const res = await fetch(upstreamUrl, {
1147
+ method,
1148
+ headers: buildUpstreamProxyHeaders(request, ctx, sessionToken),
1149
+ body
1150
+ });
1151
+ return new NextResponse(res.body, {
1152
+ status: res.status,
1153
+ headers: copyResponseHeaders(res.headers)
1154
+ });
1155
+ }
989
1156
  async function refreshTokens(refreshToken, ctx) {
990
1157
  ctx.log("Refreshing tokens");
991
1158
  try {
@@ -1050,6 +1217,7 @@ function createSylphxMiddleware(userConfig = {}) {
1050
1217
  afterSignOutUrl: userConfig.afterSignOutUrl ?? "/",
1051
1218
  afterSignInUrl: userConfig.afterSignInUrl ?? "/dashboard",
1052
1219
  authPrefix: userConfig.authPrefix ?? "/auth",
1220
+ baasPrefix: userConfig.baasPrefix === false ? null : normalizeRoutePrefix(userConfig.baasPrefix ?? DEFAULT_BAAS_PREFIX),
1053
1221
  debug: userConfig.debug ?? false,
1054
1222
  orgRequired: userConfig.orgRequired ?? false,
1055
1223
  orgSelectionUrl: userConfig.orgSelectionUrl ?? "/select-organization",
@@ -1065,7 +1233,8 @@ function createSylphxMiddleware(userConfig = {}) {
1065
1233
  ...config.publicRoutes,
1066
1234
  config.signInUrl,
1067
1235
  "/signup",
1068
- `${config.authPrefix}/*`
1236
+ `${config.authPrefix}/*`,
1237
+ ...config.baasPrefix ? [`${config.baasPrefix}/*`] : []
1069
1238
  ];
1070
1239
  const log = (msg, data) => {
1071
1240
  if (config.debug) {
@@ -1110,6 +1279,18 @@ function createSylphxMiddleware(userConfig = {}) {
1110
1279
  if (pathname === `${config.authPrefix}/oauth/authorize`) {
1111
1280
  return handleOAuthAuthorize(request, ctx);
1112
1281
  }
1282
+ if (pathname === `${config.authPrefix}/passkey/options`) {
1283
+ return handlePasskeyOptions(request, ctx);
1284
+ }
1285
+ if (pathname === `${config.authPrefix}/passkey/authenticate`) {
1286
+ return handlePasskeyAuthenticate(request, ctx);
1287
+ }
1288
+ if (pathname === `${config.authPrefix}/forgot-password`) {
1289
+ return handleForgotPassword(request, ctx);
1290
+ }
1291
+ if (pathname === `${config.authPrefix}/reset-password`) {
1292
+ return handleResetPassword(request, ctx);
1293
+ }
1113
1294
  if (pathname === `${config.authPrefix}/signout`) {
1114
1295
  return handleSignOut(request, ctx);
1115
1296
  }
@@ -1147,6 +1328,9 @@ function createSylphxMiddleware(userConfig = {}) {
1147
1328
  activeSessionToken = null;
1148
1329
  }
1149
1330
  }
1331
+ if (config.baasPrefix && stripRoutePrefix(pathname, config.baasPrefix)) {
1332
+ return handleBaasProxy(request, ctx, activeSessionToken);
1333
+ }
1150
1334
  const isPublic = matchesAny(pathname, publicRoutes);
1151
1335
  if (!isPublic && !isAuthenticated) {
1152
1336
  log("Redirecting to sign-in");