@fonderie/core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/brain/signatures.md +22 -1
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +4 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js.map +1 -1
- package/dist/index.cjs +144 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -4
- package/dist/index.d.ts +15 -4
- package/dist/index.js +141 -7
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +82 -2
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +14 -2
- package/dist/middlewares/index.d.ts +14 -2
- package/dist/middlewares/index.js +78 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +10 -2
- package/dist/types.d.ts +10 -2
- package/package.json +8 -8
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/cors.ts","../../src/middlewares/logger.ts","../../src/response.ts","../../src/middlewares/not-found.ts","../../src/middlewares/body-parser.ts","../../src/middlewares/error-handler.ts","../../src/middlewares/require-auth.ts","../../src/middlewares/require-verified.ts","../../src/middlewares/validate.ts","../../src/middlewares/client-ip.ts"],"sourcesContent":["export type { CorsOptions } from './cors';\nexport { withCors } from './cors';\nexport { withLogger } from './logger';\nexport { notFoundMiddleware } from './not-found';\nexport { withBody } from './body-parser';\nexport { defaultErrorHandler } from './error-handler';\nexport { requireAuth, requireAnyAuth } from './require-auth';\nexport { requireVerified } from './require-verified';\nexport { validate } from './validate';\nexport type { IRequestSchema } from './validate';\nexport { resolveClientIp, checkProxyConfig } from './client-ip';\n","import type { Middleware } from '../types';\n\nexport interface CorsOptions {\n\tmethods?: string[];\n\theaders?: string[];\n\torigin?: string | ((requestOrigin: string) => boolean);\n}\n\nexport function withCors(options: CorsOptions = {}): Middleware {\n\tconst {\n\t\torigin = '*',\n\t\theaders = ['Content-Type', 'Authorization'],\n\t\tmethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],\n\t} = options;\n\n\treturn async (ctx, next) => {\n\t\tconst requestOrigin = ctx.request.headers.get('origin') ?? '';\n\n\t\tconst allowOrigin =\n\t\t\ttypeof origin === 'function' ? (origin(requestOrigin) ? requestOrigin : '') : origin;\n\n\t\tconst corsHeaders: Record<string, string> = {\n\t\t\t'Access-Control-Max-Age': '86400',\n\t\t\t'Access-Control-Allow-Origin': allowOrigin,\n\t\t\t'Access-Control-Allow-Methods': methods.join(', '),\n\t\t\t'Access-Control-Allow-Headers': headers.join(', '),\n\t\t};\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\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\nexport const withBody: Middleware = async (ctx, next) => {\n\tconst method = ctx.request.method.toUpperCase();\n\n\tif (method === 'GET' || method === 'HEAD') {\n\t\treturn next();\n\t}\n\n\tconst ct = ctx.request.headers.get('content-type') ?? '';\n\n\ttry {\n\t\tif (ct.includes('application/json')) {\n\t\t\tconst text = (await ctx.request.clone().text()).trim();\n\t\t\tctx.meta.body = text ? JSON.parse(text) : {};\n\t\t} else if (ct.includes('application/x-www-form-urlencoded')) {\n\t\t\tconst text = await ctx.request.clone().text();\n\t\t\tctx.meta.body = Object.fromEntries(new URLSearchParams(text));\n\t\t}\n\t\t// multipart/form-data left to the handler — no dep-free way to parse it\n\t} catch {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'INVALID_REQUEST', 'Invalid request body');\n\t}\n\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '../response';\n\nexport function defaultErrorHandler(err: unknown): Response {\n\tconst dev = process.env['NODE_ENV'] !== 'production';\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 { 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/index.ts","../../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/middlewares/require-admin-token.ts","../../src/middlewares/require-verified.ts","../../src/middlewares/validate.ts","../../src/middlewares/client-ip.ts"],"sourcesContent":["export type { CorsOptions } from './cors';\nexport { withCors } from './cors';\nexport { withLogger } from './logger';\nexport { notFoundMiddleware } from './not-found';\nexport { withBody } from './body-parser';\nexport { withSecurityHeaders } from './security-headers';\nexport type { SecurityHeadersOptions } from './security-headers';\nexport { defaultErrorHandler } from './error-handler';\nexport { requireAuth, requireAnyAuth } from './require-auth';\nexport { requireAdminToken, validateAdminToken } from './require-admin-token';\nexport { requireVerified } from './require-verified';\nexport { validate } from './validate';\nexport type { IRequestSchema } from './validate';\nexport { resolveClientIp, checkProxyConfig } from './client-ip';\n","import type { Middleware } from '../types';\n\nexport interface CorsOptions {\n\tmethods?: string[];\n\theaders?: string[];\n\torigin?: string | ((requestOrigin: string) => boolean);\n}\n\nexport function withCors(options: CorsOptions = {}): Middleware {\n\tconst {\n\t\torigin = '*',\n\t\theaders = ['Content-Type', 'Authorization'],\n\t\tmethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],\n\t} = options;\n\n\treturn async (ctx, next) => {\n\t\tconst requestOrigin = ctx.request.headers.get('origin') ?? '';\n\n\t\tconst allowOrigin =\n\t\t\ttypeof origin === 'function' ? (origin(requestOrigin) ? requestOrigin : '') : origin;\n\n\t\tconst corsHeaders: Record<string, string> = {\n\t\t\t'Access-Control-Max-Age': '86400',\n\t\t\t'Access-Control-Allow-Origin': allowOrigin,\n\t\t\t'Access-Control-Allow-Methods': methods.join(', '),\n\t\t\t'Access-Control-Allow-Headers': headers.join(', '),\n\t\t};\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\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\nexport const withBody: Middleware = async (ctx, next) => {\n\tconst method = ctx.request.method.toUpperCase();\n\n\tif (method === 'GET' || method === 'HEAD') {\n\t\treturn next();\n\t}\n\n\tconst ct = ctx.request.headers.get('content-type') ?? '';\n\n\ttry {\n\t\tif (ct.includes('application/json')) {\n\t\t\tconst text = (await ctx.request.clone().text()).trim();\n\t\t\tctx.meta.body = text ? JSON.parse(text) : {};\n\t\t} else if (ct.includes('application/x-www-form-urlencoded')) {\n\t\t\tconst text = await ctx.request.clone().text();\n\t\t\tctx.meta.body = Object.fromEntries(new URLSearchParams(text));\n\t\t}\n\t\t// multipart/form-data left to the handler — no dep-free way to parse it\n\t} catch {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'INVALID_REQUEST', 'Invalid request body');\n\t}\n\n\treturn next();\n};\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\tconst dev = process.env['NODE_ENV'] !== 'production';\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\nimport { setApiResponse, HTTP } from '../response';\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.\n\n// Constant-time comparison so a wrong token can't be recovered byte-by-byte from\n// response timing. Length-guard first: timingSafeEqual throws on unequal lengths,\n// and that early return is acceptable — the token's length is not the secret.\nfunction safeTokenEqual(a: string, b: string): boolean {\n\tconst bufA = Buffer.from(a);\n\tconst bufB = Buffer.from(b);\n\tif (bufA.length !== bufB.length) return false;\n\treturn timingSafeEqual(bufA, bufB);\n}\n\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 || !safeTokenEqual(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 is enforced identically everywhere (previously\n// only @fonderie/config validated it). Returns a problem for a weak/placeholder\n// token; nothing when unset (that surface is simply not exposed).\nconst MIN_ADMIN_TOKEN_LENGTH = 32;\nconst PLACEHOLDER_TOKEN =\n\t/dev-secret|test-secret|changeme|change-me|your[-_]secret|placeholder|example|insecure|admin-token|min-32-chars/i;\n\nexport function validateAdminToken(\n\ttoken: string | undefined,\n\topts: { module: string },\n): IReadinessProblem[] {\n\tif (!token) return [];\n\tif (token.length < MIN_ADMIN_TOKEN_LENGTH) {\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_ADMIN_TOKEN_LENGTH} characters (got ${token.length})`,\n\t\t\t},\n\t\t];\n\t}\n\tif (PLACEHOLDER_TOKEN.test(token)) {\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,4 +1,4 @@
|
|
|
1
|
-
import { Middleware } from '../types.cjs';
|
|
1
|
+
import { Middleware, IReadinessProblem } from '../types.cjs';
|
|
2
2
|
|
|
3
3
|
interface CorsOptions {
|
|
4
4
|
methods?: string[];
|
|
@@ -13,11 +13,23 @@ declare function notFoundMiddleware(): Middleware;
|
|
|
13
13
|
|
|
14
14
|
declare const withBody: Middleware;
|
|
15
15
|
|
|
16
|
+
interface SecurityHeadersOptions {
|
|
17
|
+
hstsMaxAge?: number;
|
|
18
|
+
hstsIncludeSubDomains?: boolean;
|
|
19
|
+
hstsPreload?: boolean;
|
|
20
|
+
}
|
|
21
|
+
declare function withSecurityHeaders(options?: SecurityHeadersOptions): Middleware;
|
|
22
|
+
|
|
16
23
|
declare function defaultErrorHandler(err: unknown): Response;
|
|
17
24
|
|
|
18
25
|
declare const requireAuth: Middleware;
|
|
19
26
|
declare const requireAnyAuth: Middleware;
|
|
20
27
|
|
|
28
|
+
declare function requireAdminToken(adminToken: string): Middleware;
|
|
29
|
+
declare function validateAdminToken(token: string | undefined, opts: {
|
|
30
|
+
module: string;
|
|
31
|
+
}): IReadinessProblem[];
|
|
32
|
+
|
|
21
33
|
declare const requireVerified: Middleware;
|
|
22
34
|
|
|
23
35
|
interface IRequestSchema {
|
|
@@ -39,4 +51,4 @@ declare function validate(schema: IRequestSchema): Middleware;
|
|
|
39
51
|
declare function resolveClientIp(socketAddress: string | undefined, headers: Headers, trustProxy?: number): string | undefined;
|
|
40
52
|
declare function checkProxyConfig(socketAddress: string | undefined, headers: Headers, trustProxy: number): void;
|
|
41
53
|
|
|
42
|
-
export { type CorsOptions, type IRequestSchema, checkProxyConfig, defaultErrorHandler, notFoundMiddleware, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, validate, withBody, withCors, withLogger };
|
|
54
|
+
export { type CorsOptions, type IRequestSchema, type SecurityHeadersOptions, checkProxyConfig, defaultErrorHandler, notFoundMiddleware, requireAdminToken, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, validate, validateAdminToken, withBody, withCors, withLogger, withSecurityHeaders };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Middleware } from '../types.js';
|
|
1
|
+
import { Middleware, IReadinessProblem } from '../types.js';
|
|
2
2
|
|
|
3
3
|
interface CorsOptions {
|
|
4
4
|
methods?: string[];
|
|
@@ -13,11 +13,23 @@ declare function notFoundMiddleware(): Middleware;
|
|
|
13
13
|
|
|
14
14
|
declare const withBody: Middleware;
|
|
15
15
|
|
|
16
|
+
interface SecurityHeadersOptions {
|
|
17
|
+
hstsMaxAge?: number;
|
|
18
|
+
hstsIncludeSubDomains?: boolean;
|
|
19
|
+
hstsPreload?: boolean;
|
|
20
|
+
}
|
|
21
|
+
declare function withSecurityHeaders(options?: SecurityHeadersOptions): Middleware;
|
|
22
|
+
|
|
16
23
|
declare function defaultErrorHandler(err: unknown): Response;
|
|
17
24
|
|
|
18
25
|
declare const requireAuth: Middleware;
|
|
19
26
|
declare const requireAnyAuth: Middleware;
|
|
20
27
|
|
|
28
|
+
declare function requireAdminToken(adminToken: string): Middleware;
|
|
29
|
+
declare function validateAdminToken(token: string | undefined, opts: {
|
|
30
|
+
module: string;
|
|
31
|
+
}): IReadinessProblem[];
|
|
32
|
+
|
|
21
33
|
declare const requireVerified: Middleware;
|
|
22
34
|
|
|
23
35
|
interface IRequestSchema {
|
|
@@ -39,4 +51,4 @@ declare function validate(schema: IRequestSchema): Middleware;
|
|
|
39
51
|
declare function resolveClientIp(socketAddress: string | undefined, headers: Headers, trustProxy?: number): string | undefined;
|
|
40
52
|
declare function checkProxyConfig(socketAddress: string | undefined, headers: Headers, trustProxy: number): void;
|
|
41
53
|
|
|
42
|
-
export { type CorsOptions, type IRequestSchema, checkProxyConfig, defaultErrorHandler, notFoundMiddleware, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, validate, withBody, withCors, withLogger };
|
|
54
|
+
export { type CorsOptions, type IRequestSchema, type SecurityHeadersOptions, checkProxyConfig, defaultErrorHandler, notFoundMiddleware, requireAdminToken, requireAnyAuth, requireAuth, requireVerified, resolveClientIp, validate, validateAdminToken, withBody, withCors, withLogger, withSecurityHeaders };
|
|
@@ -102,6 +102,39 @@ var withBody = async (ctx, next) => {
|
|
|
102
102
|
return next();
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
+
// src/middlewares/security-headers.ts
|
|
106
|
+
function withSecurityHeaders(options = {}) {
|
|
107
|
+
const {
|
|
108
|
+
hstsMaxAge = 60 * 60 * 24 * 180,
|
|
109
|
+
hstsIncludeSubDomains = false,
|
|
110
|
+
hstsPreload = false
|
|
111
|
+
} = options;
|
|
112
|
+
let hsts = "";
|
|
113
|
+
if (hstsMaxAge > 0) {
|
|
114
|
+
hsts = `max-age=${hstsMaxAge}`;
|
|
115
|
+
if (hstsIncludeSubDomains || hstsPreload) hsts += "; includeSubDomains";
|
|
116
|
+
if (hstsPreload) hsts += "; preload";
|
|
117
|
+
}
|
|
118
|
+
return async (ctx, next) => {
|
|
119
|
+
const response = await next();
|
|
120
|
+
const patched = new Headers(response.headers);
|
|
121
|
+
patched.set("X-Content-Type-Options", "nosniff");
|
|
122
|
+
if (hsts && isHttps(ctx.request)) {
|
|
123
|
+
patched.set("Strict-Transport-Security", hsts);
|
|
124
|
+
}
|
|
125
|
+
return new Response(response.body, {
|
|
126
|
+
headers: patched,
|
|
127
|
+
status: response.status,
|
|
128
|
+
statusText: response.statusText
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function isHttps(request) {
|
|
133
|
+
if (request.url.startsWith("https:")) return true;
|
|
134
|
+
const proto = request.headers.get("x-forwarded-proto");
|
|
135
|
+
return proto?.split(",")[0]?.trim() === "https";
|
|
136
|
+
}
|
|
137
|
+
|
|
105
138
|
// src/middlewares/error-handler.ts
|
|
106
139
|
function defaultErrorHandler(err) {
|
|
107
140
|
const dev = process.env["NODE_ENV"] !== "production";
|
|
@@ -134,6 +167,47 @@ var requireAnyAuth = async (ctx, next) => {
|
|
|
134
167
|
return next();
|
|
135
168
|
};
|
|
136
169
|
|
|
170
|
+
// src/middlewares/require-admin-token.ts
|
|
171
|
+
import { timingSafeEqual } from "crypto";
|
|
172
|
+
function safeTokenEqual(a, b) {
|
|
173
|
+
const bufA = Buffer.from(a);
|
|
174
|
+
const bufB = Buffer.from(b);
|
|
175
|
+
if (bufA.length !== bufB.length) return false;
|
|
176
|
+
return timingSafeEqual(bufA, bufB);
|
|
177
|
+
}
|
|
178
|
+
function requireAdminToken(adminToken) {
|
|
179
|
+
return (ctx, next) => {
|
|
180
|
+
const header = ctx.request.headers.get("authorization") ?? "";
|
|
181
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
182
|
+
if (!token || !safeTokenEqual(token, adminToken)) {
|
|
183
|
+
return Promise.resolve(
|
|
184
|
+
setApiResponse(HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token")
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return next();
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
var MIN_ADMIN_TOKEN_LENGTH = 32;
|
|
191
|
+
var PLACEHOLDER_TOKEN = /dev-secret|test-secret|changeme|change-me|your[-_]secret|placeholder|example|insecure|admin-token|min-32-chars/i;
|
|
192
|
+
function validateAdminToken(token, opts) {
|
|
193
|
+
if (!token) return [];
|
|
194
|
+
if (token.length < MIN_ADMIN_TOKEN_LENGTH) {
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
module: opts.module,
|
|
198
|
+
severity: "error",
|
|
199
|
+
message: `adminToken must be at least ${MIN_ADMIN_TOKEN_LENGTH} characters (got ${token.length})`
|
|
200
|
+
}
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
if (PLACEHOLDER_TOKEN.test(token)) {
|
|
204
|
+
return [
|
|
205
|
+
{ module: opts.module, severity: "error", message: "adminToken looks like a placeholder or dev-default value" }
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
137
211
|
// src/middlewares/require-verified.ts
|
|
138
212
|
var requireVerified = async (ctx, next) => {
|
|
139
213
|
if (!ctx.user) {
|
|
@@ -228,13 +302,16 @@ export {
|
|
|
228
302
|
checkProxyConfig,
|
|
229
303
|
defaultErrorHandler,
|
|
230
304
|
notFoundMiddleware,
|
|
305
|
+
requireAdminToken,
|
|
231
306
|
requireAnyAuth,
|
|
232
307
|
requireAuth,
|
|
233
308
|
requireVerified,
|
|
234
309
|
resolveClientIp,
|
|
235
310
|
validate,
|
|
311
|
+
validateAdminToken,
|
|
236
312
|
withBody,
|
|
237
313
|
withCors,
|
|
238
|
-
withLogger
|
|
314
|
+
withLogger,
|
|
315
|
+
withSecurityHeaders
|
|
239
316
|
};
|
|
240
317
|
//# sourceMappingURL=index.js.map
|
|
@@ -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/error-handler.ts","../../src/middlewares/require-auth.ts","../../src/middlewares/require-verified.ts","../../src/middlewares/validate.ts","../../src/middlewares/client-ip.ts"],"sourcesContent":["import type { Middleware } from '../types';\n\nexport interface CorsOptions {\n\tmethods?: string[];\n\theaders?: string[];\n\torigin?: string | ((requestOrigin: string) => boolean);\n}\n\nexport function withCors(options: CorsOptions = {}): Middleware {\n\tconst {\n\t\torigin = '*',\n\t\theaders = ['Content-Type', 'Authorization'],\n\t\tmethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],\n\t} = options;\n\n\treturn async (ctx, next) => {\n\t\tconst requestOrigin = ctx.request.headers.get('origin') ?? '';\n\n\t\tconst allowOrigin =\n\t\t\ttypeof origin === 'function' ? (origin(requestOrigin) ? requestOrigin : '') : origin;\n\n\t\tconst corsHeaders: Record<string, string> = {\n\t\t\t'Access-Control-Max-Age': '86400',\n\t\t\t'Access-Control-Allow-Origin': allowOrigin,\n\t\t\t'Access-Control-Allow-Methods': methods.join(', '),\n\t\t\t'Access-Control-Allow-Headers': headers.join(', '),\n\t\t};\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\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\nexport const withBody: Middleware = async (ctx, next) => {\n\tconst method = ctx.request.method.toUpperCase();\n\n\tif (method === 'GET' || method === 'HEAD') {\n\t\treturn next();\n\t}\n\n\tconst ct = ctx.request.headers.get('content-type') ?? '';\n\n\ttry {\n\t\tif (ct.includes('application/json')) {\n\t\t\tconst text = (await ctx.request.clone().text()).trim();\n\t\t\tctx.meta.body = text ? JSON.parse(text) : {};\n\t\t} else if (ct.includes('application/x-www-form-urlencoded')) {\n\t\t\tconst text = await ctx.request.clone().text();\n\t\t\tctx.meta.body = Object.fromEntries(new URLSearchParams(text));\n\t\t}\n\t\t// multipart/form-data left to the handler — no dep-free way to parse it\n\t} catch {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'INVALID_REQUEST', 'Invalid request body');\n\t}\n\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '../response';\n\nexport function defaultErrorHandler(err: unknown): Response {\n\tconst dev = process.env['NODE_ENV'] !== 'production';\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 { 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/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\nexport interface CorsOptions {\n\tmethods?: string[];\n\theaders?: string[];\n\torigin?: string | ((requestOrigin: string) => boolean);\n}\n\nexport function withCors(options: CorsOptions = {}): Middleware {\n\tconst {\n\t\torigin = '*',\n\t\theaders = ['Content-Type', 'Authorization'],\n\t\tmethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],\n\t} = options;\n\n\treturn async (ctx, next) => {\n\t\tconst requestOrigin = ctx.request.headers.get('origin') ?? '';\n\n\t\tconst allowOrigin =\n\t\t\ttypeof origin === 'function' ? (origin(requestOrigin) ? requestOrigin : '') : origin;\n\n\t\tconst corsHeaders: Record<string, string> = {\n\t\t\t'Access-Control-Max-Age': '86400',\n\t\t\t'Access-Control-Allow-Origin': allowOrigin,\n\t\t\t'Access-Control-Allow-Methods': methods.join(', '),\n\t\t\t'Access-Control-Allow-Headers': headers.join(', '),\n\t\t};\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\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\nexport const withBody: Middleware = async (ctx, next) => {\n\tconst method = ctx.request.method.toUpperCase();\n\n\tif (method === 'GET' || method === 'HEAD') {\n\t\treturn next();\n\t}\n\n\tconst ct = ctx.request.headers.get('content-type') ?? '';\n\n\ttry {\n\t\tif (ct.includes('application/json')) {\n\t\t\tconst text = (await ctx.request.clone().text()).trim();\n\t\t\tctx.meta.body = text ? JSON.parse(text) : {};\n\t\t} else if (ct.includes('application/x-www-form-urlencoded')) {\n\t\t\tconst text = await ctx.request.clone().text();\n\t\t\tctx.meta.body = Object.fromEntries(new URLSearchParams(text));\n\t\t}\n\t\t// multipart/form-data left to the handler — no dep-free way to parse it\n\t} catch {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'INVALID_REQUEST', 'Invalid request body');\n\t}\n\n\treturn next();\n};\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\tconst dev = process.env['NODE_ENV'] !== 'production';\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\nimport { setApiResponse, HTTP } from '../response';\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.\n\n// Constant-time comparison so a wrong token can't be recovered byte-by-byte from\n// response timing. Length-guard first: timingSafeEqual throws on unequal lengths,\n// and that early return is acceptable — the token's length is not the secret.\nfunction safeTokenEqual(a: string, b: string): boolean {\n\tconst bufA = Buffer.from(a);\n\tconst bufB = Buffer.from(b);\n\tif (bufA.length !== bufB.length) return false;\n\treturn timingSafeEqual(bufA, bufB);\n}\n\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 || !safeTokenEqual(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 is enforced identically everywhere (previously\n// only @fonderie/config validated it). Returns a problem for a weak/placeholder\n// token; nothing when unset (that surface is simply not exposed).\nconst MIN_ADMIN_TOKEN_LENGTH = 32;\nconst PLACEHOLDER_TOKEN =\n\t/dev-secret|test-secret|changeme|change-me|your[-_]secret|placeholder|example|insecure|admin-token|min-32-chars/i;\n\nexport function validateAdminToken(\n\ttoken: string | undefined,\n\topts: { module: string },\n): IReadinessProblem[] {\n\tif (!token) return [];\n\tif (token.length < MIN_ADMIN_TOKEN_LENGTH) {\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_ADMIN_TOKEN_LENGTH} characters (got ${token.length})`,\n\t\t\t},\n\t\t];\n\t}\n\tif (PLACEHOLDER_TOKEN.test(token)) {\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":[]}
|
package/dist/types.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["// ──
|
|
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// ── 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":[]}
|
package/dist/types.d.cts
CHANGED
|
@@ -55,7 +55,6 @@ interface IFonderieContext {
|
|
|
55
55
|
readonly tenant: ITenant | null;
|
|
56
56
|
readonly user: IAuthUser | null;
|
|
57
57
|
readonly workspace: IWorkspace | null;
|
|
58
|
-
_router: IRouter;
|
|
59
58
|
}
|
|
60
59
|
type Middleware = (ctx: IFonderieContext, next: () => Promise<Response>) => Promise<Response>;
|
|
61
60
|
interface IFonderieApp {
|
|
@@ -67,6 +66,9 @@ interface IFonderieApp {
|
|
|
67
66
|
version?: string;
|
|
68
67
|
env?: string;
|
|
69
68
|
}): void;
|
|
69
|
+
boot(): Promise<IFonderieApp>;
|
|
70
|
+
checkProductionReadiness(): IReadinessReport;
|
|
71
|
+
securityReport(): ISecurityReport;
|
|
70
72
|
}
|
|
71
73
|
interface IReadinessProblem {
|
|
72
74
|
module: string;
|
|
@@ -77,6 +79,12 @@ interface IReadinessReport {
|
|
|
77
79
|
ok: boolean;
|
|
78
80
|
problems: IReadinessProblem[];
|
|
79
81
|
}
|
|
82
|
+
interface ISecurityReport {
|
|
83
|
+
generatedAt: string;
|
|
84
|
+
env: string;
|
|
85
|
+
registeredModules: string[];
|
|
86
|
+
readiness: IReadinessReport;
|
|
87
|
+
}
|
|
80
88
|
interface IFonderieModule {
|
|
81
89
|
name: string;
|
|
82
90
|
deps?: string[];
|
|
@@ -85,4 +93,4 @@ interface IFonderieModule {
|
|
|
85
93
|
}
|
|
86
94
|
type Operation = 'create' | 'read' | 'update' | 'delete';
|
|
87
95
|
|
|
88
|
-
export type { IAuthUser, ICourierMessage, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ITenant, IWorkspace, Middleware, Operation };
|
|
96
|
+
export type { IAuthUser, ICourierMessage, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ISecurityReport, ITenant, IWorkspace, Middleware, Operation };
|
package/dist/types.d.ts
CHANGED
|
@@ -55,7 +55,6 @@ interface IFonderieContext {
|
|
|
55
55
|
readonly tenant: ITenant | null;
|
|
56
56
|
readonly user: IAuthUser | null;
|
|
57
57
|
readonly workspace: IWorkspace | null;
|
|
58
|
-
_router: IRouter;
|
|
59
58
|
}
|
|
60
59
|
type Middleware = (ctx: IFonderieContext, next: () => Promise<Response>) => Promise<Response>;
|
|
61
60
|
interface IFonderieApp {
|
|
@@ -67,6 +66,9 @@ interface IFonderieApp {
|
|
|
67
66
|
version?: string;
|
|
68
67
|
env?: string;
|
|
69
68
|
}): void;
|
|
69
|
+
boot(): Promise<IFonderieApp>;
|
|
70
|
+
checkProductionReadiness(): IReadinessReport;
|
|
71
|
+
securityReport(): ISecurityReport;
|
|
70
72
|
}
|
|
71
73
|
interface IReadinessProblem {
|
|
72
74
|
module: string;
|
|
@@ -77,6 +79,12 @@ interface IReadinessReport {
|
|
|
77
79
|
ok: boolean;
|
|
78
80
|
problems: IReadinessProblem[];
|
|
79
81
|
}
|
|
82
|
+
interface ISecurityReport {
|
|
83
|
+
generatedAt: string;
|
|
84
|
+
env: string;
|
|
85
|
+
registeredModules: string[];
|
|
86
|
+
readiness: IReadinessReport;
|
|
87
|
+
}
|
|
80
88
|
interface IFonderieModule {
|
|
81
89
|
name: string;
|
|
82
90
|
deps?: string[];
|
|
@@ -85,4 +93,4 @@ interface IFonderieModule {
|
|
|
85
93
|
}
|
|
86
94
|
type Operation = 'create' | 'read' | 'update' | 'delete';
|
|
87
95
|
|
|
88
|
-
export type { IAuthUser, ICourierMessage, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ITenant, IWorkspace, Middleware, Operation };
|
|
96
|
+
export type { IAuthUser, ICourierMessage, IFonderieApp, IFonderieContext, IFonderieContextMeta, IFonderieModule, IReadinessProblem, IReadinessReport, IRouteMatch, IRouter, ISecurityReport, ITenant, IWorkspace, Middleware, Operation };
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Framework core — request router, middleware pipeline, module system, and shared context types. Every other @
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Framework core — request router, middleware pipeline, module system, and shared context types. Every other @fonderiejs package depends on this.",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"
|
|
6
|
+
"fonderiejs",
|
|
7
7
|
"framework",
|
|
8
8
|
"middleware",
|
|
9
9
|
"router",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"check": "biome check --write src"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@types/node": "^
|
|
63
|
+
"@types/node": "^26.4.0",
|
|
64
64
|
"tsup": "^8.5.1",
|
|
65
|
-
"tsx": "^4.
|
|
65
|
+
"tsx": "^4.23.12",
|
|
66
66
|
"typescript": "^6.0.3"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
@@ -76,11 +76,11 @@
|
|
|
76
76
|
],
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
|
79
|
-
"url": "git+https://github.com/fonderiejs/
|
|
79
|
+
"url": "git+https://github.com/fonderiejs/fonderie.git",
|
|
80
80
|
"directory": "packages/core"
|
|
81
81
|
},
|
|
82
|
-
"homepage": "https://github.com/fonderiejs/
|
|
82
|
+
"homepage": "https://github.com/fonderiejs/fonderie/tree/main/packages/core#readme",
|
|
83
83
|
"bugs": {
|
|
84
|
-
"url": "https://github.com/fonderiejs/
|
|
84
|
+
"url": "https://github.com/fonderiejs/fonderie/issues"
|
|
85
85
|
}
|
|
86
86
|
}
|