@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,280 @@
|
|
|
1
|
+
import { apikeyTable } from "@voyant-travel/db/schema/iam";
|
|
2
|
+
import { permissionsToStrings } from "@voyant-travel/types/api-keys";
|
|
3
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
4
|
+
import { constantTimeEqual, sha256Base64Url, sha256Hex } from "../auth/crypto.js";
|
|
5
|
+
import { extractBearerToken, verifySession } from "../auth/session-jwt.js";
|
|
6
|
+
import { tryGetExecutionCtx } from "../lib/execution-ctx.js";
|
|
7
|
+
import { matchesPublicPath, normalizePathname } from "../lib/public-paths.js";
|
|
8
|
+
import { selectDbFactory, } from "../types.js";
|
|
9
|
+
import { acquireRequestDb } from "./request-db.js";
|
|
10
|
+
const API_KEY_PREFIX = "voy_";
|
|
11
|
+
/**
|
|
12
|
+
* Parse `INTERNAL_API_KEY` as one-or-more comma-separated values, so the
|
|
13
|
+
* credential can be rotated without a window where one side rejects the
|
|
14
|
+
* other: deploy `new,old`, flip callers to `new`, then drop `old`.
|
|
15
|
+
*/
|
|
16
|
+
function parseInternalApiKeys(raw) {
|
|
17
|
+
if (!raw)
|
|
18
|
+
return [];
|
|
19
|
+
return raw
|
|
20
|
+
.split(",")
|
|
21
|
+
.map((value) => value.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
function parseInternalApiKeyScopes(raw) {
|
|
25
|
+
const scopes = (raw ?? "")
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((value) => value.trim())
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
return scopes.length > 0 ? scopes : ["*:*"];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Timing-safe membership check for the internal API key. Both sides are
|
|
33
|
+
* SHA-256-hashed before comparison so the constant-time equality runs on
|
|
34
|
+
* fixed-length digests — masking both content and length of the
|
|
35
|
+
* configured keys. Every configured key is checked (no early exit) so
|
|
36
|
+
* the match position is not observable either.
|
|
37
|
+
*/
|
|
38
|
+
async function matchesInternalApiKey(token, keys) {
|
|
39
|
+
const tokenDigest = await sha256Hex(token);
|
|
40
|
+
let matched = false;
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
if (constantTimeEqual(tokenDigest, await sha256Hex(key))) {
|
|
43
|
+
matched = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return matched;
|
|
47
|
+
}
|
|
48
|
+
// ---- API-key lookup cache (env.CACHE KV) ----
|
|
49
|
+
//
|
|
50
|
+
// Validating a `voy_` key costs one Postgres SELECT per request. For
|
|
51
|
+
// keys WITHOUT a usage quota (`remaining === null`) the row is cached in
|
|
52
|
+
// KV for a short TTL so steady-state server-to-server traffic skips the
|
|
53
|
+
// DB roundtrip. Quota-limited keys are never cached — their remaining
|
|
54
|
+
// count must be read fresh. Trade-off: disabling/revoking a cached key
|
|
55
|
+
// takes effect within the TTL, not instantly.
|
|
56
|
+
const API_KEY_CACHE_PREFIX = "apikey:v1:";
|
|
57
|
+
/** KV minimum is 60s; keep revocation latency at that floor. */
|
|
58
|
+
const API_KEY_CACHE_TTL_SECONDS = 60;
|
|
59
|
+
const API_KEY_DATE_FIELDS = [
|
|
60
|
+
"createdAt",
|
|
61
|
+
"updatedAt",
|
|
62
|
+
"expiresAt",
|
|
63
|
+
"lastRequest",
|
|
64
|
+
"lastRefillAt",
|
|
65
|
+
];
|
|
66
|
+
async function readCachedApiKey(kv, keyHash) {
|
|
67
|
+
try {
|
|
68
|
+
const entry = await kv.get(`${API_KEY_CACHE_PREFIX}${keyHash}`, {
|
|
69
|
+
type: "json",
|
|
70
|
+
});
|
|
71
|
+
if (!entry || typeof entry !== "object")
|
|
72
|
+
return null;
|
|
73
|
+
for (const field of API_KEY_DATE_FIELDS) {
|
|
74
|
+
const value = entry[field];
|
|
75
|
+
if (typeof value === "string")
|
|
76
|
+
entry[field] = new Date(value);
|
|
77
|
+
}
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function writeCachedApiKey(kv, keyHash, row) {
|
|
85
|
+
try {
|
|
86
|
+
await kv.put(`${API_KEY_CACHE_PREFIX}${keyHash}`, JSON.stringify(row), {
|
|
87
|
+
expirationTtl: API_KEY_CACHE_TTL_SECONDS,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// cache writes are best-effort
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function applyAuthContext(c, auth) {
|
|
95
|
+
if (auth.userId)
|
|
96
|
+
c.set("userId", auth.userId);
|
|
97
|
+
if (auth.sessionId)
|
|
98
|
+
c.set("sessionId", auth.sessionId);
|
|
99
|
+
if (auth.organizationId !== undefined)
|
|
100
|
+
c.set("organizationId", auth.organizationId ?? undefined);
|
|
101
|
+
if (auth.callerType)
|
|
102
|
+
c.set("callerType", auth.callerType);
|
|
103
|
+
if (auth.actor)
|
|
104
|
+
c.set("actor", auth.actor);
|
|
105
|
+
if (auth.scopes !== undefined)
|
|
106
|
+
c.set("scopes", auth.scopes);
|
|
107
|
+
if (auth.isInternalRequest !== undefined)
|
|
108
|
+
c.set("isInternalRequest", auth.isInternalRequest);
|
|
109
|
+
if (auth.apiTokenId)
|
|
110
|
+
c.set("apiTokenId", auth.apiTokenId);
|
|
111
|
+
if (auth.apiKeyId)
|
|
112
|
+
c.set("apiKeyId", auth.apiKeyId);
|
|
113
|
+
}
|
|
114
|
+
export function requireAuth(dbSource, opts) {
|
|
115
|
+
const publicPaths = opts?.publicPaths ?? [];
|
|
116
|
+
return async (c, next) => {
|
|
117
|
+
if (c.req.method === "OPTIONS")
|
|
118
|
+
return next();
|
|
119
|
+
// Resolve the surface-appropriate factory once — the db middleware
|
|
120
|
+
// downstream resolves the same one, so both share one client.
|
|
121
|
+
const dbFactory = selectDbFactory(dbSource, c.req.path);
|
|
122
|
+
const url = new URL(c.req.url);
|
|
123
|
+
const p = normalizePathname(url.pathname);
|
|
124
|
+
const isPublicAuth = p === "/auth/callback" || p.startsWith("/auth/");
|
|
125
|
+
const isHealthCheck = p === "/health";
|
|
126
|
+
if (isPublicAuth || isHealthCheck)
|
|
127
|
+
return next();
|
|
128
|
+
if (matchesPublicPath(p, publicPaths)) {
|
|
129
|
+
if (p.startsWith("/v1/public/")) {
|
|
130
|
+
c.set("actor", "customer");
|
|
131
|
+
}
|
|
132
|
+
return next();
|
|
133
|
+
}
|
|
134
|
+
const authHeader = c.req.header("authorization") || c.req.header("Authorization");
|
|
135
|
+
const token = extractBearerToken(authHeader);
|
|
136
|
+
// Strategy 1: Internal API Key
|
|
137
|
+
const internalKeys = parseInternalApiKeys(c.env.INTERNAL_API_KEY);
|
|
138
|
+
if (token && internalKeys.length > 0 && (await matchesInternalApiKey(token, internalKeys))) {
|
|
139
|
+
applyAuthContext(c, {
|
|
140
|
+
callerType: "internal",
|
|
141
|
+
isInternalRequest: true,
|
|
142
|
+
actor: "staff",
|
|
143
|
+
scopes: parseInternalApiKeyScopes(c.env.INTERNAL_API_KEY_SCOPES),
|
|
144
|
+
});
|
|
145
|
+
return next();
|
|
146
|
+
}
|
|
147
|
+
// Strategy 2: Core-owned API key support (voy_ prefixed)
|
|
148
|
+
if (token?.startsWith(API_KEY_PREFIX)) {
|
|
149
|
+
// Shared per-request client — the db middleware downstream reuses
|
|
150
|
+
// this same client instead of opening a second Pool.
|
|
151
|
+
const lease = acquireRequestDb(c, dbFactory);
|
|
152
|
+
const db = lease.db;
|
|
153
|
+
try {
|
|
154
|
+
const keyHash = await sha256Base64Url(token);
|
|
155
|
+
const kv = c.env.CACHE;
|
|
156
|
+
let row = kv ? await readCachedApiKey(kv, keyHash) : null;
|
|
157
|
+
if (!row) {
|
|
158
|
+
const [dbRow] = await db
|
|
159
|
+
.select()
|
|
160
|
+
.from(apikeyTable)
|
|
161
|
+
.where(and(eq(apikeyTable.key, keyHash), eq(apikeyTable.enabled, true)))
|
|
162
|
+
.limit(1);
|
|
163
|
+
row = dbRow ?? null;
|
|
164
|
+
// Only quota-less keys are cacheable — `remaining` must be
|
|
165
|
+
// read fresh for limited keys.
|
|
166
|
+
if (row && row.remaining === null && kv) {
|
|
167
|
+
const cacheable = row;
|
|
168
|
+
tryGetExecutionCtx(c)?.waitUntil(writeCachedApiKey(kv, keyHash, cacheable));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!row?.enabled) {
|
|
172
|
+
return c.json({ error: "Invalid API key" }, 401);
|
|
173
|
+
}
|
|
174
|
+
if (row.expiresAt && row.expiresAt < new Date()) {
|
|
175
|
+
return c.json({ error: "API key expired" }, 401);
|
|
176
|
+
}
|
|
177
|
+
if (row.remaining !== null && row.remaining <= 0) {
|
|
178
|
+
return c.json({ error: "API key usage limit exceeded" }, 429);
|
|
179
|
+
}
|
|
180
|
+
if (opts?.auth?.validateApiKey) {
|
|
181
|
+
const isValid = await opts.auth.validateApiKey({
|
|
182
|
+
request: c.req.raw,
|
|
183
|
+
env: c.env,
|
|
184
|
+
db,
|
|
185
|
+
// Guarded: Hono throws on `executionCtx` access outside Workers.
|
|
186
|
+
ctx: tryGetExecutionCtx(c),
|
|
187
|
+
apiKey: row,
|
|
188
|
+
});
|
|
189
|
+
if (!isValid) {
|
|
190
|
+
return c.json({ error: "Invalid API key" }, 401);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Usage counters update off the response path, as SQL increments
|
|
194
|
+
// so concurrent requests (and cache-served rows) never clobber
|
|
195
|
+
// each other with stale arithmetic. The query promise starts
|
|
196
|
+
// immediately either way; `waitUntil` (when the runtime has one)
|
|
197
|
+
// just keeps the isolate alive until it settles.
|
|
198
|
+
const counterUpdate = db
|
|
199
|
+
.update(apikeyTable)
|
|
200
|
+
.set({
|
|
201
|
+
// agent-quality: raw-sql reviewed -- Atomic quota counters must increment in SQL to avoid stale read/modify/write races.
|
|
202
|
+
requestCount: sql `${apikeyTable.requestCount} + 1`,
|
|
203
|
+
lastRequest: new Date(),
|
|
204
|
+
// agent-quality: raw-sql reviewed -- Atomic remaining decrement is guarded by the selected API-key row and avoids concurrent quota clobbering.
|
|
205
|
+
...(row.remaining !== null ? { remaining: sql `${apikeyTable.remaining} - 1` } : {}),
|
|
206
|
+
})
|
|
207
|
+
.where(eq(apikeyTable.id, row.id))
|
|
208
|
+
.then(() => { })
|
|
209
|
+
.catch(() => { });
|
|
210
|
+
tryGetExecutionCtx(c)?.waitUntil(counterUpdate);
|
|
211
|
+
const scopes = permissionsToStrings(row.permissions);
|
|
212
|
+
applyAuthContext(c, {
|
|
213
|
+
organizationId: row.referenceId,
|
|
214
|
+
scopes,
|
|
215
|
+
callerType: "api_key",
|
|
216
|
+
apiTokenId: row.id,
|
|
217
|
+
apiKeyId: row.id,
|
|
218
|
+
// Core-owned API keys (`voy_` prefix) are server-to-server credentials
|
|
219
|
+
// issued to operator staff. The actor stays explicit here so that
|
|
220
|
+
// `requireActor` doesn't have to default unset callers to "staff".
|
|
221
|
+
actor: "staff",
|
|
222
|
+
});
|
|
223
|
+
// `await` is load-bearing: with a bare `return next()` the
|
|
224
|
+
// `finally` would run as soon as the downstream promise is
|
|
225
|
+
// CREATED — releasing the shared client while the route is
|
|
226
|
+
// still querying it. Awaiting keeps the release after the
|
|
227
|
+
// entire downstream pipeline completes.
|
|
228
|
+
return await next();
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// fall through to next strategy
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
// The creating lease schedules pool teardown via waitUntil so
|
|
235
|
+
// the worker stays alive for the close handshake; reuse leases
|
|
236
|
+
// are no-ops.
|
|
237
|
+
await lease.release();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Strategy 3: App-provided auth resolution (cookies, provider tokens, etc.)
|
|
241
|
+
if (opts?.auth?.resolve) {
|
|
242
|
+
const lease = acquireRequestDb(c, dbFactory);
|
|
243
|
+
try {
|
|
244
|
+
const resolved = await opts.auth.resolve({
|
|
245
|
+
request: c.req.raw,
|
|
246
|
+
env: c.env,
|
|
247
|
+
db: lease.db,
|
|
248
|
+
// Guarded: Hono throws on `executionCtx` access outside Workers.
|
|
249
|
+
ctx: tryGetExecutionCtx(c),
|
|
250
|
+
});
|
|
251
|
+
if (resolved?.userId) {
|
|
252
|
+
applyAuthContext(c, resolved);
|
|
253
|
+
// `await` is load-bearing — see strategy 2: a bare
|
|
254
|
+
// `return next()` would let the `finally` release the shared
|
|
255
|
+
// client while downstream is still using it.
|
|
256
|
+
return await next();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
await lease.release();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Strategy 4: Generic session-claims bearer token support
|
|
264
|
+
const sessionSecret = c.env.SESSION_CLAIMS_SECRET;
|
|
265
|
+
if (token && sessionSecret && token.includes(".")) {
|
|
266
|
+
try {
|
|
267
|
+
const sessionAuth = await verifySession(token, sessionSecret);
|
|
268
|
+
applyAuthContext(c, {
|
|
269
|
+
...sessionAuth,
|
|
270
|
+
callerType: "session",
|
|
271
|
+
});
|
|
272
|
+
return next();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// fall through
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
export interface RequestBodyLimitOptions {
|
|
3
|
+
maxBytes: number;
|
|
4
|
+
}
|
|
5
|
+
export declare const DEFAULT_REQUEST_BODY_LIMIT_BYTES: number;
|
|
6
|
+
export declare function requestBodyLimit(options: RequestBodyLimitOptions): MiddlewareHandler;
|
|
7
|
+
//# sourceMappingURL=body-size.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"body-size.d.ts","sourceRoot":"","sources":["../../src/middleware/body-size.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,eAAO,MAAM,gCAAgC,QAAmB,CAAA;AAEhE,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,iBAAiB,CAuBpF"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
2
|
+
export function requestBodyLimit(options) {
|
|
3
|
+
return async (c, next) => {
|
|
4
|
+
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") {
|
|
5
|
+
return next();
|
|
6
|
+
}
|
|
7
|
+
const contentLength = c.req.header("content-length");
|
|
8
|
+
if (contentLength) {
|
|
9
|
+
const size = Number(contentLength);
|
|
10
|
+
if (Number.isFinite(size) && size > options.maxBytes) {
|
|
11
|
+
return c.json({
|
|
12
|
+
error: "Request body too large",
|
|
13
|
+
code: "request_body_too_large",
|
|
14
|
+
maxBytes: options.maxBytes,
|
|
15
|
+
}, 413);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return next();
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["../../src/middleware/cors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AA0EjD,wBAAgB,IAAI,IAAI,iBAAiB,CAAC;IAAE,QAAQ,EAAE,cAAc,CAAA;CAAE,CAAC,CAwCtE"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsed allowlists keyed by the raw `CORS_ALLOWLIST` value. The env value
|
|
3
|
+
* is constant per deployment (at most a handful of distinct values per
|
|
4
|
+
* isolate across preview/production bindings), so the cache stays tiny —
|
|
5
|
+
* but it saves a split + trim + wildcard RegExp compilation on every
|
|
6
|
+
* request.
|
|
7
|
+
*/
|
|
8
|
+
const compiledAllowlists = new Map();
|
|
9
|
+
function compileMatcher(pattern) {
|
|
10
|
+
// Credentialed CORS must never turn a bare "*" into reflected allow-all.
|
|
11
|
+
if (pattern === "*")
|
|
12
|
+
return () => false;
|
|
13
|
+
if (!pattern.includes("*")) {
|
|
14
|
+
return (origin) => origin === pattern;
|
|
15
|
+
}
|
|
16
|
+
if (!pattern.startsWith("https://*.") || pattern.slice("https://*.".length).includes("*")) {
|
|
17
|
+
return () => false;
|
|
18
|
+
}
|
|
19
|
+
// Keep local development origins exact. A wildcard such as
|
|
20
|
+
// `http://localhost:*` is too broad for credentialed requests.
|
|
21
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
22
|
+
const regex = new RegExp(`^${escaped}$`);
|
|
23
|
+
return (origin) => regex.test(origin);
|
|
24
|
+
}
|
|
25
|
+
function compileAllowlist(raw) {
|
|
26
|
+
const key = raw ?? "";
|
|
27
|
+
const cached = compiledAllowlists.get(key);
|
|
28
|
+
if (cached)
|
|
29
|
+
return cached;
|
|
30
|
+
const entries = key
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
const compiled = {
|
|
35
|
+
entries,
|
|
36
|
+
matchers: entries.map(compileMatcher),
|
|
37
|
+
};
|
|
38
|
+
compiledAllowlists.set(key, compiled);
|
|
39
|
+
return compiled;
|
|
40
|
+
}
|
|
41
|
+
function isAllowedOrigin(origin, allowlist) {
|
|
42
|
+
if (allowlist.entries.length === 0)
|
|
43
|
+
return false;
|
|
44
|
+
return allowlist.matchers.some((matches) => matches(origin));
|
|
45
|
+
}
|
|
46
|
+
const DEFAULT_ALLOWED_REQUEST_HEADERS = new Set([
|
|
47
|
+
"authorization",
|
|
48
|
+
"content-type",
|
|
49
|
+
"idempotency-key",
|
|
50
|
+
"x-api-key",
|
|
51
|
+
"x-request-id",
|
|
52
|
+
"x-voyant-checkout-capability",
|
|
53
|
+
"x-voyant-guest-booking-access",
|
|
54
|
+
]);
|
|
55
|
+
function allowedRequestHeaders(requested) {
|
|
56
|
+
if (!requested)
|
|
57
|
+
return "content-type, authorization";
|
|
58
|
+
const allowed = requested
|
|
59
|
+
.split(",")
|
|
60
|
+
.map((header) => header.trim().toLowerCase())
|
|
61
|
+
.filter((header) => DEFAULT_ALLOWED_REQUEST_HEADERS.has(header));
|
|
62
|
+
return allowed.length > 0 ? allowed.join(", ") : "content-type, authorization";
|
|
63
|
+
}
|
|
64
|
+
export function cors() {
|
|
65
|
+
return async (c, next) => {
|
|
66
|
+
const origin = c.req.header("origin") || "";
|
|
67
|
+
const allowlist = compileAllowlist(c.env.CORS_ALLOWLIST);
|
|
68
|
+
const allowed = isAllowedOrigin(origin, allowlist);
|
|
69
|
+
if (origin && !allowed) {
|
|
70
|
+
console.warn("[CORS] Origin not in allowlist - CORS headers will NOT be set", {
|
|
71
|
+
origin,
|
|
72
|
+
allowlist: allowlist.entries,
|
|
73
|
+
path: c.req.path,
|
|
74
|
+
method: c.req.method,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (c.req.method === "OPTIONS") {
|
|
78
|
+
if (allowed) {
|
|
79
|
+
c.header("Access-Control-Allow-Origin", origin);
|
|
80
|
+
c.header("Vary", "Origin");
|
|
81
|
+
c.header("Access-Control-Allow-Credentials", "true");
|
|
82
|
+
c.header("Access-Control-Allow-Headers", allowedRequestHeaders(c.req.header("access-control-request-headers")));
|
|
83
|
+
c.header("Access-Control-Allow-Methods", c.req.header("access-control-request-method") || "GET,POST,PUT,PATCH,DELETE,OPTIONS");
|
|
84
|
+
}
|
|
85
|
+
return c.body(null, 204);
|
|
86
|
+
}
|
|
87
|
+
await next();
|
|
88
|
+
if (allowed) {
|
|
89
|
+
c.header("Access-Control-Allow-Origin", origin);
|
|
90
|
+
c.header("Vary", "Origin");
|
|
91
|
+
c.header("Access-Control-Allow-Credentials", "true");
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { type DbSource, type VoyantBindings, type VoyantDb } from "../types.js";
|
|
3
|
+
import { DB_METRICS_CONTEXT_KEY, type RequestDbMetrics } from "./metrics.js";
|
|
4
|
+
export interface DbMiddlewareOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Names of modules that require a transaction-capable db adapter
|
|
7
|
+
* (those that set `Module.requiresTransactionalDb`). If non-empty
|
|
8
|
+
* and the first resolved db reports
|
|
9
|
+
* `dbSupportsTransactions(db) === false`, the middleware throws a
|
|
10
|
+
* clear error naming the offending modules. A capability of
|
|
11
|
+
* `undefined` (untagged drivers like raw `drizzle-orm/node-postgres`)
|
|
12
|
+
* is treated as "assume capable" — only an explicit `false`
|
|
13
|
+
* (neon-http) trips the assertion.
|
|
14
|
+
*/
|
|
15
|
+
requiresTransactionalDb?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the per-request db client and stores it on Hono context.
|
|
19
|
+
*
|
|
20
|
+
* If the factory returns a {@link DisposableDb} (e.g. a
|
|
21
|
+
* `dbFromEnvForApp` that owns a per-request Neon WebSocket Pool), the
|
|
22
|
+
* middleware schedules `dispose()` via `c.executionCtx.waitUntil` so
|
|
23
|
+
* the Pool closes cleanly after the response is sent. Without the
|
|
24
|
+
* scheduled dispose every request would leak its Pool until isolate
|
|
25
|
+
* teardown, which at scale exhausts Neon's connection budget.
|
|
26
|
+
*
|
|
27
|
+
* Factories that return a plain {@link VoyantDb} (e.g. a long-lived
|
|
28
|
+
* postgres-js client cached at the module level) are wired up as
|
|
29
|
+
* before with no cleanup hook.
|
|
30
|
+
*
|
|
31
|
+
* The client is shared per request via {@link acquireRequestDb}: if the
|
|
32
|
+
* auth middleware (which runs earlier) already created one for the same
|
|
33
|
+
* factory, this middleware reuses it instead of opening a second Pool —
|
|
34
|
+
* the creator's `release()` owns the dispose.
|
|
35
|
+
*/
|
|
36
|
+
export declare function db<TBindings extends VoyantBindings>(source: DbSource<TBindings>, options?: DbMiddlewareOptions): MiddlewareHandler<{
|
|
37
|
+
Bindings: TBindings;
|
|
38
|
+
Variables: {
|
|
39
|
+
db: VoyantDb;
|
|
40
|
+
[DB_METRICS_CONTEXT_KEY]?: RequestDbMetrics;
|
|
41
|
+
};
|
|
42
|
+
}>;
|
|
43
|
+
//# sourceMappingURL=db.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/middleware/db.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,OAAO,EAAE,KAAK,QAAQ,EAAuB,KAAK,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAA;AACpG,OAAO,EAAE,sBAAsB,EAAE,KAAK,gBAAgB,EAAqB,MAAM,cAAc,CAAA;AAG/F,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;;OASG;IACH,uBAAuB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC5C;AAcD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,EAAE,CAAC,SAAS,SAAS,cAAc,EACjD,MAAM,EAAE,QAAQ,CAAC,SAAS,CAAC,EAC3B,OAAO,GAAE,mBAAwB,GAChC,iBAAiB,CAAC;IACnB,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE;QAAE,EAAE,EAAE,QAAQ,CAAC;QAAC,CAAC,sBAAsB,CAAC,CAAC,EAAE,gBAAgB,CAAA;KAAE,CAAA;CACzE,CAAC,CA2CD"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { dbSupportsTransactions } from "@voyant-travel/db/transaction-capability";
|
|
2
|
+
import { isDbFactorySelector } from "../types.js";
|
|
3
|
+
import { DB_METRICS_CONTEXT_KEY, withQueryCounting } from "./metrics.js";
|
|
4
|
+
import { acquireRequestDb } from "./request-db.js";
|
|
5
|
+
function buildIncapableDbError(modules) {
|
|
6
|
+
const list = [...modules].sort().join(", ");
|
|
7
|
+
return new Error(`[voyant] db adapter does not support interactive transactions, but the ` +
|
|
8
|
+
`following modules require it: ${list}. ` +
|
|
9
|
+
`Use createServerlessDbClient (neon-serverless / WebSocket) for ` +
|
|
10
|
+
`Cloudflare Workers, or createDbClient(url, { adapter: "node" }) for ` +
|
|
11
|
+
`Node deployments. The "edge" adapter (neon-http) is read-mostly ` +
|
|
12
|
+
`and cannot run db.transaction(async (tx) => …).`);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the per-request db client and stores it on Hono context.
|
|
16
|
+
*
|
|
17
|
+
* If the factory returns a {@link DisposableDb} (e.g. a
|
|
18
|
+
* `dbFromEnvForApp` that owns a per-request Neon WebSocket Pool), the
|
|
19
|
+
* middleware schedules `dispose()` via `c.executionCtx.waitUntil` so
|
|
20
|
+
* the Pool closes cleanly after the response is sent. Without the
|
|
21
|
+
* scheduled dispose every request would leak its Pool until isolate
|
|
22
|
+
* teardown, which at scale exhausts Neon's connection budget.
|
|
23
|
+
*
|
|
24
|
+
* Factories that return a plain {@link VoyantDb} (e.g. a long-lived
|
|
25
|
+
* postgres-js client cached at the module level) are wired up as
|
|
26
|
+
* before with no cleanup hook.
|
|
27
|
+
*
|
|
28
|
+
* The client is shared per request via {@link acquireRequestDb}: if the
|
|
29
|
+
* auth middleware (which runs earlier) already created one for the same
|
|
30
|
+
* factory, this middleware reuses it instead of opening a second Pool —
|
|
31
|
+
* the creator's `release()` owns the dispose.
|
|
32
|
+
*/
|
|
33
|
+
export function db(source, options = {}) {
|
|
34
|
+
const requiresTx = options.requiresTransactionalDb ?? [];
|
|
35
|
+
// Stays `false` until a request resolves a db whose capability tag is
|
|
36
|
+
// anything other than an explicit `false`. As long as the adapter is
|
|
37
|
+
// wired wrong, every request keeps surfacing the actionable error —
|
|
38
|
+
// we don't want the first failing request to silence subsequent
|
|
39
|
+
// checks and let later writes crash with the deep transaction error.
|
|
40
|
+
//
|
|
41
|
+
// With a DbFactorySelector the assertion is per-surface: only requests
|
|
42
|
+
// the selector routes to the transactional factory must resolve a
|
|
43
|
+
// transaction-capable client — the default (http) factory is allowed,
|
|
44
|
+
// by design, to be transaction-incapable.
|
|
45
|
+
let txCapabilityVerified = false;
|
|
46
|
+
return async (c, next) => {
|
|
47
|
+
const selection = isDbFactorySelector(source)
|
|
48
|
+
? source.select(c.req.path)
|
|
49
|
+
: { factory: source, mustSupportTransactions: requiresTx.length > 0 };
|
|
50
|
+
const lease = acquireRequestDb(c, selection.factory);
|
|
51
|
+
if (!txCapabilityVerified && selection.mustSupportTransactions && requiresTx.length > 0) {
|
|
52
|
+
if (dbSupportsTransactions(lease.db) === false) {
|
|
53
|
+
try {
|
|
54
|
+
await lease.release();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// swallow dispose errors — the original throw is the actionable one
|
|
58
|
+
}
|
|
59
|
+
throw buildIncapableDbError(requiresTx);
|
|
60
|
+
}
|
|
61
|
+
txCapabilityVerified = true;
|
|
62
|
+
}
|
|
63
|
+
// When the metrics middleware put a counter on the context, expose a
|
|
64
|
+
// query-counting view of the client so per-route db-query counts land
|
|
65
|
+
// in Analytics Engine. The lease (and its dispose) stay unwrapped.
|
|
66
|
+
const dbMetrics = c.get(DB_METRICS_CONTEXT_KEY);
|
|
67
|
+
c.set("db", dbMetrics ? withQueryCounting(lease.db, dbMetrics) : lease.db);
|
|
68
|
+
try {
|
|
69
|
+
await next();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
// No-op when the auth middleware created (and therefore owns) the
|
|
73
|
+
// shared client; otherwise schedules dispose via waitUntil (or
|
|
74
|
+
// awaits inline outside Workers).
|
|
75
|
+
await lease.release();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
export declare const requestId: MiddlewareHandler;
|
|
3
|
+
export declare function handleApiError(err: unknown, c: Context): Response;
|
|
4
|
+
export declare const errorBoundary: MiddlewareHandler;
|
|
5
|
+
//# sourceMappingURL=error-boundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-boundary.d.ts","sourceRoot":"","sources":["../../src/middleware/error-boundary.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAYtD,eAAO,MAAM,SAAS,EAAE,iBAKvB,CAAA;AAeD,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,QAAQ,CA6CjE;AAED,eAAO,MAAM,aAAa,EAAE,iBAM3B,CAAA"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { apiErrorSchema } from "@voyant-travel/types";
|
|
2
|
+
import { normalizeValidationError } from "../validation.js";
|
|
3
|
+
function generateRequestId() {
|
|
4
|
+
const bytes = new Uint8Array(16);
|
|
5
|
+
crypto.getRandomValues(bytes);
|
|
6
|
+
return Array.from(bytes)
|
|
7
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
8
|
+
.join("");
|
|
9
|
+
}
|
|
10
|
+
export const requestId = async (c, next) => {
|
|
11
|
+
const existing = c.req.header("x-request-id");
|
|
12
|
+
const id = existing?.trim() || generateRequestId();
|
|
13
|
+
c.res.headers.set("X-Request-Id", id);
|
|
14
|
+
await next();
|
|
15
|
+
};
|
|
16
|
+
const LOGGED_HEADERS = new Set([
|
|
17
|
+
"accept",
|
|
18
|
+
"cf-connecting-ip",
|
|
19
|
+
"cf-ray",
|
|
20
|
+
"content-length",
|
|
21
|
+
"content-type",
|
|
22
|
+
"origin",
|
|
23
|
+
"referer",
|
|
24
|
+
"user-agent",
|
|
25
|
+
"x-forwarded-for",
|
|
26
|
+
"x-request-id",
|
|
27
|
+
]);
|
|
28
|
+
export function handleApiError(err, c) {
|
|
29
|
+
const id = c.res.headers.get("X-Request-Id") || generateRequestId();
|
|
30
|
+
const apiError = normalizeValidationError(err);
|
|
31
|
+
const errRecord = err instanceof Object ? err : {};
|
|
32
|
+
const code = apiError?.code;
|
|
33
|
+
const status = apiError?.status ?? 500;
|
|
34
|
+
const details = apiError?.details ??
|
|
35
|
+
(apiError && errRecord.details && typeof errRecord.details === "object"
|
|
36
|
+
? errRecord.details
|
|
37
|
+
: undefined);
|
|
38
|
+
const errorMessage = apiError ? apiError.message : "Internal Server Error";
|
|
39
|
+
try {
|
|
40
|
+
const headers = {};
|
|
41
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
42
|
+
const lowerKey = key.toLowerCase();
|
|
43
|
+
if (LOGGED_HEADERS.has(lowerKey))
|
|
44
|
+
headers[lowerKey] = value;
|
|
45
|
+
});
|
|
46
|
+
console.error("[API:error]", {
|
|
47
|
+
id,
|
|
48
|
+
status,
|
|
49
|
+
code,
|
|
50
|
+
path: c.req.path,
|
|
51
|
+
method: c.req.method,
|
|
52
|
+
headers,
|
|
53
|
+
err: err instanceof Error ? err.message : String(err),
|
|
54
|
+
cause: err instanceof Error && err.cause ? String(err.cause) : undefined,
|
|
55
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* ignore logging errors */
|
|
60
|
+
}
|
|
61
|
+
const statusCode = status >= 100 && status <= 599 ? status : 500;
|
|
62
|
+
return new Response(JSON.stringify(apiErrorSchema.parse({ error: errorMessage, code, requestId: id, details })), {
|
|
63
|
+
status: statusCode,
|
|
64
|
+
headers: {
|
|
65
|
+
"content-type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export const errorBoundary = async (c, next) => {
|
|
70
|
+
try {
|
|
71
|
+
await next();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return handleApiError(err, c);
|
|
75
|
+
}
|
|
76
|
+
};
|