@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,16 +14,23 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { fileURLToPath } from "node:url";
17
- import { gracefulShutdown } from "@o3co/auth.utils";
18
- import { type AppConfig, AppConfigSchema, createApp } from "@o3co/auth-provider-core";
17
+ import {
18
+ type AppConfig,
19
+ AppConfigSchema,
20
+ createApp,
21
+ createHealthcheckRouter,
22
+ createReadinessRouter,
23
+ } from "@o3co/auth-provider-core";
19
24
  import { parseFile } from "@o3co/ts.hocon";
20
25
  import { validate } from "@o3co/ts.hocon/zod";
21
26
  import express from "express";
22
27
  import helmet from "helmet";
23
-
24
- import logger from "#/logger.mjs";
25
28
  import { buildModules } from "./buildModules.mjs";
26
29
  import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
30
+ import { createAppLogger } from "./logger.mjs";
31
+ import { createMetrics } from "./metrics.mjs";
32
+ import { installGracefulShutdown } from "./shutdown.mjs";
33
+ import { createTerminalErrorHandler } from "./terminalError.mjs";
27
34
 
28
35
  // Step 1: Load and validate application config (HOCON → Zod schema).
29
36
  // ENV = CONFIG_ENV || NODE_ENV || "development"; missing {ENV}.conf is fatal.
@@ -44,9 +51,23 @@ const config: AppConfig = validate(
44
51
  AppConfigSchema,
45
52
  );
46
53
 
54
+ // The logger is built from config so its level is operator-controlled, and it
55
+ // is wired into `bootstrapComponents` so every module that declares
56
+ // `optional: ["logger"]` receives this one rather than falling back to its own
57
+ // default. Without that slot filled, configuring a level here would change
58
+ // nothing about what the modules emit.
59
+ const logger = createAppLogger(config);
60
+
47
61
  await (async (): Promise<void> => {
48
62
  // Step 2: Create the Express app and apply base security middleware.
49
63
  const app = express();
64
+ // `false` | `true` | a hop count | a list of IPs / CIDR ranges / named
65
+ // ranges — the schema hands Express exactly the shapes `trust proxy`
66
+ // already understands, validated at boot so a typo'd range fails here
67
+ // rather than becoming a rule that silently never matches (#292). Prefer
68
+ // naming the proxy: `true` believes a forwarded client address from anyone
69
+ // who can reach this process, and `req.ip` is what every IP-keyed rate
70
+ // limit is bucketed on.
50
71
  app.set("trust proxy", config.http.trustProxy);
51
72
  app.use(
52
73
  helmet({
@@ -59,6 +80,12 @@ await (async (): Promise<void> => {
59
80
  }),
60
81
  );
61
82
 
83
+ // Timing middleware goes ahead of everything so the histogram covers the
84
+ // whole stack — including responses produced by middleware that short-circuits
85
+ // before any route runs, such as the rate limiter's 429s.
86
+ const metrics = createMetrics();
87
+ app.use(metrics.middleware);
88
+
62
89
  // Step 3: Boot the auth pipeline.
63
90
  // D-5: express-session middleware is now wired by `sessionStoreModule`
64
91
  // inside the boot planner. Mount order is enforced by declarationIndex
@@ -77,17 +104,49 @@ await (async (): Promise<void> => {
77
104
  bootstrapComponents: {
78
105
  config,
79
106
  pathResolver: import.meta.resolve,
107
+ logger,
80
108
  },
81
109
  });
82
110
 
83
- // Step 4: Wire host-level routes (healthcheck) before the auth router so
84
- // they remain reachable even when the auth pipeline is degraded.
85
- app.get("/_healthcheck", (_req, res) => {
86
- res.status(200).json({ status: "ok" });
87
- });
111
+ // Step 4: Wire host-level routes before the auth router so they remain
112
+ // reachable even when the auth pipeline is degraded — which is exactly when
113
+ // an operator needs an answer from them.
114
+ //
115
+ // Liveness: the process is up and its event loop is turning. Deliberately
116
+ // static — restarting the process would not bring Redis back, so a Redis
117
+ // outage must not read as "this container is broken, kill it".
118
+ app.use(createHealthcheckRouter(express));
119
+
120
+ // Readiness: can this replica serve right now? Redis backs sessions,
121
+ // authorization codes and refresh-token families in the deployable
122
+ // defaults, so a replica that has lost it answers 503 here and should be
123
+ // taken out of rotation. Probes are contributed by the builders that own
124
+ // each connection; a memory-only deployment registers none and is always
125
+ // ready.
126
+ app.use(
127
+ createReadinessRouter(express, {
128
+ probes: handle.readinessProbes,
129
+ timeoutMs: config.http.readinessTimeoutMs,
130
+ logger,
131
+ }),
132
+ );
133
+
134
+ // Prometheus scrape endpoint. Same reasoning as the probes above: it has to
135
+ // keep answering while the auth pipeline is degraded, because that is when
136
+ // the series matter.
137
+ app.use(
138
+ metrics.route(express, {
139
+ probes: handle.readinessProbes,
140
+ probeTimeoutMs: config.http.readinessTimeoutMs,
141
+ }),
142
+ );
88
143
 
89
144
  // Step 5: Mount the composed auth router and start the HTTP server.
90
145
  app.use(handle.router);
146
+ // Terminal error handler LAST (#293 item 8): Express routes an error only
147
+ // to handlers registered after the route that threw it. See
148
+ // `terminalError.mts` for what it catches and why.
149
+ app.use(createTerminalErrorHandler(logger));
91
150
  const server = app.listen(config.http.port, (): void => {
92
151
  logger.info(`Server is running on http://localhost:${config.http.port}`);
93
152
  });
@@ -95,5 +154,14 @@ await (async (): Promise<void> => {
95
154
  // Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
96
155
  // per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
97
156
  // (Redis clients, interval timers).
98
- gracefulShutdown(server, () => handle.dispose());
157
+ //
158
+ // #290: in the template rather than a dependency, because "does SIGTERM
159
+ // wait for in-flight requests, and for how long?" has to be answerable
160
+ // from the code you deploy. See `shutdown.mts` for the stated guarantees
161
+ // and for the deadline the previous implementation did not have. Size
162
+ // `drainTimeoutMs` below your orchestrator's kill grace period.
163
+ installGracefulShutdown(server, {
164
+ logger,
165
+ cleanup: () => handle.dispose(),
166
+ });
99
167
  })();
@@ -19,6 +19,7 @@ import {
19
19
  defaultRefreshTokenFamilyRotationModule,
20
20
  jwksModule,
21
21
  type Module,
22
+ memoryAccessTokenDenylistModule,
22
23
  memoryRateLimiterModule,
23
24
  } from "@o3co/auth-provider-core";
24
25
  import { googleFederationModule } from "@o3co/auth-provider-federation-google";
@@ -28,6 +29,7 @@ import {
28
29
  oauthSessionModule,
29
30
  } from "@o3co/auth-provider-oauth";
30
31
  import {
32
+ redisAccessTokenDenylistModule,
31
33
  redisCodeRepositoryModule,
32
34
  redisRateLimiterModule,
33
35
  redisRefreshTokenFamilyStoreModule,
@@ -35,6 +37,7 @@ import {
35
37
  } from "@o3co/auth-provider-redis";
36
38
  import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
37
39
  import {
40
+ auditSinkModule,
38
41
  federationTokenStoreModule,
39
42
  googleFederationConfigModule,
40
43
  inMemoryCodeRepositoryModule,
@@ -65,6 +68,18 @@ export interface BuildModulesOverrides {
65
68
  * is dropped from the manifest unless the override list includes it.
66
69
  */
67
70
  readonly refreshTokenFamilyModules?: readonly Module[];
71
+ /**
72
+ * #287: override the module filling the `auditSink` slot. Tests substitute
73
+ * a sink they can assert on; the default writes the audit trail to stdout
74
+ * and is what a deployment gets.
75
+ *
76
+ * The override REPLACES the default — it does not add a second provider,
77
+ * which would be a boot-time slot collision. There is intentionally no way
78
+ * to pass "no audit sink": #304's sink policy is that the trail is always
79
+ * wired, and a composition that genuinely wants events discarded says so by
80
+ * providing a sink that discards them.
81
+ */
82
+ readonly auditSinkModule?: Module;
68
83
  }
69
84
 
70
85
  /**
@@ -95,6 +110,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
95
110
  // single ioredis socket per replica. Memory-only deployments skip it.
96
111
  const rateLimiterAdapter = config.rateLimiter?.adapter ?? "memory";
97
112
  const userSessionStoresAdapter = config.userSessionStores?.adapter ?? "memory";
113
+ // #277: the RFC 7009 access-token denylist. Unlike the switches above there
114
+ // is no "no denylist" branch — `oauthModule` reads the `accessTokenDenylist`
115
+ // slot because it mounts `/oauth/revoke`, and core's boot validator refuses
116
+ // a composition that reads the slot with nothing filling it, since the
117
+ // endpoint would answer 200 while the token kept working. The switch here is
118
+ // only over WHICH denylist.
119
+ const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
98
120
 
99
121
  // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
100
122
  // authoritative switch; the legacy `repositories.code.type = "redis"`
@@ -131,7 +153,8 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
131
153
  refreshTokenFamilyUsesRedis ||
132
154
  rateLimiterAdapter === "redis" ||
133
155
  userSessionStoresAdapter === "redis" ||
134
- codeRepositoryAdapter === "redis";
156
+ codeRepositoryAdapter === "redis" ||
157
+ accessTokenDenylistAdapter === "redis";
135
158
 
136
159
  // Federation-token store is always wired; only the 4 user-session
137
160
  // stores switch on the adapter. Pre-Wave-5d the redis branch dropped
@@ -156,6 +179,15 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
156
179
  ? [redisCodeRepositoryModule]
157
180
  : [inMemoryCodeRepositoryModule];
158
181
 
182
+ // #277: mutually-exclusive denylist pair, same shape as the three switches
183
+ // above. The memory branch is a dev convenience and nothing more: it forks
184
+ // per replica, so `deployment.mode = "multi"` refuses it by name (see core's
185
+ // replica-safety guard). The template's own application.conf ships `"redis"`.
186
+ const accessTokenDenylistModules: Module[] =
187
+ accessTokenDenylistAdapter === "redis"
188
+ ? [redisAccessTokenDenylistModule]
189
+ : [memoryAccessTokenDenylistModule];
190
+
159
191
  return [
160
192
  // D-5: sessionStoreModule wires the express-session middleware into the
161
193
  // boot-planner-managed lifecycle. **Mount order is enforced by this
@@ -177,6 +209,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
177
209
  ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
178
210
  overrides.keyStoreModule ?? keyStoreModule,
179
211
  overrides.repositoriesModule ?? repositoriesModule,
212
+ // #287: the audit sink, always wired. Unlike every switch above there is
213
+ // no "off" branch — the routes' security events (`token.issued.failure`,
214
+ // `authorize.rejected`, `rate_limit.unavailable`, …) went nowhere in the
215
+ // shipped artifact because the slot was empty and `emitAuditEvent`
216
+ // no-ops when it is. Which sink is a config question
217
+ // (`audit.sink.type`); whether there is one is not.
218
+ overrides.auditSinkModule ?? auditSinkModule,
180
219
  // Shared ioredis clients — only when at least one consumer adapter
181
220
  // actually needs Redis. Memory-only deployments skip this so they
182
221
  // don't open an unused socket.
@@ -192,6 +231,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
192
231
  ...rateLimiterModules,
193
232
  // OAuth code repository: redis (multi-replica) or memory (single-instance).
194
233
  ...codeRepositoryModules,
234
+ // RFC 7009 access-token denylist: redis (shared revocations) or memory
235
+ // (single-instance dev). Always wired — see the switch above.
236
+ ...accessTokenDenylistModules,
195
237
  // RT family store: redis by default (closes OR-1); override path
196
238
  // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
197
239
  ...refreshTokenFamilyModules,
@@ -31,8 +31,8 @@ const REFERENCE_CONF_SUBPATH = "@o3co/auth-provider-core/reference.conf";
31
31
  * 3-tier HOCON precedence chain (reference.conf → application.conf → {env}.conf).
32
32
  *
33
33
  * Primary path: `import.meta.resolve` (Node.js Stability 1.2 RC, unflagged
34
- * since Node 18.19.0 / 20.6.0 — the engine floor of every package in this
35
- * scope). Fallback: `createRequire(import.meta.url).resolve(...)`. The
34
+ * since Node 18.19.0 / 20.6.0 — well below this scope's Node 22 engine floor,
35
+ * so it is always available). Fallback: `createRequire(import.meta.url).resolve(...)`. The
36
36
  * fallback covers two edge cases:
37
37
  *
38
38
  * 1. A future Node release deprecates or alters the sync form of
@@ -13,6 +13,94 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { createLogger } from "@o3co/auth.utils";
16
+ import type { AppConfig, AuditSink, Logger } from "@o3co/auth-provider-core";
17
+ import { pino, stdSerializers } from "pino";
17
18
 
18
- export default createLogger("provider");
19
+ /**
20
+ * The composition root's logger, and the value wired into the `logger`
21
+ * ComponentMap slot that every module reads.
22
+ *
23
+ * pino, emitting newline-delimited JSON on stdout — the shape every log
24
+ * aggregator ingests without a parser, and the reason `Logger` was given
25
+ * pino's two-overload call signature in the first place. A pino instance
26
+ * therefore satisfies `Logger` structurally, with no adapter.
27
+ *
28
+ * Two things this closes:
29
+ *
30
+ * - **The slot was never filled.** Only a value satisfying `Logger` can occupy
31
+ * it, and the logger this template used before exposes four methods with a
32
+ * narrower call shape. So every module that declares `optional: ["logger"]`
33
+ * — oauth, session, dpop, mtls, token-exchange — silently fell back to its
34
+ * own `consoleLogger` default, and nothing an operator configured here
35
+ * reached any of them.
36
+ * - **`logging.level` is honoured.** pino drops sub-threshold calls before
37
+ * formatting, so `trace` / `debug` cost nothing in production rather than
38
+ * being emitted and filtered downstream.
39
+ *
40
+ * `silent` is pino's own name for "emit nothing", so the config vocabulary
41
+ * maps across unchanged.
42
+ */
43
+ export function createAppLogger(config: AppConfig): Logger {
44
+ return pino({
45
+ name: "provider",
46
+ level: config.logging.level,
47
+ // `err` is pino's conventional key for an Error, and every structured
48
+ // event in this stack uses it — `logger.error({ err }, "…_error")`.
49
+ // Without the serialiser an Error stringifies to `{}` and the stack is
50
+ // lost exactly where it is needed.
51
+ serializers: { err: stdSerializers.err },
52
+ });
53
+ }
54
+
55
+ /**
56
+ * The stream the audit trail is written on: `name: "audit"`, newline-delimited
57
+ * JSON on stdout, in the same pino envelope as every other line this template
58
+ * emits. One output style, so an aggregator ingests security events and
59
+ * application logs through one parser and separates them on `name`.
60
+ *
61
+ * **It takes no config, and its level is fixed.** That is the point rather
62
+ * than an oversight. `logging.level` is a diagnostics knob — `warn` is an
63
+ * ordinary production setting and `silent` a legitimate one — and an audit
64
+ * trail is evidence, not diagnostics. Routing audit events through the
65
+ * application logger would mean either of those settings silently deleting
66
+ * the record of who authenticated, what was issued, and what was refused:
67
+ * the same drop #287 is about, reached from the operator's side instead of
68
+ * the scaffold's. Where audit events go is chosen by `audit.sink.type`, and
69
+ * that selector has no "none" (#304).
70
+ *
71
+ * A second pino instance rather than a child of the app logger: a child
72
+ * inherits its parent's level, and depending on a level-override subtlety to
73
+ * keep the audit trail alive is exactly the kind of thing that stops being
74
+ * true in a later refactor without anything failing.
75
+ */
76
+ export function createAuditLogger(): Logger {
77
+ return pino({
78
+ name: "audit",
79
+ level: "info",
80
+ serializers: { err: stdSerializers.err },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * `AuditSink` writing each event through `auditLogger` — the `"logger"` sink
86
+ * kind this template registers and ships as its default.
87
+ *
88
+ * The event is nested under `audit` rather than spread at the top level:
89
+ * `level`, `time`, `name` and `msg` belong to the log envelope, and a future
90
+ * audit field colliding with one of them would corrupt the line for every
91
+ * consumer. The event type doubles as the message so operators alert on the
92
+ * name — `token.issued.failure`, `authorize.rejected` — the same way they
93
+ * already alert on `session_store_redis_error`.
94
+ *
95
+ * Errors are core's problem, not this function's: `emitAuditEvent` dispatches
96
+ * without awaiting and swallows rejections, because audit recording must never
97
+ * add latency to (or fail) an auth flow.
98
+ */
99
+ export function createLoggerAuditSink(auditLogger: Logger): AuditSink {
100
+ return {
101
+ kind: "logger",
102
+ async record(event) {
103
+ auditLogger.info({ audit: event }, event.type);
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,190 @@
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
+ import type { ReadinessProbe } from "@o3co/auth-provider-core";
18
+ import { runReadinessProbes } from "@o3co/auth-provider-core";
19
+ import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
20
+ import { collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client";
21
+
22
+ /**
23
+ * Route label for a request.
24
+ *
25
+ * Express only fills `req.route` once a handler has matched, and the label has
26
+ * to be **bounded**: labelling by `req.path` would mint a new time series per
27
+ * distinct URL, and this server's paths carry opaque values (`/oauth/authorize`
28
+ * query state, 404 probes from the internet). An unbounded label set is the
29
+ * classic way to take Prometheus down with the thing that was supposed to
30
+ * watch it. Unmatched requests collapse to a single `unmatched` bucket.
31
+ */
32
+ /**
33
+ * Method label, bounded to the methods this server can actually serve.
34
+ *
35
+ * `req.method` is attacker-controlled: Node's HTTP parser accepts any valid
36
+ * token, so `FOO` and `M000001` reach here as readily as `GET`. Each distinct
37
+ * value would mint a fresh histogram child carrying every bucket — the same
38
+ * unbounded-cardinality failure {@link routeLabel} guards against, one label
39
+ * over, and reachable without any access to `/metrics` itself.
40
+ */
41
+ const KNOWN_METHODS = new Set([
42
+ "GET",
43
+ "HEAD",
44
+ "POST",
45
+ "PUT",
46
+ "PATCH",
47
+ "DELETE",
48
+ "OPTIONS",
49
+ "TRACE",
50
+ "CONNECT",
51
+ ]);
52
+
53
+ function methodLabel(req: Request): string {
54
+ return KNOWN_METHODS.has(req.method) ? req.method : "other";
55
+ }
56
+
57
+ function routeLabel(req: Request): string {
58
+ const route = (req as Request & { route?: { path?: string } }).route?.path;
59
+ if (typeof route === "string" && route.length > 0) {
60
+ return req.baseUrl ? `${req.baseUrl}${route === "/" ? "" : route}` : route;
61
+ }
62
+ return "unmatched";
63
+ }
64
+
65
+ export interface MetricsRouteOptions {
66
+ /**
67
+ * Readiness probes, sampled on each scrape to publish a per-dependency
68
+ * up/down gauge. Pass `handle.readinessProbes`.
69
+ */
70
+ readonly probes: readonly ReadinessProbe[];
71
+ /** Per-probe deadline, in milliseconds. Use `config.http.readinessTimeoutMs`. */
72
+ readonly probeTimeoutMs: number;
73
+ /** Route path. Defaults to `/metrics`. */
74
+ readonly path?: string;
75
+ }
76
+
77
+ export interface Metrics {
78
+ /**
79
+ * Mount FIRST, ahead of every route: it times the whole downstream stack,
80
+ * and it records on the response's `finish` event so a request that ends in
81
+ * an error handler is still counted.
82
+ */
83
+ readonly middleware: RequestHandler;
84
+ /**
85
+ * Mounts the scrape endpoint.
86
+ *
87
+ * The probes arrive here rather than at construction because the two halves
88
+ * are needed at different moments: the middleware has to be mounted before
89
+ * the app boots, and the probes only exist afterwards, on the `AppHandle`.
90
+ */
91
+ readonly route: (express: { Router: () => Router }, options: MetricsRouteOptions) => Router;
92
+ }
93
+
94
+ /**
95
+ * Prometheus metrics for the standalone provider.
96
+ *
97
+ * What this publishes is chosen from what an operator cannot otherwise see.
98
+ * Before it, a degraded provider offered stderr lines and the load balancer's
99
+ * 5xx graph — no way to distinguish "rate limiter is failing closed" from
100
+ * "Redis-backed code repository is gone" without grepping container logs, and
101
+ * no latency series to alert on at all.
102
+ *
103
+ * - `http_request_duration_seconds` — histogram by method, bounded route, and
104
+ * status. Carries request rate, error rate and latency in one series, which
105
+ * is the whole RED method.
106
+ * - `auth_dependency_up` — 1/0 per readiness probe, sampled at scrape time.
107
+ * This is the series that separates "Redis is gone" from "the app is slow",
108
+ * and it reuses the probes the builders already registered rather than
109
+ * inventing a second notion of what this deployment depends on.
110
+ * - Node process defaults (event-loop lag, heap, GC, handles) under the
111
+ * `auth_provider_` prefix.
112
+ *
113
+ * Deliberately not here yet: rate-limiter fail-closed counts and audit-sink
114
+ * drops. Both happen inside `@o3co/auth-provider-core` and neither is
115
+ * observable from the composition root today; counting them needs a metrics
116
+ * hook in core (`BuilderContext` already reserves `metrics`) rather than
117
+ * something this file can reach.
118
+ */
119
+ export function createMetrics(): Metrics {
120
+ const registry = new Registry();
121
+ collectDefaultMetrics({ register: registry, prefix: "auth_provider_" });
122
+
123
+ const requestDuration = new Histogram({
124
+ name: "http_request_duration_seconds",
125
+ help: "HTTP request latency in seconds, by method, route and status.",
126
+ labelNames: ["method", "route", "status"] as const,
127
+ // Tuned for an IdP: token and authorize calls sit in the low tens of
128
+ // milliseconds when healthy, and the tail is what an outage moves.
129
+ buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
130
+ registers: [registry],
131
+ });
132
+
133
+ const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
134
+ const endTimer = requestDuration.startTimer();
135
+ let observed = false;
136
+ // Both terminal events, with a guard, rather than `finish` alone.
137
+ // `finish` covers every response the server completes — including ones
138
+ // produced by an error handler, and `req.route` is populated by then. But
139
+ // a client or proxy that disconnects mid-handler emits `close` WITHOUT
140
+ // `finish`, and those are disproportionately the slow and failing requests
141
+ // that RED metrics exist to surface; counting only `finish` would drop
142
+ // them from both the rate and the latency series.
143
+ const observe = () => {
144
+ if (observed) return;
145
+ observed = true;
146
+ endTimer({
147
+ method: methodLabel(req),
148
+ route: routeLabel(req),
149
+ status: String(res.statusCode),
150
+ });
151
+ };
152
+ res.once("finish", observe);
153
+ res.once("close", observe);
154
+ next();
155
+ };
156
+
157
+ const dependencyUp = new Gauge({
158
+ name: "auth_dependency_up",
159
+ help: "1 when a backing dependency answered its readiness probe, 0 otherwise.",
160
+ labelNames: ["dependency"] as const,
161
+ registers: [registry],
162
+ });
163
+
164
+ // Shared across scrapes so a partitioned backend does not accumulate one
165
+ // in-flight command per scrape (see `runReadinessProbes`).
166
+ const inFlight = new Map<string, Promise<unknown>>();
167
+
168
+ const route = (express: { Router: () => Router }, opts: MetricsRouteOptions): Router => {
169
+ const router = express.Router();
170
+ router.get(opts.path ?? "/metrics", async (_req: Request, res: Response) => {
171
+ // Sampled per scrape rather than held as state: a cached value would
172
+ // report a dependency healthy for as long as the cache lives, which is
173
+ // exactly the window an incident lives in.
174
+ const report = await runReadinessProbes(opts.probes, {
175
+ timeoutMs: opts.probeTimeoutMs,
176
+ inFlight,
177
+ });
178
+ for (const check of report.checks) {
179
+ dependencyUp.set({ dependency: check.name }, check.ok ? 1 : 0);
180
+ }
181
+
182
+ res.setHeader("Content-Type", registry.contentType);
183
+ res.setHeader("Cache-Control", "no-store");
184
+ res.send(await registry.metrics());
185
+ });
186
+ return router;
187
+ };
188
+
189
+ return { middleware, route };
190
+ }