@cosmicdrift/kumiko-framework 0.346.0 → 0.347.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.
@@ -320,6 +320,11 @@ export function createRedisLoginRateLimiter(redis, maxAttempts = 10, windowMs =
320
320
  },
321
321
  };
322
322
  }
323
+ // A missing, malformed or non-object body reads as {} so callers answer with their own 400.
324
+ async function readJsonObjectOrEmpty(req) {
325
+ const raw = await req.json().catch(() => null);
326
+ return raw !== null && typeof raw === "object" ? raw : {};
327
+ }
323
328
  export function createAuthRoutes(dispatcher, jwt, config) {
324
329
  const api = new Hono();
325
330
  // Default to "lax": CSRF control comes from the double-submit token, and
@@ -708,8 +713,12 @@ export function createAuthRoutes(dispatcher, jwt, config) {
708
713
  }
709
714
  // @cast-boundary engine-payload — generic dispatcher.write result for signup-confirm
710
715
  const data = result.data;
711
- if (data.kind !== "auth-session")
712
- return pendingMfaStepResponse(c, data);
716
+ if (data.kind !== "auth-session") {
717
+ // No session yet, but the account and a claimed handover exist: hand
718
+ // the signup landing to the client so the login that follows lands
719
+ // where a signup without the MFA gate would have.
720
+ return pendingMfaStepResponse(c, data, landingPathFragment({ flow: "signup", ...data.signup }));
721
+ }
713
722
  // Session creation + JWT sign + cookies — see mintSessionAndRespond.
714
723
  const token = await mintSessionAndRespond(c, data.session);
715
724
  const landingPath = landingPathFragment({
@@ -935,7 +944,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
935
944
  // POST /auth/switch-tenant — switch to a different tenant
936
945
  api.post(Routes.authSwitchTenant, async (c) => {
937
946
  const user = getUser(c);
938
- const body = await c.req.json();
947
+ const body = await readJsonObjectOrEmpty(c.req);
939
948
  // The id seeds a SYSTEM identity in resolveActiveMembership — reject anything
940
949
  // that is not a non-empty string before it gets that far.
941
950
  if (!isNonEmptyString(body.tenantId)) {
@@ -1028,15 +1037,21 @@ export function createAuthRoutes(dispatcher, jwt, config) {
1028
1037
  });
1029
1038
  return api;
1030
1039
  }
1031
- function pendingMfaStepResponse(c, step) {
1040
+ function pendingMfaStepResponse(c, step, landing = {}) {
1032
1041
  if (step.kind === "mfa-setup-required") {
1033
1042
  return c.json({
1034
1043
  isSuccess: true,
1035
1044
  mfaSetupRequired: true,
1036
1045
  preauthSetupToken: step.preauthSetupToken,
1046
+ ...landing,
1037
1047
  });
1038
1048
  }
1039
- return c.json({ isSuccess: true, mfaRequired: true, challengeToken: step.challengeToken });
1049
+ return c.json({
1050
+ isSuccess: true,
1051
+ mfaRequired: true,
1052
+ challengeToken: step.challengeToken,
1053
+ ...landing,
1054
+ });
1040
1055
  }
1041
1056
  // --- shared route builders for token flows ---------------------------------
1042
1057
  // Password-reset, email-verification and signup share the exact same request
@@ -18,6 +18,7 @@ export type RequestContextData = {
18
18
  readonly qn: string;
19
19
  readonly risk: AgentRisk;
20
20
  };
21
+ readonly chargedRateLimitBuckets?: Set<string>;
21
22
  };
22
23
  export declare const requestContext: {
23
24
  run<T>(data: RequestContextData, fn: () => T): T;
@@ -29,6 +29,9 @@ export function runWithOrigin(origin, fn) {
29
29
  // The outermost dispatch wins — a nested ctx.write/writeAs must not
30
30
  // overwrite the entry handler that the irreversible-operation gate reads.
31
31
  entryHandler: current?.entryHandler ?? origin.entryHandler,
32
+ chargedRateLimitBuckets: current?.entryHandler
33
+ ? current.chargedRateLimitBuckets
34
+ : new Set(),
32
35
  }, fn);
33
36
  }
34
37
  // Clears entryHandler so the next dispatch inside fn becomes the new entry —
@@ -26,7 +26,7 @@ export function createApiRoutes(dispatcher, options = {}) {
26
26
  const api = new Hono();
27
27
  api.post(Routes.write, async (c) => {
28
28
  const user = getUser(c);
29
- const body = await c.req.json();
29
+ const body = await readDispatchBody(c);
30
30
  try {
31
31
  assertPayloadDepthAllowed(body.payload);
32
32
  assertPatAllowed(user, body.type);
@@ -42,7 +42,7 @@ export function createApiRoutes(dispatcher, options = {}) {
42
42
  });
43
43
  api.post(Routes.batch, async (c) => {
44
44
  const user = getUser(c);
45
- const body = await c.req.json();
45
+ const body = await readJsonObject(c);
46
46
  if (!Array.isArray(body.commands)) {
47
47
  // Client-shape violation → ValidationError (400, code=validation_error)
48
48
  // matches what a Zod-level schema failure would produce if /batch had
@@ -91,7 +91,7 @@ export function createApiRoutes(dispatcher, options = {}) {
91
91
  });
92
92
  api.post(Routes.query, async (c) => {
93
93
  const user = getUser(c);
94
- const body = await c.req.json();
94
+ const body = await readDispatchBody(c);
95
95
  try {
96
96
  assertPayloadDepthAllowed(body.payload);
97
97
  assertPatAllowed(user, body.type);
@@ -104,7 +104,7 @@ export function createApiRoutes(dispatcher, options = {}) {
104
104
  });
105
105
  api.post(Routes.command, async (c) => {
106
106
  const user = getUser(c);
107
- const body = await c.req.json();
107
+ const body = await readDispatchBody(c);
108
108
  try {
109
109
  assertPayloadDepthAllowed(body.payload);
110
110
  assertPatAllowed(user, body.type);
@@ -127,7 +127,7 @@ export function createApiRoutes(dispatcher, options = {}) {
127
127
  // queryErrorResponse instead of a flushed-200 error frame (framework#1517).
128
128
  api.post(Routes.stream, async (c) => {
129
129
  const user = getUser(c);
130
- const body = await c.req.json();
130
+ const body = await readDispatchBody(c);
131
131
  const requestId = requestContext.get()?.requestId;
132
132
  let generator;
133
133
  try {
@@ -255,6 +255,37 @@ function payloadDepth(value, depth) {
255
255
  }
256
256
  return max;
257
257
  }
258
+ function invalidBodyError(path, expected, received) {
259
+ return new ValidationError({
260
+ fields: [
261
+ {
262
+ path,
263
+ code: "invalid_type",
264
+ i18nKey: "errors.validation.invalid_type",
265
+ params: { expected, received },
266
+ },
267
+ ],
268
+ });
269
+ }
270
+ // Malformed or non-object JSON is a client error; letting c.req.json() throw
271
+ // would surface as an unclassified 500.
272
+ // @cast-boundary request-body — shape is validated per route right after
273
+ async function readJsonObject(c) {
274
+ const raw = await c.req.json().catch(() => undefined);
275
+ if (raw === undefined)
276
+ throw invalidBodyError("body", "json", "malformed");
277
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
278
+ throw invalidBodyError("body", "object", raw === null ? "null" : typeof raw);
279
+ }
280
+ return raw;
281
+ }
282
+ async function readDispatchBody(c) {
283
+ const body = await readJsonObject(c);
284
+ if (typeof body.type !== "string" || body.type === "") {
285
+ throw invalidBodyError("type", "string", typeof body.type);
286
+ }
287
+ return { type: body.type, payload: body.payload, requestId: body.requestId };
288
+ }
258
289
  // Rejects payloads nested deeper than MAX_PAYLOAD_DEPTH before they reach
259
290
  // per-handler Zod validation — recursive Zod parsing (and any downstream
260
291
  // jsonb serialization of custom-fields payloads) walks the same object
@@ -144,19 +144,19 @@ export function buildServer(options) {
144
144
  throw new Error(`ServerOptions.instanceId / KUMIKO_INSTANCE_ID cannot equal the reserved sentinel "${SHARED_INSTANCE_SENTINEL}" — ` +
145
145
  `pick any other stable string.`);
146
146
  }
147
- // Warn when we fell back to a random UUID: the default SSE system-consumer
148
- // is delivery="per-instance", so every boot gets a fresh cursor-row in
149
- // kumiko_event_consumers. The previous boot's row stays behind on its last
150
- // cursor and pins pruneEvents (retention-guard uses MIN(lastProcessedEventId)
151
- // across all shards). Without a stable KUMIKO_INSTANCE_ID this accumulates
152
- // on every restart, not just scale-down. Silent when options.instanceId or
153
- // KUMIKO_INSTANCE_ID is explicit — those are deliberate choices (the test
154
- // suite sets KUMIKO_INSTANCE_ID="test-instance" in vitest config).
147
+ // Warn when we fell back to a random UUID. The built-in SSE consumer is
148
+ // delivery="shared" (one cursor for all pods, fw#2625), so it no longer
149
+ // writes a row per instance. The id still labels this process in the
150
+ // app.started event and in per-instance consumers (opt-in delivery), where a
151
+ // fresh id per boot leaves an orphaned cursor-row that pins pruneEvents
152
+ // (retention-guard uses MIN(lastProcessedEventId) across all shards). Silent
153
+ // when options.instanceId or KUMIKO_INSTANCE_ID is explicit — those are
154
+ // deliberate choices (the test suite sets KUMIKO_INSTANCE_ID="test-instance").
155
155
  const instanceIdWasRandom = options.instanceId === undefined && !process.env["KUMIKO_INSTANCE_ID"];
156
156
  if (instanceIdWasRandom) {
157
157
  console.warn(`[kumiko:boot] No ServerOptions.instanceId / KUMIKO_INSTANCE_ID set — generated a random UUID (${resolvedInstanceId}). ` +
158
- `Per-instance consumers (SSE by default) write one cursor-row per instance; without a stable id, each restart leaves an orphaned row behind and pins events-retention on its last cursor. ` +
159
- `Set KUMIKO_INSTANCE_ID to a stable value (e.g. hostname, pod name) in production.`);
158
+ `SSE no longer needs a per-instance cursor, but the id labels this process in the app.started event and in per-instance consumers; without a stable id each restart leaves an orphaned cursor-row behind for those and pins events-retention on its last cursor. ` +
159
+ `Set KUMIKO_INSTANCE_ID to the pod name (e.g. from the Kubernetes downward API metadata.name) in production.`);
160
160
  }
161
161
  // Observability — Noop by default so no call-site needs to null-check.
162
162
  // Every handler/middleware that reaches for ctx.tracer / ctx.metrics gets
@@ -910,12 +910,23 @@ export async function enforceRateLimit(ctx, rateLimit, handlerName, user, isSyst
910
910
  message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
911
911
  });
912
912
  }
913
+ // A nested dispatch re-hitting a bucket its entry dispatch already charged
914
+ // (e.g. a per:"ip" handler delegating via queryAs) must not cost a second token.
915
+ // The mark is set only after a successful enforce and includes the limit
916
+ // parameters: a denied or failed check never lets a later nested call through,
917
+ // and a nested handler with a stricter limit on the same bucket is still checked.
918
+ const charged = reqCtx?.chargedRateLimitBuckets;
919
+ const chargedKey = `${bucket.key}|${effective.limit}|${effective.windowSeconds}|${effective.cost}`;
920
+ // skip: this exact bucket, limit and window was already charged by the entry dispatch
921
+ if (charged?.has(chargedKey))
922
+ return;
913
923
  try {
914
924
  await context.rateLimit.enforce(bucket.key, {
915
925
  limit: effective.limit,
916
926
  windowSeconds: effective.windowSeconds,
917
927
  cost: effective.cost,
918
928
  });
929
+ charged?.add(chargedKey);
919
930
  }
920
931
  catch (error) {
921
932
  // Backend failure (Redis down): fail closed as 503, same as L1/L2.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.346.0",
3
+ "version": "0.347.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>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.346.0",
210
- "@cosmicdrift/kumiko-types": "0.346.0",
209
+ "@cosmicdrift/kumiko-http": "0.347.0",
210
+ "@cosmicdrift/kumiko-types": "0.347.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.346.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.347.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.347.0",
4
+ "type": "fix",
5
+ "title": "Dispatch routes reject malformed or type-less request bodies with 400 instead of 500",
6
+ "detail": "Dispatch routes answer 400 for unusable request bodies\n`/api/write`, `/api/query`, `/api/command`, `/api/batch` and `/api/stream` return a validation error (400) for malformed JSON, a non-object body or a missing handler `type`. Before, those requests ended in an unclassified 500. `/api/auth/switch-tenant` treats a non-object body like a missing `tenantId`."
7
+ },
8
+ {
9
+ "version": "0.347.0",
10
+ "type": "fix",
11
+ "title": "Missing-instance-id warning and docs reflect shared SSE delivery and recommend the pod name",
12
+ "detail": "Boot warning and docs no longer claim SSE is per instance\nThe built-in SSE consumers share one cursor since fw#2625 and fw#2630. The missing-`KUMIKO_INSTANCE_ID` warning, the consumer-state comment and `docs/reference/infra-requirements.md` now say what the id is still for (the `app.started` event and per-instance consumers) and recommend the pod name."
13
+ },
14
+ {
15
+ "version": "0.347.0",
16
+ "type": "fix",
17
+ "title": "A request costs one rate-limit token per bucket even when the handler nests queryAs or ctx.query calls into handlers sharing that bucket",
18
+ "detail": "Nested dispatches no longer charge a rate-limit bucket twice\nA handler that calls `ctx.query`, `ctx.queryAs` or `ctx.write` into another handler with a limit on the same bucket (for example `per: \"ip\"`) used to cost two tokens per request. Each entry dispatch now charges a bucket once, and nested calls reuse that charge. Nested handlers that limit a different bucket are still limited, and every command of a batch still charges on its own. The public `/media` route had half its advertised limit because of this."
19
+ },
2
20
  {
3
21
  "version": "0.345.0",
4
22
  "type": "fix",