@sylphx/sdk 0.10.0 → 0.10.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.
@@ -80,8 +80,7 @@ interface SylphxMiddlewareConfig {
80
80
  *
81
81
  * Use case: Platform Console uses dynamically generated keys.
82
82
  *
83
- * @default credential parsed from process.env.SYLPHX_SECRET_URL, falling back
84
- * to process.env.SYLPHX_SECRET_KEY for legacy apps
83
+ * @default credential parsed from process.env.SYLPHX_SECRET_URL
85
84
  */
86
85
  secretKey?: string;
87
86
  /**
@@ -96,7 +95,7 @@ interface SylphxMiddlewareConfig {
96
95
  * Platform URL for API calls.
97
96
  * Override for self-hosted or same-origin deployments.
98
97
  *
99
- * @default resolved from SYLPHX_URL, then legacy 4-part key routing
98
+ * @default resolved from SYLPHX_SECRET_URL, SYLPHX_URL, SYLPHX_BAAS_URL, or SYLPHX_RUNTIME_URL
100
99
  */
101
100
  platformUrl?: string;
102
101
  /**
@@ -113,6 +112,20 @@ interface SylphxMiddlewareConfig {
113
112
  * ```
114
113
  */
115
114
  onResponse?: (response: NextResponse, request: NextRequest) => void | Promise<void>;
115
+ /**
116
+ * Require authenticated requests to carry an active organization context.
117
+ * When true, protected routes without an `org_id` JWT claim redirect to
118
+ * `orgSelectionUrl`.
119
+ *
120
+ * @default false
121
+ */
122
+ orgRequired?: boolean;
123
+ /**
124
+ * URL for selecting an active organization when `orgRequired` is enabled.
125
+ *
126
+ * @default '/select-organization'
127
+ */
128
+ orgSelectionUrl?: string;
116
129
  }
117
130
  /**
118
131
  * Create Sylphx middleware — State of the Art
@@ -138,6 +151,7 @@ interface SylphxMiddlewareConfig {
138
151
  * ```
139
152
  */
140
153
  declare function createSylphxMiddleware(userConfig?: SylphxMiddlewareConfig): (request: NextRequest) => Promise<NextResponse>;
154
+ declare const sylphxMiddleware: typeof createSylphxMiddleware;
141
155
  /**
142
156
  * Create recommended matcher config
143
157
  */
@@ -225,8 +239,7 @@ interface UserCookieData {
225
239
  * Configure the SDK for server-side usage
226
240
  *
227
241
  * NOTE: This is optional! The SDK auto-configures from environment variables:
228
- * - SYLPHX_SECRET_URL (recommended)
229
- * - SYLPHX_SECRET_KEY (legacy fallback)
242
+ * - SYLPHX_SECRET_URL
230
243
  *
231
244
  * Use this only if you need to override the default configuration.
232
245
  *
@@ -575,4 +588,4 @@ declare function clearAuthCookiesMiddleware(response: NextResponse, namespace: s
575
588
  */
576
589
  declare function parseUserCookie(value: string): UserCookieData | null;
577
590
 
578
- export { type AuthCookiesData, type AuthResult, REFRESH_TOKEN_LIFETIME, SECURE_COOKIE_OPTIONS, SESSION_TOKEN_LIFETIME, SESSION_TOKEN_LIFETIME_MS, type SylphxMiddlewareConfig, TOKEN_EXPIRY_BUFFER_MS, USER_COOKIE_OPTIONS, type UserCookieData, auth, clearAuthCookies, clearAuthCookiesMiddleware, configureServer, createMatcher, createSylphxMiddleware, currentUser, currentUserId, decodeUserId, encodeUserId, getAuthCookies, getAuthorizationUrl, getCookieNames, getNamespace, getSessionToken, handleCallback, hasRefreshToken, isSessionExpired, parseUserCookie, setAuthCookies, setAuthCookiesMiddleware, signOut, syncAuthToCookies };
591
+ export { type AuthCookiesData, type AuthResult, REFRESH_TOKEN_LIFETIME, SECURE_COOKIE_OPTIONS, SESSION_TOKEN_LIFETIME, SESSION_TOKEN_LIFETIME_MS, type SylphxMiddlewareConfig, TOKEN_EXPIRY_BUFFER_MS, USER_COOKIE_OPTIONS, type UserCookieData, auth, clearAuthCookies, clearAuthCookiesMiddleware, configureServer, createMatcher, createSylphxMiddleware, currentUser, currentUserId, decodeUserId, encodeUserId, getAuthCookies, getAuthorizationUrl, getCookieNames, getNamespace, getSessionToken, handleCallback, hasRefreshToken, isSessionExpired, parseUserCookie, setAuthCookies, setAuthCookiesMiddleware, signOut, sylphxMiddleware, syncAuthToCookies };
@@ -4,7 +4,6 @@ import { NextResponse } from "next/server";
4
4
  // src/constants.ts
5
5
  var ENV_URL = "SYLPHX_URL";
6
6
  var ENV_SECRET_URL = "SYLPHX_SECRET_URL";
7
- var ENV_SECRET_KEY = "SYLPHX_SECRET_KEY";
8
7
  var DEFAULT_SDK_API_HOST = "api.sylphx.com";
9
8
  var SDK_PLATFORM = typeof window !== "undefined" ? "browser" : typeof process !== "undefined" && process.versions?.node ? "node" : "unknown";
10
9
  var TOKEN_EXPIRY_BUFFER_MS = 3e4;
@@ -34,13 +33,14 @@ var JWK_CACHE_TTL_MS = 60 * 60 * 1e3;
34
33
  var ETAG_CACHE_TTL_MS = 5 * 60 * 1e3;
35
34
 
36
35
  // src/key-validation.ts
37
- var PUBLIC_KEY_PATTERN = /^pk_(dev|stg|prod)_[a-z0-9]{12}_[a-f0-9]{32}$/;
36
+ var PUBLIC_KEY_PATTERN = /^pk_(dev|stg|prod|prev)(?:_[a-z0-9]{12})?_[a-f0-9]{32}$/;
38
37
  var APP_ID_PATTERN = /^(app|pk)_(dev|stg|prod|prev)_[a-z0-9_-]+$/;
39
- var SECRET_KEY_PATTERN = /^sk_(dev|stg|prod)_[a-z0-9_-]+$/;
38
+ var SECRET_KEY_PATTERN = /^sk_(dev|stg|prod|prev)_[a-z0-9_-]+$/;
40
39
  var ENV_PREFIX_MAP = {
41
40
  dev: "development",
42
41
  stg: "staging",
43
- prod: "production"
42
+ prod: "production",
43
+ prev: "preview"
44
44
  };
45
45
  function detectKeyIssues(key) {
46
46
  const issues = [];
@@ -65,7 +65,7 @@ The SDK will automatically sanitize the key, but fixing the source is recommende
65
65
  }
66
66
  function createInvalidKeyError(keyType, key, envVarName) {
67
67
  const maskedKey = key.length > 20 ? `${key.slice(0, 20)}...` : key;
68
- const formatHint = keyType === "appId" ? "pk_(dev|stg|prod)_{ref}_{hex} or app_(dev|stg|prod)_[id]" : "sk_(dev|stg|prod)_{ref}_{hex}";
68
+ const formatHint = keyType === "appId" ? "pk_(dev|stg|prod|prev)_{ref}_{hex} or app_(dev|stg|prod|prev)_[id]" : "sk_(dev|stg|prod|prev)_{ref}_{hex}";
69
69
  const keyTypeName = keyType === "appId" ? "App ID" : "Secret Key";
70
70
  return `[Sylphx] Invalid ${keyTypeName} format.
71
71
 
@@ -78,7 +78,7 @@ You can find your keys in the Sylphx Console \u2192 API Keys.
78
78
  Common issues:
79
79
  \u2022 Key has uppercase characters (must be lowercase)
80
80
  \u2022 Key has wrong prefix (App ID: pk_ or app_, Secret Key: sk_)
81
- \u2022 Key has invalid environment (must be dev, stg, or prod)
81
+ \u2022 Key has invalid environment (must be dev, stg, prod, or prev)
82
82
  \u2022 Key was copied with extra whitespace`;
83
83
  }
84
84
  function extractEnvironment(key) {
@@ -125,7 +125,7 @@ function validateKeyForType(key, keyType, pattern, envVarName) {
125
125
  };
126
126
  }
127
127
  function validatePublicKey(key) {
128
- return validateKeyForType(key, "publicKey", PUBLIC_KEY_PATTERN, "NEXT_PUBLIC_SYLPHX_KEY");
128
+ return validateKeyForType(key, "publicKey", PUBLIC_KEY_PATTERN, "publishable credential");
129
129
  }
130
130
  function validateAppId(key) {
131
131
  return validateKeyForType(key, "appId", APP_ID_PATTERN, "NEXT_PUBLIC_SYLPHX_APP_ID");
@@ -141,7 +141,7 @@ function validateAndSanitizeAppId(key) {
141
141
  return result.sanitizedKey;
142
142
  }
143
143
  function validateSecretKey(key) {
144
- return validateKeyForType(key, "secret", SECRET_KEY_PATTERN, "SYLPHX_SECRET_KEY");
144
+ return validateKeyForType(key, "secret", SECRET_KEY_PATTERN, "secret credential");
145
145
  }
146
146
  function validateAndSanitizeSecretKey(key) {
147
147
  const result = validateSecretKey(key);
@@ -176,7 +176,7 @@ function detectEnvironment(key) {
176
176
  }
177
177
  function getCookieNamespace(secretKey) {
178
178
  const env = detectEnvironment(secretKey);
179
- const shortEnv = env === "development" ? "dev" : env === "staging" ? "stg" : "prod";
179
+ const shortEnv = env === "development" ? "dev" : env === "staging" ? "stg" : env === "preview" ? "prev" : "prod";
180
180
  return `sylphx_${shortEnv}`;
181
181
  }
182
182
 
@@ -302,7 +302,7 @@ function parseUserCookie(value) {
302
302
  // src/connection-url.ts
303
303
  var SYLPHX_PROTOCOL = "sylphx:";
304
304
  var DEFAULT_VERSION = "v1";
305
- var CREDENTIAL_REGEX = /^(pk|sk)_(dev|stg|prod|prev)_[a-f0-9]{32,64}$/;
305
+ var CREDENTIAL_REGEX = /^(pk|sk)_(dev|stg|prod|prev)(?:_[a-z0-9]{12})?_[a-f0-9]{32,64}$/;
306
306
  var VERSION_REGEX = /^v[0-9]+$/;
307
307
  var SLUG_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
308
308
  var InvalidConnectionUrlError = class _InvalidConnectionUrlError extends Error {
@@ -319,7 +319,7 @@ function fail(reason) {
319
319
  function parseCredential(raw) {
320
320
  const match = CREDENTIAL_REGEX.exec(raw);
321
321
  if (!match) {
322
- fail(`credential must match (pk|sk)_(dev|stg|prod|prev)_[a-f0-9]{32,64}, got "${raw}"`);
322
+ fail(`credential must match (pk|sk)_(dev|stg|prod|prev)(_{ref})?_{hex}, got "${raw}"`);
323
323
  }
324
324
  return {
325
325
  credentialType: match[1],
@@ -414,12 +414,6 @@ function secretKeyFromConnectionUrl(value) {
414
414
  if (!parsed || parsed.credentialType !== "sk") return null;
415
415
  return parsed.credential;
416
416
  }
417
- function platformUrlFromLegacyKey(secretKey) {
418
- if (!secretKey) return null;
419
- const parts = secretKey.trim().toLowerCase().split("_");
420
- const embeddedRef = parts.length === 4 ? parts[2] : null;
421
- return embeddedRef ? `https://${embeddedRef}.${DEFAULT_SDK_API_HOST}` : null;
422
- }
423
417
  function resolveNextjsPlatformUrl(options) {
424
418
  if (options.explicitPlatformUrl) return normalizePlatformUrl(options.explicitPlatformUrl);
425
419
  const fromExplicitSecretUrl = platformUrlFromConnectionUrl(options.secretUrl);
@@ -430,8 +424,6 @@ function resolveNextjsPlatformUrl(options) {
430
424
  if (fromConnectionUrl) return fromConnectionUrl;
431
425
  const fromBaasOverride = process.env.SYLPHX_BAAS_URL ?? process.env.SYLPHX_RUNTIME_URL;
432
426
  if (fromBaasOverride) return normalizePlatformUrl(fromBaasOverride);
433
- const fromLegacyKey = platformUrlFromLegacyKey(options.secretKey);
434
- if (fromLegacyKey) return fromLegacyKey;
435
427
  throw new SylphxRoutingConfigurationError(
436
428
  '[Sylphx] BaaS routing target is required for @sylphx/sdk/nextjs. Set SYLPHX_SECRET_URL="sylphx://sk_<env>_<hex>@<tenant>.api.sylphx.com" or SYLPHX_URL="sylphx://<credential>@<tenant>.api.sylphx.com" or pass platformUrl explicitly. New-format sk_* credentials do not carry routing material.'
437
429
  );
@@ -444,7 +436,7 @@ function resolveNextjsSecretKey(options) {
444
436
  if (fromSecretUrl) return fromSecretUrl;
445
437
  const fromGenericUrl = secretKeyFromConnectionUrl(process.env[ENV_URL]);
446
438
  if (fromGenericUrl) return fromGenericUrl;
447
- return process.env[ENV_SECRET_KEY]?.trim() || null;
439
+ return null;
448
440
  }
449
441
 
450
442
  // src/nextjs/middleware.ts
@@ -457,7 +449,8 @@ function decodeJwtPayload(token) {
457
449
  if (parts.length !== 3) return null;
458
450
  const payload = parts[1];
459
451
  const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
460
- const jsonPayload = atob(base64);
452
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
453
+ const jsonPayload = atob(padded);
461
454
  return JSON.parse(jsonPayload);
462
455
  } catch {
463
456
  return null;
@@ -571,6 +564,99 @@ function handleToken(request, ctx) {
571
564
  ctx.log("Token returned");
572
565
  return NextResponse.json({ accessToken: sessionToken });
573
566
  }
567
+ function resolveOrgScopedTokenExpiresIn(data) {
568
+ if (typeof data.expiresIn === "number") return data.expiresIn;
569
+ if (typeof data.expires_in === "number") return data.expires_in;
570
+ return SESSION_TOKEN_LIFETIME;
571
+ }
572
+ function resolveOrgScopedTokenType(data) {
573
+ if (data.tokenType) return data.tokenType;
574
+ if (data.token_type) return data.token_type;
575
+ return "Bearer";
576
+ }
577
+ function parseUserCookie2(value) {
578
+ if (!value) return null;
579
+ try {
580
+ return JSON.parse(value);
581
+ } catch {
582
+ return null;
583
+ }
584
+ }
585
+ async function handleSwitchOrg(request, ctx) {
586
+ ctx.log("Switch org request");
587
+ if (request.method !== "POST") {
588
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
589
+ }
590
+ const sessionToken = request.cookies.get(ctx.cookieNames.SESSION)?.value;
591
+ if (!sessionToken || isTokenExpired(sessionToken)) {
592
+ return NextResponse.json({ error: "Not authenticated", accessToken: null }, { status: 401 });
593
+ }
594
+ let orgId = null;
595
+ try {
596
+ const body = await request.json();
597
+ orgId = typeof body.orgId === "string" && body.orgId.trim() ? body.orgId.trim() : null;
598
+ } catch {
599
+ return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
600
+ }
601
+ if (!orgId) {
602
+ return NextResponse.json({ error: "orgId is required" }, { status: 400 });
603
+ }
604
+ try {
605
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/switch-org`, {
606
+ method: "POST",
607
+ headers: {
608
+ "Content-Type": "application/json",
609
+ "x-app-secret": ctx.secretKey,
610
+ Authorization: `Bearer ${sessionToken}`
611
+ },
612
+ body: JSON.stringify({ orgId })
613
+ });
614
+ const data = await res.json().catch(() => ({}));
615
+ if (!res.ok) {
616
+ return NextResponse.json(
617
+ { error: data.error || data.message || "switch_org_failed" },
618
+ { status: res.status }
619
+ );
620
+ }
621
+ const accessToken = data.accessToken ?? data.access_token;
622
+ if (!accessToken) {
623
+ return NextResponse.json({ error: "invalid_response" }, { status: 502 });
624
+ }
625
+ const expiresIn = resolveOrgScopedTokenExpiresIn(data);
626
+ const expiresAt = Date.now() + expiresIn * 1e3;
627
+ const response = NextResponse.json({
628
+ accessToken,
629
+ token: accessToken,
630
+ expiresIn,
631
+ tokenType: resolveOrgScopedTokenType(data),
632
+ user: data.user ?? null
633
+ });
634
+ response.cookies.set(ctx.cookieNames.SESSION, accessToken, {
635
+ ...SECURE_COOKIE_OPTIONS,
636
+ maxAge: expiresIn
637
+ });
638
+ const existingUser = parseUserCookie2(request.cookies.get(ctx.cookieNames.USER)?.value);
639
+ const user = data.user ?? existingUser?.user;
640
+ if (user) {
641
+ response.cookies.set(
642
+ ctx.cookieNames.USER,
643
+ JSON.stringify({
644
+ user,
645
+ expiresAt
646
+ }),
647
+ {
648
+ ...USER_COOKIE_OPTIONS,
649
+ maxAge: expiresIn
650
+ }
651
+ );
652
+ }
653
+ ctx.log("Switch org success", { orgId });
654
+ return response;
655
+ } catch (err) {
656
+ ctx.log("Switch org error", err);
657
+ return NextResponse.json({ error: "internal_error" }, { status: 500 });
658
+ }
659
+ }
574
660
  async function refreshTokens(refreshToken, ctx) {
575
661
  ctx.log("Refreshing tokens");
576
662
  try {
@@ -609,7 +695,7 @@ function createSylphxMiddleware(userConfig = {}) {
609
695
  });
610
696
  if (!rawSecretKey) {
611
697
  throw new Error(
612
- "[Sylphx] Server connection URL is required.\nEither pass secretUrl in config or set SYLPHX_SECRET_URL env var.\nLegacy apps may pass secretKey or set SYLPHX_SECRET_KEY during migration."
698
+ "[Sylphx] Server connection URL is required.\nEither pass secretUrl in config or set SYLPHX_SECRET_URL env var."
613
699
  );
614
700
  }
615
701
  secretKey = validateAndSanitizeSecretKey(rawSecretKey);
@@ -636,6 +722,8 @@ function createSylphxMiddleware(userConfig = {}) {
636
722
  afterSignInUrl: userConfig.afterSignInUrl ?? "/dashboard",
637
723
  authPrefix: userConfig.authPrefix ?? "/auth",
638
724
  debug: userConfig.debug ?? false,
725
+ orgRequired: userConfig.orgRequired ?? false,
726
+ orgSelectionUrl: userConfig.orgSelectionUrl ?? "/select-organization",
639
727
  onResponse: userConfig.onResponse
640
728
  };
641
729
  const publicRoutes = [
@@ -684,20 +772,26 @@ function createSylphxMiddleware(userConfig = {}) {
684
772
  if (pathname === `${config.authPrefix}/token`) {
685
773
  return handleToken(request, ctx);
686
774
  }
775
+ if (pathname === `${config.authPrefix}/switch-org`) {
776
+ return handleSwitchOrg(request, ctx);
777
+ }
687
778
  const sessionToken = request.cookies.get(cookieNames.SESSION)?.value;
688
779
  const refreshToken = request.cookies.get(cookieNames.REFRESH)?.value;
689
780
  const hasValidSession = sessionToken && !isTokenExpired(sessionToken);
690
781
  const response = NextResponse.next();
691
782
  let isAuthenticated = hasValidSession;
783
+ let activeSessionToken = hasValidSession ? sessionToken : null;
692
784
  if (!hasValidSession && refreshToken) {
693
785
  log("Session expired, refreshing");
694
786
  const tokens = await refreshTokens(refreshToken, ctx);
695
787
  if (tokens) {
696
788
  setAuthCookiesMiddleware(response, namespace, tokens);
697
789
  isAuthenticated = true;
790
+ activeSessionToken = tokens.accessToken;
698
791
  } else {
699
792
  clearAuthCookiesMiddleware(response, namespace);
700
793
  isAuthenticated = false;
794
+ activeSessionToken = null;
701
795
  }
702
796
  }
703
797
  const isPublic = matchesAny(pathname, publicRoutes);
@@ -711,12 +805,22 @@ function createSylphxMiddleware(userConfig = {}) {
711
805
  log("Redirecting from sign-in to dashboard");
712
806
  return NextResponse.redirect(new URL(config.afterSignInUrl, request.url));
713
807
  }
808
+ if (config.orgRequired && !isPublic && isAuthenticated && activeSessionToken && !matchesPattern(pathname, config.orgSelectionUrl)) {
809
+ const payload = decodeJwtPayload(activeSessionToken);
810
+ if (!payload?.org_id) {
811
+ log("Redirecting to organization selection");
812
+ const url = new URL(config.orgSelectionUrl, request.url);
813
+ url.searchParams.set("redirect_to", pathname);
814
+ return NextResponse.redirect(url);
815
+ }
816
+ }
714
817
  if (config.onResponse) {
715
818
  await config.onResponse(response, request);
716
819
  }
717
820
  return response;
718
821
  };
719
822
  }
823
+ var sylphxMiddleware = createSylphxMiddleware;
720
824
  function createMatcher() {
721
825
  return {
722
826
  matcher: ["/((?!_next|monitoring|.*\\..*).*)", "/"]
@@ -2187,6 +2291,7 @@ export {
2187
2291
  setAuthCookies,
2188
2292
  setAuthCookiesMiddleware,
2189
2293
  signOut,
2294
+ sylphxMiddleware,
2190
2295
  syncAuthToCookies
2191
2296
  };
2192
2297
  //# sourceMappingURL=index.mjs.map