@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

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 (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  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 +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  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 +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -14,56 +14,49 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { fileURLToPath } from "node:url";
17
- import { type AppConfig, AppConfigSchema, createApp } from "@o3co/auth-provider-core";
18
- import { parseFile } from "@o3co/ts.hocon";
19
- import { validate } from "@o3co/ts.hocon/zod";
17
+ import { createApp } from "@o3co/auth-provider-core";
20
18
  import express from "express";
21
19
  import helmet from "helmet";
22
20
  import { buildModules } from "./buildModules.mjs";
23
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
21
+ import {
22
+ configDefaultsFor,
23
+ readLogging,
24
+ readOwnLayers,
25
+ readSwitches,
26
+ resolveConfigPaths,
27
+ resolveForBoot,
28
+ type Switches,
29
+ } from "./configPath.mjs";
24
30
  import { listen } from "./listen.mjs";
25
31
  import { createAppLogger } from "./logger.mjs";
26
32
  import { createMetrics } from "./metrics.mjs";
27
33
  import { mountRoutes } from "./routes.mjs";
28
- import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.mjs";
34
+ import { installGracefulShutdown } from "./shutdown.mjs";
29
35
 
30
- // Step 1: Load and validate application config (HOCON → Zod schema).
36
+ // Step 1: the configuration, in two phases (`configPath.mts`; template
37
+ // README, "Environment-specific config overlay").
31
38
  // ENV = CONFIG_ENV || NODE_ENV || "development"; missing {ENV}.conf is fatal.
32
- //
33
- // 3-tier HOCON precedence (highest → lowest):
34
- // 1. {env}.conf — environment-specific overrides (e.g. production.conf)
35
- // 2. application.conf — template-level consumer delta
36
- // 3. reference.conf — library defaults shipped in @o3co/auth-provider-core
37
39
  const env = process.env.CONFIG_ENV || process.env.NODE_ENV || "development";
38
40
  const configDir = new URL("../config/", import.meta.url);
39
41
  const configDirPath = fileURLToPath(configDir);
40
42
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDirPath, env);
41
- const libraryReferencePath = resolveLibraryReferenceConfPath();
42
- const config: AppConfig = validate(
43
- parseFile(envConfPath)
44
- .withFallback(parseFile(applicationConfPath))
45
- .withFallback(parseFile(libraryReferencePath)),
46
- AppConfigSchema,
47
- );
43
+ // Read once, so both phases read the same thing.
44
+ const own = readOwnLayers([envConfPath, applicationConfPath]);
45
+ // Phase one: what the template reads before it knows its modules, only for
46
+ // those choices — its own `adapters` and MFA switch (`mfaMode`, MFA_MODE),
47
+ // and whether federation grants are installed.
48
+ const switches: Switches = readSwitches(own);
48
49
 
49
- // The logger is built from config so its level is operator-controlled, and it
50
- // is wired into `bootstrapComponents` so every module that declares
51
- // `optional: ["logger"]` receives this one rather than falling back to its own
52
- // default. Without that slot filled, configuring a level here would change
53
- // nothing about what the modules emit.
54
- const logger = createAppLogger(config);
50
+ // From the `logging` module's section, so the level holds from the first line;
51
+ // wired into `bootstrapComponents` so every module logs through it (template
52
+ // README, "Logging").
53
+ const logger = createAppLogger(readLogging(own));
55
54
 
56
55
  await (async (): Promise<void> => {
57
56
  // Step 2: Create the Express app and apply base security middleware.
57
+ // `trust proxy` is set from the `http` module's `httpSettings` once boot
58
+ // has it (step 3), before a request can arrive.
58
59
  const app = express();
59
- // `false` | `true` | a hop count | a list of IPs / CIDR ranges / named
60
- // ranges — the schema hands Express exactly the shapes `trust proxy`
61
- // already understands, validated at boot so a typo'd range fails here
62
- // rather than becoming a rule that silently never matches (#292). Prefer
63
- // naming the proxy: `true` believes a forwarded client address from anyone
64
- // who can reach this process, and `req.ip` is what every IP-keyed rate
65
- // limit is bucketed on.
66
- app.set("trust proxy", config.http.trustProxy);
67
60
  app.use(
68
61
  helmet({
69
62
  contentSecurityPolicy: {
@@ -81,70 +74,63 @@ await (async (): Promise<void> => {
81
74
  const metrics = createMetrics();
82
75
  app.use(metrics.middleware);
83
76
 
84
- // Step 3: Boot the auth pipeline.
85
- // D-5: express-session middleware is now wired by `sessionStoreModule`
86
- // inside the boot planner. Mount order is enforced by declarationIndex
87
- // tie-breaking — sessionStoreModule MUST come first in `buildModules(...)`
88
- // so the middleware initialises `req.session` for every downstream route.
89
- // The connect-redis client's lifetime is owned by the planner via
90
- // `BuilderContext.lifecycle` and drained on `handle.dispose()`.
91
- // bootstrapComponents carries only host-
92
- // environment values (config + pathResolver per A2-γ §4 worked example);
93
- // every other component flows through composition-root-local modules.
94
- // `buildModules` is the single source of truth for the module list — it
95
- // gates federation modules on `config.federations.<name>.enabled`, which
96
- // the standalone scaffold defaults to false.
77
+ // Step 3: boot the auth pipeline. `buildModules` is the single source of
78
+ // truth for the module list and its order; `bootstrapComponents` carries
79
+ // only host-environment values, every other component comes from the
80
+ // modules. `environment` is the name the config was selected by: the Redis
81
+ // federation-token store's plaintext guard reads it as well as NODE_ENV,
82
+ // so `CONFIG_ENV=production` is production to the guard too.
97
83
  //
98
- // `environment` is the name the config above was selected by (#473): the
99
- // Redis federation-token store's plaintext guard reads it in addition to
100
- // NODE_ENV, so `CONFIG_ENV=production` is production to the guard too.
101
- // `logger` is where the composition's own notices go (a deprecated config
102
- // key), the same logger every module gets through the slot below.
84
+ // Phase two: `createApp` parses the configuration as resolved once, with
85
+ // every loaded module's schema (`configPath.mts`), and names each section
86
+ // nothing loaded reads by the configuration's defaults. From here on the
87
+ // template reads the parsed configuration.
88
+ const modules = buildModules(switches, { environment: env });
103
89
  const handle = await createApp({
104
- modules: buildModules(config, { environment: env, logger }),
90
+ modules,
105
91
  bootstrapComponents: {
106
- config,
92
+ config: resolveForBoot(own, modules, switches),
93
+ configDefaults: configDefaultsFor(modules),
107
94
  pathResolver: import.meta.resolve,
108
95
  logger,
109
96
  },
110
97
  });
98
+ // The `http` module's settings: core's slot and the host's own.
99
+ const { httpSettings, httpHostSettings } = handle.components;
100
+ if (httpSettings === undefined || httpHostSettings === undefined) {
101
+ throw new Error("createApp booted without the http module's settings");
102
+ }
103
+ // `false` | `true` | a hop count | a list of IPs / CIDR ranges / named
104
+ // ranges: the shapes `trust proxy` understands, validated at boot so a
105
+ // typo'd range fails there rather than silently never matching. Prefer
106
+ // naming the proxy: `true` believes a forwarded client address from anyone
107
+ // who can reach this process, and every IP-keyed rate limit buckets on
108
+ // `req.ip`.
109
+ app.set("trust proxy", httpSettings.trustProxy);
111
110
 
112
- // Step 4: the host-level routes (liveness, readiness, metrics) ahead of the
113
- // auth router so they remain reachable even when the auth pipeline is
114
- // degraded — which is exactly when an operator needs an answer from them —
115
- // then the composed auth router, then the terminal error handler. See
116
- // `routes.mts` for what each is and why the order is what it is.
111
+ // Step 4: the host routes (liveness, readiness, metrics), then the composed
112
+ // auth router, then the terminal error handler; `routes.mts` says why in
113
+ // that order.
117
114
  mountRoutes(app, {
118
115
  router: handle.router,
119
116
  probes: handle.readinessProbes,
120
- readinessTimeoutMs: config.http.readinessTimeoutMs,
117
+ readinessTimeoutMs: httpHostSettings.readinessTimeoutMs,
121
118
  metrics,
122
119
  logger,
123
120
  });
124
- // Step 5: start the HTTP server.
125
- // Resolves once the socket is bound (`server_listening`); a port that
126
- // cannot be bound rejects, and boot fails with that error (`listen.mts`).
127
- const server = await listen(app, config.http.port, logger);
121
+ // Step 5: start the HTTP server. A port that cannot be bound fails boot
122
+ // with that error (`listen.mts`).
123
+ const server = await listen(app, httpHostSettings.port, logger);
128
124
 
129
- // Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
130
- // per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
131
- // (Redis clients, interval timers).
132
- //
133
- // #290: in the template rather than a dependency, because "does SIGTERM
134
- // wait for in-flight requests, and for how long?" has to be answerable
135
- // from the code you deploy. See `shutdown.mts` for the stated guarantees
136
- // and for the deadline the previous implementation did not have. Size
137
- // `drainTimeoutMs` below your orchestrator's kill grace period.
138
- //
139
- // #593 slice 7: with federation grants on, cleanup gets the longest refresh
140
- // tail the configured budgets allow plus a margin — 45 s under the shipped
141
- // ones — rather than inheriting the drain's ten: the dispose is what waits
142
- // for a rotated credential's write. The compose files size
143
- // `stop_grace_period` to cover the drain, this and an exit margin for the
144
- // shipped budgets; a raised budget raises both.
125
+ // Step 6: graceful shutdown (`shutdown.mts`). Size `drainTimeoutMs` below
126
+ // your orchestrator's kill grace period. Cleanup gets at least the
127
+ // allowance the modules registered with their cleanups (45 s with
128
+ // federation grants on under the shipped budgets) rather than only the
129
+ // drain's ten; the template README, "Shutdown guarantees", sizes the grace
130
+ // period for it.
145
131
  installGracefulShutdown(server, {
146
132
  logger,
147
133
  cleanup: () => handle.dispose(),
148
- ...cleanupAllowanceFor(config),
134
+ cleanupAllowanceMs: () => handle.cleanupAllowanceMs,
149
135
  });
150
136
  })();
@@ -0,0 +1,117 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The template's refusals before boot, as core's `BootError`: each under the
19
+ * reason boot raises for the same case, at the stage boot raises it in
20
+ * (`validateManifests`), so that an alert keyed on a boot error's reason sees
21
+ * every refusal to start. The template's checks stay its own; only how a
22
+ * refusal is reported is boot's.
23
+ *
24
+ * The details name a module as boot does. A key of the composition root's
25
+ * own — the `adapters` section, the `mfaMode` switch — is named after itself,
26
+ * as boot names core's own section "core": a module with a section under
27
+ * either name is refused (`resolveForBoot`), so the name points at the key.
28
+ */
29
+
30
+ import {
31
+ BootError,
32
+ type ConfigPathRelocatedDetails,
33
+ type ConfigValidationFailedDetails,
34
+ type EnvironmentVariableRenamedDetails,
35
+ } from "@o3co/auth-provider-core";
36
+
37
+ /** A Zod issue as `config-validation-failed` carries one: its path is the path in the configuration. */
38
+ export type ConfigIssue = ConfigValidationFailedDetails["issues"][number];
39
+
40
+ /** The stage boot raises the configuration's refusals in. */
41
+ const STAGE = "validateManifests";
42
+
43
+ /** An issue at `path` of the configuration that no schema raised: what is wrong there, in `message`. */
44
+ export function customIssue(path: readonly string[], message: string): ConfigIssue {
45
+ return { code: "custom", path: [...path], message } as ConfigIssue;
46
+ }
47
+
48
+ /** `issues`, each with `prefix`, the path its schema read at, in front of its own path. */
49
+ export function issuesAt(prefix: readonly string[], issues: readonly ConfigIssue[]): ConfigIssue[] {
50
+ return issues.map((issue) => ({ ...issue, path: [...prefix, ...issue.path] }) as ConfigIssue);
51
+ }
52
+
53
+ /**
54
+ * `config-validation-failed`: `issues` at their paths, refused in the
55
+ * sections `modules` read, each at its `schemaPath`.
56
+ */
57
+ export function configRefused(
58
+ message: string,
59
+ issues: readonly ConfigIssue[],
60
+ modules: ConfigValidationFailedDetails["modules"],
61
+ ): BootError {
62
+ return new BootError({
63
+ message,
64
+ reason: "config-validation-failed",
65
+ stage: STAGE,
66
+ details: { reason: "config-validation-failed", issues: [...issues], modules: [...modules] },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * `config-validation-failed` for one key, at `path`, of the section `module`
72
+ * reads at its own name: `message` is the refusal and the issue's message.
73
+ */
74
+ export function keyRefused(message: string, module: string, path: readonly string[]): BootError {
75
+ return configRefused(message, [customIssue(path, message)], [{ module, schemaPath: module }]);
76
+ }
77
+
78
+ /** `config-path-relocated`: each key still set at a path it moved from. */
79
+ export function pathsRelocated(
80
+ message: string,
81
+ relocated: ConfigPathRelocatedDetails["relocated"],
82
+ ): BootError {
83
+ return new BootError({
84
+ message,
85
+ reason: "config-path-relocated",
86
+ stage: STAGE,
87
+ details: { reason: "config-path-relocated", relocated: [...relocated] },
88
+ });
89
+ }
90
+
91
+ /** `environment-variable-renamed`: each old variable name set, with no value. */
92
+ export function variablesRenamed(
93
+ message: string,
94
+ renamed: EnvironmentVariableRenamedDetails["renamed"],
95
+ ): BootError {
96
+ return new BootError({
97
+ message,
98
+ reason: "environment-variable-renamed",
99
+ stage: STAGE,
100
+ details: { reason: "environment-variable-renamed", renamed: [...renamed] },
101
+ });
102
+ }
103
+
104
+ /** `module-section-path-invalid`: `module`'s section is read at `at`, which `problem` says it may not be. */
105
+ export function sectionPathRefused(
106
+ message: string,
107
+ module: string,
108
+ at: string,
109
+ problem: string,
110
+ ): BootError {
111
+ return new BootError({
112
+ message,
113
+ reason: "module-section-path-invalid",
114
+ stage: STAGE,
115
+ details: { reason: "module-section-path-invalid", module, at, problem },
116
+ });
117
+ }