@voyant-travel/auth 0.140.2 → 0.141.0

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 {};
@@ -23,6 +23,7 @@ import { customerBusinessAccountCreateInputSchema, customerBusinessAccountReques
23
23
  import { CustomerBusinessOnboardingConflictError, CustomerBusinessOnboardingNotFoundError, } from "./customer-business-onboarding-service.js";
24
24
  import { createDrizzleCustomerBuyerAccountStore, listCustomerBuyerAccounts, normalizeCustomerBuyerAccountPolicy, repairCustomerPersonalBuyerAccountEntitlement, resolveActiveCustomerBuyerContext, selectCustomerBuyerAccount, } from "./customer-buyer-accounts.js";
25
25
  import { createAdminBetterAuth, createCustomerBetterAuth, handleApiTokenManagementRequest, handleOrganizationMembersRequest, } from "./server.js";
26
+ import { StorefrontCustomerAuthResolutionError } from "./storefront-customer-auth-resolver.js";
26
27
  import { ensureCurrentUserProfile } from "./workspace.js";
27
28
  function classifyOperatorApiAuthSurface(requestUrl) {
28
29
  const pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
@@ -33,6 +34,21 @@ function classifyOperatorApiAuthSurface(requestUrl) {
33
34
  return "customer";
34
35
  return null;
35
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
+ }
36
52
  /** Resolve Better Auth's standard cross-subdomain cookie policy for Node hosts. */
37
53
  export function buildBetterAuthCookieAdvancedOptions(env) {
38
54
  const domain = env.AUTH_COOKIE_DOMAIN?.trim();
@@ -147,7 +163,21 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
147
163
  });
148
164
  }
149
165
  });
150
- auth.onError((err, c) => handleApiError(err, c, { reporter: runtimeOptions.reporter, appName: runtimeOptions.appName }));
166
+ auth.onError((err, c) => {
167
+ // A storefront customer-auth resolution failure is a client-side auth
168
+ // failure (missing/invalid storefront key or a disallowed origin), not a
169
+ // server fault. Translate it into a clean 401/403 with a stable, non-leaky
170
+ // body instead of letting it fall through to `handleApiError`'s 500. The
171
+ // catch is narrowed to this error type so genuine server faults still 500.
172
+ if (err instanceof StorefrontCustomerAuthResolutionError) {
173
+ c.header("Cache-Control", "no-store");
174
+ return c.json({ error: err.code }, err.status);
175
+ }
176
+ return handleApiError(err, c, {
177
+ reporter: runtimeOptions.reporter,
178
+ appName: runtimeOptions.appName,
179
+ });
180
+ });
151
181
  const DEFAULT_APP_URL = "http://localhost:3300";
152
182
  const CLOUD_BETTER_AUTH_ALLOWLIST = new Set([
153
183
  "/auth/get-session",
@@ -404,7 +434,14 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
404
434
  : defaultCustomerAuthContext(env, parseCustomerAuthPolicy(env));
405
435
  const baseURL = requireCanonicalOrigin(context.baseURL, "customer auth baseURL");
406
436
  const publicApiBaseURL = requirePublicApiBaseUrl(context.publicApiBaseURL ?? `${baseURL}/api`, baseURL);
407
- 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"));
408
445
  const invitationAcceptBaseURL = context.invitationAcceptBaseURL
409
446
  ? requireCanonicalOrigin(context.invitationAcceptBaseURL, "customer invitation accept baseURL")
410
447
  : undefined;
@@ -416,6 +453,13 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
416
453
  ...(invitationAcceptBaseURL ? { invitationAcceptBaseURL } : {}),
417
454
  };
418
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
+ }
419
463
  function requireCanonicalOrigin(value, label) {
420
464
  let parsed;
421
465
  try {
@@ -632,9 +676,22 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
632
676
  }
633
677
  const { db, dispose } = openDatabase(env);
634
678
  try {
635
- const customerContext = customerSurface
636
- ? await resolveCustomerAuthContext(env, request)
637
- : 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
+ }
638
695
  const auth = customerSurface
639
696
  ? await buildCustomerBetterAuth(env, db, request, customerContext ?? undefined)
640
697
  : buildAdminBetterAuth(env, db);
@@ -738,6 +795,23 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
738
795
  const auth = await resolveAuthRequest(request, env);
739
796
  return auth !== null;
740
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
+ }
741
815
  class CurrentUserNotFoundError extends Error {
742
816
  constructor() {
743
817
  super("User not found");
@@ -1414,6 +1488,7 @@ export function createOperatorAuthNodeRuntime(runtimeOptions) {
1414
1488
  getCurrentUserForRequest,
1415
1489
  hasAuthPermission,
1416
1490
  resolveAuthRequest,
1491
+ resolveCustomerCorsOrigin,
1417
1492
  validateApiTokenAccess,
1418
1493
  };
1419
1494
  }