@cosmicdrift/kumiko-framework 0.192.0 → 0.193.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.192.0",
3
+ "version": "0.193.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>",
@@ -186,7 +186,7 @@
186
186
  "./package.json": "./package.json"
187
187
  },
188
188
  "dependencies": {
189
- "@cosmicdrift/kumiko-types": "0.192.0",
189
+ "@cosmicdrift/kumiko-types": "0.193.0",
190
190
  "bullmq": "^5.76.7",
191
191
  "bun-types": "^1.3.13",
192
192
  "hono": "^4.13.1",
@@ -202,7 +202,7 @@
202
202
  "zod": "^4.4.3"
203
203
  },
204
204
  "devDependencies": {
205
- "@cosmicdrift/kumiko-dispatcher-live": "0.192.0",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.193.0",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -1,5 +1,5 @@
1
1
  import type { Context, Next } from "hono";
2
- import { requestContext } from "./request-context";
2
+ import { type RequestContextData, requestContext } from "./request-context";
3
3
 
4
4
  const REQUEST_ID_HEADER = "X-Request-ID";
5
5
  const CORRELATION_ID_HEADER = "X-Correlation-ID";
@@ -14,6 +14,47 @@ function sanitizeClientId(value: string | undefined): string | undefined {
14
14
  return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined;
15
15
  }
16
16
 
17
+ /**
18
+ * Builds the RequestContextData record for a Hono request — requestId
19
+ * (client-supplied + sanitized, or generated), correlationId (mirrors
20
+ * requestId unless the client set its own), the underlying abort signal,
21
+ * and the client IP/User-Agent. Extracted out of `requestIdMiddleware` so
22
+ * call-sites that invoke a handler outside that middleware's `next()`
23
+ * chain (e.g. server.ts's httpRoute→systemQuery mount) can still populate
24
+ * the same AsyncLocalStorage record via `requestContext.run(...)`.
25
+ */
26
+ export function buildRequestContextData(c: Context): RequestContextData {
27
+ const requestId =
28
+ sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
29
+ const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
30
+
31
+ // Hono exposes the underlying Fetch Request — its `signal` aborts
32
+ // when the client disconnects (mobile back-press, tab close). We
33
+ // propagate it through requestContext so framework internals can
34
+ // honour cancellation at long-running checkpoints. Older Hono /
35
+ // adapter combos may not populate `c.req.raw.signal`; conditional
36
+ // spread keeps `signal: undefined` out of the stored record so
37
+ // downstream `signal?` checks behave as if no signal exists.
38
+ const signal = c.req.raw?.signal;
39
+ // Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
40
+ // present (proxy/CDN) — first hop is the originating client. Adapter-
41
+ // specific socket-address fallback (bun, node) is not standardized
42
+ // in Hono; deployments behind a proxy should always set xff. Without
43
+ // either we leave `ip` undefined and skip ip-bucketed checks rather
44
+ // than fabricate one.
45
+ const xff = c.req.header("x-forwarded-for");
46
+ const ip = xff?.split(",")[0]?.trim();
47
+ const userAgent = c.req.header("user-agent");
48
+
49
+ return {
50
+ requestId,
51
+ correlationId,
52
+ ...(signal ? { signal } : {}),
53
+ ...(ip && ip.length > 0 ? { ip } : {}),
54
+ ...(userAgent !== undefined ? { userAgent } : {}),
55
+ };
56
+ }
57
+
17
58
  /**
18
59
  * Assigns a requestId + correlationId to every request and wraps execution
19
60
  * in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for
@@ -25,39 +66,11 @@ function sanitizeClientId(value: string | undefined): string | undefined {
25
66
  */
26
67
  export function requestIdMiddleware() {
27
68
  return async (c: Context, next: Next) => {
28
- const requestId =
29
- sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
30
- const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
31
- c.header(REQUEST_ID_HEADER, requestId);
32
- c.header(CORRELATION_ID_HEADER, correlationId);
33
- c.set("requestId", requestId);
34
-
35
- // Hono exposes the underlying Fetch Request — its `signal` aborts
36
- // when the client disconnects (mobile back-press, tab close). We
37
- // propagate it through requestContext so framework internals can
38
- // honour cancellation at long-running checkpoints. Older Hono /
39
- // adapter combos may not populate `c.req.raw.signal`; conditional
40
- // spread keeps `signal: undefined` out of the stored record so
41
- // downstream `signal?` checks behave as if no signal exists.
42
- const signal = c.req.raw?.signal;
43
- // Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
44
- // present (proxy/CDN) — first hop is the originating client. Adapter-
45
- // specific socket-address fallback (bun, node) is not standardized
46
- // in Hono; deployments behind a proxy should always set xff. Without
47
- // either we leave `ip` undefined and skip ip-bucketed checks rather
48
- // than fabricate one.
49
- const xff = c.req.header("x-forwarded-for");
50
- const ip = xff?.split(",")[0]?.trim();
51
- const userAgent = c.req.header("user-agent");
52
- await requestContext.run(
53
- {
54
- requestId,
55
- correlationId,
56
- ...(signal ? { signal } : {}),
57
- ...(ip && ip.length > 0 ? { ip } : {}),
58
- ...(userAgent !== undefined ? { userAgent } : {}),
59
- },
60
- () => next(),
61
- );
69
+ const data = buildRequestContextData(c);
70
+ c.header(REQUEST_ID_HEADER, data.requestId);
71
+ c.header(CORRELATION_ID_HEADER, data.correlationId);
72
+ c.set("requestId", data.requestId);
73
+
74
+ await requestContext.run(data, () => next());
62
75
  };
63
76
  }
package/src/api/server.ts CHANGED
@@ -57,7 +57,8 @@ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
57
57
  import { observabilityMiddleware } from "./observability-middleware";
58
58
  import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
59
59
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
60
- import { requestIdMiddleware } from "./request-id-middleware";
60
+ import { requestContext } from "./request-context";
61
+ import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
61
62
  import {
62
63
  DEFAULT_MAX_REQUEST_BYTES,
63
64
  registerBodyLimit,
@@ -769,7 +770,17 @@ export function buildServer(options: ServerOptions): KumikoServer {
769
770
  // it into a public response. The forced tenant already comes
770
771
  // from bypassing the HTTP layer entirely; no elevated role
771
772
  // is needed or wanted on top of that.
772
- dispatcher.query(type, payload, createAnonymousUser(tenantId)),
773
+ //
774
+ // httpRoute handlers run OUTSIDE /api/* — requestIdMiddleware
775
+ // (which wraps requestContext.run with ip/requestId/
776
+ // correlationId) never sees this request. Without this wrap,
777
+ // `rateLimit: {per: "ip", ...}` on a handler invoked via
778
+ // systemQuery is silent dead-code: enforceRateLimit reads
779
+ // requestContext.get()?.ip, which is undefined here, so
780
+ // buildBucketKey always returns {kind: "skip"}.
781
+ requestContext.run(buildRequestContextData(c), () =>
782
+ dispatcher.query(type, payload, createAnonymousUser(tenantId)),
783
+ ),
773
784
  });
774
785
  switch (route.method) {
775
786
  case "GET":
@@ -814,6 +814,28 @@ describe("createApp", () => {
814
814
  );
815
815
  });
816
816
 
817
+ // hasMoneyField used to only look at top-level fields, so an entity whose
818
+ // only money lives inside an embedded-list sub-schema (e.g. invoice lines
819
+ // with no top-level money field) slipped past this check — its cells and
820
+ // totals would then silently render in entity.defaultCurrency ?? "EUR"
821
+ // regardless of the app's actual currency.
822
+ test("rejects an entity whose only money field is nested in an embedded list", () => {
823
+ const feature = defineFeature("test", (r) => {
824
+ r.entity(
825
+ "invoice",
826
+ createEntity({
827
+ table: "Invoices",
828
+ fields: {
829
+ lines: createEmbeddedListField({ amount: { type: "money", required: true } }),
830
+ },
831
+ }),
832
+ );
833
+ });
834
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
835
+ "has money fields but no defaultCurrency",
836
+ );
837
+ });
838
+
817
839
  test("rejects unknown defaultCurrency", () => {
818
840
  const feature = defineFeature("test", (r) => {
819
841
  r.entity(
@@ -955,6 +977,9 @@ describe("createApp", () => {
955
977
  qty: { type: "decimal", scale: 3 },
956
978
  }),
957
979
  },
980
+ // meta.amount is money nested in an embedded field — needs a
981
+ // defaultCurrency the same as a top-level money field would.
982
+ defaultCurrency: "EUR",
958
983
  }),
959
984
  );
960
985
  });
@@ -1073,6 +1098,7 @@ describe("createApp", () => {
1073
1098
  { totalsMatch: { amount: "title" } },
1074
1099
  ),
1075
1100
  },
1101
+ defaultCurrency: "EUR",
1076
1102
  }),
1077
1103
  );
1078
1104
  });
@@ -1093,6 +1119,7 @@ describe("createApp", () => {
1093
1119
  { totalsMatch: { amount: "ghostTotal" } },
1094
1120
  ),
1095
1121
  },
1122
+ defaultCurrency: "EUR",
1096
1123
  }),
1097
1124
  );
1098
1125
  });
@@ -85,7 +85,17 @@ export function createApp(config: AppConfig): App {
85
85
  // Validate defaultCurrency on entities that have money fields
86
86
  for (const feature of config.features) {
87
87
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
88
- const hasMoneyField = Object.values(entity.fields).some((f) => f.type === "money");
88
+ // A top-level money field isn't the only way an entity can hold money
89
+ // an embedded-list's sub-schema (e.g. invoice lines) can carry a money
90
+ // cell with no top-level money field at all. Without this, that entity
91
+ // slips past the defaultCurrency check and its cells/totals render in
92
+ // the entity.defaultCurrency ?? "EUR" fallback regardless of the app's
93
+ // actual currency.
94
+ const hasMoneyField = Object.values(entity.fields).some(
95
+ (f) =>
96
+ f.type === "money" ||
97
+ (f.type === "embedded" && Object.values(f.schema).some((s) => s.type === "money")),
98
+ );
89
99
  if (entity.defaultCurrency && !currencies.includes(entity.defaultCurrency)) {
90
100
  throw new Error(
91
101
  `Entity "${entityName}" in feature "${feature.name}" has defaultCurrency "${entity.defaultCurrency}" which is not in the currencies list. Available: ${currencies.join(", ")}`,
@@ -104,6 +104,22 @@ export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as con
104
104
  */
105
105
  export const EXT_DERIVATIVE_RENDERER = "derivativeRenderer" as const;
106
106
 
107
+ /**
108
+ * `derivativePublicPredicate` — per-entityType "is this FileRef's derivative
109
+ * publicly readable?" gate for the anonymous variant route (file-derivatives).
110
+ *
111
+ * Apps register via
112
+ * `r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "<entityType>", { isPublic:
113
+ * (args, ctx) => boolean | Promise<boolean> })`, where `<entityType>` is the
114
+ * `entityType` string a FileRef carries from upload (e.g. "vehicle", "event").
115
+ * No registration for a given entityType is default-deny — the public route
116
+ * serves nothing for that entityType, not even a 403 (404, so existence isn't
117
+ * confirmed to an unauthorised caller).
118
+ *
119
+ * Registered/consumed by: `file-derivatives`' public variant route (#1951).
120
+ */
121
+ export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate" as const;
122
+
107
123
  /**
108
124
  * `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
109
125
  * bei User-Forget oder Tenant-Destroy).
@@ -77,6 +77,7 @@ export type { EmitCtx } from "./event-helpers";
77
77
  export { emitEvent, typedPayload } from "./event-helpers";
78
78
  export type { KumikoExtensionName } from "./extension-names";
79
79
  export {
80
+ EXT_DERIVATIVE_PUBLIC_PREDICATE,
80
81
  EXT_DERIVATIVE_RENDERER,
81
82
  EXT_EXTERNAL_RESOURCE,
82
83
  EXT_FILE_PROVIDER,
@@ -0,0 +1,55 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // Simulates an unreachable Redis: waitUntilReady() never resolves. Everything
4
+ // else start() touches (Queue.on/close/add/upsertJobScheduler) is a no-op —
5
+ // with an empty registry those code paths aren't exercised anyway.
6
+ mock.module("bullmq", () => {
7
+ class FakeQueue {
8
+ on() {}
9
+ close() {
10
+ return Promise.resolve();
11
+ }
12
+ getJobCounts() {
13
+ return Promise.resolve({});
14
+ }
15
+ removeJobScheduler() {
16
+ return Promise.resolve();
17
+ }
18
+ upsertJobScheduler() {
19
+ return Promise.resolve();
20
+ }
21
+ add() {
22
+ return Promise.resolve();
23
+ }
24
+ }
25
+ class FakeWorker {
26
+ on() {}
27
+ waitUntilReady() {
28
+ return new Promise(() => {});
29
+ }
30
+ close() {
31
+ return Promise.resolve();
32
+ }
33
+ }
34
+ return { Queue: FakeQueue, Worker: FakeWorker };
35
+ });
36
+
37
+ import { createRegistry } from "../../engine";
38
+ import type { AppContext } from "../../engine/types";
39
+ import { createJobRunner } from "../job-runner";
40
+
41
+ describe("createJobRunner start() boot timeout", () => {
42
+ test("rejects instead of hanging forever when the worker's Redis connection never becomes ready", async () => {
43
+ const registry = createRegistry([]);
44
+ const context: AppContext = {};
45
+ const runner = createJobRunner({
46
+ registry,
47
+ context,
48
+ redisUrl: "redis://localhost:6379",
49
+ consumerLane: "worker",
50
+ bootRedisTimeoutMs: 50,
51
+ });
52
+
53
+ await expect(runner.start()).rejects.toThrow(/Redis not reachable within 50ms \(lane=worker\)/);
54
+ });
55
+ });
@@ -132,6 +132,10 @@ export type JobRunnerOptions = {
132
132
  // Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
133
133
  // two parallel test-runners never see each other's jobs.
134
134
  queueNamePrefix?: string | undefined;
135
+ // Override how long start() waits for the worker's Redis connection
136
+ // before failing boot. Defaults to BOOT_REDIS_TIMEOUT_MS; tests shrink it
137
+ // to keep an unreachable-Redis assertion fast.
138
+ bootRedisTimeoutMs?: number | undefined;
135
139
  getActiveTenantIds?: () => Promise<TenantId[]>;
136
140
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
137
141
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
@@ -192,9 +196,20 @@ function parseRedisOpts(url: string): { host: string; port: number; db?: number
192
196
  return result;
193
197
  }
194
198
 
199
+ // redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
200
+ // would otherwise hang start() forever with no health endpoint to notice.
201
+ const BOOT_REDIS_TIMEOUT_MS = 10_000;
202
+
203
+ function timeoutReject(ms: number, message: string): Promise<never> {
204
+ return new Promise((_, reject) => {
205
+ setTimeout(() => reject(new Error(message)), ms);
206
+ });
207
+ }
208
+
195
209
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
196
210
  const { registry, context, redisUrl, consumerLane } = options;
197
211
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
212
+ const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
198
213
  const redisOpts = parseRedisOpts(redisUrl);
199
214
  // Use the context's tracer when present (observability-provider injected at
200
215
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
@@ -531,7 +546,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
531
546
  // worker's connections (main + blocking) are ready (fw#1805). This
532
547
  // mirrors the wait BullMQ already does internally for
533
548
  // upsertJobScheduler()/add() below when the lane has a cron/boot job.
534
- await worker.waitUntilReady();
549
+ // Racing a timeout against it keeps an unreachable Redis from hanging
550
+ // start() forever — there's no worker health endpoint to notice.
551
+ await Promise.race([
552
+ worker.waitUntilReady(),
553
+ timeoutReject(
554
+ bootRedisTimeoutMs,
555
+ `job-runner: Redis not reachable within ${bootRedisTimeoutMs}ms (lane=${consumerLane})`,
556
+ ),
557
+ ]);
535
558
 
536
559
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
537
560
  // assigned to the other lane get their cron/boot wiring from the