@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.
Files changed (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +58 -0
  3. package/dist/app-workflows.d.ts +31 -0
  4. package/dist/app-workflows.d.ts.map +1 -0
  5. package/dist/app-workflows.js +110 -0
  6. package/dist/app.d.ts +45 -0
  7. package/dist/app.d.ts.map +1 -0
  8. package/dist/app.js +403 -0
  9. package/dist/auth/crypto.d.ts +16 -0
  10. package/dist/auth/crypto.d.ts.map +1 -0
  11. package/dist/auth/crypto.js +66 -0
  12. package/dist/auth/index.d.ts +5 -0
  13. package/dist/auth/index.d.ts.map +1 -0
  14. package/dist/auth/index.js +3 -0
  15. package/dist/auth/require-user.d.ts +3 -0
  16. package/dist/auth/require-user.d.ts.map +1 -0
  17. package/dist/auth/require-user.js +8 -0
  18. package/dist/auth/session-jwt.d.ts +7 -0
  19. package/dist/auth/session-jwt.d.ts.map +1 -0
  20. package/dist/auth/session-jwt.js +23 -0
  21. package/dist/composition.d.ts +67 -0
  22. package/dist/composition.d.ts.map +1 -0
  23. package/dist/composition.js +46 -0
  24. package/dist/document-download.d.ts +30 -0
  25. package/dist/document-download.d.ts.map +1 -0
  26. package/dist/document-download.js +102 -0
  27. package/dist/index.d.ts +17 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +9 -0
  30. package/dist/lib/db-selector.d.ts +24 -0
  31. package/dist/lib/db-selector.d.ts.map +1 -0
  32. package/dist/lib/db-selector.js +28 -0
  33. package/dist/lib/execution-ctx.d.ts +16 -0
  34. package/dist/lib/execution-ctx.d.ts.map +1 -0
  35. package/dist/lib/execution-ctx.js +16 -0
  36. package/dist/lib/public-paths.d.ts +19 -0
  37. package/dist/lib/public-paths.d.ts.map +1 -0
  38. package/dist/lib/public-paths.js +27 -0
  39. package/dist/lib/request-event-bus.d.ts +21 -0
  40. package/dist/lib/request-event-bus.d.ts.map +1 -0
  41. package/dist/lib/request-event-bus.js +43 -0
  42. package/dist/middleware/auth.d.ts +10 -0
  43. package/dist/middleware/auth.d.ts.map +1 -0
  44. package/dist/middleware/auth.js +280 -0
  45. package/dist/middleware/body-size.d.ts +7 -0
  46. package/dist/middleware/body-size.d.ts.map +1 -0
  47. package/dist/middleware/body-size.js +20 -0
  48. package/dist/middleware/cors.d.ts +6 -0
  49. package/dist/middleware/cors.d.ts.map +1 -0
  50. package/dist/middleware/cors.js +94 -0
  51. package/dist/middleware/db.d.ts +43 -0
  52. package/dist/middleware/db.d.ts.map +1 -0
  53. package/dist/middleware/db.js +78 -0
  54. package/dist/middleware/error-boundary.d.ts +5 -0
  55. package/dist/middleware/error-boundary.d.ts.map +1 -0
  56. package/dist/middleware/error-boundary.js +76 -0
  57. package/dist/middleware/idempotency-key.d.ts +97 -0
  58. package/dist/middleware/idempotency-key.d.ts.map +1 -0
  59. package/dist/middleware/idempotency-key.js +235 -0
  60. package/dist/middleware/index.d.ts +14 -0
  61. package/dist/middleware/index.d.ts.map +1 -0
  62. package/dist/middleware/index.js +13 -0
  63. package/dist/middleware/logger.d.ts +5 -0
  64. package/dist/middleware/logger.d.ts.map +1 -0
  65. package/dist/middleware/logger.js +27 -0
  66. package/dist/middleware/metrics.d.ts +55 -0
  67. package/dist/middleware/metrics.d.ts.map +1 -0
  68. package/dist/middleware/metrics.js +94 -0
  69. package/dist/middleware/public-cache.d.ts +44 -0
  70. package/dist/middleware/public-cache.d.ts.map +1 -0
  71. package/dist/middleware/public-cache.js +205 -0
  72. package/dist/middleware/rate-limit.d.ts +214 -0
  73. package/dist/middleware/rate-limit.d.ts.map +1 -0
  74. package/dist/middleware/rate-limit.js +240 -0
  75. package/dist/middleware/request-db.d.ts +42 -0
  76. package/dist/middleware/request-db.d.ts.map +1 -0
  77. package/dist/middleware/request-db.js +62 -0
  78. package/dist/middleware/require-actor.d.ts +28 -0
  79. package/dist/middleware/require-actor.d.ts.map +1 -0
  80. package/dist/middleware/require-actor.js +89 -0
  81. package/dist/middleware/require-permission.d.ts +9 -0
  82. package/dist/middleware/require-permission.d.ts.map +1 -0
  83. package/dist/middleware/require-permission.js +62 -0
  84. package/dist/middleware/security-headers.d.ts +10 -0
  85. package/dist/middleware/security-headers.d.ts.map +1 -0
  86. package/dist/middleware/security-headers.js +19 -0
  87. package/dist/module.d.ts +41 -0
  88. package/dist/module.d.ts.map +1 -0
  89. package/dist/module.js +1 -0
  90. package/dist/plugin.d.ts +66 -0
  91. package/dist/plugin.d.ts.map +1 -0
  92. package/dist/plugin.js +37 -0
  93. package/dist/public-capability.d.ts +46 -0
  94. package/dist/public-capability.d.ts.map +1 -0
  95. package/dist/public-capability.js +140 -0
  96. package/dist/public-document-delivery.d.ts +111 -0
  97. package/dist/public-document-delivery.d.ts.map +1 -0
  98. package/dist/public-document-delivery.js +234 -0
  99. package/dist/types.d.ts +318 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +29 -0
  102. package/dist/validation.d.ts +36 -0
  103. package/dist/validation.d.ts.map +1 -0
  104. package/dist/validation.js +106 -0
  105. 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"}