@fonderie/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,15 @@ interface CorsOptions {
9
9
  headers?: string[];
10
10
  /** Response headers exposed to browser JS (Access-Control-Expose-Headers). */
11
11
  exposeHeaders?: string[];
12
- origin?: string | ((requestOrigin: string) => boolean);
12
+ /**
13
+ * Who may call this API from a browser. A single origin, a list (apex and
14
+ * www are two different origins), or a predicate for patterns such as
15
+ * preview deployments.
16
+ *
17
+ * String forms are NORMALIZED — see normalizeOrigin. A predicate receives
18
+ * the raw `Origin` header and owns its own matching.
19
+ */
20
+ origin?: string | string[] | ((requestOrigin: string) => boolean);
13
21
  /**
14
22
  * Allow credentialed requests. @fonderie/client always fetches with
15
23
  * credentials:'include', so a browser frontend on another origin needs
@@ -19,6 +27,21 @@ interface CorsOptions {
19
27
  credentials?: boolean;
20
28
  }
21
29
  type ResolvedCorsOptions = Required<CorsOptions>;
30
+ /**
31
+ * An `Origin` header is `scheme://host[:port]` and, per RFC 6454, never carries
32
+ * a path or a trailing slash — so a configured origin with one can never match
33
+ * anything. That makes it unambiguously a typo rather than intent, and the
34
+ * usual one: every address bar and dashboard "copy URL" hands you the slash.
35
+ *
36
+ * The failure it caused was a total outage with a misleading message — the
37
+ * browser blocks every request and the app reports "can't reach the server" —
38
+ * so normalizing beats honouring a value that cannot work. Scheme and host are
39
+ * case-insensitive and browsers send them lowercased, so casing is folded too.
40
+ *
41
+ * A path (`https://x.com/app`) is a DIFFERENT mistake that normalizing cannot
42
+ * silently repair, so it warns instead.
43
+ */
44
+ declare function normalizeOrigin(origin: string): string;
22
45
  declare function resolveCorsOptions(options?: CorsOptions): ResolvedCorsOptions;
23
46
  declare function corsHeadersFor(resolved: ResolvedCorsOptions, requestOrigin: string): Record<string, string>;
24
47
  declare function withCors(options?: CorsOptions): Middleware;
@@ -65,4 +88,4 @@ declare function validate(schema: IRequestSchema): Middleware;
65
88
  declare function resolveClientIp(socketAddress: string | undefined, headers: Headers, trustProxy?: number): string | undefined;
66
89
  declare function checkProxyConfig(socketAddress: string | undefined, headers: Headers, trustProxy: number): void;
67
90
 
68
- export { type CorsOptions, DEFAULT_CORS_EXPOSE_HEADERS, DEFAULT_CORS_HEADERS, FONDERIE_CLIENT_HEADERS, type IRequestSchema, type ResolvedCorsOptions, type SecurityHeadersOptions, checkProxyConfig, corsHeadersFor, defaultErrorHandler, notFoundMiddleware, requireAdminToken, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, resolveCorsOptions, validate, validateAdminToken, withCors, withLogger, withSecurityHeaders };
91
+ export { type CorsOptions, DEFAULT_CORS_EXPOSE_HEADERS, DEFAULT_CORS_HEADERS, FONDERIE_CLIENT_HEADERS, type IRequestSchema, type ResolvedCorsOptions, type SecurityHeadersOptions, checkProxyConfig, corsHeadersFor, defaultErrorHandler, normalizeOrigin, notFoundMiddleware, requireAdminToken, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, resolveCorsOptions, validate, validateAdminToken, withCors, withLogger, withSecurityHeaders };
@@ -9,7 +9,15 @@ interface CorsOptions {
9
9
  headers?: string[];
10
10
  /** Response headers exposed to browser JS (Access-Control-Expose-Headers). */
11
11
  exposeHeaders?: string[];
12
- origin?: string | ((requestOrigin: string) => boolean);
12
+ /**
13
+ * Who may call this API from a browser. A single origin, a list (apex and
14
+ * www are two different origins), or a predicate for patterns such as
15
+ * preview deployments.
16
+ *
17
+ * String forms are NORMALIZED — see normalizeOrigin. A predicate receives
18
+ * the raw `Origin` header and owns its own matching.
19
+ */
20
+ origin?: string | string[] | ((requestOrigin: string) => boolean);
13
21
  /**
14
22
  * Allow credentialed requests. @fonderie/client always fetches with
15
23
  * credentials:'include', so a browser frontend on another origin needs
@@ -19,6 +27,21 @@ interface CorsOptions {
19
27
  credentials?: boolean;
20
28
  }
21
29
  type ResolvedCorsOptions = Required<CorsOptions>;
30
+ /**
31
+ * An `Origin` header is `scheme://host[:port]` and, per RFC 6454, never carries
32
+ * a path or a trailing slash — so a configured origin with one can never match
33
+ * anything. That makes it unambiguously a typo rather than intent, and the
34
+ * usual one: every address bar and dashboard "copy URL" hands you the slash.
35
+ *
36
+ * The failure it caused was a total outage with a misleading message — the
37
+ * browser blocks every request and the app reports "can't reach the server" —
38
+ * so normalizing beats honouring a value that cannot work. Scheme and host are
39
+ * case-insensitive and browsers send them lowercased, so casing is folded too.
40
+ *
41
+ * A path (`https://x.com/app`) is a DIFFERENT mistake that normalizing cannot
42
+ * silently repair, so it warns instead.
43
+ */
44
+ declare function normalizeOrigin(origin: string): string;
22
45
  declare function resolveCorsOptions(options?: CorsOptions): ResolvedCorsOptions;
23
46
  declare function corsHeadersFor(resolved: ResolvedCorsOptions, requestOrigin: string): Record<string, string>;
24
47
  declare function withCors(options?: CorsOptions): Middleware;
@@ -65,4 +88,4 @@ declare function validate(schema: IRequestSchema): Middleware;
65
88
  declare function resolveClientIp(socketAddress: string | undefined, headers: Headers, trustProxy?: number): string | undefined;
66
89
  declare function checkProxyConfig(socketAddress: string | undefined, headers: Headers, trustProxy: number): void;
67
90
 
68
- export { type CorsOptions, DEFAULT_CORS_EXPOSE_HEADERS, DEFAULT_CORS_HEADERS, FONDERIE_CLIENT_HEADERS, type IRequestSchema, type ResolvedCorsOptions, type SecurityHeadersOptions, checkProxyConfig, corsHeadersFor, defaultErrorHandler, notFoundMiddleware, requireAdminToken, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, resolveCorsOptions, validate, validateAdminToken, withCors, withLogger, withSecurityHeaders };
91
+ export { type CorsOptions, DEFAULT_CORS_EXPOSE_HEADERS, DEFAULT_CORS_HEADERS, FONDERIE_CLIENT_HEADERS, type IRequestSchema, type ResolvedCorsOptions, type SecurityHeadersOptions, checkProxyConfig, corsHeadersFor, defaultErrorHandler, normalizeOrigin, notFoundMiddleware, requireAdminToken, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, resolveCorsOptions, validate, validateAdminToken, withCors, withLogger, withSecurityHeaders };
@@ -2,15 +2,31 @@
2
2
  var FONDERIE_CLIENT_HEADERS = ["X-Request-ID", "traceparent", "X-Workspace-ID"];
3
3
  var DEFAULT_CORS_HEADERS = ["Content-Type", "Authorization", ...FONDERIE_CLIENT_HEADERS];
4
4
  var DEFAULT_CORS_EXPOSE_HEADERS = ["X-Request-ID"];
5
+ function normalizeOrigin(origin) {
6
+ const trimmed = origin.trim();
7
+ if (trimmed === "*") return trimmed;
8
+ const withoutTrailingSlashes = trimmed.replace(/\/+$/, "");
9
+ const match = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+)(\/.*)?$/.exec(withoutTrailingSlashes);
10
+ if (!match) return withoutTrailingSlashes;
11
+ const [, schemeAndHost, path] = match;
12
+ if (path) {
13
+ console.warn(
14
+ `[fonderie] CORS origin "${origin}" contains a path. An Origin header is scheme://host[:port] only, so this can never match a real request \u2014 use "${schemeAndHost.toLowerCase()}".`
15
+ );
16
+ }
17
+ return schemeAndHost.toLowerCase();
18
+ }
5
19
  function resolveCorsOptions(options = {}) {
20
+ const rawOrigin = options.origin ?? "*";
6
21
  const resolved = {
7
- origin: options.origin ?? "*",
22
+ origin: typeof rawOrigin === "string" ? normalizeOrigin(rawOrigin) : Array.isArray(rawOrigin) ? rawOrigin.map(normalizeOrigin) : rawOrigin,
8
23
  headers: options.headers ?? DEFAULT_CORS_HEADERS,
9
24
  exposeHeaders: options.exposeHeaders ?? DEFAULT_CORS_EXPOSE_HEADERS,
10
25
  methods: options.methods ?? ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
11
26
  credentials: options.credentials ?? false
12
27
  };
13
- if (resolved.credentials && resolved.origin === "*") {
28
+ const allowsWildcard = resolved.origin === "*" || Array.isArray(resolved.origin) && resolved.origin.includes("*");
29
+ if (resolved.credentials && allowsWildcard) {
14
30
  throw new Error(
15
31
  "withCors: credentials:true cannot be combined with origin:'*' (browsers reject it). Pass the frontend origin, or a predicate \u2014 `origin: () => true` deliberately reflects any origin."
16
32
  );
@@ -19,7 +35,16 @@ function resolveCorsOptions(options = {}) {
19
35
  }
20
36
  function corsHeadersFor(resolved, requestOrigin) {
21
37
  const { origin, headers, exposeHeaders, methods, credentials } = resolved;
22
- const allowOrigin = typeof origin === "function" ? origin(requestOrigin) ? requestOrigin : "" : origin;
38
+ let allowOrigin;
39
+ if (typeof origin === "function") {
40
+ allowOrigin = origin(requestOrigin) ? requestOrigin : "";
41
+ } else if (Array.isArray(origin)) {
42
+ allowOrigin = origin.includes(normalizeOrigin(requestOrigin)) ? requestOrigin : "";
43
+ } else if (origin === "*") {
44
+ allowOrigin = "*";
45
+ } else {
46
+ allowOrigin = normalizeOrigin(requestOrigin) === origin ? requestOrigin : "";
47
+ }
23
48
  const corsHeaders = {
24
49
  "Access-Control-Max-Age": "86400",
25
50
  "Access-Control-Allow-Methods": methods.join(", "),
@@ -30,7 +55,7 @@ function corsHeadersFor(resolved, requestOrigin) {
30
55
  }
31
56
  if (credentials) corsHeaders["Access-Control-Allow-Credentials"] = "true";
32
57
  if (allowOrigin) corsHeaders["Access-Control-Allow-Origin"] = allowOrigin;
33
- if (typeof origin === "function") corsHeaders["Vary"] = "Origin";
58
+ if (origin !== "*") corsHeaders["Vary"] = "Origin";
34
59
  return corsHeaders;
35
60
  }
36
61
  function withCors(options = {}) {
@@ -399,6 +424,7 @@ export {
399
424
  checkProxyConfig,
400
425
  corsHeadersFor,
401
426
  defaultErrorHandler,
427
+ normalizeOrigin,
402
428
  notFoundMiddleware,
403
429
  requireAdminToken,
404
430
  requireAnyAuth,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/cors.ts","../../src/middlewares/logger.ts","../../src/response.ts","../../src/middlewares/not-found.ts","../../src/middlewares/body-parser.ts","../../src/middlewares/security-headers.ts","../../src/middlewares/error-handler.ts","../../src/middlewares/require-auth.ts","../../src/crypto.ts","../../src/secret-strength.ts","../../src/middlewares/require-admin-token.ts","../../src/middlewares/require-verified.ts","../../src/middlewares/validate.ts","../../src/middlewares/client-ip.ts"],"sourcesContent":["import type { Middleware } from '../types';\n\n// Request headers @fonderie/client sends on browser calls. A preflight\n// rejects the WHOLE request when any of them is missing from the allow-list,\n// so they ship as defaults here, in lockstep with the client:\n// X-Request-ID — request correlation (client >= 0.19)\n// traceparent — W3C trace context (client >= 0.20)\n// X-Workspace-ID — workspace scoping (setWorkspaceId)\nexport const FONDERIE_CLIENT_HEADERS = ['X-Request-ID', 'traceparent', 'X-Workspace-ID'];\n\nexport const DEFAULT_CORS_HEADERS = ['Content-Type', 'Authorization', ...FONDERIE_CLIENT_HEADERS];\n\n// Response headers browser JS is allowed to read. Without X-Request-ID here\n// the client cannot see the echoed correlation id — FonderieApiError.requestId\n// would silently stay at the client-minted value instead of the server echo.\nexport const DEFAULT_CORS_EXPOSE_HEADERS = ['X-Request-ID'];\n\nexport interface CorsOptions {\n\tmethods?: string[];\n\theaders?: string[];\n\t/** Response headers exposed to browser JS (Access-Control-Expose-Headers). */\n\texposeHeaders?: string[];\n\torigin?: string | ((requestOrigin: string) => boolean);\n\t/**\n\t * Allow credentialed requests. @fonderie/client always fetches with\n\t * credentials:'include', so a browser frontend on another origin needs\n\t * this on. Requires an explicit `origin` — browsers reject '*' on\n\t * credentialed responses.\n\t */\n\tcredentials?: boolean;\n}\n\nexport type ResolvedCorsOptions = Required<CorsOptions>;\n\n// Applies the defaults and rejects impossible combinations at boot. The\n// framework adapters' native cors() middlewares resolve through here too, so\n// every mounting style shares one contract and one failure mode.\nexport function resolveCorsOptions(options: CorsOptions = {}): ResolvedCorsOptions {\n\tconst resolved: ResolvedCorsOptions = {\n\t\torigin: options.origin ?? '*',\n\t\theaders: options.headers ?? DEFAULT_CORS_HEADERS,\n\t\texposeHeaders: options.exposeHeaders ?? DEFAULT_CORS_EXPOSE_HEADERS,\n\t\tmethods: options.methods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],\n\t\tcredentials: options.credentials ?? false,\n\t};\n\t// Browsers reject `Access-Control-Allow-Origin: *` on credentialed\n\t// requests — cookies demand a deliberate origin choice. Fail at boot with\n\t// a clear message, not per-request as an opaque browser error. Reflecting\n\t// every origin stays possible, but only as an explicit opt-in.\n\tif (resolved.credentials && resolved.origin === '*') {\n\t\tthrow new Error(\n\t\t\t\"withCors: credentials:true cannot be combined with origin:'*' (browsers reject it). \" +\n\t\t\t\t'Pass the frontend origin, or a predicate — `origin: () => true` deliberately reflects any origin.',\n\t\t);\n\t}\n\treturn resolved;\n}\n\n// The response headers for one request. Pure — withCors and the adapters'\n// native middlewares all emit exactly this, so the header contract cannot\n// fork per framework.\nexport function corsHeadersFor(\n\tresolved: ResolvedCorsOptions,\n\trequestOrigin: string,\n): Record<string, string> {\n\tconst { origin, headers, exposeHeaders, methods, credentials } = resolved;\n\n\tconst allowOrigin =\n\t\ttypeof origin === 'function' ? (origin(requestOrigin) ? requestOrigin : '') : origin;\n\n\tconst corsHeaders: Record<string, string> = {\n\t\t'Access-Control-Max-Age': '86400',\n\t\t'Access-Control-Allow-Methods': methods.join(', '),\n\t\t'Access-Control-Allow-Headers': headers.join(', '),\n\t};\n\tif (exposeHeaders.length > 0) {\n\t\tcorsHeaders['Access-Control-Expose-Headers'] = exposeHeaders.join(', ');\n\t}\n\tif (credentials) corsHeaders['Access-Control-Allow-Credentials'] = 'true';\n\t// Omit the header entirely for a denied origin (an empty ACAO value is\n\t// invalid); when the value varies by request origin, say so — otherwise a\n\t// shared cache can serve one origin's ACAO to another.\n\tif (allowOrigin) corsHeaders['Access-Control-Allow-Origin'] = allowOrigin;\n\tif (typeof origin === 'function') corsHeaders['Vary'] = 'Origin';\n\n\treturn corsHeaders;\n}\n\nexport function withCors(options: CorsOptions = {}): Middleware {\n\tconst resolved = resolveCorsOptions(options);\n\n\treturn async (ctx, next) => {\n\t\tconst corsHeaders = corsHeadersFor(resolved, ctx.request.headers.get('origin') ?? '');\n\n\t\t// Preflight — respond immediately, skip the pipeline\n\t\tif (ctx.request.method === 'OPTIONS') {\n\t\t\treturn new Response(null, { status: 204, headers: corsHeaders });\n\t\t}\n\n\t\tconst response = await next();\n\n\t\tconst patched = new Headers(response.headers);\n\n\t\tfor (const [k, v] of Object.entries(corsHeaders)) {\n\t\t\tpatched.set(k, v);\n\t\t}\n\n\t\treturn new Response(response.body, {\n\t\t\theaders: patched,\n\t\t\tstatus: response.status,\n\t\t\tstatusText: response.statusText,\n\t\t});\n\t};\n}\n","import type { Middleware } from '../types';\n\nexport const withLogger: Middleware = async (ctx, next) => {\n\tconst start = Date.now();\n\n\tconst { method, url } = ctx.request;\n\tconst { pathname } = new URL(url);\n\n\tconst response = await next();\n\n\tconsole.log(\n\t\tJSON.stringify({\n\t\t\tts: new Date().toISOString(),\n\t\t\tmethod,\n\t\t\tpath: pathname,\n\t\t\tstatus: response.status,\n\t\t\tms: Date.now() - start,\n\t\t}),\n\t);\n\n\treturn response;\n};\n","export const HTTP = {\n\tOK: 200,\n\tCREATED: 201,\n\tACCEPTED: 202,\n\tNO_CONTENT: 204,\n\tBAD_REQUEST: 400,\n\tUNAUTHORIZED: 401,\n\tPAYMENT_REQUIRED: 402,\n\tFORBIDDEN: 403,\n\tNOT_FOUND: 404,\n\tCONFLICT: 409,\n\tGONE: 410,\n\tPAYLOAD_TOO_LARGE: 413,\n\tUNPROCESSABLE: 422,\n\tTOO_MANY_REQUESTS: 429,\n\tSERVER_ERROR: 500,\n\tNOT_IMPLEMENTED: 501,\n\tBAD_GATEWAY: 502,\n\tSERVICE_UNAVAILABLE: 503,\n} as const;\n\nexport type HttpStatus = (typeof HTTP)[keyof typeof HTTP];\n\nexport interface IApiEnvelope {\n\treason: string;\n\texplanation: string;\n\tresult?: unknown;\n}\n\nexport interface IApiError {\n\treason: string;\n\texplanation: string;\n\tdetails?: unknown;\n}\n\nexport function setApiResponse<T>(\n\tstatus: number,\n\treason: string,\n\texplanation: string,\n\tpayload?: T,\n): Response {\n\tconst body: Record<string, unknown> = { reason, explanation };\n\tif (payload !== undefined) {\n\t\tbody[status < 400 ? 'result' : 'details'] = payload;\n\t}\n\treturn Response.json(body, { status });\n}\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\nexport function notFoundMiddleware(): Middleware {\n\treturn async (_ctx, _next) => setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'Not found');\n}\n","import type { Middleware } from '../types';\nimport { setApiResponse, HTTP } from '../response';\n\n/**\n * Default cap on the request body the pipeline will buffer, in bytes (5 MiB).\n * Enforced HERE, in the body parser, so EVERY entry point inherits it — the\n * built-in listen() server, and all adapters' buildContext()/handle() paths.\n * (An uncapped parser was a memory-exhaustion DoS on any adapter whose\n * transport didn't add its own cap, e.g. adapter-hono on node-server.)\n */\nexport const DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024;\n\nclass PayloadTooLargeError extends Error {\n\treadonly fonderiePayloadTooLarge = true as const;\n}\n\n// Read the request body as text, refusing to buffer past maxBytes: a\n// Content-Length fast path rejects declared-oversize bodies without reading a\n// byte, and the streamed read stops the moment a chunked/lying body crosses\n// the cap. Returns null when there is no body stream.\n//\n// Deliberately CONSUMES the original stream instead of clone()-ing it:\n// clone() tees the stream, and a tee applies backpressure from BOTH branches\n// — with the second branch never read, any body larger than the stream's\n// high-water mark stalls the read forever. The caller re-materializes\n// ctx.request from the buffered text so downstream raw-body readers (e.g.\n// webhook signature verification) keep working.\nasync function readBytesCapped(req: Request, maxBytes: number): Promise<Uint8Array | null> {\n\tconst declared = Number(req.headers.get('content-length'));\n\tif (Number.isFinite(declared) && declared > maxBytes) {\n\t\tthrow new PayloadTooLargeError();\n\t}\n\n\tif (!req.body) return null;\n\n\tconst reader = req.body.getReader();\n\tconst chunks: Uint8Array[] = [];\n\tlet total = 0;\n\tfor (;;) {\n\t\tconst { done, value } = await reader.read();\n\t\tif (done) break;\n\t\ttotal += value.byteLength;\n\t\tif (total > maxBytes) {\n\t\t\tawait reader.cancel().catch(() => undefined);\n\t\t\tthrow new PayloadTooLargeError();\n\t\t}\n\t\tchunks.push(value);\n\t}\n\n\tconst merged = new Uint8Array(total);\n\tlet offset = 0;\n\tfor (const c of chunks) {\n\t\tmerged.set(c, offset);\n\t\toffset += c.byteLength;\n\t}\n\treturn merged;\n}\n\n/**\n * Build the body-parsing middleware with an explicit byte cap. The core app\n * wires this with `config.maxBodyBytes`; the bare `withBody` export below\n * keeps the default cap for direct users.\n */\nexport function bodyParser(maxBytes: number = DEFAULT_MAX_BODY_BYTES): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst method = ctx.request.method.toUpperCase();\n\n\t\tif (method === 'GET' || method === 'HEAD') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Universal Content-Length cap — regardless of content-type. The parser\n\t\t// only READS json/form bodies (so only those get the streamed cap), but\n\t\t// a declared-oversize body of ANY type (notably multipart, which the\n\t\t// parser hands to the route) must be refused here — otherwise, on an\n\t\t// adapter with no transport-level cap (adapter-hono on node-server), a\n\t\t// large multipart upload buffered by the route is an unbounded-memory\n\t\t// DoS. The route still owns the chunked/no-Content-Length streaming case.\n\t\tconst declared = Number(ctx.request.headers.get('content-length'));\n\t\tif (Number.isFinite(declared) && declared > maxBytes) {\n\t\t\treturn setApiResponse(HTTP.PAYLOAD_TOO_LARGE, 'PAYLOAD_TOO_LARGE', 'Request body too large');\n\t\t}\n\n\t\tconst ct = ctx.request.headers.get('content-type') ?? '';\n\n\t\ttry {\n\t\t\tif (ct.includes('application/json') || ct.includes('application/x-www-form-urlencoded')) {\n\t\t\t\tconst bytes = await readBytesCapped(ctx.request, maxBytes);\n\t\t\t\t// The read consumed the original stream — re-materialize the request\n\t\t\t\t// with the ORIGINAL BYTES (not a re-encoded string) so a handler\n\t\t\t\t// that reads the raw body for signature verification (Stripe /\n\t\t\t\t// SendGrid webhooks) gets byte-identical input, even for payloads\n\t\t\t\t// with a BOM or non-UTF-8 bytes.\n\t\t\t\tif (bytes !== null) {\n\t\t\t\t\tctx.request = new Request(ctx.request.url, {\n\t\t\t\t\t\tmethod: ctx.request.method,\n\t\t\t\t\t\theaders: ctx.request.headers,\n\t\t\t\t\t\t// Cast: a Uint8Array is a valid BodyInit at runtime; the lib's\n\t\t\t\t\t\t// BodyInit union is narrower than Uint8Array<ArrayBufferLike>.\n\t\t\t\t\t\tbody: bytes.length > 0 ? (bytes as unknown as BodyInit) : null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tconst text = bytes ? new TextDecoder().decode(bytes) : '';\n\t\t\t\tif (ct.includes('application/json')) {\n\t\t\t\t\tconst trimmed = text.trim();\n\t\t\t\t\tctx.meta.body = trimmed ? JSON.parse(trimmed) : {};\n\t\t\t\t} else {\n\t\t\t\t\tctx.meta.body = Object.fromEntries(new URLSearchParams(text));\n\t\t\t\t}\n\t\t\t}\n\t\t\t// multipart/form-data left to the handler — no dep-free way to parse it\n\t\t} catch (err) {\n\t\t\tif ((err as { fonderiePayloadTooLarge?: boolean } | null)?.fonderiePayloadTooLarge) {\n\t\t\t\treturn setApiResponse(\n\t\t\t\t\tHTTP.PAYLOAD_TOO_LARGE,\n\t\t\t\t\t'PAYLOAD_TOO_LARGE',\n\t\t\t\t\t'Request body too large',\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'INVALID_REQUEST', 'Invalid request body');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\n// Backward-compatible bare middleware with the default cap.\nexport const withBody: Middleware = bodyParser();\n","import type { Middleware } from '../types';\n\nexport interface SecurityHeadersOptions {\n\t// HSTS max-age in seconds. Default 180 days. Set 0 to omit the header.\n\thstsMaxAge?: number;\n\t// Add `includeSubDomains` to the HSTS header. Off by default — only enable\n\t// once every subdomain is known to serve HTTPS.\n\thstsIncludeSubDomains?: boolean;\n\t// Add `preload` to the HSTS header (implies includeSubDomains). Off by default.\n\thstsPreload?: boolean;\n}\n\n// Baseline response hardening. `X-Content-Type-Options: nosniff` is always safe.\n// HSTS is only meaningful — and only emitted — over HTTPS: browsers ignore it on\n// plain HTTP, so gating on the effective scheme keeps local http/dev untouched\n// while enforcing TLS in production (behind a TLS-terminating proxy, detected via\n// X-Forwarded-Proto). Wired into the default pipeline by FonderieApp.\nexport function withSecurityHeaders(options: SecurityHeadersOptions = {}): Middleware {\n\tconst {\n\t\thstsMaxAge = 60 * 60 * 24 * 180,\n\t\thstsIncludeSubDomains = false,\n\t\thstsPreload = false,\n\t} = options;\n\n\tlet hsts = '';\n\tif (hstsMaxAge > 0) {\n\t\thsts = `max-age=${hstsMaxAge}`;\n\t\tif (hstsIncludeSubDomains || hstsPreload) hsts += '; includeSubDomains';\n\t\tif (hstsPreload) hsts += '; preload';\n\t}\n\n\treturn async (ctx, next) => {\n\t\tconst response = await next();\n\t\tconst patched = new Headers(response.headers);\n\n\t\tpatched.set('X-Content-Type-Options', 'nosniff');\n\n\t\tif (hsts && isHttps(ctx.request)) {\n\t\t\tpatched.set('Strict-Transport-Security', hsts);\n\t\t}\n\n\t\treturn new Response(response.body, {\n\t\t\theaders: patched,\n\t\t\tstatus: response.status,\n\t\t\tstatusText: response.statusText,\n\t\t});\n\t};\n}\n\n// HTTPS if the request URL is https, or a TLS-terminating proxy says so.\nfunction isHttps(request: Request): boolean {\n\tif (request.url.startsWith('https:')) return true;\n\tconst proto = request.headers.get('x-forwarded-proto');\n\treturn proto?.split(',')[0]?.trim() === 'https';\n}\n","import { setApiResponse, HTTP } from '../response';\n\nexport function defaultErrorHandler(err: unknown): Response {\n\t// Only leak the raw error message in EXPLICITLY non-production-like envs.\n\t// `NODE_ENV !== 'production'` also covered 'staging' and any custom value,\n\t// where an error message can carry connection strings / PII. Unknown or\n\t// unset NODE_ENV is treated as production-safe (no leak).\n\tconst env = process.env['NODE_ENV'];\n\tconst dev = env === 'development' || env === 'test';\n\n\tif (err instanceof Error) {\n\t\tconsole.error('[fonderie]', err.message, err.stack);\n\t\treturn setApiResponse(\n\t\t\tHTTP.SERVER_ERROR,\n\t\t\t'SERVER_ERROR',\n\t\t\tdev ? err.message : 'Internal server error',\n\t\t);\n\t}\n\n\tconsole.error('[fonderie] unknown error', err);\n\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Internal server error');\n}\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\n// Requires a fully-authenticated user. Rejects mfaPending tokens — those are\n// short-lived pre-auth tokens issued mid-MFA-login and must not grant access\n// to any route other than /auth/mfa/verify.\nexport const requireAuth: Middleware = async (ctx, next) => {\n\tif (!ctx.user) {\n\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t}\n\tif (ctx.user.mfaPending) {\n\t\treturn setApiResponse(HTTP.FORBIDDEN, 'MFA_REQUIRED', 'Complete MFA verification to continue');\n\t}\n\treturn next();\n};\n\n// Accepts both fully-authenticated and mfaPending tokens. Only for routes that\n// need to serve both contexts on the same path (e.g. POST /auth/mfa/verify\n// handles setup confirmation with a full token and TOTP login with mfaPending).\nexport const requireAnyAuth: Middleware = async (ctx, next) => {\n\tif (!ctx.user) {\n\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t}\n\treturn next();\n};\n","import { timingSafeEqual } from 'node:crypto';\n\n// The one timing-attack-safe equality across Fonderie — admin tokens (billing/\n// config/courier), MFA/TOTP codes (auth), event-log + inbound webhook-signature\n// HMACs (events/courier). Length-guard first: timingSafeEqual throws on unequal\n// lengths, and the length is not the secret. Accepts strings or Buffers (callers\n// comparing decoded signature bytes pass Buffers).\nexport function constantTimeEqual(a: string | Buffer, b: string | Buffer): boolean {\n\tconst bufA = Buffer.isBuffer(a) ? a : Buffer.from(a);\n\tconst bufB = Buffer.isBuffer(b) ? b : Buffer.from(b);\n\tif (bufA.length !== bufB.length) return false;\n\treturn timingSafeEqual(bufA, bufB);\n}\n","// One weak/placeholder-secret denylist + minimum length, so every module that\n// guards a surface with a bootstrap secret (auth jwtSecret, module adminTokens)\n// enforces the SAME bar. Call sites keep their own POLICY (required vs optional,\n// which field name to name in the message) — only the rule is shared.\nexport const MIN_SECRET_LENGTH = 32;\nexport const PLACEHOLDER_SECRET =\n\t/dev-secret|test-secret|changeme|change-me|your[-_]secret|placeholder|example|insecure|admin-token|min-32-chars/i;\n\n// The single classification. null = strong enough. Each caller formats its own\n// IReadinessProblem (naming its field) from this verdict.\nexport function secretStrengthProblem(secret: string): 'too-short' | 'placeholder' | null {\n\tif (secret.length < MIN_SECRET_LENGTH) return 'too-short';\n\tif (PLACEHOLDER_SECRET.test(secret)) return 'placeholder';\n\treturn null;\n}\n","import { constantTimeEqual } from '../crypto';\nimport { setApiResponse, HTTP } from '../response';\nimport { MIN_SECRET_LENGTH, secretStrengthProblem } from '../secret-strength';\nimport type { IReadinessProblem, Middleware } from '../types';\n\n// The one admin-route guard for every Fonderie module with an ops surface\n// (billing plan-writes/wallet-grant, config/secrets admin, courier template\n// admin). A bootstrap Bearer token compared in constant time. Modules MUST\n// register their admin routes only when a token is configured (unset ⇒ 404),\n// so this guard only ever runs against a real token. See docs/ADMIN-AUTH-SPEC.md.\nexport function requireAdminToken(adminToken: string): Middleware {\n\treturn (ctx, next) => {\n\t\tconst header = ctx.request.headers.get('authorization') ?? '';\n\t\tconst token = header.startsWith('Bearer ') ? header.slice(7) : '';\n\t\t// Same 401 for a missing and a wrong token — no oracle distinguishing them.\n\t\tif (!token || !constantTimeEqual(token, adminToken)) {\n\t\t\treturn Promise.resolve(\n\t\t\t\tsetApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing or invalid admin token'),\n\t\t\t);\n\t\t}\n\t\treturn next();\n\t};\n}\n\n// A bootstrap admin token must be strong: an admin surface guarded by a short or\n// placeholder token is barely guarded at all. Modules call this from\n// checkReadiness() so the rule (shared secret-strength denylist) is enforced\n// identically everywhere. Returns a problem for a weak/placeholder token;\n// nothing when unset (that surface is simply not exposed).\nexport function validateAdminToken(\n\ttoken: string | undefined,\n\topts: { module: string },\n): IReadinessProblem[] {\n\tif (!token) return [];\n\tconst problem = secretStrengthProblem(token);\n\tif (problem === 'too-short') {\n\t\treturn [\n\t\t\t{\n\t\t\t\tmodule: opts.module,\n\t\t\t\tseverity: 'error',\n\t\t\t\tmessage: `adminToken must be at least ${MIN_SECRET_LENGTH} characters (got ${token.length})`,\n\t\t\t},\n\t\t];\n\t}\n\tif (problem === 'placeholder') {\n\t\treturn [\n\t\t\t{ module: opts.module, severity: 'error', message: 'adminToken looks like a placeholder or dev-default value' },\n\t\t];\n\t}\n\treturn [];\n}\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\nexport const requireVerified: Middleware = async (ctx, next) => {\n\tif (!ctx.user) {\n\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t}\n\n\tif (ctx.user.loginMethod === 'phone') {\n\t\tif (!ctx.user.phoneVerified) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'PHONE_NOT_VERIFIED',\n\t\t\t\t'Please verify your phone number',\n\t\t\t);\n\t\t}\n\t\treturn next();\n\t}\n\n\tif (!ctx.user.emailVerifiedAt) {\n\t\treturn setApiResponse(HTTP.FORBIDDEN, 'EMAIL_NOT_VERIFIED', 'Please verify your email address');\n\t}\n\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\n// Route-boundary request validation — the one validation middleware every\n// package wires in front of its body-taking routes, so error shape and parse\n// semantics are identical across the whole surface.\n//\n// core stays dependency-free: this accepts anything implementing zod's\n// safeParse contract structurally (zod v3/v4 both match), without importing\n// zod. Feature packages own their schemas; see @fonderie/auth's schemas.ts\n// for the reference pattern.\n\nexport interface IRequestSchema {\n\tsafeParse(input: unknown):\n\t\t| { success: true; data: unknown }\n\t\t| { success: false; error: { issues: Array<{ path: PropertyKey[]; message: string }> } };\n}\n\nexport function validate(schema: IRequestSchema): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst result = schema.safeParse(ctx.meta['body'] ?? {});\n\t\tif (!result.success) {\n\t\t\tconst first = result.error.issues[0];\n\t\t\tconst path = first?.path.length ? `${first.path.map(String).join('.')}: ` : '';\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.UNPROCESSABLE,\n\t\t\t\t'INVALID_PARAMETER',\n\t\t\t\t`${path}${first?.message ?? 'Invalid request body'}`,\n\t\t\t);\n\t\t}\n\t\t// Parsed output replaces the raw body: trimmed, coerced, unknown keys\n\t\t// stripped — controllers read clean input.\n\t\tctx.meta['body'] = result.data;\n\t\treturn next();\n\t};\n}\n","// Client-IP resolution shared by the adapters. The web-standard Request the\n// pipeline runs on carries no socket address, so each adapter passes the\n// socket's remote address here together with the headers; this resolves the\n// effective client IP with explicit proxy trust.\n//\n// trustProxy semantics (deliberately explicit — a permissive default lets\n// any client spoof X-Forwarded-For and dodge per-IP rate limits):\n// 0 / undefined → ignore forwarding headers; the socket address is the client\n// N > 0 → the client is the Nth-from-the-right entry in\n// X-Forwarded-For (N = number of trusted proxy hops)\n//\n// ⚠️ THE PROXY FOOTGUN. With trustProxy=0 (the spoof-safe default) deployed\n// behind nginx, a Kubernetes ingress, or any L7 proxy, the socket address is\n// the PROXY's IP for every request — so every client collapses onto one\n// per-IP bucket and the limit becomes global (one attacker locks everyone\n// out). You cannot have a default that is both spoof-safe AND correct behind\n// a proxy; they contradict. So we ship spoof-safe and DETECT the mismatch:\n// checkProxyConfig() below warns once, loudly, when the deployment looks\n// proxied but trustProxy is unset. Set TRUST_PROXY=<hops> in that case.\n\nexport function resolveClientIp(\n\tsocketAddress: string | undefined,\n\theaders: Headers,\n\ttrustProxy: number = trustProxyFromEnv(),\n): string | undefined {\n\tif (trustProxy > 0) {\n\t\tconst xff = headers.get('x-forwarded-for');\n\t\tif (xff) {\n\t\t\tconst hops = xff\n\t\t\t\t.split(',')\n\t\t\t\t.map((s) => s.trim())\n\t\t\t\t.filter(Boolean);\n\t\t\tconst candidate = hops[Math.max(0, hops.length - trustProxy)];\n\t\t\tif (candidate) return normalizeIp(candidate);\n\t\t}\n\t}\n\tcheckProxyConfig(socketAddress, headers, trustProxy);\n\treturn socketAddress ? normalizeIp(socketAddress) : undefined;\n}\n\nfunction trustProxyFromEnv(): number {\n\tconst raw = Number(process.env['TRUST_PROXY']);\n\treturn Number.isFinite(raw) && raw > 0 ? raw : 0;\n}\n\nfunction normalizeIp(ip: string): string {\n\t// ::ffff:203.0.113.7 → 203.0.113.7 ; strip port if a proxy appended one\n\tconst noV6Prefix = ip.startsWith('::ffff:') ? ip.slice(7) : ip;\n\tconst m = noV6Prefix.match(/^(\\d{1,3}(?:\\.\\d{1,3}){3}):\\d+$/);\n\treturn m ? m[1]! : noV6Prefix;\n}\n\n// Address ranges that indicate a local proxy sits in front of us (so a\n// forwarding header without TRUST_PROXY is a misconfiguration, not spoofing).\nconst LOOPBACK_IPS = new Set(['127.0.0.1', '::1']);\nconst PRIVATE_IP_PREFIXES = [\n\t'10.', // RFC1918\n\t'192.168.', // RFC1918\n\t'169.254.', // link-local\n\t'fc', // IPv6 unique local (fc00::/7)\n\t'fd', // IPv6 unique local\n] as const;\nconst CGNAT_OR_RFC1918_172 = /^172\\.(1[6-9]|2\\d|3[01])\\./; // 172.16.0.0/12\n\nfunction isPrivateOrLoopback(ip: string): boolean {\n\tconst a = normalizeIp(ip);\n\treturn (\n\t\tLOOPBACK_IPS.has(a) ||\n\t\tCGNAT_OR_RFC1918_172.test(a) ||\n\t\tPRIVATE_IP_PREFIXES.some((prefix) => a.startsWith(prefix))\n\t);\n}\n\nlet warned = false;\n\n// Warn ONCE when the deployment looks proxied (forwarding header present, and\n// the socket is a private/loopback address — i.e. a local proxy) but\n// trustProxy is unset. That configuration silently rate-limits every client\n// as one IP. Emitting on the request path (not at boot) is deliberate: the\n// signal we need — an actual X-Forwarded-For header — only exists once real\n// traffic arrives.\nexport function checkProxyConfig(\n\tsocketAddress: string | undefined,\n\theaders: Headers,\n\ttrustProxy: number,\n): void {\n\tif (warned || trustProxy > 0) return;\n\tconst forwarded =\n\t\theaders.get('x-forwarded-for') ??\n\t\theaders.get('cf-connecting-ip') ??\n\t\theaders.get('x-real-ip');\n\tif (forwarded && socketAddress && isPrivateOrLoopback(socketAddress)) {\n\t\twarned = true;\n\t\tconsole.warn(\n\t\t\t'[fonderie] Requests carry a forwarding header (X-Forwarded-For) and ' +\n\t\t\t\t'arrive from a private/loopback socket, but TRUST_PROXY is unset. ' +\n\t\t\t\t'Every client is being rate-limited as a single IP, which will cause ' +\n\t\t\t\t'global lockout behind nginx / a Kubernetes ingress / any L7 proxy. ' +\n\t\t\t\t'Set TRUST_PROXY=<number of trusted proxy hops>. ' +\n\t\t\t\t'See @fonderie/rate-limit README § Deploying behind a proxy.',\n\t\t);\n\t}\n}\n\n// Test seam — reset the once-only warning latch.\nexport function _resetProxyWarning(): void {\n\twarned = false;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/middlewares/cors.ts","../../src/middlewares/logger.ts","../../src/response.ts","../../src/middlewares/not-found.ts","../../src/middlewares/body-parser.ts","../../src/middlewares/security-headers.ts","../../src/middlewares/error-handler.ts","../../src/middlewares/require-auth.ts","../../src/crypto.ts","../../src/secret-strength.ts","../../src/middlewares/require-admin-token.ts","../../src/middlewares/require-verified.ts","../../src/middlewares/validate.ts","../../src/middlewares/client-ip.ts"],"sourcesContent":["import type { Middleware } from '../types';\n\n// Request headers @fonderie/client sends on browser calls. A preflight\n// rejects the WHOLE request when any of them is missing from the allow-list,\n// so they ship as defaults here, in lockstep with the client:\n// X-Request-ID — request correlation (client >= 0.19)\n// traceparent — W3C trace context (client >= 0.20)\n// X-Workspace-ID — workspace scoping (setWorkspaceId)\nexport const FONDERIE_CLIENT_HEADERS = ['X-Request-ID', 'traceparent', 'X-Workspace-ID'];\n\nexport const DEFAULT_CORS_HEADERS = ['Content-Type', 'Authorization', ...FONDERIE_CLIENT_HEADERS];\n\n// Response headers browser JS is allowed to read. Without X-Request-ID here\n// the client cannot see the echoed correlation id — FonderieApiError.requestId\n// would silently stay at the client-minted value instead of the server echo.\nexport const DEFAULT_CORS_EXPOSE_HEADERS = ['X-Request-ID'];\n\nexport interface CorsOptions {\n\tmethods?: string[];\n\theaders?: string[];\n\t/** Response headers exposed to browser JS (Access-Control-Expose-Headers). */\n\texposeHeaders?: string[];\n\t/**\n\t * Who may call this API from a browser. A single origin, a list (apex and\n\t * www are two different origins), or a predicate for patterns such as\n\t * preview deployments.\n\t *\n\t * String forms are NORMALIZED — see normalizeOrigin. A predicate receives\n\t * the raw `Origin` header and owns its own matching.\n\t */\n\torigin?: string | string[] | ((requestOrigin: string) => boolean);\n\t/**\n\t * Allow credentialed requests. @fonderie/client always fetches with\n\t * credentials:'include', so a browser frontend on another origin needs\n\t * this on. Requires an explicit `origin` — browsers reject '*' on\n\t * credentialed responses.\n\t */\n\tcredentials?: boolean;\n}\n\nexport type ResolvedCorsOptions = Required<CorsOptions>;\n\n/**\n * An `Origin` header is `scheme://host[:port]` and, per RFC 6454, never carries\n * a path or a trailing slash — so a configured origin with one can never match\n * anything. That makes it unambiguously a typo rather than intent, and the\n * usual one: every address bar and dashboard \"copy URL\" hands you the slash.\n *\n * The failure it caused was a total outage with a misleading message — the\n * browser blocks every request and the app reports \"can't reach the server\" —\n * so normalizing beats honouring a value that cannot work. Scheme and host are\n * case-insensitive and browsers send them lowercased, so casing is folded too.\n *\n * A path (`https://x.com/app`) is a DIFFERENT mistake that normalizing cannot\n * silently repair, so it warns instead.\n */\nexport function normalizeOrigin(origin: string): string {\n\tconst trimmed = origin.trim();\n\tif (trimmed === '*') return trimmed;\n\n\tconst withoutTrailingSlashes = trimmed.replace(/\\/+$/, '');\n\n\t// Lowercase only scheme://host[:port]; anything after would be a path,\n\t// which is reported below rather than quietly reshaped.\n\tconst match = /^([a-zA-Z][a-zA-Z0-9+.-]*:\\/\\/[^/]+)(\\/.*)?$/.exec(withoutTrailingSlashes);\n\tif (!match) return withoutTrailingSlashes;\n\n\tconst [, schemeAndHost, path] = match;\n\tif (path) {\n\t\tconsole.warn(\n\t\t\t`[fonderie] CORS origin \"${origin}\" contains a path. An Origin header is ` +\n\t\t\t\t'scheme://host[:port] only, so this can never match a real request — ' +\n\t\t\t\t`use \"${schemeAndHost!.toLowerCase()}\".`,\n\t\t);\n\t}\n\treturn schemeAndHost!.toLowerCase();\n}\n\n// Applies the defaults and rejects impossible combinations at boot. The\n// framework adapters' native cors() middlewares resolve through here too, so\n// every mounting style shares one contract and one failure mode.\nexport function resolveCorsOptions(options: CorsOptions = {}): ResolvedCorsOptions {\n\tconst rawOrigin = options.origin ?? '*';\n\tconst resolved: ResolvedCorsOptions = {\n\t\torigin:\n\t\t\ttypeof rawOrigin === 'string'\n\t\t\t\t? normalizeOrigin(rawOrigin)\n\t\t\t\t: Array.isArray(rawOrigin)\n\t\t\t\t\t? rawOrigin.map(normalizeOrigin)\n\t\t\t\t\t: rawOrigin,\n\t\theaders: options.headers ?? DEFAULT_CORS_HEADERS,\n\t\texposeHeaders: options.exposeHeaders ?? DEFAULT_CORS_EXPOSE_HEADERS,\n\t\tmethods: options.methods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],\n\t\tcredentials: options.credentials ?? false,\n\t};\n\t// Browsers reject `Access-Control-Allow-Origin: *` on credentialed\n\t// requests — cookies demand a deliberate origin choice. Fail at boot with\n\t// a clear message, not per-request as an opaque browser error. Reflecting\n\t// every origin stays possible, but only as an explicit opt-in.\n\tconst allowsWildcard =\n\t\tresolved.origin === '*' ||\n\t\t(Array.isArray(resolved.origin) && resolved.origin.includes('*'));\n\tif (resolved.credentials && allowsWildcard) {\n\t\tthrow new Error(\n\t\t\t\"withCors: credentials:true cannot be combined with origin:'*' (browsers reject it). \" +\n\t\t\t\t'Pass the frontend origin, or a predicate — `origin: () => true` deliberately reflects any origin.',\n\t\t);\n\t}\n\treturn resolved;\n}\n\n// The response headers for one request. Pure — withCors and the adapters'\n// native middlewares all emit exactly this, so the header contract cannot\n// fork per framework.\nexport function corsHeadersFor(\n\tresolved: ResolvedCorsOptions,\n\trequestOrigin: string,\n): Record<string, string> {\n\tconst { origin, headers, exposeHeaders, methods, credentials } = resolved;\n\n\t// Echo the REQUEST's origin on a match, never the configured spelling: the\n\t// browser compares byte-for-byte against what it sent, so reflecting a\n\t// normalized-but-different string would fail the very check normalizing is\n\t// meant to survive.\n\tlet allowOrigin: string;\n\tif (typeof origin === 'function') {\n\t\tallowOrigin = origin(requestOrigin) ? requestOrigin : '';\n\t} else if (Array.isArray(origin)) {\n\t\tallowOrigin = origin.includes(normalizeOrigin(requestOrigin)) ? requestOrigin : '';\n\t} else if (origin === '*') {\n\t\tallowOrigin = '*';\n\t} else {\n\t\tallowOrigin = normalizeOrigin(requestOrigin) === origin ? requestOrigin : '';\n\t}\n\n\tconst corsHeaders: Record<string, string> = {\n\t\t'Access-Control-Max-Age': '86400',\n\t\t'Access-Control-Allow-Methods': methods.join(', '),\n\t\t'Access-Control-Allow-Headers': headers.join(', '),\n\t};\n\tif (exposeHeaders.length > 0) {\n\t\tcorsHeaders['Access-Control-Expose-Headers'] = exposeHeaders.join(', ');\n\t}\n\tif (credentials) corsHeaders['Access-Control-Allow-Credentials'] = 'true';\n\t// Omit the header entirely for a denied origin (an empty ACAO value is\n\t// invalid); when the value varies by request origin, say so — otherwise a\n\t// shared cache can serve one origin's ACAO to another.\n\tif (allowOrigin) corsHeaders['Access-Control-Allow-Origin'] = allowOrigin;\n\t// Vary whenever the emitted value depends on the request — a shared cache\n\t// must not serve one origin's ACAO to another. Only the literal '*' is\n\t// request-independent.\n\tif (origin !== '*') corsHeaders['Vary'] = 'Origin';\n\n\treturn corsHeaders;\n}\n\nexport function withCors(options: CorsOptions = {}): Middleware {\n\tconst resolved = resolveCorsOptions(options);\n\n\treturn async (ctx, next) => {\n\t\tconst corsHeaders = corsHeadersFor(resolved, ctx.request.headers.get('origin') ?? '');\n\n\t\t// Preflight — respond immediately, skip the pipeline\n\t\tif (ctx.request.method === 'OPTIONS') {\n\t\t\treturn new Response(null, { status: 204, headers: corsHeaders });\n\t\t}\n\n\t\tconst response = await next();\n\n\t\tconst patched = new Headers(response.headers);\n\n\t\tfor (const [k, v] of Object.entries(corsHeaders)) {\n\t\t\tpatched.set(k, v);\n\t\t}\n\n\t\treturn new Response(response.body, {\n\t\t\theaders: patched,\n\t\t\tstatus: response.status,\n\t\t\tstatusText: response.statusText,\n\t\t});\n\t};\n}\n","import type { Middleware } from '../types';\n\nexport const withLogger: Middleware = async (ctx, next) => {\n\tconst start = Date.now();\n\n\tconst { method, url } = ctx.request;\n\tconst { pathname } = new URL(url);\n\n\tconst response = await next();\n\n\tconsole.log(\n\t\tJSON.stringify({\n\t\t\tts: new Date().toISOString(),\n\t\t\tmethod,\n\t\t\tpath: pathname,\n\t\t\tstatus: response.status,\n\t\t\tms: Date.now() - start,\n\t\t}),\n\t);\n\n\treturn response;\n};\n","export const HTTP = {\n\tOK: 200,\n\tCREATED: 201,\n\tACCEPTED: 202,\n\tNO_CONTENT: 204,\n\tBAD_REQUEST: 400,\n\tUNAUTHORIZED: 401,\n\tPAYMENT_REQUIRED: 402,\n\tFORBIDDEN: 403,\n\tNOT_FOUND: 404,\n\tCONFLICT: 409,\n\tGONE: 410,\n\tPAYLOAD_TOO_LARGE: 413,\n\tUNPROCESSABLE: 422,\n\tTOO_MANY_REQUESTS: 429,\n\tSERVER_ERROR: 500,\n\tNOT_IMPLEMENTED: 501,\n\tBAD_GATEWAY: 502,\n\tSERVICE_UNAVAILABLE: 503,\n} as const;\n\nexport type HttpStatus = (typeof HTTP)[keyof typeof HTTP];\n\nexport interface IApiEnvelope {\n\treason: string;\n\texplanation: string;\n\tresult?: unknown;\n}\n\nexport interface IApiError {\n\treason: string;\n\texplanation: string;\n\tdetails?: unknown;\n}\n\nexport function setApiResponse<T>(\n\tstatus: number,\n\treason: string,\n\texplanation: string,\n\tpayload?: T,\n): Response {\n\tconst body: Record<string, unknown> = { reason, explanation };\n\tif (payload !== undefined) {\n\t\tbody[status < 400 ? 'result' : 'details'] = payload;\n\t}\n\treturn Response.json(body, { status });\n}\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\nexport function notFoundMiddleware(): Middleware {\n\treturn async (_ctx, _next) => setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'Not found');\n}\n","import type { Middleware } from '../types';\nimport { setApiResponse, HTTP } from '../response';\n\n/**\n * Default cap on the request body the pipeline will buffer, in bytes (5 MiB).\n * Enforced HERE, in the body parser, so EVERY entry point inherits it — the\n * built-in listen() server, and all adapters' buildContext()/handle() paths.\n * (An uncapped parser was a memory-exhaustion DoS on any adapter whose\n * transport didn't add its own cap, e.g. adapter-hono on node-server.)\n */\nexport const DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024;\n\nclass PayloadTooLargeError extends Error {\n\treadonly fonderiePayloadTooLarge = true as const;\n}\n\n// Read the request body as text, refusing to buffer past maxBytes: a\n// Content-Length fast path rejects declared-oversize bodies without reading a\n// byte, and the streamed read stops the moment a chunked/lying body crosses\n// the cap. Returns null when there is no body stream.\n//\n// Deliberately CONSUMES the original stream instead of clone()-ing it:\n// clone() tees the stream, and a tee applies backpressure from BOTH branches\n// — with the second branch never read, any body larger than the stream's\n// high-water mark stalls the read forever. The caller re-materializes\n// ctx.request from the buffered text so downstream raw-body readers (e.g.\n// webhook signature verification) keep working.\nasync function readBytesCapped(req: Request, maxBytes: number): Promise<Uint8Array | null> {\n\tconst declared = Number(req.headers.get('content-length'));\n\tif (Number.isFinite(declared) && declared > maxBytes) {\n\t\tthrow new PayloadTooLargeError();\n\t}\n\n\tif (!req.body) return null;\n\n\tconst reader = req.body.getReader();\n\tconst chunks: Uint8Array[] = [];\n\tlet total = 0;\n\tfor (;;) {\n\t\tconst { done, value } = await reader.read();\n\t\tif (done) break;\n\t\ttotal += value.byteLength;\n\t\tif (total > maxBytes) {\n\t\t\tawait reader.cancel().catch(() => undefined);\n\t\t\tthrow new PayloadTooLargeError();\n\t\t}\n\t\tchunks.push(value);\n\t}\n\n\tconst merged = new Uint8Array(total);\n\tlet offset = 0;\n\tfor (const c of chunks) {\n\t\tmerged.set(c, offset);\n\t\toffset += c.byteLength;\n\t}\n\treturn merged;\n}\n\n/**\n * Build the body-parsing middleware with an explicit byte cap. The core app\n * wires this with `config.maxBodyBytes`; the bare `withBody` export below\n * keeps the default cap for direct users.\n */\nexport function bodyParser(maxBytes: number = DEFAULT_MAX_BODY_BYTES): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst method = ctx.request.method.toUpperCase();\n\n\t\tif (method === 'GET' || method === 'HEAD') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Universal Content-Length cap — regardless of content-type. The parser\n\t\t// only READS json/form bodies (so only those get the streamed cap), but\n\t\t// a declared-oversize body of ANY type (notably multipart, which the\n\t\t// parser hands to the route) must be refused here — otherwise, on an\n\t\t// adapter with no transport-level cap (adapter-hono on node-server), a\n\t\t// large multipart upload buffered by the route is an unbounded-memory\n\t\t// DoS. The route still owns the chunked/no-Content-Length streaming case.\n\t\tconst declared = Number(ctx.request.headers.get('content-length'));\n\t\tif (Number.isFinite(declared) && declared > maxBytes) {\n\t\t\treturn setApiResponse(HTTP.PAYLOAD_TOO_LARGE, 'PAYLOAD_TOO_LARGE', 'Request body too large');\n\t\t}\n\n\t\tconst ct = ctx.request.headers.get('content-type') ?? '';\n\n\t\ttry {\n\t\t\tif (ct.includes('application/json') || ct.includes('application/x-www-form-urlencoded')) {\n\t\t\t\tconst bytes = await readBytesCapped(ctx.request, maxBytes);\n\t\t\t\t// The read consumed the original stream — re-materialize the request\n\t\t\t\t// with the ORIGINAL BYTES (not a re-encoded string) so a handler\n\t\t\t\t// that reads the raw body for signature verification (Stripe /\n\t\t\t\t// SendGrid webhooks) gets byte-identical input, even for payloads\n\t\t\t\t// with a BOM or non-UTF-8 bytes.\n\t\t\t\tif (bytes !== null) {\n\t\t\t\t\tctx.request = new Request(ctx.request.url, {\n\t\t\t\t\t\tmethod: ctx.request.method,\n\t\t\t\t\t\theaders: ctx.request.headers,\n\t\t\t\t\t\t// Cast: a Uint8Array is a valid BodyInit at runtime; the lib's\n\t\t\t\t\t\t// BodyInit union is narrower than Uint8Array<ArrayBufferLike>.\n\t\t\t\t\t\tbody: bytes.length > 0 ? (bytes as unknown as BodyInit) : null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tconst text = bytes ? new TextDecoder().decode(bytes) : '';\n\t\t\t\tif (ct.includes('application/json')) {\n\t\t\t\t\tconst trimmed = text.trim();\n\t\t\t\t\tctx.meta.body = trimmed ? JSON.parse(trimmed) : {};\n\t\t\t\t} else {\n\t\t\t\t\tctx.meta.body = Object.fromEntries(new URLSearchParams(text));\n\t\t\t\t}\n\t\t\t}\n\t\t\t// multipart/form-data left to the handler — no dep-free way to parse it\n\t\t} catch (err) {\n\t\t\tif ((err as { fonderiePayloadTooLarge?: boolean } | null)?.fonderiePayloadTooLarge) {\n\t\t\t\treturn setApiResponse(\n\t\t\t\t\tHTTP.PAYLOAD_TOO_LARGE,\n\t\t\t\t\t'PAYLOAD_TOO_LARGE',\n\t\t\t\t\t'Request body too large',\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'INVALID_REQUEST', 'Invalid request body');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\n// Backward-compatible bare middleware with the default cap.\nexport const withBody: Middleware = bodyParser();\n","import type { Middleware } from '../types';\n\nexport interface SecurityHeadersOptions {\n\t// HSTS max-age in seconds. Default 180 days. Set 0 to omit the header.\n\thstsMaxAge?: number;\n\t// Add `includeSubDomains` to the HSTS header. Off by default — only enable\n\t// once every subdomain is known to serve HTTPS.\n\thstsIncludeSubDomains?: boolean;\n\t// Add `preload` to the HSTS header (implies includeSubDomains). Off by default.\n\thstsPreload?: boolean;\n}\n\n// Baseline response hardening. `X-Content-Type-Options: nosniff` is always safe.\n// HSTS is only meaningful — and only emitted — over HTTPS: browsers ignore it on\n// plain HTTP, so gating on the effective scheme keeps local http/dev untouched\n// while enforcing TLS in production (behind a TLS-terminating proxy, detected via\n// X-Forwarded-Proto). Wired into the default pipeline by FonderieApp.\nexport function withSecurityHeaders(options: SecurityHeadersOptions = {}): Middleware {\n\tconst {\n\t\thstsMaxAge = 60 * 60 * 24 * 180,\n\t\thstsIncludeSubDomains = false,\n\t\thstsPreload = false,\n\t} = options;\n\n\tlet hsts = '';\n\tif (hstsMaxAge > 0) {\n\t\thsts = `max-age=${hstsMaxAge}`;\n\t\tif (hstsIncludeSubDomains || hstsPreload) hsts += '; includeSubDomains';\n\t\tif (hstsPreload) hsts += '; preload';\n\t}\n\n\treturn async (ctx, next) => {\n\t\tconst response = await next();\n\t\tconst patched = new Headers(response.headers);\n\n\t\tpatched.set('X-Content-Type-Options', 'nosniff');\n\n\t\tif (hsts && isHttps(ctx.request)) {\n\t\t\tpatched.set('Strict-Transport-Security', hsts);\n\t\t}\n\n\t\treturn new Response(response.body, {\n\t\t\theaders: patched,\n\t\t\tstatus: response.status,\n\t\t\tstatusText: response.statusText,\n\t\t});\n\t};\n}\n\n// HTTPS if the request URL is https, or a TLS-terminating proxy says so.\nfunction isHttps(request: Request): boolean {\n\tif (request.url.startsWith('https:')) return true;\n\tconst proto = request.headers.get('x-forwarded-proto');\n\treturn proto?.split(',')[0]?.trim() === 'https';\n}\n","import { setApiResponse, HTTP } from '../response';\n\nexport function defaultErrorHandler(err: unknown): Response {\n\t// Only leak the raw error message in EXPLICITLY non-production-like envs.\n\t// `NODE_ENV !== 'production'` also covered 'staging' and any custom value,\n\t// where an error message can carry connection strings / PII. Unknown or\n\t// unset NODE_ENV is treated as production-safe (no leak).\n\tconst env = process.env['NODE_ENV'];\n\tconst dev = env === 'development' || env === 'test';\n\n\tif (err instanceof Error) {\n\t\tconsole.error('[fonderie]', err.message, err.stack);\n\t\treturn setApiResponse(\n\t\t\tHTTP.SERVER_ERROR,\n\t\t\t'SERVER_ERROR',\n\t\t\tdev ? err.message : 'Internal server error',\n\t\t);\n\t}\n\n\tconsole.error('[fonderie] unknown error', err);\n\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Internal server error');\n}\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\n// Requires a fully-authenticated user. Rejects mfaPending tokens — those are\n// short-lived pre-auth tokens issued mid-MFA-login and must not grant access\n// to any route other than /auth/mfa/verify.\nexport const requireAuth: Middleware = async (ctx, next) => {\n\tif (!ctx.user) {\n\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t}\n\tif (ctx.user.mfaPending) {\n\t\treturn setApiResponse(HTTP.FORBIDDEN, 'MFA_REQUIRED', 'Complete MFA verification to continue');\n\t}\n\treturn next();\n};\n\n// Accepts both fully-authenticated and mfaPending tokens. Only for routes that\n// need to serve both contexts on the same path (e.g. POST /auth/mfa/verify\n// handles setup confirmation with a full token and TOTP login with mfaPending).\nexport const requireAnyAuth: Middleware = async (ctx, next) => {\n\tif (!ctx.user) {\n\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t}\n\treturn next();\n};\n","import { timingSafeEqual } from 'node:crypto';\n\n// The one timing-attack-safe equality across Fonderie — admin tokens (billing/\n// config/courier), MFA/TOTP codes (auth), event-log + inbound webhook-signature\n// HMACs (events/courier). Length-guard first: timingSafeEqual throws on unequal\n// lengths, and the length is not the secret. Accepts strings or Buffers (callers\n// comparing decoded signature bytes pass Buffers).\nexport function constantTimeEqual(a: string | Buffer, b: string | Buffer): boolean {\n\tconst bufA = Buffer.isBuffer(a) ? a : Buffer.from(a);\n\tconst bufB = Buffer.isBuffer(b) ? b : Buffer.from(b);\n\tif (bufA.length !== bufB.length) return false;\n\treturn timingSafeEqual(bufA, bufB);\n}\n","// One weak/placeholder-secret denylist + minimum length, so every module that\n// guards a surface with a bootstrap secret (auth jwtSecret, module adminTokens)\n// enforces the SAME bar. Call sites keep their own POLICY (required vs optional,\n// which field name to name in the message) — only the rule is shared.\nexport const MIN_SECRET_LENGTH = 32;\nexport const PLACEHOLDER_SECRET =\n\t/dev-secret|test-secret|changeme|change-me|your[-_]secret|placeholder|example|insecure|admin-token|min-32-chars/i;\n\n// The single classification. null = strong enough. Each caller formats its own\n// IReadinessProblem (naming its field) from this verdict.\nexport function secretStrengthProblem(secret: string): 'too-short' | 'placeholder' | null {\n\tif (secret.length < MIN_SECRET_LENGTH) return 'too-short';\n\tif (PLACEHOLDER_SECRET.test(secret)) return 'placeholder';\n\treturn null;\n}\n","import { constantTimeEqual } from '../crypto';\nimport { setApiResponse, HTTP } from '../response';\nimport { MIN_SECRET_LENGTH, secretStrengthProblem } from '../secret-strength';\nimport type { IReadinessProblem, Middleware } from '../types';\n\n// The one admin-route guard for every Fonderie module with an ops surface\n// (billing plan-writes/wallet-grant, config/secrets admin, courier template\n// admin). A bootstrap Bearer token compared in constant time. Modules MUST\n// register their admin routes only when a token is configured (unset ⇒ 404),\n// so this guard only ever runs against a real token. See docs/ADMIN-AUTH-SPEC.md.\nexport function requireAdminToken(adminToken: string): Middleware {\n\treturn (ctx, next) => {\n\t\tconst header = ctx.request.headers.get('authorization') ?? '';\n\t\tconst token = header.startsWith('Bearer ') ? header.slice(7) : '';\n\t\t// Same 401 for a missing and a wrong token — no oracle distinguishing them.\n\t\tif (!token || !constantTimeEqual(token, adminToken)) {\n\t\t\treturn Promise.resolve(\n\t\t\t\tsetApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing or invalid admin token'),\n\t\t\t);\n\t\t}\n\t\treturn next();\n\t};\n}\n\n// A bootstrap admin token must be strong: an admin surface guarded by a short or\n// placeholder token is barely guarded at all. Modules call this from\n// checkReadiness() so the rule (shared secret-strength denylist) is enforced\n// identically everywhere. Returns a problem for a weak/placeholder token;\n// nothing when unset (that surface is simply not exposed).\nexport function validateAdminToken(\n\ttoken: string | undefined,\n\topts: { module: string },\n): IReadinessProblem[] {\n\tif (!token) return [];\n\tconst problem = secretStrengthProblem(token);\n\tif (problem === 'too-short') {\n\t\treturn [\n\t\t\t{\n\t\t\t\tmodule: opts.module,\n\t\t\t\tseverity: 'error',\n\t\t\t\tmessage: `adminToken must be at least ${MIN_SECRET_LENGTH} characters (got ${token.length})`,\n\t\t\t},\n\t\t];\n\t}\n\tif (problem === 'placeholder') {\n\t\treturn [\n\t\t\t{ module: opts.module, severity: 'error', message: 'adminToken looks like a placeholder or dev-default value' },\n\t\t];\n\t}\n\treturn [];\n}\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\nexport const requireVerified: Middleware = async (ctx, next) => {\n\tif (!ctx.user) {\n\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t}\n\n\tif (ctx.user.loginMethod === 'phone') {\n\t\tif (!ctx.user.phoneVerified) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'PHONE_NOT_VERIFIED',\n\t\t\t\t'Please verify your phone number',\n\t\t\t);\n\t\t}\n\t\treturn next();\n\t}\n\n\tif (!ctx.user.emailVerifiedAt) {\n\t\treturn setApiResponse(HTTP.FORBIDDEN, 'EMAIL_NOT_VERIFIED', 'Please verify your email address');\n\t}\n\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '../response';\nimport type { Middleware } from '../types';\n\n// Route-boundary request validation — the one validation middleware every\n// package wires in front of its body-taking routes, so error shape and parse\n// semantics are identical across the whole surface.\n//\n// core stays dependency-free: this accepts anything implementing zod's\n// safeParse contract structurally (zod v3/v4 both match), without importing\n// zod. Feature packages own their schemas; see @fonderie/auth's schemas.ts\n// for the reference pattern.\n\nexport interface IRequestSchema {\n\tsafeParse(input: unknown):\n\t\t| { success: true; data: unknown }\n\t\t| { success: false; error: { issues: Array<{ path: PropertyKey[]; message: string }> } };\n}\n\nexport function validate(schema: IRequestSchema): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst result = schema.safeParse(ctx.meta['body'] ?? {});\n\t\tif (!result.success) {\n\t\t\tconst first = result.error.issues[0];\n\t\t\tconst path = first?.path.length ? `${first.path.map(String).join('.')}: ` : '';\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.UNPROCESSABLE,\n\t\t\t\t'INVALID_PARAMETER',\n\t\t\t\t`${path}${first?.message ?? 'Invalid request body'}`,\n\t\t\t);\n\t\t}\n\t\t// Parsed output replaces the raw body: trimmed, coerced, unknown keys\n\t\t// stripped — controllers read clean input.\n\t\tctx.meta['body'] = result.data;\n\t\treturn next();\n\t};\n}\n","// Client-IP resolution shared by the adapters. The web-standard Request the\n// pipeline runs on carries no socket address, so each adapter passes the\n// socket's remote address here together with the headers; this resolves the\n// effective client IP with explicit proxy trust.\n//\n// trustProxy semantics (deliberately explicit — a permissive default lets\n// any client spoof X-Forwarded-For and dodge per-IP rate limits):\n// 0 / undefined → ignore forwarding headers; the socket address is the client\n// N > 0 → the client is the Nth-from-the-right entry in\n// X-Forwarded-For (N = number of trusted proxy hops)\n//\n// ⚠️ THE PROXY FOOTGUN. With trustProxy=0 (the spoof-safe default) deployed\n// behind nginx, a Kubernetes ingress, or any L7 proxy, the socket address is\n// the PROXY's IP for every request — so every client collapses onto one\n// per-IP bucket and the limit becomes global (one attacker locks everyone\n// out). You cannot have a default that is both spoof-safe AND correct behind\n// a proxy; they contradict. So we ship spoof-safe and DETECT the mismatch:\n// checkProxyConfig() below warns once, loudly, when the deployment looks\n// proxied but trustProxy is unset. Set TRUST_PROXY=<hops> in that case.\n\nexport function resolveClientIp(\n\tsocketAddress: string | undefined,\n\theaders: Headers,\n\ttrustProxy: number = trustProxyFromEnv(),\n): string | undefined {\n\tif (trustProxy > 0) {\n\t\tconst xff = headers.get('x-forwarded-for');\n\t\tif (xff) {\n\t\t\tconst hops = xff\n\t\t\t\t.split(',')\n\t\t\t\t.map((s) => s.trim())\n\t\t\t\t.filter(Boolean);\n\t\t\tconst candidate = hops[Math.max(0, hops.length - trustProxy)];\n\t\t\tif (candidate) return normalizeIp(candidate);\n\t\t}\n\t}\n\tcheckProxyConfig(socketAddress, headers, trustProxy);\n\treturn socketAddress ? normalizeIp(socketAddress) : undefined;\n}\n\nfunction trustProxyFromEnv(): number {\n\tconst raw = Number(process.env['TRUST_PROXY']);\n\treturn Number.isFinite(raw) && raw > 0 ? raw : 0;\n}\n\nfunction normalizeIp(ip: string): string {\n\t// ::ffff:203.0.113.7 → 203.0.113.7 ; strip port if a proxy appended one\n\tconst noV6Prefix = ip.startsWith('::ffff:') ? ip.slice(7) : ip;\n\tconst m = noV6Prefix.match(/^(\\d{1,3}(?:\\.\\d{1,3}){3}):\\d+$/);\n\treturn m ? m[1]! : noV6Prefix;\n}\n\n// Address ranges that indicate a local proxy sits in front of us (so a\n// forwarding header without TRUST_PROXY is a misconfiguration, not spoofing).\nconst LOOPBACK_IPS = new Set(['127.0.0.1', '::1']);\nconst PRIVATE_IP_PREFIXES = [\n\t'10.', // RFC1918\n\t'192.168.', // RFC1918\n\t'169.254.', // link-local\n\t'fc', // IPv6 unique local (fc00::/7)\n\t'fd', // IPv6 unique local\n] as const;\nconst CGNAT_OR_RFC1918_172 = /^172\\.(1[6-9]|2\\d|3[01])\\./; // 172.16.0.0/12\n\nfunction isPrivateOrLoopback(ip: string): boolean {\n\tconst a = normalizeIp(ip);\n\treturn (\n\t\tLOOPBACK_IPS.has(a) ||\n\t\tCGNAT_OR_RFC1918_172.test(a) ||\n\t\tPRIVATE_IP_PREFIXES.some((prefix) => a.startsWith(prefix))\n\t);\n}\n\nlet warned = false;\n\n// Warn ONCE when the deployment looks proxied (forwarding header present, and\n// the socket is a private/loopback address — i.e. a local proxy) but\n// trustProxy is unset. That configuration silently rate-limits every client\n// as one IP. Emitting on the request path (not at boot) is deliberate: the\n// signal we need — an actual X-Forwarded-For header — only exists once real\n// traffic arrives.\nexport function checkProxyConfig(\n\tsocketAddress: string | undefined,\n\theaders: Headers,\n\ttrustProxy: number,\n): void {\n\tif (warned || trustProxy > 0) return;\n\tconst forwarded =\n\t\theaders.get('x-forwarded-for') ??\n\t\theaders.get('cf-connecting-ip') ??\n\t\theaders.get('x-real-ip');\n\tif (forwarded && socketAddress && isPrivateOrLoopback(socketAddress)) {\n\t\twarned = true;\n\t\tconsole.warn(\n\t\t\t'[fonderie] Requests carry a forwarding header (X-Forwarded-For) and ' +\n\t\t\t\t'arrive from a private/loopback socket, but TRUST_PROXY is unset. ' +\n\t\t\t\t'Every client is being rate-limited as a single IP, which will cause ' +\n\t\t\t\t'global lockout behind nginx / a Kubernetes ingress / any L7 proxy. ' +\n\t\t\t\t'Set TRUST_PROXY=<number of trusted proxy hops>. ' +\n\t\t\t\t'See @fonderie/rate-limit README § Deploying behind a proxy.',\n\t\t);\n\t}\n}\n\n// Test seam — reset the once-only warning latch.\nexport function _resetProxyWarning(): void {\n\twarned = false;\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["// ── Identity contracts ───────────────────────────────────────────\n// Owned by core so packages that only peer on core (the adapters) can name\n// them without importing optional peers. @fonderie/auth populates `user`/\n// `tenant` and @fonderie/workspaces populates `workspace` on the context.\nexport interface ITenant {\n\tid: string;\n\tslug: string;\n\tplan: string;\n}\n\nexport interface IAuthUser {\n\tid: string;\n\temail: string | null;\n\tphone: string | null;\n\tsuspended: boolean;\n\tmfaEnabled: boolean;\n\tdeletedAt: Date | null;\n\temailVerifiedAt: Date | null;\n\tloginMethod: 'email' | 'phone' | 'google'; // sourced from JWT payload\n\tphoneVerified: boolean; // per-session, sourced from JWT payload\n\tmfaPending?: boolean; // true on the short-lived pre-auth token issued during MFA login\n\tlocale: string; // the user's preferred locale (DB row); drives per-locale courier templates\n}\n\nexport interface IWorkspace {\n\tid: string;\n\tname: string;\n\tisPersonal?: boolean;\n}\n\n// ── Courier contract — lives in core because auth + workspaces emit\n// messages without importing @fonderie/courier.\nexport interface ICourierMessage {\n\ttype: string;\n\tlocale?: string;\n\trecipient: {\n\t\temail: string | null;\n\t\tphone: string | null;\n\t\tdeviceToken: string | null;\n\t};\n\tdata: Record<string, unknown>;\n}\n\n// A module's built-in default copy for one message type — what ships so a\n// notification renders out of the box, before any app override. Same shape a\n// courier template (DB row / FS file) resolves to: `text` is required (every\n// channel), `subject`/`html` are email-only. A module exports a\n// Record<ItsMessageKey, IDefaultTemplate> so an unfilled key is a compile error.\nexport interface IDefaultTemplate {\n\tsubject?: string;\n\ttext: string;\n\thtml?: string;\n}\n\n// ── Router interface — avoids circular dep with router.ts ────────\nexport interface IRouteMatch {\n\thandler: Middleware;\n\tparams: Record<string, string>;\n}\n\nexport interface IRouter {\n\tmatch(method: string, path: string): IRouteMatch | null;\n\tadd(method: string, path: string, handler: Middleware): void;\n}\n\n// ── Typed well-known ctx.meta keys ───────────────────────────────\nexport interface IFonderieContextMeta {\n\tparams?: Record<string, string>;\n\tbody?: unknown;\n\t// Trust-proxy-resolved client IP, populated by the adapters (see\n\t// resolveClientIp in @fonderie/core/middlewares). Consumed by\n\t// @fonderie/rate-limit's byIp() keying.\n\tclientIp?: string;\n\tworkspaceId?: string;\n\tuserId?: string;\n\tuserWorkspaceRoles?: string[];\n\tmessage?: ICourierMessage;\n\t[key: string]: unknown;\n}\n\n// ── Core types ───────────────────────────────────────────────────\nexport interface IFonderieContext {\n\trequest: Request;\n\tmeta: IFonderieContextMeta;\n\treadonly tenant: ITenant | null;\n\treadonly user: IAuthUser | null;\n\treadonly workspace: IWorkspace | null;\n}\n\nexport type Middleware = (\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n) => Promise<Response>;\n\n// ── App + module contracts ────────────────────────────────────────\nexport interface IFonderieApp {\n\tuse(middleware: Middleware): IFonderieApp;\n\tregister(module: IFonderieModule): IFonderieApp;\n\taddRoute(method: string, path: string, ...handlers: Middleware[]): void;\n\tlisten(port: number, options?: { name?: string; version?: string; env?: string }): void;\n\t// Install every registered module (dependency-ordered). Returns the app.\n\tboot(): Promise<IFonderieApp>;\n\t// Aggregate every module's self-reported readiness problems; gate a deploy\n\t// or expose from a readiness endpoint. See IReadinessReport.\n\tcheckProductionReadiness(): IReadinessReport;\n\t// Point-in-time control-posture snapshot for SOC 2 evidence.\n\tsecurityReport(): ISecurityReport;\n}\n\n// A production-readiness finding a module reports about its own config.\n// `error` means \"unsafe to run in production\" (e.g. a forgeable-token secret);\n// `warning` means \"probably a misconfiguration\" (e.g. emails that will silently\n// drop). Collected across modules by `FonderieApp.checkProductionReadiness`.\nexport interface IReadinessProblem {\n\tmodule: string;\n\tseverity: 'error' | 'warning';\n\tmessage: string;\n}\n\nexport interface IReadinessReport {\n\t// True when there are no `error`-severity problems — safe to boot in prod.\n\tok: boolean;\n\tproblems: IReadinessProblem[];\n}\n\n// A point-in-time control-posture snapshot for SOC 2 evidence (see\n// FonderieApp.securityReport). Serialise it to a file/log as an audit artifact.\nexport interface ISecurityReport {\n\tgeneratedAt: string; // ISO timestamp\n\tenv: string; // NODE_ENV\n\tregisteredModules: string[];\n\treadiness: IReadinessReport;\n}\n\nexport interface IFonderieModule {\n\tname: string;\n\tdeps?: string[];\n\tinstall(app: IFonderieApp): void | Promise<void>;\n\t// Optional: report production-readiness problems with this module's config.\n\t// Modules opt in; `FonderieApp.checkProductionReadiness` aggregates them.\n\tcheckReadiness?(): IReadinessProblem[];\n}\n\n// ── Cross-module vocabulary ───────────────────────────────────────\n// Lives in core (not permissions) so packages that only peer on core —\n// the adapters — can re-export it without loading optional peers.\nexport type Operation = 'create' | 'read' | 'update' | 'delete';\n"],"mappings":";;;;;;;;;;;;;;;;AAAA;AAAA;","names":[]}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["// ── Identity contracts ───────────────────────────────────────────\n// Owned by core so packages that only peer on core (the adapters) can name\n// them without importing optional peers. @fonderie/auth populates `user`/\n// `tenant` and @fonderie/workspaces populates `workspace` on the context.\nexport interface ITenant {\n\tid: string;\n\tslug: string;\n\tplan: string;\n}\n\nexport interface IAuthUser {\n\tid: string;\n\temail: string | null;\n\tphone: string | null;\n\tsuspended: boolean;\n\tmfaEnabled: boolean;\n\tdeletedAt: Date | null;\n\temailVerifiedAt: Date | null;\n\tloginMethod: 'email' | 'phone' | 'google'; // sourced from JWT payload\n\tphoneVerified: boolean; // per-session, sourced from JWT payload\n\tmfaPending?: boolean; // true on the short-lived pre-auth token issued during MFA login\n\tlocale: string; // the user's preferred locale (DB row); drives per-locale courier templates\n}\n\nexport interface IWorkspace {\n\tid: string;\n\tname: string;\n\tisPersonal?: boolean;\n}\n\n// ── Courier contract — lives in core because auth + workspaces emit\n// messages without importing @fonderie/courier.\nexport interface ICourierMessage {\n\ttype: string;\n\tlocale?: string;\n\trecipient: {\n\t\temail: string | null;\n\t\tphone: string | null;\n\t\tdeviceToken: string | null;\n\t};\n\tdata: Record<string, unknown>;\n}\n\n// A module's built-in default copy for one message type — what ships so a\n// notification renders out of the box, before any app override. Same shape a\n// courier template (DB row / FS file) resolves to: `text` is required (every\n// channel), `subject`/`html` are email-only. A module exports a\n// Record<ItsMessageKey, IDefaultTemplate> so an unfilled key is a compile error.\nexport interface IDefaultTemplate {\n\tsubject?: string;\n\ttext: string;\n\thtml?: string;\n}\n\n// ── Router interface — avoids circular dep with router.ts ────────\nexport interface IRouteMatch {\n\thandler: Middleware;\n\tparams: Record<string, string>;\n}\n\nexport interface IRouter {\n\tmatch(method: string, path: string): IRouteMatch | null;\n\tadd(method: string, path: string, handler: Middleware): void;\n}\n\n// ── Typed well-known ctx.meta keys ───────────────────────────────\nexport interface IFonderieContextMeta {\n\tparams?: Record<string, string>;\n\tbody?: unknown;\n\t// Trust-proxy-resolved client IP, populated by the adapters (see\n\t// resolveClientIp in @fonderie/core/middlewares). Consumed by\n\t// @fonderie/rate-limit's byIp() keying.\n\tclientIp?: string;\n\tworkspaceId?: string;\n\tuserId?: string;\n\tuserWorkspaceRoles?: string[];\n\tmessage?: ICourierMessage;\n\t[key: string]: unknown;\n}\n\n// ── Core types ───────────────────────────────────────────────────\nexport interface IFonderieContext {\n\trequest: Request;\n\tmeta: IFonderieContextMeta;\n\treadonly tenant: ITenant | null;\n\treadonly user: IAuthUser | null;\n\treadonly workspace: IWorkspace | null;\n}\n\n/**\n * What an adapter hands to `handle()` alongside the request. A Web Standard\n * Request carries no socket address (and no framework state), so anything the\n * adapter resolved from its native request — the client IP above all — must be\n * seeded here or it is lost: `handle()` builds a fresh context.\n */\nexport interface IHandleInit {\n\tmeta?: IFonderieContextMeta;\n}\n\nexport type Middleware = (\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n) => Promise<Response>;\n\n// ── App + module contracts ────────────────────────────────────────\nexport interface IFonderieApp {\n\tuse(middleware: Middleware): IFonderieApp;\n\tregister(module: IFonderieModule): IFonderieApp;\n\taddRoute(method: string, path: string, ...handlers: Middleware[]): void;\n\tlisten(port: number, options?: { name?: string; version?: string; env?: string }): void;\n\t// Install every registered module (dependency-ordered). Returns the app.\n\tboot(): Promise<IFonderieApp>;\n\t// Aggregate every module's self-reported readiness problems; gate a deploy\n\t// or expose from a readiness endpoint. See IReadinessReport.\n\tcheckProductionReadiness(): IReadinessReport;\n\t// Point-in-time control-posture snapshot for SOC 2 evidence.\n\tsecurityReport(): ISecurityReport;\n}\n\n// A production-readiness finding a module reports about its own config.\n// `error` means \"unsafe to run in production\" (e.g. a forgeable-token secret);\n// `warning` means \"probably a misconfiguration\" (e.g. emails that will silently\n// drop). Collected across modules by `FonderieApp.checkProductionReadiness`.\nexport interface IReadinessProblem {\n\tmodule: string;\n\tseverity: 'error' | 'warning';\n\tmessage: string;\n}\n\nexport interface IReadinessReport {\n\t// True when there are no `error`-severity problems — safe to boot in prod.\n\tok: boolean;\n\tproblems: IReadinessProblem[];\n}\n\n// A point-in-time control-posture snapshot for SOC 2 evidence (see\n// FonderieApp.securityReport). Serialise it to a file/log as an audit artifact.\nexport interface ISecurityReport {\n\tgeneratedAt: string; // ISO timestamp\n\tenv: string; // NODE_ENV\n\tregisteredModules: string[];\n\treadiness: IReadinessReport;\n}\n\nexport interface IFonderieModule {\n\tname: string;\n\tdeps?: string[];\n\tinstall(app: IFonderieApp): void | Promise<void>;\n\t// Optional: report production-readiness problems with this module's config.\n\t// Modules opt in; `FonderieApp.checkProductionReadiness` aggregates them.\n\tcheckReadiness?(): IReadinessProblem[];\n}\n\n// ── Cross-module vocabulary ───────────────────────────────────────\n// Lives in core (not permissions) so packages that only peer on core —\n// the adapters — can re-export it without loading optional peers.\nexport type Operation = 'create' | 'read' | 'update' | 'delete';\n"],"mappings":";;;;;;;;;;;;;;;;AAAA;AAAA;","names":[]}
package/dist/types.d.cts CHANGED
@@ -61,6 +61,15 @@ interface IFonderieContext {
61
61
  readonly user: IAuthUser | null;
62
62
  readonly workspace: IWorkspace | null;
63
63
  }
64
+ /**
65
+ * What an adapter hands to `handle()` alongside the request. A Web Standard
66
+ * Request carries no socket address (and no framework state), so anything the
67
+ * adapter resolved from its native request — the client IP above all — must be
68
+ * seeded here or it is lost: `handle()` builds a fresh context.
69
+ */
70
+ interface IHandleInit {
71
+ meta?: IFonderieContextMeta;
72
+ }
64
73
  type Middleware = (ctx: IFonderieContext, next: () => Promise<Response>) => Promise<Response>;
65
74
  interface IFonderieApp {
66
75
  use(middleware: Middleware): IFonderieApp;
@@ -98,4 +107,4 @@ interface IFonderieModule {
98
107
  }
99
108
  type Operation = 'create' | 'read' | 'update' | 'delete';
100
109
 
101
- export type { IAuthUser, ICourierMessage, IDefaultTemplate, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ISecurityReport, ITenant, IWorkspace, Middleware, Operation };
110
+ export type { IAuthUser, ICourierMessage, IDefaultTemplate, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IHandleInit, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ISecurityReport, ITenant, IWorkspace, Middleware, Operation };
package/dist/types.d.ts CHANGED
@@ -61,6 +61,15 @@ interface IFonderieContext {
61
61
  readonly user: IAuthUser | null;
62
62
  readonly workspace: IWorkspace | null;
63
63
  }
64
+ /**
65
+ * What an adapter hands to `handle()` alongside the request. A Web Standard
66
+ * Request carries no socket address (and no framework state), so anything the
67
+ * adapter resolved from its native request — the client IP above all — must be
68
+ * seeded here or it is lost: `handle()` builds a fresh context.
69
+ */
70
+ interface IHandleInit {
71
+ meta?: IFonderieContextMeta;
72
+ }
64
73
  type Middleware = (ctx: IFonderieContext, next: () => Promise<Response>) => Promise<Response>;
65
74
  interface IFonderieApp {
66
75
  use(middleware: Middleware): IFonderieApp;
@@ -98,4 +107,4 @@ interface IFonderieModule {
98
107
  }
99
108
  type Operation = 'create' | 'read' | 'update' | 'delete';
100
109
 
101
- export type { IAuthUser, ICourierMessage, IDefaultTemplate, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ISecurityReport, ITenant, IWorkspace, Middleware, Operation };
110
+ export type { IAuthUser, ICourierMessage, IDefaultTemplate, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IHandleInit, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ISecurityReport, ITenant, IWorkspace, Middleware, Operation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/core",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "fonderie": { "stability": "maturing" },
5
5
  "description": "Framework core — request router, middleware pipeline, module system, and shared context types. Every other @fonderiejs package depends on this.",
6
6
  "keywords": [