nestjs-slightly-better-auth 1.5.2 → 1.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 CHANGED
@@ -343,7 +343,7 @@ Better Auth treats a process with `NODE_ENV=test` or a truthy `TEST` variable (V
343
343
 
344
344
  The kits verify extension units against the invariants of the [reviewed specification](docs/design/design-v7.md) (§4 and §14.1). Each kit returns `ConformanceCase[]` (`{ id, title, skip?, run() }`) that `runConformance(cases, { describe, it })` registers with Vitest, Jest or `node:test`. Assertions use `node:assert/strict`; the kits import no test runner.
345
345
 
346
- Applicability comes from the unit, not from the options a harness passes. A platform that declares `capabilities.http2` needs the `http2` option. A transport with a lineage needs `invokeTwice`, one that nests handlers (`defaultAccessFor`) needs `invokeGraph`, and one that claims the kit's federation reference resolver needs `invokeEntities`. A transport whose browser leg is a connection's handshake, shared by the connection's messages (the WebSocket transports), runs the `T-ws-origin-*` cases instead of the `T-csrf-*` cases and needs `invokeConnection`, plus `authenticateConnection` when the app provides `WS_CONNECTION_AUTH`. Such a case fails when the helper is missing, and `T-selection` fails `expectBrowserLeg: false` for a transport that describes a browser leg. A case whose optional capability is absent is skipped with the reason. The kits read these members from unit objects before running; for classes and `defineExtension()` definitions they boot once and read the resolved unit. A case known to be inapplicable registers with its reason in the test name. A case that finds out at run time resolves with `{ skipped: reason }`, which `runConformance` reports through the runner's `context.skip(reason)` (Vitest, `node:test`; Jest has no runtime skip, so the test passes).
346
+ Applicability comes from the unit, not from the options a harness passes. A platform that declares `capabilities.http2` needs the `http2` option. The platform kit's trusted-hop rows need `trustOneProxy`, and its microservice rows of `H-no-adapter` need `microservice`, which returns the transport options to boot with (for example `{ transport: Transport.TCP, options: { host: "127.0.0.1", port: 0 } }`); the kit does not load the optional `@nestjs/microservices` peer itself. The production-mode rows set `process.env.NODE_ENV` to `production` while they run and restore it afterwards, so run the kit's cases sequentially. A transport with a lineage needs `invokeTwice`, one that nests handlers (`defaultAccessFor`) needs `invokeGraph`, and one that claims the kit's federation reference resolver needs `invokeEntities`. A transport whose browser leg is a connection's handshake, shared by the connection's messages (the WebSocket transports), runs the `T-ws-origin-*` cases instead of the `T-csrf-*` cases and needs `invokeConnection`, plus `authenticateConnection` when the app provides `WS_CONNECTION_AUTH`. Such a case fails when the helper is missing, and `T-selection` fails `expectBrowserLeg: false` for a transport that describes a browser leg. A case whose optional capability is absent is skipped with the reason. The kits read these members from unit objects before running; for classes and `defineExtension()` definitions they boot once and read the resolved unit. A case known to be inapplicable registers with its reason in the test name. A case that finds out at run time resolves with `{ skipped: reason }`, which `runConformance` reports through the runner's `context.skip(reason)` (Vitest, `node:test`; Jest has no runtime skip, so the test passes).
347
347
 
348
348
  ```ts
349
349
  import { ExpressAdapter } from "@nestjs/platform-express";
@@ -380,14 +380,14 @@ runConformance(
380
380
 
381
381
  | Kit | Unit | Covers |
382
382
  | ---------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
383
- | `httpPlatformConformance` | `HttpPlatform` | Mounting, byte-exact bodies, limits with host CORS, URL and header facts, client IP, HTTP/2, write-back, request accessors, error delivery, lifecycle (no adapter, late adapter, reused `TestingModule`, double `forRoot`), diagnostics and log hygiene |
383
+ | `httpPlatformConformance` | `HttpPlatform` | Mounting (including a dynamic `allowedHosts` base URL), byte-exact bodies, limits with host CORS, URL and header facts, client IP (production-mode per-IP rate-limit buckets, Better Auth `trustedProxies`), HTTP/2, write-back, request accessors, error delivery, lifecycle (no adapter, including microservices, late adapter, reused `TestingModule`, double `forRoot`, hooks kept after `close()`), diagnostics (404 hints, `W_PROXY_UNTRUSTED`) and log hygiene |
384
384
  | `transportConformance` | `AuthTransport` | Selection, credentials, per-request memoization, per-invocation decisions, acceptance, cookie forwarding or refresh suppression, origin and form-CSRF rules including forwarding on safe operations, WebSocket handshake origins on every message and at connection time (untrusted, missing and forwarded origins, junk tokens, dynamic base URLs, function-valued `trustedOrigins`), error shapes and logging, the exact coverage report of the transport's claims, `globalScope: false`, mixed-kind session reads, lazy scope construction and fail-closed extraction |
385
385
  | `principalSourceConformance` | `PrincipalSource` | Rejection without throwing, storage outages, delivery of every own-credential `Set-Cookie` exactly once, refresh suppression, clean absence, principal shape, declared credential headers, `sessionBacked` and `delegates` declarations |
386
386
  | `policyConformance` | `AuthorizationPolicy` | Deny values, storage outages, APIError mapping and re-classification, delegation scope, unnamed-kind gating, per-invocation decisions with shared I/O and plugin prerequisites; requirements name policy objects (construct a DI policy with its dependencies) |
387
387
 
388
388
  Every case boots real Nest applications or modules with a real Better Auth instance that includes `conformanceProbePlugin()`. The probe counts and faults every operation of the instance's database adapter, so storage outages reach endpoints that would swallow them, and records the `Set-Cookie` lines each call produces. `createConformanceAuth({ plugins })` builds such an instance: memory adapter, `testUtils()`, `bearer()`, the probe plugin, `nestjs()` last, and `session.updateAge: 0` and origin checks on, whatever `options` says. The principal-source and policy kits take that instance as `auth`; the principal-source kit rejects an instance without `session.updateAge: 0`. The transport kit creates its own instance and passes it to your `createApp` harness together with the kit's API-key source in `principals`, a `logger` to boot with, and the `globalScope`, `appEnhancers`, `fieldResolverEnhancers` and `federation` settings of each boot. Apply every fixture decorator, including the kit's `SetMetadata()` marker, which maps the transport's claims to fixtures. On a connection-shaped browser leg, the kit's cookie credential carries the instance's base URL, `http://localhost:3000`, as `Origin`, as a browser page of that origin does; the `T-ws-origin-*` cases pass their own `Origin`, `Host` and forwarding headers, which the harness sends in the handshake.
389
389
 
390
- This repository runs the platform kit against Express and Fastify; the transport kit against the built-in HTTP transport, an in-process reference transport, Socket.IO and `ws` (each with and without a principal TTL), the RPC transport over TCP (standalone and hybrid), gRPC, NATS, RabbitMQ, MQTT, Redis and Kafka, and the Apollo (Express and Fastify) and Mercurius transports over HTTP and over graphql-ws (with and without a subscription principal TTL; Mercurius also without `mercuriusSubscriptionContext()`), with federation 2 subgraphs and a federation 1 run of `T-reference-resolver`; the principal-source kit against the session and API-key sources; and the policy kit against the admin, organization, API-key and fresh-session policies. gRPC skips `T-error-shape`, because it delivers only a status and a message. The kit reads each call's browser leg while its invocation runs, because the GraphQL transports reject a completed request. Mutation tests confirm that each of these defects fails the intended case: a platform merging `Set-Cookie`, a transport sharing invocation state across aliases, a transport without a browser leg on safe operations, a `defineExtension()` transport with a lineage or nesting and no helper, a WebSocket transport that enforces its handshake like a safe read, exempts handshakes that carry a token, builds its leg URL from the bare handshake path or from forwarded headers, or answers internal errors with their cause, a GraphQL transport whose aliases share one invocation, whose lineage gives every field of an operation one position, whose field resolvers do not inherit their root's access, or that serves reference resolvers without field guards, a GraphQL harness that omits `invokeGraph`, `invokeEntities` or `invokeConnection`, a source throwing its denial, a source dropping its refresh cookie, a source turning a storage outage into a 401, and a policy widening delegated principals.
390
+ This repository runs the platform kit against Express and Fastify, including the Fastify HTTP/2 adapter for the HTTP/2 rows; the transport kit against the built-in HTTP transport, an in-process reference transport, Socket.IO and `ws` (each with and without a principal TTL), the RPC transport over TCP (standalone and hybrid), gRPC, NATS, RabbitMQ, MQTT, Redis and Kafka, and the Apollo (Express and Fastify) and Mercurius transports over HTTP and over graphql-ws (with and without a subscription principal TTL; Mercurius also without `mercuriusSubscriptionContext()`), with federation 2 subgraphs and a federation 1 run of `T-reference-resolver`; the principal-source kit against the session and API-key sources; and the policy kit against the admin, organization, API-key and fresh-session policies. gRPC skips `T-error-shape`, because it delivers only a status and a message. The kit reads each call's browser leg while its invocation runs, because the GraphQL transports reject a completed request. Mutation tests confirm that each of these defects fails the intended case: a platform merging `Set-Cookie`, handing Better Auth no client IP, ignoring its proxy trust, dropping the request host or stripping forwarding headers, a kernel that unbinds hooks on `close()`, reports taking over a closed binding or keeps a microservice's binding open, a transport sharing invocation state across aliases, a transport without a browser leg on safe operations, a `defineExtension()` transport with a lineage or nesting and no helper, a WebSocket transport that enforces its handshake like a safe read, exempts handshakes that carry a token, builds its leg URL from the bare handshake path or from forwarded headers, or answers internal errors with their cause, a GraphQL transport whose aliases share one invocation, whose lineage gives every field of an operation one position, whose field resolvers do not inherit their root's access, or that serves reference resolvers without field guards, a GraphQL harness that omits `invokeGraph`, `invokeEntities` or `invokeConnection`, a source throwing its denial, a source dropping its refresh cookie, a source turning a storage outage into a 401, and a policy widening delegated principals.
391
391
 
392
392
  ## Develop in this monorepo
393
393
 
@@ -493,7 +493,10 @@ function probePlugin(options = {}) {
493
493
  probeIp: (0, better_auth_api.createAuthEndpoint)("/probe/ip", {
494
494
  method: "GET",
495
495
  requireRequest: true
496
- }, async (ctx) => ctx.json({ ip: (0, better_auth_api.getIP)(ctx.request, ctx.context.options) })),
496
+ }, async (ctx) => ctx.json({
497
+ ip: (0, better_auth_api.getIP)(ctx.request, ctx.context.options),
498
+ resolved: resolvedIp(ctx.request, ctx.context.options)
499
+ })),
497
500
  probeThrowApiError: (0, better_auth_api.createAuthEndpoint)("/probe/throw-api-error", { method: "GET" }, async () => {
498
501
  throw new better_auth_api.APIError("BAD_REQUEST", {
499
502
  code: "PROBE_API_ERROR",
@@ -536,6 +539,30 @@ function probePlugin(options = {}) {
536
539
  Object.defineProperty(plugin, PROBE_STATE, { value: state });
537
540
  return plugin;
538
541
  }
542
+ /** A TEST-NET-2 address that no kit request sends and no kit configuration trusts. */
543
+ const IP_SENTINEL = "198.51.100.254";
544
+ const IP_SENTINEL_HEADER = "x-conformance-ip-sentinel";
545
+ /**
546
+ * The client IP Better Auth resolves from the request's IP headers, without the localhost fallback getIP() returns
547
+ * under NODE_ENV=development/test or a truthy TEST (vitest sets it): the sentinel header is read last, so getIP()
548
+ * returns the sentinel exactly when the configured headers yield no trusted address.
549
+ */
550
+ function resolvedIp(request, options) {
551
+ const ipAddress = options.advanced?.ipAddress;
552
+ const headers = new Headers(request.headers);
553
+ headers.set(IP_SENTINEL_HEADER, IP_SENTINEL);
554
+ const ip = (0, better_auth_api.getIP)(headers, {
555
+ ...options,
556
+ advanced: {
557
+ ...options.advanced,
558
+ ipAddress: {
559
+ ...ipAddress,
560
+ ipAddressHeaders: [...ipAddress?.ipAddressHeaders ?? ["x-forwarded-for"], IP_SENTINEL_HEADER]
561
+ }
562
+ }
563
+ });
564
+ return ip === IP_SENTINEL ? null : ip;
565
+ }
539
566
  /** The probe state of an instance that includes conformanceProbePlugin(). */
540
567
  async function probeOf(auth) {
541
568
  const plugin = (await auth.$context).getPlugin(PROBE_PLUGIN_ID);
@@ -756,6 +783,9 @@ function bootIssueCodes(error) {
756
783
  //#endregion
757
784
  //#region src/conformance-http.ts
758
785
  const AUTH = "/api/auth";
786
+ const DYNAMIC_AUTH = "/tenant/auth";
787
+ /** The client IP of the kit's requests: baseUrl() always connects over IPv4 loopback. */
788
+ const LOOPBACK = "127.0.0.1";
759
789
  const CORS_ORIGIN = "https://app.example";
760
790
  /** Makes the kit's `around` interceptor throw, so auth.handler rejects with a non-APIError. */
761
791
  const THROW_HEADER = "x-conformance-throw";
@@ -796,7 +826,19 @@ async function capabilitiesOf(options) {
796
826
  await boot.close();
797
827
  }
798
828
  }
829
+ /** Whether the resolved platform implements an optional member, booting a testing app when the reference cannot tell. */
830
+ async function implementsMember(options, name) {
831
+ const known = staticMember$1(options.platform, name);
832
+ if (known !== void 0) return known;
833
+ const boot = await bootHttp(options, "testing", {});
834
+ try {
835
+ return typeof Reflect.get(resolvedPlatform(boot.app), name) === "function";
836
+ } finally {
837
+ await boot.close();
838
+ }
839
+ }
799
840
  function kitModule(options, auth, probe, config, state) {
841
+ const hooks = state.hooks ?? config.hooks;
800
842
  const record = async (call, next) => {
801
843
  state.around.push(`${call.request.method.toUpperCase()} ${new URL(call.request.url).pathname}`);
802
844
  const failure = call.request.headers.get(THROW_HEADER);
@@ -949,7 +991,7 @@ function kitModule(options, auth, probe, config, state) {
949
991
  ConformanceAppController = require_decorate.__decorate([(0, _nestjs_common.Controller)()], ConformanceAppController);
950
992
  let ConformanceHookProvider = class ConformanceHookProvider {
951
993
  record(_context) {
952
- state.hooks?.push("/probe/html");
994
+ hooks?.push("/probe/html");
953
995
  }
954
996
  };
955
997
  require_decorate.__decorate([
@@ -976,6 +1018,9 @@ function kitModule(options, auth, probe, config, state) {
976
1018
  method: _nestjs_common.RequestMethod.ALL
977
1019
  });
978
1020
  }
1021
+ async onApplicationShutdown() {
1022
+ await config.onShutdown?.();
1023
+ }
979
1024
  };
980
1025
  ConformanceHttpModule = require_decorate.__decorate([(0, _nestjs_common.Module)({
981
1026
  imports,
@@ -988,7 +1033,7 @@ function kitModule(options, auth, probe, config, state) {
988
1033
  return ConformanceHttpModule;
989
1034
  }
990
1035
  async function bootHttp(options, bootstrap, config) {
991
- const auth = createConformanceAuth(config.auth);
1036
+ const auth = config.instance ?? createConformanceAuth(config.auth);
992
1037
  const probe = await probeOf(auth);
993
1038
  const logger = new CapturingLogger();
994
1039
  const filtered = [];
@@ -1104,6 +1149,53 @@ async function waitFor(predicate, message, timeoutMs = 2e3) {
1104
1149
  await new Promise((resolve) => setTimeout(resolve, 10));
1105
1150
  }
1106
1151
  }
1152
+ /**
1153
+ * Runs `fn` with process.env.NODE_ENV set to `value`, then restores it. The kit's production-mode rows use it: the
1154
+ * library treats every NODE_ENV except development, dev and test as production (§5.4). Run kit cases sequentially.
1155
+ */
1156
+ async function withNodeEnv(value, fn) {
1157
+ const previous = process.env.NODE_ENV;
1158
+ process.env.NODE_ENV = value;
1159
+ try {
1160
+ return await fn();
1161
+ } finally {
1162
+ if (previous === void 0) Reflect.deleteProperty(process.env, "NODE_ENV");
1163
+ else process.env.NODE_ENV = previous;
1164
+ }
1165
+ }
1166
+ const inProduction = (fn) => withNodeEnv("production", fn);
1167
+ /** The per-IP rule the rate-limit rows apply to /probe/ip, counted in a per-boot store that records every key. */
1168
+ const IP_RULE = {
1169
+ window: 60,
1170
+ max: 2
1171
+ };
1172
+ function rateLimited(keys) {
1173
+ const counts = /* @__PURE__ */ new Map();
1174
+ return { auth: { rateLimit: {
1175
+ enabled: true,
1176
+ customRules: { "/probe/ip": IP_RULE },
1177
+ customStorage: { async consume(key, rule) {
1178
+ keys.push(key);
1179
+ const count = (counts.get(key) ?? 0) + 1;
1180
+ counts.set(key, count);
1181
+ return count > rule.max ? {
1182
+ allowed: false,
1183
+ retryAfter: rule.window
1184
+ } : {
1185
+ allowed: true,
1186
+ retryAfter: null
1187
+ };
1188
+ } }
1189
+ } } };
1190
+ }
1191
+ /** Better Auth trustedProxies covering every loopback address the kit's requests come from. */
1192
+ const loopbackProxies = { auth: { advanced: { ipAddress: { trustedProxies: ["127.0.0.0/8", "::1/128"] } } } };
1193
+ function warningsWith(boot, ...parts) {
1194
+ return boot.logger.entries.filter((entry) => entry.level === "warn" && parts.every((part) => entry.text.includes(part))).map((entry) => entry.text);
1195
+ }
1196
+ function http2Origin(boot) {
1197
+ return boot.url.replace(/^https?:/, "http:");
1198
+ }
1107
1199
  function http2Request(origin, headers, body) {
1108
1200
  return new Promise((resolve, reject) => {
1109
1201
  const session = (0, node_http2.connect)(origin);
@@ -1146,7 +1238,9 @@ const multipartBody = [
1146
1238
  * Auth instance that includes conformanceProbePlugin(), an app controller, a @BeforeAuth() hook provider, a recording
1147
1239
  * global filter and an `around` recorder. HTTP/2 is required when the platform declares capabilities.http2; a platform
1148
1240
  * without the capability, or without the optional proxyTrust() or trustOneProxy, gets a skip with the reason: before
1149
- * the run for platform objects, at run time for classes and definitions.
1241
+ * the run for platform objects, at run time for classes and definitions. The microservice rows of H-no-adapter need the
1242
+ * `microservice` option. Production-mode rows set process.env.NODE_ENV to 'production' while they run and restore it,
1243
+ * so run the cases sequentially.
1150
1244
  */
1151
1245
  function httpPlatformConformance(options) {
1152
1246
  const bootstraps = options.bootstrap ?? ["factory", "testing"];
@@ -1154,6 +1248,14 @@ function httpPlatformConformance(options) {
1154
1248
  const add = (id, title, run, only) => {
1155
1249
  for (const bootstrap of bootstraps) cases.push(conformanceCase(id, `${title} [${bootstrap}]`, () => run(options, bootstrap), only?.(bootstrap)));
1156
1250
  };
1251
+ const noHttp2 = () => declaredCapabilities(options.platform) !== void 0 && !declaredCapabilities(options.platform)?.http2 ? "the platform declares no http2 capability" : void 0;
1252
+ /** A skip when the resolved platform declares no http2 capability; requires the http2 option when it does. */
1253
+ const http2Ready = async (o) => {
1254
+ if (!(await capabilitiesOf(o))?.http2) return conformanceSkip("the platform declares no http2 capability");
1255
+ node_assert_strict.default.ok(o.http2, "the platform declares capabilities.http2, so the kit requires the http2 option");
1256
+ };
1257
+ const noProxyTrust = () => staticMember$1(options.platform, "proxyTrust") === false ? "the platform does not implement the optional proxyTrust()" : void 0;
1258
+ const noTrustHook = () => options.trustOneProxy ? void 0 : "no trustOneProxy option was given";
1157
1259
  add("H-mount-methods", "every method under the base path reaches Better Auth", (o, b) => withBoot(o, b, {}, async (boot) => {
1158
1260
  for (const method of [
1159
1261
  "GET",
@@ -1203,6 +1305,53 @@ function httpPlatformConformance(options) {
1203
1305
  node_assert_strict.default.equal(response.status, 200);
1204
1306
  });
1205
1307
  });
1308
+ const dynamicHosts = [new URL(KIT_BASE_URL).host, "tenant.example"];
1309
+ const dynamicMount = {
1310
+ auth: {
1311
+ baseURL: {
1312
+ allowedHosts: dynamicHosts,
1313
+ fallback: KIT_BASE_URL
1314
+ },
1315
+ basePath: DYNAMIC_AUTH
1316
+ },
1317
+ prefix: "v1",
1318
+ versioning: true
1319
+ };
1320
+ const assertDynamicUrl = (value, host) => {
1321
+ const url = new URL(String(value));
1322
+ node_assert_strict.default.equal(url.host, host, `the request URL lost the allowed host ${host}`);
1323
+ node_assert_strict.default.equal(url.pathname, `${DYNAMIC_AUTH}/probe/url`);
1324
+ };
1325
+ add("H-mount-custom-path", "a dynamic allowedHosts baseURL mounts at basePath for every allowed host, unaffected by a global prefix and URI versioning", (o, b) => withBoot(o, b, dynamicMount, async (boot) => {
1326
+ for (const host of dynamicHosts) {
1327
+ const response = await sendRaw(`${boot.url}${DYNAMIC_AUTH}/probe/url`, { headers: { host } });
1328
+ node_assert_strict.default.equal(response.status, 200, `${host}: ${response.body.toString("utf8").slice(0, 200)}`);
1329
+ assertDynamicUrl(json(response).url, host);
1330
+ }
1331
+ const ping = await sendRaw(`${boot.url}/v1/app/ping`);
1332
+ node_assert_strict.default.equal(ping.status, 200);
1333
+ const prefixed = await sendRaw(`${boot.url}/v1${DYNAMIC_AUTH}/probe/html`);
1334
+ node_assert_strict.default.notEqual(prefixed.status, 200);
1335
+ const fallthrough = await sendRaw(`${boot.url}${AUTH}/probe/html`);
1336
+ node_assert_strict.default.equal(fallthrough.status, 404);
1337
+ node_assert_strict.default.ok(!boot.around.some((entry) => entry.includes(AUTH)), `the default base path reached Better Auth: ${boot.around.join(", ")}`);
1338
+ }));
1339
+ add("H-mount-custom-path", "over HTTP/2, a dynamic allowedHosts baseURL serves the :authority host at basePath", async (o, b) => {
1340
+ const skip = await http2Ready(o);
1341
+ if (skip) return skip;
1342
+ await withBoot(o, b, {
1343
+ ...dynamicMount,
1344
+ http2: true
1345
+ }, async (boot) => {
1346
+ const response = await http2Request(http2Origin(boot), {
1347
+ ":method": "GET",
1348
+ ":path": `${DYNAMIC_AUTH}/probe/url`,
1349
+ ":authority": "tenant.example"
1350
+ });
1351
+ node_assert_strict.default.equal(response.status, 200, response.body.toString("utf8"));
1352
+ assertDynamicUrl(JSON.parse(response.body.toString("utf8")).url, "tenant.example");
1353
+ });
1354
+ }, noHttp2);
1206
1355
  add("H-app-route-precedence", "a controller route under the base path is served by Nest", (o, b) => withBoot(o, b, {}, async (boot) => {
1207
1356
  const response = await sendRaw(`${boot.url}${AUTH}/app-owned`);
1208
1357
  node_assert_strict.default.equal(response.status, 200);
@@ -1410,10 +1559,87 @@ function httpPlatformConformance(options) {
1410
1559
  });
1411
1560
  for (const [name, value] of Object.entries(headers)) node_assert_strict.default.equal(result.headers.get(name), value, `${name} changed`);
1412
1561
  }));
1562
+ const probeIp = async (boot, headers = {}) => {
1563
+ const response = await sendRaw(`${boot.url}${AUTH}/probe/ip`, { headers });
1564
+ return response.status === 200 ? {
1565
+ status: response.status,
1566
+ ...json(response)
1567
+ } : { status: response.status };
1568
+ };
1413
1569
  add("H-ip-platform", "Better Auth's getIP sees the platform client IP", (o, b) => withBoot(o, b, {}, async (boot) => {
1414
- const response = await sendRaw(`${boot.url}${AUTH}/probe/ip`);
1415
- node_assert_strict.default.equal(json(response).ip, "127.0.0.1");
1570
+ const value = await probeIp(boot);
1571
+ node_assert_strict.default.equal(value.resolved, LOOPBACK, "Better Auth resolved no client IP from the request headers the platform set");
1572
+ node_assert_strict.default.equal(value.ip, LOOPBACK);
1416
1573
  }));
1574
+ add("H-ip-platform", "over HTTP/2, Better Auth's getIP sees the platform client IP", async (o, b) => {
1575
+ const skip = await http2Ready(o);
1576
+ if (skip) return skip;
1577
+ await withBoot(o, b, { http2: true }, async (boot) => {
1578
+ const response = await http2Request(http2Origin(boot), {
1579
+ ":method": "GET",
1580
+ ":path": `${AUTH}/probe/ip`
1581
+ });
1582
+ node_assert_strict.default.equal(response.status, 200, response.body.toString("utf8"));
1583
+ const value = JSON.parse(response.body.toString("utf8"));
1584
+ node_assert_strict.default.equal(value.resolved, LOOPBACK, "Better Auth resolved no client IP over HTTP/2");
1585
+ });
1586
+ }, noHttp2);
1587
+ const spoofedIp = {
1588
+ "x-forwarded-for": "203.0.113.20",
1589
+ "x-real-ip": "203.0.113.21",
1590
+ [`x-nsba-ip-${"0".repeat(32)}`]: "203.0.113.22"
1591
+ };
1592
+ add("H-ip-platform", "in production mode, the platform IP keys a per-IP rate-limit bucket that client-sent IP headers cannot leave", (o, b) => {
1593
+ const keys = [];
1594
+ return inProduction(() => withBoot(o, b, rateLimited(keys), async (boot) => {
1595
+ const first = await probeIp(boot);
1596
+ node_assert_strict.default.equal(first.resolved, LOOPBACK);
1597
+ const statuses = [first.status];
1598
+ for (let index = 1; index <= IP_RULE.max; index++) statuses.push((await probeIp(boot)).status);
1599
+ node_assert_strict.default.deepEqual(statuses, [
1600
+ 200,
1601
+ 200,
1602
+ 429
1603
+ ], `the per-IP rule (max ${IP_RULE.max}) answered ${statuses.join(", ")}`);
1604
+ node_assert_strict.default.equal((await probeIp(boot, spoofedIp)).status, 429, "client-sent IP headers moved the request out of the platform IP's bucket");
1605
+ node_assert_strict.default.deepEqual([...new Set(keys)], [`${LOOPBACK}|/probe/ip`], "the rate limiter was not keyed by the platform IP");
1606
+ }));
1607
+ });
1608
+ add("H-ip-platform", "in production mode behind a trusted hop, every forwarded client gets its own rate-limit bucket", (o, b) => {
1609
+ const keys = [];
1610
+ const [left, right] = ["203.0.113.30", "203.0.113.31"];
1611
+ return inProduction(() => withBoot(o, b, {
1612
+ ...rateLimited(keys),
1613
+ trustProxy: true
1614
+ }, async (boot) => {
1615
+ const statuses = [];
1616
+ for (let index = 0; index <= IP_RULE.max; index++) statuses.push((await probeIp(boot, { "x-forwarded-for": left })).status);
1617
+ node_assert_strict.default.deepEqual(statuses, [
1618
+ 200,
1619
+ 200,
1620
+ 429
1621
+ ]);
1622
+ const other = await probeIp(boot, { "x-forwarded-for": right });
1623
+ node_assert_strict.default.equal(other.status, 200, "a second client shared the first client's bucket");
1624
+ node_assert_strict.default.equal(other.resolved, right);
1625
+ node_assert_strict.default.deepEqual([...new Set(keys)].sort(), [`${left}|/probe/ip`, `${right}|/probe/ip`]);
1626
+ }));
1627
+ }, noTrustHook);
1628
+ add("H-ip-platform", "with trustedProxies covering the platform IP and trust off, Better Auth resolves no IP and B18 warns where proxyTrust() reports 'none'", (o, b) => inProduction(() => withBoot(o, b, loopbackProxies, async (boot) => {
1629
+ const value = await probeIp(boot, { "x-forwarded-for": "203.0.113.40" });
1630
+ node_assert_strict.default.equal(value.resolved, null, `Better Auth resolved ${String(value.resolved)} although trustedProxies covers the socket IP`);
1631
+ const trust = resolvedPlatform(boot.app).proxyTrust?.(boot.app.getHttpAdapter());
1632
+ const b18 = warningsWith(boot, "W_CLIENT_IP", "trustedProxies");
1633
+ if (trust?.mode === "none") node_assert_strict.default.equal(b18.length, 1, boot.logger.text());
1634
+ })));
1635
+ add("H-ip-platform", "with trustedProxies covering the proxy and trust on, Better Auth resolves the client IP without a B18 warning", (o, b) => inProduction(() => withBoot(o, b, {
1636
+ ...loopbackProxies,
1637
+ trustProxy: true
1638
+ }, async (boot) => {
1639
+ const value = await probeIp(boot, { "x-forwarded-for": "203.0.113.41" });
1640
+ node_assert_strict.default.equal(value.resolved, "203.0.113.41");
1641
+ node_assert_strict.default.deepEqual(warningsWith(boot, "W_CLIENT_IP", "trustedProxies"), []);
1642
+ })), noTrustHook);
1417
1643
  add("H-ip-spoof", "client-sent client-IP headers are ignored", (o, b) => withBoot(o, b, {}, async (boot) => {
1418
1644
  const ip = json(await sendRaw(`${boot.url}${AUTH}/probe/ip`, { headers: {
1419
1645
  "x-forwarded-for": "203.0.113.7",
@@ -1461,10 +1687,10 @@ function httpPlatformConformance(options) {
1461
1687
  node_assert_strict.default.equal((await probedUrl(boot)).origin, "https://proxy.example");
1462
1688
  }), () => options.trustOneProxy ? void 0 : "no trustOneProxy option was given");
1463
1689
  add("H-h2-pseudo", "HTTP/2 pseudo-headers never reach Better Auth", async (o, b) => {
1464
- if (!(await capabilitiesOf(o))?.http2) return conformanceSkip("the platform declares no http2 capability");
1465
- node_assert_strict.default.ok(o.http2, "the platform declares capabilities.http2, so the kit requires the http2 option");
1690
+ const skip = await http2Ready(o);
1691
+ if (skip) return skip;
1466
1692
  await withBoot(o, b, { http2: true }, async (boot) => {
1467
- const response = await http2Request(boot.url.replace(/^https?:/, "http:"), {
1693
+ const response = await http2Request(http2Origin(boot), {
1468
1694
  ":method": "POST",
1469
1695
  ":path": `${AUTH}/probe/echo-raw`,
1470
1696
  origin: KIT_BASE_URL,
@@ -1475,7 +1701,7 @@ function httpPlatformConformance(options) {
1475
1701
  node_assert_strict.default.equal(Buffer.from(value.body, "base64").toString(), "h2 body");
1476
1702
  node_assert_strict.default.ok(value.headers.every(([name]) => !name.startsWith(":")), "pseudo-headers reached Better Auth");
1477
1703
  });
1478
- }, () => declaredCapabilities(options.platform) !== void 0 && !declaredCapabilities(options.platform)?.http2 ? "the platform declares no http2 capability" : void 0);
1704
+ }, noHttp2);
1479
1705
  add("H-resp-multicookie", "three Set-Cookie values arrive on separate lines", (o, b) => withBoot(o, b, {}, async (boot) => {
1480
1706
  const lines = setCookieLines((await sendRaw(`${boot.url}${AUTH}/probe/multi-cookie`)).headers);
1481
1707
  node_assert_strict.default.equal(lines.length, 3, `Set-Cookie lines: ${lines.join(" | ")}`);
@@ -1640,6 +1866,39 @@ function httpPlatformConformance(options) {
1640
1866
  await moduleRef.close();
1641
1867
  node_assert_strict.default.equal(bridge.state, "closed");
1642
1868
  });
1869
+ add("H-no-adapter", "a microservice boots once without an HTTP adapter, with listen() and with init() then listen()", async (o, b) => {
1870
+ for (const initFirst of [false, true]) {
1871
+ const label = `${b === "factory" ? "NestFactory.createMicroservice()" : "Test.createNestMicroservice()"} with ${initFirst ? "init() then listen()" : "listen()"}`;
1872
+ const auth = createConformanceAuth();
1873
+ const bridge = await bridgeOf(auth);
1874
+ const logger = new CapturingLogger();
1875
+ const module = kitModule(o, auth, await probeOf(auth), { module: { logSummary: true } }, {
1876
+ filtered: [],
1877
+ around: [],
1878
+ accessor: {},
1879
+ requests: () => node_assert_strict.default.fail("no HTTP platform expected"),
1880
+ adapter: () => node_assert_strict.default.fail("no HTTP adapter expected")
1881
+ });
1882
+ const microserviceOptions = {
1883
+ ...o.microservice(),
1884
+ logger,
1885
+ abortOnError: false
1886
+ };
1887
+ const microservice = b === "factory" ? await _nestjs_core.NestFactory.createMicroservice(module, microserviceOptions) : (await _nestjs_testing.Test.createTestingModule({ imports: [module] }).compile()).createNestMicroservice(microserviceOptions);
1888
+ try {
1889
+ if (initFirst) await microservice.init();
1890
+ await microservice.listen();
1891
+ node_assert_strict.default.equal(bridge.state, "bound", `${label}: the instance is not bound`);
1892
+ const summaries = logger.entries.filter((entry) => entry.text.startsWith("'default': "));
1893
+ node_assert_strict.default.equal(summaries.length, 1, `${label}: the lifecycle ran ${summaries.length} times\n${logger.text()}`);
1894
+ node_assert_strict.default.match(summaries[0].text, /not mounted/, label);
1895
+ node_assert_strict.default.deepEqual(logger.errors(), [], `${label}: ${logger.text()}`);
1896
+ } finally {
1897
+ await microservice.close();
1898
+ }
1899
+ node_assert_strict.default.equal(bridge.state, "closed", `${label}: close() left the binding ${bridge.state}`);
1900
+ }
1901
+ }, () => options.microservice ? void 0 : "no microservice option was given");
1643
1902
  add("H-late-adapter", "module init followed by an application init fails with LATE_HTTP_ADAPTER", async (o) => {
1644
1903
  const auth = createConformanceAuth();
1645
1904
  const module = kitModule(o, auth, await probeOf(auth), {}, {
@@ -1713,6 +1972,46 @@ function httpPlatformConformance(options) {
1713
1972
  }
1714
1973
  node_assert_strict.default.ok(bootIssueCodes(result.error).includes("DUPLICATE_INSTANCE"), String(result.error));
1715
1974
  });
1975
+ add("H-close-keeps-hooks", "app.close() closes the binding without unbinding its hooks, and the next application takes it over silently", async (o, b) => {
1976
+ const auth = createConformanceAuth();
1977
+ const bridge = await bridgeOf(auth);
1978
+ const direct = async () => (await auth.handler(new Request(`${KIT_BASE_URL}${AUTH}/probe/html`))).status;
1979
+ const closedHooks = [];
1980
+ let duringShutdown;
1981
+ const first = await bootHttp(o, b, {
1982
+ instance: auth,
1983
+ hooks: closedHooks,
1984
+ onShutdown: async () => {
1985
+ duringShutdown = await direct();
1986
+ }
1987
+ });
1988
+ try {
1989
+ const served = await sendRaw(`${first.url}${AUTH}/probe/html`);
1990
+ node_assert_strict.default.equal(served.status, 200);
1991
+ node_assert_strict.default.equal(closedHooks.length, 1, "the @BeforeAuth() hook did not run");
1992
+ } finally {
1993
+ await first.close();
1994
+ }
1995
+ const warns = (boot) => boot.logger.entries.filter((entry) => entry.level === "warn").map((entry) => entry.text);
1996
+ const expected = new Set(warns(first));
1997
+ node_assert_strict.default.equal(duringShutdown, 200);
1998
+ node_assert_strict.default.equal(closedHooks.length, 2, "a Better Auth call from another module's shutdown hook skipped the application's @BeforeAuth() hook");
1999
+ node_assert_strict.default.equal(bridge.state, "closed", `app.close() left the binding ${bridge.state}`);
2000
+ node_assert_strict.default.equal(await direct(), 200);
2001
+ node_assert_strict.default.equal(closedHooks.length, 3, "a Better Auth call after app.close() skipped the closed application's @BeforeAuth() hook");
2002
+ const nextHooks = [];
2003
+ await withBoot(o, b, {
2004
+ instance: auth,
2005
+ hooks: nextHooks
2006
+ }, async (next) => {
2007
+ node_assert_strict.default.equal(bridge.state, "bound");
2008
+ node_assert_strict.default.deepEqual(warns(next).filter((text) => !expected.has(text)), [], "the next application warned while taking over the closed binding");
2009
+ const served = await sendRaw(`${next.url}${AUTH}/probe/html`);
2010
+ node_assert_strict.default.equal(served.status, 200);
2011
+ node_assert_strict.default.equal(nextHooks.length, 1, "the next application's hook did not run");
2012
+ node_assert_strict.default.equal(closedHooks.length, 3, "the closed application's hook still ran after the next application bound");
2013
+ });
2014
+ });
1716
2015
  add("H-diagnostics-404", "an unknown auth path logs one WARN with a suggestion and keeps the 404", (o, b) => withBoot(o, b, { module: { http: { diagnostics: true } } }, async (boot) => {
1717
2016
  for (let index = 0; index < 2; index++) {
1718
2017
  const response = await sendRaw(`${boot.url}${AUTH}/sign-inn/email`, {
@@ -1729,6 +2028,43 @@ function httpPlatformConformance(options) {
1729
2028
  node_assert_strict.default.equal(warnings.length, 1, boot.logger.text());
1730
2029
  node_assert_strict.default.match(warnings[0].text, /Did you mean/);
1731
2030
  }));
2031
+ const proxyWarnings = (boot) => warningsWith(boot, "W_PROXY_UNTRUSTED");
2032
+ add("H-proxy-untrusted-warning", "in production with proxy trust off, the first forwarded auth request logs W_PROXY_UNTRUSTED once and changes no response", (o, b) => inProduction(() => withBoot(o, b, {}, async (boot) => {
2033
+ const platform = resolvedPlatform(boot.app);
2034
+ if (typeof platform.proxyTrust !== "function") return conformanceSkip("the platform does not implement the optional proxyTrust()");
2035
+ const trust = platform.proxyTrust(boot.app.getHttpAdapter());
2036
+ if (trust.mode !== "none") return conformanceSkip(`a fresh adapter reports proxy trust '${trust.mode}', not 'none'`);
2037
+ const html = async (headers = {}) => {
2038
+ const response = await sendRaw(`${boot.url}${AUTH}/probe/html`, { headers });
2039
+ node_assert_strict.default.equal(response.status, 200);
2040
+ node_assert_strict.default.equal(response.body.toString("utf8"), "<p>probe</p>");
2041
+ };
2042
+ await html();
2043
+ node_assert_strict.default.deepEqual(proxyWarnings(boot), [], "a request without forwarding headers logged");
2044
+ const ping = await sendRaw(`${boot.url}/app/ping`, { headers: { "x-forwarded-for": "203.0.113.50" } });
2045
+ node_assert_strict.default.equal(ping.status, 200);
2046
+ node_assert_strict.default.deepEqual(proxyWarnings(boot), [], "an app route logged");
2047
+ await html({ "x-forwarded-for": "203.0.113.51" });
2048
+ const [warning, ...repeated] = proxyWarnings(boot);
2049
+ node_assert_strict.default.ok(warning, "a forwarded auth request logged no W_PROXY_UNTRUSTED");
2050
+ node_assert_strict.default.ok(warning.includes(`socket IP ${LOOPBACK}.`), `the warning did not name the platform's socket IP: ${warning}`);
2051
+ node_assert_strict.default.ok(!warning.includes("203.0.113.51"), "the warning named a client-sent address as the socket IP");
2052
+ node_assert_strict.default.deepEqual(repeated, []);
2053
+ await html({ forwarded: "for=203.0.113.52" });
2054
+ await html({ "x-real-ip": "203.0.113.53" });
2055
+ node_assert_strict.default.equal(proxyWarnings(boot).length, 1, "W_PROXY_UNTRUSTED was logged more than once per application");
2056
+ })), noProxyTrust);
2057
+ add("H-proxy-untrusted-warning", "outside production, forwarded auth requests log no W_PROXY_UNTRUSTED", (o, b) => withNodeEnv("test", () => withBoot(o, b, {}, async (boot) => {
2058
+ await sendRaw(`${boot.url}${AUTH}/probe/html`, { headers: { "x-forwarded-for": "203.0.113.54" } });
2059
+ node_assert_strict.default.deepEqual(proxyWarnings(boot), []);
2060
+ })), noProxyTrust);
2061
+ add("H-proxy-untrusted-warning", "in production behind a trusted hop, forwarded auth requests log no W_PROXY_UNTRUSTED", async (o, b) => {
2062
+ if (!await implementsMember(o, "proxyTrust")) return conformanceSkip("the platform does not implement the optional proxyTrust()");
2063
+ return inProduction(() => withBoot(o, b, { trustProxy: true }, async (boot) => {
2064
+ await sendRaw(`${boot.url}${AUTH}/probe/html`, { headers: { "x-forwarded-for": "203.0.113.55" } });
2065
+ node_assert_strict.default.deepEqual(proxyWarnings(boot), []);
2066
+ }));
2067
+ }, () => noProxyTrust() ?? noTrustHook());
1732
2068
  add("H-log-hygiene", "session and probe cookies never appear in captured logs", (o, b) => withBoot(o, b, {}, async (boot) => {
1733
2069
  const identity = await kitIdentity(boot.auth);
1734
2070
  const token = identity.cookie.split("=")[1].split(".")[0];