@o3co/create-auth-provider 0.14.0 → 0.16.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 (45) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +34 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +565 -130
  7. package/templates/standalone/README.md +356 -92
  8. package/templates/standalone/config/application.conf +77 -2
  9. package/templates/standalone/docker-compose.production.yml +7 -0
  10. package/templates/standalone/docker-compose.yml +7 -0
  11. package/templates/standalone/gitignore +3 -2
  12. package/templates/standalone/package.json +3 -2
  13. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  14. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  16. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
  18. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
  19. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
  20. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  21. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
  22. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  23. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  24. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  25. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  26. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  27. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  28. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  29. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  30. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  31. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
  32. package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
  33. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  34. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  35. package/templates/standalone/src/app.mts +30 -51
  36. package/templates/standalone/src/buildModules.mts +94 -12
  37. package/templates/standalone/src/listen.mts +66 -0
  38. package/templates/standalone/src/logger.mts +7 -4
  39. package/templates/standalone/src/modules.mts +118 -12
  40. package/templates/standalone/src/routes.mts +95 -0
  41. package/templates/standalone/src/shutdown.mts +90 -18
  42. package/templates/standalone/vitest.supertest-loopback.mts +22 -3
  43. package/templates/versions.json +15 -14
  44. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  45. package/templates/standalone/src/terminalError.mts +0 -60
@@ -44,10 +44,13 @@ export function createAppLogger(config: AppConfig): Logger {
44
44
  return pino({
45
45
  name: "provider",
46
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.
47
+ // `err` is pino's conventional key for an error, and every structured
48
+ // event in this stack uses it — `logger.error({ err: loggableError(err) },
49
+ // "…_error")`. Core's projection is plain data with no `message`, so
50
+ // this serialiser hands it through as it is, every cause's fields
51
+ // included. It is here for an Error handed to it as it is, which would
52
+ // otherwise stringify to `{}`, the stack lost exactly where it is
53
+ // needed.
51
54
  serializers: { err: stdSerializers.err },
52
55
  });
53
56
  }
@@ -30,6 +30,7 @@ import {
30
30
  defineModule,
31
31
  type LifecycleRegistrar,
32
32
  type Logger,
33
+ loggableError,
33
34
  type Module,
34
35
  type ReadinessRegistrar,
35
36
  registerBuiltinAuditSinks,
@@ -39,7 +40,11 @@ import {
39
40
  import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
40
41
  import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
41
42
  import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
42
- import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
43
+ import {
44
+ makeIoredisClients,
45
+ makeIoredisFederationGrantIntentStoreClient,
46
+ makeIoredisFederationGrantStoreClient,
47
+ } from "@o3co/auth-provider-redis/ioredis";
43
48
  import { extractFederationSection } from "@o3co/auth-provider-session";
44
49
  // Named import is required here, not default. ioredis is CJS and its entry
45
50
  // does `module.exports = Redis` with the class re-exported as both `default`
@@ -366,7 +371,11 @@ export const storesModule: Module = defineModule({
366
371
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
367
372
  * subject-level revocation index + watermark + rate limiter + code
368
373
  * repository + access-token denylist + replay seen-set + federation token
369
- * store + device-code store + the consent stores).
374
+ * store + device-code store + WebAuthn challenge store + the consent stores),
375
+ * and the two federation-grant stores' clients from their own factories.
376
+ * Every store module `@o3co/auth-provider-redis` ships finds its client
377
+ * slot here, including the ones this template does not select — pinned by
378
+ * `all-modules-composition.multi.test.mts`.
370
379
  *
371
380
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
372
381
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -392,11 +401,12 @@ export const storesModule: Module = defineModule({
392
401
  * the single connection cleanly.
393
402
  *
394
403
  * Conditional inclusion: `buildModules` only adds this module to the
395
- * manifest when at least one Redis-backed adapter is selected
396
- * (`userSessionStores.adapter = "redis"` OR `rateLimiter.adapter = "redis"`
397
- * — refresh-token-family is always Redis-backed in production). Adding the
398
- * module unconditionally would open an ioredis socket for memory-only
399
- * deployments.
404
+ * manifest when at least one composed module needs a Redis client
405
+ * (`usingRedisAnywhere` there): an adapter switch set to `"redis"` — the two
406
+ * federation-grant store switches only while `federationGrants.enabled` —
407
+ * and the refresh-token family store, which is Redis-backed unless a test
408
+ * override replaces it. Adding the module unconditionally would open an
409
+ * ioredis socket for memory-only compositions.
400
410
  */
401
411
  export const standaloneRedisClientsModule: Module = defineModule({
402
412
  name: "standalone:redis-clients",
@@ -438,6 +448,28 @@ export const standaloneRedisClientsModule: Module = defineModule({
438
448
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
439
449
  .sessionFederationIndexClient;
440
450
  },
451
+ // #593 slice 7: the grant store and the intent store, off the same
452
+ // socket. Consumed by the Redis grant modules when their switches say
453
+ // "redis"; provided whenever this module is installed, as every slot
454
+ // here is, because a slot is cheap and the socket is the cost.
455
+ federationGrantStoreClient: async ({
456
+ config,
457
+ lifecycleRegistrar,
458
+ readinessRegistrar,
459
+ logger,
460
+ }) => {
461
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
462
+ .federationGrantStoreClient;
463
+ },
464
+ federationGrantIntentStoreClient: async ({
465
+ config,
466
+ lifecycleRegistrar,
467
+ readinessRegistrar,
468
+ logger,
469
+ }) => {
470
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
471
+ .federationGrantIntentStoreClient;
472
+ },
441
473
  // #484: the replay seen-set behind private_key_jwt client assertions.
442
474
  replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
443
475
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
@@ -505,6 +537,15 @@ export const standaloneRedisClientsModule: Module = defineModule({
505
537
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
506
538
  .deviceCodeStoreClient;
507
539
  },
540
+ // The WebAuthn challenge store's client, off the same shared socket, for
541
+ // the reason the device-code slot above is here: this template does not
542
+ // mount WebAuthn, and a deployment that adds `webauthnModule` with
543
+ // `redisChallengeStoreModule` would otherwise be refused at boot for a
544
+ // `challengeStoreClient` nothing provided.
545
+ challengeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
546
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
547
+ .challengeStoreClient;
548
+ },
508
549
  // #561: the consent stores' clients, off the same shared socket.
509
550
  // `redisConsentStoreModule` requires both — it provides the consent
510
551
  // records and the parked requests together — and `buildModules` selects
@@ -630,13 +671,26 @@ const SHARED_REDIS_TIMEOUTS = {
630
671
  // on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
631
672
  // that don't seed it), each call creates a fresh client; tests are
632
673
  // isolated and don't need cross-slot sharing.
633
- const clientsCache = new WeakMap<LifecycleRegistrar, ReturnType<typeof makeIoredisClients>>();
674
+ /**
675
+ * Every per-purpose client off the one socket: the general bundle, plus the
676
+ * two the federation-grant stores need (#593 slice 7). Those two have their
677
+ * own factories in the Redis package, so a Cluster deployment can give the
678
+ * grants a connection of their own; this template shares the socket, as it
679
+ * does for every other store.
680
+ */
681
+ type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
682
+ readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
683
+ readonly federationGrantIntentStoreClient: ReturnType<
684
+ typeof makeIoredisFederationGrantIntentStoreClient
685
+ >;
686
+ };
687
+ const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
634
688
  function getOrCreateClients(
635
689
  config: AppConfig,
636
690
  lifecycleRegistrar: LifecycleRegistrar | undefined,
637
691
  readinessRegistrar?: ReadinessRegistrar,
638
692
  injectedLogger?: Logger,
639
- ): ReturnType<typeof makeIoredisClients> {
693
+ ): StandaloneRedisClients {
640
694
  const logger = injectedLogger ?? consoleLogger;
641
695
  const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
642
696
  if (cached) return cached;
@@ -656,9 +710,11 @@ function getOrCreateClients(
656
710
 
657
711
  // Attach an error handler so unhandled "error" events do not crash the
658
712
  // process. Initial connection failures surface here; downstream adapter
659
- // operations then fail visibly.
713
+ // operations then fail visibly. It logs the projection: ioredis puts the
714
+ // command a reply answered on the error, and for a refused handshake that
715
+ // is `AUTH` with the configured password.
660
716
  io.on("error", (err: unknown) => {
661
- logger.error({ err }, "standalone_redis_clients_error");
717
+ logger.error({ err: loggableError(err) }, "standalone_redis_clients_error");
662
718
  });
663
719
 
664
720
  lifecycleRegistrar?.register(async () => {
@@ -676,7 +732,11 @@ function getOrCreateClients(
676
732
  // The wrapper opens its own connections for refresh rotation
677
733
  // (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
678
734
  // `io`; passing the logger lets those report through the same channel.
679
- const clients = makeIoredisClients(io, { logger });
735
+ const clients: StandaloneRedisClients = {
736
+ ...makeIoredisClients(io, { logger }),
737
+ federationGrantStoreClient: makeIoredisFederationGrantStoreClient(io),
738
+ federationGrantIntentStoreClient: makeIoredisFederationGrantIntentStoreClient(io),
739
+ };
680
740
  if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
681
741
  return clients;
682
742
  }
@@ -703,6 +763,47 @@ function optionalString(
703
763
  return { [field]: value };
704
764
  }
705
765
 
766
+ /**
767
+ * An optional boolean from the `federations.google` slice. HOCON gives a
768
+ * boolean; an environment override (`${?VAR}`) gives the string, in the
769
+ * spellings core's `coerceBooleanFromEnv` accepts: "true" / "false" / "1" /
770
+ * "0", trimmed and in any case. One deliberate difference: that coercion reads
771
+ * an empty value as false, and this refuses it. HOCON substitutes an
772
+ * exported-but-empty variable as "", and for a security switch that must not
773
+ * quietly turn the check off. Anything else is refused too — a typo that
774
+ * silently reads as either value is worse than a boot failure.
775
+ */
776
+ function optionalBoolean(
777
+ slice: Record<string, unknown>,
778
+ field: string,
779
+ ): Record<string, boolean> | Record<string, never> {
780
+ const value = slice[field];
781
+ if (value === undefined || value === null) return {};
782
+ if (typeof value === "boolean") return { [field]: value };
783
+ if (typeof value === "string") {
784
+ const normalized = value.trim().toLowerCase();
785
+ if (normalized === "true" || normalized === "1") return { [field]: true };
786
+ if (normalized === "false" || normalized === "0") return { [field]: false };
787
+ }
788
+ throw new Error(
789
+ `federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
790
+ );
791
+ }
792
+
793
+ /**
794
+ * `accessType` from the `federations.google` slice: `"offline"` or `"online"`,
795
+ * exactly, or absent. `federation-google` refuses any other value too; this
796
+ * refuses it first, naming the key, as the fields above do.
797
+ */
798
+ function optionalAccessType(
799
+ slice: Record<string, unknown>,
800
+ ): { accessType: "offline" | "online" } | Record<string, never> {
801
+ const value = slice.accessType;
802
+ if (value === undefined || value === null) return {};
803
+ if (value === "offline" || value === "online") return { accessType: value };
804
+ throw new Error('federations.google.accessType must be "offline" or "online" when present');
805
+ }
806
+
706
807
  /**
707
808
  * Google federation config bridge — supplies the typed `googleFederationConfig`
708
809
  * ComponentMap slot from the `config.federations.google` slice.
@@ -769,6 +870,11 @@ export const googleFederationConfigModule: Module = defineModule({
769
870
  ...optionalString(slice, "sessionDomain"),
770
871
  ...optionalString(slice, "authCallbackUrl"),
771
872
  ...optionalString(slice, "clientUrl"),
873
+ // #597: absent means the provider's default, which is to require it.
874
+ ...optionalBoolean(slice, "requireAuthorizationResponseIss"),
875
+ // Absent means the provider's default, "offline": consent on every
876
+ // sign-in, and a refresh token for every session.
877
+ ...optionalAccessType(slice),
772
878
  };
773
879
  },
774
880
  },
@@ -0,0 +1,95 @@
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
27
+ * the composed router, so an error one of the host's routes lets through
28
+ * is answered as every other route's is: `500 server_error` /
29
+ * `unexpected_error` in the RFC 6749 envelope with `Cache-Control:
30
+ * no-store`, logged once as `unhandled_request_error` with the path through
31
+ * `auditErrorText` and the error's `loggableError` projection; a refusal an
32
+ * `http-errors` error marks as the client's keeps its 4xx; a response whose
33
+ * headers already went out is closed rather than handed to Express's final
34
+ * handler, which would print the stack.
35
+ */
36
+
37
+ import {
38
+ createHealthcheckRouter,
39
+ createReadinessRouter,
40
+ type Logger,
41
+ type ReadinessProbe,
42
+ terminalErrorHandler,
43
+ } from "@o3co/auth-provider-core";
44
+ import express, { type Express, type Router } from "express";
45
+ import type { Metrics } from "./metrics.mjs";
46
+
47
+ export interface MountRoutesOptions {
48
+ /** The router `createApp` composed (`handle.router`). */
49
+ readonly router: Router;
50
+ /** The readiness probes the builders registered (`handle.readinessProbes`). */
51
+ readonly probes: readonly ReadinessProbe[];
52
+ /** Per-probe deadline, in milliseconds: `config.http.readinessTimeoutMs`. */
53
+ readonly readinessTimeoutMs: number;
54
+ readonly metrics: Metrics;
55
+ readonly logger: Logger;
56
+ }
57
+
58
+ /** Mount the host's routes, the composed router and the terminal error handler on `app`. */
59
+ export function mountRoutes(app: Express, options: MountRoutesOptions): void {
60
+ // Liveness: the process is up and its event loop is turning. Deliberately
61
+ // static — restarting the process would not bring Redis back, so a Redis
62
+ // outage must not read as "this container is broken, kill it".
63
+ app.use(createHealthcheckRouter(express));
64
+
65
+ // Readiness: can this replica serve right now? Redis backs sessions,
66
+ // authorization codes and refresh-token families in the deployable
67
+ // defaults, so a replica that has lost it answers 503 here and should be
68
+ // taken out of rotation. Probes are contributed by the builders that own
69
+ // each connection; a memory-only deployment registers none and is always
70
+ // ready.
71
+ app.use(
72
+ createReadinessRouter(express, {
73
+ probes: options.probes,
74
+ timeoutMs: options.readinessTimeoutMs,
75
+ logger: options.logger,
76
+ }),
77
+ );
78
+
79
+ // Prometheus scrape endpoint. Same reasoning as the probes above: it has to
80
+ // keep answering while the auth pipeline is degraded, because that is when
81
+ // the series matter.
82
+ app.use(
83
+ options.metrics.route(express, {
84
+ probes: options.probes,
85
+ probeTimeoutMs: options.readinessTimeoutMs,
86
+ }),
87
+ );
88
+
89
+ // The composed auth router, which ends in core's terminal handler.
90
+ app.use(options.router);
91
+ // Core's terminal handler again, LAST (#293 item 8), for the host routes
92
+ // above: Express routes an error only to handlers registered after the
93
+ // route that threw it. See the file header.
94
+ app.use(terminalErrorHandler(options.logger));
95
+ }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { Server } from "node:http";
18
- import type { Logger } from "@o3co/auth-provider-core";
18
+ import { type Logger, loggableError } from "@o3co/auth-provider-core";
19
19
 
20
20
  /**
21
21
  * Graceful shutdown for the scaffolded server (#290).
@@ -54,17 +54,25 @@ import type { Logger } from "@o3co/auth-provider-core";
54
54
  * and the process exits **non-zero** — an orchestrator that only ever sees
55
55
  * `0` cannot tell a clean drain from one that ran out of time.
56
56
  * 5. **`cleanup` runs after draining, before exit**, and its failure is logged
57
- * through the app logger and reflected in the exit code. It never wedges the
58
- * process: a dispose that throws still exits, and one that never settles is
59
- * cut off at `cleanupTimeoutMs`.
57
+ * through the app logger — as core's `loggableError` projection, never the
58
+ * error, which holds every cleanup's own error — and reflected in the exit
59
+ * code. It never wedges the process: a dispose that throws still exits, and
60
+ * one that never settles is cut off at `cleanupTimeoutMs`.
60
61
  * 6. **A `close` that fails is not reported as a clean drain.** `server.close`
61
62
  * reports through its callback, and treating that as success would tell an
62
63
  * orchestrator the listener came down when it did not.
63
64
  *
64
- * Size `drainTimeoutMs` **below** the orchestrator's own kill grace period
65
- * (Kubernetes `terminationGracePeriodSeconds`, compose `stop_grace_period`,
66
- * both 30s by default) — the point is to close on our terms before SIGKILL
67
- * arrives on someone else's.
65
+ * Every stage is one object-first line under a snake_case event name:
66
+ * `shutdown_draining` (info, `drainTimeoutMs`), `shutdown_drain_deadline_exceeded`
67
+ * (error), `shutdown_server_close_failed` (error, `err`), `shutdown_cleanup_timed_out`
68
+ * (error, `cleanupTimeoutMs`), `shutdown_cleanup_failed` (error, `err`) and
69
+ * `shutdown_complete` (info, `reason`, `drain`, `exitCode`).
70
+ *
71
+ * Size `drainTimeoutMs` plus `cleanupTimeoutMs` **below** the orchestrator's
72
+ * own kill grace period (Kubernetes `terminationGracePeriodSeconds` is 30s by
73
+ * default, compose `stop_grace_period` 10s; with federation grants on the sum
74
+ * is 55s, so the grace is 60s or more) — the point is to close on our terms
75
+ * before SIGKILL arrives on someone else's.
68
76
  */
69
77
  export interface GracefulShutdownOptions {
70
78
  readonly logger: Logger;
@@ -102,6 +110,71 @@ export function deferExit(code: number, exitProcess: (code: number) => void = pr
102
110
  const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
103
111
  const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
104
112
 
113
+ /**
114
+ * The least cleanup allowance federation grants get (#593 slice 7): 45
115
+ * seconds. The package's background registry drains on dispose — it waits for
116
+ * a rotated upstream credential's write — and the longest tail one refresh
117
+ * can have is the upstream hard timeout, the persist budget and the wait for
118
+ * the grant's lock, back to back. With the shipped budgets (25 s + 3 s + 5 s)
119
+ * and {@link FEDERATION_GRANTS_CLEANUP_MARGIN_MS} that is exactly this floor;
120
+ * a deployment that raises a budget raises the allowance with it, below.
121
+ * The ten-second drain that cleanup would otherwise inherit is shorter than
122
+ * the tail, and a shutdown under it would abandon exactly the write the drain
123
+ * exists to wait for, after the IdP had moved on to the new refresh token.
124
+ *
125
+ * A host policy, not a grant setting: what it bounds is `handle.dispose()`,
126
+ * and the orchestrator's grace period has to cover the drain, this and an exit
127
+ * margin — the compose files ship 60 seconds for the shipped budgets, and an
128
+ * operator who raises a budget raises the grace to match.
129
+ */
130
+ export const FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS = 45_000;
131
+
132
+ /** What the allowance adds to the longest refresh tail: an exit margin. */
133
+ export const FEDERATION_GRANTS_CLEANUP_MARGIN_MS = 12_000;
134
+
135
+ /**
136
+ * The most a timer can be asked for: Node's `setTimeout` takes a 32-bit signed
137
+ * delay, and a larger one fires after ~1 ms instead. A sum of budgets an
138
+ * operator set high enough to reach it would otherwise turn the allowance
139
+ * into no allowance at all (Copilot, #614).
140
+ */
141
+ const MAX_TIMER_MS = 2_147_483_647;
142
+
143
+ /**
144
+ * The `cleanupTimeoutMs` to hand {@link installGracefulShutdown}, from the
145
+ * config: while the feature is on, the longer of the floor above and the
146
+ * configured refresh tail plus the margin; while it is off, nothing — the
147
+ * drain's own budget, as before. A fragment to spread, so an absent allowance
148
+ * is absent rather than `undefined`. A config that carries no budgets (one
149
+ * built by hand, without `reference.conf`) gets the floor.
150
+ */
151
+ export function cleanupAllowanceFor(config: {
152
+ readonly federationGrants?:
153
+ | {
154
+ readonly enabled?: boolean | undefined;
155
+ readonly upstreamHardTimeoutMs?: number | undefined;
156
+ readonly persistRetryBudgetMs?: number | undefined;
157
+ readonly lockWaitMs?: number | undefined;
158
+ }
159
+ | undefined;
160
+ }): { readonly cleanupTimeoutMs: number } | Record<string, never> {
161
+ const grants = config.federationGrants;
162
+ if (grants?.enabled !== true) return {};
163
+ const budgets = [grants.upstreamHardTimeoutMs, grants.persistRetryBudgetMs, grants.lockWaitMs];
164
+ const tail = budgets.every((budget) => typeof budget === "number" && Number.isFinite(budget))
165
+ ? (budgets as number[]).reduce(
166
+ (sum, budget) => sum + budget,
167
+ FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
168
+ )
169
+ : 0;
170
+ return {
171
+ cleanupTimeoutMs: Math.min(
172
+ Math.max(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS, tail),
173
+ MAX_TIMER_MS,
174
+ ),
175
+ };
176
+ }
177
+
105
178
  export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
106
179
  const {
107
180
  logger,
@@ -160,7 +233,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
160
233
  let outcome = reason;
161
234
  try {
162
235
  if ((await runCleanup()) === CLEANUP_TIMED_OUT) {
163
- logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out");
236
+ logger.error({ cleanupTimeoutMs }, "shutdown_cleanup_timed_out");
164
237
  exitCode = 1;
165
238
  outcome = "cleanup-timeout";
166
239
  }
@@ -168,12 +241,14 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
168
241
  // Through the app logger, not `console.error`: a shutdown that
169
242
  // failed to release its Redis connections is exactly the line an
170
243
  // operator needs to find later, and a bare write is the one their
171
- // pipeline drops.
172
- logger.error({ err }, "graceful shutdown: cleanup failed");
244
+ // pipeline drops. The projection, not the error: `dispose()`
245
+ // rejects with every cleanup's own error on `errors`, a store's
246
+ // write — and what it wrote — among them.
247
+ logger.error({ err: loggableError(err) }, "shutdown_cleanup_failed");
173
248
  exitCode = 1;
174
249
  outcome = "cleanup-failed";
175
250
  }
176
- logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete");
251
+ logger.info({ reason: outcome, drain: reason, exitCode }, "shutdown_complete");
177
252
  exit(exitCode);
178
253
  };
179
254
 
@@ -181,13 +256,10 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
181
256
  if (shuttingDown) return;
182
257
  shuttingDown = true;
183
258
  for (const signal of SIGNALS) offSignal(signal, handler);
184
- logger.info({ drainTimeoutMs }, "graceful shutdown: draining");
259
+ logger.info({ drainTimeoutMs }, "shutdown_draining");
185
260
 
186
261
  const deadline = setTimeout(() => {
187
- logger.error(
188
- { drainTimeoutMs },
189
- "graceful shutdown: drain deadline exceeded, closing remaining connections",
190
- );
262
+ logger.error({ drainTimeoutMs }, "shutdown_drain_deadline_exceeded");
191
263
  server.closeAllConnections();
192
264
  void finish(1, "drain-timeout");
193
265
  }, drainTimeoutMs);
@@ -203,7 +275,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
203
275
  // here. Reporting "drained" and exiting 0 on it would tell an
204
276
  // orchestrator the shutdown went cleanly when the listener did
205
277
  // not actually come down.
206
- logger.error({ err }, "graceful shutdown: server close failed");
278
+ logger.error({ err: loggableError(err) }, "shutdown_server_close_failed");
207
279
  void finish(1, "close-failed");
208
280
  return;
209
281
  }
@@ -37,13 +37,24 @@
37
37
  * is sent (`end`, which `then` and `expect(..., fn)` go through). A server the
38
38
  * test started itself is never touched.
39
39
  *
40
+ * Which version of supertest closes the server it started, and when, has
41
+ * changed under this file once (7.3.0): 7.2 closed `_server` before asserting;
42
+ * 7.3 tracks the servers it started itself in a private registry and closes
43
+ * only those, so a server this file bound is left listening. The patched
44
+ * `end` therefore closes the server this request started, before the test's
45
+ * callback, whenever supertest has not — the same order either way.
46
+ *
40
47
  * supertest is loaded from the package that owns the running test file (the
41
48
  * nearest `package.json` above it), so the patched class is the one that test
42
49
  * imports, whatever directory vitest was started from. A package that does
43
50
  * not declare supertest is left alone. Everything else this file cannot do —
44
51
  * no test path, a declared supertest that does not load, a supertest whose
45
52
  * internals no longer look like this — throws, so the guard is never silently
46
- * off.
53
+ * off. That load-time check sees only the two methods' presence; a supertest
54
+ * that moved the `listen` out of `serverAddress` would pass it and leave the
55
+ * guard off. The behavioural tests catch that shape of change: this
56
+ * template's `supertest-loopback.test.mts` and its twin in the session
57
+ * package assert the address the server actually bound.
47
58
  *
48
59
  * This file is part of the project template and ships with every scaffold.
49
60
  * The auth.provider workspace loads this same file for its packages (see
@@ -160,8 +171,8 @@ function patch(proto: TestPrototype): void {
160
171
  const protocol = app instanceof TlsServer ? "https" : "http";
161
172
  let ready = binding.get(app);
162
173
  if (!ready) {
163
- // supertest closes `_server` after the response, as it does for the
164
- // server it would have started itself.
174
+ // supertest 7.2 closes `_server` after the response; 7.3 does not,
175
+ // and the callback in the patched `end` below closes it instead.
165
176
  this._server = app.listen(0, LOOPBACK);
166
177
  // Subscribed now, not in `end`: a listen error emitted before the test
167
178
  // sends the request must still reach it, not crash the worker as an
@@ -188,6 +199,14 @@ function patch(proto: TestPrototype): void {
188
199
  let called = false;
189
200
  const callback = (err: unknown, res?: unknown) => {
190
201
  called = true;
202
+ // supertest 7.2 has closed the server it started by now, before the
203
+ // callback; 7.3 closes only servers in its own private registry, which
204
+ // this bind is not in. Close what this request started, if it is
205
+ // still open, before the callback — the order 7.2 kept.
206
+ if (this._server === server && server.listening) {
207
+ server.close(() => fn?.(err, res));
208
+ return;
209
+ }
191
210
  fn?.(err, res);
192
211
  };
193
212
  // Unpatched, whatever `end` throws while building the request (a header
@@ -1,16 +1,17 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.14.0",
3
- "@o3co/auth-provider-device-grant": "0.14.0",
4
- "@o3co/auth-provider-dpop": "0.14.0",
5
- "@o3co/auth-provider-mtls": "0.14.0",
6
- "@o3co/auth-provider-federation-apple": "0.14.0",
7
- "@o3co/auth-provider-federation-google": "0.14.0",
8
- "@o3co/auth-provider-federation-github": "0.14.0",
9
- "@o3co/auth-provider-federation-oidc": "0.14.0",
10
- "@o3co/auth-provider-oauth": "0.14.0",
11
- "@o3co/auth-provider-oauth-token-exchange": "0.14.0",
12
- "@o3co/auth-provider-session": "0.14.0",
13
- "@o3co/auth-provider-foundation": "0.14.0",
14
- "@o3co/auth-provider-redis": "0.14.0",
15
- "@o3co/auth-provider-webauthn": "0.14.0"
2
+ "@o3co/auth-provider-core": "0.16.0",
3
+ "@o3co/auth-provider-device-grant": "0.16.0",
4
+ "@o3co/auth-provider-dpop": "0.16.0",
5
+ "@o3co/auth-provider-mtls": "0.16.0",
6
+ "@o3co/auth-provider-federation-apple": "0.16.0",
7
+ "@o3co/auth-provider-federation-google": "0.16.0",
8
+ "@o3co/auth-provider-federation-github": "0.16.0",
9
+ "@o3co/auth-provider-federation-grants": "0.16.0",
10
+ "@o3co/auth-provider-federation-oidc": "0.16.0",
11
+ "@o3co/auth-provider-oauth": "0.16.0",
12
+ "@o3co/auth-provider-oauth-token-exchange": "0.16.0",
13
+ "@o3co/auth-provider-session": "0.16.0",
14
+ "@o3co/auth-provider-foundation": "0.16.0",
15
+ "@o3co/auth-provider-redis": "0.16.0",
16
+ "@o3co/auth-provider-webauthn": "0.16.0"
16
17
  }