@cosmicdrift/kumiko-server-runtime 0.158.2 → 1.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.158.2",
3
+ "version": "1.0.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -72,8 +72,8 @@
72
72
  }
73
73
  },
74
74
  "dependencies": {
75
- "@cosmicdrift/kumiko-bundled-features": "0.158.2",
76
- "@cosmicdrift/kumiko-framework": "0.158.2",
75
+ "@cosmicdrift/kumiko-bundled-features": "1.0.0",
76
+ "@cosmicdrift/kumiko-framework": "1.0.0",
77
77
  "temporal-polyfill": "^0.3.2"
78
78
  },
79
79
  "publishConfig": {
@@ -13,6 +13,13 @@ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
13
13
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
17
+ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
18
+ import {
19
+ createSessionsFeature,
20
+ userSessionEntity,
21
+ } from "@cosmicdrift/kumiko-bundled-features/sessions";
22
+ import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
16
23
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
24
  import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
18
25
  import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
@@ -26,6 +33,10 @@ import {
26
33
  createArchivedStreamsTable,
27
34
  createEventsTable,
28
35
  } from "@cosmicdrift/kumiko-framework/event-store";
36
+ import {
37
+ createNoopProvider,
38
+ createPrometheusMeter,
39
+ } from "@cosmicdrift/kumiko-framework/observability";
29
40
  import {
30
41
  createEventConsumerStateTable,
31
42
  createProjectionStateTable,
@@ -196,6 +207,8 @@ async function migrateTestDb(): Promise<void> {
196
207
  await createProjectionStateTable(db);
197
208
  await createEventConsumerStateTable(db);
198
209
  await unsafeEnsureEntityTable(db, widgetEntity, "widget");
210
+ await unsafeEnsureEntityTable(db, userEntity, "user");
211
+ await unsafeEnsureEntityTable(db, userSessionEntity, "user-session");
199
212
  await asRawClient(db).unsafe(
200
213
  `CREATE TABLE IF NOT EXISTS prod_probe_pings (
201
214
  id BIGSERIAL PRIMARY KEY,
@@ -790,7 +803,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
790
803
  test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
791
804
  await expect(
792
805
  boot(undefined, {
793
- auth: { admin: ADMIN, cookieDomain: "example.eu" },
806
+ auth: { admin: ADMIN, cookieDomain: "example.eu", sessions: false },
794
807
  allowPlaintextPii: "test: origin-guard focus, not crypto",
795
808
  }),
796
809
  ).rejects.toThrow(/allowedOrigins is empty/);
@@ -807,6 +820,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
807
820
  admin: ADMIN,
808
821
  cookieDomain: "example.eu",
809
822
  allowedOrigins: ["https://app.example.eu"],
823
+ sessions: false,
810
824
  },
811
825
  });
812
826
  expect(handle).toBeDefined();
@@ -819,6 +833,48 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
819
833
  });
820
834
  });
821
835
 
836
+ describe("runProdApp — session boot gate (#1262/#1275)", () => {
837
+ const ADMIN = {
838
+ email: "session-gate@example.eu",
839
+ password: "test-pw-strong-1234",
840
+ displayName: "Admin",
841
+ memberships: [],
842
+ };
843
+
844
+ test("auth mounted, sessions feature missing, no opt-out → aborts boot", async () => {
845
+ await expect(
846
+ boot(undefined, {
847
+ auth: {
848
+ admin: ADMIN,
849
+ cookieDomain: "example.eu",
850
+ allowedOrigins: ["https://app.example.eu"],
851
+ },
852
+ allowPlaintextPii: "test: session-gate focus, not crypto",
853
+ }),
854
+ ).rejects.toThrow(/BOOT ABORTED.*sessions.*stateless/s);
855
+ });
856
+
857
+ test("auth mounted, sessions feature mounted → boots cleanly (the happy path the gate guards)", async () => {
858
+ const handle = await boot(undefined, {
859
+ // "user" is auto-mounted via includeBundled whenever auth.admin is set.
860
+ // sessions requires auth-foundation, which needs a tokenVerifier
861
+ // provider — PAT.
862
+ features: [
863
+ authFoundationFeature,
864
+ createPersonalAccessTokensFeature({ scopes: {} }),
865
+ createSessionsFeature(),
866
+ ],
867
+ auth: {
868
+ admin: ADMIN,
869
+ cookieDomain: "example.eu",
870
+ allowedOrigins: ["https://app.example.eu"],
871
+ },
872
+ allowPlaintextPii: "test: session-gate focus, not crypto",
873
+ });
874
+ expect(handle).toBeDefined();
875
+ });
876
+ });
877
+
822
878
  describe("runProdApp job-lane wiring (runSingleInstance)", () => {
823
879
  // Red-then-green for the export bug: on createApiEntrypoint (old default) the
824
880
  // worker-lane cron was never registered. createAllInOneEntrypoint (new
@@ -932,3 +988,36 @@ describe("hard PII boot gate (#818 step 2)", () => {
932
988
  expect(handle).toBeDefined();
933
989
  });
934
990
  });
991
+
992
+ // Regression for fw#1352: runProdApp wires the metrics route through two
993
+ // independently forwarded options (observability, metrics). Wrong nesting
994
+ // or a renamed key in ApiEntrypointOptions would silently no-op instead of
995
+ // erroring the boot, and /metrics would stay 404 or empty.
996
+ describe("runProdApp — /metrics endpoint (fw#1352)", () => {
997
+ test("observability (PrometheusMeter) + metrics.token wired → GET /metrics mit Bearer liefert OpenMetrics-Body", async () => {
998
+ const meter = createPrometheusMeter();
999
+ meter.registerMetric({ name: "kumiko_probe_total", type: "counter" });
1000
+ meter.counter("kumiko_probe_total").inc(2);
1001
+
1002
+ const handle = await boot(undefined, {
1003
+ observability: { ...createNoopProvider(), meter },
1004
+ metrics: { token: "t" },
1005
+ });
1006
+
1007
+ const res = await handle.entrypoint.app.fetch(
1008
+ new Request("http://test/metrics", { headers: { Authorization: "Bearer t" } }),
1009
+ );
1010
+ expect(res.status).toBe(200);
1011
+ expect(res.headers.get("Content-Type")).toMatch(/openmetrics-text/);
1012
+ const body = await res.text();
1013
+ expect(body).toContain("kumiko_probe_total 2");
1014
+ expect(body).toMatch(/# EOF\n$/);
1015
+ });
1016
+
1017
+ test("ohne observability und ohne metrics-Option → /metrics ist keine Route (404)", async () => {
1018
+ const handle = await boot();
1019
+
1020
+ const res = await handle.entrypoint.app.fetch(new Request("http://test/metrics"));
1021
+ expect(res.status).toBe(404);
1022
+ });
1023
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { assertSessionBootInvariants } from "../session-boot-gate";
3
+
4
+ describe("assertSessionBootInvariants", () => {
5
+ test("no auth mounted → nothing to gate", () => {
6
+ expect(() =>
7
+ assertSessionBootInvariants({
8
+ hasAuth: false,
9
+ sessionsFeatureMounted: false,
10
+ sessionsOption: undefined,
11
+ }),
12
+ ).not.toThrow();
13
+ });
14
+
15
+ test("auth mounted, sessions feature missing, no opt-out → aborts boot", () => {
16
+ expect(() =>
17
+ assertSessionBootInvariants({
18
+ hasAuth: true,
19
+ sessionsFeatureMounted: false,
20
+ sessionsOption: undefined,
21
+ }),
22
+ ).toThrow(/BOOT ABORTED.*sessions.*stateless/s);
23
+ });
24
+
25
+ test("auth mounted, sessions feature missing, explicit sessions:false → boots", () => {
26
+ expect(() =>
27
+ assertSessionBootInvariants({
28
+ hasAuth: true,
29
+ sessionsFeatureMounted: false,
30
+ sessionsOption: false,
31
+ }),
32
+ ).not.toThrow();
33
+ });
34
+
35
+ test("auth mounted, sessions feature wired → boots", () => {
36
+ expect(() =>
37
+ assertSessionBootInvariants({
38
+ hasAuth: true,
39
+ sessionsFeatureMounted: true,
40
+ sessionsOption: undefined,
41
+ }),
42
+ ).not.toThrow();
43
+ });
44
+
45
+ test("auth mounted, sessions feature wired AND an expiresInMs override → boots", () => {
46
+ expect(() =>
47
+ assertSessionBootInvariants({
48
+ hasAuth: true,
49
+ sessionsFeatureMounted: true,
50
+ sessionsOption: { expiresInMs: 60_000 },
51
+ }),
52
+ ).not.toThrow();
53
+ });
54
+ });
@@ -5,7 +5,6 @@
5
5
  // buildBootExtraContext + applyBootSeeds so resolver, set-handler and
6
6
  // seeds share the same cipher instance (and DEK cache).
7
7
 
8
- import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
9
8
  import {
10
9
  createDekCache,
11
10
  createEnvelopeCipher,
@@ -27,12 +26,10 @@ export function envHasMasterKek(env: Record<string, string | undefined>): boolea
27
26
  export type BootCrypto = {
28
27
  readonly masterKeyProvider?: MasterKeyProvider;
29
28
  // Cipher for encrypted config keys. Present exactly when a master key is
30
- // available. Decrypts legacy CONFIG_ENCRYPTION_KEY values as fallback
31
- // until the config re-encrypt job migrated them.
29
+ // available.
32
30
  readonly configCipher?: EnvelopeCipher;
33
- // Cipher for `encrypted: true` entity fields — same master key, but the
34
- // legacy fallback reads the pre-envelope ENCRYPTION_KEY format. Identical
35
- // to configCipher when no ENCRYPTION_KEY is set.
31
+ // Cipher for `encrypted: true` entity fields — same master key and
32
+ // instance as configCipher (kept as a separate field for API stability).
36
33
  readonly entityFieldCipher?: EnvelopeCipher;
37
34
  readonly dekCache: DekCache;
38
35
  };
@@ -56,22 +53,10 @@ export function resolveBootCrypto(
56
53
  : undefined);
57
54
 
58
55
  const dekCache = createDekCache();
59
- const legacyConfigKey = envSource["CONFIG_ENCRYPTION_KEY"];
60
56
  const configCipher = masterKeyProvider
61
- ? createEnvelopeCipher(masterKeyProvider, {
62
- dekCache,
63
- ...(legacyConfigKey ? { legacy: createEncryptionProvider(legacyConfigKey) } : {}),
64
- })
57
+ ? createEnvelopeCipher(masterKeyProvider, { dekCache })
65
58
  : undefined;
66
-
67
- const legacyEntityKey = envSource["ENCRYPTION_KEY"];
68
- const entityFieldCipher =
69
- masterKeyProvider && legacyEntityKey
70
- ? createEnvelopeCipher(masterKeyProvider, {
71
- dekCache,
72
- legacy: createEncryptionProvider(legacyEntityKey),
73
- })
74
- : configCipher;
59
+ const entityFieldCipher = configCipher;
75
60
 
76
61
  return {
77
62
  ...(masterKeyProvider && { masterKeyProvider }),
@@ -12,6 +12,7 @@
12
12
  // auf Frühere referenzieren (z.B. authClaims-Hooks an user/tenant).
13
13
 
14
14
  import {
15
+ type AccountUnlockOptions,
15
16
  type AuthEmailPasswordOptions,
16
17
  type AuthMailLocale,
17
18
  createAuthEmailPasswordFeature,
@@ -102,6 +103,7 @@ export type AuthOptionsCarrier = {
102
103
  readonly emailVerification?: EmailVerificationOptions;
103
104
  readonly signup?: SignupOptions;
104
105
  readonly invite?: InviteOptions;
106
+ readonly accountUnlock?: AccountUnlockOptions;
105
107
  };
106
108
 
107
109
  /** Baut den authOptions-Block für composeFeatures aus einem
@@ -158,7 +160,17 @@ export function buildComposeAuthOptions(
158
160
  if (auth.invite) {
159
161
  opts.invite = pickMailFields(auth.invite);
160
162
  }
161
- return opts.passwordReset || opts.emailVerification || opts.signup || opts.invite
163
+ if (auth.accountUnlock) {
164
+ opts.accountUnlock = {
165
+ hmacSecret: auth.accountUnlock.hmacSecret,
166
+ ...pickMailFields(auth.accountUnlock),
167
+ };
168
+ }
169
+ return opts.passwordReset ||
170
+ opts.emailVerification ||
171
+ opts.signup ||
172
+ opts.invite ||
173
+ opts.accountUnlock
162
174
  ? opts
163
175
  : undefined;
164
176
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // Dev-Tooling mehr in ihre node_modules.
5
5
  export { type ComposeFeaturesOptions, composeFeatures } from "./compose-features";
6
6
  export type {
7
+ AccountUnlockSetup,
7
8
  EmailVerificationSetup,
8
9
  InviteSetup,
9
10
  PasswordResetSetup,
@@ -160,6 +160,13 @@ type AuthMailNormalizable = {
160
160
  readonly invite?: InviteSetup;
161
161
  };
162
162
 
163
+ // accountUnlock (#1266) deliberately does NOT join this convenience block —
164
+ // unlike reset/verify/signup/invite it's only meaningful paired with
165
+ // `accountLockout`, which itself isn't wired through RunProdAppAuthOptions
166
+ // today. Apps that mount `accountLockout` set `auth.accountUnlock` alongside
167
+ // it explicitly (same shape as `passwordReset`), so `mail` alone can't
168
+ // silently expose a new public endpoint an app didn't ask for.
169
+
163
170
  export function resolveAuthMail<T extends AuthMailNormalizable>(
164
171
  auth: T,
165
172
  hmacSecret: string,
@@ -216,7 +223,6 @@ export function buildProdSessionAuth(
216
223
  readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
217
224
  readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
218
225
  readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
219
- readonly sessionStrictMode: true;
220
226
  } {
221
227
  const cbs = createSessionCallbacks({
222
228
  db,
@@ -236,6 +242,5 @@ export function buildProdSessionAuth(
236
242
  sessionCreator: cbs.sessionCreator,
237
243
  sessionRevoker: cbs.sessionRevoker,
238
244
  sessionChecker: cbs.sessionChecker,
239
- sessionStrictMode: true,
240
245
  };
241
246
  }
@@ -20,11 +20,18 @@
20
20
  // Container/Coolify setzt:
21
21
  // DATABASE_URL=postgresql://...
22
22
  // REDIS_URL=redis://...
23
- // JWT_SECRET=<random-32+>
23
+ // JWT_SECRET=<random-32+> (always required — also signs the password-
24
+ // reset/email-verification HMAC tokens, a separate non-rotating family)
25
+ // — additionally, for zero-downtime rotation of the session-JWT
26
+ // signing key specifically:
27
+ // JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
28
+ // JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
29
+ // still verify in-flight tokens until they expire)
24
30
  // PORT=3000
25
31
  // KUMIKO_INSTANCE_ID=<stable per replica>
26
32
 
27
33
  import {
34
+ type AccountUnlockOptions,
28
35
  AuthErrors,
29
36
  AuthHandlers,
30
37
  type AuthMailLocale,
@@ -38,12 +45,14 @@ import {
38
45
  type SeedAdminOptions,
39
46
  seedAdmin,
40
47
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
48
+ import {
49
+ EXT_TOKEN_VERIFIER,
50
+ resolveTokenVerifier,
51
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
41
52
  import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
42
53
  import {
43
- createPatResolver,
44
54
  PAT_FEATURE,
45
55
  patRateLimitFromFeature,
46
- patScopesFromFeature,
47
56
  } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
48
57
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
49
58
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
@@ -56,7 +65,9 @@ import {
56
65
  createRedisLoginRateLimiter,
57
66
  createSseBroker,
58
67
  type LoginRateLimiter,
68
+ loadJwtSecretOrKeyring,
59
69
  type SseBroker,
70
+ type TokenVerifier,
60
71
  } from "@cosmicdrift/kumiko-framework/api";
61
72
  import {
62
73
  configureBlindIndexKey,
@@ -128,6 +139,7 @@ import {
128
139
  } from "./run-prod-app-boot-context";
129
140
  import { buildStaticFallback } from "./run-prod-app-static-files";
130
141
  import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
142
+ import { assertSessionBootInvariants } from "./session-boot-gate";
131
143
  import {
132
144
  type ProdSessionsOption,
133
145
  resolveProdSessionsConfig,
@@ -244,6 +256,13 @@ export type SignupSetup = SignupOptions;
244
256
  * AuthHandlers (analog signup). */
245
257
  export type InviteSetup = InviteOptions;
246
258
 
259
+ /** Wrapper API for the account-unlock flow (#1266). = AccountUnlockOptions
260
+ * (appUrl via delivery, symmetric to PasswordResetSetup). Self-service
261
+ * escape hatch for accountLockout's monotonic failure counter — only
262
+ * meaningful when `auth.accountLockout` is also set, but wired
263
+ * independently like the other flows. */
264
+ export type AccountUnlockSetup = AccountUnlockOptions;
265
+
247
266
  /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
248
267
  * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
249
268
  * Templates (siehe `auth.mail` + resolveAuthMail). */
@@ -271,7 +290,7 @@ export type RunProdAppAuthOptions = {
271
290
  /** Opt-in: revocable server-side sessions. Caller MUSS
272
291
  * `createSessionsFeature()` zu `features` adden — runProdApp wired
273
292
  * hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
274
- * echte db-connection, plus sessionStrictMode=true.
293
+ * echte db-connection (sidless JWTs werden dann abgelehnt).
275
294
  *
276
295
  * Standardverhalten ohne diese Option: stateless JWTs ohne sid
277
296
  * (legacy-Verhalten, Karten­haus existing-Apps unangefasst). */
@@ -308,6 +327,11 @@ export type RunProdAppAuthOptions = {
308
327
  * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
309
328
  * are mounted. */
310
329
  readonly invite?: InviteSetup;
330
+ /** Account-unlock flow (#1266). When set, /api/auth/request-account-unlock
331
+ * + /api/auth/confirm-account-unlock are mounted. Self-service escape
332
+ * hatch for accountLockout's monotonic failure-counter — confirming
333
+ * clears the Redis lockout state, no entity write. */
334
+ readonly accountUnlock?: AccountUnlockSetup;
311
335
  /** Domain attribute for both auth cookies (see
312
336
  * AuthRoutesConfig.cookieDomain). Set to the registrable parent
313
337
  * domain when login and app live on different subdomains. */
@@ -660,7 +684,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
660
684
  // configured.
661
685
  const databaseUrl = requireEnv("DATABASE_URL", envSource);
662
686
  const redisUrl = requireEnv("REDIS_URL", envSource);
687
+ // JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
688
+ // fallback below — a separate, non-rotating HMAC token family, not the
689
+ // session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
690
+ // JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
691
+ // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
663
692
  const jwtSecret = requireEnv("JWT_SECRET", envSource);
693
+ const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
664
694
  const jwtIssuer = readEnv("JWT_ISSUER", envSource);
665
695
  const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
666
696
  const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
@@ -695,6 +725,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
695
725
  allowPlaintextPii: options.allowPlaintextPii,
696
726
  mode: "prod",
697
727
  });
728
+ const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
729
+ assertSessionBootInvariants({
730
+ hasAuth: Boolean(effectiveAuth),
731
+ sessionsFeatureMounted: sessionsFeature !== undefined,
732
+ sessionsOption: effectiveAuth?.sessions,
733
+ });
698
734
  const registry = createRegistry(features);
699
735
 
700
736
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
@@ -832,17 +868,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
832
868
 
833
869
  // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
834
870
  // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
835
- // sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
836
- // von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
871
+ // Ein JWT ohne sid wird abgelehnt, sobald ein sessionChecker verdrahtet ist —
872
+ // Prod-Sessions können nicht stillschweigend umgangen werden. sessionMassRevoker
837
873
  // (4. callback aus createSessionCallbacks) ist nicht Teil der
838
874
  // AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
839
875
  // sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
840
876
  // über die auth-routes.
841
877
  // Secure-by-default: if the sessions feature is mounted, server-side revocation +
842
- // sessionStrictMode + auto-revoke-on-password-change are wired automatically;
878
+ // auto-revoke-on-password-change are wired automatically;
843
879
  // `auth.sessions` only overrides the config, and `auth.sessions: false` is the
844
880
  // explicit opt-out (back to stateless JWTs).
845
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
846
881
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
847
882
  const sessionAuthFragment = shouldWireProdSessions(
848
883
  Boolean(effectiveAuth),
@@ -857,20 +892,24 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
857
892
  )
858
893
  : undefined;
859
894
 
860
- // PAT opt-in: if the personal-access-tokens feature is mounted, wire its
861
- // resolver (bearer PATs → SessionUser, before jwt.verify). Scopes come from
862
- // the feature's exports the same declaration its handlers use.
895
+ // Token-verifier opt-in: any provider feature (personal-access-tokens, a
896
+ // future auth-provider-jwt, ...) self-registers via
897
+ // r.useExtension(EXT_TOKEN_VERIFIER, ...)wire one generic resolver
898
+ // whenever at least one is mounted, resolved by shape at request-time.
899
+ // PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
900
+ // verification.
863
901
  const patFeature = features.find((f) => f.name === PAT_FEATURE);
902
+ const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
864
903
  let patAuthFragment:
865
904
  | {
866
- patResolver: ReturnType<typeof createPatResolver>;
905
+ tokenVerifier: TokenVerifier;
867
906
  patRateLimiter: LoginRateLimiter;
868
907
  }
869
908
  | undefined;
870
- if (effectiveAuth && patFeature) {
909
+ if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
871
910
  const rl = patRateLimitFromFeature(patFeature);
872
911
  patAuthFragment = {
873
- patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
912
+ tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
874
913
  patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
875
914
  };
876
915
  }
@@ -895,7 +934,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
895
934
  ...extraContext,
896
935
  },
897
936
  sseBroker,
898
- jwtSecret,
937
+ jwtSecret: jwtSecretOrKeyring,
899
938
  ...(jwtIssuer && { jwtIssuer }),
900
939
  ...(instanceId && { instanceId }),
901
940
  dispatcherOptions: {
@@ -955,6 +994,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
955
994
  confirmHandler: AuthHandlers.verifyEmail,
956
995
  },
957
996
  }),
997
+ ...(effectiveAuth.accountUnlock && {
998
+ accountUnlock: {
999
+ requestHandler: AuthHandlers.requestAccountUnlock,
1000
+ confirmHandler: AuthHandlers.confirmAccountUnlock,
1001
+ },
1002
+ }),
958
1003
  ...(effectiveAuth.signup && {
959
1004
  signup: {
960
1005
  requestHandler: AuthHandlers.signupRequest,
@@ -0,0 +1,29 @@
1
+ import type { ProdSessionsOption } from "./session-wiring";
2
+
3
+ export type SessionBootGateOptions = {
4
+ readonly hasAuth: boolean;
5
+ readonly sessionsFeatureMounted: boolean;
6
+ readonly sessionsOption: ProdSessionsOption | undefined;
7
+ };
8
+
9
+ // Mirrors pii-boot-gate.ts: catch a forgotten wiring at boot instead of
10
+ // letting it degrade silently into stateless JWTs (no server-side
11
+ // revocation, valid for the full 24h token TTL). `auth.sessions: false` is
12
+ // already the sanctioned opt-out (see session-wiring.ts) — reusing it here
13
+ // instead of inventing a second acknowledgment param.
14
+ export function assertSessionBootInvariants(opts: SessionBootGateOptions): void {
15
+ // skip: no auth mounted — nothing to gate.
16
+ if (!opts.hasAuth) return;
17
+ // skip: explicit opt-out, operator acknowledged stateless JWTs.
18
+ if (opts.sessionsOption === false) return;
19
+ // skip: sessions feature is wired.
20
+ if (opts.sessionsFeatureMounted) return;
21
+
22
+ throw new Error(
23
+ "[runProdApp] BOOT ABORTED — auth is mounted but the `sessions` feature is not. " +
24
+ "JWTs would be stateless (no server-side revocation, valid until the 24h expiry) " +
25
+ "with no warning. Mount createSessionsFeature() " +
26
+ "(@cosmicdrift/kumiko-bundled-features/sessions) for revocable sessions, or pass " +
27
+ "{ auth: { sessions: false } } to acknowledge stateless JWTs are intentional.",
28
+ );
29
+ }
@@ -3,7 +3,7 @@
3
3
  * full prod boot).
4
4
  *
5
5
  * Secure-by-default: mounting the `sessions` feature turns server-side session
6
- * revocation + sessionStrictMode ON automatically — there is no separate opt-in. The
6
+ * revocation ON automatically — there is no separate opt-in. The
7
7
  * `auth.sessions` option only overrides the config, and `auth.sessions: false` is the
8
8
  * explicit opt-out (back to stateless JWTs).
9
9
  */