@o3co/create-auth-provider 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11 -1
- package/package.json +2 -2
- package/templates/standalone/.env.example +19 -1
- package/templates/standalone/Dockerfile +9 -1
- package/templates/standalone/README.ja.md +24 -4
- package/templates/standalone/README.md +175 -21
- package/templates/standalone/config/application.conf +28 -0
- package/templates/standalone/config/clients.yaml.example +15 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +80 -5
- package/templates/standalone/docker-compose.yml +24 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +5 -6
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -4
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +246 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +16 -3
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +128 -24
- package/templates/standalone/src/shutdown.mts +219 -0
- package/templates/versions.json +13 -11
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
* Issue #290 — shutdown was delegated to `@o3co/auth.utils@0.0.4`, whose
|
|
19
|
+
* guarantees were not pinned by any contract this repository could check.
|
|
20
|
+
*
|
|
21
|
+
* Reading the 22 lines answered the question the issue asked, and the answer
|
|
22
|
+
* was worth knowing: **there was no force-close deadline**. `server.close()`
|
|
23
|
+
* waits for in-flight requests indefinitely, so one stuck request meant the
|
|
24
|
+
* process never exited on its own and the orchestrator's SIGKILL took it down
|
|
25
|
+
* mid-flight — the opposite of a graceful shutdown, and invisible until it
|
|
26
|
+
* happened. The cleanup-failure path also wrote to `console.error`, a bare
|
|
27
|
+
* line in a service whose every other line is NDJSON.
|
|
28
|
+
*
|
|
29
|
+
* So the behaviour lives here now, with a deadline and the app's own logger,
|
|
30
|
+
* and these tests are the contract the issue said was missing.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Server } from "node:http";
|
|
34
|
+
import type { Logger } from "@o3co/auth-provider-core";
|
|
35
|
+
import { describe, expect, it, vi } from "vitest";
|
|
36
|
+
import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
|
|
37
|
+
|
|
38
|
+
/** A `Server` double whose `close` callback fires only when we say so. */
|
|
39
|
+
function makeServer() {
|
|
40
|
+
let closeCallback: ((err?: Error) => void) | undefined;
|
|
41
|
+
const server = {
|
|
42
|
+
close: vi.fn((cb?: (err?: Error) => void) => {
|
|
43
|
+
closeCallback = cb;
|
|
44
|
+
return server;
|
|
45
|
+
}),
|
|
46
|
+
closeIdleConnections: vi.fn(),
|
|
47
|
+
closeAllConnections: vi.fn(),
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
server: server as unknown as Server,
|
|
51
|
+
spies: server,
|
|
52
|
+
/** Simulate the last in-flight request finishing. */
|
|
53
|
+
finishDraining: () => closeCallback?.(),
|
|
54
|
+
/** Simulate `close` reporting a failure through its callback. */
|
|
55
|
+
failClose: (err: Error) => closeCallback?.(err),
|
|
56
|
+
get drained() {
|
|
57
|
+
return closeCallback !== undefined;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
|
|
64
|
+
* mismatch the day the port gains a method, which is the whole reason the
|
|
65
|
+
* shutdown path logs through the app logger instead of `console`.
|
|
66
|
+
*/
|
|
67
|
+
const makeLogger = () => {
|
|
68
|
+
const spy = {
|
|
69
|
+
trace: vi.fn(),
|
|
70
|
+
debug: vi.fn(),
|
|
71
|
+
info: vi.fn(),
|
|
72
|
+
warn: vi.fn(),
|
|
73
|
+
error: vi.fn(),
|
|
74
|
+
fatal: vi.fn(),
|
|
75
|
+
// `child` returns a Logger; returning this one keeps a child's calls
|
|
76
|
+
// visible on the same spies, which is what a test wants.
|
|
77
|
+
child: vi.fn(() => spy as unknown as Logger),
|
|
78
|
+
};
|
|
79
|
+
return spy satisfies Logger;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Drive one shutdown without touching the real `process` or exiting. */
|
|
83
|
+
function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?: number } = {}) {
|
|
84
|
+
const { server, spies, finishDraining, failClose } = makeServer();
|
|
85
|
+
const logger = makeLogger();
|
|
86
|
+
const exit = vi.fn();
|
|
87
|
+
const signals = new Map<string, () => void>();
|
|
88
|
+
|
|
89
|
+
installGracefulShutdown(server, {
|
|
90
|
+
logger,
|
|
91
|
+
cleanup: opts.cleanup ?? (() => {}),
|
|
92
|
+
...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
|
|
93
|
+
exit,
|
|
94
|
+
onSignal: (name, handler) => signals.set(name, handler),
|
|
95
|
+
offSignal: (name) => signals.delete(name),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return { spies, logger, exit, signals, finishDraining, failClose };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("installGracefulShutdown (#290)", () => {
|
|
102
|
+
it("listens for both SIGTERM and SIGINT", async () => {
|
|
103
|
+
const { signals } = install();
|
|
104
|
+
expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("stops accepting connections and releases idle keep-alive sockets", async () => {
|
|
108
|
+
const { spies, signals } = install();
|
|
109
|
+
signals.get("SIGTERM")?.();
|
|
110
|
+
expect(spies.close).toHaveBeenCalled();
|
|
111
|
+
// Idle keep-alive sockets hold the server open with no request behind
|
|
112
|
+
// them; releasing them is what lets a quiet server exit promptly.
|
|
113
|
+
expect(spies.closeIdleConnections).toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("runs cleanup once draining completes, then exits zero", async () => {
|
|
117
|
+
const cleanup = vi.fn();
|
|
118
|
+
const { exit, signals, finishDraining } = install({ cleanup });
|
|
119
|
+
signals.get("SIGTERM")?.();
|
|
120
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
121
|
+
finishDraining();
|
|
122
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
|
|
123
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("ignores a second signal instead of running cleanup twice", async () => {
|
|
127
|
+
// An operator pressing ^C twice, or a SIGINT arriving after SIGTERM,
|
|
128
|
+
// must not start a second dispose over the first one's stores.
|
|
129
|
+
const cleanup = vi.fn();
|
|
130
|
+
const { signals, finishDraining, exit } = install({ cleanup });
|
|
131
|
+
signals.get("SIGTERM")?.();
|
|
132
|
+
signals.get("SIGINT")?.();
|
|
133
|
+
finishDraining();
|
|
134
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
135
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The gap the audit was pointing at.
|
|
139
|
+
it("forces the remaining connections closed when draining outruns the deadline", async () => {
|
|
140
|
+
vi.useFakeTimers();
|
|
141
|
+
try {
|
|
142
|
+
const { spies, signals, exit } = install({ drainTimeoutMs: 5_000 });
|
|
143
|
+
signals.get("SIGTERM")?.();
|
|
144
|
+
expect(spies.closeAllConnections).not.toHaveBeenCalled();
|
|
145
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
146
|
+
expect(spies.closeAllConnections).toHaveBeenCalled();
|
|
147
|
+
expect(exit).toHaveBeenCalled();
|
|
148
|
+
} finally {
|
|
149
|
+
vi.useRealTimers();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("exits non-zero on a forced close, so the drain outcome is visible", async () => {
|
|
154
|
+
// An orchestrator that only ever sees exit 0 cannot tell a clean drain
|
|
155
|
+
// from one that ran out of time and cut requests off.
|
|
156
|
+
vi.useFakeTimers();
|
|
157
|
+
try {
|
|
158
|
+
const { signals, exit, logger } = install({ drainTimeoutMs: 5_000 });
|
|
159
|
+
signals.get("SIGTERM")?.();
|
|
160
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
161
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
162
|
+
expect(logger.error).toHaveBeenCalled();
|
|
163
|
+
} finally {
|
|
164
|
+
vi.useRealTimers();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("does not force-close a drain that finished in time", async () => {
|
|
169
|
+
vi.useFakeTimers();
|
|
170
|
+
try {
|
|
171
|
+
const { spies, signals, finishDraining, exit } = install({ drainTimeoutMs: 5_000 });
|
|
172
|
+
signals.get("SIGTERM")?.();
|
|
173
|
+
finishDraining();
|
|
174
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
|
|
175
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
176
|
+
expect(spies.closeAllConnections).not.toHaveBeenCalled();
|
|
177
|
+
expect(exit).toHaveBeenCalledTimes(1);
|
|
178
|
+
} finally {
|
|
179
|
+
vi.useRealTimers();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("reports a cleanup failure through the app logger, not console", async () => {
|
|
184
|
+
// Every other line this service emits is NDJSON through pino; a bare
|
|
185
|
+
// console.error on the shutdown path is the one a log pipeline drops.
|
|
186
|
+
const { logger, exit, signals, finishDraining } = install({
|
|
187
|
+
cleanup: () => {
|
|
188
|
+
throw new Error("redis quit failed");
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
signals.get("SIGTERM")?.();
|
|
192
|
+
finishDraining();
|
|
193
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
194
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({ err: expect.any(Error) }),
|
|
196
|
+
expect.stringContaining("cleanup"),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
|
|
201
|
+
const { exit, signals, finishDraining } = install({
|
|
202
|
+
cleanup: async () => {
|
|
203
|
+
throw new Error("dispose failed");
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
signals.get("SIGTERM")?.();
|
|
207
|
+
finishDraining();
|
|
208
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not report a failed close as a clean drain", async () => {
|
|
212
|
+
// `server.close` reports through its callback -- "Server is not running"
|
|
213
|
+
// is the common one, but any listener teardown failure lands there.
|
|
214
|
+
// Exiting 0 on it would tell an orchestrator the listener came down
|
|
215
|
+
// when it did not.
|
|
216
|
+
const { exit, logger, signals, failClose } = install();
|
|
217
|
+
signals.get("SIGTERM")?.();
|
|
218
|
+
failClose(new Error("Server is not running"));
|
|
219
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
220
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
221
|
+
expect.objectContaining({ err: expect.any(Error) }),
|
|
222
|
+
expect.stringContaining("close"),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("still runs cleanup when close reports a failure", async () => {
|
|
227
|
+
// The listener failing to come down is no reason to leak the Redis
|
|
228
|
+
// connections behind it.
|
|
229
|
+
const cleanup = vi.fn();
|
|
230
|
+
const { exit, signals, failClose } = install({ cleanup });
|
|
231
|
+
signals.get("SIGTERM")?.();
|
|
232
|
+
failClose(new Error("teardown failed"));
|
|
233
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
234
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
|
|
238
|
+
// The guarantees above say cleanup "never wedges the process", but `finish`
|
|
239
|
+
// awaited it with no deadline — and the drain deadline is already cleared
|
|
240
|
+
// by then, so a dispose that never settles meant `exit` was never reached.
|
|
241
|
+
// The same defect was found in auth.proxy#81 and auth.policy-verifier#210,
|
|
242
|
+
// both of which took this file as their starting point.
|
|
243
|
+
vi.useFakeTimers();
|
|
244
|
+
try {
|
|
245
|
+
const { signals, finishDraining, exit, logger } = install({
|
|
246
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
247
|
+
drainTimeoutMs: 5_000,
|
|
248
|
+
});
|
|
249
|
+
signals.get("SIGTERM")?.();
|
|
250
|
+
finishDraining();
|
|
251
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
252
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
253
|
+
expect.objectContaining({ cleanupTimeoutMs: 5_000 }),
|
|
254
|
+
expect.stringContaining("cleanup timed out"),
|
|
255
|
+
);
|
|
256
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
257
|
+
} finally {
|
|
258
|
+
vi.useRealTimers();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("does not penalise a cleanup that finishes inside its budget", async () => {
|
|
263
|
+
vi.useFakeTimers();
|
|
264
|
+
try {
|
|
265
|
+
const { signals, finishDraining, exit } = install({
|
|
266
|
+
cleanup: () => Promise.resolve(),
|
|
267
|
+
drainTimeoutMs: 5_000,
|
|
268
|
+
});
|
|
269
|
+
signals.get("SIGTERM")?.();
|
|
270
|
+
finishDraining();
|
|
271
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
272
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(0);
|
|
273
|
+
} finally {
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("defers the real exit a turn so a buffered log destination can flush", async () => {
|
|
279
|
+
// The scaffolded app logs NDJSON through pino, whose default destination is
|
|
280
|
+
// not synchronous: exiting in the same tick as the last `logger.error` can
|
|
281
|
+
// drop exactly the `cleanup failed` line an operator would look for.
|
|
282
|
+
const exitProcess = vi.fn();
|
|
283
|
+
deferExit(3, exitProcess);
|
|
284
|
+
expect(exitProcess).not.toHaveBeenCalled();
|
|
285
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
286
|
+
expect(exitProcess).toHaveBeenCalledWith(3);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
|
|
290
|
+
// `exitCode` became 1 while `reason` still said "drained", so the one line
|
|
291
|
+
// an operator alerts on contradicted itself. The drain outcome is still
|
|
292
|
+
// carried, under its own key, so neither fact is lost.
|
|
293
|
+
const { signals, finishDraining, logger, exit } = install({
|
|
294
|
+
cleanup: () => Promise.reject(new Error("teardown failed")),
|
|
295
|
+
});
|
|
296
|
+
signals.get("SIGTERM")?.();
|
|
297
|
+
finishDraining();
|
|
298
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
299
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
300
|
+
{ reason: "cleanup-failed", drain: "drained", exitCode: 1 },
|
|
301
|
+
"graceful shutdown: complete",
|
|
302
|
+
);
|
|
303
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("keeps reason and drain identical when cleanup succeeds", async () => {
|
|
307
|
+
const { signals, finishDraining, logger } = install({ cleanup: () => Promise.resolve() });
|
|
308
|
+
signals.get("SIGTERM")?.();
|
|
309
|
+
finishDraining();
|
|
310
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
311
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
312
|
+
{ reason: "drained", drain: "drained", exitCode: 0 },
|
|
313
|
+
"graceful shutdown: complete",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("removes its own signal listeners once shutting down", async () => {
|
|
318
|
+
// Otherwise a repeated signal keeps re-entering a handler that has
|
|
319
|
+
// already handed the process over to `close`.
|
|
320
|
+
const { signals } = install();
|
|
321
|
+
signals.get("SIGTERM")?.();
|
|
322
|
+
expect(signals.size).toBe(0);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -469,6 +469,42 @@ describe("standalone smoke test", () => {
|
|
|
469
469
|
expect(names).not.toContain("standalone:stores");
|
|
470
470
|
expect(names).toContain("standalone:redis-clients");
|
|
471
471
|
});
|
|
472
|
+
|
|
473
|
+
// #321's `redisSessionStoresModule` requires the two subject-level client
|
|
474
|
+
// slots (`subjectSessionIndexClient`, `subjectRevocationClient`) its
|
|
475
|
+
// adapters consume, and the scaffold's shared ioredis module did not
|
|
476
|
+
// provide them — so `userSessionStores.adapter = "redis"`, the
|
|
477
|
+
// template's own multi-replica path, failed stage-1 boot with
|
|
478
|
+
// `missing-required-component`. Nothing here booted that branch: the
|
|
479
|
+
// tests above check which modules are selected, not whether the
|
|
480
|
+
// selection is satisfiable. This pins the invariant the shared clients
|
|
481
|
+
// module exists for: every `*Client` slot any selected module requires
|
|
482
|
+
// is one it provides.
|
|
483
|
+
it("standalone:redis-clients provides every *Client slot the Redis branches require", () => {
|
|
484
|
+
const allRedisConfig = {
|
|
485
|
+
...config,
|
|
486
|
+
userSessionStores: { adapter: "redis" as const },
|
|
487
|
+
rateLimiter: { adapter: "redis" as const },
|
|
488
|
+
accessTokenDenylist: { adapter: "redis" as const },
|
|
489
|
+
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
490
|
+
};
|
|
491
|
+
// No `refreshTokenFamilyModules` override: the default is the Redis
|
|
492
|
+
// RT-family store, so `refreshTokenFamilyClient` is part of what the
|
|
493
|
+
// invariant covers. Still hermetic — buildModules opens no socket.
|
|
494
|
+
const modules = buildModules(allRedisConfig, {
|
|
495
|
+
keyStoreModule: testKeyStoreModule,
|
|
496
|
+
repositoriesModule: testRepositoriesModule,
|
|
497
|
+
});
|
|
498
|
+
const clients = modules.find((m) => m.name === "standalone:redis-clients");
|
|
499
|
+
expect(clients).toBeDefined();
|
|
500
|
+
const provided = new Set(Object.keys(clients?.provides ?? {}));
|
|
501
|
+
const requiredClientSlots = new Set(
|
|
502
|
+
modules.flatMap((m) => [...(m.requires ?? [])]).filter((key) => key.endsWith("Client")),
|
|
503
|
+
);
|
|
504
|
+
expect(requiredClientSlots.size).toBeGreaterThan(0);
|
|
505
|
+
const missing = [...requiredClientSlots].filter((key) => !provided.has(key));
|
|
506
|
+
expect(missing).toEqual([]);
|
|
507
|
+
});
|
|
472
508
|
});
|
|
473
509
|
|
|
474
510
|
// #277: the scaffold used to wire NO access-token denylist, so the
|
|
@@ -193,18 +193,23 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
193
193
|
// every in-memory shared store, so the denylist this change adds has to
|
|
194
194
|
// come out of that environment as the Redis one — from the template's
|
|
195
195
|
// own config, since the compose file names no denylist variable.
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
196
|
+
// Asked of each manifest, the way the guard does (#455), so a rename
|
|
197
|
+
// cannot quietly invalidate it and the template's own memory modules
|
|
198
|
+
// — which no name list in core covers — count too.
|
|
199
|
+
const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
|
|
199
200
|
const config = buildResolvedConfig("production", {
|
|
200
201
|
USER_SESSION_STORES_ADAPTER: "redis",
|
|
201
202
|
RATE_LIMITER_ADAPTER: "redis",
|
|
202
203
|
OAUTH_CODE_ADAPTER: "redis",
|
|
204
|
+
// #456: the federation token store defaults to memory, and the
|
|
205
|
+
// memory module declares itself replica-unsafe (#455).
|
|
206
|
+
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
207
|
+
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
|
203
208
|
});
|
|
204
|
-
const
|
|
205
|
-
expect(
|
|
206
|
-
for (const
|
|
207
|
-
expect(
|
|
209
|
+
const modules = buildModules(config);
|
|
210
|
+
expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
|
|
211
|
+
for (const module of modules) {
|
|
212
|
+
expect(replicaUnsafeReason(module), module.name).toBeUndefined();
|
|
208
213
|
}
|
|
209
214
|
});
|
|
210
215
|
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { gracefulShutdown } from "@o3co/auth.utils";
|
|
18
17
|
import {
|
|
19
18
|
type AppConfig,
|
|
20
19
|
AppConfigSchema,
|
|
@@ -30,6 +29,7 @@ import { buildModules } from "./buildModules.mjs";
|
|
|
30
29
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
|
|
31
30
|
import { createAppLogger } from "./logger.mjs";
|
|
32
31
|
import { createMetrics } from "./metrics.mjs";
|
|
32
|
+
import { installGracefulShutdown } from "./shutdown.mjs";
|
|
33
33
|
import { createTerminalErrorHandler } from "./terminalError.mjs";
|
|
34
34
|
|
|
35
35
|
// Step 1: Load and validate application config (HOCON → Zod schema).
|
|
@@ -99,8 +99,12 @@ await (async (): Promise<void> => {
|
|
|
99
99
|
// `buildModules` is the single source of truth for the module list — it
|
|
100
100
|
// gates federation modules on `config.federations.<name>.enabled`, which
|
|
101
101
|
// the standalone scaffold defaults to false.
|
|
102
|
+
//
|
|
103
|
+
// `environment` is the name the config above was selected by (#473): the
|
|
104
|
+
// Redis federation-token store's plaintext guard reads it in addition to
|
|
105
|
+
// NODE_ENV, so `CONFIG_ENV=production` is production to the guard too.
|
|
102
106
|
const handle = await createApp({
|
|
103
|
-
modules: buildModules(config),
|
|
107
|
+
modules: buildModules(config, { environment: env }),
|
|
104
108
|
bootstrapComponents: {
|
|
105
109
|
config,
|
|
106
110
|
pathResolver: import.meta.resolve,
|
|
@@ -154,5 +158,14 @@ await (async (): Promise<void> => {
|
|
|
154
158
|
// Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
|
|
155
159
|
// per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
|
|
156
160
|
// (Redis clients, interval timers).
|
|
157
|
-
|
|
161
|
+
//
|
|
162
|
+
// #290: in the template rather than a dependency, because "does SIGTERM
|
|
163
|
+
// wait for in-flight requests, and for how long?" has to be answerable
|
|
164
|
+
// from the code you deploy. See `shutdown.mts` for the stated guarantees
|
|
165
|
+
// and for the deadline the previous implementation did not have. Size
|
|
166
|
+
// `drainTimeoutMs` below your orchestrator's kill grace period.
|
|
167
|
+
installGracefulShutdown(server, {
|
|
168
|
+
logger,
|
|
169
|
+
cleanup: () => handle.dispose(),
|
|
170
|
+
});
|
|
158
171
|
})();
|
|
@@ -31,16 +31,17 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
redisAccessTokenDenylistModule,
|
|
33
33
|
redisCodeRepositoryModule,
|
|
34
|
+
redisFederationTokenStoreModuleFor,
|
|
34
35
|
redisRateLimiterModule,
|
|
35
36
|
redisRefreshTokenFamilyStoreModule,
|
|
36
37
|
redisSessionStoresModule,
|
|
37
38
|
} from "@o3co/auth-provider-redis";
|
|
38
|
-
import { sessionModule,
|
|
39
|
+
import { sessionModule, sessionStoreModuleFor } from "@o3co/auth-provider-session";
|
|
39
40
|
import {
|
|
40
41
|
auditSinkModule,
|
|
41
|
-
federationTokenStoreModule,
|
|
42
42
|
googleFederationConfigModule,
|
|
43
43
|
inMemoryCodeRepositoryModule,
|
|
44
|
+
inMemoryFederationTokenStoreModule,
|
|
44
45
|
inMemorySessionStoresModule,
|
|
45
46
|
keyStoreModule,
|
|
46
47
|
repositoriesModule,
|
|
@@ -48,11 +49,21 @@ import {
|
|
|
48
49
|
} from "./modules.mjs";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
|
-
*
|
|
52
|
-
* implementations of the file-system-backed
|
|
53
|
-
* should not pass
|
|
52
|
+
* Overrides for the composition. All but `environment` are test-only: they let
|
|
53
|
+
* the smoke test substitute in-memory implementations of the file-system-backed
|
|
54
|
+
* modules, and production callers should not pass them — the defaults match the
|
|
55
|
+
* standalone scaffold.
|
|
54
56
|
*/
|
|
55
57
|
export interface BuildModulesOverrides {
|
|
58
|
+
/**
|
|
59
|
+
* #473: the name this deployment selected its configuration by —
|
|
60
|
+
* `CONFIG_ENV || NODE_ENV`, computed once in `app.mts` and passed here so
|
|
61
|
+
* the Redis federation-token store's `allow-plaintext` guard reads the
|
|
62
|
+
* environment the config actually came from, not `NODE_ENV` alone. Omitted,
|
|
63
|
+
* the guard falls back to `NODE_ENV` (and `deployment.mode`, which it reads
|
|
64
|
+
* off the config either way).
|
|
65
|
+
*/
|
|
66
|
+
readonly environment?: string;
|
|
56
67
|
readonly keyStoreModule?: Module;
|
|
57
68
|
readonly repositoriesModule?: Module;
|
|
58
69
|
readonly storesModule?: Module;
|
|
@@ -117,6 +128,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
117
128
|
// endpoint would answer 200 while the token kept working. The switch here is
|
|
118
129
|
// only over WHICH denylist.
|
|
119
130
|
const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
|
|
131
|
+
// #456: adapter switch for the federation token store. `"memory"` by
|
|
132
|
+
// default, the template's local-dev shape; `"redis"` mounts
|
|
133
|
+
// `redisFederationTokenStoreModule` off the shared socket — what the README
|
|
134
|
+
// promised and what the previous factory-based module never delivered: it
|
|
135
|
+
// built the Redis store without a client and failed at boot.
|
|
136
|
+
const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
|
|
120
137
|
|
|
121
138
|
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
122
139
|
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
@@ -154,12 +171,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
154
171
|
rateLimiterAdapter === "redis" ||
|
|
155
172
|
userSessionStoresAdapter === "redis" ||
|
|
156
173
|
codeRepositoryAdapter === "redis" ||
|
|
157
|
-
accessTokenDenylistAdapter === "redis"
|
|
174
|
+
accessTokenDenylistAdapter === "redis" ||
|
|
175
|
+
federationTokenStoreAdapter === "redis";
|
|
158
176
|
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
177
|
+
// The four user-session stores switch on `userSessionStores.adapter`; the
|
|
178
|
+
// federation-token store is always wired and switches on its own key
|
|
179
|
+
// below (#456). Pre-Wave-5d the redis branch dropped the
|
|
180
|
+
// federation-token-store provider entirely (Copilot review on PR #121);
|
|
181
|
+
// splitting `storesModule` fixed that boot failure.
|
|
163
182
|
const sessionStoresModules: Module[] =
|
|
164
183
|
userSessionStoresAdapter === "redis"
|
|
165
184
|
? [redisSessionStoresModule]
|
|
@@ -188,13 +207,32 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
188
207
|
? [redisAccessTokenDenylistModule]
|
|
189
208
|
: [memoryAccessTokenDenylistModule];
|
|
190
209
|
|
|
210
|
+
// #455 / #456: mutually-exclusive federation-token-store pair, the same
|
|
211
|
+
// shape as the session stores. One module per adapter, so the memory one
|
|
212
|
+
// can declare `replicaSafety` on its manifest and the Redis one can
|
|
213
|
+
// `require` its client slot. The previous single module chose the adapter
|
|
214
|
+
// at runtime under one name — the replica guard could not tell the two
|
|
215
|
+
// apart, and the Redis branch had no client to hand the builder.
|
|
216
|
+
//
|
|
217
|
+
// #473: the Redis module is built for this composition root so its
|
|
218
|
+
// plaintext guard knows which environment selected the config; it reads
|
|
219
|
+
// `deployment.mode` off the config itself.
|
|
220
|
+
const federationTokenStoreModules: Module[] =
|
|
221
|
+
federationTokenStoreAdapter === "redis"
|
|
222
|
+
? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
|
|
223
|
+
: [inMemoryFederationTokenStoreModule];
|
|
224
|
+
|
|
191
225
|
return [
|
|
192
226
|
// D-5: sessionStoreModule wires the express-session middleware into the
|
|
193
227
|
// boot-planner-managed lifecycle. **Mount order is enforced by this
|
|
194
228
|
// list position (declarationIndex tie-breaking)** — the module
|
|
195
229
|
// intentionally has no `before`/`after` clause, so it MUST be listed
|
|
196
230
|
// ahead of every session-consuming module here. Do not reorder.
|
|
197
|
-
|
|
231
|
+
//
|
|
232
|
+
// #474: built from `config` rather than the static manifest, so that
|
|
233
|
+
// `session.storage.type = "memory"` declares itself replica-unsafe and
|
|
234
|
+
// `deployment.mode = "multi"` refuses it by name like the other stores.
|
|
235
|
+
sessionStoreModuleFor(config),
|
|
198
236
|
oauthModule({ config }),
|
|
199
237
|
oauthSessionModule({ config }),
|
|
200
238
|
oauthAuthorizationModule({ config }),
|
|
@@ -220,11 +258,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
220
258
|
// actually needs Redis. Memory-only deployments skip this so they
|
|
221
259
|
// don't open an unused socket.
|
|
222
260
|
...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
|
|
223
|
-
// Federation-token store
|
|
224
|
-
// `userSessionStores.adapter` switch
|
|
225
|
-
// pre-Wave-5d
|
|
226
|
-
//
|
|
227
|
-
|
|
261
|
+
// Federation-token store: redis (multi-replica) or memory (dev). Always
|
|
262
|
+
// wired, independent of the `userSessionStores.adapter` switch — it was
|
|
263
|
+
// bundled into `storesModule` pre-Wave-5d, and the redis session-stores
|
|
264
|
+
// branch dropped the provider.
|
|
265
|
+
...federationTokenStoreModules,
|
|
228
266
|
// User-session-store family: redis (multi-replica) or memory (dev).
|
|
229
267
|
...sessionStoresModules,
|
|
230
268
|
// OAuth-endpoint rate limiter: redis (shared counters) or memory.
|