@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 +3 -3
- package/src/api/request-id-middleware.ts +48 -35
- package/src/api/server.ts +13 -2
- package/src/engine/__tests__/engine.test.ts +27 -0
- package/src/engine/create-app.ts +11 -1
- package/src/engine/extension-names.ts +16 -0
- package/src/engine/index.ts +1 -0
- package/src/jobs/__tests__/job-runner-boot-timeout.test.ts +55 -0
- package/src/jobs/job-runner.ts +24 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
c.
|
|
32
|
-
|
|
33
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
});
|
package/src/engine/create-app.ts
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|