@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,79 @@
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 variables renamed with a path of the composition root's own layers,
19
+ * which no module declares: the adapter selections and the federations the
20
+ * template ships. Phase one refuses an old name set alone, or beside its new
21
+ * name at a different value, before any module is chosen; the two at one
22
+ * value are accepted.
23
+ */
24
+
25
+ /** A variable renamed with a path of the composition root's own: its old name, its new one, and the path the new one binds. */
26
+ export interface RootRename {
27
+ readonly from: string;
28
+ readonly to: string;
29
+ readonly path: string;
30
+ }
31
+
32
+ /**
33
+ * Refuses, with a `RangeError`, an old name in `env` set alone, or beside its
34
+ * new name at a different value; the two at one value are accepted. Every
35
+ * such rename is named at once; no value is quoted.
36
+ */
37
+ export function refuseRenamedVariables(
38
+ env: Readonly<Record<string, string>>,
39
+ renames: readonly RootRename[],
40
+ ): void {
41
+ const refused = renames.flatMap(({ from, to, path }) => {
42
+ const old = env[from];
43
+ if (old === undefined || env[to] === old) return [];
44
+ const renamed = `${from} was renamed ${to}, the variable ${path} is bound to; see CHANGELOG.`;
45
+ return env[to] === undefined
46
+ ? [`${renamed} Set ${to} instead and unset ${from}.`]
47
+ : [
48
+ `${renamed} ${to} is set to a different value: keep the one you mean in ${to} and unset ${from}.`,
49
+ ];
50
+ });
51
+ if (refused.length > 0) {
52
+ throw new RangeError(
53
+ `The environment sets ${refused.length} variable(s) that were renamed: ${refused.join(" ")}`,
54
+ );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The variables `config/application.conf` binds for the two federations the
60
+ * template ships, each renamed after its path under `core.federations`.
61
+ */
62
+ export const SHIPPED_FEDERATION_RENAMES: readonly RootRename[] = (
63
+ [
64
+ ["GOOGLE", "google", ["ENABLED", "enabled"]],
65
+ ["GOOGLE", "google", ["CLIENT_ID", "clientId"]],
66
+ ["GOOGLE", "google", ["CLIENT_SECRET", "clientSecret"]],
67
+ ["GOOGLE", "google", ["CALLBACK_URL", "callbackURL"]],
68
+ ["GOOGLE", "google", ["ACCESS_TYPE", "accessType"]],
69
+ ["OIDC", "oidc", ["ENABLED", "enabled"]],
70
+ ["OIDC", "oidc", ["ISSUER", "issuer"]],
71
+ ["OIDC", "oidc", ["CLIENT_ID", "clientId"]],
72
+ ["OIDC", "oidc", ["CLIENT_SECRET", "clientSecret"]],
73
+ ["OIDC", "oidc", ["CALLBACK_URL", "callbackURL"]],
74
+ ] as const
75
+ ).map(([federation, name, [key, path]]) => ({
76
+ from: `FEDERATIONS_${federation}_${key}`,
77
+ to: `CORE_FEDERATIONS_${federation}_${key}`,
78
+ path: `core.federations.${name}.${path}`,
79
+ }));
@@ -0,0 +1,89 @@
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 host's routes, in the order `app.mts` mounts them: liveness, readiness
19
+ * and the metrics scrape ahead of the composed auth router, so they keep
20
+ * answering while the auth pipeline is degraded (which is when an operator
21
+ * needs them); then that router; then core's terminal error handler, last,
22
+ * because Express hands an error only to handlers mounted after the route
23
+ * that raised it. A function rather than inline in `app.mts`, so the order
24
+ * and the handler can be tested as the process mounts them.
25
+ *
26
+ * The handler is core's `terminalErrorHandler`, the one that already ends the
27
+ * composed router, so an error a host route lets through is answered as every
28
+ * other route's is (see core's `middleware/terminalError.mts`).
29
+ */
30
+
31
+ import {
32
+ createHealthcheckRouter,
33
+ createReadinessRouter,
34
+ type Logger,
35
+ type ReadinessProbe,
36
+ terminalErrorHandler,
37
+ } from "@o3co/auth-provider-core";
38
+ import express, { type Express, type Router } from "express";
39
+ import type { Metrics } from "./metrics.mjs";
40
+
41
+ export interface MountRoutesOptions {
42
+ /** The router `createApp` composed (`handle.router`). */
43
+ readonly router: Router;
44
+ /** The readiness probes the builders registered (`handle.readinessProbes`). */
45
+ readonly probes: readonly ReadinessProbe[];
46
+ /** Per-probe deadline, in milliseconds: the `http` module's `readinessTimeoutMs`. */
47
+ readonly readinessTimeoutMs: number;
48
+ readonly metrics: Metrics;
49
+ readonly logger: Logger;
50
+ }
51
+
52
+ /** Mount the host's routes, the composed router and the terminal error handler on `app`. */
53
+ export function mountRoutes(app: Express, options: MountRoutesOptions): void {
54
+ // Liveness: the process is up and its event loop is turning. Deliberately
55
+ // static — restarting the process would not bring Redis back, so a Redis
56
+ // outage must not read as "this container is broken, kill it".
57
+ app.use(createHealthcheckRouter(express));
58
+
59
+ // Readiness: can this replica serve right now? Redis backs sessions,
60
+ // authorization codes and refresh-token families in the deployable
61
+ // defaults, so a replica that has lost it answers 503 here and should be
62
+ // taken out of rotation. Probes are contributed by the builders that own
63
+ // each connection; a memory-only deployment registers none and is always
64
+ // ready.
65
+ app.use(
66
+ createReadinessRouter(express, {
67
+ probes: options.probes,
68
+ timeoutMs: options.readinessTimeoutMs,
69
+ logger: options.logger,
70
+ }),
71
+ );
72
+
73
+ // Prometheus scrape endpoint. Same reasoning as the probes above: it has to
74
+ // keep answering while the auth pipeline is degraded, because that is when
75
+ // the series matter.
76
+ app.use(
77
+ options.metrics.route(express, {
78
+ probes: options.probes,
79
+ probeTimeoutMs: options.readinessTimeoutMs,
80
+ }),
81
+ );
82
+
83
+ // The composed auth router, which ends in core's terminal handler.
84
+ app.use(options.router);
85
+ // Core's terminal handler again, LAST, for the host routes
86
+ // above: Express routes an error only to handlers registered after the
87
+ // route that threw it. See the file header.
88
+ app.use(terminalErrorHandler(options.logger));
89
+ }
@@ -0,0 +1,366 @@
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 schemas of the template's own sections: its modules' — `logging`,
19
+ * `http` (with its CORS list), `key-store`, `redis-clients`, `repositories`,
20
+ * the in-process code repository's and `audit-sink` — and the composition
21
+ * root's own `adapters` and `mfaMode`. Each is strict, a key it does not
22
+ * declare refused;
23
+ * each reads the strings an environment variable carries; none holds a
24
+ * default, which lives in `config/reference.conf`. The rules a value is held
25
+ * to are core's shared vocabulary (trusted-proxy entries, serialized origins,
26
+ * key ids), applied here, never restated.
27
+ */
28
+
29
+ import {
30
+ checkSerializedOrigin,
31
+ checkTrustedProxyEntry,
32
+ describeSerializedOriginRejection,
33
+ describeTrustedProxyEntryRejection,
34
+ isWellFormedKid,
35
+ MAX_KID_LENGTH,
36
+ MAX_TRUST_PROXY_HOPS,
37
+ normalizeAllowedOrigins,
38
+ wholeNumberFromEnv,
39
+ wholeNumberInRangeFromEnv,
40
+ } from "@o3co/auth-provider-core";
41
+ import { mfaConfigSchema } from "@o3co/auth-provider-mfa";
42
+ import { z } from "zod";
43
+
44
+ /** `logging`: the level the process logs at. `silent` is a threshold, not a level. */
45
+ export const loggingSectionSchema = z
46
+ .object({ level: z.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) })
47
+ .strict();
48
+
49
+ /** What the logger is built from: the `logging` module's section. */
50
+ export type LoggingSettings = z.output<typeof loggingSectionSchema>;
51
+
52
+ /**
53
+ * A decimal, optionally signed or fractional: the variable's shapes meant as a
54
+ * hop count, so `-1` and `1.5` fail as hop counts instead of reading as
55
+ * one-entry address lists.
56
+ */
57
+ const NUMERIC_STRING = /^-?[0-9]+(\.[0-9]+)?$/;
58
+
59
+ /**
60
+ * A `trust proxy` value in one of Express's shapes: the variable's string read
61
+ * as `true` / `false` / a hop count / a comma-separated list, a list's entries
62
+ * trimmed. `1` and `0` are hop counts here, not booleans. An exported-but-empty
63
+ * variable is `false`: trust nothing.
64
+ */
65
+ const normalizeTrustProxy = (raw: unknown): unknown => {
66
+ if (Array.isArray(raw)) {
67
+ return raw.map((entry) => (typeof entry === "string" ? entry.trim() : entry));
68
+ }
69
+ if (typeof raw !== "string") return raw;
70
+ const value = raw.trim();
71
+ if (value === "") return false;
72
+ const lower = value.toLowerCase();
73
+ if (lower === "true") return true;
74
+ if (lower === "false") return false;
75
+ if (NUMERIC_STRING.test(value)) return Number(value);
76
+ return value
77
+ .split(",")
78
+ .map((entry) => entry.trim())
79
+ .filter((entry) => entry !== "");
80
+ };
81
+
82
+ /**
83
+ * `http.trustProxy`, handed to Express's `trust proxy`: `false` (trust
84
+ * nothing), `true` (trust every hop), a hop count up to
85
+ * `MAX_TRUST_PROXY_HOPS`, or a non-empty list of IP literals, CIDR ranges and
86
+ * named ranges, each held to core's trusted-proxy vocabulary so a typo fails
87
+ * at boot, naming its index, instead of never matching.
88
+ */
89
+ const trustProxySchema = z
90
+ .preprocess(
91
+ normalizeTrustProxy,
92
+ z.union([
93
+ z.boolean(),
94
+ z.number().int().min(0).max(MAX_TRUST_PROXY_HOPS),
95
+ z
96
+ .array(z.string())
97
+ .min(
98
+ 1,
99
+ "must list at least one address, CIDR range, or named range — use `false` to trust no forwarding hop",
100
+ ),
101
+ ]),
102
+ )
103
+ .superRefine((value, ctx) => {
104
+ if (!Array.isArray(value)) return;
105
+ value.forEach((entry, index) => {
106
+ const rejection = checkTrustedProxyEntry(entry);
107
+ if (rejection !== null) {
108
+ ctx.addIssue({
109
+ code: "custom",
110
+ message: `http.trustProxy[${index}] ${describeTrustedProxyEntryRejection(rejection)}`,
111
+ path: [index],
112
+ });
113
+ }
114
+ });
115
+ });
116
+
117
+ /**
118
+ * `http.cors.allowedOrigins`: the browser origins allowed to read the token,
119
+ * userinfo, revocation and discovery/JWKS responses; empty is CORS off. A list,
120
+ * or the one comma-separated string `HTTP_CORS_ALLOWED_ORIGINS` carries
121
+ * (`normalizeAllowedOrigins`, core's reading of an origin list);
122
+ * `null` reads as no origins, any other shape is refused. Each entry must be a
123
+ * serialized origin, matched by exact string equality: one that could never
124
+ * match is refused, naming its index.
125
+ */
126
+ const allowedOriginsSchema = z
127
+ .preprocess((raw, ctx) => {
128
+ if (raw === undefined) return raw;
129
+ if (raw !== null && typeof raw !== "string" && !Array.isArray(raw)) {
130
+ ctx.addIssue({
131
+ code: "custom",
132
+ message: `http.cors.allowedOrigins must be a list of origins, or one comma-separated string of them (HTTP_CORS_ALLOWED_ORIGINS); got ${typeof raw === "object" ? "an object" : `a ${typeof raw}`}`,
133
+ });
134
+ return raw;
135
+ }
136
+ return normalizeAllowedOrigins(raw);
137
+ }, z.array(z.string()))
138
+ .superRefine((value, ctx) => {
139
+ value.forEach((entry, index) => {
140
+ const rejection = checkSerializedOrigin(entry);
141
+ if (rejection !== null) {
142
+ ctx.addIssue({
143
+ code: "custom",
144
+ message: `http.cors.allowedOrigins[${index}] ${describeSerializedOriginRejection(rejection)}`,
145
+ path: [index],
146
+ });
147
+ }
148
+ });
149
+ });
150
+
151
+ const PORT_RULE =
152
+ "http.port must be a port number from 0 to 65535 in decimal digits, 0 letting the OS choose a free port (HTTP_PORT); an exported-but-empty HTTP_PORT is not one";
153
+
154
+ /**
155
+ * `http.port`, read strictly: an empty or non-decimal value fails boot instead
156
+ * of reading as 0, which would listen on a port the OS picks at random.
157
+ */
158
+ const portSchema = wholeNumberFromEnv(
159
+ z
160
+ .number({ error: PORT_RULE })
161
+ .int({ error: PORT_RULE })
162
+ .min(0, { error: PORT_RULE })
163
+ .max(65_535, { error: PORT_RULE }),
164
+ );
165
+
166
+ /**
167
+ * `http`: the listener's port, the trusted forwarding hops, the readiness
168
+ * deadline, and the CORS list. The deadline is bounded both ways, because
169
+ * `setTimeout` turns 0 and anything above 2^31-1 into 1 ms: every probe would
170
+ * time out.
171
+ */
172
+ export const httpSectionSchema = z
173
+ .object({
174
+ port: portSchema,
175
+ trustProxy: trustProxySchema,
176
+ readinessTimeoutMs: wholeNumberInRangeFromEnv(1, 2_147_483_647),
177
+ cors: z.object({ allowedOrigins: allowedOriginsSchema }).strict(),
178
+ })
179
+ .strict();
180
+
181
+ /** A key id `verifyJwt` accepts: one it would refuse would fail every token signed under it. */
182
+ const kidSchema = z.string().refine(isWellFormedKid, {
183
+ message: `must be a key id: a string of 1 to ${MAX_KID_LENGTH} characters with no control character`,
184
+ });
185
+
186
+ /** HS256: a shared secret, rotated through `previousSecrets`. */
187
+ const hs256LocalSchema = z
188
+ .object({
189
+ algorithm: z.literal("HS256"),
190
+ kid: kidSchema,
191
+ secret: z.string().optional(),
192
+ previousSecrets: z
193
+ .array(z.object({ kid: kidSchema, secret: z.string(), expiresAt: z.string() }).strict())
194
+ .optional(),
195
+ })
196
+ .strict();
197
+
198
+ /**
199
+ * RS256, ES256, EdDSA: a key pair, rotated through `previousKeys`. `secret`,
200
+ * which the reference binds for every algorithm, and `previousSecrets` are
201
+ * accepted and left to the key store, which names what an asymmetric key
202
+ * cannot use them for.
203
+ */
204
+ const asymmetricLocalSchema = z
205
+ .object({
206
+ algorithm: z.enum(["RS256", "ES256", "EdDSA"]),
207
+ kid: kidSchema,
208
+ privateKey: z.string().optional(),
209
+ privateKeyPath: z.string().optional(),
210
+ publicKey: z.string().optional(),
211
+ publicKeyPath: z.string().optional(),
212
+ previousKeys: z
213
+ .array(
214
+ z
215
+ .object({
216
+ kid: kidSchema,
217
+ publicKey: z.string().optional(),
218
+ publicKeyPath: z.string().optional(),
219
+ expiresAt: z.string(),
220
+ })
221
+ .strict(),
222
+ )
223
+ .optional(),
224
+ secret: z.string().optional(),
225
+ previousSecrets: z.unknown().optional(),
226
+ })
227
+ .strict();
228
+
229
+ /**
230
+ * `key-store`: which key store builds the signing key (`provider`, `local` the
231
+ * one this template registers) and the local store's settings, shape only:
232
+ * whether the key material is there is the key store's check, when it is
233
+ * built.
234
+ */
235
+ export const keyStoreSectionSchema = z
236
+ .object({
237
+ provider: z.string(),
238
+ local: z.discriminatedUnion("algorithm", [hs256LocalSchema, asymmetricLocalSchema]).optional(),
239
+ })
240
+ .strict();
241
+
242
+ /** `redis-clients`: the one Redis connection every Redis-backed store shares. */
243
+ export const redisClientsSectionSchema = z
244
+ .object({ url: z.string(), password: z.string().optional() })
245
+ .strict();
246
+
247
+ /**
248
+ * `adapters`: which adapter fills each slot, the composition root's own
249
+ * choice. `userSessionStores` switches the four user-session stores and the
250
+ * subject-level revocation pair together; the repositories take core's
251
+ * `static`, an alias of `yaml` with a block of its own; `mfaFactorStore` and
252
+ * `mfaTransactionStore` are read by a composition that installs MFA;
253
+ * `auditSink` names a sink the audit-sink module's factory registers.
254
+ */
255
+ export const adaptersSchema = z
256
+ .object({
257
+ rateLimiter: z.enum(["memory", "redis"]),
258
+ userSessionStores: z.enum(["memory", "redis"]),
259
+ accessTokenDenylist: z.enum(["memory", "redis"]),
260
+ replaySeenSet: z.enum(["memory", "redis"]),
261
+ consentStore: z.enum(["none", "memory", "redis"]),
262
+ federationTokenStore: z.enum(["memory", "redis"]),
263
+ federationGrantStore: z.enum(["none", "memory", "redis"]),
264
+ federationGrantIntentStore: z.enum(["none", "memory", "redis"]),
265
+ mfaFactorStore: z.enum(["memory", "redis", "store"]),
266
+ mfaTransactionStore: z.enum(["memory", "redis"]),
267
+ codeRepository: z.enum(["memory", "redis"]),
268
+ clientRepository: z.enum(["yaml", "static"]),
269
+ userRepository: z.enum(["yaml", "static", "http"]),
270
+ auditSink: z.string().min(1),
271
+ })
272
+ .strict();
273
+
274
+ /** The composition root's adapter selections, as `adaptersSchema` reads them. */
275
+ export type Adapters = z.output<typeof adaptersSchema>;
276
+
277
+ /** A section of keys: an object whose prototype is `Object.prototype` or none. */
278
+ export function isPlainSection(value: unknown): value is Readonly<Record<string, unknown>> {
279
+ if (typeof value !== "object" || value === null) return false;
280
+ const prototype: unknown = Object.getPrototypeOf(value);
281
+ return prototype === Object.prototype || prototype === null;
282
+ }
283
+
284
+ /**
285
+ * `mfaMode`: whether the composition root installs MFA, its own choice — the
286
+ * MFA package's mode, read with that package's schema for `mfa.mode`. `off`
287
+ * installs nothing of MFA; `optional` and `required` install it and are
288
+ * written to `mfa.mode`.
289
+ */
290
+ export const mfaSwitchSchema = mfaConfigSchema.shape.mode;
291
+
292
+ /** The composition root's MFA switch, as `mfaSwitchSchema` reads it. */
293
+ export type MfaSwitch = z.output<typeof mfaSwitchSchema>;
294
+
295
+ /** A YAML file's path: a repository reads its entries from it when it is built. */
296
+ const yamlSchema = z.object({ path: z.string() }).strict();
297
+
298
+ /**
299
+ * `repositories`: the YAML client registry, and the user repository's
300
+ * settings for each adapter it may be — the YAML file, or the Store's HTTP
301
+ * endpoints. `static`, core's alias of `yaml`, reads the same shape from a
302
+ * block of its own, which has no default. The HTTP settings are shape only: the repository holds each
303
+ * value to its rules (https or loopback, a credential's strength, positive
304
+ * whole numbers) when it is built, and names what it refuses.
305
+ */
306
+ export const repositoriesSectionSchema = z
307
+ .object({
308
+ client: z.object({ yaml: yamlSchema, static: yamlSchema.optional() }).strict(),
309
+ user: z
310
+ .object({
311
+ yaml: yamlSchema,
312
+ static: yamlSchema.optional(),
313
+ http: z
314
+ .object({
315
+ authenticateUrl: z.unknown().optional(),
316
+ authenticateByTokenUrl: z.unknown().optional(),
317
+ linkFederatedIdentityUrl: z.unknown().optional(),
318
+ findSubjectByFederatedIdentityUrl: z.unknown().optional(),
319
+ markMfaEnrolledUrl: z.unknown().optional(),
320
+ federatedIdentityLookupCoverage: z.unknown().optional(),
321
+ bearerToken: z.unknown().optional(),
322
+ timeout: z.unknown().optional(),
323
+ maxResponseBytes: z.unknown().optional(),
324
+ })
325
+ .strict(),
326
+ })
327
+ .strict(),
328
+ })
329
+ .strict();
330
+
331
+ /**
332
+ * `repositories` for the adapters `selection` names: `repositoriesSectionSchema`,
333
+ * and the `static` block's path required for a repository that selects
334
+ * `static`, since the block has no default. Refused at the section's parse,
335
+ * naming the path, before any repository is built.
336
+ */
337
+ export function repositoriesSectionSchemaFor(selection: {
338
+ readonly client: Adapters["clientRepository"];
339
+ readonly user: Adapters["userRepository"];
340
+ }) {
341
+ return repositoriesSectionSchema.superRefine((section, ctx) => {
342
+ for (const repository of ["client", "user"] as const) {
343
+ if (selection[repository] === "static" && section[repository].static === undefined) {
344
+ ctx.addIssue({
345
+ code: "custom",
346
+ path: [repository, "static", "path"],
347
+ message: `required when adapters.${repository}Repository is "static": the static block has no default`,
348
+ });
349
+ }
350
+ }
351
+ });
352
+ }
353
+
354
+ /** The in-process code repository's section: the default lifetime, in positive whole seconds. */
355
+ export const inMemoryCodeRepositorySectionSchema = z
356
+ .object({ defaultExpiresIn: wholeNumberInRangeFromEnv(1) })
357
+ .strict();
358
+
359
+ /**
360
+ * `audit-sink`: each sink's options, keyed by the sink's name, for the sink
361
+ * `adapters.auditSink` selects. Open: the sinks are the factory's, and each
362
+ * builder holds its own options to its rules.
363
+ */
364
+ export const auditSinkSectionSchema = z
365
+ .record(z.string(), z.record(z.string(), z.unknown()))
366
+ .optional();