@o3co/create-auth-provider 0.9.0 → 0.11.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/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +110 -7
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +49 -5
  6. package/templates/standalone/Dockerfile +59 -16
  7. package/templates/standalone/README.ja.md +92 -13
  8. package/templates/standalone/README.md +358 -18
  9. package/templates/standalone/config/application.conf +132 -4
  10. package/templates/standalone/config/clients.yaml.example +56 -2
  11. package/templates/standalone/config/production.conf +9 -3
  12. package/templates/standalone/docker-compose.production.yml +132 -0
  13. package/templates/standalone/docker-compose.yml +7 -0
  14. package/templates/standalone/gitignore +21 -0
  15. package/templates/standalone/package.json +15 -20
  16. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
  18. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  19. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  20. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  21. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
  22. package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
  23. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  24. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  25. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  26. package/templates/standalone/src/app.mts +78 -10
  27. package/templates/standalone/src/buildModules.mts +43 -1
  28. package/templates/standalone/src/configPath.mts +2 -2
  29. package/templates/standalone/src/logger.mts +90 -2
  30. package/templates/standalone/src/metrics.mts +190 -0
  31. package/templates/standalone/src/modules.mts +319 -33
  32. package/templates/standalone/src/shutdown.mts +162 -0
  33. package/templates/standalone/src/terminalError.mts +60 -0
  34. package/templates/standalone/tests/index.test.js +69 -5
  35. package/templates/standalone/tsconfig.json +9 -1
  36. package/templates/standalone/vitest.config.mts +8 -0
  37. package/templates/versions.json +12 -11
@@ -14,11 +14,14 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
+ import { generateKeyPairSync } from "node:crypto";
17
18
  import { readFileSync } from "node:fs";
18
19
  import {
19
20
  type AppConfig,
20
21
  createApp,
22
+ createHealthcheckRouter,
21
23
  createKeyStoreFactory,
24
+ createReadinessRouter,
22
25
  defineModule,
23
26
  generateToken,
24
27
  InMemoryClientRepository,
@@ -33,17 +36,28 @@ import { buildModules } from "../buildModules.mjs";
33
36
 
34
37
  const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
35
38
 
39
+ // #282: the template's shipped default is EdDSA with a published JWKS, so the
40
+ // smoke test signs the way a real deployment of this scaffold does. Generated
41
+ // per run — no key material is committed.
42
+ const smokeKeyPair = generateKeyPairSync("ed25519", {
43
+ publicKeyEncoding: { type: "spki", format: "pem" },
44
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
45
+ });
46
+
36
47
  const config: AppConfig = {
37
- http: { port: 0, trustProxy: false },
48
+ http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
49
+ logging: { level: "silent" },
38
50
  oauth: {
39
51
  jwt: {
52
+ issuer: "https://auth.test",
40
53
  signingKey: {
41
54
  provider: "local",
42
55
  local: {
43
- algorithm: "HS256",
44
- secret: "test-secret-for-smoke-test",
56
+ algorithm: "EdDSA",
45
57
  kid: "v0",
46
- previousSecrets: [],
58
+ privateKey: smokeKeyPair.privateKey,
59
+ publicKey: smokeKeyPair.publicKey,
60
+ previousKeys: [],
47
61
  },
48
62
  },
49
63
  },
@@ -55,6 +69,8 @@ const config: AppConfig = {
55
69
  },
56
70
  grants: {},
57
71
  oidcMode: "oidc-required",
72
+ // #330: no `authorize` section — the `allowUnmarkedClients` migration
73
+ // flag was removed; the first-party invariant is unconditional.
58
74
  // OR-9: explicit memory adapter for the smoke test. The legacy
59
75
  // `repositories.code.type` fallback would also pick memory here,
60
76
  // but the explicit setting documents the intent and avoids
@@ -62,7 +78,8 @@ const config: AppConfig = {
62
78
  code: { adapter: "memory" as const },
63
79
  },
64
80
  session: {
65
- secret: "test-session-secret",
81
+ // #282: `session.secret` carries a 256-bit entropy floor.
82
+ secret: "test-session-secret.at-least-32-bytes.ok",
66
83
  name: "auth.sid",
67
84
  maxAge: 3600000,
68
85
  secure: false,
@@ -173,9 +190,13 @@ describe("standalone smoke test", () => {
173
190
  });
174
191
 
175
192
  const app = express();
176
- app.get("/_healthcheck", (_req, res) => {
177
- res.status(200).json({ status: "ok" });
178
- });
193
+ app.use(createHealthcheckRouter(express));
194
+ app.use(
195
+ createReadinessRouter(express, {
196
+ probes: handle.readinessProbes,
197
+ timeoutMs: config.http.readinessTimeoutMs,
198
+ }),
199
+ );
179
200
  app.use(handle.router);
180
201
  return { app, handle };
181
202
  }
@@ -208,11 +229,34 @@ describe("standalone smoke test", () => {
208
229
  expect(dockerfile).toMatch(
209
230
  /CMD\s+wget\s+-q\s+-O\s+\/dev\/null\s+"http:\/\/localhost:[$]\{HTTP_PORT\}\/_healthcheck"\s+\|\|\s+exit\s+1/,
210
231
  );
211
- expect(dockerfile).toMatch(/FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install/);
232
+ expect(dockerfile).toMatch(
233
+ /FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
234
+ );
212
235
  expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
236
+ // #289: dependencies are resolved exactly once, in `deps`. The runtime
237
+ // stage must take its node_modules from a prune of that same tree — a
238
+ // second `pnpm install` would be a second resolution, so its absence
239
+ // after the runtime FROM is part of the contract.
240
+ expect(dockerfile).toMatch(
241
+ /FROM deps AS prod-deps[\s\S]*USER node[\s\S]*RUN pnpm prune --prod/,
242
+ );
213
243
  expect(dockerfile).toMatch(
214
- /FROM node-base AS runtime[\s\S]*USER node[\s\S]*RUN pnpm install --prod/,
244
+ /FROM node-base AS runtime[\s\S]*COPY --from=prod-deps[\s\S]*USER node/,
215
245
  );
246
+ expect(dockerfile.slice(dockerfile.indexOf("FROM node-base AS runtime"))).not.toContain(
247
+ "pnpm install",
248
+ );
249
+ });
250
+
251
+ it("Dockerfile pins its mutable build inputs (#289)", () => {
252
+ // Base image by digest — the tag alone is a moving pointer, and
253
+ // Dependabot's docker ecosystem bumps tag and digest together.
254
+ expect(dockerfile).toMatch(/FROM node:24-alpine@sha256:[0-9a-f]{64} AS node-base/);
255
+ // Global corepack by version — `npm install -g corepack` with no pin
256
+ // resolved whatever was latest at build time.
257
+ expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
258
+ // The lockfile is a required build input for --frozen-lockfile.
259
+ expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
216
260
  });
217
261
 
218
262
  it("GET /_healthcheck returns 200", async () => {
@@ -222,6 +266,39 @@ describe("standalone smoke test", () => {
222
266
  expect(res.status).toBe(200);
223
267
  });
224
268
 
269
+ it("GET /readyz returns 200 for a memory-only composition with no probes", async () => {
270
+ // These smoke modules are all in-memory, so nothing registers a probe.
271
+ // Readiness must not invent a dependency that is not wired.
272
+ const { app, handle } = await buildApp();
273
+ handleRef = handle;
274
+ const res = await request(app).get("/readyz");
275
+ expect(res.status).toBe(200);
276
+ expect(res.body).toEqual({ status: "ready", checks: [] });
277
+ });
278
+
279
+ it("GET /readyz returns 503 naming the dependency when a probe fails", async () => {
280
+ const { handle } = await buildApp();
281
+ handleRef = handle;
282
+ const app = express();
283
+ app.use(
284
+ createReadinessRouter(express, {
285
+ probes: [
286
+ {
287
+ name: "redis",
288
+ check: async () => {
289
+ throw new Error("ECONNREFUSED");
290
+ },
291
+ },
292
+ ],
293
+ timeoutMs: config.http.readinessTimeoutMs,
294
+ }),
295
+ );
296
+ const res = await request(app).get("/readyz");
297
+ expect(res.status).toBe(503);
298
+ expect(res.body.status).toBe("unready");
299
+ expect(res.body.checks[0].name).toBe("redis");
300
+ });
301
+
225
302
  it("issuer-configured buildModules serves the advertised jwks_uri (discovery <-> JWKS presence contract)", async () => {
226
303
  // Guards the cross-module contract end-to-end against the REAL composition
227
304
  // root: core's `jwksModule` contributes `jwks_uri` to discovery AND mounts
@@ -256,6 +333,12 @@ describe("standalone smoke test", () => {
256
333
  const res = await request(app).get(jwksPath);
257
334
  expect(res.status).toBe(200);
258
335
  expect(Array.isArray(res.body.keys)).toBe(true);
336
+ // #282: presence is no longer enough — the advertised jwks_uri must
337
+ // publish an actual verification key, and never the private half.
338
+ expect(res.body.keys).toHaveLength(1);
339
+ expect(res.body.keys[0].alg).toBe("EdDSA");
340
+ expect(res.body.keys[0].kid).toBe("v0");
341
+ expect(res.body.keys[0].d).toBeUndefined();
259
342
  });
260
343
 
261
344
  it("POST /oauth/token with unsupported grant_type returns 400", async () => {
@@ -386,6 +469,117 @@ describe("standalone smoke test", () => {
386
469
  expect(names).not.toContain("standalone:stores");
387
470
  expect(names).toContain("standalone:redis-clients");
388
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
+ });
508
+ });
509
+
510
+ // #277: the scaffold used to wire NO access-token denylist, so the
511
+ // `/oauth/revoke` it shipped answered 200 for an access token and left the
512
+ // JWT working until expiry. The template now always wires one, and its own
513
+ // application.conf selects the Redis-backed adapter — the memory one forks
514
+ // per replica and `deployment.mode = "multi"` refuses it.
515
+ describe("#277: access-token denylist wiring", () => {
516
+ it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
517
+ const modules = buildModules(config, {
518
+ keyStoreModule: testKeyStoreModule,
519
+ repositoriesModule: testRepositoriesModule,
520
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
521
+ });
522
+ const providesDenylist = modules.filter((m) =>
523
+ Object.keys(m.provides ?? {}).includes("accessTokenDenylist"),
524
+ );
525
+ expect(providesDenylist).toHaveLength(1);
526
+ });
527
+
528
+ it("defaults to the memory denylist when no adapter is configured", () => {
529
+ const modules = buildModules(config, {
530
+ keyStoreModule: testKeyStoreModule,
531
+ repositoriesModule: testRepositoriesModule,
532
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
533
+ });
534
+ const names = modules.map((m) => m.name);
535
+ expect(names).toContain("core-access-token-denylist-memory");
536
+ expect(names).not.toContain("redis-access-token-denylist");
537
+ });
538
+
539
+ it("switches to the Redis denylist when accessTokenDenylist.adapter = 'redis'", () => {
540
+ const redisDenylistConfig = {
541
+ ...config,
542
+ accessTokenDenylist: { adapter: "redis" as const },
543
+ };
544
+ const modules = buildModules(redisDenylistConfig, {
545
+ keyStoreModule: testKeyStoreModule,
546
+ repositoriesModule: testRepositoriesModule,
547
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
548
+ });
549
+ const names = modules.map((m) => m.name);
550
+ expect(names).toContain("redis-access-token-denylist");
551
+ expect(names).not.toContain("core-access-token-denylist-memory");
552
+ // The Redis branch must also pull in the shared ioredis socket, or the
553
+ // `accessTokenDenylistClient` slot has no provider and boot fails on a
554
+ // missing component instead of on the thing the operator changed.
555
+ expect(names).toContain("standalone:redis-clients");
556
+ });
557
+
558
+ it("the shipped application.conf selects the replica-safe adapter", () => {
559
+ // The template's own config is the artifact operators deploy. It runs
560
+ // with `deployment.mode = multi` in the umbrella E2E, which refuses
561
+ // every in-memory shared store — so shipping the memory denylist here
562
+ // would be a boot failure in the very stack that proves the scaffold
563
+ // works.
564
+ const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
565
+ expect(conf).toMatch(/accessTokenDenylist\s*\{[\s\S]*?adapter\s*=\s*"redis"/);
566
+ });
567
+
568
+ it("keeps the composition boot-valid end to end", async () => {
569
+ // The core boot guard (#277 step 13.9) refuses a composition that reads
570
+ // the denylist slot without one. A scaffold that trips its own library's
571
+ // guard is not a scaffold.
572
+ const handle = await createApp({
573
+ modules: buildModules(config, {
574
+ keyStoreModule: testKeyStoreModule,
575
+ repositoriesModule: testRepositoriesModule,
576
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
577
+ }),
578
+ bootstrapComponents: { config, pathResolver: (s) => s },
579
+ });
580
+ handleRef = handle;
581
+ expect(handle).toBeDefined();
582
+ });
389
583
  });
390
584
 
391
585
  // OR-9 (Wave 5d): adapter switch for the OAuth code repository. Closes
@@ -476,9 +670,17 @@ describe("standalone smoke test", () => {
476
670
  type: "local",
477
671
  ...(config.oauth.jwt.signingKey.local ?? {}),
478
672
  });
673
+ // The token must carry the deployment's configured `iss`: introspection
674
+ // pins it (RFC 9068 §4), and since #266 every deployment has one.
479
675
  const { token } = await generateToken(
480
676
  {},
481
- { keyStore, subject: "u1", expiresIn: 3600, tokenType: "at+jwt" },
677
+ {
678
+ keyStore,
679
+ subject: "u1",
680
+ expiresIn: 3600,
681
+ tokenType: "at+jwt",
682
+ issuer: config.oauth.jwt.issuer,
683
+ },
482
684
  );
483
685
 
484
686
  const res = await request(app)
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import type { Logger } from "@o3co/auth-provider-core";
17
+ import express from "express";
18
+ import request from "supertest";
19
+ import { describe, expect, it, vi } from "vitest";
20
+ import { createTerminalErrorHandler } from "../terminalError.mjs";
21
+
22
+ const makeLogger = () => {
23
+ const error = vi.fn();
24
+ const logger = {
25
+ error,
26
+ warn: vi.fn(),
27
+ info: vi.fn(),
28
+ debug: vi.fn(),
29
+ } as unknown as Logger;
30
+ return { logger, error };
31
+ };
32
+
33
+ const makeApp = (logger: Logger) => {
34
+ const app = express();
35
+ app.use(express.json());
36
+ app.post("/echo", (_req, res) => {
37
+ res.status(200).json({ ok: true });
38
+ });
39
+ app.get("/boom", () => {
40
+ throw new Error("route exploded");
41
+ });
42
+ app.use(createTerminalErrorHandler(logger));
43
+ return app;
44
+ };
45
+
46
+ describe("terminal error handler (#293 item 8)", () => {
47
+ it("answers malformed JSON with the client's 400 in the shared envelope, not Express's HTML page", async () => {
48
+ const { logger, error } = makeLogger();
49
+ const res = await request(makeApp(logger))
50
+ .post("/echo")
51
+ .set("Content-Type", "application/json")
52
+ .send("{not json");
53
+
54
+ expect(res.status).toBe(400);
55
+ expect(res.headers["content-type"]).toContain("application/json");
56
+ expect(res.body.error).toBe("invalid_request");
57
+ // The client's malformed body is not a server error worth an error line.
58
+ expect(error).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("keeps the body parser's own status for an over-limit body (413)", async () => {
62
+ const { logger } = makeLogger();
63
+ const app = express();
64
+ app.use(express.json({ limit: 16 }));
65
+ app.post("/echo", (_req, res) => {
66
+ res.status(200).json({ ok: true });
67
+ });
68
+ app.use(createTerminalErrorHandler(logger));
69
+
70
+ const res = await request(app)
71
+ .post("/echo")
72
+ .set("Content-Type", "application/json")
73
+ .send(JSON.stringify({ pad: "x".repeat(64) }));
74
+
75
+ expect(res.status).toBe(413);
76
+ expect(res.body.error).toBe("invalid_request");
77
+ expect(res.body.error_description).toBe("request body too large");
78
+ });
79
+
80
+ it("answers an unexpected route throw with 500 server_error and logs it structurally", async () => {
81
+ const { logger, error } = makeLogger();
82
+ const res = await request(makeApp(logger)).get("/boom");
83
+
84
+ expect(res.status).toBe(500);
85
+ expect(res.body).toEqual({
86
+ error: "server_error",
87
+ error_description: "Internal server error",
88
+ });
89
+ // No stack trace on the wire — the envelope is the whole body.
90
+ expect(JSON.stringify(res.body)).not.toContain("route exploded");
91
+ expect(error).toHaveBeenCalledWith(
92
+ expect.objectContaining({ endpoint: "/boom" }),
93
+ "unhandled_request_error",
94
+ );
95
+ });
96
+ });
@@ -19,6 +19,7 @@ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
19
19
  import { parseFile } from "@o3co/ts.hocon";
20
20
  import { validate } from "@o3co/ts.hocon/zod";
21
21
  import { describe, expect, it } from "vitest";
22
+ import { buildModules } from "../buildModules.mjs";
22
23
  import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
23
24
 
24
25
  // config/ is two levels above this test file:
@@ -26,10 +27,13 @@ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPa
26
27
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
27
28
 
28
29
  // Provide required secrets so AppConfigSchema parse succeeds. These are
29
- // test-only values — no real keys are embedded here.
30
+ // test-only values — no real keys are embedded here. #282: SESSION_SECRET
31
+ // carries a 256-bit entropy floor, so these clear it (the '.' characters keep
32
+ // them outside the base64 alphabet, so the UTF-8 length is what counts).
30
33
  const testEnv = {
31
- OAUTH_JWT_SECRET: "test-secret-three-tier",
32
- SESSION_SECRET: "test-session-secret-three-tier",
34
+ OAUTH_JWT_SECRET: "test-secret-three-tier.at-least-32-bytes.ok",
35
+ OAUTH_JWT_ISSUER: "https://auth.test",
36
+ SESSION_SECRET: "test-session-secret-three-tier.at-least-32-bytes.ok",
33
37
  };
34
38
 
35
39
  function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): AppConfig {
@@ -75,10 +79,15 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
75
79
  // so OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED=false reaches the resolved value.
76
80
  // Without the repeated env line, the substitution at reference.conf is
77
81
  // shadowed by the template's literal `true`.
78
- // Note: ts.hocon substitutes env vars as strings; the schema (passthrough)
79
- // preserves the string. The assertion checks the string form so that
80
- // the precedence invariant is testable now; a future schema-coercion PR
81
- // (separate from this one) will make this resolve to boolean false.
82
+ // Note: ts.hocon substitutes env vars as strings, and `oauth.grants` is
83
+ // `z.object({}).passthrough()` — an open tree each grant module's own
84
+ // `configSchema` validates — so no shape-walking coercion reaches this
85
+ // leaf and the string survives to the resolved config. #288 unified the
86
+ // coercion for every DECLARED boolean in the schema and deliberately
87
+ // left this one alone: every consumer of a grant's `enabled`
88
+ // (`GrantRegistry.isEnabled`, `oauthAuthorization`, `oauthSession`)
89
+ // already reads `true` and `"true"` as enabled and everything else,
90
+ // `"false"` included, as not-enabled.
82
91
  const config = buildResolvedConfig("development", {
83
92
  OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
84
93
  });
@@ -114,4 +123,105 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
114
123
  });
115
124
  expect(config.oauth.resourceIndicator?.enabled).toBe(true);
116
125
  });
126
+
127
+ // #287 — the audit trail as the shipped artifact resolves it. The section is
128
+ // declared in core's schema for a load-bearing reason: `AppConfigSchema` is
129
+ // a plain `z.object` and strips what it does not declare, so an undeclared
130
+ // `audit` block would vanish between `parseFile` and `buildModules` and the
131
+ // sink selector would read `undefined` while the config sat in the file
132
+ // looking effective. These assertions run the real three-tier merge, so they
133
+ // fail if either layer stops carrying the key.
134
+ describe("#287: the audit sink as the shipped artifact resolves it", () => {
135
+ it("resolves audit.sink.type to the template's logger sink with nothing set", () => {
136
+ const config = buildResolvedConfig("production");
137
+ expect(config.audit?.sink.type).toBe("logger");
138
+ });
139
+
140
+ it("wires exactly one auditSink provider for that resolved config", () => {
141
+ const modules = buildModules(buildResolvedConfig("production"));
142
+ const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
143
+ expect(providers).toHaveLength(1);
144
+ });
145
+
146
+ it("lets an operator select core's built-in console sink by env var", () => {
147
+ // The env-override line has to be repeated at the template layer or
148
+ // the literal `"logger"` shadows reference.conf's substitution — the
149
+ // same precedence rule the grant tests above pin.
150
+ const config = buildResolvedConfig("production", { AUDIT_SINK_TYPE: "console" });
151
+ expect(config.audit?.sink.type).toBe("console");
152
+ });
153
+
154
+ it("reference.conf's own default is a sink, not a drop", () => {
155
+ // A consumer that resolves against reference.conf alone still lands on
156
+ // a sink. #304's sink policy is stdout JSON, never "none", and the
157
+ // library layer has to hold that on its own — a composition root that
158
+ // forgets to override it must not thereby lose its audit trail.
159
+ const referenceOnly = validate(
160
+ parseFile(resolveLibraryReferenceConfPath(), { env: testEnv }),
161
+ AppConfigSchema,
162
+ );
163
+ expect(referenceOnly.audit?.sink.type).toBe("console");
164
+ });
165
+ });
166
+
167
+ // #277 — this is the shape the umbrella E2E (o3co/auth) boots: the shipped
168
+ // application.conf, `DEPLOYMENT_MODE=multi`, and one shared ioredis socket.
169
+ // The assertions below are what keep that stack booting: a memory denylist
170
+ // under `multi` is refused by the replica-safety guard, and NO denylist is
171
+ // refused by the #277 guard, so the template has to land on "redis" without
172
+ // the deployment naming it.
173
+ describe("#277: access-token revocation as the shipped artifact resolves it", () => {
174
+ it("resolves accessTokenDenylist.adapter to redis with nothing set", () => {
175
+ const config = buildResolvedConfig("production");
176
+ expect(config.accessTokenDenylist?.adapter).toBe("redis");
177
+ });
178
+
179
+ it("resolves oauth.revocation.accessToken to denylist with nothing set", () => {
180
+ const config = buildResolvedConfig("production");
181
+ expect(config.oauth.revocation?.accessToken).toBe("denylist");
182
+ });
183
+
184
+ it("picks the replica-safe denylist module for that resolved config", () => {
185
+ const names = buildModules(buildResolvedConfig("production")).map((m) => m.name);
186
+ expect(names).toContain("redis-access-token-denylist");
187
+ expect(names).not.toContain("core-access-token-denylist-memory");
188
+ });
189
+
190
+ it("leaves nothing replica-unsafe under the umbrella E2E's environment", async () => {
191
+ // The environment `o3co/auth`'s tests/docker-compose.yml sets. Under
192
+ // `DEPLOYMENT_MODE=multi` the replica-safety guard fails boot naming
193
+ // every in-memory shared store, so the denylist this change adds has to
194
+ // come out of that environment as the Redis one — from the template's
195
+ // own config, since the compose file names no denylist variable.
196
+ // Asserted against the guard's own list rather than a module name, so a
197
+ // rename cannot quietly invalidate it.
198
+ const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
199
+ const config = buildResolvedConfig("production", {
200
+ USER_SESSION_STORES_ADAPTER: "redis",
201
+ RATE_LIMITER_ADAPTER: "redis",
202
+ OAUTH_CODE_ADAPTER: "redis",
203
+ });
204
+ const names = buildModules(config).map((m) => m.name);
205
+ expect(names).toContain("redis-access-token-denylist");
206
+ for (const name of names) {
207
+ expect(REPLICA_UNSAFE_MODULES).not.toContain(name);
208
+ }
209
+ });
210
+
211
+ it("lets a single-instance deployment opt down to memory by env var", () => {
212
+ const config = buildResolvedConfig("production", {
213
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
214
+ });
215
+ expect(config.accessTokenDenylist?.adapter).toBe("memory");
216
+ const names = buildModules(config).map((m) => m.name);
217
+ expect(names).toContain("core-access-token-denylist-memory");
218
+ });
219
+
220
+ it("lets a deployment declare access-token revocation unsupported by env var", () => {
221
+ const config = buildResolvedConfig("production", {
222
+ OAUTH_REVOCATION_ACCESS_TOKEN: "unsupported",
223
+ });
224
+ expect(config.oauth.revocation?.accessToken).toBe("unsupported");
225
+ });
226
+ });
117
227
  });