nestjs-slightly-better-auth 1.9.2 → 1.10.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
@@ -948,7 +948,7 @@ Better Auth treats a process with `NODE_ENV=test` or a truthy `TEST` variable (V
948
948
 
949
949
  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.
950
950
 
951
- 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 transport that nests handlers needs `graphqlContexts` to list `'static'` and `'cached'` for `T-stale-context`, whose cached-context row runs where the browser leg follows each operation; the custom-context rows of `T-subscription-origin` run for the listed `'fresh'` and `'plain-request'` contexts. The `T-subscription-*` cases run when `invocationShapes` includes `'subscriptions'` and need `invokeConnection` with its `connectionParams` option. `T-inherited-handler` runs with `invokeInherited`; harnesses of transports that address a handler by a name one inherited method shares across subclasses (an RPC pattern, a GraphQL root field) omit it, and the case is skipped with the reason. 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).
951
+ 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`, which trusts one forwarding hop on a created application (Express `app.set("trust proxy", 1)`), or `createTrustingHttpAdapter` (and `http2.createTrustingHttpAdapter` for their HTTP/2 rows), which returns a fresh adapter that already trusts it, for platforms that fix proxy trust at construction (Fastify `new FastifyAdapter({ trustProxy: "loopback" })`; the kit's requests come from loopback). 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 transport that nests handlers needs `graphqlContexts` to list `'static'` and `'cached'` for `T-stale-context`, whose cached-context row runs where the browser leg follows each operation; the custom-context rows of `T-subscription-origin` run for the listed `'fresh'` and `'plain-request'` contexts. The `T-subscription-*` cases run when `invocationShapes` includes `'subscriptions'` and need `invokeConnection` with its `connectionParams` option. `T-inherited-handler` runs with `invokeInherited`; harnesses of transports that address a handler by a name one inherited method shares across subclasses (an RPC pattern, a GraphQL root field) omit it, and the case is skipped with the reason. 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).
952
952
 
953
953
  ```ts
954
954
  import { ExpressAdapter } from "@nestjs/platform-express";
@@ -853,17 +853,6 @@ async function capabilitiesOf(options) {
853
853
  await boot.close();
854
854
  }
855
855
  }
856
- /** Whether the resolved platform implements an optional member, booting a testing app when the reference cannot tell. */
857
- async function implementsMember(options, name) {
858
- const known = staticMember$1(options.platform, name);
859
- if (known !== void 0) return known;
860
- const boot = await bootHttp(options, "testing", {});
861
- try {
862
- return typeof Reflect.get(resolvedPlatform(boot.app), name) === "function";
863
- } finally {
864
- await boot.close();
865
- }
866
- }
867
856
  function kitModule$1(options, auth, probe, config, state) {
868
857
  const hooks = state.hooks ?? config.hooks;
869
858
  const record = async (call, next) => {
@@ -1059,6 +1048,17 @@ function kitModule$1(options, auth, probe, config, state) {
1059
1048
  })], ConformanceHttpModule);
1060
1049
  return ConformanceHttpModule;
1061
1050
  }
1051
+ /** The factory of an adapter that trusts one forwarding hop, for HTTP/1.1 or HTTP/2, when the options give one. */
1052
+ function trustingAdapterOf(options, http2) {
1053
+ const source = http2 ? options.http2 : options;
1054
+ return source?.createTrustingHttpAdapter ? () => source.createTrustingHttpAdapter() : void 0;
1055
+ }
1056
+ /** A fresh adapter for a boot: the trusting adapter for a trusted-hop boot when given, else the default one. */
1057
+ function createAdapter(options, config) {
1058
+ const trusting = config.trustProxy ? trustingAdapterOf(options, config.http2) : void 0;
1059
+ if (trusting) return trusting();
1060
+ return config.http2 ? options.http2.createHttpAdapter() : options.createHttpAdapter();
1061
+ }
1062
1062
  async function bootHttp(options, bootstrap, config) {
1063
1063
  const auth = config.instance ?? createConformanceAuth(config.auth);
1064
1064
  const probe = await probeOf(auth);
@@ -1067,7 +1067,7 @@ async function bootHttp(options, bootstrap, config) {
1067
1067
  const around = [];
1068
1068
  const accessor = {};
1069
1069
  let application;
1070
- const adapter = config.http2 ? options.http2.createHttpAdapter() : options.createHttpAdapter();
1070
+ const adapter = createAdapter(options, config);
1071
1071
  const requests = () => resolvedPlatform(application).requests;
1072
1072
  const module = kitModule$1(options, auth, probe, config, {
1073
1073
  filtered,
@@ -1095,8 +1095,8 @@ async function bootHttp(options, bootstrap, config) {
1095
1095
  }
1096
1096
  if (config.prefix) app.setGlobalPrefix(config.prefix);
1097
1097
  if (config.versioning) app.enableVersioning({ type: _nestjs_common.VersioningType.URI });
1098
- if (config.trustProxy) {
1099
- node_assert_strict.default.ok(options.trustOneProxy, "trustOneProxy is required here");
1098
+ if (config.trustProxy && !trustingAdapterOf(options, config.http2)) {
1099
+ node_assert_strict.default.ok(options.trustOneProxy, "trustOneProxy or createTrustingHttpAdapter is required here");
1100
1100
  await options.trustOneProxy(app);
1101
1101
  }
1102
1102
  await app.init();
@@ -1104,6 +1104,7 @@ async function bootHttp(options, bootstrap, config) {
1104
1104
  return {
1105
1105
  app,
1106
1106
  url,
1107
+ http2: config.http2 === true,
1107
1108
  auth,
1108
1109
  probe,
1109
1110
  logger,
@@ -1263,9 +1264,10 @@ const multipartBody = [
1263
1264
  /**
1264
1265
  * The HTTP platform kit (invariants H1–H14). Each case boots a real Nest application per bootstrap with a real Better
1265
1266
  * Auth instance that includes conformanceProbePlugin(), an app controller, a @BeforeAuth() hook provider, a recording
1266
- * global filter and an `around` recorder. HTTP/2 is required when the platform declares capabilities.http2; a platform
1267
- * without the capability, or without the optional proxyTrust() or trustOneProxy, gets a skip with the reason: before
1268
- * the run for platform objects, at run time for classes and definitions. The microservice rows of H-no-adapter need the
1267
+ * global filter and an `around` recorder. HTTP/2 is required when the platform declares capabilities.http2. A platform
1268
+ * without the capability or the optional proxyTrust(), and a harness with neither trustOneProxy nor
1269
+ * createTrustingHttpAdapter for the trusted-hop rows, get a skip with the reason: before the run for platform objects,
1270
+ * at run time for classes and definitions. The microservice rows of H-no-adapter need the
1269
1271
  * `microservice` option. Production-mode rows set process.env.NODE_ENV to 'production' while they run and restore it,
1270
1272
  * so run the cases sequentially.
1271
1273
  */
@@ -1282,7 +1284,13 @@ function httpPlatformConformance(options) {
1282
1284
  node_assert_strict.default.ok(o.http2, "the platform declares capabilities.http2, so the kit requires the http2 option");
1283
1285
  };
1284
1286
  const noProxyTrust = () => staticMember$1(options.platform, "proxyTrust") === false ? "the platform does not implement the optional proxyTrust()" : void 0;
1285
- const noTrustHook = () => options.trustOneProxy ? void 0 : "no trustOneProxy option was given";
1287
+ /** A skip when neither trustOneProxy nor the protocol's createTrustingHttpAdapter can make a trusted-hop boot. */
1288
+ const noTrustHook = (http2 = false) => options.trustOneProxy || trustingAdapterOf(options, http2) ? void 0 : http2 ? "neither trustOneProxy nor http2.createTrustingHttpAdapter was given" : "neither trustOneProxy nor createTrustingHttpAdapter was given";
1289
+ /** The static skip of a trusted-hop row: the HTTP/2 row also needs the http2 capability. */
1290
+ const trustedHop = (http2) => () => (http2 ? noHttp2() : void 0) ?? noTrustHook(http2);
1291
+ /** The run-time skip of a trusted-hop row, decided from the resolved platform for HTTP/2. */
1292
+ const trustedHopReady = async (o, http2) => http2 ? http2Ready(o) : void 0;
1293
+ const overHttp2 = (http2, title) => http2 ? `over HTTP/2, ${title}` : title;
1286
1294
  add("H-mount-methods", "every method under the base path reaches Better Auth", (o, b) => withBoot(o, b, {}, async (boot) => {
1287
1295
  for (const method of [
1288
1296
  "GET",
@@ -1586,11 +1594,16 @@ function httpPlatformConformance(options) {
1586
1594
  });
1587
1595
  for (const [name, value] of Object.entries(headers)) node_assert_strict.default.equal(result.headers.get(name), value, `${name} changed`);
1588
1596
  }));
1597
+ /** GET /probe/ip over the boot's protocol. */
1589
1598
  const probeIp = async (boot, headers = {}) => {
1590
- const response = await sendRaw(`${boot.url}${AUTH}/probe/ip`, { headers });
1599
+ const response = boot.http2 ? await http2Request(http2Origin(boot), {
1600
+ ":method": "GET",
1601
+ ":path": `${AUTH}/probe/ip`,
1602
+ ...headers
1603
+ }) : await sendRaw(`${boot.url}${AUTH}/probe/ip`, { headers });
1591
1604
  return response.status === 200 ? {
1592
1605
  status: response.status,
1593
- ...json$1(response)
1606
+ ...JSON.parse(response.body.toString("utf8"))
1594
1607
  } : { status: response.status };
1595
1608
  };
1596
1609
  add("H-ip-platform", "Better Auth's getIP sees the platform client IP", (o, b) => withBoot(o, b, {}, async (boot) => {
@@ -1632,12 +1645,15 @@ function httpPlatformConformance(options) {
1632
1645
  node_assert_strict.default.deepEqual([...new Set(keys)], [`${LOOPBACK}|/probe/ip`], "the rate limiter was not keyed by the platform IP");
1633
1646
  }));
1634
1647
  });
1635
- add("H-ip-platform", "in production mode behind a trusted hop, every forwarded client gets its own rate-limit bucket", (o, b) => {
1648
+ for (const http2 of [false, true]) add("H-ip-platform", overHttp2(http2, "in production mode behind a trusted hop, every forwarded client gets its own rate-limit bucket"), async (o, b) => {
1649
+ const skip = await trustedHopReady(o, http2);
1650
+ if (skip) return skip;
1636
1651
  const keys = [];
1637
1652
  const [left, right] = ["203.0.113.30", "203.0.113.31"];
1638
1653
  return inProduction(() => withBoot(o, b, {
1639
1654
  ...rateLimited(keys),
1640
- trustProxy: true
1655
+ trustProxy: true,
1656
+ http2
1641
1657
  }, async (boot) => {
1642
1658
  const statuses = [];
1643
1659
  for (let index = 0; index <= IP_RULE.max; index++) statuses.push((await probeIp(boot, { "x-forwarded-for": left })).status);
@@ -1651,7 +1667,7 @@ function httpPlatformConformance(options) {
1651
1667
  node_assert_strict.default.equal(other.resolved, right);
1652
1668
  node_assert_strict.default.deepEqual([...new Set(keys)].sort(), [`${left}|/probe/ip`, `${right}|/probe/ip`]);
1653
1669
  }));
1654
- }, noTrustHook);
1670
+ }, trustedHop(http2));
1655
1671
  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) => {
1656
1672
  const value = await probeIp(boot, { "x-forwarded-for": "203.0.113.40" });
1657
1673
  node_assert_strict.default.equal(value.resolved, null, `Better Auth resolved ${String(value.resolved)} although trustedProxies covers the socket IP`);
@@ -1659,14 +1675,19 @@ function httpPlatformConformance(options) {
1659
1675
  const b18 = warningsWith(boot, "W_CLIENT_IP", "trustedProxies");
1660
1676
  if (trust?.mode === "none") node_assert_strict.default.equal(b18.length, 1, boot.logger.text());
1661
1677
  })));
1662
- 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, {
1663
- ...loopbackProxies,
1664
- trustProxy: true
1665
- }, async (boot) => {
1666
- const value = await probeIp(boot, { "x-forwarded-for": "203.0.113.41" });
1667
- node_assert_strict.default.equal(value.resolved, "203.0.113.41");
1668
- node_assert_strict.default.deepEqual(warningsWith(boot, "W_CLIENT_IP", "trustedProxies"), []);
1669
- })), noTrustHook);
1678
+ for (const http2 of [false, true]) add("H-ip-platform", overHttp2(http2, "with trustedProxies covering the proxy and trust on, Better Auth resolves the client IP without a B18 warning"), async (o, b) => {
1679
+ const skip = await trustedHopReady(o, http2);
1680
+ if (skip) return skip;
1681
+ return inProduction(() => withBoot(o, b, {
1682
+ ...loopbackProxies,
1683
+ trustProxy: true,
1684
+ http2
1685
+ }, async (boot) => {
1686
+ const value = await probeIp(boot, { "x-forwarded-for": "203.0.113.41" });
1687
+ node_assert_strict.default.equal(value.resolved, "203.0.113.41", "Better Auth did not resolve the client IP behind the trusted hop");
1688
+ node_assert_strict.default.deepEqual(warningsWith(boot, "W_CLIENT_IP", "trustedProxies"), []);
1689
+ }));
1690
+ }, trustedHop(http2));
1670
1691
  add("H-ip-spoof", "client-sent client-IP headers are ignored", (o, b) => withBoot(o, b, {}, async (boot) => {
1671
1692
  const ip = json$1(await sendRaw(`${boot.url}${AUTH}/probe/ip`, { headers: {
1672
1693
  "x-forwarded-for": "203.0.113.7",
@@ -1679,20 +1700,30 @@ function httpPlatformConformance(options) {
1679
1700
  "203.0.113.9"
1680
1701
  ].includes(String(ip)), `a spoofed IP reached Better Auth: ${String(ip)}`);
1681
1702
  }));
1682
- add("H-proxy-trust", "proxyTrust() reports the effective setting before and after trustOneProxy", (o, b) => withBoot(o, b, {}, async (boot) => {
1703
+ add("H-proxy-trust", "proxyTrust() reports the effective setting before and after trusting one hop", (o, b) => withBoot(o, b, {}, async (boot) => {
1683
1704
  const platform = resolvedPlatform(boot.app);
1684
1705
  if (typeof platform.proxyTrust !== "function") return conformanceSkip("the platform does not implement the optional proxyTrust()");
1685
1706
  const adapter = boot.app.getHttpAdapter();
1686
1707
  const before = platform.proxyTrust(adapter);
1687
- await o.trustOneProxy(boot.app);
1688
- const after = platform.proxyTrust(adapter);
1708
+ let after;
1709
+ if (o.trustOneProxy) {
1710
+ await o.trustOneProxy(boot.app);
1711
+ after = platform.proxyTrust(adapter);
1712
+ } else {
1713
+ const trusted = await bootHttp(o, b, { trustProxy: true });
1714
+ try {
1715
+ after = resolvedPlatform(trusted.app).proxyTrust(trusted.app.getHttpAdapter());
1716
+ } finally {
1717
+ await trusted.close();
1718
+ }
1719
+ }
1689
1720
  if (before.mode === "unknown" || after.mode === "unknown") {
1690
1721
  node_assert_strict.default.equal(before.mode, after.mode, "a platform that cannot inspect proxy trust must report unknown consistently");
1691
1722
  return;
1692
1723
  }
1693
- node_assert_strict.default.notDeepEqual(after, before, "proxyTrust() did not reflect trustOneProxy");
1724
+ node_assert_strict.default.notDeepEqual(after, before, "proxyTrust() did not reflect the trusted hop");
1694
1725
  node_assert_strict.default.notEqual(after.mode, "none");
1695
- }), () => staticMember$1(options.platform, "proxyTrust") === false ? "the platform does not implement the optional proxyTrust()" : options.trustOneProxy ? void 0 : "no trustOneProxy option was given");
1726
+ }), () => noProxyTrust() ?? noTrustHook());
1696
1727
  const forwarded = {
1697
1728
  "x-forwarded-proto": "https",
1698
1729
  "x-forwarded-host": "proxy.example"
@@ -1712,7 +1743,7 @@ function httpPlatformConformance(options) {
1712
1743
  trustProxy: true
1713
1744
  }, async (boot) => {
1714
1745
  node_assert_strict.default.equal((await probedUrl(boot)).origin, "https://proxy.example");
1715
- }), () => options.trustOneProxy ? void 0 : "no trustOneProxy option was given");
1746
+ }), () => noTrustHook());
1716
1747
  add("H-h2-pseudo", "HTTP/2 pseudo-headers never reach Better Auth", async (o, b) => {
1717
1748
  const skip = await http2Ready(o);
1718
1749
  if (skip) return skip;
@@ -2085,13 +2116,23 @@ function httpPlatformConformance(options) {
2085
2116
  await sendRaw(`${boot.url}${AUTH}/probe/html`, { headers: { "x-forwarded-for": "203.0.113.54" } });
2086
2117
  node_assert_strict.default.deepEqual(proxyWarnings(boot), []);
2087
2118
  })), noProxyTrust);
2088
- add("H-proxy-untrusted-warning", "in production behind a trusted hop, forwarded auth requests log no W_PROXY_UNTRUSTED", async (o, b) => {
2089
- if (!await implementsMember(o, "proxyTrust")) return conformanceSkip("the platform does not implement the optional proxyTrust()");
2090
- return inProduction(() => withBoot(o, b, { trustProxy: true }, async (boot) => {
2091
- await sendRaw(`${boot.url}${AUTH}/probe/html`, { headers: { "x-forwarded-for": "203.0.113.55" } });
2119
+ for (const http2 of [false, true]) add("H-proxy-untrusted-warning", overHttp2(http2, "in production behind a trusted hop, forwarded auth requests log no W_PROXY_UNTRUSTED"), async (o, b) => {
2120
+ const skip = await trustedHopReady(o, http2);
2121
+ if (skip) return skip;
2122
+ return inProduction(() => withBoot(o, b, {
2123
+ trustProxy: true,
2124
+ http2
2125
+ }, async (boot) => {
2126
+ const headers = { "x-forwarded-for": "203.0.113.55" };
2127
+ const response = boot.http2 ? await http2Request(http2Origin(boot), {
2128
+ ":method": "GET",
2129
+ ":path": `${AUTH}/probe/html`,
2130
+ ...headers
2131
+ }) : await sendRaw(`${boot.url}${AUTH}/probe/html`, { headers });
2132
+ node_assert_strict.default.equal(response.status, 200);
2092
2133
  node_assert_strict.default.deepEqual(proxyWarnings(boot), []);
2093
2134
  }));
2094
- }, () => noProxyTrust() ?? noTrustHook());
2135
+ }, trustedHop(http2));
2095
2136
  add("H-log-hygiene", "session and probe cookies never appear in captured logs", (o, b) => withBoot(o, b, {}, async (boot) => {
2096
2137
  const identity = await kitIdentity(boot.auth);
2097
2138
  const token = identity.cookie.split("=")[1].split(".")[0];