@o3co/create-auth-provider 0.15.0 → 0.16.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 (41) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +12 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +516 -154
  7. package/templates/standalone/README.md +218 -91
  8. package/templates/standalone/config/application.conf +26 -3
  9. package/templates/standalone/gitignore +3 -2
  10. package/templates/standalone/package.json +1 -1
  11. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  12. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  13. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  14. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  15. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +19 -5
  16. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
  17. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -0
  18. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  19. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +28 -0
  20. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  21. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  22. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  23. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  24. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  25. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  26. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  27. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  28. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  29. package/templates/standalone/src/__tests__/shutdown.test.mts +157 -20
  30. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  31. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  32. package/templates/standalone/src/app.mts +21 -50
  33. package/templates/standalone/src/buildModules.mts +33 -16
  34. package/templates/standalone/src/listen.mts +66 -0
  35. package/templates/standalone/src/logger.mts +7 -4
  36. package/templates/standalone/src/modules.mts +42 -8
  37. package/templates/standalone/src/routes.mts +95 -0
  38. package/templates/standalone/src/shutdown.mts +20 -14
  39. package/templates/versions.json +15 -15
  40. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  41. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,139 @@
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
+ /**
18
+ * What the template's shared Redis connection logs when the server refuses
19
+ * it: the real ioredis the template ships, against a TCP server that answers
20
+ * the way Redis answers a password it no longer accepts. ioredis puts the
21
+ * handshake it sent — `AUTH` and the password — on the error it emits, so
22
+ * the `error` listener must log core's `loggableError` projection, never
23
+ * the error. The logger here serialises every own property of what it is
24
+ * handed, as a deployment's logger may.
25
+ */
26
+
27
+ import { once } from "node:events";
28
+ import { type AddressInfo, createServer, type Socket } from "node:net";
29
+ import type { LifecycleRegistrar, Logger } from "@o3co/auth-provider-core";
30
+ import { describe, expect, it, vi } from "vitest";
31
+ import { standaloneRedisClientsModule } from "#/modules.mjs";
32
+
33
+ /** Redis's reply to a handshake whose password it does not accept. */
34
+ const WRONGPASS = "-WRONGPASS invalid username-password pair or user is disabled.\r\n";
35
+
36
+ /** The password the deployment is configured with, and the server no longer takes. */
37
+ const STALE_PASSWORD = "stale-s3cret-redis-password";
38
+
39
+ /** A TCP server that answers everything sent to it as Redis answers a refused password. */
40
+ async function refusingRedis(): Promise<{ port: number; close: () => Promise<void> }> {
41
+ const sockets = new Set<Socket>();
42
+ const server = createServer((socket) => {
43
+ sockets.add(socket);
44
+ socket.on("close", () => sockets.delete(socket));
45
+ socket.on("data", () => {
46
+ socket.write(WRONGPASS);
47
+ });
48
+ });
49
+ server.listen(0, "127.0.0.1");
50
+ await once(server, "listening");
51
+ const { port } = server.address() as AddressInfo;
52
+ return {
53
+ port,
54
+ close: async () => {
55
+ for (const socket of sockets) socket.destroy();
56
+ await new Promise<void>((resolve) => server.close(() => resolve()));
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * A logger that serialises every own property of what it is handed, `cause`
63
+ * and non-enumerable fields included. `lines` is what it wrote; its levels
64
+ * are spies.
65
+ */
66
+ const serialiseEverythingLogger = () => {
67
+ const lines: string[] = [];
68
+ const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
69
+ if (typeof value !== "object" || value === null) return value;
70
+ if (seen.has(value)) return "[circular]";
71
+ seen.add(value);
72
+ const out: Record<string, unknown> = {};
73
+ for (const key of Object.getOwnPropertyNames(value)) {
74
+ out[key] = walk((value as Record<string, unknown>)[key], seen);
75
+ }
76
+ return out;
77
+ };
78
+ const record = (level: string) =>
79
+ vi.fn((...args: unknown[]): void => {
80
+ lines.push(JSON.stringify({ level, args: walk(args) }));
81
+ });
82
+ const logger = {
83
+ trace: record("trace"),
84
+ debug: record("debug"),
85
+ info: record("info"),
86
+ warn: record("warn"),
87
+ error: record("error"),
88
+ fatal: record("fatal"),
89
+ child: () => logger,
90
+ };
91
+ return { logger: logger as typeof logger & Logger, lines };
92
+ };
93
+
94
+ describe("standaloneRedisClientsModule: the shared connection refused by the server", () => {
95
+ it("logs the connection's error as loggableError's projection, never the AUTH arguments ioredis puts on it", async () => {
96
+ const redis = await refusingRedis();
97
+ const cleanups: Array<() => Promise<void>> = [];
98
+ const lifecycleRegistrar: LifecycleRegistrar = {
99
+ register: (cleanup) => {
100
+ cleanups.push(cleanup);
101
+ },
102
+ };
103
+ const { logger, lines } = serialiseEverythingLogger();
104
+ const provides = (
105
+ standaloneRedisClientsModule as unknown as {
106
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
107
+ }
108
+ ).provides;
109
+ try {
110
+ await provides.refreshTokenFamilyClient({
111
+ config: {
112
+ refreshTokenFamilyStore: {
113
+ redis: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
114
+ },
115
+ },
116
+ lifecycleRegistrar,
117
+ logger,
118
+ });
119
+ await vi.waitFor(() => expect(logger.error).toHaveBeenCalled(), { timeout: 10_000 });
120
+ } finally {
121
+ // `io.quit()`, as `handle.dispose()` runs it.
122
+ await Promise.all(cleanups.map((cleanup) => cleanup().catch(() => {})));
123
+ await redis.close();
124
+ }
125
+
126
+ expect(logger.error).toHaveBeenCalledWith(
127
+ {
128
+ err: expect.objectContaining({
129
+ name: "ReplyError",
130
+ detail: expect.stringMatching(/^WRONGPASS /),
131
+ // Which command the server refused, and nothing it carried.
132
+ command: { name: "hello" },
133
+ }),
134
+ },
135
+ "standalone_redis_clients_error",
136
+ );
137
+ for (const line of lines) expect(line).not.toContain(STALE_PASSWORD);
138
+ });
139
+ });
@@ -510,12 +510,22 @@ describe("#473: the Redis federation store's plaintext guard, booted from the sh
510
510
  const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
511
511
  handleRef = await boot(config, "development");
512
512
  expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
513
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("allow-plaintext"));
513
+ expect(warnSpy).toHaveBeenCalledWith(
514
+ { store: "federation-tokens", mode: "allow-plaintext" },
515
+ "federation_store_plaintext",
516
+ );
514
517
  });
515
518
 
516
- it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, at CRITICAL", async () => {
519
+ it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, logged at error", async () => {
517
520
  process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
518
521
  handleRef = await boot(resolveConfig(PLAINTEXT_ENV), "development");
519
- expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("CRITICAL"));
522
+ expect(errorSpy).toHaveBeenCalledWith(
523
+ expect.objectContaining({
524
+ store: "federation-tokens",
525
+ deploymentMode: "multi",
526
+ override: "FEDERATION_TOKENS_ALLOW_INSECURE",
527
+ }),
528
+ "federation_store_plaintext_override",
529
+ );
520
530
  });
521
531
  });
@@ -0,0 +1,149 @@
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
+ /**
18
+ * The template's composition, booted through core's `createApp` and mounted
19
+ * with no terminal handler of the template's after it — the router alone, as
20
+ * a composition root that copies nothing of `app.mts` mounts it.
21
+ *
22
+ * The OAuth and session routers parse their own bodies and have no error
23
+ * handler: a body parser's refusal on `/oauth/token`, `/oauth/introspect` or
24
+ * `/session/login`, and any error a route let through, went on to whatever
25
+ * the host had after the router. Only `app.mts` had a handler there, so any
26
+ * other host answered with Express's final handler — an HTML page, with the
27
+ * stack outside production, where V8's JSON error quotes the body it could
28
+ * not parse. The router core returns now answers them itself.
29
+ */
30
+
31
+ import { defineModule } from "@o3co/auth-provider-core";
32
+ import { Router } from "express";
33
+ import request from "supertest";
34
+ import { afterEach, describe, expect, it } from "vitest";
35
+ import {
36
+ basic,
37
+ type Composition,
38
+ compose,
39
+ JSON_TYPE,
40
+ KIB,
41
+ M2M,
42
+ padJson,
43
+ } from "./all-modules-composition.fixture.mjs";
44
+
45
+ let current: Composition | undefined;
46
+
47
+ afterEach(async () => {
48
+ await current?.handle.dispose();
49
+ current = undefined;
50
+ });
51
+
52
+ /** A route that lets an error through, contributed as a deployment adds one. */
53
+ const throwingRouteModule = defineModule({
54
+ name: "test:throwing-route",
55
+ contributes: {
56
+ routes: [
57
+ () => {
58
+ const router = Router();
59
+ router.get("/", () => {
60
+ throw new Error("a store said something: escaped-error-marker");
61
+ });
62
+ return { id: "test:throwing-route", mountPath: "/throws", handler: router };
63
+ },
64
+ ],
65
+ },
66
+ });
67
+
68
+ /** The composition, the router alone mounted. */
69
+ const boot = async (): Promise<Composition> => {
70
+ current = await compose({
71
+ terminalErrorHandler: false,
72
+ extraModules: () => [throwingRouteModule],
73
+ });
74
+ return current;
75
+ };
76
+
77
+ /** The routes that parse a body, and what each needs to reach its parser. */
78
+ const PARSING_ROUTES = [
79
+ ["/oauth/token", { authorization: basic(M2M) }],
80
+ ["/oauth/introspect", { authorization: basic(M2M) }],
81
+ ["/session/login", {}],
82
+ ] as const;
83
+
84
+ const errorLines = (c: Composition) => c.logger.lines.filter((line) => line.level === "error");
85
+
86
+ describe("the composed router, mounted alone, answers a body parser's refusal", () => {
87
+ it.each(PARSING_ROUTES)(
88
+ "%s: malformed JSON is 400 in the envelope, nothing logged",
89
+ async (path, headers) => {
90
+ const c = await boot();
91
+ const res = await request(c.app)
92
+ .post(path)
93
+ .set(headers)
94
+ .set("Content-Type", JSON_TYPE)
95
+ .send('{"password":"body-secret-marker');
96
+
97
+ expect(res.status).toBe(400);
98
+ expect(res.headers["content-type"]).toMatch(/^application\/json/);
99
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
100
+ expect(res.text).not.toContain("body-secret-marker");
101
+ expect(res.text).not.toMatch(/ at /);
102
+ expect(errorLines(c)).toEqual([]);
103
+ },
104
+ );
105
+
106
+ it.each(PARSING_ROUTES)(
107
+ "%s: a body over the parser's limit is 413 in the envelope, nothing logged",
108
+ async (path, headers) => {
109
+ const c = await boot();
110
+ const res = await request(c.app)
111
+ .post(path)
112
+ .set(headers)
113
+ .set("Content-Type", JSON_TYPE)
114
+ .send(padJson(200 * KIB));
115
+
116
+ expect(res.status).toBe(413);
117
+ expect(res.headers["content-type"]).toMatch(/^application\/json/);
118
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
119
+ expect(errorLines(c)).toEqual([]);
120
+ },
121
+ );
122
+ });
123
+
124
+ describe("the composed router, mounted alone, answers an error a route let through", () => {
125
+ it("is 500 server_error in the envelope, logged once at error", async () => {
126
+ const c = await boot();
127
+ const res = await request(c.app).get("/throws");
128
+
129
+ expect(res.status).toBe(500);
130
+ expect(res.headers["content-type"]).toMatch(/^application\/json/);
131
+ expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
132
+ expect(res.text).not.toContain("escaped-error-marker");
133
+ expect(errorLines(c)).toEqual([
134
+ {
135
+ level: "error",
136
+ args: [
137
+ {
138
+ endpoint: "/throws",
139
+ err: expect.objectContaining({
140
+ name: "Error",
141
+ detail: "a store said something: escaped-error-marker",
142
+ }),
143
+ },
144
+ "unhandled_request_error",
145
+ ],
146
+ },
147
+ ]);
148
+ });
149
+ });
@@ -30,8 +30,15 @@
30
30
  * and these tests are the contract the issue said was missing.
31
31
  */
32
32
 
33
- import type { Server } from "node:http";
34
- import type { Logger } from "@o3co/auth-provider-core";
33
+ import { createServer, type Server } from "node:http";
34
+ import {
35
+ type BootstrapMap,
36
+ createApp,
37
+ createMemoryReplaySeenSet,
38
+ defineModule,
39
+ type Logger,
40
+ } from "@o3co/auth-provider-core";
41
+ import { makeValidCoreConfig } from "@o3co/auth-provider-core/testing";
35
42
  import { describe, expect, it, vi } from "vitest";
36
43
  import {
37
44
  cleanupAllowanceFor,
@@ -85,16 +92,53 @@ const makeLogger = () => {
85
92
  return spy satisfies Logger;
86
93
  };
87
94
 
95
+ /**
96
+ * A logger that serialises every own property of what it is handed, `cause`
97
+ * and non-enumerable fields included — a deployment is free to install one.
98
+ * `lines` is what it wrote; its levels are spies.
99
+ */
100
+ const serialiseEverythingLogger = () => {
101
+ const lines: string[] = [];
102
+ const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
103
+ if (typeof value !== "object" || value === null) return value;
104
+ if (seen.has(value)) return "[circular]";
105
+ seen.add(value);
106
+ const out: Record<string, unknown> = {};
107
+ for (const key of Object.getOwnPropertyNames(value)) {
108
+ out[key] = walk((value as Record<string, unknown>)[key], seen);
109
+ }
110
+ return out;
111
+ };
112
+ const record = (level: string) =>
113
+ vi.fn((...args: unknown[]): void => {
114
+ lines.push(JSON.stringify({ level, args: walk(args) }));
115
+ });
116
+ const logger = {
117
+ trace: record("trace"),
118
+ debug: record("debug"),
119
+ info: record("info"),
120
+ warn: record("warn"),
121
+ error: record("error"),
122
+ fatal: record("fatal"),
123
+ child: vi.fn(() => logger as unknown as Logger),
124
+ };
125
+ return { logger: logger satisfies Logger, lines };
126
+ };
127
+
128
+ /** A logged projection's `stack`: frames only, from the first. */
129
+ const FRAMES = expect.stringMatching(/^ {4}at /);
130
+
88
131
  /** Drive one shutdown without touching the real `process` or exiting. */
89
132
  function install(
90
133
  opts: {
91
134
  cleanup?: () => void | Promise<void>;
92
135
  drainTimeoutMs?: number;
93
136
  cleanupTimeoutMs?: number;
137
+ logger?: ReturnType<typeof makeLogger>;
94
138
  } = {},
95
139
  ) {
96
140
  const { server, spies, finishDraining, failClose } = makeServer();
97
- const logger = makeLogger();
141
+ const logger = opts.logger ?? makeLogger();
98
142
  const exit = vi.fn();
99
143
  const signals = new Map<string, () => void>();
100
144
 
@@ -172,7 +216,12 @@ describe("installGracefulShutdown (#290)", () => {
172
216
  signals.get("SIGTERM")?.();
173
217
  await vi.advanceTimersByTimeAsync(5_000);
174
218
  expect(exit).toHaveBeenCalledWith(1);
175
- expect(logger.error).toHaveBeenCalled();
219
+ // Each stage is an event: object-first, a snake_case name.
220
+ expect(logger.info).toHaveBeenCalledWith({ drainTimeoutMs: 5_000 }, "shutdown_draining");
221
+ expect(logger.error).toHaveBeenCalledWith(
222
+ { drainTimeoutMs: 5_000 },
223
+ "shutdown_drain_deadline_exceeded",
224
+ );
176
225
  } finally {
177
226
  vi.useRealTimers();
178
227
  }
@@ -193,21 +242,91 @@ describe("installGracefulShutdown (#290)", () => {
193
242
  }
194
243
  });
195
244
 
196
- it("reports a cleanup failure through the app logger, not console", async () => {
245
+ it("reports a cleanup failure through the app logger, not console, as loggableError's projection", async () => {
197
246
  // Every other line this service emits is NDJSON through pino; a bare
198
247
  // console.error on the shutdown path is the one a log pipeline drops.
199
- const { logger, exit, signals, finishDraining } = install({
200
- cleanup: () => {
201
- throw new Error("redis quit failed");
248
+ // What a real `handle.dispose()` rejects with is an AggregateError of
249
+ // every cleanup's own error. The line names each member and its code,
250
+ // and carries nothing of what they hold: an OAuth library's refusal
251
+ // keeps the refresh answer it refused on a non-Error cause, an ioredis
252
+ // reply the write it refused.
253
+ const refusedRefresh = Object.assign(
254
+ new Error("invalid response encountered", {
255
+ cause: { body: { refresh_token: "1//0g-UPSTREAM-S3CRET" } },
256
+ }),
257
+ { name: "ClientError", code: "OAUTH_INVALID_RESPONSE" },
258
+ );
259
+ const refusedWrite = Object.assign(
260
+ new Error("READONLY You can't write against a read only replica."),
261
+ {
262
+ name: "ReplyError",
263
+ command: { name: "set", args: ["fg:credential:grant-1", "1//0g-ARGS-S3CRET"] },
264
+ },
265
+ );
266
+ const failingCleanups = defineModule({
267
+ name: "test:failing-cleanups",
268
+ provides: {
269
+ replaySeenSet: () => createMemoryReplaySeenSet(),
270
+ challengeStore: () => ({}) as never,
271
+ },
272
+ lifecycle: {
273
+ replaySeenSet: {
274
+ eager: true,
275
+ cleanup: () => {
276
+ throw refusedRefresh;
277
+ },
278
+ },
279
+ challengeStore: {
280
+ eager: true,
281
+ cleanup: () => {
282
+ throw refusedWrite;
283
+ },
284
+ },
202
285
  },
203
286
  });
287
+ const handle = await createApp({
288
+ modules: [failingCleanups],
289
+ bootstrapComponents: {
290
+ config: makeValidCoreConfig(),
291
+ pathResolver: (path: string) => path,
292
+ } as unknown as BootstrapMap,
293
+ });
294
+ const { logger, lines } = serialiseEverythingLogger();
295
+ const { exit, signals, finishDraining } = install({
296
+ logger,
297
+ cleanup: () => handle.dispose(),
298
+ });
204
299
  signals.get("SIGTERM")?.();
205
300
  finishDraining();
206
301
  await vi.waitFor(() => expect(exit).toHaveBeenCalled());
207
302
  expect(logger.error).toHaveBeenCalledWith(
208
- expect.objectContaining({ err: expect.any(Error) }),
209
- expect.stringContaining("cleanup"),
303
+ {
304
+ err: {
305
+ name: "AggregateError",
306
+ detail: expect.stringMatching(/^AppHandle\.dispose: 2 cleanup errors /),
307
+ stack: FRAMES,
308
+ aggregateErrors: expect.arrayContaining([
309
+ {
310
+ name: "ClientError",
311
+ detail: "invalid response encountered",
312
+ code: "OAUTH_INVALID_RESPONSE",
313
+ stack: FRAMES,
314
+ },
315
+ {
316
+ name: "ReplyError",
317
+ detail: "READONLY You can't write against a read only replica.",
318
+ command: { name: "set" },
319
+ stack: FRAMES,
320
+ },
321
+ ]),
322
+ },
323
+ },
324
+ "shutdown_cleanup_failed",
210
325
  );
326
+ for (const line of lines) {
327
+ expect(line).not.toContain("S3CRET");
328
+ expect(line).not.toContain("refresh_token");
329
+ }
211
330
  });
212
331
 
213
332
  it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
@@ -221,19 +340,37 @@ describe("installGracefulShutdown (#290)", () => {
221
340
  await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
222
341
  });
223
342
 
224
- it("does not report a failed close as a clean drain", async () => {
343
+ it("does not report a failed close as a clean drain, and logs the failure as loggableError's projection", async () => {
225
344
  // `server.close` reports through its callback -- "Server is not running"
226
345
  // is the common one, but any listener teardown failure lands there.
227
346
  // Exiting 0 on it would tell an orchestrator the listener came down
228
- // when it did not.
229
- const { exit, logger, signals, failClose } = install();
347
+ // when it did not. A real server that is not listening gives Node's
348
+ // own error; the line keeps its name, message, code and frames.
349
+ const server = createServer();
350
+ const { logger, lines } = serialiseEverythingLogger();
351
+ const exit = vi.fn();
352
+ const signals = new Map<string, () => void>();
353
+ installGracefulShutdown(server, {
354
+ logger,
355
+ exit,
356
+ onSignal: (name, handler) => signals.set(name, handler),
357
+ offSignal: (name) => signals.delete(name),
358
+ });
230
359
  signals.get("SIGTERM")?.();
231
- failClose(new Error("Server is not running"));
232
360
  await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
233
361
  expect(logger.error).toHaveBeenCalledWith(
234
- expect.objectContaining({ err: expect.any(Error) }),
235
- expect.stringContaining("close"),
362
+ {
363
+ err: {
364
+ name: "Error",
365
+ detail: "Server is not running.",
366
+ code: "ERR_SERVER_NOT_RUNNING",
367
+ stack: FRAMES,
368
+ },
369
+ },
370
+ "shutdown_server_close_failed",
236
371
  );
372
+ // The frames, never the header line that repeats the message.
373
+ for (const line of lines) expect(line).not.toContain("Error [ERR_SERVER_NOT_RUNNING]");
237
374
  });
238
375
 
239
376
  it("still runs cleanup when close reports a failure", async () => {
@@ -263,8 +400,8 @@ describe("installGracefulShutdown (#290)", () => {
263
400
  finishDraining();
264
401
  await vi.advanceTimersByTimeAsync(5_000);
265
402
  expect(logger.error).toHaveBeenCalledWith(
266
- expect.objectContaining({ cleanupTimeoutMs: 5_000 }),
267
- expect.stringContaining("cleanup timed out"),
403
+ { cleanupTimeoutMs: 5_000 },
404
+ "shutdown_cleanup_timed_out",
268
405
  );
269
406
  expect(exit).toHaveBeenCalledWith(1);
270
407
  } finally {
@@ -311,7 +448,7 @@ describe("installGracefulShutdown (#290)", () => {
311
448
  await new Promise((resolve) => setImmediate(resolve));
312
449
  expect(logger.info).toHaveBeenCalledWith(
313
450
  { reason: "cleanup-failed", drain: "drained", exitCode: 1 },
314
- "graceful shutdown: complete",
451
+ "shutdown_complete",
315
452
  );
316
453
  expect(exit).toHaveBeenCalledWith(1);
317
454
  });
@@ -323,7 +460,7 @@ describe("installGracefulShutdown (#290)", () => {
323
460
  await new Promise((resolve) => setImmediate(resolve));
324
461
  expect(logger.info).toHaveBeenCalledWith(
325
462
  { reason: "drained", drain: "drained", exitCode: 0 },
326
- "graceful shutdown: complete",
463
+ "shutdown_complete",
327
464
  );
328
465
  });
329
466
 
@@ -618,27 +618,46 @@ describe("standalone smoke test", () => {
618
618
  expect(names).toContain("standalone:redis-clients");
619
619
  });
620
620
 
621
- it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
622
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
623
- try {
624
- const legacyConfig = {
625
- ...config,
626
- oauth: { ...config.oauth, code: undefined },
627
- repositories: {
628
- ...config.repositories,
629
- code: { type: "redis" as const, defaultExpiresIn: 600 },
621
+ it("buildModules honors legacy repositories.code.type='redis' with one object-first deprecation warn when oauth.code.adapter is absent", () => {
622
+ const warn = vi.fn();
623
+ const logger = {
624
+ trace: vi.fn(),
625
+ debug: vi.fn(),
626
+ info: vi.fn(),
627
+ warn,
628
+ error: vi.fn(),
629
+ fatal: vi.fn(),
630
+ child: () => logger,
631
+ };
632
+ const legacyConfig = {
633
+ ...config,
634
+ oauth: { ...config.oauth, code: undefined },
635
+ repositories: {
636
+ ...config.repositories,
637
+ code: { type: "redis" as const, defaultExpiresIn: 600 },
638
+ },
639
+ };
640
+ const modules = buildModules(legacyConfig, {
641
+ keyStoreModule: testKeyStoreModule,
642
+ repositoriesModule: testRepositoriesModule,
643
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
644
+ logger,
645
+ });
646
+ const names = modules.map((m) => m.name);
647
+ expect(names).toContain("redis-code-repository");
648
+ expect(warn.mock.calls).toEqual([
649
+ [
650
+ {
651
+ key: "repositories.code.type",
652
+ env: "CLIENT_CODE_TYPE",
653
+ replacement: "oauth.code.adapter",
654
+ replacementEnv: "OAUTH_CODE_ADAPTER",
630
655
  },
631
- };
632
- const modules = buildModules(legacyConfig, {
633
- keyStoreModule: testKeyStoreModule,
634
- repositoriesModule: testRepositoriesModule,
635
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
636
- });
637
- const names = modules.map((m) => m.name);
638
- expect(names).toContain("redis-code-repository");
639
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
640
- } finally {
641
- warnSpy.mockRestore();
656
+ "config_key_deprecated",
657
+ ],
658
+ ]);
659
+ for (const level of ["trace", "debug", "info", "error", "fatal"] as const) {
660
+ expect(logger[level]).not.toHaveBeenCalled();
642
661
  }
643
662
  });
644
663