@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 +24 -2
- package/dist/middleware/cors.d.ts +21 -1
- package/dist/middleware/cors.js +15 -4
- package/dist/middleware/rate-limit.d.ts +1 -0
- package/dist/middleware/rate-limit.js +18 -1
- package/dist/types.d.ts +8 -0
- package/package.json +5 -5
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
|
-
|
|
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
|
-
|
|
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
|
}>;
|
package/dist/middleware/cors.js
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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",
|
|
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.
|
|
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.
|
|
134
|
+
"@voyant-travel/db": "^0.117.1",
|
|
135
135
|
"@voyant-travel/types": "^0.109.8",
|
|
136
|
-
"@voyant-travel/utils": "^0.
|
|
137
|
-
"@voyant-travel/workflows": "^0.122.
|
|
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.
|
|
143
|
+
"@voyant-travel/workflows-orchestrator": "^0.122.18"
|
|
144
144
|
},
|
|
145
145
|
"files": [
|
|
146
146
|
"dist"
|