@o3co/create-auth-provider 0.5.1 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,6 +24,9 @@
24
24
  "dist",
25
25
  "templates"
26
26
  ],
27
+ "engines": {
28
+ "node": ">=18.19.0"
29
+ },
27
30
  "devDependencies": {
28
31
  "@types/node": "^25.1.0",
29
32
  "vitest": "^4.1.2"
@@ -2,6 +2,23 @@
2
2
 
3
3
  Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
4
4
 
5
+ ## Requirements
6
+
7
+ - **Node.js** `>=18.19.0`
8
+ - **`bcrypt` native binary**: this template depends transitively on `bcrypt@6.x`, which ships **prebuilt N-API binaries** for `darwin-arm64`, `darwin-x64`, `linux-x64` (glibc and musl), `linux-arm64` (glibc and musl), `linux-arm`, `win32-x64`, and `win32-arm64`. On these platforms `pnpm install` succeeds without compiling from source, and no extra toolchain is required (the `darwin-*`, `node:*-alpine`, and `node:*-bookworm` images all match a shipped prebuild). On platforms or libc/arch combinations that have no matching prebuild, `pnpm install` falls back to compiling, which then requires a C++ compiler and Python (the standard Node.js native-addon toolchain): Debian/Ubuntu `apt-get install build-essential python3`, Alpine `apk add make g++ python3`, macOS `xcode-select --install`. The pnpm 10 `onlyBuiltDependencies` allowlist for `bcrypt` is set in this template's `package.json` so a fresh `pnpm install` does not silently skip the install hook on those platforms.
9
+ - **Redis 7.2 LTS or later** for any Redis-backed adapter (refresh token family store, code repository, federation token store, session store). The `pExpireGT` flag pair on which several adapters depend was introduced in Redis 7.0+; 7.2 LTS is the tested floor. Tested against AWS ElastiCache for Redis 7.2, Upstash Redis, Redis Cloud 7.2, and self-managed `redis:7.2-alpine`.
10
+ - **ioredis** `^5.4.1` (direct runtime dependency, used by the refresh-token-family client). Other Redis-backed adapters in this template still use the `redis` npm package; ioredis is added for the RT family store specifically.
11
+
12
+ ## Multi-replica deployments
13
+
14
+ When running multiple instances of the standalone server behind a load balancer, point `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` at a **shared** Redis 7.2+ instance. Without a shared Redis URL, every replica holds refresh-token families in its own connection (and any local-only Redis), so a refresh request that lands on a replica that did not issue the token returns `invalid_grant` on every other request in a round-robin LB.
15
+
16
+ Other multi-replica considerations covered by the default modules:
17
+
18
+ - The session store is wired via `redisSessionStoresModule`; set the corresponding session-store Redis URL (per `session.storage.redis.url`).
19
+ - The code repository defaults to Redis when `repositories.code.type = "redis"`; set `CLIENT_CODE_ENDPOINT_URI` to a shared Redis instance.
20
+ - The federation token store can be backed by Redis via `federationTokenStore.type = "redis"` (deferred to operator config; default is in-memory).
21
+
5
22
  ## Usage
6
23
 
7
24
  ```bash
@@ -8,6 +8,16 @@ http {
8
8
  oauth {
9
9
  jwt {
10
10
  issuer = ${?OAUTH_JWT_ISSUER}
11
+ # SF-1 (v0.5.1): legacy typ acceptance for the central JWT verifier.
12
+ # When true (default), tokens whose `typ` header is absent are
13
+ # accepted with a `jwt_verify_legacy_typ` warning so v0.4.x tokens
14
+ # keep verifying through the v0.5.x transition window. Flip to false
15
+ # in v0.6+ to reject typ-less tokens. Active default per the v0.5.1
16
+ # ADR: literal default lives in HOCON (zod has no `.default()`).
17
+ # Operators set OAUTH_JWT_LEGACY_TYP_ACCEPT to override (set "false"
18
+ # to enforce strict mode early).
19
+ legacyTypAccept = true
20
+ legacyTypAccept = ${?OAUTH_JWT_LEGACY_TYP_ACCEPT}
11
21
  signingKey {
12
22
  provider = "local"
13
23
  provider = ${?OAUTH_JWT_SIGNING_KEY_PROVIDER}
@@ -21,7 +31,18 @@ oauth {
21
31
  privateKeyPath = ${?OAUTH_JWT_PRIVATE_KEY_PATH}
22
32
  publicKey = ${?OAUTH_JWT_PUBLIC_KEY}
23
33
  publicKeyPath = ${?OAUTH_JWT_PUBLIC_KEY_PATH}
24
- previousKeys = []
34
+ # IH-9: HS256 key rotation. To rotate without downtime, set the
35
+ # new secret + kid here (e.g. "v1") and move the old pair into
36
+ # `previousSecrets` with an `expiresAt` covering the access-token
37
+ # TTL plus a buffer. `getVerificationKey(kid)` resolves old
38
+ # tokens by kid until `expiresAt`. Operators on RS256/ES256/EdDSA
39
+ # use the asymmetric-shaped `previousKeys` instead — the
40
+ # discriminated-union schema rejects mixing the shapes.
41
+ # Example:
42
+ # previousSecrets = [
43
+ # { kid = "v0", secret = "<old>", expiresAt = "2026-06-05T00:00:00Z" }
44
+ # ]
45
+ previousSecrets = []
25
46
  }
26
47
  }
27
48
  }
@@ -32,6 +53,31 @@ oauth {
32
53
  refreshToken {
33
54
  expiresIn = 86400
34
55
  expiresIn = ${?OAUTH_REFRESH_TOKEN_EXPIRES_IN}
56
+ # CC-2 (v0.5.1): handling for refresh tokens whose family_id is not
57
+ # found in the rotation store. "reject" (default, inherited from core
58
+ # application.conf) is safe-by-default. Use "accept" only during a
59
+ # bounded migration window — e.g. moving from v0.4.x in-memory family
60
+ # store to Redis with old tokens still in circulation. Operators set
61
+ # OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY for env-var override.
62
+ # unknownFamilyPolicy = "reject"
63
+ # SF-6 (v0.5.1): handling for refresh tokens lacking jti or family_id
64
+ # claims when rotation is wired. "reject" (default) is safe. Use
65
+ # "accept-with-warning" only during a migration window where v0.4.x
66
+ # tokens (issued before claim standardization) are still in circulation;
67
+ # that mode skips replay detection for the request and emits an audit
68
+ # log. Pair with `unknownFamilyPolicy = "accept"` to avoid immediate
69
+ # rejection on the next refresh — see Phase F migration guide.
70
+ # legacyRtPolicy = "reject"
71
+ # AS-12 (v0.5.1): v0.5.x default accepts v0.4.x refresh tokens that use
72
+ # payload.type = "refresh" or claims.user.id as the subject. Set false
73
+ # only after all pre-v0.5 refresh tokens have expired; remaining legacy
74
+ # tokens will be rejected with invalid_grant. The strict marker-required
75
+ # gate is preserved across both flag values: a typ-less JWT with no
76
+ # payload.type is rejected at the refresh-grant gate even when
77
+ # oauth.jwt.legacyTypAccept = true (AT-as-RT confusion stays defended).
78
+ # Operators set OAUTH_REFRESH_TOKEN_LEGACY_TOKEN_COMPAT for env-var
79
+ # override.
80
+ # legacyTokenCompat = true
35
81
  }
36
82
  grants {
37
83
  session { enabled = true, enabled = ${?OAUTH_GRANTS_SESSION_ENABLED} }
@@ -45,6 +91,17 @@ oauth {
45
91
  }
46
92
  refresh_token { enabled = true, enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED} }
47
93
  }
94
+ # OR-9: adapter switch for the OAuth authorization-code repository. When
95
+ # set to "redis", the standalone composition root wires
96
+ # `redisCodeRepositoryModule` against the shared ioredis socket
97
+ # (`standaloneRedisClientsModule`). Memory-only deployments lose codes on
98
+ # restart and across replicas — multi-replica production MUST use "redis".
99
+ # Supersedes the legacy `repositories.code.type` switch (kept below for
100
+ # one release cycle for backward compat).
101
+ code {
102
+ adapter = "redis"
103
+ adapter = ${?OAUTH_CODE_ADAPTER}
104
+ }
48
105
  }
49
106
 
50
107
  session {
@@ -68,8 +125,21 @@ session {
68
125
  }
69
126
  }
70
127
 
128
+ # Rate limiting for SESSION routes (e.g. /session/login bruteforce protection).
129
+ # Uses windowMs (milliseconds) — consumed by express-rate-limit in Session.mts.
130
+ # NOTE (IH-18): this section does NOT govern OAuth endpoint rate limiting.
131
+ # For OAuth rate limiting (/token, /authorize), use the `rateLimiter`
132
+ # component (memoryRateLimiterModule or redisRateLimiterModule) with
133
+ # its own `memoryRateLimiter.*` / `redisRateLimiter.*` config section
134
+ # (uses `windowSeconds` instead of `windowMs`). Switch via
135
+ # `rateLimiter.adapter = "memory" | "redis"` (see below).
71
136
  rateLimit {
72
137
  login { windowMs = 900000, limit = 20 }
138
+ # OR-5: fail-mode for the OAuth-endpoint rate limiter when the limiter
139
+ # backend errors. "open" = allow + `logger.error`. "closed" = HTTP 503
140
+ # + `logger.error` (recommended for security-sensitive deployments).
141
+ failMode = "open"
142
+ failMode = ${?RATE_LIMIT_FAIL_MODE}
73
143
  }
74
144
 
75
145
  federations {
@@ -126,10 +196,78 @@ endpoints {
126
196
  url = "/login"
127
197
  url = ${?ENDPOINTS_LOGIN_URL}
128
198
  }
129
- client { url = ${?ENDPOINTS_CLIENT_URL} }
130
- authCallback { url = ${?ENDPOINTS_AUTH_CALLBACK_URL} }
199
+ # IH-10: `client { url }` and `authCallback { url }` removed — no
200
+ # production consumer reads them.
131
201
  }
132
202
 
133
203
  cors {
134
204
  allowedOrigins = []
135
205
  }
206
+
207
+ # Wave 5d (IH-14 + OR-M1 + OR-4): adapter switches for the OAuth-endpoint
208
+ # rate limiter and the user-session stores (4-store split: userSessionStore
209
+ # / sessionRPRegistry / sessionFamilyIndex / sessionFederationIndex).
210
+ #
211
+ # Multi-replica production MUST set BOTH to `"redis"`. The defaults are
212
+ # `"memory"` for backward-compat with single-instance deployments and CI;
213
+ # in-memory rate-limit counters / session state are isolated per replica
214
+ # and inconsistent under load-balanced traffic.
215
+ #
216
+ # When `userSessionStores.adapter = "redis"` or `rateLimiter.adapter = "redis"`,
217
+ # the standalone composition root opens a single shared ioredis connection
218
+ # (via `standaloneRedisClientsModule`) consuming `refreshTokenFamilyStore.redis.url`
219
+ # (D-2 v2). One ioredis socket per replica backs ALL Redis-typed clients.
220
+ rateLimiter {
221
+ adapter = "memory"
222
+ adapter = ${?RATE_LIMITER_ADAPTER}
223
+ }
224
+
225
+ userSessionStores {
226
+ adapter = "memory"
227
+ adapter = ${?USER_SESSION_STORES_ADAPTER}
228
+ }
229
+
230
+ # D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
231
+ # `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`.
232
+ # One ioredis socket per replica backs ALL Redis-typed clients (RT family
233
+ # + 4 user-session stores + rate limiter). Multi-replica deployments MUST
234
+ # point this at a shared Redis 7.2+ instance — without it, each replica
235
+ # stores RT families in its own ioredis connection against a local-only
236
+ # Redis, defeating the cross-replica persistence purpose. Module-internal
237
+ # config (keyPrefix / casRetryLimit) lives separately under
238
+ # `redisRefreshTokenFamilyStore.*` (consumed by the store module's schema).
239
+ redisRefreshTokenFamilyStore {
240
+ keyPrefix = "rtfam:"
241
+ keyPrefix = ${?REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX}
242
+ casRetryLimit = 3
243
+ casRetryLimit = ${?REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT}
244
+ }
245
+
246
+ # OR-9 (Wave 5d): module-internal config for `redisCodeRepositoryModule`.
247
+ # Consumed when `oauth.code.adapter = "redis"`. The shared ioredis socket
248
+ # (`standaloneRedisClientsModule`) provides the `codeRepositoryClient`
249
+ # slot; this section governs only key namespacing and the default
250
+ # expiry-in-seconds the repository uses when /authorize doesn't supply one.
251
+ #
252
+ # Multi-tenant deployments override `keyPrefix` to isolate tenants in the
253
+ # same Redis instance — e.g. `keyPrefix = "tenant-a:code:"` keeps tenant A's
254
+ # codes from colliding with tenant B's under the default `oauth:code:`
255
+ # prefix (MIN-3 advisory).
256
+ redisCodeRepository {
257
+ # No literal default for keyPrefix — the RedisCodeRepository constructor
258
+ # falls back to `oauth:code:` when this field is absent. Operators that
259
+ # need multi-tenant isolation set CLIENT_CODE_KEY_PREFIX (e.g.
260
+ # `tenant-a:code:`); leaving the env var unset keeps the constructor
261
+ # default intact via HOCON's `${?VAR}` "absent if unset" semantics.
262
+ keyPrefix = ${?CLIENT_CODE_KEY_PREFIX}
263
+ defaultExpiresIn = 600
264
+ defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN}
265
+ }
266
+
267
+ refreshTokenFamilyStore {
268
+ redis {
269
+ url = "redis://localhost:6379"
270
+ url = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_URL}
271
+ password = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD}
272
+ }
273
+ }
@@ -1,14 +1,30 @@
1
1
  # Example client definitions loaded via loadYamlMap into InMemoryClientRepository.
2
2
  # Copy this file to clients.yaml and customize for your deployment.
3
3
  #
4
- # Secret format:
4
+ # `tokenEndpointAuthMethod` is REQUIRED (RFC 6749 §2.3 / RFC 7591 §2):
5
+ # - "client_secret_basic": HTTP Basic Authorization header — recommended for confidential clients
6
+ # - "client_secret_post": form-encoded body parameters
7
+ # - "none": public client — no clientSecret; PKCE/S256 mandatory at /authorize
8
+ #
9
+ # Secret format (only for confidential clients — basic / post):
5
10
  # - Plain text: "my-secret"
6
11
  # - Bcrypt hash: "$2b$10$..." (generate with: node -e "require('bcrypt').hash('secret',10).then(console.log)")
7
12
 
8
13
  my-app:
14
+ tokenEndpointAuthMethod: "client_secret_basic"
9
15
  clientSecret: "$2b$10$REPLACE_WITH_BCRYPT_HASH"
10
16
  allowedRedirectUris:
11
17
  - "http://localhost:3000/callback"
12
18
  allowedScopes:
13
19
  - "read"
14
20
  - "write"
21
+
22
+ # Public client example (e.g., a single-page app or mobile app):
23
+ # my-spa:
24
+ # tokenEndpointAuthMethod: "none"
25
+ # # clientSecret MUST be absent for "none" clients.
26
+ # allowedRedirectUris:
27
+ # - "https://app.example/callback"
28
+ # allowedScopes:
29
+ # - "openid"
30
+ # - "profile"
@@ -8,7 +8,7 @@ services:
8
8
  condition: service_healthy
9
9
 
10
10
  redis:
11
- image: redis:7-alpine
11
+ image: redis:7.2-alpine
12
12
  healthcheck:
13
13
  test: ["CMD", "redis-cli", "ping"]
14
14
  interval: 5s
@@ -16,7 +16,7 @@ services:
16
16
  required: false
17
17
 
18
18
  redis:
19
- image: redis:7-alpine
19
+ image: redis:7.2-alpine
20
20
  ports:
21
21
  - "127.0.0.1:6379:6379"
22
22
  healthcheck:
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
+ "engines": {
7
+ "node": ">=18.19.0"
8
+ },
6
9
  "packageManager": "pnpm@10.29.3",
7
10
  "type": "module",
8
11
  "scripts": {
@@ -24,14 +27,15 @@
24
27
  "@o3co/auth-provider-session": "workspace:*",
25
28
  "@o3co/auth-provider-foundation": "workspace:*",
26
29
  "@o3co/auth-provider-redis": "workspace:*",
27
- "@o3co/auth.utils": "^0.0.3",
30
+ "@o3co/auth.utils": "^0.0.4",
28
31
  "@o3co/ts.hocon": "^1.1.0",
29
32
  "connect-redis": "^9.0.0",
30
33
  "express": "^5.2.1",
31
34
  "express-session": "^1.19.0",
32
35
  "helmet": "^8.1.0",
36
+ "ioredis": "^5.4.1",
33
37
  "pino": "^10.3.1",
34
- "redis": "^5.10.0"
38
+ "redis": "^5.12.1"
35
39
  },
36
40
  "devDependencies": {
37
41
  "@types/express": "^5.0.6",
@@ -42,5 +46,10 @@
42
46
  "tsx": "^4.21.0",
43
47
  "typescript": "^5.9.3",
44
48
  "vitest": "^4.1.2"
49
+ },
50
+ "pnpm": {
51
+ "onlyBuiltDependencies": [
52
+ "bcrypt"
53
+ ]
45
54
  }
46
55
  }
@@ -0,0 +1,180 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import type { LifecycleRegistrar } from "@o3co/auth-provider-core";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ // D-2 v2: ioredis Redis constructor capture. The module under test imports
20
+ // `Redis` from "ioredis"; vi.mock replaces that import so the test never
21
+ // opens a real socket. Captured arguments verify that the validated config
22
+ // reaches the constructor (BLOCKER 1: schema strip closure).
23
+ const redisCtorCalls: Array<{ url: string; options: Record<string, unknown> | undefined }> = [];
24
+ const quitSpies: Array<ReturnType<typeof vi.fn>> = [];
25
+ const onSpies: Array<ReturnType<typeof vi.fn>> = [];
26
+
27
+ vi.mock("ioredis", () => {
28
+ class MockRedis {
29
+ duplicate(): MockRedis {
30
+ return new MockRedis("redis://duplicate.local");
31
+ }
32
+ on = vi.fn();
33
+ quit = vi.fn(async () => "OK" as const);
34
+ // Minimal command surface — `makeIoredisClients` only invokes these
35
+ // during construction in some paths; tests never exercise the wrapped
36
+ // client itself, so most stubs are unused.
37
+ set = vi.fn();
38
+ get = vi.fn();
39
+ pttl = vi.fn();
40
+ watch = vi.fn();
41
+ unwatch = vi.fn();
42
+ multi = vi.fn(() => ({
43
+ set: vi.fn().mockReturnThis(),
44
+ exec: vi.fn(async () => []),
45
+ }));
46
+
47
+ constructor(url: string, options?: Record<string, unknown>) {
48
+ redisCtorCalls.push({ url, options });
49
+ onSpies.push(this.on);
50
+ quitSpies.push(this.quit);
51
+ }
52
+ }
53
+ return { Redis: MockRedis, default: MockRedis };
54
+ });
55
+
56
+ // Imported AFTER vi.mock so the mocked ioredis is in scope.
57
+ const importModule = async () =>
58
+ (await import("../modules.mjs")) as typeof import("../modules.mjs") & {
59
+ standaloneRedisClientsModule: import("@o3co/auth-provider-core").Module;
60
+ };
61
+
62
+ // D-1 / D-5 are already merged on develop, so importing `standaloneRedisClientsModule`
63
+ // from `../modules.mjs` is the natural integration point. Pre-fix the export does not
64
+ // exist — TS error → RED.
65
+
66
+ const baseConfig = {
67
+ refreshTokenFamilyStore: {
68
+ redis: { url: "redis://example.com:6379", password: "test-pw" },
69
+ },
70
+ };
71
+
72
+ describe("D-2 / standaloneRedisClientsModule", () => {
73
+ beforeEach(() => {
74
+ redisCtorCalls.length = 0;
75
+ quitSpies.length = 0;
76
+ onSpies.length = 0;
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ it("passes operator-supplied Redis URL + password from config to the ioredis constructor (BLOCKER 1 closure)", async () => {
84
+ const { standaloneRedisClientsModule } = await importModule();
85
+ const provides = (
86
+ standaloneRedisClientsModule as unknown as {
87
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
88
+ }
89
+ ).provides;
90
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
91
+
92
+ expect(redisCtorCalls).toHaveLength(1);
93
+ expect(redisCtorCalls[0]?.url).toBe("redis://example.com:6379");
94
+ expect(redisCtorCalls[0]?.options?.password).toBe("test-pw");
95
+ });
96
+
97
+ it("registers io.quit() with lifecycleRegistrar when provided; drain calls quit exactly once", async () => {
98
+ const registered: Array<() => Promise<void>> = [];
99
+ const lifecycleRegistrar: LifecycleRegistrar = {
100
+ register: (cleanup) => {
101
+ registered.push(cleanup);
102
+ },
103
+ };
104
+
105
+ const { standaloneRedisClientsModule } = await importModule();
106
+ const provides = (
107
+ standaloneRedisClientsModule as unknown as {
108
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
109
+ }
110
+ ).provides;
111
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig }, lifecycleRegistrar });
112
+
113
+ expect(registered).toHaveLength(1);
114
+ // Pre-drain: quit not yet called.
115
+ expect(quitSpies[0]).not.toHaveBeenCalled();
116
+
117
+ // Drain (simulate handle.dispose()).
118
+ await registered[0]?.();
119
+ expect(quitSpies[0]).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("does not crash when lifecycleRegistrar is absent (graceful no-op, no quit registered)", async () => {
123
+ const { standaloneRedisClientsModule } = await importModule();
124
+ const provides = (
125
+ standaloneRedisClientsModule as unknown as {
126
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
127
+ }
128
+ ).provides;
129
+
130
+ // No lifecycleRegistrar key in deps — must not throw and must NOT
131
+ // register a cleanup (verified by the absence of any quit invocation
132
+ // after the factory resolves; the registrar branch is the only place
133
+ // `quit()` would be wired up in the no-real-shutdown unit-test path).
134
+ await expect(
135
+ provides.refreshTokenFamilyClient({ config: { ...baseConfig } }),
136
+ ).resolves.toBeDefined();
137
+ expect(quitSpies[0]).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("fails fast when refreshTokenFamilyStore.redis.url is missing (no silent localhost fallback)", async () => {
141
+ const { standaloneRedisClientsModule } = await importModule();
142
+ const provides = (
143
+ standaloneRedisClientsModule as unknown as {
144
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
145
+ }
146
+ ).provides;
147
+
148
+ // Operator deliberately removed the section — must throw instead of
149
+ // silently falling back to redis://localhost:6379 (which would re-
150
+ // introduce the OR-1 multi-replica failure mode in production).
151
+ await expect(provides.refreshTokenFamilyClient({ config: {} })).rejects.toThrow(
152
+ /refreshTokenFamilyStore\.redis\.url/,
153
+ );
154
+ // And no ioredis instance was constructed because we threw before
155
+ // `new Redis(...)`.
156
+ expect(redisCtorCalls).toHaveLength(0);
157
+ });
158
+
159
+ it("attaches an error event handler to the ioredis client (prevents unhandled-error crash)", async () => {
160
+ const { standaloneRedisClientsModule } = await importModule();
161
+ const provides = (
162
+ standaloneRedisClientsModule as unknown as {
163
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
164
+ }
165
+ ).provides;
166
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
167
+
168
+ expect(onSpies[0]).toHaveBeenCalledWith("error", expect.any(Function));
169
+ });
170
+
171
+ it("declares 'config' as required and 'lifecycleRegistrar' as optional", async () => {
172
+ const { standaloneRedisClientsModule } = await importModule();
173
+ const m = standaloneRedisClientsModule as {
174
+ requires?: readonly string[];
175
+ optional?: readonly string[];
176
+ };
177
+ expect(m.requires).toContain("config");
178
+ expect(m.optional).toContain("lifecycleRegistrar");
179
+ });
180
+ });