@voyant-travel/auth 0.140.3 → 0.141.1

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.
@@ -17,6 +17,14 @@ import type { CustomerBusinessAccountOnboardingRuntimeProvider } from "./custome
17
17
  import { type CustomerBuyerAccountPolicy } from "./customer-buyer-accounts.js";
18
18
  import { type CreateBetterAuthOptions, type CustomerAuthMethods } from "./server.js";
19
19
  type OperatorAuthMode = "local" | "voyant-cloud";
20
+ /**
21
+ * Whether a request targets the customer realm surface eligible for per-storefront
22
+ * dynamic CORS: the public storefront API (`/v1/public` + `/v1/public/*`) and the
23
+ * customer auth routes (`/auth/customer` + `/auth/customer/*`) a direct client
24
+ * hits to sign in. Tolerates an optional `/api` host prefix. Admin/dash surfaces
25
+ * are intentionally excluded — they keep the static `CORS_ALLOWLIST` behavior.
26
+ */
27
+ export declare function isCustomerCorsSurface(requestUrl: string): boolean;
20
28
  export interface OperatorAuthNodeEnv extends NodeDatabaseEnv {
21
29
  API_BASE_URL?: string;
22
30
  APP_URL?: string;
@@ -114,6 +122,15 @@ export interface CreateOperatorAuthNodeRuntimeOptions<Env extends OperatorAuthNo
114
122
  * merchant credentials. Never derive these values from Host/X-Forwarded-Host.
115
123
  */
116
124
  resolveCustomerAuthContext?: (env: Env, request: Request) => CustomerAuthRuntimeContext | Promise<CustomerAuthRuntimeContext>;
125
+ /**
126
+ * Request-time dynamic-CORS origin authorizer for the customer realm
127
+ * (`/v1/public/*` + `/auth/customer/*`). Returns the exact request origin to
128
+ * echo in `Access-Control-Allow-Origin` when a storefront authorizes it, or
129
+ * `null` to omit CORS headers. Authorizes keyed requests by the presented
130
+ * storefront key and keyless preflight by the declared allowed origins. When
131
+ * unset, the customer realm keeps the static `CORS_ALLOWLIST` behavior.
132
+ */
133
+ resolveCustomerCorsOrigin?: (env: Env, request: Request) => Promise<string | null>;
117
134
  }
118
135
  export interface CustomerAuthRuntimeContext {
119
136
  /** Browser-visible proxy base used by storefront/BFF auth callbacks. */
@@ -123,6 +140,12 @@ export interface CustomerAuthRuntimeContext {
123
140
  /** Explicit trusted storefront origin used for customer invitation links and request audit. */
124
141
  invitationAcceptBaseURL?: string;
125
142
  trustedOrigins: string[];
143
+ /**
144
+ * Full declared browser origins for the resolved storefront (exact +
145
+ * `https://*.host` wildcard). Carried for dynamic CORS; `trustedOrigins` is
146
+ * the canonical-origin subset Better Auth validates.
147
+ */
148
+ allowedOrigins?: string[];
126
149
  methods: CustomerAuthMethods;
127
150
  /** Buyer capabilities are independent from identity sign-up methods. */
128
151
  accountPolicy?: CustomerBuyerAccountPolicy | null;
@@ -164,6 +187,7 @@ export declare function createOperatorAuthNodeRuntime<Env extends OperatorAuthNo
164
187
  getCurrentUserForRequest: (request: Request, env: Env) => Promise<OperatorCurrentUser | null>;
165
188
  hasAuthPermission: (request: Request, env: Env) => Promise<boolean>;
166
189
  resolveAuthRequest: (request: Request, env: Env) => Promise<VoyantResolvedSessionAuthContext | null>;
190
+ resolveCustomerCorsOrigin: (request: Request, env: Env) => Promise<string | null>;
167
191
  validateApiTokenAccess: (env: Env, db: VoyantDb, apiKey: SelectApikey) => Promise<boolean>;
168
192
  };
169
193
  export {};
@@ -34,6 +34,21 @@ function classifyOperatorApiAuthSurface(requestUrl) {
34
34
  return "customer";
35
35
  return null;
36
36
  }
37
+ /**
38
+ * Whether a request targets the customer realm surface eligible for per-storefront
39
+ * dynamic CORS: the public storefront API (`/v1/public` + `/v1/public/*`) and the
40
+ * customer auth routes (`/auth/customer` + `/auth/customer/*`) a direct client
41
+ * hits to sign in. Tolerates an optional `/api` host prefix. Admin/dash surfaces
42
+ * are intentionally excluded — they keep the static `CORS_ALLOWLIST` behavior.
43
+ */
44
+ export function isCustomerCorsSurface(requestUrl) {
45
+ const pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
46
+ const normalized = pathname.startsWith("/api/") ? pathname.slice(4) : pathname;
47
+ return (normalized === "/v1/public" ||
48
+ normalized.startsWith("/v1/public/") ||
49
+ normalized === "/auth/customer" ||
50
+ normalized.startsWith("/auth/customer/"));
51
+ }
37
52
  /** Resolve Better Auth's standard cross-subdomain cookie policy for Node hosts. */
38
53
  export function buildBetterAuthCookieAdvancedOptions(env) {
39
54
  const domain = env.AUTH_COOKIE_DOMAIN?.trim();
@@ -419,7 +434,14 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
419
434
  : defaultCustomerAuthContext(env, parseCustomerAuthPolicy(env));
420
435
  const baseURL = requireCanonicalOrigin(context.baseURL, "customer auth baseURL");
421
436
  const publicApiBaseURL = requirePublicApiBaseUrl(context.publicApiBaseURL ?? `${baseURL}/api`, baseURL);
422
- const trustedOrigins = context.trustedOrigins.map((origin) => requireCanonicalOrigin(origin, "customer auth trusted origin"));
437
+ // Fold the resolved storefront's trusted origins together with the static
438
+ // env allowlist so a cross-origin customer-auth call from any allowed
439
+ // storefront origin is trusted by Better Auth (WORK: direct-client support).
440
+ // Wildcard (`https://*.host`) entries pass through untouched — Better Auth
441
+ // matches them natively; every other entry must be a canonical origin.
442
+ const trustedOrigins = [...new Set([...context.trustedOrigins, ...getTrustedOrigins(env)])].map((origin) => isCustomerWildcardOrigin(origin)
443
+ ? origin
444
+ : requireCanonicalOrigin(origin, "customer auth trusted origin"));
423
445
  const invitationAcceptBaseURL = context.invitationAcceptBaseURL
424
446
  ? requireCanonicalOrigin(context.invitationAcceptBaseURL, "customer invitation accept baseURL")
425
447
  : undefined;
@@ -431,6 +453,13 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
431
453
  ...(invitationAcceptBaseURL ? { invitationAcceptBaseURL } : {}),
432
454
  };
433
455
  }
456
+ /** A single-label `https://*.host` wildcard trusted-origin declaration. */
457
+ function isCustomerWildcardOrigin(value) {
458
+ if (!value.startsWith("https://*."))
459
+ return false;
460
+ const host = value.slice("https://*.".length);
461
+ return host.length > 0 && !host.includes("*") && !host.includes("/") && !host.includes(":");
462
+ }
434
463
  function requireCanonicalOrigin(value, label) {
435
464
  let parsed;
436
465
  try {
@@ -647,9 +676,22 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
647
676
  }
648
677
  const { db, dispose } = openDatabase(env);
649
678
  try {
650
- const customerContext = customerSurface
651
- ? await resolveCustomerAuthContext(env, request)
652
- : null;
679
+ // A storefront customer-auth resolution failure on `/v1/public/*` (missing/
680
+ // unknown/revoked key or a disallowed origin) is a client-side auth failure,
681
+ // not a server fault: resolve it to "unauthorized" (null) so the framework
682
+ // returns 401 instead of letting it surface as a 500. Genuine faults still
683
+ // throw. Mirrors the auth handler's onError mapping for `/auth/customer/*`.
684
+ let customerContext = null;
685
+ if (customerSurface) {
686
+ try {
687
+ customerContext = await resolveCustomerAuthContext(env, request);
688
+ }
689
+ catch (error) {
690
+ if (error instanceof StorefrontCustomerAuthResolutionError)
691
+ return null;
692
+ throw error;
693
+ }
694
+ }
653
695
  const auth = customerSurface
654
696
  ? await buildCustomerBetterAuth(env, db, request, customerContext ?? undefined)
655
697
  : buildAdminBetterAuth(env, db);
@@ -753,6 +795,23 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
753
795
  const auth = await resolveAuthRequest(request, env);
754
796
  return auth !== null;
755
797
  }
798
+ /**
799
+ * Authorize a customer-realm cross-origin request for dynamic CORS. Returns
800
+ * the exact request origin to echo in `Access-Control-Allow-Origin`, or `null`
801
+ * to omit CORS headers. Only the customer surface (`/v1/public/*` and
802
+ * `/auth/customer/*`) is dynamically authorized; every other surface keeps the
803
+ * static `CORS_ALLOWLIST` behavior. The customer realm being disabled, or no
804
+ * authorizer being wired, yields `null` (static behavior).
805
+ */
806
+ async function resolveCustomerCorsOrigin(request, env) {
807
+ if (!runtimeOptions.resolveCustomerCorsOrigin)
808
+ return null;
809
+ if (env.VOYANT_CUSTOMER_AUTH_MODE?.trim() === "disabled")
810
+ return null;
811
+ if (!isCustomerCorsSurface(request.url))
812
+ return null;
813
+ return runtimeOptions.resolveCustomerCorsOrigin(env, request);
814
+ }
756
815
  class CurrentUserNotFoundError extends Error {
757
816
  constructor() {
758
817
  super("User not found");
@@ -1429,6 +1488,7 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
1429
1488
  getCurrentUserForRequest,
1430
1489
  hasAuthPermission,
1431
1490
  resolveAuthRequest,
1491
+ resolveCustomerCorsOrigin,
1432
1492
  validateApiTokenAccess,
1433
1493
  };
1434
1494
  }