@o3co/create-auth-provider 0.17.0-rc.3 → 0.17.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 (37) hide show
  1. package/package.json +3 -3
  2. package/templates/standalone/README.ja.md +5 -4
  3. package/templates/standalone/README.md +23 -4
  4. package/templates/standalone/config/clients.yaml.example +11 -5
  5. package/templates/standalone/config/reference.conf +5 -2
  6. package/templates/standalone/package.json +7 -6
  7. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +6 -10
  8. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +2 -2
  9. package/templates/standalone/src/__tests__/audit-sink.test.mts +13 -4
  10. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +64 -0
  11. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +14 -12
  12. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +7 -4
  13. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +7 -4
  14. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +168 -0
  15. package/templates/standalone/src/__tests__/google-session.fixture.mts +17 -7
  16. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +13 -12
  17. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
  18. package/templates/standalone/src/__tests__/own-modules.test.mts +3 -35
  19. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
  20. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
  21. package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
  22. package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
  23. package/templates/standalone/src/__tests__/sections.test.mts +15 -3
  24. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
  25. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
  26. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
  27. package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
  28. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
  29. package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
  30. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
  31. package/templates/standalone/src/app.mts +2 -11
  32. package/templates/standalone/src/modules.mts +9 -3
  33. package/templates/standalone/src/sections.mts +3 -2
  34. package/templates/standalone/src/securityHeaders.mts +36 -0
  35. package/templates/standalone/tests/package-lock.json +95 -95
  36. package/templates/standalone/tests/package.json +1 -1
  37. package/templates/versions.json +18 -18
@@ -0,0 +1,98 @@
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
+ * A TCP stand-in for a Redis server, for the tests that boot the template
19
+ * against a socket and need no real server: it records what each connection
20
+ * sends and answers only what boot needs — the handshake (`HELLO` refused, so
21
+ * the client speaks RESP2), `INFO` reporting `maxmemory-policy noeviction`,
22
+ * and `+OK` to anything else. It stores nothing and is not a Redis for data:
23
+ * a test that reads or writes a store needs a real server.
24
+ */
25
+
26
+ import { once } from "node:events";
27
+ import { type AddressInfo, createServer, type Socket } from "node:net";
28
+
29
+ /** The first command `buffer` holds in full, as RESP sends it, and its length; `undefined` until it does. */
30
+ function nextCommand(
31
+ buffer: string,
32
+ ): { readonly name: string; readonly length: number } | undefined {
33
+ const header = /^\*(\d+)\r\n/.exec(buffer);
34
+ if (header === null) return undefined;
35
+ let at = header[0].length;
36
+ const args: string[] = [];
37
+ for (let i = 0; i < Number(header[1]); i++) {
38
+ const bulk = /^\$(\d+)\r\n/.exec(buffer.slice(at));
39
+ if (bulk === null) return undefined;
40
+ const start = at + bulk[0].length;
41
+ const end = start + Number(bulk[1]);
42
+ if (buffer.length < end + 2) return undefined;
43
+ args.push(buffer.slice(start, end));
44
+ at = end + 2;
45
+ }
46
+ return { name: (args[0] ?? "").toLowerCase(), length: at };
47
+ }
48
+
49
+ /** What a server that runs `noeviction` and speaks RESP2 answers `name`. */
50
+ function replyTo(name: string): string {
51
+ if (name === "hello") return "-NOPROTO unsupported protocol version\r\n";
52
+ if (name === "info") {
53
+ const info =
54
+ "# Memory\r\nmaxmemory_policy:noeviction\r\n# Persistence\r\naof_enabled:1\r\nloading:0\r\n";
55
+ return `$${Buffer.byteLength(info)}\r\n${info}\r\n`;
56
+ }
57
+ return "+OK\r\n";
58
+ }
59
+
60
+ /**
61
+ * A TCP server standing in for Redis: it records what each connection sends,
62
+ * and answers as a server running `noeviction` does — `HELLO` refused, so the
63
+ * client speaks RESP2, `INFO` with the policy, anything else `OK` — so a
64
+ * client dialled at it writes its handshake and the stores whose factories
65
+ * read the policy build.
66
+ */
67
+ export async function listeningRedis(): Promise<{
68
+ readonly port: number;
69
+ readonly received: () => string;
70
+ readonly close: () => Promise<void>;
71
+ }> {
72
+ const sockets = new Set<Socket>();
73
+ let received = "";
74
+ const server = createServer((socket) => {
75
+ sockets.add(socket);
76
+ let pending = "";
77
+ socket.on("close", () => sockets.delete(socket));
78
+ socket.on("data", (chunk) => {
79
+ received += chunk.toString("utf8");
80
+ pending += chunk.toString("utf8");
81
+ for (let command = nextCommand(pending); command; command = nextCommand(pending)) {
82
+ pending = pending.slice(command.length);
83
+ socket.write(replyTo(command.name));
84
+ }
85
+ });
86
+ });
87
+ server.listen(0, "127.0.0.1");
88
+ await once(server, "listening");
89
+ const { port } = server.address() as AddressInfo;
90
+ return {
91
+ port,
92
+ received: () => received,
93
+ close: async () => {
94
+ for (const socket of sockets) socket.destroy();
95
+ await new Promise<void>((resolve) => server.close(() => resolve()));
96
+ },
97
+ };
98
+ }
@@ -56,6 +56,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
56
56
  import { buildModules } from "../buildModules.mjs";
57
57
  import { resolveConfigPaths } from "../configPath.mjs";
58
58
  import { templateReference } from "../modules.mjs";
59
+ import { keyStoreSectionSchema } from "../sections.mjs";
59
60
  import {
60
61
  type BothPhases,
61
62
  bothPhasesOf,
@@ -198,16 +199,18 @@ const testRepositoriesModule = defineModule({
198
199
  },
199
200
  });
200
201
 
202
+ // The template's `key-store` section, read by its own schema, without the
203
+ // section's relocation bridge.
201
204
  const testKeyStoreModule = defineModule({
202
- name: "test:key-store",
203
- requires: ["config"] as const,
205
+ name: "key-store",
206
+ section: { schema: keyStoreSectionSchema },
204
207
  provides: {
205
- keyStore: async ({ config: c }) => {
208
+ keyStore: async ({ section }) => {
206
209
  const factory = createKeyStoreFactory();
207
210
  registerBuiltinKeyStores(factory);
208
211
  return factory.create({
209
212
  type: "local",
210
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
213
+ ...(section.local ?? {}),
211
214
  });
212
215
  },
213
216
  },
@@ -0,0 +1,119 @@
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 repositories module builds its user repository with the composition's
19
+ * logger: the yaml user builder's `user_repository_in_memory` line reaches the
20
+ * `logger` bootstrap component (the template's pino logger, at its level),
21
+ * not the console.
22
+ */
23
+
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import {
28
+ type BootstrapMap,
29
+ createApp,
30
+ defineModule,
31
+ type Logger,
32
+ type UserRepository,
33
+ } from "@o3co/auth-provider-core";
34
+ import {
35
+ CORE_RELOCATIONS,
36
+ makeValidCoreConfig,
37
+ renamedVariableCaptures,
38
+ } from "@o3co/auth-provider-core/testing";
39
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
40
+ import { repositoriesModuleFor } from "../modules.mjs";
41
+
42
+ const spyLogger = () => {
43
+ const logger = {
44
+ trace: vi.fn(),
45
+ debug: vi.fn(),
46
+ info: vi.fn(),
47
+ warn: vi.fn(),
48
+ error: vi.fn(),
49
+ fatal: vi.fn(),
50
+ child: vi.fn(),
51
+ };
52
+ logger.child.mockReturnValue(logger);
53
+ return logger;
54
+ };
55
+
56
+ describe("the repositories module's user repository", () => {
57
+ let dir: string;
58
+ beforeEach(() => {
59
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "repositories-logger-"));
60
+ });
61
+ afterEach(() => {
62
+ fs.rmSync(dir, { recursive: true, force: true });
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ it("warns user_repository_in_memory on the composition's logger, built through createApp, and not on the console", async () => {
67
+ const usersPath = path.join(dir, "users.yaml");
68
+ fs.writeFileSync(usersPath, 'alice:\n password: "plainpass"\n');
69
+ const clientsPath = path.join(dir, "clients.yaml");
70
+ fs.writeFileSync(clientsPath, "");
71
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ const logger = spyLogger();
73
+ let built: UserRepository | undefined;
74
+ const reader = defineModule({
75
+ name: "test:user-repository-reader",
76
+ requires: ["userRepository"] as const,
77
+ provides: {
78
+ userRepositoryRead: ({ userRepository }: { userRepository: UserRepository }) => {
79
+ built = userRepository;
80
+ return true;
81
+ },
82
+ },
83
+ lifecycle: { userRepositoryRead: { eager: true } },
84
+ } as never);
85
+
86
+ const repositories = repositoriesModuleFor({ client: "yaml", user: "yaml" });
87
+ const handle = await createApp({
88
+ modules: [repositories, reader],
89
+ bootstrapComponents: {
90
+ config: {
91
+ ...makeValidCoreConfig(),
92
+ repositories: {
93
+ client: { yaml: { path: clientsPath } },
94
+ user: { yaml: { path: usersPath }, http: {} },
95
+ },
96
+ // What a resolution with none of the renamed variables set captures.
97
+ "renamed-variables": renamedVariableCaptures({
98
+ modules: [repositories],
99
+ core: CORE_RELOCATIONS,
100
+ env: {},
101
+ }),
102
+ },
103
+ pathResolver: (p: string) => p,
104
+ logger: logger as unknown as Logger,
105
+ } as unknown as BootstrapMap,
106
+ });
107
+ try {
108
+ expect((await built?.authenticate("alice", "plainpass"))?.username).toBe("alice");
109
+ expect(
110
+ logger.warn.mock.calls.filter(([, event]) => event === "user_repository_in_memory"),
111
+ ).toEqual([[{ store: "userRepository", adapter: "yaml" }, "user_repository_in_memory"]]);
112
+ expect(
113
+ consoleWarn.mock.calls.filter((args) => args.includes("user_repository_in_memory")),
114
+ ).toEqual([]);
115
+ } finally {
116
+ await handle.dispose();
117
+ }
118
+ });
119
+ });
@@ -440,12 +440,20 @@ describe("key-store", () => {
440
440
 
441
441
  describe("redis-clients", () => {
442
442
  it("accepts a URL, and a password beside it", () => {
443
- expect(redisClientsSectionSchema.parse({ url: "redis://r:6379", password: "p" })).toEqual({
443
+ expect(
444
+ redisClientsSectionSchema.parse({
445
+ url: "redis://r:6379",
446
+ password: "p",
447
+ assumeNoEviction: false,
448
+ }),
449
+ ).toEqual({
444
450
  url: "redis://r:6379",
445
451
  password: "p",
446
452
  assumeNoEviction: false,
447
453
  });
448
- expect(redisClientsSectionSchema.parse({ url: "redis://r:6379" })).toEqual({
454
+ expect(
455
+ redisClientsSectionSchema.parse({ url: "redis://r:6379", assumeNoEviction: false }),
456
+ ).toEqual({
449
457
  url: "redis://r:6379",
450
458
  assumeNoEviction: false,
451
459
  });
@@ -476,7 +484,11 @@ describe("redis-clients", () => {
476
484
  it.each([
477
485
  ["no URL", {}],
478
486
  ["a null URL", { url: null }],
479
- ["a key the section does not declare", { url: "redis://r:6379", db: 2 }],
487
+ [
488
+ "a key the section does not declare",
489
+ { url: "redis://r:6379", db: 2, assumeNoEviction: false },
490
+ ],
491
+ ["no assumeNoEviction, which the template's reference.conf ships", { url: "redis://r:6379" }],
480
492
  ])("refuses %s", (_what, section) => {
481
493
  expect(redisClientsSectionSchema.safeParse(section).success).toBe(false);
482
494
  });
@@ -41,6 +41,7 @@ import {
41
41
  WEB,
42
42
  webTokens,
43
43
  } from "./all-modules-composition.fixture.mjs";
44
+ import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
44
45
 
45
46
  afterEach(() => {
46
47
  vi.restoreAllMocks();
@@ -68,7 +69,9 @@ describe("the template, which composes the session lifecycle module", () => {
68
69
  });
69
70
 
70
71
  it("tells a relying party that joined, through the oauth module's notifier, when the session closes", async () => {
72
+ const peer = await backchannelPeer();
71
73
  const { handle } = await compose({
74
+ config: withLoopbackRelyingParties,
72
75
  extraClients: {
73
76
  "rp-bc": {
74
77
  tokenEndpointAuthMethod: "client_secret_basic",
@@ -76,7 +79,7 @@ describe("the template, which composes the session lifecycle module", () => {
76
79
  allowedRedirectUris: ["https://rp-bc.test/cb"],
77
80
  allowedScopes: ["openid"],
78
81
  allowedGrantTypes: ["authorization_code"],
79
- backchannelLogoutUri: "https://rp-bc.test/logout",
82
+ backchannelLogoutUri: peer.uri,
80
83
  },
81
84
  },
82
85
  });
@@ -99,7 +102,7 @@ describe("the template, which composes the session lifecycle module", () => {
99
102
  });
100
103
  const rp = {
101
104
  clientId: "rp-bc",
102
- backchannelLogoutUri: "https://rp-bc.test/logout",
105
+ backchannelLogoutUri: peer.uri,
103
106
  backchannelLogoutSessionRequired: true,
104
107
  frontchannelLogoutUri: undefined,
105
108
  frontchannelLogoutSessionRequired: undefined,
@@ -108,15 +111,9 @@ describe("the template, which composes the session lifecycle module", () => {
108
111
  expect(await lifecycle.join("sid-bc", { rp, familyId: "family-bc" })).toEqual({
109
112
  outcome: "joined",
110
113
  });
111
- const posted = vi
112
- .spyOn(globalThis, "fetch")
113
- .mockImplementation(async () => new Response(null, { status: 200 }));
114
114
  expect((await lifecycle.close("sid-bc", "rp_logout")).outcome).toBe("done");
115
- expect(posted).toHaveBeenCalledTimes(1);
116
- const [url, init] = posted.mock.calls[0] as [string, RequestInit];
117
- expect(url).toBe("https://rp-bc.test/logout");
118
- const token = new URLSearchParams(String(init.body)).get("logout_token");
119
- expect(claimsOf(token ?? "")).toMatchObject({
115
+ expect(peer.tokens).toHaveLength(1);
116
+ expect(claimsOf(peer.tokens[0] ?? "")).toMatchObject({
120
117
  iss: ISSUER,
121
118
  aud: "rp-bc",
122
119
  sub: "u-bc",
@@ -124,6 +121,7 @@ describe("the template, which composes the session lifecycle module", () => {
124
121
  });
125
122
  } finally {
126
123
  await handle.dispose();
124
+ await peer.close();
127
125
  }
128
126
  });
129
127
 
@@ -48,6 +48,7 @@ import request from "supertest";
48
48
  import { afterEach, describe, expect, it } from "vitest";
49
49
  import { buildModules } from "../buildModules.mjs";
50
50
  import type { Switches } from "../configPath.mjs";
51
+ import { keyStoreSectionSchema } from "../sections.mjs";
51
52
  import { capturedRenames, inProcessAdapters } from "./library-references.fixture.mjs";
52
53
 
53
54
  const keyPair = generateKeyPairSync("ed25519", {
@@ -117,6 +118,12 @@ const config: AppConfig & Record<string, unknown> = {
117
118
  },
118
119
  rateLimit: { failMode: "open" },
119
120
  "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
121
+ // The in-process limiter's section as core's `reference.conf` ships it.
122
+ "core-rate-limiter-memory": {
123
+ maxBuckets: 10000,
124
+ defaultLimit: { limit: 60, windowSeconds: 60 },
125
+ limits: {},
126
+ },
120
127
  } as unknown as AppConfig;
121
128
 
122
129
  /** What phase one hands `buildModules`: the configuration, and every store in process. */
@@ -155,16 +162,18 @@ const testRepositoriesModule = defineModule({
155
162
  } as never,
156
163
  });
157
164
 
165
+ // The template's `key-store` section, read by its own schema, without the
166
+ // section's relocation bridge.
158
167
  const testKeyStoreModule = defineModule({
159
- name: "test:key-store",
160
- requires: ["config"] as const,
168
+ name: "key-store",
169
+ section: { schema: keyStoreSectionSchema },
161
170
  provides: {
162
- keyStore: async ({ config: c }) => {
171
+ keyStore: async ({ section }) => {
163
172
  const factory = createKeyStoreFactory();
164
173
  registerBuiltinKeyStores(factory);
165
174
  return factory.create({
166
175
  type: "local",
167
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
176
+ ...(section.local ?? {}),
168
177
  });
169
178
  },
170
179
  },
@@ -31,6 +31,7 @@ import {
31
31
  import request from "supertest";
32
32
  import { afterEach, describe, expect, it, vi } from "vitest";
33
33
  import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
34
+ import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
34
35
 
35
36
  afterEach(() => {
36
37
  vi.restoreAllMocks();
@@ -49,7 +50,9 @@ const csrfTokenOf = (cookies: readonly string[]): string => {
49
50
 
50
51
  describe("POST /session/logout with the session lifecycle module", () => {
51
52
  it("revokes the session's families, tells its relying parties and closes its record", async () => {
53
+ const peer = await backchannelPeer();
52
54
  const { app, handle } = await compose({
55
+ config: withLoopbackRelyingParties,
53
56
  extraClients: {
54
57
  "rp-bc": {
55
58
  tokenEndpointAuthMethod: "client_secret_basic",
@@ -57,7 +60,7 @@ describe("POST /session/logout with the session lifecycle module", () => {
57
60
  allowedRedirectUris: ["https://rp-bc.test/cb"],
58
61
  allowedScopes: ["openid"],
59
62
  allowedGrantTypes: ["authorization_code"],
60
- backchannelLogoutUri: "https://rp-bc.test/logout",
63
+ backchannelLogoutUri: peer.uri,
61
64
  },
62
65
  },
63
66
  });
@@ -76,7 +79,7 @@ describe("POST /session/logout with the session lifecycle module", () => {
76
79
  await lifecycle.join(sid, {
77
80
  rp: {
78
81
  clientId: "rp-bc",
79
- backchannelLogoutUri: "https://rp-bc.test/logout",
82
+ backchannelLogoutUri: peer.uri,
80
83
  backchannelLogoutSessionRequired: true,
81
84
  frontchannelLogoutUri: undefined,
82
85
  frontchannelLogoutSessionRequired: undefined,
@@ -84,10 +87,6 @@ describe("POST /session/logout with the session lifecycle module", () => {
84
87
  },
85
88
  }),
86
89
  ).toEqual({ outcome: "joined" });
87
- const posted = vi
88
- .spyOn(globalThis, "fetch")
89
- .mockImplementation(async () => new Response(null, { status: 200 }));
90
-
91
90
  const csrfToken = csrfTokenOf(cookies);
92
91
  const res = await request(app)
93
92
  .post("/session/logout")
@@ -96,12 +95,13 @@ describe("POST /session/logout with the session lifecycle module", () => {
96
95
 
97
96
  expect(res.status).toBe(200);
98
97
  expect(await families.isFamilyRevoked(familyId)).toBe(true);
99
- expect(posted.mock.calls.map(([url]) => String(url))).toEqual(["https://rp-bc.test/logout"]);
98
+ expect(peer.tokens).toHaveLength(1);
100
99
  expect(await sessions.get(sid)).toBeNull();
101
100
  const store = components.sessionLifecycleStore as SessionLifecycleStore;
102
101
  expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
103
102
  } finally {
104
103
  await handle.dispose();
104
+ await peer.close();
105
105
  }
106
106
  });
107
107
 
@@ -36,7 +36,7 @@ import request from "supertest";
36
36
  import { afterEach, describe, expect, it } from "vitest";
37
37
  import { buildModules } from "../buildModules.mjs";
38
38
  import type { Switches } from "../configPath.mjs";
39
- import type { Adapters } from "../sections.mjs";
39
+ import { type Adapters, keyStoreSectionSchema } from "../sections.mjs";
40
40
  import {
41
41
  capturedRenames,
42
42
  inProcessAdapters,
@@ -110,6 +110,12 @@ const config: AppConfig & Record<string, unknown> = {
110
110
  },
111
111
  rateLimit: { failMode: "open" },
112
112
  "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
113
+ // The in-process limiter's section as core's `reference.conf` ships it.
114
+ "core-rate-limiter-memory": {
115
+ maxBuckets: 10000,
116
+ defaultLimit: { limit: 60, windowSeconds: 60 },
117
+ limits: {},
118
+ },
113
119
  };
114
120
 
115
121
  /** What phase one hands `buildModules`: the configuration, with `changes` over every store in process. */
@@ -165,16 +171,18 @@ const testRepositoriesModule = defineModule({
165
171
  },
166
172
  });
167
173
 
174
+ // The template's `key-store` section, read by its own schema, without the
175
+ // section's relocation bridge.
168
176
  const testKeyStoreModule = defineModule({
169
- name: "test:key-store",
170
- requires: ["config"] as const,
177
+ name: "key-store",
178
+ section: { schema: keyStoreSectionSchema },
171
179
  provides: {
172
- keyStore: async ({ config: c }) => {
180
+ keyStore: async ({ section }) => {
173
181
  const factory = createKeyStoreFactory();
174
182
  registerBuiltinKeyStores(factory);
175
183
  return factory.create({
176
184
  type: "local",
177
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
185
+ ...(section.local ?? {}),
178
186
  });
179
187
  },
180
188
  },
@@ -28,7 +28,8 @@ import { createRequire } from "node:module";
28
28
  import { tmpdir } from "node:os";
29
29
  import { join } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
- import { describe, expect, it } from "vitest";
31
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
32
+ import { listeningRedis } from "./redis-stand-in.fixture.mjs";
32
33
 
33
34
  const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
34
35
  const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
@@ -45,14 +46,29 @@ const clientsFile = (() => {
45
46
  return file;
46
47
  })();
47
48
 
49
+ /**
50
+ * The Redis the refresh-token family store's shared socket dials: a stand-in
51
+ * that reports `noeviction`, so the store passes its eviction gate and the run
52
+ * reaches no real server.
53
+ */
54
+ let redis: Awaited<ReturnType<typeof listeningRedis>>;
55
+
56
+ beforeAll(async () => {
57
+ redis = await listeningRedis();
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await redis?.close();
62
+ });
63
+
48
64
  /**
49
65
  * Development, with every store the `adapters` section selects in memory.
50
66
  * The refresh-token family store has no memory choice in the template; its
51
- * shared Redis socket points at a port nothing answers, so the run reaches no
52
- * real server. Nothing is inherited from the test runner's environment but
53
- * PATH, so its NODE_ENV and LOGGING_LEVEL do not reach the child.
67
+ * shared Redis socket points at the stand-in above. Nothing is inherited from
68
+ * the test runner's environment but PATH, so its NODE_ENV and LOGGING_LEVEL do
69
+ * not reach the child.
54
70
  */
55
- const ENV: Readonly<Record<string, string>> = {
71
+ const env = (): Readonly<Record<string, string>> => ({
56
72
  PATH: process.env.PATH ?? "",
57
73
  CONFIG_ENV: "development",
58
74
  NODE_ENV: "development",
@@ -74,11 +90,11 @@ const ENV: Readonly<Record<string, string>> = {
74
90
  ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
75
91
  ADAPTERS_MFA_FACTOR_STORE: "memory",
76
92
  ADAPTERS_MFA_TRANSACTION_STORE: "memory",
77
- REDIS_CLIENTS_URL: "redis://127.0.0.1:9",
93
+ REDIS_CLIENTS_URL: `redis://127.0.0.1:${redis.port}`,
78
94
  REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
79
95
  REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "http://127.0.0.1:9/authenticate",
80
96
  REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "http://127.0.0.1:9/authenticate-by-token",
81
- };
97
+ });
82
98
 
83
99
  interface Run {
84
100
  readonly stdout: string;
@@ -91,7 +107,7 @@ function startAndStop(timeoutMs: number): Promise<Run> {
91
107
  return new Promise((resolve, reject) => {
92
108
  const child: ChildProcess = spawn(process.execPath, [tsxCli, "src/app.mts"], {
93
109
  cwd: templateRoot,
94
- env: ENV,
110
+ env: env(),
95
111
  stdio: ["ignore", "pipe", "pipe"],
96
112
  });
97
113
  let stdout = "";
@@ -48,7 +48,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
48
48
  import { buildModules } from "../buildModules.mjs";
49
49
  import { resolveConfigPaths } from "../configPath.mjs";
50
50
  import { repositoriesModuleFor, templateReference } from "../modules.mjs";
51
- import { repositoriesSectionSchema } from "../sections.mjs";
51
+ import { keyStoreSectionSchema, repositoriesSectionSchema } from "../sections.mjs";
52
52
  import {
53
53
  type BothPhases,
54
54
  bothPhasesOf,
@@ -186,16 +186,18 @@ describe("a token the Store refuses, seen from outside the booted app", () => {
186
186
  ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
187
187
  };
188
188
 
189
+ // The template's `key-store` section, read by its own schema, without the
190
+ // section's relocation bridge.
189
191
  const testKeyStoreModule = defineModule({
190
- name: "test:key-store",
191
- requires: ["config"] as const,
192
+ name: "key-store",
193
+ section: { schema: keyStoreSectionSchema },
192
194
  provides: {
193
- keyStore: async ({ config: c }) => {
195
+ keyStore: async ({ section }) => {
194
196
  const factory = createKeyStoreFactory();
195
197
  registerBuiltinKeyStores(factory);
196
198
  return factory.create({
197
199
  type: "local",
198
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
200
+ ...(section.local ?? {}),
199
201
  });
200
202
  },
201
203
  },
@@ -50,6 +50,7 @@ import { afterEach, describe, expect, it } from "vitest";
50
50
  import { buildModules } from "#/buildModules.mjs";
51
51
  import { resolveConfigPaths } from "#/configPath.mjs";
52
52
  import { templateReference } from "../modules.mjs";
53
+ import { keyStoreSectionSchema } from "../sections.mjs";
53
54
  import {
54
55
  type BothPhases,
55
56
  bothPhasesOf,
@@ -181,16 +182,18 @@ const testRepositoriesModule = defineModule({
181
182
  } as never,
182
183
  });
183
184
 
185
+ // The template's `key-store` section, read by its own schema, without the
186
+ // section's relocation bridge.
184
187
  const testKeyStoreModule = defineModule({
185
- name: "test:key-store",
186
- requires: ["config"] as const,
188
+ name: "key-store",
189
+ section: { schema: keyStoreSectionSchema },
187
190
  provides: {
188
- keyStore: async ({ config: c }) => {
191
+ keyStore: async ({ section }) => {
189
192
  const factory = createKeyStoreFactory();
190
193
  registerBuiltinKeyStores(factory);
191
194
  return factory.create({
192
195
  type: "local",
193
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
196
+ ...(section.local ?? {}),
194
197
  });
195
198
  },
196
199
  },
@@ -16,7 +16,6 @@
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createApp } from "@o3co/auth-provider-core";
18
18
  import express from "express";
19
- import helmet from "helmet";
20
19
  import { buildModules } from "./buildModules.mjs";
21
20
  import {
22
21
  configDefaultsFor,
@@ -31,6 +30,7 @@ import { listen } from "./listen.mjs";
31
30
  import { createAppLogger } from "./logger.mjs";
32
31
  import { createMetrics } from "./metrics.mjs";
33
32
  import { mountRoutes } from "./routes.mjs";
33
+ import { securityHeaders } from "./securityHeaders.mjs";
34
34
  import { installGracefulShutdown } from "./shutdown.mjs";
35
35
 
36
36
  // Step 1: the configuration, in two phases (`configPath.mts`; template
@@ -57,16 +57,7 @@ await (async (): Promise<void> => {
57
57
  // `trust proxy` is set from the `http` module's `httpSettings` once boot
58
58
  // has it (step 3), before a request can arrive.
59
59
  const app = express();
60
- app.use(
61
- helmet({
62
- contentSecurityPolicy: {
63
- directives: {
64
- defaultSrc: ["'none'"],
65
- frameAncestors: ["'none'"],
66
- },
67
- },
68
- }),
69
- );
60
+ app.use(securityHeaders());
70
61
 
71
62
  // Timing middleware goes ahead of everything so the histogram covers the
72
63
  // whole stack — including responses produced by middleware that short-circuits