@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.1

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 (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
@@ -22,16 +22,11 @@
22
22
  *
23
23
  * Google publishes no end-session endpoint, and without one configured its
24
24
  * adapter sends the browser straight to the `postLogoutRedirectUri` it is
25
- * handed. Both logout routes used to hand it the caller's value unchecked,
26
- * ahead of the allowlist check that guarded their own redirect, so a fresh
27
- * id_token of the caller's own and `post_logout_redirect_uri=https://evil…`
28
- * made this provider's origin answer `303` to any site. With an end-session
29
- * endpoint configured, the unchecked value was forwarded to the upstream
30
- * instead.
31
- *
32
- * The redirect asserted here is the one a browser would follow: the real
33
- * Google adapter, composed as a deployment composes it
34
- * (`google-session.fixture.mts`).
25
+ * handed: unchecked, a caller's own fresh id_token would make this provider's
26
+ * origin answer `303` to any site. With an end-session endpoint configured,
27
+ * an unchecked value would be forwarded to the upstream instead. The real
28
+ * Google adapter is composed (`google-session.fixture.mts`), so the redirect
29
+ * asserted is the one a browser would follow.
35
30
  */
36
31
 
37
32
  import type express from "express";
@@ -73,6 +68,7 @@ describe("a logout's post_logout_redirect_uri reaches the upstream only once it
73
68
  tokenType: "Bearer",
74
69
  scope: undefined,
75
70
  grantedScope: undefined,
71
+ obtainedAt: undefined,
76
72
  },
77
73
  });
78
74
  return session;
@@ -107,7 +103,7 @@ describe("a logout's post_logout_redirect_uri reaches the upstream only once it
107
103
  expect(res.status).toBe(303);
108
104
  expect(new URL(res.headers.location as string).origin).not.toBe("https://evil.example");
109
105
  // Google's own logout page: the adapter's answer when it is handed no
110
- // redirect target, which is what an unregistered one now is.
106
+ // redirect target, which is what an unregistered one becomes.
111
107
  expect(res.headers.location).toBe(`${GOOGLE_LOGOUT}?state=s-1`);
112
108
  // The logout itself still happened.
113
109
  expect(await userSessionStore.get(sid)).toBeNull();
@@ -108,11 +108,7 @@ describe("standaloneRedisClientsModule: the shared connection refused by the ser
108
108
  ).provides;
109
109
  try {
110
110
  await provides.refreshTokenFamilyClient({
111
- config: {
112
- refreshTokenFamilyStore: {
113
- redis: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
114
- },
115
- },
111
+ section: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
116
112
  lifecycleRegistrar,
117
113
  logger,
118
114
  });
@@ -15,83 +15,82 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #495 / #496 — end to end, through this template's real resolution chain.
19
- *
20
- * `app.mts` resolves `{env}.conf → application.conf → reference.conf` and
21
- * validates the result with `AppConfigSchema` before `buildModules` runs.
22
- * The schema strips what it does not declare, so a section declared by a
23
- * module rather than by core used to vanish at exactly that step — and,
24
- * because `redisRateLimiterModule.configSchema` carries a
25
- * `.default({ limit: 60, windowSeconds: 60 })`, the deployment that
26
- * `docker-compose.production.yml` ships (`RATE_LIMITER_ADAPTER: redis`) then
27
- * ran on 60 requests / 60 s no matter what the operator wrote.
28
- *
29
- * This starts from the shipped configuration, adds the two overrides an
30
- * operator writes in their own `application.conf` layer, and follows them all
31
- * the way to the limiter the module builds.
18
+ * Sections declared by a module rather than by core (`redis-rate-limiter`,
19
+ * `mtls`) survive this template's real resolution chain, end to end.
20
+ * Starting from the shipped configuration plus an operator's own layer, the
21
+ * overrides are followed through both configuration phases and `createApp`
22
+ * to the limiter the module builds. Were `redis-rate-limiter` lost, the
23
+ * module's default of 60 requests / 60 s would apply to the deployment
24
+ * `docker-compose.production.yml` ships (`ADAPTERS_RATE_LIMITER: redis`)
25
+ * whatever the operator wrote.
32
26
  */
33
27
 
28
+ import { mkdtempSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
34
31
  import { fileURLToPath } from "node:url";
35
- import { type AppConfig, AppConfigSchema, type RateLimiter } from "@o3co/auth-provider-core";
32
+ import {
33
+ type AppConfig,
34
+ createApp,
35
+ defineModule,
36
+ type RateLimiter,
37
+ } from "@o3co/auth-provider-core";
36
38
  import { redisRateLimiterModule } from "@o3co/auth-provider-redis";
37
- import { parseFile, parseString } from "@o3co/ts.hocon";
38
- import { validate } from "@o3co/ts.hocon/zod";
39
- import { describe, expect, it } from "vitest";
40
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
39
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
40
+ import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "../configPath.mjs";
41
+ import { loggingModule } from "../modules.mjs";
41
42
 
42
43
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
43
44
 
44
45
  /**
45
46
  * The secrets `application.conf` substitutes, plus the one
46
47
  * `docker-compose.production.yml` sets to put the OAuth endpoints behind a
47
- * shared limiter. Test-only values; #282's entropy floor applies to the two
48
+ * shared limiter. Test-only values; the entropy floor applies to the two
48
49
  * secrets, and the `.` characters keep them out of the base64 alphabet so the
49
50
  * UTF-8 length is what counts.
50
51
  */
51
52
  const ENV = {
52
- OAUTH_JWT_SECRET: "test-secret-rate-limiter-e2e.at-least-32-bytes.ok",
53
+ KEY_STORE_LOCAL_SECRET: "test-secret-rate-limiter-e2e.at-least-32-bytes.ok",
53
54
  OAUTH_JWT_ISSUER: "https://auth.test",
54
- SESSION_SECRET: "test-session-secret-rate-limiter-e2e.at-least-32-bytes.ok",
55
- RATE_LIMITER_ADAPTER: "redis",
55
+ SESSION_STORE_SECRET: "test-session-secret-rate-limiter-e2e.at-least-32-bytes.ok",
56
+ ADAPTERS_RATE_LIMITER: "redis",
57
+ MFA_MODE: "off",
56
58
  };
57
59
 
58
60
  /**
59
61
  * What an operator adds to their own `application.conf` layer: per-endpoint
60
62
  * budgets for the Redis limiter, and the mTLS posture from
61
63
  * `@o3co/auth-provider-mtls`. Neither section appears in anything this
62
- * template ships, which is why neither was ever covered by a `.conf` diff.
64
+ * template ships.
63
65
  */
64
66
  const OPERATOR_OVERRIDES = `
65
- redisRateLimiter {
67
+ redis-rate-limiter {
66
68
  limits {
67
69
  token { limit = 120, windowSeconds = 60 }
68
70
  authorize { limit = 30, windowSeconds = 60 }
69
71
  }
70
72
  }
71
73
 
72
- oauth.mtls {
74
+ mtls {
73
75
  enabled = true
74
76
  mode = "full-pki"
75
- trusted-cas = ["-----BEGIN CERTIFICATE-----"]
76
- full-pki {
77
+ trustedCas = ["-----BEGIN CERTIFICATE-----"]
78
+ fullPki {
77
79
  revocation {
78
80
  mode = "crl"
79
- on-unavailable = "reject"
80
- allowed-hosts = ["crl.example.com"]
81
+ onUnavailable = "reject"
82
+ allowedHosts = ["crl.example.com"]
81
83
  }
82
84
  }
83
85
  }
84
86
  `;
85
87
 
86
- function resolveShippedConfig(): AppConfig {
88
+ /** The composition's own files: the operator's layer over the shipped production ones. */
89
+ function ownFiles(): string[] {
90
+ const operator = join(mkdtempSync(join(tmpdir(), "redis-rate-limiter-")), "operator.conf");
91
+ writeFileSync(operator, OPERATOR_OVERRIDES);
87
92
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
88
- return validate(
89
- parseString(OPERATOR_OVERRIDES, { env: ENV })
90
- .withFallback(parseFile(envConfPath, { env: ENV }))
91
- .withFallback(parseFile(applicationConfPath, { env: ENV }))
92
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: ENV })),
93
- AppConfigSchema,
94
- );
93
+ return [operator, envConfPath, applicationConfPath];
95
94
  }
96
95
 
97
96
  /** Counts one request per key, the way an untouched Redis counter would. */
@@ -99,79 +98,111 @@ function makeCountingClient(): { incrementWithTtl: (key: string, ttl: number) =>
99
98
  return { incrementWithTtl: async () => 1 };
100
99
  }
101
100
 
102
- function buildLimiter(config: AppConfig): RateLimiter {
103
- const provides = (
104
- redisRateLimiterModule as unknown as {
105
- provides: Record<string, (deps: Record<string, unknown>) => RateLimiter>;
106
- }
107
- ).provides;
108
- const provider = provides.rateLimiter;
109
- if (!provider) throw new Error("redisRateLimiterModule no longer provides rateLimiter");
110
- return provider({ config, rateLimiterClient: makeCountingClient() });
101
+ /** The limiter a consumer of the slot is handed, as a route module would be. */
102
+ let consumed: RateLimiter | undefined;
103
+ const consumerModule = defineModule({
104
+ name: "test:rate-limiter-consumer",
105
+ requires: ["rateLimiter"] as const,
106
+ contributes: {
107
+ grantMiddleware: [
108
+ ({ rateLimiter }) => {
109
+ consumed = rateLimiter;
110
+ return null;
111
+ },
112
+ ],
113
+ },
114
+ });
115
+
116
+ /** The shipped configuration through both phases, booted with the Redis limiter's module. */
117
+ async function bootShipped() {
118
+ const own = readOwnLayers(ownFiles(), { env: ENV });
119
+ const switches = readSwitches(own);
120
+ const modules = [redisRateLimiterModule, consumerModule];
121
+ return {
122
+ switches,
123
+ handle: await createApp({
124
+ modules,
125
+ bootstrapComponents: {
126
+ // The template's own reference too, which its application.conf is layered over.
127
+ config: resolveForBoot(own, [...modules, loggingModule], switches),
128
+ pathResolver: (s: string) => s,
129
+ rateLimiterClient: makeCountingClient() as never,
130
+ },
131
+ }),
132
+ };
111
133
  }
112
134
 
113
- describe("the shipped configuration reaches the Redis rate limiter (#495)", () => {
114
- const config = resolveShippedConfig();
135
+ let booted: Awaited<ReturnType<typeof bootShipped>>;
136
+ let config: AppConfig;
137
+ let limiter: RateLimiter;
138
+
139
+ beforeAll(async () => {
140
+ booted = await bootShipped();
141
+ const parsed = booted.handle.components.config;
142
+ const built = consumed;
143
+ if (parsed === undefined || built === undefined) {
144
+ throw new Error("the boot filled no config, or handed no consumer the limiter");
145
+ }
146
+ config = parsed;
147
+ limiter = built;
148
+ });
115
149
 
116
- it("selects the Redis adapter the production compose file pins", () => {
117
- expect(config.rateLimiter?.adapter).toBe("redis");
150
+ afterAll(async () => {
151
+ await booted?.handle.dispose();
152
+ });
153
+
154
+ describe("the shipped configuration reaches the Redis rate limiter", () => {
155
+ it("selects the Redis adapter the production compose file pins, in phase one", () => {
156
+ expect(booted.switches.adapters.rateLimiter).toBe("redis");
118
157
  });
119
158
 
120
- it("carries the operator's per-endpoint budgets through the pre-parse", () => {
121
- expect(config.redisRateLimiter?.limits).toEqual({
159
+ it("carries the operator's per-endpoint budgets through boot's parse", () => {
160
+ expect(
161
+ (config as unknown as Record<string, { limits?: unknown }>)["redis-rate-limiter"]?.limits,
162
+ ).toEqual({
122
163
  token: { limit: 120, windowSeconds: 60 },
123
164
  authorize: { limit: 30, windowSeconds: 60 },
124
165
  });
125
166
  });
126
167
 
127
168
  it("applies the declared budget at /token", async () => {
128
- const decision = await buildLimiter(config).check("token:ip:203.0.113.5", {
129
- ip: "203.0.113.5",
130
- });
169
+ const decision = await limiter.check("token:ip:203.0.113.5", { ip: "203.0.113.5" });
131
170
  expect(decision.limit).toBe(120);
132
171
  });
133
172
 
134
173
  it("applies the declared budget at /authorize", async () => {
135
- const decision = await buildLimiter(config).check("authorize:ip:203.0.113.5", {
136
- ip: "203.0.113.5",
137
- });
174
+ const decision = await limiter.check("authorize:ip:203.0.113.5", { ip: "203.0.113.5" });
138
175
  expect(decision.limit).toBe(30);
139
176
  });
140
177
 
141
178
  it("still falls back to the module's default where nothing is declared", async () => {
142
- // The point of the assertion above: 60 is what EVERY endpoint got while
143
- // the section was stripped, so a test that only saw 60 could not tell a
144
- // working configuration from a lost one.
145
- const decision = await buildLimiter(config).check("introspect:ip:203.0.113.5", {
146
- ip: "203.0.113.5",
147
- });
179
+ // 60 is what every endpoint would get were the section stripped, so the
180
+ // budgets above differ from it to tell a working configuration from a
181
+ // lost one.
182
+ const decision = await limiter.check("introspect:ip:203.0.113.5", { ip: "203.0.113.5" });
148
183
  expect(decision.limit).toBe(60);
149
184
  });
150
185
 
151
- it("keeps seeding /session/login from rateLimit.login", async () => {
152
- // `resolveSeededLimitSpecs` reads a declared section, so this worked
153
- // even while `redisRateLimiter` was being stripped — which is why the
154
- // defect showed up only at the endpoints below it.
155
- const decision = await buildLimiter(config).check("login:ip:203.0.113.5", {
156
- ip: "203.0.113.5",
157
- });
158
- expect(decision.limit).toBe(config.rateLimit?.login.limit);
186
+ it("applies no login budget without the session module: session.rateLimit.login is that module's to contribute", async () => {
187
+ // This boot loads the limiter alone. With the session module, the
188
+ // limiter applies the budget it contributes from session.rateLimit.login.
189
+ expect(config.session).toMatchObject({ rateLimit: { login: { limit: 20 } } });
190
+ const decision = await limiter.check("login:ip:203.0.113.5", { ip: "203.0.113.5" });
191
+ expect(decision.limit).toBe(60);
159
192
  });
160
193
  });
161
194
 
162
- describe("the shipped configuration carries an mTLS posture through (#496)", () => {
163
- const config = resolveShippedConfig();
164
-
195
+ describe("the shipped configuration carries an mTLS posture through", () => {
165
196
  it("keeps the operator's mTLS block instead of reporting mTLS off", () => {
166
- expect(config.oauth.mtls).toEqual({
197
+ expect((config as unknown as { mtls?: unknown }).mtls).toEqual({
167
198
  enabled: true,
168
199
  mode: "full-pki",
169
- "trusted-cas": ["-----BEGIN CERTIFICATE-----"],
170
- "full-pki": {
200
+ trustedCas: ["-----BEGIN CERTIFICATE-----"],
201
+ fullPki: {
171
202
  revocation: {
172
203
  mode: "crl",
173
- "on-unavailable": "reject",
174
- "allowed-hosts": ["crl.example.com"],
204
+ onUnavailable: "reject",
205
+ allowedHosts: ["crl.example.com"],
175
206
  },
176
207
  },
177
208
  });
@@ -16,10 +16,10 @@
16
16
  import type { LifecycleRegistrar } from "@o3co/auth-provider-core";
17
17
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
18
 
19
- // D-2 v2: ioredis Redis constructor capture. The module under test imports
19
+ // ioredis Redis constructor capture. The module under test imports
20
20
  // `Redis` from "ioredis"; vi.mock replaces that import so the test never
21
21
  // opens a real socket. Captured arguments verify that the validated config
22
- // reaches the constructor (BLOCKER 1: schema strip closure).
22
+ // reaches the constructor.
23
23
  const redisCtorCalls: Array<{ url: string; options: Record<string, unknown> | undefined }> = [];
24
24
  const quitSpies: Array<ReturnType<typeof vi.fn>> = [];
25
25
  const onSpies: Array<ReturnType<typeof vi.fn>> = [];
@@ -60,17 +60,10 @@ const importModule = async () =>
60
60
  standaloneRedisClientsModule: import("@o3co/auth-provider-core").Module;
61
61
  };
62
62
 
63
- // D-1 / D-5 are already merged on develop, so importing `standaloneRedisClientsModule`
64
- // from `../modules.mjs` is the natural integration point. Pre-fix the export does not
65
- // exist — TS error → RED.
63
+ /** The module's own section, `redis-clients`, as boot hands it over. */
64
+ const baseSection = { url: "redis://example.com:6379", password: "test-pw" };
66
65
 
67
- const baseConfig = {
68
- refreshTokenFamilyStore: {
69
- redis: { url: "redis://example.com:6379", password: "test-pw" },
70
- },
71
- };
72
-
73
- describe("D-2 / standaloneRedisClientsModule", () => {
66
+ describe("standaloneRedisClientsModule", () => {
74
67
  beforeEach(() => {
75
68
  redisCtorCalls.length = 0;
76
69
  quitSpies.length = 0;
@@ -81,14 +74,14 @@ describe("D-2 / standaloneRedisClientsModule", () => {
81
74
  vi.restoreAllMocks();
82
75
  });
83
76
 
84
- it("passes operator-supplied Redis URL + password from config to the ioredis constructor (BLOCKER 1 closure)", async () => {
77
+ it("passes the operator's Redis URL and password from its section to the ioredis constructor", async () => {
85
78
  const { standaloneRedisClientsModule } = await importModule();
86
79
  const provides = (
87
80
  standaloneRedisClientsModule as unknown as {
88
81
  provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
89
82
  }
90
83
  ).provides;
91
- await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
84
+ await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
92
85
 
93
86
  expect(redisCtorCalls).toHaveLength(1);
94
87
  expect(redisCtorCalls[0]?.url).toBe("redis://example.com:6379");
@@ -109,7 +102,7 @@ describe("D-2 / standaloneRedisClientsModule", () => {
109
102
  provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
110
103
  }
111
104
  ).provides;
112
- await provides.refreshTokenFamilyClient({ config: { ...baseConfig }, lifecycleRegistrar });
105
+ await provides.refreshTokenFamilyClient({ section: { ...baseSection }, lifecycleRegistrar });
113
106
 
114
107
  expect(registered).toHaveLength(1);
115
108
  // Pre-drain: quit not yet called.
@@ -133,12 +126,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
133
126
  // after the factory resolves; the registrar branch is the only place
134
127
  // `quit()` would be wired up in the no-real-shutdown unit-test path).
135
128
  await expect(
136
- provides.refreshTokenFamilyClient({ config: { ...baseConfig } }),
129
+ provides.refreshTokenFamilyClient({ section: { ...baseSection } }),
137
130
  ).resolves.toBeDefined();
138
131
  expect(quitSpies[0]).not.toHaveBeenCalled();
139
132
  });
140
133
 
141
- it("fails fast when refreshTokenFamilyStore.redis.url is missing (no silent localhost fallback)", async () => {
134
+ it("fails fast on an empty redis-clients.url (no silent localhost fallback)", async () => {
142
135
  const { standaloneRedisClientsModule } = await importModule();
143
136
  const provides = (
144
137
  standaloneRedisClientsModule as unknown as {
@@ -146,11 +139,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
146
139
  }
147
140
  ).provides;
148
141
 
149
- // Operator deliberately removed the section — must throw instead of
150
- // silently falling back to redis://localhost:6379 (which would re-
151
- // introduce the OR-1 multi-replica failure mode in production).
152
- await expect(provides.refreshTokenFamilyClient({ config: {} })).rejects.toThrow(
153
- /refreshTokenFamilyStore\.redis\.url/,
142
+ // An operator who blanked the URL — must throw instead of silently
143
+ // falling back to redis://localhost:6379, which in production would
144
+ // leave each replica with refresh-token families the others cannot
145
+ // see. A section with no URL at all is refused by boot's parse of it.
146
+ await expect(provides.refreshTokenFamilyClient({ section: { url: "" } })).rejects.toThrow(
147
+ /redis-clients\.url/,
154
148
  );
155
149
  // And no ioredis instance was constructed because we threw before
156
150
  // `new Redis(...)`.
@@ -164,12 +158,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
164
158
  provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
165
159
  }
166
160
  ).provides;
167
- await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
161
+ await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
168
162
 
169
163
  expect(onSpies[0]).toHaveBeenCalledWith("error", expect.any(Function));
170
164
  });
171
165
 
172
- it("bounds every command and every connect attempt (#286)", async () => {
166
+ it("bounds every command and every connect attempt", async () => {
173
167
  // Without these the driver's defaults apply: no command timeout at all,
174
168
  // a 10s connect timeout, and 20 reconnect attempts before a queued
175
169
  // command is failed. A partition therefore parks in-flight `/token`
@@ -181,7 +175,7 @@ describe("D-2 / standaloneRedisClientsModule", () => {
181
175
  provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
182
176
  }
183
177
  ).provides;
184
- await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
178
+ await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
185
179
 
186
180
  const options = redisCtorCalls[0]?.options;
187
181
  expect(options?.commandTimeout).toBe(1_000);
@@ -193,7 +187,7 @@ describe("D-2 / standaloneRedisClientsModule", () => {
193
187
  expect(options?.lazyConnect).toBe(false);
194
188
  });
195
189
 
196
- it("keeps the offline queue ON, explicitly, because one socket serves every purpose (#286)", async () => {
190
+ it("keeps the offline queue ON, explicitly, because one socket serves every purpose", async () => {
197
191
  // `enableOfflineQueue` is a per-CONNECTION option and every adapter in
198
192
  // this template draws from the single socket built below, so the
199
193
  // "off for the rate limiter, on elsewhere" split is not expressible
@@ -207,18 +201,18 @@ describe("D-2 / standaloneRedisClientsModule", () => {
207
201
  provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
208
202
  }
209
203
  ).provides;
210
- await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
204
+ await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
211
205
 
212
206
  expect(redisCtorCalls[0]?.options?.enableOfflineQueue).toBe(true);
213
207
  });
214
208
 
215
- it("declares 'config' as required and both registrars as optional", async () => {
209
+ it("requires no configuration, and takes both registrars as optional", async () => {
216
210
  const { standaloneRedisClientsModule } = await importModule();
217
211
  const m = standaloneRedisClientsModule as {
218
212
  requires?: readonly string[];
219
213
  optional?: readonly string[];
220
214
  };
221
- expect(m.requires).toContain("config");
215
+ expect(m.requires ?? []).not.toContain("config");
222
216
  expect(m.optional).toContain("lifecycleRegistrar");
223
217
  expect(m.optional).toContain("readinessRegistrar");
224
218
  });
@@ -238,12 +232,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
238
232
  const lifecycleRegistrar: LifecycleRegistrar = { register: () => {} };
239
233
 
240
234
  await provides.refreshTokenFamilyClient({
241
- config: { ...baseConfig },
235
+ section: { ...baseSection },
242
236
  lifecycleRegistrar,
243
237
  readinessRegistrar,
244
238
  });
245
239
  await provides.rateLimiterClient({
246
- config: { ...baseConfig },
240
+ section: { ...baseSection },
247
241
  lifecycleRegistrar,
248
242
  readinessRegistrar,
249
243
  });