nucleus-core-ts 0.10.2 → 0.10.3

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.
@@ -280,6 +280,24 @@ export function createHttpProxyHandler(config) {
280
280
  }
281
281
  return `${baseUrl}${finalPath}`;
282
282
  }
283
+ /**
284
+ * The caller's address, from the inbound chain when hops are trusted.
285
+ *
286
+ * `X-Forwarded-For` is appended left-to-right, so with N trusted proxies in
287
+ * front the caller is the entry N from the RIGHT. Counting from the right is
288
+ * what makes it unspoofable: a client can prepend anything to the left, but
289
+ * it cannot control what the trusted hops append after it.
290
+ *
291
+ * Returns undefined when the chain is too short to satisfy the configured
292
+ * hop count — a chain that does not reach means the deployment is not what
293
+ * the configuration says, and inventing an address there would be worse than
294
+ * having none.
295
+ */ function clientFromChain(req, hops) {
296
+ if (hops <= 0) return undefined;
297
+ const chain = (req.headers.get('x-forwarded-for') ?? '').split(',').map((one)=>one.trim()).filter(Boolean);
298
+ if (chain.length < hops) return undefined;
299
+ return chain[chain.length - hops];
300
+ }
283
301
  function buildHeaders(req, target, clientIp) {
284
302
  const headers = new Headers(req.headers);
285
303
  // Strip hop-by-hop headers (RFC 7230 §6.1) — these are connection-scoped and
@@ -321,9 +339,21 @@ export function createHttpProxyHandler(config) {
321
339
  'true-client-ip',
322
340
  'x-client-ip'
323
341
  ])headers.delete(h);
324
- if (clientIp) {
325
- headers.set('x-forwarded-for', clientIp);
326
- headers.set('x-real-ip', clientIp);
342
+ /*
343
+ * With trusted hops configured, the CHAIN wins over the socket.
344
+ *
345
+ * The socket peer is only the client when this proxy is the edge. Behind an
346
+ * ingress it is the ingress pod, and overwriting the chain with it threw
347
+ * away the one value that identified the caller — so everything downstream
348
+ * keyed on the address saw a single visitor for the whole cluster.
349
+ *
350
+ * Still an OVERWRITE, never an append: what is written is a value derived
351
+ * from a position the client cannot reach, so a spoofed prefix on the left
352
+ * is discarded rather than forwarded.
353
+ */ const resolved = clientFromChain(req, config.trustedProxyHops ?? 0) ?? clientIp;
354
+ if (resolved) {
355
+ headers.set('x-forwarded-for', resolved);
356
+ headers.set('x-real-ip', resolved);
327
357
  } else {
328
358
  // Couldn't resolve the socket IP — do not forward a client-controlled value.
329
359
  headers.delete('x-forwarded-for');
@@ -142,6 +142,28 @@ export interface HttpProxyTarget {
142
142
  }
143
143
  export interface HttpProxyConfig {
144
144
  targets: HttpProxyTarget[];
145
+ /**
146
+ * How many proxies sit IN FRONT of this one, and are trusted.
147
+ *
148
+ * Default 0, which keeps the old behaviour: the socket peer is the client and
149
+ * `X-Forwarded-For` is overwritten with it. That is right when this proxy is
150
+ * the edge, and wrong the moment anything stands in front — behind a
151
+ * Kubernetes ingress the socket peer is the INGRESS POD, so every request in
152
+ * the cluster arrives with one address and everything keyed on it collapses
153
+ * into a single bucket.
154
+ *
155
+ * Measured on a live install: 353 captcha challenges over 58 hours all
156
+ * attributed to `10.233.104.64`, 50 of them refused by a 10-per-minute
157
+ * per-IP limit, which during the morning sign-in rush meant the login page
158
+ * could not load a challenge at all.
159
+ *
160
+ * Set to the number of trusted hops and the client address is read from the
161
+ * inbound chain instead: `X-Forwarded-For` is a list appended left-to-right,
162
+ * so the caller is the entry `trustedProxyHops` from the RIGHT. Counting from
163
+ * the right is what makes it unspoofable — a client can prepend whatever it
164
+ * likes to the left, but it cannot control what the trusted hops append.
165
+ */
166
+ trustedProxyHops?: number;
145
167
  debug?: boolean;
146
168
  onError?: (error: Error, path: string, status?: number) => void;
147
169
  onRequest?: (path: string, target: string) => void;
@@ -29,6 +29,8 @@ export { createTenantRoutes } from './routes/tenant';
29
29
  * Read by `createNucleusServer`, which puts it on every handler's context.
30
30
  */
31
31
  export declare function getMountedRuntime(): unknown;
32
+ /** @see recordResolvedOptions — the config AFTER a path was read and parsed. */
33
+ export declare function getResolvedOptions(): unknown;
32
34
  export declare function getMountedRouteGroups(): RouteGroup[];
33
35
  export declare function getMountedSocket(): WebSocketRoute | null;
34
36
  export declare function getMountedStaticAssets(): string | null;
@@ -33,13 +33,42 @@ export declare function requiredBodyCeiling(input: {
33
33
  * configuration promises, or `null` when it can.
34
34
  *
35
35
  * Deliberately a full instruction rather than a warning. "Body size may be too
36
- * small" sends somebody reading source; naming the constructor argument and the
37
- * number ends it.
36
+ * small" sends somebody reading source; naming the setting and the number ends
37
+ * it.
38
+ *
39
+ * The instruction used to name Elysia's constructor, which stopped existing at
40
+ * 0.10 — so every upgraded install logged an ERROR whose one actionable
41
+ * sentence was impossible to act on. A boot error that cannot be obeyed teaches
42
+ * people to ignore boot errors.
38
43
  */
39
44
  export declare function bodyCeilingComplaint(input: {
40
45
  configured: number | null | undefined;
41
46
  required: number | null;
42
47
  }): string | null;
48
+ /**
49
+ * What to hand `Bun.serve` as `maxRequestBodySize`, or `undefined` to leave
50
+ * Bun's default alone.
51
+ *
52
+ * This is HEADROOM, not enforcement, and the difference is the whole rule.
53
+ *
54
+ * Handing Bun the product's limit makes Bun answer first — a bare
55
+ * connection-level 413, no content-type, no body — instead of the guard's
56
+ * envelope. That is why the server passes it nothing by default. But Bun still
57
+ * cuts at 128 MiB before any guard runs, so an install advertising 1 GB uploads
58
+ * had them severed with nothing to show, and no way to raise it: this path
59
+ * forwarded nothing, and the boot warning told the host to construct an Elysia
60
+ * that no longer exists.
61
+ *
62
+ * So: never below what the guard enforces, never below what storage advertises,
63
+ * and unset whenever neither exceeds Bun's own default. Bun's number then sits
64
+ * at or above every limit the product applies, which leaves the guard as the
65
+ * first responder for everything it refuses — the envelope survives — while
66
+ * Bun stops severing uploads the product means to accept.
67
+ */
68
+ export declare function bunBodyCeilingFor(input: {
69
+ storageRequired: number | null;
70
+ guardCeiling: number | undefined;
71
+ }): number | undefined;
43
72
  /**
44
73
  * The sentence to print when the POD cannot carry what the configuration
45
74
  * promises, or `null` when it can.
@@ -99,6 +99,22 @@ export declare function getRecordedSwagger(): unknown;
99
99
  export declare function recordRuntime(runtime: unknown): void;
100
100
  /** What `recordRuntime` last recorded, or undefined when nothing did. */
101
101
  export declare function getMountedRuntime(): unknown;
102
+ /**
103
+ * The config AFTER it was resolved, which is not the config the host passed.
104
+ *
105
+ * `config.options` may be an object or a PATH to a JSON file. When it is a
106
+ * path, `buildNucleus` reads and parses it, and everything inside — the
107
+ * `security` block among it — exists only in there. `createNucleusServer`
108
+ * needed one of those settings and could only see the un-resolved form, so a
109
+ * host that passed a path silently ran without its request-body ceiling: the
110
+ * setting was in the file, read by the plugin, and invisible to the server.
111
+ *
112
+ * Recorded rather than returned from `buildNucleus`, to keep the one channel
113
+ * these two modules already use instead of opening a second.
114
+ */
115
+ export declare function recordResolvedOptions(options: unknown): void;
116
+ /** What `recordResolvedOptions` last recorded, or null when nothing did. */
117
+ export declare function getResolvedOptions(): unknown;
102
118
  export declare function recordStaticAssets(assetsPath: string): void;
103
119
  export declare function getRecordedStaticAssets(): string | null;
104
120
  export declare function recordBootStep(step: BootStep): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",