@voyant-travel/hono 0.109.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.
- package/LICENSE +201 -0
- package/README.md +58 -0
- package/dist/app-workflows.d.ts +31 -0
- package/dist/app-workflows.d.ts.map +1 -0
- package/dist/app-workflows.js +110 -0
- package/dist/app.d.ts +45 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +403 -0
- package/dist/auth/crypto.d.ts +16 -0
- package/dist/auth/crypto.d.ts.map +1 -0
- package/dist/auth/crypto.js +66 -0
- package/dist/auth/index.d.ts +5 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth/require-user.d.ts +3 -0
- package/dist/auth/require-user.d.ts.map +1 -0
- package/dist/auth/require-user.js +8 -0
- package/dist/auth/session-jwt.d.ts +7 -0
- package/dist/auth/session-jwt.d.ts.map +1 -0
- package/dist/auth/session-jwt.js +23 -0
- package/dist/composition.d.ts +67 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +46 -0
- package/dist/document-download.d.ts +30 -0
- package/dist/document-download.d.ts.map +1 -0
- package/dist/document-download.js +102 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/lib/db-selector.d.ts +24 -0
- package/dist/lib/db-selector.d.ts.map +1 -0
- package/dist/lib/db-selector.js +28 -0
- package/dist/lib/execution-ctx.d.ts +16 -0
- package/dist/lib/execution-ctx.d.ts.map +1 -0
- package/dist/lib/execution-ctx.js +16 -0
- package/dist/lib/public-paths.d.ts +19 -0
- package/dist/lib/public-paths.d.ts.map +1 -0
- package/dist/lib/public-paths.js +27 -0
- package/dist/lib/request-event-bus.d.ts +21 -0
- package/dist/lib/request-event-bus.d.ts.map +1 -0
- package/dist/lib/request-event-bus.js +43 -0
- package/dist/middleware/auth.d.ts +10 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +280 -0
- package/dist/middleware/body-size.d.ts +7 -0
- package/dist/middleware/body-size.d.ts.map +1 -0
- package/dist/middleware/body-size.js +20 -0
- package/dist/middleware/cors.d.ts +6 -0
- package/dist/middleware/cors.d.ts.map +1 -0
- package/dist/middleware/cors.js +94 -0
- package/dist/middleware/db.d.ts +43 -0
- package/dist/middleware/db.d.ts.map +1 -0
- package/dist/middleware/db.js +78 -0
- package/dist/middleware/error-boundary.d.ts +5 -0
- package/dist/middleware/error-boundary.d.ts.map +1 -0
- package/dist/middleware/error-boundary.js +76 -0
- package/dist/middleware/idempotency-key.d.ts +97 -0
- package/dist/middleware/idempotency-key.d.ts.map +1 -0
- package/dist/middleware/idempotency-key.js +235 -0
- package/dist/middleware/index.d.ts +14 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +13 -0
- package/dist/middleware/logger.d.ts +5 -0
- package/dist/middleware/logger.d.ts.map +1 -0
- package/dist/middleware/logger.js +27 -0
- package/dist/middleware/metrics.d.ts +55 -0
- package/dist/middleware/metrics.d.ts.map +1 -0
- package/dist/middleware/metrics.js +94 -0
- package/dist/middleware/public-cache.d.ts +44 -0
- package/dist/middleware/public-cache.d.ts.map +1 -0
- package/dist/middleware/public-cache.js +205 -0
- package/dist/middleware/rate-limit.d.ts +214 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +240 -0
- package/dist/middleware/request-db.d.ts +42 -0
- package/dist/middleware/request-db.d.ts.map +1 -0
- package/dist/middleware/request-db.js +62 -0
- package/dist/middleware/require-actor.d.ts +28 -0
- package/dist/middleware/require-actor.d.ts.map +1 -0
- package/dist/middleware/require-actor.js +89 -0
- package/dist/middleware/require-permission.d.ts +9 -0
- package/dist/middleware/require-permission.d.ts.map +1 -0
- package/dist/middleware/require-permission.js +62 -0
- package/dist/middleware/security-headers.d.ts +10 -0
- package/dist/middleware/security-headers.d.ts.map +1 -0
- package/dist/middleware/security-headers.js +19 -0
- package/dist/module.d.ts +41 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +1 -0
- package/dist/plugin.d.ts +66 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +37 -0
- package/dist/public-capability.d.ts +46 -0
- package/dist/public-capability.d.ts.map +1 -0
- package/dist/public-capability.js +140 -0
- package/dist/public-document-delivery.d.ts +111 -0
- package/dist/public-document-delivery.d.ts.map +1 -0
- package/dist/public-document-delivery.js +234 -0
- package/dist/types.d.ts +318 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/validation.d.ts +36 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +106 -0
- package/package.json +156 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { type DbFactory, type VoyantBindings, type VoyantDb, type VoyantVariables } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Twenty-four hours, in milliseconds. Default TTL for stored idempotency
|
|
5
|
+
* keys. Tunable per middleware instance.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_IDEMPOTENCY_TTL_MS: number;
|
|
8
|
+
export interface IdempotencyKeyOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Namespacing label so unrelated endpoints can safely accept overlapping
|
|
11
|
+
* client keys. Defaults to the request method + pathname when omitted.
|
|
12
|
+
*/
|
|
13
|
+
scope?: string;
|
|
14
|
+
/**
|
|
15
|
+
* How long stored responses are replayable. Defaults to
|
|
16
|
+
* {@link DEFAULT_IDEMPOTENCY_TTL_MS}.
|
|
17
|
+
*/
|
|
18
|
+
ttlMs?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Whether the header is required. When `true`, requests without the header
|
|
21
|
+
* are rejected with 400. Defaults to `false` so existing clients keep
|
|
22
|
+
* working through a deprecation window.
|
|
23
|
+
*/
|
|
24
|
+
required?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Query parameters that affect the response contract and should be included
|
|
27
|
+
* in the idempotency fingerprint alongside the request body. Reusing the
|
|
28
|
+
* same key with different fingerprinted query values returns 409 instead of
|
|
29
|
+
* replaying a response captured under different request semantics.
|
|
30
|
+
*/
|
|
31
|
+
fingerprintSearchParams?: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* Optional callback that, given the response body that's about to be
|
|
34
|
+
* stored, returns a `referenceId` (typically the booking id). Used for
|
|
35
|
+
* operational queries to correlate replays with the underlying entity.
|
|
36
|
+
*/
|
|
37
|
+
extractReferenceId?: (body: unknown) => string | null | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Whether successful JSON responses are replayable. Disable for
|
|
40
|
+
* endpoints whose response carries session-bound bearer secrets.
|
|
41
|
+
*/
|
|
42
|
+
replayResponses?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Include the caller/session/IP in the `(scope, key)` namespace.
|
|
45
|
+
* Enabled by default so client-chosen keys cannot replay another
|
|
46
|
+
* caller's response on anonymous or shared public surfaces.
|
|
47
|
+
*/
|
|
48
|
+
scopeWithCaller?: boolean;
|
|
49
|
+
/** Maximum request body bytes the middleware may buffer. Defaults to 10 MiB. */
|
|
50
|
+
maxBodyBytes?: number;
|
|
51
|
+
}
|
|
52
|
+
interface ContextWithIdempotency {
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
idempotencyReplayed?: boolean;
|
|
55
|
+
}
|
|
56
|
+
declare module "hono" {
|
|
57
|
+
interface ContextVariableMap extends ContextWithIdempotency {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Idempotency-Key middleware.
|
|
62
|
+
*
|
|
63
|
+
* On request:
|
|
64
|
+
* - reads the `Idempotency-Key` header
|
|
65
|
+
* - if absent and `required` is false, passes through
|
|
66
|
+
* - if absent and `required` is true, returns 400
|
|
67
|
+
* - looks up `(scope, key)` in `idempotency_keys`:
|
|
68
|
+
* - hit + same body hash → returns the stored response (replay)
|
|
69
|
+
* - hit + different body hash → returns 409 (conflict)
|
|
70
|
+
* - miss → runs the handler, then stores the response on the way out
|
|
71
|
+
*
|
|
72
|
+
* The handler's response body is captured by cloning the response. The
|
|
73
|
+
* `Idempotency-Key` and a `Idempotency-Replayed: true` header are echoed
|
|
74
|
+
* on replay so callers can detect replays in client-side logging.
|
|
75
|
+
*
|
|
76
|
+
* The middleware reads the `db` instance off the request context (set by
|
|
77
|
+
* the `db` middleware that ships with `createApp`) — caller is responsible
|
|
78
|
+
* for ordering this middleware after `db`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function idempotencyKey<TBindings extends object = VoyantBindings, TVariables extends {
|
|
81
|
+
db: VoyantDb;
|
|
82
|
+
} = VoyantVariables>(options?: IdempotencyKeyOptions): MiddlewareHandler<{
|
|
83
|
+
Bindings: TBindings;
|
|
84
|
+
Variables: TVariables;
|
|
85
|
+
}>;
|
|
86
|
+
/**
|
|
87
|
+
* Sweep expired idempotency rows. Call from a daily cron.
|
|
88
|
+
*
|
|
89
|
+
* If `dbFactory` returns a `DisposableDb` (e.g. a per-call Neon
|
|
90
|
+
* WebSocket Pool), the sweep awaits `dispose()` before returning so
|
|
91
|
+
* the connection closes cleanly inside the cron handler.
|
|
92
|
+
*/
|
|
93
|
+
export declare function purgeExpiredIdempotencyKeys<TBindings extends VoyantBindings>(dbFactory: DbFactory<TBindings>, env: TBindings): Promise<{
|
|
94
|
+
removed: number;
|
|
95
|
+
}>;
|
|
96
|
+
export {};
|
|
97
|
+
//# sourceMappingURL=idempotency-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency-key.d.ts","sourceRoot":"","sources":["../../src/middleware/idempotency-key.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAC7C,OAAO,EACL,KAAK,SAAS,EAEd,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,eAAe,EACrB,MAAM,aAAa,CAAA;AAKpB;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAsB,CAAA;AAmB7D,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3C;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;IACjE;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,UAAU,sBAAsB;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AAED,OAAO,QAAQ,MAAM,CAAC;IACpB,UAAU,kBAAmB,SAAQ,sBAAsB;KAAG;CAC/D;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAC5B,SAAS,SAAS,MAAM,GAAG,cAAc,EACzC,UAAU,SAAS;IAAE,EAAE,EAAE,QAAQ,CAAA;CAAE,GAAG,eAAe,EAErD,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CAAC;IACnB,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE,UAAU,CAAA;CACtB,CAAC,CA4ID;AAwED;;;;;;GAMG;AACH,wBAAsB,2BAA2B,CAAC,SAAS,SAAS,cAAc,EAChF,SAAS,EAAE,SAAS,CAAC,SAAS,CAAC,EAC/B,GAAG,EAAE,SAAS,GACb,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAW9B"}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { infraIdempotencyKeysTable } from "@voyant-travel/db/schema/infra";
|
|
2
|
+
import { and, eq, lt } from "drizzle-orm";
|
|
3
|
+
import { resolveDbFactoryResult, } from "../types.js";
|
|
4
|
+
import { RequestValidationError } from "../validation.js";
|
|
5
|
+
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "./body-size.js";
|
|
6
|
+
import { clientIpKey } from "./rate-limit.js";
|
|
7
|
+
/**
|
|
8
|
+
* Twenty-four hours, in milliseconds. Default TTL for stored idempotency
|
|
9
|
+
* keys. Tunable per middleware instance.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const HEADER_NAME = "Idempotency-Key";
|
|
13
|
+
/**
|
|
14
|
+
* Hashes a UTF-8 string with SHA-256 and returns the hex digest. Uses Web
|
|
15
|
+
* Crypto so the middleware works in Cloudflare Workers and Node.
|
|
16
|
+
*/
|
|
17
|
+
async function sha256Hex(value) {
|
|
18
|
+
const data = new TextEncoder().encode(value);
|
|
19
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
20
|
+
const bytes = new Uint8Array(digest);
|
|
21
|
+
let out = "";
|
|
22
|
+
for (const byte of bytes) {
|
|
23
|
+
out += byte.toString(16).padStart(2, "0");
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Idempotency-Key middleware.
|
|
29
|
+
*
|
|
30
|
+
* On request:
|
|
31
|
+
* - reads the `Idempotency-Key` header
|
|
32
|
+
* - if absent and `required` is false, passes through
|
|
33
|
+
* - if absent and `required` is true, returns 400
|
|
34
|
+
* - looks up `(scope, key)` in `idempotency_keys`:
|
|
35
|
+
* - hit + same body hash → returns the stored response (replay)
|
|
36
|
+
* - hit + different body hash → returns 409 (conflict)
|
|
37
|
+
* - miss → runs the handler, then stores the response on the way out
|
|
38
|
+
*
|
|
39
|
+
* The handler's response body is captured by cloning the response. The
|
|
40
|
+
* `Idempotency-Key` and a `Idempotency-Replayed: true` header are echoed
|
|
41
|
+
* on replay so callers can detect replays in client-side logging.
|
|
42
|
+
*
|
|
43
|
+
* The middleware reads the `db` instance off the request context (set by
|
|
44
|
+
* the `db` middleware that ships with `createApp`) — caller is responsible
|
|
45
|
+
* for ordering this middleware after `db`.
|
|
46
|
+
*/
|
|
47
|
+
export function idempotencyKey(options = {}) {
|
|
48
|
+
const ttlMs = options.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS;
|
|
49
|
+
return async (c, next) => {
|
|
50
|
+
if (c.req.method === "OPTIONS")
|
|
51
|
+
return next();
|
|
52
|
+
const headerKey = c.req.header(HEADER_NAME) ?? c.req.header(HEADER_NAME.toLowerCase());
|
|
53
|
+
if (!headerKey) {
|
|
54
|
+
if (options.required) {
|
|
55
|
+
return c.json({ error: `${HEADER_NAME} header is required for this endpoint` }, 400);
|
|
56
|
+
}
|
|
57
|
+
return next();
|
|
58
|
+
}
|
|
59
|
+
if (headerKey.length > 255) {
|
|
60
|
+
return c.json({ error: `${HEADER_NAME} must be 255 characters or fewer` }, 400);
|
|
61
|
+
}
|
|
62
|
+
const requestUrl = new URL(c.req.url);
|
|
63
|
+
const baseScope = options.scope ?? `${c.req.method} ${requestUrl.pathname}`;
|
|
64
|
+
const scope = options.scopeWithCaller === false ? baseScope : buildCallerScopedKey(c, baseScope);
|
|
65
|
+
const rawBody = await readBoundedBody(c, options.maxBodyBytes ?? DEFAULT_REQUEST_BODY_LIMIT_BYTES);
|
|
66
|
+
const bodyHash = await sha256Hex(buildFingerprintInput(rawBody, requestUrl, options.fingerprintSearchParams));
|
|
67
|
+
// The `db` middleware always unwraps a `DisposableDb` to a plain
|
|
68
|
+
// `VoyantDb` before storing on context, so this cast is safe.
|
|
69
|
+
const db = c.get("db");
|
|
70
|
+
if (!db) {
|
|
71
|
+
throw new Error("idempotencyKey middleware requires `db` on the request context. Mount `db()` (or `createApp`) before this middleware.");
|
|
72
|
+
}
|
|
73
|
+
// Look up an existing record. If the (scope, key) was used recently we
|
|
74
|
+
// either replay or 409.
|
|
75
|
+
const [existing] = await db
|
|
76
|
+
.select()
|
|
77
|
+
.from(infraIdempotencyKeysTable)
|
|
78
|
+
.where(and(eq(infraIdempotencyKeysTable.scope, scope), eq(infraIdempotencyKeysTable.key, headerKey)))
|
|
79
|
+
.limit(1);
|
|
80
|
+
if (existing) {
|
|
81
|
+
if (existing.expiresAt < new Date()) {
|
|
82
|
+
// The row is past its TTL — clean it up and proceed as if missed.
|
|
83
|
+
await db
|
|
84
|
+
.delete(infraIdempotencyKeysTable)
|
|
85
|
+
.where(eq(infraIdempotencyKeysTable.id, existing.id));
|
|
86
|
+
}
|
|
87
|
+
else if (existing.bodyHash !== bodyHash) {
|
|
88
|
+
return c.json({
|
|
89
|
+
error: `${HEADER_NAME} ${headerKey} was previously used with a different request body`,
|
|
90
|
+
}, 409);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
c.set("idempotencyKey", headerKey);
|
|
94
|
+
c.set("idempotencyReplayed", true);
|
|
95
|
+
const replayed = c.json(existing.responseBody, existing.responseStatus);
|
|
96
|
+
replayed.headers.set("Idempotency-Replayed", "true");
|
|
97
|
+
replayed.headers.set("Idempotency-Key", headerKey);
|
|
98
|
+
return replayed;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Re-attach the body so downstream handlers can re-parse it. Hono's
|
|
102
|
+
// `c.req.text()` consumed the original; rebuild a Request whose body
|
|
103
|
+
// matches what we hashed.
|
|
104
|
+
c.req.raw = new Request(c.req.raw, { body: rawBody });
|
|
105
|
+
c.set("idempotencyKey", headerKey);
|
|
106
|
+
c.set("idempotencyReplayed", false);
|
|
107
|
+
await next();
|
|
108
|
+
// Capture the response without consuming the original.
|
|
109
|
+
const response = c.res;
|
|
110
|
+
if (!response)
|
|
111
|
+
return;
|
|
112
|
+
const cloned = response.clone();
|
|
113
|
+
let parsedBody = null;
|
|
114
|
+
try {
|
|
115
|
+
const text = await cloned.text();
|
|
116
|
+
parsedBody = text.length > 0 ? JSON.parse(text) : null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Non-JSON responses are not stored — idempotency replay only works
|
|
120
|
+
// for JSON endpoints. Pass through silently.
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Only persist successful, replayable responses (2xx). 4xx/5xx leave
|
|
124
|
+
// the slot open so the client can retry with a corrected body.
|
|
125
|
+
if (response.status < 200 || response.status >= 300)
|
|
126
|
+
return;
|
|
127
|
+
if (options.replayResponses === false)
|
|
128
|
+
return;
|
|
129
|
+
const referenceId = options.extractReferenceId?.(parsedBody) ??
|
|
130
|
+
pickStringField(parsedBody, ["bookingId", "id"]) ??
|
|
131
|
+
null;
|
|
132
|
+
try {
|
|
133
|
+
await db
|
|
134
|
+
.insert(infraIdempotencyKeysTable)
|
|
135
|
+
.values({
|
|
136
|
+
scope,
|
|
137
|
+
key: headerKey,
|
|
138
|
+
bodyHash,
|
|
139
|
+
responseStatus: response.status,
|
|
140
|
+
responseBody: parsedBody,
|
|
141
|
+
referenceId,
|
|
142
|
+
expiresAt: new Date(Date.now() + ttlMs),
|
|
143
|
+
})
|
|
144
|
+
.onConflictDoNothing({
|
|
145
|
+
target: [infraIdempotencyKeysTable.scope, infraIdempotencyKeysTable.key],
|
|
146
|
+
});
|
|
147
|
+
// Echo the header so callers can confirm idempotent storage.
|
|
148
|
+
c.res = new Response(c.res.body, {
|
|
149
|
+
status: c.res.status,
|
|
150
|
+
statusText: c.res.statusText,
|
|
151
|
+
headers: new Headers(c.res.headers),
|
|
152
|
+
});
|
|
153
|
+
c.res.headers.set("Idempotency-Key", headerKey);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Best-effort storage. If the write fails we still return the
|
|
157
|
+
// response — duplicate-detection just degrades to "no protection
|
|
158
|
+
// for this request." Logging is the deployment's responsibility.
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function readBoundedBody(c, maxBytes) {
|
|
163
|
+
const contentLength = c.req.header("content-length");
|
|
164
|
+
if (contentLength) {
|
|
165
|
+
const size = Number(contentLength);
|
|
166
|
+
if (Number.isFinite(size) && size > maxBytes) {
|
|
167
|
+
throw new RequestValidationError("Request body too large", { maxBytes });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const body = await c.req.text();
|
|
171
|
+
if (new TextEncoder().encode(body).byteLength > maxBytes) {
|
|
172
|
+
throw new RequestValidationError("Request body too large", { maxBytes });
|
|
173
|
+
}
|
|
174
|
+
return body;
|
|
175
|
+
}
|
|
176
|
+
function buildCallerScopedKey(c, baseScope) {
|
|
177
|
+
const stableCaller = pickContextString(c, "apiKeyId") ??
|
|
178
|
+
pickContextString(c, "apiTokenId") ??
|
|
179
|
+
pickContextString(c, "sessionId") ??
|
|
180
|
+
pickContextString(c, "userId");
|
|
181
|
+
if (stableCaller)
|
|
182
|
+
return `${baseScope}:caller:${stableCaller}`;
|
|
183
|
+
return `${baseScope}:ip:${clientIpKey(c)}`;
|
|
184
|
+
}
|
|
185
|
+
function pickContextString(c, key) {
|
|
186
|
+
const value = c.get(key);
|
|
187
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
188
|
+
}
|
|
189
|
+
function buildFingerprintInput(rawBody, url, searchParamKeys) {
|
|
190
|
+
if (!searchParamKeys?.length)
|
|
191
|
+
return rawBody;
|
|
192
|
+
const queryFingerprint = searchParamKeys.map((key) => [key, url.searchParams.getAll(key).sort()]);
|
|
193
|
+
return JSON.stringify({
|
|
194
|
+
body: rawBody,
|
|
195
|
+
query: queryFingerprint,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function pickStringField(body, keys) {
|
|
199
|
+
if (!body || typeof body !== "object")
|
|
200
|
+
return null;
|
|
201
|
+
const obj = body;
|
|
202
|
+
for (const key of keys) {
|
|
203
|
+
const value = obj[key];
|
|
204
|
+
if (typeof value === "string" && value.length > 0)
|
|
205
|
+
return value;
|
|
206
|
+
// Common shape: `{ data: { id: ... } }`
|
|
207
|
+
if (key === "id" && obj.data && typeof obj.data === "object") {
|
|
208
|
+
const nested = obj.data.id;
|
|
209
|
+
if (typeof nested === "string" && nested.length > 0)
|
|
210
|
+
return nested;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Sweep expired idempotency rows. Call from a daily cron.
|
|
217
|
+
*
|
|
218
|
+
* If `dbFactory` returns a `DisposableDb` (e.g. a per-call Neon
|
|
219
|
+
* WebSocket Pool), the sweep awaits `dispose()` before returning so
|
|
220
|
+
* the connection closes cleanly inside the cron handler.
|
|
221
|
+
*/
|
|
222
|
+
export async function purgeExpiredIdempotencyKeys(dbFactory, env) {
|
|
223
|
+
const { db, dispose } = resolveDbFactoryResult(dbFactory(env));
|
|
224
|
+
try {
|
|
225
|
+
const rows = await db
|
|
226
|
+
.delete(infraIdempotencyKeysTable)
|
|
227
|
+
.where(lt(infraIdempotencyKeysTable.expiresAt, new Date()))
|
|
228
|
+
.returning();
|
|
229
|
+
return { removed: rows.length };
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
if (dispose)
|
|
233
|
+
await dispose();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { requireAuth } from "./auth.js";
|
|
2
|
+
export { DEFAULT_REQUEST_BODY_LIMIT_BYTES, type RequestBodyLimitOptions, requestBodyLimit, } from "./body-size.js";
|
|
3
|
+
export { cors } from "./cors.js";
|
|
4
|
+
export { db } from "./db.js";
|
|
5
|
+
export { errorBoundary, handleApiError, requestId } from "./error-boundary.js";
|
|
6
|
+
export { DEFAULT_IDEMPOTENCY_TTL_MS, type IdempotencyKeyOptions, idempotencyKey, purgeExpiredIdempotencyKeys, } from "./idempotency-key.js";
|
|
7
|
+
export { consoleLoggerProvider, logger } from "./logger.js";
|
|
8
|
+
export { type AnalyticsEngineDatasetLike, DB_METRICS_CONTEXT_KEY, type MetricsMiddlewareOptions, metrics, type RequestDbMetrics, withQueryCounting, } from "./metrics.js";
|
|
9
|
+
export { type PublicCacheOptions, publicResponseCache, resetPublicCacheStateForTests, } from "./public-cache.js";
|
|
10
|
+
export { type CloudflareRateLimiterBinding, clientIpKey, createCloudflareRateLimitStore, createKvRateLimitStore, createMemoryRateLimitStore, enforceRateLimit, LIVE_LIMITS, type RateLimitConfig, type RateLimitPolicy, type RateLimitRequestContext, type RateLimitResult, type RateLimitRule, type RateLimitStore, rateLimit, resolveRateLimitStore, } from "./rate-limit.js";
|
|
11
|
+
export { requireActor } from "./require-actor.js";
|
|
12
|
+
export { requirePermission } from "./require-permission.js";
|
|
13
|
+
export { type SecurityHeadersOptions, securityHeaders } from "./security-headers.js";
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EACL,gCAAgC,EAChC,KAAK,uBAAuB,EAC5B,gBAAgB,GACjB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAA;AAC5B,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC9E,OAAO,EACL,0BAA0B,EAC1B,KAAK,qBAAqB,EAC1B,cAAc,EACd,2BAA2B,GAC5B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,qBAAqB,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAC3D,OAAO,EACL,KAAK,0BAA0B,EAC/B,sBAAsB,EACtB,KAAK,wBAAwB,EAC7B,OAAO,EACP,KAAK,gBAAgB,EACrB,iBAAiB,GAClB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,KAAK,kBAAkB,EACvB,mBAAmB,EACnB,6BAA6B,GAC9B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,KAAK,4BAA4B,EACjC,WAAW,EACX,8BAA8B,EAC9B,sBAAsB,EACtB,0BAA0B,EAC1B,gBAAgB,EAChB,WAAW,EACX,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,SAAS,EACT,qBAAqB,GACtB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAA;AAC3D,OAAO,EAAE,KAAK,sBAAsB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { requireAuth } from "./auth.js";
|
|
2
|
+
export { DEFAULT_REQUEST_BODY_LIMIT_BYTES, requestBodyLimit, } from "./body-size.js";
|
|
3
|
+
export { cors } from "./cors.js";
|
|
4
|
+
export { db } from "./db.js";
|
|
5
|
+
export { errorBoundary, handleApiError, requestId } from "./error-boundary.js";
|
|
6
|
+
export { DEFAULT_IDEMPOTENCY_TTL_MS, idempotencyKey, purgeExpiredIdempotencyKeys, } from "./idempotency-key.js";
|
|
7
|
+
export { consoleLoggerProvider, logger } from "./logger.js";
|
|
8
|
+
export { DB_METRICS_CONTEXT_KEY, metrics, withQueryCounting, } from "./metrics.js";
|
|
9
|
+
export { publicResponseCache, resetPublicCacheStateForTests, } from "./public-cache.js";
|
|
10
|
+
export { clientIpKey, createCloudflareRateLimitStore, createKvRateLimitStore, createMemoryRateLimitStore, enforceRateLimit, LIVE_LIMITS, rateLimit, resolveRateLimitStore, } from "./rate-limit.js";
|
|
11
|
+
export { requireActor } from "./require-actor.js";
|
|
12
|
+
export { requirePermission } from "./require-permission.js";
|
|
13
|
+
export { securityHeaders } from "./security-headers.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { LoggerProvider } from "../types.js";
|
|
3
|
+
export declare const consoleLoggerProvider: LoggerProvider;
|
|
4
|
+
export declare function logger(provider?: LoggerProvider): MiddlewareHandler;
|
|
5
|
+
//# sourceMappingURL=logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/middleware/logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD,eAAO,MAAM,qBAAqB,EAAE,cAInC,CAAA;AAUD,wBAAgB,MAAM,CAAC,QAAQ,CAAC,EAAE,cAAc,GAAG,iBAAiB,CAanE"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const consoleLoggerProvider = {
|
|
2
|
+
log(entry) {
|
|
3
|
+
console.log(`${entry.method} ${entry.path} → ${entry.status} (${entry.durationMs}ms)`);
|
|
4
|
+
},
|
|
5
|
+
};
|
|
6
|
+
function logPath(c) {
|
|
7
|
+
const routePath = c.req.routePath;
|
|
8
|
+
if (routePath && routePath !== "/*")
|
|
9
|
+
return routePath;
|
|
10
|
+
return c.req.path
|
|
11
|
+
.replace(/(\/accountant\/)[^/]+/g, "$1[token]")
|
|
12
|
+
.replace(/(\/download\/)[^/]+/g, "$1[token]");
|
|
13
|
+
}
|
|
14
|
+
export function logger(provider) {
|
|
15
|
+
const log = provider ?? consoleLoggerProvider;
|
|
16
|
+
return async (c, next) => {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
await next();
|
|
19
|
+
const durationMs = Date.now() - start;
|
|
20
|
+
log.log({
|
|
21
|
+
method: c.req.method,
|
|
22
|
+
path: logPath(c),
|
|
23
|
+
status: c.res.status,
|
|
24
|
+
durationMs,
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { VoyantVariables } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Structural shape of a Workers Analytics Engine dataset binding —
|
|
5
|
+
* declared locally so `@voyant-travel/hono` needs no `@cloudflare/workers-types`
|
|
6
|
+
* dependency.
|
|
7
|
+
*/
|
|
8
|
+
export interface AnalyticsEngineDatasetLike {
|
|
9
|
+
writeDataPoint(point: {
|
|
10
|
+
blobs?: string[];
|
|
11
|
+
doubles?: number[];
|
|
12
|
+
indexes?: string[];
|
|
13
|
+
}): void;
|
|
14
|
+
}
|
|
15
|
+
/** Per-request db-query counter, populated by the db middleware. */
|
|
16
|
+
export interface RequestDbMetrics {
|
|
17
|
+
queries: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const DB_METRICS_CONTEXT_KEY = "__voyantDbMetrics";
|
|
20
|
+
export interface MetricsMiddlewareOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the Analytics Engine dataset from bindings. Defaults to
|
|
23
|
+
* `env.METRICS`. Returning undefined makes the middleware a no-op for
|
|
24
|
+
* that request (deployments without the binding pay ~nothing).
|
|
25
|
+
*/
|
|
26
|
+
dataset?: (env: unknown) => AnalyticsEngineDatasetLike | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Per-request metrics → Workers Analytics Engine (RFC voyant#1687 Phase
|
|
30
|
+
* 3.4, the in-worker half — the platform dispatcher's DISPATCH_METRICS
|
|
31
|
+
* dataset already records hostname/plan/cache/duration per dispatch;
|
|
32
|
+
* this adds what only the worker can see: the matched route pattern,
|
|
33
|
+
* the db query count, and in-worker cache hits).
|
|
34
|
+
*
|
|
35
|
+
* One data point per request:
|
|
36
|
+
* - blobs: [method, routePattern, surface, cacheStatus]
|
|
37
|
+
* - doubles: [durationMs, status, dbQueryCount]
|
|
38
|
+
* - indexes: [routePattern] (AE sampling key — per-route analysis)
|
|
39
|
+
*
|
|
40
|
+
* `writeDataPoint` is fire-and-forget and never throws into the
|
|
41
|
+
* request; a missing binding short-circuits before timing overhead.
|
|
42
|
+
*/
|
|
43
|
+
export declare function metrics(options?: MetricsMiddlewareOptions): MiddlewareHandler<{
|
|
44
|
+
Variables: Pick<VoyantVariables, typeof DB_METRICS_CONTEXT_KEY>;
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* Wrap a drizzle client so top-level query-initiating calls increment
|
|
48
|
+
* the request counter. Counts `select/insert/update/delete/execute/
|
|
49
|
+
* transaction/query` invocations — a transaction counts as ONE (its
|
|
50
|
+
* inner statements run on the tx handle, which is not wrapped). An
|
|
51
|
+
* approximation by design: the goal is spotting N+1 routes and
|
|
52
|
+
* subrequest-budget pressure, not exact accounting.
|
|
53
|
+
*/
|
|
54
|
+
export declare function withQueryCounting<TDb extends object>(db: TDb, counter: RequestDbMetrics): TDb;
|
|
55
|
+
//# sourceMappingURL=metrics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/middleware/metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElD;;;;GAIG;AACH,MAAM,WAAW,0BAA0B;IACzC,cAAc,CAAC,KAAK,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,GAAG,IAAI,CAAA;CAC1F;AAED,oEAAoE;AACpE,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,eAAO,MAAM,sBAAsB,sBAAsB,CAAA;AAEzD,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,0BAA0B,GAAG,SAAS,CAAA;CACnE;AASD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,wBAA6B,GAAG,iBAAiB,CAAC;IACjF,SAAS,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,sBAAsB,CAAC,CAAA;CAChE,CAAC,CAiCD;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,SAAS,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,gBAAgB,GAAG,GAAG,CA4B7F"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export const DB_METRICS_CONTEXT_KEY = "__voyantDbMetrics";
|
|
2
|
+
function surfaceOf(path) {
|
|
3
|
+
if (path.startsWith("/v1/admin/"))
|
|
4
|
+
return "admin";
|
|
5
|
+
if (path.startsWith("/v1/public/"))
|
|
6
|
+
return "public";
|
|
7
|
+
if (path.startsWith("/v1/"))
|
|
8
|
+
return "legacy";
|
|
9
|
+
return "other";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Per-request metrics → Workers Analytics Engine (RFC voyant#1687 Phase
|
|
13
|
+
* 3.4, the in-worker half — the platform dispatcher's DISPATCH_METRICS
|
|
14
|
+
* dataset already records hostname/plan/cache/duration per dispatch;
|
|
15
|
+
* this adds what only the worker can see: the matched route pattern,
|
|
16
|
+
* the db query count, and in-worker cache hits).
|
|
17
|
+
*
|
|
18
|
+
* One data point per request:
|
|
19
|
+
* - blobs: [method, routePattern, surface, cacheStatus]
|
|
20
|
+
* - doubles: [durationMs, status, dbQueryCount]
|
|
21
|
+
* - indexes: [routePattern] (AE sampling key — per-route analysis)
|
|
22
|
+
*
|
|
23
|
+
* `writeDataPoint` is fire-and-forget and never throws into the
|
|
24
|
+
* request; a missing binding short-circuits before timing overhead.
|
|
25
|
+
*/
|
|
26
|
+
export function metrics(options = {}) {
|
|
27
|
+
const resolveDataset = options.dataset ??
|
|
28
|
+
((env) => env?.METRICS);
|
|
29
|
+
return async (c, next) => {
|
|
30
|
+
const dataset = resolveDataset(c.env);
|
|
31
|
+
if (!dataset || typeof dataset.writeDataPoint !== "function") {
|
|
32
|
+
return next();
|
|
33
|
+
}
|
|
34
|
+
const counter = { queries: 0 };
|
|
35
|
+
c.set(DB_METRICS_CONTEXT_KEY, counter);
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
try {
|
|
38
|
+
await next();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
const durationMs = Date.now() - start;
|
|
42
|
+
const routePattern = c.req.routePath ?? c.req.path;
|
|
43
|
+
const status = c.res?.status ?? 0;
|
|
44
|
+
const cacheStatus = c.res?.headers.get("x-voyant-cache") ?? "";
|
|
45
|
+
try {
|
|
46
|
+
dataset.writeDataPoint({
|
|
47
|
+
blobs: [c.req.method, routePattern, surfaceOf(c.req.path), cacheStatus],
|
|
48
|
+
doubles: [durationMs, status, counter.queries],
|
|
49
|
+
indexes: [routePattern.slice(0, 96)],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// metrics are observability, never a request failure
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Wrap a drizzle client so top-level query-initiating calls increment
|
|
60
|
+
* the request counter. Counts `select/insert/update/delete/execute/
|
|
61
|
+
* transaction/query` invocations — a transaction counts as ONE (its
|
|
62
|
+
* inner statements run on the tx handle, which is not wrapped). An
|
|
63
|
+
* approximation by design: the goal is spotting N+1 routes and
|
|
64
|
+
* subrequest-budget pressure, not exact accounting.
|
|
65
|
+
*/
|
|
66
|
+
export function withQueryCounting(db, counter) {
|
|
67
|
+
const counted = new Set([
|
|
68
|
+
"select",
|
|
69
|
+
"selectDistinct",
|
|
70
|
+
"insert",
|
|
71
|
+
"update",
|
|
72
|
+
"delete",
|
|
73
|
+
"execute",
|
|
74
|
+
"transaction",
|
|
75
|
+
"query",
|
|
76
|
+
]);
|
|
77
|
+
return new Proxy(db, {
|
|
78
|
+
get(target, prop, receiver) {
|
|
79
|
+
const value = Reflect.get(target, prop, target);
|
|
80
|
+
if (typeof value === "function" && typeof prop === "string" && counted.has(prop)) {
|
|
81
|
+
return (...args) => {
|
|
82
|
+
counter.queries += 1;
|
|
83
|
+
return value.apply(target, args);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (typeof value === "function") {
|
|
87
|
+
// Preserve `this` for drizzle internals (incl. #private fields).
|
|
88
|
+
return value.bind(target);
|
|
89
|
+
}
|
|
90
|
+
void receiver;
|
|
91
|
+
return value;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { VoyantBindings } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Options for {@link publicResponseCache}.
|
|
5
|
+
*/
|
|
6
|
+
export interface PublicCacheOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Path prefixes eligible for caching. Defaults to the public API
|
|
9
|
+
* surface only — admin and legacy surfaces are never cached.
|
|
10
|
+
*/
|
|
11
|
+
pathPrefixes?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Responses larger than this (in bytes, after text decoding) are not
|
|
14
|
+
* stored in the KV fallback. Protects isolate memory and KV value
|
|
15
|
+
* limits. Default 2 MiB. The Cache API path streams and is not
|
|
16
|
+
* subject to this guard.
|
|
17
|
+
*/
|
|
18
|
+
maxKvBodyBytes?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Test hook — resets the memoized Cache API probe state. */
|
|
21
|
+
export declare function resetPublicCacheStateForTests(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Shared response cache for the public API surface.
|
|
24
|
+
*
|
|
25
|
+
* Fail-closed by design: a response is only ever cached when the route
|
|
26
|
+
* explicitly marked it shareable — `Cache-Control` containing `public`
|
|
27
|
+
* AND a positive `s-maxage` — and it carries no `Set-Cookie`. Routes
|
|
28
|
+
* emit `private`/`no-store` (or nothing) to opt out, so personalized
|
|
29
|
+
* endpoints under `/v1/public/*` (customer portal, verification) are
|
|
30
|
+
* never cached by accident.
|
|
31
|
+
*
|
|
32
|
+
* Cache hits are served before auth, the DB middleware, and the runtime
|
|
33
|
+
* bootstrap — a hit costs no Postgres connection, no session lookup,
|
|
34
|
+
* and no module-graph instantiation, which is the entire point under
|
|
35
|
+
* storefront load (#1686).
|
|
36
|
+
*
|
|
37
|
+
* Backend selection: Cache API (`caches.default`) where the runtime
|
|
38
|
+
* provides it; otherwise the `env.CACHE` KV binding when present;
|
|
39
|
+
* otherwise the middleware is a transparent no-op.
|
|
40
|
+
*/
|
|
41
|
+
export declare function publicResponseCache<TBindings extends VoyantBindings>(options?: PublicCacheOptions): MiddlewareHandler<{
|
|
42
|
+
Bindings: TBindings;
|
|
43
|
+
}>;
|
|
44
|
+
//# sourceMappingURL=public-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-cache.d.ts","sourceRoot":"","sources":["../../src/middleware/public-cache.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAG7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AA6ED,6DAA6D;AAC7D,wBAAgB,6BAA6B,IAAI,IAAI,CAEpD;AA0DD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,SAAS,cAAc,EAClE,OAAO,GAAE,kBAAuB,GAC/B,iBAAiB,CAAC;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,CAAC,CAsE5C"}
|