@voyant-travel/hono 0.131.2 → 0.133.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.
package/dist/app.js CHANGED
@@ -377,8 +377,30 @@ export function mountApp(config) {
377
377
  app.use("*", requestId);
378
378
  // Structured logger
379
379
  app.use("*", logger(config.logger));
380
- // CORS (allowlist via env CORS_ALLOWLIST)
381
- app.use("*", cors());
380
+ // CORS (allowlist via env CORS_ALLOWLIST). The customer realm additionally
381
+ // supports per-storefront dynamic CORS when the auth integration provides a
382
+ // `resolveCorsOrigin` authorizer: an operator-configured storefront authorizes
383
+ // its own declared origins (exact + `https://*.host`) for direct cross-origin
384
+ // SPA/native clients, while admin/dash surfaces keep the static allowlist.
385
+ const resolveCorsOrigin = composedAuth?.resolveCorsOrigin;
386
+ app.use("*", resolveCorsOrigin
387
+ ? cors({
388
+ resolveDynamicOrigin: (c) => resolveCorsOrigin({
389
+ request: c.req.raw,
390
+ // The composed app's bindings ARE TBindings at runtime; the shared
391
+ // cors() signature only knows the VoyantBindings base.
392
+ env: c.env,
393
+ ctx: tryGetExecutionCtx(c),
394
+ }),
395
+ isDynamicPath: (pathname) => {
396
+ const p = normalizePathname(pathname, { basePath: config.basePath });
397
+ return (p === "/v1/public" ||
398
+ p.startsWith("/v1/public/") ||
399
+ p === "/auth/customer" ||
400
+ p.startsWith("/auth/customer/"));
401
+ },
402
+ })
403
+ : cors());
382
404
  if (config.securityHeaders !== false) {
383
405
  app.use("*", securityHeaders(config.securityHeaders));
384
406
  }
@@ -1,5 +1,25 @@
1
1
  import type { MiddlewareHandler } from "hono";
2
2
  import type { VoyantBindings } from "../types.js";
3
- export declare function cors(): MiddlewareHandler<{
3
+ /**
4
+ * Resolve the exact origin to echo for a customer-realm request, or `null` to
5
+ * fall back to the static allowlist. Runs before the db middleware, so it owns
6
+ * any db access. Provided by the auth integration (`resolveCorsOrigin`).
7
+ */
8
+ export type DynamicCorsOriginResolver = (c: Parameters<MiddlewareHandler<{
9
+ Bindings: VoyantBindings;
10
+ }>>[0]) => Promise<string | null> | string | null;
11
+ export interface CorsOptions {
12
+ /**
13
+ * Per-storefront dynamic origin authorizer for the customer realm. When it
14
+ * returns an origin, that specific origin is echoed with credentials — never
15
+ * `*`. When it returns `null`, the request falls back to the static
16
+ * `CORS_ALLOWLIST`. Only consulted for {@link CorsOptions.isDynamicPath}
17
+ * matches, so admin/dash surfaces stay on the static allowlist.
18
+ */
19
+ resolveDynamicOrigin?: DynamicCorsOriginResolver;
20
+ /** Whether a pathname is eligible for dynamic per-storefront CORS. */
21
+ isDynamicPath?: (pathname: string) => boolean;
22
+ }
23
+ export declare function cors(options?: CorsOptions): MiddlewareHandler<{
4
24
  Bindings: VoyantBindings;
5
25
  }>;
@@ -51,6 +51,7 @@ const DEFAULT_ALLOWED_REQUEST_HEADERS = new Set([
51
51
  "x-request-id",
52
52
  "x-voyant-checkout-capability",
53
53
  "x-voyant-guest-booking-access",
54
+ "x-voyant-storefront-origin",
54
55
  ]);
55
56
  function allowedRequestHeaders(requested) {
56
57
  if (!requested)
@@ -61,11 +62,21 @@ function allowedRequestHeaders(requested) {
61
62
  .filter((header) => DEFAULT_ALLOWED_REQUEST_HEADERS.has(header));
62
63
  return allowed.length > 0 ? allowed.join(", ") : "content-type, authorization";
63
64
  }
64
- export function cors() {
65
+ export function cors(options = {}) {
66
+ const { resolveDynamicOrigin, isDynamicPath } = options;
65
67
  return async (c, next) => {
66
68
  const origin = c.req.header("origin") || "";
67
69
  const allowlist = compileAllowlist(c.env.CORS_ALLOWLIST);
68
- const allowed = isAllowedOrigin(origin, allowlist);
70
+ // Per-storefront dynamic CORS for the customer realm: an operator-configured
71
+ // storefront authorizes its own declared origins via the resolver, so a
72
+ // direct cross-origin SPA works without the origin sitting in the static env
73
+ // allowlist. The resolver echoes only the specific request origin (never
74
+ // `*`), and preflight is authorized without a key/cookie. A `null` result
75
+ // means "no storefront allows this origin" — fall back to the static list.
76
+ const dynamicEligible = Boolean(origin && resolveDynamicOrigin && (isDynamicPath?.(c.req.path) ?? true));
77
+ const dynamicOrigin = dynamicEligible ? await resolveDynamicOrigin(c) : null;
78
+ const allowed = dynamicOrigin !== null || isAllowedOrigin(origin, allowlist);
79
+ const echoOrigin = dynamicOrigin ?? origin;
69
80
  if (origin && !allowed) {
70
81
  console.warn("[CORS] Origin not in allowlist - CORS headers will NOT be set", {
71
82
  origin,
@@ -76,7 +87,7 @@ export function cors() {
76
87
  }
77
88
  if (c.req.method === "OPTIONS") {
78
89
  if (allowed) {
79
- c.header("Access-Control-Allow-Origin", origin);
90
+ c.header("Access-Control-Allow-Origin", echoOrigin);
80
91
  c.header("Vary", "Origin");
81
92
  c.header("Access-Control-Allow-Credentials", "true");
82
93
  c.header("Access-Control-Allow-Headers", allowedRequestHeaders(c.req.header("access-control-request-headers")));
@@ -86,7 +97,7 @@ export function cors() {
86
97
  }
87
98
  await next();
88
99
  if (allowed) {
89
- c.header("Access-Control-Allow-Origin", origin);
100
+ c.header("Access-Control-Allow-Origin", echoOrigin);
90
101
  c.header("Vary", "Origin");
91
102
  c.header("Access-Control-Allow-Credentials", "true");
92
103
  }
@@ -125,6 +125,7 @@ export declare function createMemoryRateLimitStore(options?: {
125
125
  }): RateLimitStore;
126
126
  export interface RedisRateLimitStoreOptions {
127
127
  client?: LazyRedisClient;
128
+ keyPrefix?: string;
128
129
  }
129
130
  export declare function createRedisRateLimitStore(redisUrl: string, options?: RedisRateLimitStoreOptions): RateLimitStore;
130
131
  /** Test-only: re-arm the once-per-isolate missing-store warning. */
@@ -67,14 +67,31 @@ export function createMemoryRateLimitStore(options) {
67
67
  },
68
68
  };
69
69
  }
70
+ function normalizeRedisRateLimitKeyPrefix(keyPrefix) {
71
+ if (keyPrefix === undefined || keyPrefix.length === 0)
72
+ return "";
73
+ if (hasControlCharacter(keyPrefix)) {
74
+ throw new Error("Redis rate-limit keyPrefix must not contain control characters.");
75
+ }
76
+ return keyPrefix;
77
+ }
78
+ function hasControlCharacter(value) {
79
+ for (let index = 0; index < value.length; index += 1) {
80
+ const code = value.charCodeAt(index);
81
+ if (code <= 0x1f || code === 0x7f)
82
+ return true;
83
+ }
84
+ return false;
85
+ }
70
86
  export function createRedisRateLimitStore(redisUrl, options = {}) {
71
87
  const lazyClient = options.client ?? createLazyRedisClient(redisUrl);
88
+ const keyPrefix = normalizeRedisRateLimitKeyPrefix(options.keyPrefix);
72
89
  return {
73
90
  async limit(key, { max, windowSeconds }) {
74
91
  const client = await lazyClient.get();
75
92
  const nowSeconds = Math.floor(Date.now() / 1000);
76
93
  const windowKey = Math.floor(nowSeconds / windowSeconds);
77
- const storageKey = `${key}:${windowKey}`;
94
+ const storageKey = `${keyPrefix}${key}:${windowKey}`;
78
95
  const count = await client.incr(storageKey);
79
96
  if (count === 1) {
80
97
  await client.expire(storageKey, Math.max(1, windowSeconds * 2));
package/dist/types.d.ts CHANGED
@@ -175,6 +175,14 @@ export interface VoyantAuthIntegration<TBindings extends VoyantBindings = Voyant
175
175
  hasPermission?: (args: VoyantAuthPermissionArgs<TBindings>) => Promise<boolean> | boolean;
176
176
  validateApiKey?: (args: VoyantAuthApiKeyValidationArgs<TBindings>) => Promise<boolean> | boolean;
177
177
  onUnauthorized?: (args: VoyantAuthUnauthorizedArgs<TBindings>) => Promise<Response | null> | Response | null;
178
+ /**
179
+ * Authorize a customer-realm cross-origin request for dynamic CORS. Returns
180
+ * the exact request origin to echo in `Access-Control-Allow-Origin` (dynamic,
181
+ * per-storefront), or `null` to fall back to the static `CORS_ALLOWLIST`.
182
+ * Runs before the db middleware, so implementations own any db access. Only
183
+ * consulted for the customer surface; admin/dash keep the static allowlist.
184
+ */
185
+ resolveCorsOrigin?: (args: VoyantAuthUnauthorizedArgs<TBindings>) => Promise<string | null> | string | null;
178
186
  }
179
187
  export interface VoyantAppConfig<TBindings extends VoyantBindings = VoyantBindings> {
180
188
  db: DbFactory<TBindings>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/hono",
3
- "version": "0.131.2",
3
+ "version": "0.133.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -131,16 +131,16 @@
131
131
  "hono": "^4.12.27",
132
132
  "zod": "^4.4.3",
133
133
  "@voyant-travel/core": "^0.130.0",
134
- "@voyant-travel/db": "^0.117.0",
134
+ "@voyant-travel/db": "^0.117.1",
135
135
  "@voyant-travel/types": "^0.109.8",
136
- "@voyant-travel/utils": "^0.108.0",
137
- "@voyant-travel/workflows": "^0.122.11"
136
+ "@voyant-travel/utils": "^0.109.0",
137
+ "@voyant-travel/workflows": "^0.122.18"
138
138
  },
139
139
  "devDependencies": {
140
140
  "typescript": "^6.0.3",
141
141
  "vitest": "^4.1.9",
142
142
  "@voyant-travel/voyant-typescript-config": "^0.1.0",
143
- "@voyant-travel/workflows-orchestrator": "^0.122.11"
143
+ "@voyant-travel/workflows-orchestrator": "^0.122.18"
144
144
  },
145
145
  "files": [
146
146
  "dist"