@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 +3 -3
- package/src/api/auth-middleware.ts +17 -44
- package/src/api/auth-routes.ts +6 -2
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +5 -4
- package/src/stack/request-helper.ts +19 -2
- package/src/stack/test-stack.ts +33 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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?:
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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:
|
|
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:
|
|
471
|
+
config: AnonymousAccessResolved,
|
|
499
472
|
clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
|
|
500
473
|
): Promise<ResolvedTenant | ResolveError> {
|
|
501
474
|
if (config.defaultTenantId !== undefined) {
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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).
|
|
205
|
-
//
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
-
...(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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,
|