@beignet/core 0.0.32 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +409 -34
  3. package/dist/application/index.d.ts.map +1 -1
  4. package/dist/application/index.js +63 -47
  5. package/dist/application/index.js.map +1 -1
  6. package/dist/contracts/contract-builder.d.ts +0 -4
  7. package/dist/contracts/contract-builder.d.ts.map +1 -1
  8. package/dist/contracts/contract-builder.js +0 -6
  9. package/dist/contracts/contract-builder.js.map +1 -1
  10. package/dist/events/index.d.ts +6 -0
  11. package/dist/events/index.d.ts.map +1 -1
  12. package/dist/events/index.js +20 -10
  13. package/dist/events/index.js.map +1 -1
  14. package/dist/flags/index.d.ts +0 -4
  15. package/dist/flags/index.d.ts.map +1 -1
  16. package/dist/flags/index.js +0 -4
  17. package/dist/flags/index.js.map +1 -1
  18. package/dist/jobs/index.d.ts +415 -4
  19. package/dist/jobs/index.d.ts.map +1 -1
  20. package/dist/jobs/index.js +392 -3
  21. package/dist/jobs/index.js.map +1 -1
  22. package/dist/notifications/index.d.ts +194 -10
  23. package/dist/notifications/index.d.ts.map +1 -1
  24. package/dist/notifications/index.js +397 -59
  25. package/dist/notifications/index.js.map +1 -1
  26. package/dist/outbox/index.d.ts +150 -1
  27. package/dist/outbox/index.d.ts.map +1 -1
  28. package/dist/outbox/index.js +170 -1
  29. package/dist/outbox/index.js.map +1 -1
  30. package/dist/ports/events.d.ts +2 -2
  31. package/dist/ports/index.d.ts +7 -2
  32. package/dist/ports/index.d.ts.map +1 -1
  33. package/dist/ports/index.js +2 -1
  34. package/dist/ports/index.js.map +1 -1
  35. package/dist/providers/instrumentation.d.ts +4 -0
  36. package/dist/providers/instrumentation.d.ts.map +1 -1
  37. package/dist/providers/instrumentation.js.map +1 -1
  38. package/dist/providers/provider.d.ts +1 -1
  39. package/dist/schedules/index.d.ts +8 -7
  40. package/dist/schedules/index.d.ts.map +1 -1
  41. package/dist/schedules/index.js +47 -16
  42. package/dist/schedules/index.js.map +1 -1
  43. package/dist/server/hooks/index.d.ts +1 -0
  44. package/dist/server/hooks/index.d.ts.map +1 -1
  45. package/dist/server/hooks/index.js +1 -0
  46. package/dist/server/hooks/index.js.map +1 -1
  47. package/dist/server/hooks/rate-limit.d.ts +26 -13
  48. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  49. package/dist/server/hooks/rate-limit.js +28 -34
  50. package/dist/server/hooks/rate-limit.js.map +1 -1
  51. package/dist/server/hooks/security.d.ts +179 -0
  52. package/dist/server/hooks/security.d.ts.map +1 -0
  53. package/dist/server/hooks/security.js +225 -0
  54. package/dist/server/hooks/security.js.map +1 -0
  55. package/dist/server/index.d.ts +8 -0
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +8 -0
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/instrumentation.d.ts.map +1 -1
  60. package/dist/server/instrumentation.js +22 -6
  61. package/dist/server/instrumentation.js.map +1 -1
  62. package/dist/server/request-context.d.ts +4 -0
  63. package/dist/server/request-context.d.ts.map +1 -1
  64. package/dist/server/request-context.js +3 -0
  65. package/dist/server/request-context.js.map +1 -1
  66. package/dist/server/runtime-integrity.d.ts +84 -0
  67. package/dist/server/runtime-integrity.d.ts.map +1 -0
  68. package/dist/server/runtime-integrity.js +160 -0
  69. package/dist/server/runtime-integrity.js.map +1 -0
  70. package/dist/server/server.d.ts +10 -10
  71. package/dist/server/server.d.ts.map +1 -1
  72. package/dist/server/server.js +75 -11
  73. package/dist/server/server.js.map +1 -1
  74. package/dist/server/trusted-proxy.d.ts +77 -0
  75. package/dist/server/trusted-proxy.d.ts.map +1 -0
  76. package/dist/server/trusted-proxy.js +129 -0
  77. package/dist/server/trusted-proxy.js.map +1 -0
  78. package/dist/tasks/index.d.ts +6 -7
  79. package/dist/tasks/index.d.ts.map +1 -1
  80. package/dist/tasks/index.js +22 -5
  81. package/dist/tasks/index.js.map +1 -1
  82. package/dist/tenancy/index.d.ts +41 -0
  83. package/dist/tenancy/index.d.ts.map +1 -0
  84. package/dist/tenancy/index.js +34 -0
  85. package/dist/tenancy/index.js.map +1 -0
  86. package/dist/testing/index.d.ts +1 -1
  87. package/dist/testing/index.d.ts.map +1 -1
  88. package/dist/testing/index.js +1 -0
  89. package/dist/testing/index.js.map +1 -1
  90. package/dist/tracing/execution.d.ts +14 -0
  91. package/dist/tracing/execution.d.ts.map +1 -0
  92. package/dist/tracing/execution.js +17 -0
  93. package/dist/tracing/execution.js.map +1 -0
  94. package/dist/tracing/index.d.ts +49 -0
  95. package/dist/tracing/index.d.ts.map +1 -1
  96. package/dist/tracing/index.js +59 -0
  97. package/dist/tracing/index.js.map +1 -1
  98. package/dist/uploads/client.d.ts.map +1 -1
  99. package/dist/uploads/client.js +44 -6
  100. package/dist/uploads/client.js.map +1 -1
  101. package/dist/uploads/index.d.ts +71 -0
  102. package/dist/uploads/index.d.ts.map +1 -1
  103. package/dist/uploads/index.js +344 -15
  104. package/dist/uploads/index.js.map +1 -1
  105. package/dist/webhooks/index.d.ts +55 -4
  106. package/dist/webhooks/index.d.ts.map +1 -1
  107. package/dist/webhooks/index.js +106 -2
  108. package/dist/webhooks/index.js.map +1 -1
  109. package/package.json +5 -5
  110. package/skills/app-architecture/SKILL.md +31 -2
  111. package/src/application/index.ts +88 -56
  112. package/src/contracts/contract-builder.ts +0 -7
  113. package/src/events/index.ts +27 -14
  114. package/src/flags/index.ts +0 -5
  115. package/src/jobs/index.ts +895 -7
  116. package/src/notifications/index.ts +678 -70
  117. package/src/outbox/index.ts +358 -2
  118. package/src/ports/events.ts +2 -2
  119. package/src/ports/index.ts +17 -1
  120. package/src/providers/instrumentation.ts +4 -0
  121. package/src/providers/provider.ts +1 -1
  122. package/src/schedules/index.ts +60 -26
  123. package/src/server/hooks/index.ts +10 -0
  124. package/src/server/hooks/rate-limit.ts +52 -46
  125. package/src/server/hooks/security.ts +503 -0
  126. package/src/server/index.ts +8 -0
  127. package/src/server/instrumentation.ts +25 -5
  128. package/src/server/request-context.ts +7 -0
  129. package/src/server/runtime-integrity.ts +322 -0
  130. package/src/server/server.ts +88 -35
  131. package/src/server/trusted-proxy.ts +249 -0
  132. package/src/tasks/index.ts +29 -12
  133. package/src/tenancy/index.ts +55 -0
  134. package/src/testing/index.ts +2 -0
  135. package/src/tracing/execution.ts +37 -0
  136. package/src/tracing/index.ts +137 -0
  137. package/src/uploads/client.ts +65 -6
  138. package/src/uploads/index.ts +539 -14
  139. package/src/webhooks/index.ts +238 -9
@@ -9,6 +9,11 @@ import {
9
9
  createProviderInstrumentation,
10
10
  type ProviderInstrumentationTarget,
11
11
  } from "../../providers/index.js";
12
+ import {
13
+ resolveTrustedClientIp,
14
+ type TrustedProxyClientIpSource,
15
+ type TrustedProxyConfig,
16
+ } from "../trusted-proxy.js";
12
17
  import type { HttpRequestLike, ServerHook } from "../types.js";
13
18
 
14
19
  /**
@@ -40,14 +45,11 @@ type EarlyRateLimitScope = Exclude<RateLimitScope, "user">;
40
45
  * - `"x-forwarded-for-first"`: the first `x-forwarded-for` entry. This value
41
46
  * is client-controlled, so only use it when a trusted edge normalizes the
42
47
  * header before it reaches the app.
48
+ * - `"x-real-ip"` and `"cf-connecting-ip"`: dedicated platform headers.
43
49
  * - A function receives the raw request and returns the client IP, for
44
- * platform-specific headers such as `cf-connecting-ip`.
50
+ * platform-specific resolution.
45
51
  */
46
- export type RateLimitIpSource =
47
- | "none"
48
- | "x-forwarded-for-last"
49
- | "x-forwarded-for-first"
50
- | ((req: HttpRequestLike) => string | undefined);
52
+ export type RateLimitIpSource = "none" | TrustedProxyClientIpSource;
51
53
 
52
54
  /**
53
55
  * Options for `createRateLimitHooks(...)`.
@@ -76,42 +78,40 @@ export interface RateLimitOptions<Ctx> {
76
78
  * Resolve the client IP for `ip`-scoped limits.
77
79
  *
78
80
  * There is no default: when any contract declares an `ip`-scoped rate limit
79
- * and neither `ipSource` nor a custom `earlyKey` is configured, the hook
80
- * fails at startup. Pass an explicit trusted proxy strategy, a function for
81
- * platform-specific headers, or `"none"` to explicitly accept one shared
81
+ * and neither `trustedProxy.clientIp`, `ipSource`, nor a custom `earlyKey`
82
+ * is configured, the hook fails at startup. Prefer `trustedProxy.clientIp`
83
+ * for production proxy headers; keep `ipSource` for custom keying or
84
+ * compatibility, or pass `"none"` to explicitly accept one shared
82
85
  * `ip:unknown` bucket for all clients.
83
86
  */
84
87
  ipSource?: RateLimitIpSource;
85
- }
86
-
87
- function parseForwardedFor(req: HttpRequestLike): string[] {
88
- const header = req.headers.get("x-forwarded-for") ?? "";
89
- return header
90
- .split(",")
91
- .map((entry) => entry.trim())
92
- .filter(Boolean);
88
+ /**
89
+ * Shared trusted-proxy policy used to resolve client IPs when `ipSource` is
90
+ * not set.
91
+ *
92
+ * Configure this only when the app is always behind a platform or reverse
93
+ * proxy that strips or normalizes forwarding headers. For `ip`-scoped rate
94
+ * limits, set `trustedProxy.clientIp` to the header source written by that
95
+ * trusted edge.
96
+ */
97
+ trustedProxy?: TrustedProxyConfig;
93
98
  }
94
99
 
95
100
  function resolveClientIp(
96
101
  req: HttpRequestLike,
97
102
  ipSource: RateLimitIpSource,
98
103
  ): string | undefined {
99
- if (typeof ipSource === "function") {
100
- return ipSource(req) || undefined;
101
- }
102
-
103
104
  if (ipSource === "none") {
104
105
  return undefined;
105
106
  }
107
+ return resolveTrustedClientIp(req, ipSource);
108
+ }
106
109
 
107
- const entries = parseForwardedFor(req);
108
- if (entries.length === 0) {
109
- return undefined;
110
- }
111
-
112
- return ipSource === "x-forwarded-for-first"
113
- ? entries[0]
114
- : entries[entries.length - 1];
110
+ function hasTrustedProxyClientIp(
111
+ config: TrustedProxyConfig | undefined,
112
+ ): boolean {
113
+ if (!config) return false;
114
+ return Boolean(config.clientIp);
115
115
  }
116
116
 
117
117
  function formatContractNames(names: readonly string[]): string {
@@ -120,10 +120,9 @@ function formatContractNames(names: readonly string[]): string {
120
120
 
121
121
  function ipSourceConfigurationError(contractNames: string): Error {
122
122
  return new Error(
123
- `createRateLimitHooks(...) has no ipSource configured, but contract(s) ${contractNames} declare an "ip"-scoped rate limit. ` +
124
- `Set ipSource to "x-forwarded-for-last" (app is always behind a trusted reverse proxy that appends the socket address), ` +
125
- `"x-forwarded-for-first" (a trusted edge normalizes the header before it reaches the app), ` +
126
- `a function for platform-specific headers such as cf-connecting-ip, ` +
123
+ `createRateLimitHooks(...) has no client IP source configured, but contract(s) ${contractNames} declare an "ip"-scoped rate limit. ` +
124
+ `Set trustedProxy.clientIp or ipSource to a header source written by a trusted edge, ` +
125
+ `for example "x-forwarded-for-last", "x-forwarded-for-first", "x-real-ip", "cf-connecting-ip", or a custom function, ` +
127
126
  `or "none" to explicitly accept one shared ip:unknown bucket for all clients.`,
128
127
  );
129
128
  }
@@ -240,14 +239,14 @@ async function enforceRateLimit(
240
239
  * never sent to clients; denials emit a `rateLimit.denied` instrumentation
241
240
  * event that carries the key for operators.
242
241
  *
243
- * `ip`-scoped limits require an explicit `ipSource` (or a custom `earlyKey`):
244
- * the hook's `validate` phase fails `createServer(...)` startup when a
245
- * registered contract declares an `ip` scope without one, instead of silently
246
- * collapsing all clients into a shared `ip:unknown` bucket. Contracts added
247
- * later through `server.route(...)` are not visible to `validate`, so
248
- * enforcing an `ip`-scoped limit without an `ipSource` throws the same
249
- * configuration error at request time as a backstop. Pass `ipSource: "none"`
250
- * to explicitly opt in to the shared `ip:unknown` bucket.
242
+ * `ip`-scoped limits require an explicit `trustedProxy.clientIp`, `ipSource`,
243
+ * or custom `earlyKey`: the hook's `validate` phase fails `createServer(...)`
244
+ * startup when a registered contract declares an `ip` scope without one,
245
+ * instead of silently collapsing all clients into a shared `ip:unknown` bucket.
246
+ * Contracts added later through `server.route(...)` are not visible to
247
+ * `validate`, so enforcing an `ip`-scoped limit without a client-IP source
248
+ * throws the same configuration error at request time as a backstop. Pass
249
+ * `ipSource: "none"` to explicitly opt in to the shared `ip:unknown` bucket.
251
250
  *
252
251
  * @param options - Optional key builders and client-IP source.
253
252
  * @returns A server hook backed by `ctx.ports.rateLimit`.
@@ -255,21 +254,28 @@ async function enforceRateLimit(
255
254
  export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
256
255
  options: RateLimitOptions<Ctx> = {},
257
256
  ): ServerHook<Ctx, RateLimitPorts> {
258
- const { ipSource } = options;
257
+ const { ipSource, trustedProxy } = options;
259
258
  const getClientIp = (
260
259
  req: HttpRequestLike,
261
260
  contractName: string,
262
261
  ): string | undefined => {
263
- if (ipSource === undefined) {
264
- throw ipSourceConfigurationError(formatContractNames([contractName]));
262
+ if (ipSource !== undefined) {
263
+ return resolveClientIp(req, ipSource);
264
+ }
265
+ if (hasTrustedProxyClientIp(trustedProxy) && trustedProxy) {
266
+ return resolveTrustedClientIp(req, trustedProxy.clientIp);
265
267
  }
266
- return resolveClientIp(req, ipSource);
268
+ throw ipSourceConfigurationError(formatContractNames([contractName]));
267
269
  };
268
270
 
269
271
  return {
270
272
  name: "rate-limit",
271
273
  validate: ({ contracts }) => {
272
- if (ipSource !== undefined || options.earlyKey) {
274
+ if (
275
+ ipSource !== undefined ||
276
+ hasTrustedProxyClientIp(trustedProxy) ||
277
+ options.earlyKey
278
+ ) {
273
279
  return;
274
280
  }
275
281
  const ipScoped = contracts
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Security hooks for @beignet/core/server
3
+ */
4
+
5
+ import type { HttpContractConfig } from "../../contracts/index.js";
6
+ import { AppError, httpErrors } from "../../errors/index.js";
7
+ import {
8
+ resolveTrustedRequest,
9
+ type TrustedProxyConfig,
10
+ type TrustedProxyOptions,
11
+ } from "../trusted-proxy.js";
12
+ import type { HttpRequestLike, ServerHook } from "../types.js";
13
+
14
+ /**
15
+ * Strict-Transport-Security configuration.
16
+ *
17
+ * HSTS is disabled by default because it should only be sent by HTTPS
18
+ * deployments that intentionally commit browsers to the configured host policy.
19
+ */
20
+ export interface StrictTransportSecurityOptions {
21
+ /**
22
+ * HSTS max-age value in seconds.
23
+ *
24
+ * Defaults to one year when `strictTransportSecurity` is configured as an
25
+ * object.
26
+ */
27
+ maxAgeSec?: number;
28
+ /**
29
+ * Include subdomains in the HSTS policy.
30
+ */
31
+ includeSubDomains?: boolean;
32
+ /**
33
+ * Mark the policy as eligible for browser preload lists.
34
+ */
35
+ preload?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Response security headers applied by `createSecurityHeadersHooks(...)`.
40
+ *
41
+ * Headers are only added when the response does not already define the same
42
+ * header name. Route handlers can therefore opt into route-specific CSP or
43
+ * download headers without fighting the global hook.
44
+ */
45
+ export interface SecurityHeadersOptions {
46
+ /**
47
+ * Content Security Policy value. Disabled by default because browser apps need
48
+ * an app-owned asset, image, frame, and script policy.
49
+ */
50
+ contentSecurityPolicy?: string | false;
51
+ /**
52
+ * Cross-Origin-Opener-Policy value.
53
+ *
54
+ * Defaults to `"same-origin"`.
55
+ */
56
+ crossOriginOpenerPolicy?: string | false;
57
+ /**
58
+ * Cross-Origin-Resource-Policy value.
59
+ *
60
+ * Defaults to `"same-origin"`.
61
+ */
62
+ crossOriginResourcePolicy?: string | false;
63
+ /**
64
+ * Permissions-Policy value.
65
+ *
66
+ * Defaults to disabling camera, microphone, and geolocation.
67
+ */
68
+ permissionsPolicy?: string | false;
69
+ /**
70
+ * Referrer-Policy value.
71
+ *
72
+ * Defaults to `"strict-origin-when-cross-origin"`.
73
+ */
74
+ referrerPolicy?: string | false;
75
+ /**
76
+ * Strict-Transport-Security value. Pass a string for full control or an object
77
+ * for Beignet to format the header. Disabled by default.
78
+ */
79
+ strictTransportSecurity?: StrictTransportSecurityOptions | string | false;
80
+ /**
81
+ * X-Content-Type-Options value.
82
+ *
83
+ * Defaults to `"nosniff"`.
84
+ */
85
+ xContentTypeOptions?: "nosniff" | false;
86
+ /**
87
+ * X-Frame-Options value.
88
+ *
89
+ * Defaults to `"DENY"`. Use `contentSecurityPolicy` with `frame-ancestors`
90
+ * for more precise frame control.
91
+ */
92
+ xFrameOptions?: "DENY" | "SAMEORIGIN" | false;
93
+ }
94
+
95
+ export type CsrfFailureReason =
96
+ | "missing_origin"
97
+ | "untrusted_origin"
98
+ | "missing_token"
99
+ | "invalid_token";
100
+
101
+ /**
102
+ * Double-submit cookie token configuration for `createCsrfHooks(...)`.
103
+ */
104
+ export interface CsrfTokenOptions {
105
+ /**
106
+ * Header that must carry the CSRF token.
107
+ *
108
+ * Defaults to `"x-csrf-token"`.
109
+ */
110
+ headerName?: string;
111
+ /**
112
+ * Cookie that stores the expected CSRF token.
113
+ *
114
+ * Defaults to `"beignet.csrf"`.
115
+ */
116
+ cookieName?: string;
117
+ }
118
+
119
+ /**
120
+ * Options for `createCsrfHooks(...)`.
121
+ */
122
+ export interface CsrfHooksOptions {
123
+ /**
124
+ * Unsafe HTTP methods protected by the hook.
125
+ *
126
+ * Defaults to `POST`, `PUT`, `PATCH`, and `DELETE`.
127
+ */
128
+ protectedMethods?: readonly string[];
129
+ /**
130
+ * Additional trusted origins allowed to send protected requests.
131
+ *
132
+ * The request URL's own origin is always trusted. Use this for sibling
133
+ * frontends such as `https://app.example.com` calling `https://api.example.com`.
134
+ */
135
+ trustedOrigins?:
136
+ | readonly string[]
137
+ | ((args: {
138
+ origin: string;
139
+ req: HttpRequestLike;
140
+ contract: HttpContractConfig;
141
+ }) => boolean);
142
+ /**
143
+ * Whether unsafe requests without `Origin` or `Referer` are allowed.
144
+ *
145
+ * Defaults to `true` so server-to-server calls, tests, and older same-origin
146
+ * clients keep working. Set to `false` for cookie-backed browser-only APIs.
147
+ */
148
+ allowMissingOrigin?: boolean;
149
+ /**
150
+ * Optional double-submit cookie token check.
151
+ *
152
+ * When configured, protected requests must send the same token in the
153
+ * configured header and cookie.
154
+ */
155
+ token?: false | CsrfTokenOptions;
156
+ /**
157
+ * Shared trusted-proxy policy used when comparing the request's external
158
+ * origin against `Origin` or `Referer`.
159
+ *
160
+ * Configure this only when the app is always behind a platform or reverse
161
+ * proxy that strips or normalizes forwarding headers. Without this option,
162
+ * CSRF uses `req.url` exactly as the adapter provided it.
163
+ */
164
+ trustedProxy?: TrustedProxyConfig;
165
+ /**
166
+ * App-owned escape hatch for routes that have another verifier, such as
167
+ * provider webhooks or auth callbacks.
168
+ */
169
+ skip?: (args: {
170
+ req: HttpRequestLike;
171
+ contract: HttpContractConfig;
172
+ params: Record<string, string>;
173
+ }) => boolean | Promise<boolean>;
174
+ }
175
+
176
+ const DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=()";
177
+ const DEFAULT_PROTECTED_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
178
+ const DEFAULT_CSRF_HEADER = "x-csrf-token";
179
+ const DEFAULT_CSRF_COOKIE = "beignet.csrf";
180
+
181
+ function headerKey(
182
+ headers: Record<string, string>,
183
+ name: string,
184
+ ): string | undefined {
185
+ const lowerName = name.toLowerCase();
186
+ return Object.keys(headers).find((key) => key.toLowerCase() === lowerName);
187
+ }
188
+
189
+ function setHeaderIfMissing(
190
+ headers: Record<string, string>,
191
+ name: string,
192
+ value: string | false | undefined,
193
+ ): void {
194
+ if (value === false || value === undefined) return;
195
+ if (headerKey(headers, name)) return;
196
+ headers[name] = value;
197
+ }
198
+
199
+ function formatStrictTransportSecurity(
200
+ config: SecurityHeadersOptions["strictTransportSecurity"],
201
+ ): string | false | undefined {
202
+ if (typeof config === "string" || config === false || config === undefined) {
203
+ return config;
204
+ }
205
+
206
+ const directives = [`max-age=${config.maxAgeSec ?? 31_536_000}`];
207
+ if (config.includeSubDomains) directives.push("includeSubDomains");
208
+ if (config.preload) directives.push("preload");
209
+ return directives.join("; ");
210
+ }
211
+
212
+ /**
213
+ * Apply Beignet's default security response headers to a mutable header record.
214
+ *
215
+ * Existing headers are preserved case-insensitively so route-owned responses can
216
+ * provide more specific policies.
217
+ */
218
+ export function applySecurityHeaders(
219
+ headers: Record<string, string>,
220
+ options: SecurityHeadersOptions = {},
221
+ ): void {
222
+ setHeaderIfMissing(
223
+ headers,
224
+ "Content-Security-Policy",
225
+ options.contentSecurityPolicy,
226
+ );
227
+ setHeaderIfMissing(
228
+ headers,
229
+ "Cross-Origin-Opener-Policy",
230
+ options.crossOriginOpenerPolicy ?? "same-origin",
231
+ );
232
+ setHeaderIfMissing(
233
+ headers,
234
+ "Cross-Origin-Resource-Policy",
235
+ options.crossOriginResourcePolicy ?? "same-origin",
236
+ );
237
+ setHeaderIfMissing(
238
+ headers,
239
+ "Permissions-Policy",
240
+ options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY,
241
+ );
242
+ setHeaderIfMissing(
243
+ headers,
244
+ "Referrer-Policy",
245
+ options.referrerPolicy ?? "strict-origin-when-cross-origin",
246
+ );
247
+ setHeaderIfMissing(
248
+ headers,
249
+ "Strict-Transport-Security",
250
+ formatStrictTransportSecurity(options.strictTransportSecurity),
251
+ );
252
+ setHeaderIfMissing(
253
+ headers,
254
+ "X-Content-Type-Options",
255
+ options.xContentTypeOptions ?? "nosniff",
256
+ );
257
+ setHeaderIfMissing(
258
+ headers,
259
+ "X-Frame-Options",
260
+ options.xFrameOptions ?? "DENY",
261
+ );
262
+ }
263
+
264
+ /**
265
+ * Create a server hook that adds common browser security headers to every
266
+ * response, including native streamed responses.
267
+ */
268
+ export function createSecurityHeadersHooks<Ctx>(
269
+ options: SecurityHeadersOptions = {},
270
+ ): ServerHook<Ctx> {
271
+ return {
272
+ name: "security-headers",
273
+ beforeSend: ({ response }) => {
274
+ const headers = { ...(response.headers ?? {}) };
275
+ applySecurityHeaders(headers, options);
276
+ return {
277
+ ...response,
278
+ headers,
279
+ };
280
+ },
281
+ };
282
+ }
283
+
284
+ function normalizeOrigin(value: string, source: string): string {
285
+ try {
286
+ return new URL(value).origin;
287
+ } catch {
288
+ throw new Error(`${source} must be an absolute URL origin.`);
289
+ }
290
+ }
291
+
292
+ function requestOrigin(
293
+ req: HttpRequestLike,
294
+ trustedProxy: TrustedProxyConfig | undefined,
295
+ ): string {
296
+ return resolveTrustedRequest(req, trustedProxyOriginConfig(trustedProxy))
297
+ .origin;
298
+ }
299
+
300
+ function trustedProxyOriginConfig(
301
+ trustedProxy: TrustedProxyConfig | undefined,
302
+ ): TrustedProxyConfig | undefined {
303
+ if (!trustedProxy) return trustedProxy;
304
+
305
+ const originConfig: TrustedProxyOptions = {};
306
+ if (trustedProxy.hostHeader !== undefined) {
307
+ originConfig.hostHeader = trustedProxy.hostHeader;
308
+ }
309
+ if (trustedProxy.protocolHeader !== undefined) {
310
+ originConfig.protocolHeader = trustedProxy.protocolHeader;
311
+ }
312
+ return originConfig;
313
+ }
314
+
315
+ function headerOrigin(value: string | null): string | undefined {
316
+ if (!value) return undefined;
317
+
318
+ try {
319
+ return new URL(value).origin;
320
+ } catch {
321
+ return undefined;
322
+ }
323
+ }
324
+
325
+ function originFromRequestHeaders(req: HttpRequestLike): string | undefined {
326
+ const origin = req.headers.get("origin");
327
+ if (origin) return headerOrigin(origin) ?? origin;
328
+
329
+ return headerOrigin(req.headers.get("referer"));
330
+ }
331
+
332
+ function normalizeTrustedOrigins(
333
+ trustedOrigins: CsrfHooksOptions["trustedOrigins"],
334
+ ): CsrfHooksOptions["trustedOrigins"] {
335
+ if (!Array.isArray(trustedOrigins)) return trustedOrigins;
336
+ return trustedOrigins.map((origin) =>
337
+ normalizeOrigin(origin, "trustedOrigins entries"),
338
+ );
339
+ }
340
+
341
+ function isTrustedOrigin(args: {
342
+ origin: string;
343
+ req: HttpRequestLike;
344
+ contract: HttpContractConfig;
345
+ requestOrigin: string;
346
+ trustedOrigins: CsrfHooksOptions["trustedOrigins"];
347
+ }): boolean {
348
+ if (args.origin === args.requestOrigin) return true;
349
+
350
+ if (Array.isArray(args.trustedOrigins)) {
351
+ return args.trustedOrigins.includes(args.origin);
352
+ }
353
+
354
+ if (typeof args.trustedOrigins === "function") {
355
+ return args.trustedOrigins({
356
+ origin: args.origin,
357
+ req: args.req,
358
+ contract: args.contract,
359
+ });
360
+ }
361
+
362
+ return false;
363
+ }
364
+
365
+ function decodeCookiePart(value: string): string {
366
+ try {
367
+ return decodeURIComponent(value);
368
+ } catch {
369
+ return value;
370
+ }
371
+ }
372
+
373
+ function parseCookieHeader(
374
+ cookieHeader: string | null,
375
+ ): Record<string, string> {
376
+ if (!cookieHeader) return {};
377
+
378
+ return Object.fromEntries(
379
+ cookieHeader
380
+ .split(";")
381
+ .map((entry) => entry.trim())
382
+ .filter(Boolean)
383
+ .map((entry) => {
384
+ const separator = entry.indexOf("=");
385
+ if (separator === -1) return [entry, ""];
386
+ return [
387
+ decodeCookiePart(entry.slice(0, separator).trim()),
388
+ decodeCookiePart(entry.slice(separator + 1).trim()),
389
+ ];
390
+ }),
391
+ );
392
+ }
393
+
394
+ function tokensMatch(left: string, right: string): boolean {
395
+ if (left.length !== right.length) return false;
396
+
397
+ let result = 0;
398
+ for (let index = 0; index < left.length; index += 1) {
399
+ result |= left.charCodeAt(index) ^ right.charCodeAt(index);
400
+ }
401
+ return result === 0;
402
+ }
403
+
404
+ function csrfError(reason: CsrfFailureReason, message: string): AppError {
405
+ return new AppError(httpErrors.Forbidden, { reason }, message);
406
+ }
407
+
408
+ function enforceOrigin(args: {
409
+ req: HttpRequestLike;
410
+ contract: HttpContractConfig;
411
+ allowMissingOrigin: boolean;
412
+ trustedOrigins: CsrfHooksOptions["trustedOrigins"];
413
+ trustedProxy: TrustedProxyConfig | undefined;
414
+ }): void {
415
+ const origin = originFromRequestHeaders(args.req);
416
+ if (!origin) {
417
+ if (args.allowMissingOrigin) return;
418
+ throw csrfError(
419
+ "missing_origin",
420
+ "CSRF check failed because the request did not include an Origin or Referer header.",
421
+ );
422
+ }
423
+
424
+ if (
425
+ !isTrustedOrigin({
426
+ origin,
427
+ req: args.req,
428
+ contract: args.contract,
429
+ requestOrigin: requestOrigin(args.req, args.trustedProxy),
430
+ trustedOrigins: args.trustedOrigins,
431
+ })
432
+ ) {
433
+ throw csrfError(
434
+ "untrusted_origin",
435
+ "CSRF check failed because the request origin is not trusted.",
436
+ );
437
+ }
438
+ }
439
+
440
+ function enforceToken(
441
+ req: HttpRequestLike,
442
+ token: CsrfTokenOptions | false,
443
+ ): void {
444
+ if (!token) return;
445
+
446
+ const headerName = token.headerName ?? DEFAULT_CSRF_HEADER;
447
+ const cookieName = token.cookieName ?? DEFAULT_CSRF_COOKIE;
448
+ const headerToken = req.headers.get(headerName);
449
+ const cookieToken = parseCookieHeader(req.headers.get("cookie"))[cookieName];
450
+
451
+ if (!headerToken || !cookieToken) {
452
+ throw csrfError(
453
+ "missing_token",
454
+ `CSRF check failed because ${headerName} and ${cookieName} must both be present.`,
455
+ );
456
+ }
457
+
458
+ if (!tokensMatch(headerToken, cookieToken)) {
459
+ throw csrfError(
460
+ "invalid_token",
461
+ "CSRF check failed because the submitted token does not match the cookie token.",
462
+ );
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Create CSRF protection for unsafe HTTP methods.
468
+ *
469
+ * The default protects cookie-backed browser routes from cross-origin unsafe
470
+ * requests while still allowing server-to-server calls and tests that do not
471
+ * send browser origin headers. Set `allowMissingOrigin: false` and configure
472
+ * `token` for stricter browser-only APIs.
473
+ */
474
+ export function createCsrfHooks<Ctx>(
475
+ options: CsrfHooksOptions = {},
476
+ ): ServerHook<Ctx> {
477
+ const protectedMethods = new Set(
478
+ (options.protectedMethods ?? DEFAULT_PROTECTED_METHODS).map((method) =>
479
+ method.toUpperCase(),
480
+ ),
481
+ );
482
+ const allowMissingOrigin = options.allowMissingOrigin ?? true;
483
+ const trustedOrigins = normalizeTrustedOrigins(options.trustedOrigins);
484
+ const token = options.token ?? false;
485
+
486
+ return {
487
+ name: "csrf",
488
+ onRequest: async ({ req, contract, params }) => {
489
+ if (!protectedMethods.has(req.method.toUpperCase())) return undefined;
490
+ if (await options.skip?.({ req, contract, params })) return undefined;
491
+
492
+ enforceOrigin({
493
+ req,
494
+ contract,
495
+ allowMissingOrigin,
496
+ trustedOrigins,
497
+ trustedProxy: options.trustedProxy,
498
+ });
499
+ enforceToken(req, token);
500
+ return undefined;
501
+ },
502
+ };
503
+ }
@@ -63,6 +63,10 @@ export {
63
63
  getActiveRequestContext,
64
64
  inheritActiveRequestContext,
65
65
  } from "./request-context.js";
66
+ /**
67
+ * Runtime workflow registration integrity helpers.
68
+ */
69
+ export * from "./runtime-integrity.js";
66
70
  /**
67
71
  * Server configuration and route types.
68
72
  */
@@ -89,6 +93,10 @@ export {
89
93
  * Server context blueprint declaration helper.
90
94
  */
91
95
  export { defineServerContext } from "./server-context.js";
96
+ /**
97
+ * Trusted proxy request metadata helpers.
98
+ */
99
+ export * from "./trusted-proxy.js";
92
100
  export * from "./types.js";
93
101
  /**
94
102
  * Contract-aware route binder types and helpers.