@cosmicdrift/kumiko-framework 0.160.0 → 0.161.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-framework",
3
- "version": "0.160.0",
3
+ "version": "0.161.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.160.0",
185
+ "@cosmicdrift/kumiko-types": "0.161.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.18",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.160.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.161.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -75,7 +75,7 @@ export type AuthMiddlewareOptions = {
75
75
  // callers instead of being rejected with 401. The middleware synthesises
76
76
  // a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
77
77
  // tenantId resolved through the chain documented on AnonymousAccessConfig.
78
- readonly anonymousAccess?: AnonymousAccessConfig;
78
+ readonly anonymousAccess?: AnonymousAccessResolved;
79
79
  // Consulted after tenantId is resolved (JWT/PAT/anonymous). Returns 410
80
80
  // when the tenant is in teardown (destroyRequested/destroying/destroyed).
81
81
  // cancel-destruction is exempt while status=destroyRequested.
@@ -96,50 +96,23 @@ export type TenantResolver = (c: Context) => Promise<TenantId | null> | TenantId
96
96
  // deployments where a caller could otherwise probe arbitrary ids.
97
97
  export type TenantExists = (tenantId: TenantId) => Promise<boolean> | boolean;
98
98
 
99
- // Single-tenant shortcut. When set, the server runs in **locked** mode:
100
- // - no client-supplied tenant: defaultTenantId is used.
101
- // - client supplies a matching tenant (header/cookie/resolver): allowed.
102
- // - client supplies a non-matching tenant: 400 tenant_mismatch (the
103
- // server is single-tenant; rejecting protects against confused clients
104
- // who think they're talking to a different deployment).
105
- // The framework does NOT verify defaultTenantId against the DB at boot;
106
- // the caller is responsible (see sample for the pattern).
107
- // Per-request existence check for header/cookie/resolver-supplied ids.
108
- // Skipped for the defaultTenantId path (the caller already vetted that
109
- // value when configuring the server).
110
- type AnonymousAccessConfigCommon = {
99
+ // App-facing anonymous-access config (#1374). tenantResolver / tenantExists
100
+ // / resolverTrust come from auth-foundation providers (EXT_TENANT_RESOLVER /
101
+ // EXT_TENANT_EXISTENCE), resolved at boot into AnonymousAccessResolved.
102
+ //
103
+ // Single-tenant shortcut: when defaultTenantId is set, the server runs in
104
+ // **locked** mode (client must agree or get tenant_mismatch).
105
+ export type AnonymousAccessConfig = {
111
106
  readonly defaultTenantId?: TenantId;
112
- readonly tenantExists?: TenantExists;
113
107
  };
114
108
 
115
- // Union, not one flat optional-everything type: a tenantResolver without a
116
- // declared resolverTrust is an ambiguous trust decision the compiler should
117
- // catch, not a silent runtime default. Set resolverTrust to:
118
- // - "authoritative": the resolver is trusted (e.g. it derives the tenant
119
- // from the subdomain, which the client cannot forge) and is consulted
120
- // FIRST. A client-supplied tenant that disagrees with the resolver's
121
- // answer is rejected with 400 tenant_mismatch — it is never used to
122
- // override the resolver, and it is never used as a substitute answer
123
- // when the resolver returns null either (that would just reopen the
124
- // same override via an unrecognised host). Pick this whenever the
125
- // resolver derives the tenant from something the caller cannot control
126
- // (subdomain, mTLS cert, etc.) — the whole point of such a resolver is
127
- // defeated if a client header can still override it.
128
- // - "fallback-only": a client-supplied header/cookie wins outright; the
129
- // resolver only runs when neither is present. Pick this only when the
130
- // resolver is a pure convenience fallback for callers that never send
131
- // a tenant of their own (e.g. a bare API host with no per-tenant
132
- // subdomains) and its answer carries no more trust than the client's
133
- // own claim.
134
- export type AnonymousAccessConfig =
135
- | (AnonymousAccessConfigCommon & {
136
- readonly tenantResolver?: undefined;
137
- readonly resolverTrust?: undefined;
138
- })
139
- | (AnonymousAccessConfigCommon & {
140
- readonly tenantResolver: TenantResolver;
141
- readonly resolverTrust: "authoritative" | "fallback-only";
142
- });
109
+ // Runtime config consumed by authMiddleware after boot merges registry
110
+ // providers onto the app-facing AnonymousAccessConfig.
111
+ export type AnonymousAccessResolved = AnonymousAccessConfig & {
112
+ readonly tenantResolver?: TenantResolver;
113
+ readonly tenantExists?: TenantExists;
114
+ readonly resolverTrust?: "authoritative" | "fallback-only";
115
+ };
143
116
 
144
117
  // Where the candidate tenant came from. Drives the validation policy:
145
118
  // - header / cookie / resolver: untrusted, must pass tenantExists if set.
@@ -409,7 +382,7 @@ async function handleVerifiedBearerUser(
409
382
  // no CSRF vector to defend against).
410
383
  async function handleAnonymous(
411
384
  c: Context,
412
- config: AnonymousAccessConfig,
385
+ config: AnonymousAccessResolved,
413
386
  next: Next,
414
387
  resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
415
388
  ): Promise<Response | undefined> {
@@ -495,7 +468,7 @@ type ResolveError = { error: RejectArgs };
495
468
  // clients that think they're talking to a different installation.
496
469
  async function resolveTenant(
497
470
  c: Context,
498
- config: AnonymousAccessConfig,
471
+ config: AnonymousAccessResolved,
499
472
  clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
500
473
  ): Promise<ResolvedTenant | ResolveError> {
501
474
  if (config.defaultTenantId !== undefined) {
@@ -598,13 +598,17 @@ export function createAuthRoutes(
598
598
  const data = result.data as
599
599
  | { kind: "auth-session"; session: SessionUser }
600
600
  | { kind: "mfa-challenge"; challengeToken: string }
601
- | { kind: "mfa-setup-required" };
601
+ | { kind: "mfa-setup-required"; preauthSetupToken: string };
602
602
 
603
603
  if (data.kind === "mfa-setup-required") {
604
604
  // No session, no challenge — the client must show an
605
605
  // enrollment-required message. No rate-limit reset (same reasoning
606
606
  // as the mfa-challenge branch below).
607
- return c.json({ isSuccess: true, mfaSetupRequired: true });
607
+ return c.json({
608
+ isSuccess: true,
609
+ mfaSetupRequired: true,
610
+ preauthSetupToken: data.preauthSetupToken,
611
+ });
608
612
  }
609
613
 
610
614
  if (data.kind === "mfa-challenge") {
package/src/api/index.ts CHANGED
@@ -2,6 +2,7 @@ export type { SetTenantCookieOptions } from "./anonymous-cookie";
2
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
3
3
  export type {
4
4
  AnonymousAccessConfig,
5
+ AnonymousAccessResolved,
5
6
  AuthMiddlewareOptions,
6
7
  AuthSessionChecker,
7
8
  AuthSessionStatus,
package/src/api/server.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  import type { SearchAdapter } from "../search/types";
48
48
  import { assertUnreachable, generateId } from "../utils";
49
49
  import { PUBLIC_API_PATHS } from "./api-constants";
50
- import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
50
+ import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
51
51
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
52
52
  import { csrfMiddleware } from "./csrf-middleware";
53
53
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
@@ -201,9 +201,10 @@ export type ServerOptions = {
201
201
  instanceId?: string;
202
202
  // Opt-in: serve unauthenticated requests on handlers that allow
203
203
  // roles=["anonymous"]. When omitted, every /api/* request still requires
204
- // a valid JWT (status quo). See AnonymousAccessConfig for the resolution
205
- // chain (header cookie resolver → defaultTenantId).
206
- anonymousAccess?: AnonymousAccessConfig;
204
+ // a valid JWT (status quo). App-facing config is AnonymousAccessConfig
205
+ // (defaultTenantId only); run{Prod,Dev}App merge auth-foundation tenant
206
+ // providers into AnonymousAccessResolved before calling buildServer.
207
+ anonymousAccess?: AnonymousAccessResolved;
207
208
  };
208
209
 
209
210
  export type KumikoServer = {
@@ -1,4 +1,5 @@
1
1
  import type { Hono } from "hono";
2
+ import type { SessionCreator } from "../api/auth-routes";
2
3
  import type { JwtHelper } from "../api/jwt";
3
4
  import type { SessionUser } from "../engine/types";
4
5
 
@@ -98,9 +99,25 @@ export type RequestHelper = {
98
99
  ) => Promise<Response>;
99
100
  };
100
101
 
101
- export function createRequestHelper(app: Hono, jwt: JwtHelper): RequestHelper {
102
+ export type RequestHelperOptions = {
103
+ // When sessionChecker is wired (sessions feature), JWTs without jti are
104
+ // rejected as no_sid. Seed/test helpers that only jwt.sign(user) need a
105
+ // live sid — create one via the same sessionCreator login uses (#1372).
106
+ readonly sessionCreator?: SessionCreator;
107
+ };
108
+
109
+ export function createRequestHelper(
110
+ app: Hono,
111
+ jwt: JwtHelper,
112
+ options: RequestHelperOptions = {},
113
+ ): RequestHelper {
102
114
  async function authHeader(user: SessionUser): Promise<Record<string, string>> {
103
- const token = await jwt.sign(user);
115
+ let forJwt = user;
116
+ if (options.sessionCreator && !user.sid) {
117
+ const sid = await options.sessionCreator(user, { ip: "test", userAgent: "request-helper" });
118
+ forJwt = { ...user, sid };
119
+ }
120
+ const token = await jwt.sign(forJwt);
104
121
  return { Authorization: `Bearer ${token}` };
105
122
  }
106
123
 
@@ -124,6 +124,16 @@ export type TestStackOptions = {
124
124
  sseBroker: import("../api/sse-broker").SseBroker;
125
125
  redis: import("ioredis").default;
126
126
  }) => import("../api/server").ServerOptions["anonymousAccess"]);
127
+ /** Optional post-factory enricher (e.g. merge auth-foundation tenant
128
+ * providers). Keeps framework free of a bundled-features dependency. */
129
+ enrichAnonymousAccess?: (
130
+ base: import("../api/server").ServerOptions["anonymousAccess"] | undefined,
131
+ deps: {
132
+ registry: Registry;
133
+ // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
134
+ db: any;
135
+ },
136
+ ) => Promise<import("../api/server").ServerOptions["anonymousAccess"] | undefined>;
127
137
  /** Opt-in JobRunner wired into ctx.jobRunner and merged into
128
138
  * dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
129
139
  * the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
@@ -382,19 +392,24 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
382
392
  : {}),
383
393
  ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
384
394
  ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
385
- ...(options.anonymousAccess
386
- ? {
387
- anonymousAccess:
388
- typeof options.anonymousAccess === "function"
389
- ? options.anonymousAccess({
390
- registry,
391
- db: testDb.db,
392
- sseBroker,
393
- redis: testRedis.redis,
394
- })
395
- : options.anonymousAccess,
396
- }
397
- : {}),
395
+ ...(await (async () => {
396
+ const baseAnon =
397
+ typeof options.anonymousAccess === "function"
398
+ ? options.anonymousAccess({
399
+ registry,
400
+ db: testDb.db,
401
+ sseBroker,
402
+ redis: testRedis.redis,
403
+ })
404
+ : options.anonymousAccess;
405
+ const resolvedAnon = options.enrichAnonymousAccess
406
+ ? await options.enrichAnonymousAccess(baseAnon, {
407
+ registry,
408
+ db: testDb.db,
409
+ })
410
+ : baseAnon;
411
+ return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
412
+ })()),
398
413
  });
399
414
 
400
415
  const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
@@ -406,7 +421,11 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
406
421
  // timer loop call start() again (idempotent) after setup.
407
422
  if (eventDispatcher) await eventDispatcher.ensureRegistered();
408
423
 
409
- const http = createRequestHelper(server.app, server.jwt);
424
+ const http = createRequestHelper(server.app, server.jwt, {
425
+ ...(options.authConfig?.sessionCreator !== undefined && {
426
+ sessionCreator: options.authConfig.sessionCreator,
427
+ }),
428
+ });
410
429
 
411
430
  return {
412
431
  app: server.app,