@o3co/create-auth-provider 0.8.0 → 0.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.
Files changed (31) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +99 -6
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +43 -4
  6. package/templates/standalone/Dockerfile +51 -16
  7. package/templates/standalone/README.ja.md +79 -12
  8. package/templates/standalone/README.md +345 -17
  9. package/templates/standalone/config/application.conf +121 -4
  10. package/templates/standalone/config/clients.yaml.example +41 -0
  11. package/templates/standalone/docker-compose.production.yml +116 -0
  12. package/templates/standalone/package.json +14 -18
  13. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  14. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +419 -0
  15. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  16. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  17. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  18. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  19. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  20. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  21. package/templates/standalone/src/app.mts +67 -8
  22. package/templates/standalone/src/buildModules.mts +51 -1
  23. package/templates/standalone/src/configPath.mts +2 -2
  24. package/templates/standalone/src/logger.mts +90 -2
  25. package/templates/standalone/src/metrics.mts +190 -0
  26. package/templates/standalone/src/modules.mts +281 -33
  27. package/templates/standalone/src/terminalError.mts +60 -0
  28. package/templates/standalone/tests/index.test.js +69 -5
  29. package/templates/standalone/tsconfig.json +9 -1
  30. package/templates/standalone/vitest.config.mts +8 -0
  31. package/templates/versions.json +11 -11
@@ -0,0 +1,190 @@
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
+
17
+ import type { ReadinessProbe } from "@o3co/auth-provider-core";
18
+ import { runReadinessProbes } from "@o3co/auth-provider-core";
19
+ import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
20
+ import { collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client";
21
+
22
+ /**
23
+ * Route label for a request.
24
+ *
25
+ * Express only fills `req.route` once a handler has matched, and the label has
26
+ * to be **bounded**: labelling by `req.path` would mint a new time series per
27
+ * distinct URL, and this server's paths carry opaque values (`/oauth/authorize`
28
+ * query state, 404 probes from the internet). An unbounded label set is the
29
+ * classic way to take Prometheus down with the thing that was supposed to
30
+ * watch it. Unmatched requests collapse to a single `unmatched` bucket.
31
+ */
32
+ /**
33
+ * Method label, bounded to the methods this server can actually serve.
34
+ *
35
+ * `req.method` is attacker-controlled: Node's HTTP parser accepts any valid
36
+ * token, so `FOO` and `M000001` reach here as readily as `GET`. Each distinct
37
+ * value would mint a fresh histogram child carrying every bucket — the same
38
+ * unbounded-cardinality failure {@link routeLabel} guards against, one label
39
+ * over, and reachable without any access to `/metrics` itself.
40
+ */
41
+ const KNOWN_METHODS = new Set([
42
+ "GET",
43
+ "HEAD",
44
+ "POST",
45
+ "PUT",
46
+ "PATCH",
47
+ "DELETE",
48
+ "OPTIONS",
49
+ "TRACE",
50
+ "CONNECT",
51
+ ]);
52
+
53
+ function methodLabel(req: Request): string {
54
+ return KNOWN_METHODS.has(req.method) ? req.method : "other";
55
+ }
56
+
57
+ function routeLabel(req: Request): string {
58
+ const route = (req as Request & { route?: { path?: string } }).route?.path;
59
+ if (typeof route === "string" && route.length > 0) {
60
+ return req.baseUrl ? `${req.baseUrl}${route === "/" ? "" : route}` : route;
61
+ }
62
+ return "unmatched";
63
+ }
64
+
65
+ export interface MetricsRouteOptions {
66
+ /**
67
+ * Readiness probes, sampled on each scrape to publish a per-dependency
68
+ * up/down gauge. Pass `handle.readinessProbes`.
69
+ */
70
+ readonly probes: readonly ReadinessProbe[];
71
+ /** Per-probe deadline, in milliseconds. Use `config.http.readinessTimeoutMs`. */
72
+ readonly probeTimeoutMs: number;
73
+ /** Route path. Defaults to `/metrics`. */
74
+ readonly path?: string;
75
+ }
76
+
77
+ export interface Metrics {
78
+ /**
79
+ * Mount FIRST, ahead of every route: it times the whole downstream stack,
80
+ * and it records on the response's `finish` event so a request that ends in
81
+ * an error handler is still counted.
82
+ */
83
+ readonly middleware: RequestHandler;
84
+ /**
85
+ * Mounts the scrape endpoint.
86
+ *
87
+ * The probes arrive here rather than at construction because the two halves
88
+ * are needed at different moments: the middleware has to be mounted before
89
+ * the app boots, and the probes only exist afterwards, on the `AppHandle`.
90
+ */
91
+ readonly route: (express: { Router: () => Router }, options: MetricsRouteOptions) => Router;
92
+ }
93
+
94
+ /**
95
+ * Prometheus metrics for the standalone provider.
96
+ *
97
+ * What this publishes is chosen from what an operator cannot otherwise see.
98
+ * Before it, a degraded provider offered stderr lines and the load balancer's
99
+ * 5xx graph — no way to distinguish "rate limiter is failing closed" from
100
+ * "Redis-backed code repository is gone" without grepping container logs, and
101
+ * no latency series to alert on at all.
102
+ *
103
+ * - `http_request_duration_seconds` — histogram by method, bounded route, and
104
+ * status. Carries request rate, error rate and latency in one series, which
105
+ * is the whole RED method.
106
+ * - `auth_dependency_up` — 1/0 per readiness probe, sampled at scrape time.
107
+ * This is the series that separates "Redis is gone" from "the app is slow",
108
+ * and it reuses the probes the builders already registered rather than
109
+ * inventing a second notion of what this deployment depends on.
110
+ * - Node process defaults (event-loop lag, heap, GC, handles) under the
111
+ * `auth_provider_` prefix.
112
+ *
113
+ * Deliberately not here yet: rate-limiter fail-closed counts and audit-sink
114
+ * drops. Both happen inside `@o3co/auth-provider-core` and neither is
115
+ * observable from the composition root today; counting them needs a metrics
116
+ * hook in core (`BuilderContext` already reserves `metrics`) rather than
117
+ * something this file can reach.
118
+ */
119
+ export function createMetrics(): Metrics {
120
+ const registry = new Registry();
121
+ collectDefaultMetrics({ register: registry, prefix: "auth_provider_" });
122
+
123
+ const requestDuration = new Histogram({
124
+ name: "http_request_duration_seconds",
125
+ help: "HTTP request latency in seconds, by method, route and status.",
126
+ labelNames: ["method", "route", "status"] as const,
127
+ // Tuned for an IdP: token and authorize calls sit in the low tens of
128
+ // milliseconds when healthy, and the tail is what an outage moves.
129
+ buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
130
+ registers: [registry],
131
+ });
132
+
133
+ const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
134
+ const endTimer = requestDuration.startTimer();
135
+ let observed = false;
136
+ // Both terminal events, with a guard, rather than `finish` alone.
137
+ // `finish` covers every response the server completes — including ones
138
+ // produced by an error handler, and `req.route` is populated by then. But
139
+ // a client or proxy that disconnects mid-handler emits `close` WITHOUT
140
+ // `finish`, and those are disproportionately the slow and failing requests
141
+ // that RED metrics exist to surface; counting only `finish` would drop
142
+ // them from both the rate and the latency series.
143
+ const observe = () => {
144
+ if (observed) return;
145
+ observed = true;
146
+ endTimer({
147
+ method: methodLabel(req),
148
+ route: routeLabel(req),
149
+ status: String(res.statusCode),
150
+ });
151
+ };
152
+ res.once("finish", observe);
153
+ res.once("close", observe);
154
+ next();
155
+ };
156
+
157
+ const dependencyUp = new Gauge({
158
+ name: "auth_dependency_up",
159
+ help: "1 when a backing dependency answered its readiness probe, 0 otherwise.",
160
+ labelNames: ["dependency"] as const,
161
+ registers: [registry],
162
+ });
163
+
164
+ // Shared across scrapes so a partitioned backend does not accumulate one
165
+ // in-flight command per scrape (see `runReadinessProbes`).
166
+ const inFlight = new Map<string, Promise<unknown>>();
167
+
168
+ const route = (express: { Router: () => Router }, opts: MetricsRouteOptions): Router => {
169
+ const router = express.Router();
170
+ router.get(opts.path ?? "/metrics", async (_req: Request, res: Response) => {
171
+ // Sampled per scrape rather than held as state: a cached value would
172
+ // report a dependency healthy for as long as the cache lives, which is
173
+ // exactly the window an incident lives in.
174
+ const report = await runReadinessProbes(opts.probes, {
175
+ timeoutMs: opts.probeTimeoutMs,
176
+ inFlight,
177
+ });
178
+ for (const check of report.checks) {
179
+ dependencyUp.set({ dependency: check.name }, check.ok ? 1 : 0);
180
+ }
181
+
182
+ res.setHeader("Content-Type", registry.contentType);
183
+ res.setHeader("Cache-Control", "no-store");
184
+ res.send(await registry.metrics());
185
+ });
186
+ return router;
187
+ };
188
+
189
+ return { middleware, route };
190
+ }
@@ -16,6 +16,8 @@
16
16
  import path from "node:path";
17
17
  import {
18
18
  type AppConfig,
19
+ consoleLogger,
20
+ createAuditSinkFactory,
19
21
  createFederationTokenStoreFactory,
20
22
  createInMemorySessionFamilyIndex,
21
23
  createInMemorySessionFederationIndex,
@@ -25,7 +27,10 @@ import {
25
27
  createRepositoryFactories,
26
28
  defineModule,
27
29
  type LifecycleRegistrar,
30
+ type Logger,
28
31
  type Module,
32
+ type ReadinessRegistrar,
33
+ registerBuiltinAuditSinks,
29
34
  registerBuiltinFederationTokenStores,
30
35
  registerBuiltinKeyStores,
31
36
  } from "@o3co/auth-provider-core";
@@ -34,16 +39,20 @@ import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
34
39
  import { redisFederationTokenStoreBuilder } from "@o3co/auth-provider-redis";
35
40
  import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
36
41
  import { extractFederationSection } from "@o3co/auth-provider-session";
37
- // Named import is required here, not default. Under `module: "nodenext"`
38
- // with esModuleInterop, the default import resolves to the entire ioredis
39
- // CJS module-exports namespace object (because ioredis 5.x ships
40
- // `export = Redis`), so `new Redis(...)` raises TS2351 "not constructable".
42
+ // Named import is required here, not default. ioredis is CJS and its entry
43
+ // does `module.exports = Redis` with the class re-exported as both `default`
44
+ // and `Redis`; under `module: "nodenext"` with esModuleInterop the default
45
+ // import therefore resolves to the module-exports namespace object rather
46
+ // than the class, and `new Redis(...)` raises TS2351 "not constructable".
47
+ // Still true on ioredis 6 — verified against 6.0.0, not inherited from the
48
+ // ioredis 5 era this note was first written in.
41
49
  // The 11 default-import sites elsewhere in the repo all live under
42
50
  // `packages/redis/__tests__/` which `packages/redis/tsconfig.json`
43
51
  // explicitly excludes from strict tsc build (vitest's vue-tsc is more
44
52
  // permissive about CJS interop). Standalone production source is built
45
53
  // with strict nodenext, so the named import is the right shape here.
46
54
  import { Redis } from "ioredis";
55
+ import { createAuditLogger, createLoggerAuditSink } from "./logger.mjs";
47
56
 
48
57
  /**
49
58
  * Helper: turn a v0.4.x { type, [type]: {...} } adapter-config slice into the
@@ -218,6 +227,66 @@ export const federationTokenStoreModule: Module = defineModule({
218
227
  },
219
228
  });
220
229
 
230
+ /**
231
+ * Audit-sink module — fills the `auditSink` slot every route that emits a
232
+ * security event reads (#287).
233
+ *
234
+ * The slot is `optional` on `oauthModule`, `sessionModule` and `webauthnModule`,
235
+ * and `emitAuditEvent` is a no-op when it is empty. That combination is why the
236
+ * gap was invisible: the scaffold wired no sink, so `token.issued.failure`,
237
+ * `authorize.rejected` / `authorize.granted`, `logout.cascade_failed` and the
238
+ * shared rate-limit guard's `rate_limit.unavailable` were all discarded by the
239
+ * deployable artifact, with nothing failing and nothing warning. This module is
240
+ * always in the manifest for that reason — there is no "no audit sink" branch,
241
+ * the way there is a memory/redis branch for the state stores.
242
+ *
243
+ * Two sink kinds are registered:
244
+ *
245
+ * - `"logger"` (the template's default) — one NDJSON line per event on stdout,
246
+ * in the same pino envelope as every other line this template emits, on a
247
+ * stream named `audit` whose level is fixed. See `createAuditLogger`.
248
+ * - `"console"` — core's built-in, from `registerBuiltinAuditSinks`: the bare
249
+ * event as one JSON object per line on stdout, with no log envelope. For a
250
+ * pipeline that wants the event and nothing else.
251
+ *
252
+ * A deployment with a real sink (SIEM, log pipeline, message bus) registers its
253
+ * builder here and names it in `audit.sink.type`; its options ride along in the
254
+ * same config block (`flattenAdapterConfig` unwraps `sink { type = "x", x { … } }`).
255
+ *
256
+ * One thing to know before writing such a builder: `createAuditSinkFactory()`
257
+ * takes no `BuilderContext`, so a sink builder receives `{}` and the
258
+ * `ctx.lifecycle?.register(…)` / `ctx.readiness?.register(…)` calls that
259
+ * CONTRIBUTING.md requires of a builder opening a connection are silent no-ops
260
+ * here. A sink holding a socket must own its cleanup another way until that
261
+ * factory takes a context.
262
+ *
263
+ * There is deliberately no `"none"`. An unknown type — including the `"none"`
264
+ * an operator might reasonably guess at — fails boot naming the sinks that
265
+ * exist, rather than producing a deployment with no audit trail (#304).
266
+ */
267
+ export const auditSinkModule: Module = defineModule({
268
+ name: "standalone:audit-sink",
269
+ requires: ["config"] as const,
270
+ provides: {
271
+ auditSink: async ({ config }) => {
272
+ const factory = createAuditSinkFactory();
273
+ registerBuiltinAuditSinks(factory);
274
+ factory.register("logger", () => createLoggerAuditSink(createAuditLogger()));
275
+ const slice = (config as AppConfig).audit?.sink;
276
+ // Absence lands on the default sink rather than on `undefined`: a
277
+ // config that says nothing about auditing is not a config asking for
278
+ // the events to be dropped. The literal default lives in HOCON
279
+ // (`reference.conf` / `application.conf`); this is the floor under a
280
+ // hand-built config that never met either.
281
+ return factory.create(
282
+ slice
283
+ ? flattenAdapterConfig(slice as { type: string } & Record<string, unknown>)
284
+ : { type: "logger" },
285
+ );
286
+ },
287
+ },
288
+ });
289
+
221
290
  /**
222
291
  * @deprecated since Wave 5d (F4 PR2): split into `inMemorySessionStoresModule`
223
292
  * + `federationTokenStoreModule`. Re-exported for backward compatibility
@@ -235,10 +304,10 @@ export const storesModule: Module = defineModule({
235
304
 
236
305
  /**
237
306
  * Shared ioredis clients module — opens ONE long-lived ioredis connection
238
- * per replica, wraps it via `makeIoredisClients()` (returns 9 typed
307
+ * per replica, wraps it via `makeIoredisClients()` (returns 11 typed
239
308
  * per-purpose clients), and exposes the slots consumed by the standalone's
240
309
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
241
- * rate limiter).
310
+ * rate limiter + code repository + access-token denylist).
242
311
  *
243
312
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
244
313
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -273,26 +342,46 @@ export const storesModule: Module = defineModule({
273
342
  export const standaloneRedisClientsModule: Module = defineModule({
274
343
  name: "standalone:redis-clients",
275
344
  requires: ["config"] as const,
276
- optional: ["lifecycleRegistrar"] as const,
345
+ optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
277
346
  provides: {
278
- refreshTokenFamilyClient: async ({ config, lifecycleRegistrar }) => {
279
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).refreshTokenFamilyClient;
347
+ refreshTokenFamilyClient: async ({
348
+ config,
349
+ lifecycleRegistrar,
350
+ readinessRegistrar,
351
+ logger,
352
+ }) => {
353
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
354
+ .refreshTokenFamilyClient;
280
355
  },
281
- userSessionStoreClient: async ({ config, lifecycleRegistrar }) => {
282
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).userSessionStoreClient;
356
+ userSessionStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
357
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
358
+ .userSessionStoreClient;
283
359
  },
284
- sessionRPRegistryClient: async ({ config, lifecycleRegistrar }) => {
285
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).sessionRPRegistryClient;
360
+ sessionRPRegistryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
361
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
362
+ .sessionRPRegistryClient;
286
363
  },
287
- sessionFamilyIndexClient: async ({ config, lifecycleRegistrar }) => {
288
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).sessionFamilyIndexClient;
364
+ sessionFamilyIndexClient: async ({
365
+ config,
366
+ lifecycleRegistrar,
367
+ readinessRegistrar,
368
+ logger,
369
+ }) => {
370
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
371
+ .sessionFamilyIndexClient;
289
372
  },
290
- sessionFederationIndexClient: async ({ config, lifecycleRegistrar }) => {
291
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar)
373
+ sessionFederationIndexClient: async ({
374
+ config,
375
+ lifecycleRegistrar,
376
+ readinessRegistrar,
377
+ logger,
378
+ }) => {
379
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
292
380
  .sessionFederationIndexClient;
293
381
  },
294
- rateLimiterClient: async ({ config, lifecycleRegistrar }) => {
295
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).rateLimiterClient;
382
+ rateLimiterClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
383
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
384
+ .rateLimiterClient;
296
385
  },
297
386
  // OR-9: code-repository client wired off the same shared ioredis
298
387
  // socket. `redisCodeRepositoryModule` consumes this slot when
@@ -300,12 +389,105 @@ export const standaloneRedisClientsModule: Module = defineModule({
300
389
  // high-volume — sharing the connection avoids opening a separate
301
390
  // socket while the per-purpose typed wrapper keeps the Redis
302
391
  // command surface consumed by RedisCodeRepository explicit.
303
- codeRepositoryClient: async ({ config, lifecycleRegistrar }) => {
304
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).codeRepositoryClient;
392
+ codeRepositoryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
393
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
394
+ .codeRepositoryClient;
395
+ },
396
+ // #277: access-token denylist client, off the same shared socket. A
397
+ // denylist is only worth having if every replica reads the same one —
398
+ // that is the entire reason `redisAccessTokenDenylistModule` exists — so
399
+ // it belongs on the connection the rest of the shared state already uses
400
+ // rather than opening a second one.
401
+ accessTokenDenylistClient: async ({
402
+ config,
403
+ lifecycleRegistrar,
404
+ readinessRegistrar,
405
+ logger,
406
+ }) => {
407
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
408
+ .accessTokenDenylistClient;
305
409
  },
306
410
  },
307
411
  });
308
412
 
413
+ /**
414
+ * Failure-timing options for the one shared ioredis socket (#286).
415
+ *
416
+ * On the driver's defaults this connection had no `commandTimeout` at all, so
417
+ * a partition did not produce errors — it produced *waiting*. Every `/token`
418
+ * request that touched Redis parked in the offline queue while the socket
419
+ * reconnected, and because nothing threw, the `rateLimit.failMode = "closed"`
420
+ * policy (`createRateLimitGuard`, OR-5) never engaged: it only fires when
421
+ * `limiter.check()` rejects. The load the policy exists to shed kept arriving
422
+ * and kept accumulating, and memory and sockets grew until ioredis's 20th
423
+ * reconnect attempt finally flushed the queue — tens of seconds later.
424
+ *
425
+ * The values below are chosen against the actual command surface of this
426
+ * socket, which is entirely O(1) primitives and small paged scans
427
+ * (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/`SSCAN`/`ZADD`/…).
428
+ * There is no blocking command here, so no legitimate command has any reason
429
+ * to take a meaningful fraction of a second.
430
+ *
431
+ * - `commandTimeout: 1000` — the hard ceiling on any single command. ioredis
432
+ * arms this timer in `sendCommand`, *before* the writability check, so it
433
+ * bounds a command that went into the offline queue exactly as it bounds one
434
+ * already on the wire. That is what makes the queued case fail rather than
435
+ * hang, and it is also the only guard that survives the nastiest partition
436
+ * shape: a zombie TCP connection where no `close` event ever fires, so the
437
+ * reconnect path described next is never entered at all.
438
+ * - `maxRetriesPerRequest: 3` — on the fourth reconnect attempt (roughly
439
+ * 0.4–1 s in on ioredis 6's jittered exponential backoff) the whole queue is
440
+ * failed in one go with `MaxRetriesPerRequestError`, rather than on the
441
+ * twentieth as the default has it. `commandTimeout` alone bounds each command's
442
+ * *latency*, but the queue would still grow to (request rate × 1 s) entries
443
+ * for as long as the outage lasted; this bounds its *depth*. Three rather
444
+ * than one so an ordinary sub-second reconnect blip is ridden out silently.
445
+ * - `connectTimeout: 5000` — halves the driver's 10 s default. A dropped SYN
446
+ * (a partition that black-holes packets rather than refusing them) is the
447
+ * only case that reaches this ceiling; a real connect on any deployment
448
+ * topology this template targets completes in well under a second.
449
+ * - `enableOfflineQueue: true` — the driver default, declared explicitly
450
+ * because it is a decision, not an inherited value. See below.
451
+ * - `lazyConnect: false` — also the driver default (still, on ioredis 6),
452
+ * declared so the boot-time-connect contract survives a future flip.
453
+ *
454
+ * **Why the offline queue stays on.** Turning it off is the sharper answer for
455
+ * the rate limiter specifically: a command issued while the socket is down
456
+ * rejects immediately instead of after `commandTimeout`, so the fail-closed
457
+ * policy sheds load at the first request rather than one second per request
458
+ * later. But `enableOfflineQueue` is a per-**connection** option, and this
459
+ * template runs every purpose off one socket — refresh-token families, the
460
+ * four user-session stores, the authorization-code repository, the
461
+ * access-token denylist and the rate limiter all come out of a single
462
+ * `makeIoredisClients(io)` call (pinned by "one connection in, one connection
463
+ * used" in `packages/redis/__tests__/ioredis.test.mts`). There is no way to
464
+ * express "off here, on there" without opening a second socket, which is the
465
+ * connection-pool pressure the shared-socket design was built to avoid.
466
+ *
467
+ * So the choice is one setting for all eleven purposes, and `false` is the
468
+ * wrong one to pick for all eleven: it converts every reconnect — including
469
+ * the sub-second blip of a routine managed-Redis failover, during which
470
+ * ioredis would have recovered transparently — into hard errors on session
471
+ * lookup, code redemption and refresh rotation. A failed refresh rotation is
472
+ * not a retryable blip to the end user; it is a re-login.
473
+ *
474
+ * `true` costs the rate limiter up to `commandTimeout` of delay before it
475
+ * starts shedding, and that is the honest trade. It is affordable precisely
476
+ * because `commandTimeout` exists: the pile-up is now bounded by
477
+ * (request rate × 1 s) and self-draining, rather than unbounded and growing.
478
+ * A deployment that wants the sharper behavior gives the rate limiter its own
479
+ * connection in its own composition root — the per-purpose client interfaces
480
+ * exist for exactly that, and it is a deliberate second socket rather than a
481
+ * silent global.
482
+ */
483
+ const SHARED_REDIS_TIMEOUTS = {
484
+ commandTimeout: 1_000,
485
+ connectTimeout: 5_000,
486
+ maxRetriesPerRequest: 3,
487
+ enableOfflineQueue: true,
488
+ lazyConnect: false,
489
+ } as const;
490
+
309
491
  // Module-scoped cache so each `provides.*` factory invocation reuses the
310
492
  // same `Redis` instance (and its `makeIoredisClients()` derivation) within
311
493
  // a single createApp() invocation. The boot planner calls each `provides`
@@ -324,7 +506,10 @@ const clientsCache = new WeakMap<LifecycleRegistrar, ReturnType<typeof makeIored
324
506
  function getOrCreateClients(
325
507
  config: AppConfig,
326
508
  lifecycleRegistrar: LifecycleRegistrar | undefined,
509
+ readinessRegistrar?: ReadinessRegistrar,
510
+ injectedLogger?: Logger,
327
511
  ): ReturnType<typeof makeIoredisClients> {
512
+ const logger = injectedLogger ?? consoleLogger;
328
513
  const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
329
514
  if (cached) return cached;
330
515
 
@@ -339,29 +524,57 @@ function getOrCreateClients(
339
524
  }
340
525
  const password = typeof cfg.password === "string" ? cfg.password : undefined;
341
526
 
342
- // `lazyConnect: false` is the ioredis 5.x default; the explicit option
343
- // documents the boot-time-connect contract so a future ioredis version
344
- // flip does not silently change the failure mode.
345
- const io = new Redis(cfg.url, { password, lazyConnect: false });
527
+ const io = new Redis(cfg.url, { password, ...SHARED_REDIS_TIMEOUTS });
346
528
 
347
- // Attach error handler so unhandled "error" events from ioredis 5.x do
348
- // not crash the Node.js process. Initial connection failures surface
349
- // here; downstream adapter operations then fail visibly. TODO(D-4):
350
- // swap `console.error` for the Logger interface once a v0.5.2 polish
351
- // PR lands.
529
+ // Attach an error handler so unhandled "error" events do not crash the
530
+ // process. Initial connection failures surface here; downstream adapter
531
+ // operations then fail visibly.
352
532
  io.on("error", (err: unknown) => {
353
- console.error("[standalone:redis-clients] ioredis error", err);
533
+ logger.error({ err }, "standalone_redis_clients_error");
354
534
  });
355
535
 
356
536
  lifecycleRegistrar?.register(async () => {
357
537
  await io.quit();
358
538
  });
359
539
 
360
- const clients = makeIoredisClients(io);
540
+ // One probe for the one socket. Registering per consumed slot would report
541
+ // the same connection six times; the cache above means this runs once per
542
+ // boot, on the call that actually constructs the client.
543
+ readinessRegistrar?.register({
544
+ name: "redis",
545
+ check: () => io.ping(),
546
+ });
547
+
548
+ // The wrapper opens its own connections for refresh rotation
549
+ // (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
550
+ // `io`; passing the logger lets those report through the same channel.
551
+ const clients = makeIoredisClients(io, { logger });
361
552
  if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
362
553
  return clients;
363
554
  }
364
555
 
556
+ /**
557
+ * Reads an optional string field off a federation slice.
558
+ *
559
+ * A field the operator did not set stays absent rather than becoming
560
+ * `undefined`, so `"sessionDomain" in config` still distinguishes the two. A
561
+ * field that is present but not a string throws: HOCON hands through whatever
562
+ * shape the file holds, and silently ignoring `sessionDomain = 42` would leave
563
+ * the redirect policy running with one fewer constraint than the operator
564
+ * wrote down.
565
+ */
566
+ function optionalString(
567
+ slice: Record<string, unknown>,
568
+ field: string,
569
+ ): Record<string, string> | Record<string, never> {
570
+ const value = slice[field];
571
+ if (value === undefined || value === null) return {};
572
+ if (typeof value !== "string") {
573
+ throw new Error(`federations.google.${field} must be a string when present`);
574
+ }
575
+ return { [field]: value };
576
+ }
577
+
365
578
  /**
366
579
  * Google federation config bridge — supplies the typed `googleFederationConfig`
367
580
  * ComponentMap slot from the `config.federations.google` slice.
@@ -369,6 +582,15 @@ function getOrCreateClients(
369
582
  * Per `@o3co/auth-provider-federation-google` README. Per-federation modules
370
583
  * (Phase 7 A5) consume this slot; the bridge is the standalone composition
371
584
  * root's responsibility because the slot's content is consumer-specific.
585
+ *
586
+ * #278: this used to return `{ clientId, clientSecret, callbackURL }` and stop
587
+ * there, dropping `redirectAllowlist` / `sessionDomain` / `authCallbackUrl` /
588
+ * `clientUrl` on the floor. Nothing complained — `googleFederationModule` hands
589
+ * this same object to `createFederationRedirectPolicy`, so the redirect policy
590
+ * was built from a config with none of the fields it reads, and a deployment
591
+ * that had configured them ran as though it had not. The generated app is the
592
+ * shape most operators start from, so the safe path has to be the one that
593
+ * works by filling in the config file.
372
594
  */
373
595
  export const googleFederationConfigModule: Module = defineModule({
374
596
  name: "standalone:google-federation-config",
@@ -393,7 +615,33 @@ export const googleFederationConfigModule: Module = defineModule({
393
615
  "federations.google requires clientId, clientSecret, callbackURL when enabled",
394
616
  );
395
617
  }
396
- return { clientId, clientSecret, callbackURL };
618
+
619
+ // The allowlist is checked for shape here and for content by
620
+ // `createFederationRedirectPolicy`, which owns the URL rules. This
621
+ // only has to establish that HOCON produced a list of strings —
622
+ // `redirectAllowlist = "https://…"` (a bare string, the natural typo)
623
+ // would otherwise reach the policy as a config it cannot read.
624
+ const rawAllowlist = slice.redirectAllowlist;
625
+ let redirectAllowlist: Record<string, readonly string[]> | Record<string, never> = {};
626
+ if (rawAllowlist !== undefined && rawAllowlist !== null) {
627
+ if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
628
+ throw new Error(
629
+ "federations.google.redirectAllowlist must be a list of URL strings, " +
630
+ 'e.g. ["https://app.example.com/welcome"]',
631
+ );
632
+ }
633
+ redirectAllowlist = { redirectAllowlist: rawAllowlist as readonly string[] };
634
+ }
635
+
636
+ return {
637
+ clientId,
638
+ clientSecret,
639
+ callbackURL,
640
+ ...redirectAllowlist,
641
+ ...optionalString(slice, "sessionDomain"),
642
+ ...optionalString(slice, "authCallbackUrl"),
643
+ ...optionalString(slice, "clientUrl"),
644
+ };
397
645
  },
398
646
  },
399
647
  });
@@ -0,0 +1,60 @@
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 { errorEnvelope, type Logger } from "@o3co/auth-provider-core";
17
+ import type { ErrorRequestHandler } from "express";
18
+
19
+ /**
20
+ * Terminal Express error handler (#293 item 8).
21
+ *
22
+ * Anything a route threw past its own try/catch — and every body-parser
23
+ * rejection, which fires before any route runs — used to fall through to
24
+ * Express's default handler: an HTML 500 outside the structured-log pipeline,
25
+ * carrying a stack trace outside production. A client that parses only the
26
+ * RFC 6749 §5.2 JSON envelope this surface answers with everywhere else has
27
+ * no way to read that page.
28
+ *
29
+ * Body-parser failures (malformed JSON/form, over-limit, bad charset) carry
30
+ * their own 4xx `status` and are the client's fault: keep the status, wrap it
31
+ * in the shared envelope, and do not log them as server errors. Everything
32
+ * else is logged with the request path and answered `500 server_error`.
33
+ *
34
+ * Mount it AFTER every route (`app.use(handle.router)` included) — Express
35
+ * routes errors only to handlers registered later.
36
+ */
37
+ export const createTerminalErrorHandler = (logger: Logger): ErrorRequestHandler => {
38
+ return (err, req, res, next) => {
39
+ // A failure after the response started is not ours to rewrite; handing
40
+ // it back lets Express close the connection.
41
+ if (res.headersSent) {
42
+ next(err);
43
+ return;
44
+ }
45
+ const status = (err as { status?: unknown; statusCode?: unknown } | null) ?? {};
46
+ const httpStatus =
47
+ typeof status.status === "number"
48
+ ? status.status
49
+ : typeof status.statusCode === "number"
50
+ ? status.statusCode
51
+ : undefined;
52
+ if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
53
+ const description = httpStatus === 413 ? "request body too large" : "malformed request body";
54
+ res.status(httpStatus).json(errorEnvelope("invalid_request", description));
55
+ return;
56
+ }
57
+ logger.error({ err, endpoint: req.path }, "unhandled_request_error");
58
+ res.status(500).json(errorEnvelope("server_error", "Internal server error"));
59
+ };
60
+ };