@cosmicdrift/kumiko-framework 0.320.0 → 0.322.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 +4 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/client-ip.test.ts +25 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +42 -0
- package/src/api/client-ip.ts +27 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +7 -1
- package/src/api/request-context.ts +23 -1
- package/src/api/server.ts +3 -1
- package/src/changes.json +60 -0
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/entity-handlers.ts +20 -1
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-state.ts +2 -1
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/index.ts +2 -0
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +5 -0
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/i18n/__tests__/i18n.test.ts +35 -0
- package/src/i18n/__tests__/mail-registry.test.ts +19 -0
- package/src/i18n/index.ts +11 -4
- package/src/i18n/mail-registry.ts +13 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
- package/src/pipeline/__tests__/irreversible-operation-gate.integration.test.ts +249 -0
- package/src/pipeline/dispatch-shared.ts +33 -4
- package/src/pipeline/index.ts +4 -0
- package/src/pipeline/irreversible-operation-gate.ts +33 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/schema-cli.ts +5 -1
- package/src/ui-types/__tests__/plural.test.ts +108 -0
- package/src/ui-types/index.ts +6 -0
- package/src/ui-types/plural.ts +69 -0
- package/src/upgrade-cli.ts +58 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.322.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>",
|
|
@@ -202,8 +202,8 @@
|
|
|
202
202
|
"./package.json": "./package.json"
|
|
203
203
|
},
|
|
204
204
|
"dependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-http": "0.322.0",
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.322.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"bun-types": "^1.3.13",
|
|
209
209
|
"hono": "^4.13.1",
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
"zod": "^4.4.3"
|
|
220
220
|
},
|
|
221
221
|
"devDependencies": {
|
|
222
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
222
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.322.0",
|
|
223
223
|
"bun-types": "^1.3.13",
|
|
224
224
|
"pino-pretty": "^13.1.3"
|
|
225
225
|
},
|
|
@@ -54,6 +54,17 @@ describe("runSchemaCli — no-DB paths", () => {
|
|
|
54
54
|
expect(cap.err).toHaveLength(0);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test.each(["--help", "-h", "help"])(
|
|
58
|
+
"%s prints the same usage as no subcommand and exits 0",
|
|
59
|
+
async (helpArg) => {
|
|
60
|
+
const cap = captureOut();
|
|
61
|
+
const code = await runSchemaCli([helpArg], appCwd, cap.out);
|
|
62
|
+
expect(code).toBe(0);
|
|
63
|
+
expect(cap.log.join("\n")).toContain("Subcommands:");
|
|
64
|
+
expect(cap.err).toHaveLength(0);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
57
68
|
test("unknown subcommand exits 1 and reports it on stderr", async () => {
|
|
58
69
|
const cap = captureOut();
|
|
59
70
|
const code = await runSchemaCli(["cutover"], appCwd, cap.out);
|
|
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import {
|
|
16
16
|
findCodemodScriptsRoot,
|
|
17
|
-
findCoreChangelogFile,
|
|
18
17
|
findFeaturesDirs,
|
|
18
|
+
findPackageChangelogFiles,
|
|
19
19
|
resolveCodemodScript,
|
|
20
20
|
runUpgradeCli,
|
|
21
21
|
type UpgradeCliOut,
|
|
@@ -92,6 +92,23 @@ async function runJson(cwd: string, from: string): Promise<{ pending: Array<{ ti
|
|
|
92
92
|
return JSON.parse(spy.logs.join("\n"));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
describe("upgrade command — --help", () => {
|
|
96
|
+
test("--help prints usage and exits 0 without touching the filesystem", async () => {
|
|
97
|
+
const spy = makeSpyOutput();
|
|
98
|
+
const exit = await runUpgradeCli(["--help"], "/nonexistent-cwd", spy.out);
|
|
99
|
+
expect(exit).toBe(0);
|
|
100
|
+
expect(spy.logs.join("\n")).toContain("kumiko-upgrade");
|
|
101
|
+
expect(spy.errs).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("-h behaves the same as --help", async () => {
|
|
105
|
+
const spy = makeSpyOutput();
|
|
106
|
+
const exit = await runUpgradeCli(["-h"], "/nonexistent-cwd", spy.out);
|
|
107
|
+
expect(exit).toBe(0);
|
|
108
|
+
expect(spy.logs.join("\n")).toContain("--apply");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
95
112
|
describe("upgrade command — framework core changelog", () => {
|
|
96
113
|
test("collects core changes.json from the framework repo layout", async () => {
|
|
97
114
|
const cwd = tmp({
|
|
@@ -198,6 +215,37 @@ describe("upgrade command — framework core changelog", () => {
|
|
|
198
215
|
});
|
|
199
216
|
});
|
|
200
217
|
|
|
218
|
+
describe("upgrade command — every package's changelog, not just framework core", () => {
|
|
219
|
+
const SERVER_RUNTIME_ENTRY = JSON.stringify([
|
|
220
|
+
{ version: "0.168.0", type: "breaking", title: "server-runtime breaking change" },
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
test("a hoisted non-framework, non-bundled-features package's changes.json shows up in pending", async () => {
|
|
224
|
+
const cwd = tmp({
|
|
225
|
+
"node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
|
|
226
|
+
"apps/web/package.json": "{}",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const result = await runJson(`${cwd}/apps/web`, "0.165.0");
|
|
230
|
+
|
|
231
|
+
expect(result.pending.map((e) => e.title)).toEqual(["server-runtime breaking change"]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("bundled-features is never double-counted alongside findFeaturesDirs's own collection", async () => {
|
|
235
|
+
const cwd = tmp({
|
|
236
|
+
"node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
|
|
237
|
+
"node_modules/@cosmicdrift/kumiko-bundled-features/src/user/changes.json": FEATURE_ENTRY,
|
|
238
|
+
"apps/web/package.json": "{}",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const result = await runJson(`${cwd}/apps/web`, "0.165.0");
|
|
242
|
+
|
|
243
|
+
expect(result.pending.map((e) => e.title).sort()).toEqual(
|
|
244
|
+
["feature fix", "server-runtime breaking change"].sort(),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
201
249
|
describe("upgrade command — enterprise package layout", () => {
|
|
202
250
|
// Layout is detected by presence of changes.json, not an "ai-" name
|
|
203
251
|
// prefix — the old heuristic silently dropped every enterprise package
|
|
@@ -641,9 +689,8 @@ describe("changes.json codemod fields resolve to real published scripts", () =>
|
|
|
641
689
|
}
|
|
642
690
|
|
|
643
691
|
test("every codemod field is a scripts/codemod/*.ts path that resolves to an existing published script", () => {
|
|
644
|
-
const coreChangelog = findCoreChangelogFile(REAL_REPO_ROOT);
|
|
645
692
|
const changesJsonFiles = [
|
|
646
|
-
...(
|
|
693
|
+
...findPackageChangelogFiles(REAL_REPO_ROOT),
|
|
647
694
|
...findFeaturesDirs(REAL_REPO_ROOT).flatMap((dir) => findChangesJsonFiles(dir)),
|
|
648
695
|
];
|
|
649
696
|
expect(changesJsonFiles.length).toBeGreaterThan(0);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
assertValidTrustedProxyHops,
|
|
10
10
|
type ClientIpHeaderSource,
|
|
11
11
|
createClientIpResolver,
|
|
12
|
+
parseTrustedProxyHopsEnv,
|
|
12
13
|
} from "../client-ip";
|
|
13
14
|
|
|
14
15
|
function sourceOf(headers: Record<string, string>, socketAddress?: string): ClientIpHeaderSource {
|
|
@@ -137,3 +138,27 @@ describe("createClientIpResolver", () => {
|
|
|
137
138
|
expect(() => createClientIpResolver(-1, "test")).toThrow(/non-negative integer/);
|
|
138
139
|
});
|
|
139
140
|
});
|
|
141
|
+
|
|
142
|
+
describe("parseTrustedProxyHopsEnv", () => {
|
|
143
|
+
test("undefined -> undefined (no env set)", () => {
|
|
144
|
+
expect(parseTrustedProxyHopsEnv(undefined, "test")).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("valid digits-only string -> parsed integer", () => {
|
|
148
|
+
expect(parseTrustedProxyHopsEnv("1", "test")).toBe(1);
|
|
149
|
+
expect(parseTrustedProxyHopsEnv("0", "test")).toBe(0);
|
|
150
|
+
expect(parseTrustedProxyHopsEnv("42", "test")).toBe(42);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("hex/exponent/negative notation throws instead of silently coercing", () => {
|
|
154
|
+
expect(() => parseTrustedProxyHopsEnv("0x10", "test")).toThrow(/non-negative integer/);
|
|
155
|
+
expect(() => parseTrustedProxyHopsEnv("1e3", "test")).toThrow(/non-negative integer/);
|
|
156
|
+
expect(() => parseTrustedProxyHopsEnv("-1", "test")).toThrow(/non-negative integer/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("error message carries the context prefix and the raw value", () => {
|
|
160
|
+
expect(() => parseTrustedProxyHopsEnv("x", "runProdApp")).toThrow(
|
|
161
|
+
'runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "x".',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -585,6 +585,48 @@ describe("extraRoutes: entry:signature", () => {
|
|
|
585
585
|
});
|
|
586
586
|
});
|
|
587
587
|
|
|
588
|
+
describe("extraRoutes: entry:anonymous deps.clientIp", () => {
|
|
589
|
+
const clientIpRoute: AnonymousExtraRoute = {
|
|
590
|
+
method: "GET",
|
|
591
|
+
path: "/public/client-ip-probe",
|
|
592
|
+
entry: "anonymous",
|
|
593
|
+
handler: async (c, deps) => c.json({ clientIp: deps.clientIp }),
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
test("default trustedProxyHops (0): a spoofed X-Forwarded-For is ignored, socket address wins", async () => {
|
|
597
|
+
const stack = await setupTestStack({
|
|
598
|
+
features: [probeFeature],
|
|
599
|
+
extraRoutes: [clientIpRoute],
|
|
600
|
+
});
|
|
601
|
+
try {
|
|
602
|
+
const res = await stack.app.request(
|
|
603
|
+
"/public/client-ip-probe",
|
|
604
|
+
{ headers: { "x-forwarded-for": "203.0.113.10" } },
|
|
605
|
+
"198.51.100.9",
|
|
606
|
+
);
|
|
607
|
+
expect(await res.json()).toEqual({ clientIp: "198.51.100.9" });
|
|
608
|
+
} finally {
|
|
609
|
+
await stack.cleanup();
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("trustedProxyHops: 1 — the rightmost X-Forwarded-For entry is trusted", async () => {
|
|
614
|
+
const stack = await setupTestStack({
|
|
615
|
+
features: [probeFeature],
|
|
616
|
+
extraRoutes: [clientIpRoute],
|
|
617
|
+
trustedProxyHops: 1,
|
|
618
|
+
});
|
|
619
|
+
try {
|
|
620
|
+
const res = await stack.app.request("/public/client-ip-probe", {
|
|
621
|
+
headers: { "x-forwarded-for": "203.0.113.10" },
|
|
622
|
+
});
|
|
623
|
+
expect(await res.json()).toEqual({ clientIp: "203.0.113.10" });
|
|
624
|
+
} finally {
|
|
625
|
+
await stack.cleanup();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
588
630
|
// Type-level contract for ExtraRouteDefinition (kumiko-framework#3050): the
|
|
589
631
|
// bodies below are never invoked — tsc checks them, bun:test does not run
|
|
590
632
|
// them. Each `@ts-expect-error` turns into an "unused directive" compile
|
package/src/api/client-ip.ts
CHANGED
|
@@ -44,6 +44,33 @@ export function assertValidTrustedProxyHops(value: number | undefined, context:
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
|
|
48
|
+
|
|
49
|
+
// Fail loud on a garbage env value rather than silently coercing to NaN:
|
|
50
|
+
// a resolver built from NaN treats it like "always short chain" and returns
|
|
51
|
+
// "unknown" for every request, which collapses an IP-keyed rate limiter
|
|
52
|
+
// into one shared bucket for the whole deployment — a self-inflicted DoS,
|
|
53
|
+
// worse than staying on the default. Digits-only (not parseInt) —
|
|
54
|
+
// parseInt("0x10")/("1e3")/("2x") would silently coerce instead of failing.
|
|
55
|
+
export function parseTrustedProxyHopsEnv(
|
|
56
|
+
raw: string | undefined,
|
|
57
|
+
context: string,
|
|
58
|
+
): number | undefined {
|
|
59
|
+
if (raw === undefined) return undefined;
|
|
60
|
+
if (!/^\d+$/.test(raw)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const parsed = Number.parseInt(raw, 10);
|
|
66
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
// Instance-scoped, NOT a module singleton: the warn-once flag must reset
|
|
48
75
|
// per server boot (and per test), otherwise the first test to trigger it
|
|
49
76
|
// consumes the warning for the whole process and later tests can't observe
|
package/src/api/extra-route.ts
CHANGED
|
@@ -32,6 +32,9 @@ export type AnonymousExtraRouteDeps = {
|
|
|
32
32
|
* user, same request-resolved tenant (never overridable), only under
|
|
33
33
|
* "/api/" (the only path that populates a session user). */
|
|
34
34
|
readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
|
|
35
|
+
/** Resolved by the server's single trustedProxyHops-aware resolver —
|
|
36
|
+
* never read X-Forwarded-For yourself. */
|
|
37
|
+
readonly clientIp: string;
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export type AnonymousExtraRoute = {
|
package/src/api/index.ts
CHANGED
|
@@ -34,6 +34,8 @@ export {
|
|
|
34
34
|
clientIpSourceFromHonoContext,
|
|
35
35
|
createClientIpResolver,
|
|
36
36
|
extractSocketAddress,
|
|
37
|
+
parseTrustedProxyHopsEnv,
|
|
38
|
+
TRUSTED_PROXY_HOPS_ENV,
|
|
37
39
|
UNKNOWN_CLIENT_IP,
|
|
38
40
|
} from "./client-ip";
|
|
39
41
|
export type {
|
|
@@ -73,7 +75,11 @@ export type {
|
|
|
73
75
|
export { isSafeLandingPath } from "./post-auth-landing";
|
|
74
76
|
export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
|
|
75
77
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
|
|
76
|
-
export {
|
|
78
|
+
export {
|
|
79
|
+
type RequestContextData,
|
|
80
|
+
requestContext,
|
|
81
|
+
runAsDirectCallEntry,
|
|
82
|
+
} from "./request-context";
|
|
77
83
|
export {
|
|
78
84
|
buildRequestContextDataFromRequest,
|
|
79
85
|
requestIdMiddleware,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
3
|
+
import type { AgentRisk } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
4
|
import { generateId } from "../utils";
|
|
4
5
|
|
|
5
6
|
// Request-scoped propagation. Populated by the HTTP middleware and by the
|
|
@@ -54,6 +55,10 @@ export type RequestContextData = {
|
|
|
54
55
|
// Only ever a gated origin. Read by job enqueue and event-store.append();
|
|
55
56
|
// dispatch roots never narrow from it, only jobs inherit explicitly.
|
|
56
57
|
readonly writeOrigin?: WriteOrigin;
|
|
58
|
+
// The handler directly invoked by the dispatcher (not a nested ctx.write/
|
|
59
|
+
// writeAs target) — nested dispatches inherit it unchanged, so the
|
|
60
|
+
// irreversible-operation gate always sees the outermost caller's risk.
|
|
61
|
+
readonly entryHandler?: { readonly qn: string; readonly risk: AgentRisk };
|
|
57
62
|
};
|
|
58
63
|
|
|
59
64
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -77,7 +82,11 @@ export const requestContext = {
|
|
|
77
82
|
// when there is no surrounding request (job-runner, event-dispatcher) —
|
|
78
83
|
// requestId/correlationId are mandatory, `get()` may be undefined.
|
|
79
84
|
export function runWithOrigin<T>(
|
|
80
|
-
origin: {
|
|
85
|
+
origin: {
|
|
86
|
+
readonly feature?: string;
|
|
87
|
+
readonly handler?: string;
|
|
88
|
+
readonly entryHandler?: { readonly qn: string; readonly risk: AgentRisk };
|
|
89
|
+
},
|
|
81
90
|
fn: () => T,
|
|
82
91
|
): T {
|
|
83
92
|
const current = requestContext.get();
|
|
@@ -89,11 +98,24 @@ export function runWithOrigin<T>(
|
|
|
89
98
|
correlationId: current?.correlationId ?? requestId,
|
|
90
99
|
feature: origin.feature,
|
|
91
100
|
handler: origin.handler,
|
|
101
|
+
// The outermost dispatch wins — a nested ctx.write/writeAs must not
|
|
102
|
+
// overwrite the entry handler that the irreversible-operation gate reads.
|
|
103
|
+
entryHandler: current?.entryHandler ?? origin.entryHandler,
|
|
92
104
|
},
|
|
93
105
|
fn,
|
|
94
106
|
);
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
// Clears entryHandler so the next dispatch inside fn becomes the new entry —
|
|
110
|
+
// used by dispatchToolCall so the invoked tool handler, not the surrounding
|
|
111
|
+
// turn/approve handler, is what the irreversible-operation gate sees.
|
|
112
|
+
export function runAsDirectCallEntry<T>(fn: () => T): T {
|
|
113
|
+
const current = requestContext.get();
|
|
114
|
+
if (!current) return fn();
|
|
115
|
+
const { entryHandler: _entryHandler, ...rest } = current;
|
|
116
|
+
return requestContext.run(rest, fn);
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
// An ungated origin never mints a scope: seeds and boot writes keep their missing context.
|
|
98
120
|
export function runWithWriteOrigin<T>(origin: WriteOrigin, fn: () => T): T {
|
|
99
121
|
const current = requestContext.get();
|
package/src/api/server.ts
CHANGED
|
@@ -617,6 +617,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
617
617
|
userId: event.metadata.userId,
|
|
618
618
|
...(mspOwner && { callerFeature: mspOwner }),
|
|
619
619
|
...(mspFiles && { files: mspFiles }),
|
|
620
|
+
...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
|
|
620
621
|
...(mspFiles && {
|
|
621
622
|
derivatives: createDerivativesContext({
|
|
622
623
|
files: mspFiles,
|
|
@@ -1260,7 +1261,7 @@ type ExtraRouteHonoHandlerDeps = {
|
|
|
1260
1261
|
readonly secrets: import("../secrets").SecretsContext | undefined;
|
|
1261
1262
|
readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
|
|
1262
1263
|
readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
|
|
1263
|
-
readonly clientIpResolver
|
|
1264
|
+
readonly clientIpResolver: ClientIpResolver;
|
|
1264
1265
|
};
|
|
1265
1266
|
|
|
1266
1267
|
function buildExtraRouteHonoHandler(
|
|
@@ -1276,6 +1277,7 @@ function buildExtraRouteHonoHandler(
|
|
|
1276
1277
|
registry: shared.registry,
|
|
1277
1278
|
systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
|
|
1278
1279
|
write: makeAnonymousWrite(c, shared.dispatcher),
|
|
1280
|
+
clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
|
|
1279
1281
|
});
|
|
1280
1282
|
case ExtraRouteEntries.user:
|
|
1281
1283
|
return async (c) => {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.322.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Irreversible writes now require agent.risk \"high\" on the directly-dispatched handler",
|
|
6
|
+
"migration": "Any write handler reachable as the directly-dispatched entry point that\nperforms `executor.forget()` (any entity) or `executor.delete()` on an\nentity without `softDelete` must resolve `agent.risk: \"high\"`, or the\nexecutor gate now denies it with `access_denied` /\n`irreversible_operation_requires_high_risk` before any DB read.\n\nThree breaking changes ship together:\n1. Add `agent: { risk: \"high\" }` to your own write handler if it directly\n runs `forget` or a hard `delete` on a non-softDelete entity.\n2. Delegating via `ctx.write`/`ctx.writeAs` from a lower-risk handler into\n a high-risk forget/hard-delete handler no longer inherits the inner\n handler's risk — the outermost/entry handler's declared risk governs,\n so the delegating handler itself must be \"high\".\n3. Declaring `agent.risk` below \"high\" on a standard entity-convention\n delete handler for a non-softDelete entity is now a define-time/boot\n error instead of a silently-accepted setting.\n\nStandard entity-convention delete handlers on a non-softDelete entity now\ndefault to \"high\" automatically (no more implicit \"always allow\").\n\n`dispatchToolCall` treats the invoked tool itself as the entry handler,\nnot the surrounding turn/approve write handler that dispatched it — a\nmid-risk \"approve\" handler can safely invoke a high-risk tool directly,\nbut a mid-risk tool that itself delegates to a high-risk handler via\n`ctx.write`/`writeAs` is still denied per rule 2.\n\n`runAsDirectCallEntry` (framework `/api`) is the one sanctioned entry\nboundary: it clears the entry handler so the next dispatch counts as the\ndirectly called one. `dispatchToolCall` uses it because the human\nconfirmation happens in approve; do not use it to lift a handler past\nthe floor.\n\nRemaining gap: `afterCommit` hooks, jobs, and event consumers are not\ngated by this floor — they have no directly-dispatched entry handler to\nattribute risk to."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.322.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Translation values may now be CLDR plural-form objects, resolved per locale with Intl.PluralRules",
|
|
12
|
+
"migration": "A translation entry's value type widens from `string` to\n`string | PluralForms`, so an existing `TranslationEntry` locale value can\nnow also be an object with `one`/`few`/`many`/`other` (etc.) CLDR\ncategories, where `other` is required:\n\n```ts\n{ de: { one: \"{count} Status-Seite\", other: \"{count} Status-Seiten\" } }\n```\n\n`createI18n(...).t(key, locale, { count })` resolves the CLDR category for\nthe given locale via a per-locale-cached `Intl.PluralRules` and interpolates\n`{count}`; it falls back to `other` when `count` is missing/non-finite, the\nlocale tag is invalid, or `Intl.PluralRules` is unavailable (older Hermes).\n`mailT` and the renderer's `translateWithFallbacks` resolve plural values\nthe same way, through the shared `resolveTranslationValue` helper.\n\nExisting string-only translations keep working unchanged. Code that reads\nvalues back out of a bundle (`TranslationBundle`, `TranslationsByLocale`,\n`TranslationEntry`) and annotates them as `string` no longer typechecks:\nannotate as `TranslationValue` (from `@cosmicdrift/kumiko-framework/ui-types`)\nor flatten with `translationValueOtherText(value)`."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.321.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "kumiko-schema gained --help/-h/help",
|
|
18
|
+
"detail": "Prints usage listing the available subcommands and exits 0.",
|
|
19
|
+
"migration": "No action needed."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.321.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "parseTrustedProxyHopsEnv/TRUSTED_PROXY_HOPS_ENV factored out of runProdApp for runDevApp and consumer tooling to share",
|
|
25
|
+
"detail": "`packages/framework/src/api/client-ip.ts` gained\n`parseTrustedProxyHopsEnv(raw, context)` and the\n`TRUSTED_PROXY_HOPS_ENV = \"KUMIKO_TRUSTED_PROXY_HOPS\"` constant (both\nexported via the `api` barrel), moved out of `runProdApp`'s inline\nparsing. Same validation and error message as before.",
|
|
26
|
+
"migration": "No action needed; runProdApp's behavior and error message are unchanged."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.321.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "AnonymousExtraRouteDeps gained clientIp, resolved by the server's trustedProxyHops-aware resolver",
|
|
32
|
+
"detail": "`AnonymousExtraRouteDeps.clientIp: string`, populated the same way as the\nhttpRoute path via `shared.clientIpResolver.resolve\n(clientIpSourceFromHonoContext(c))`. `ExtraRouteHonoHandlerDeps\n.clientIpResolver` is now required (its only caller always supplied it).",
|
|
33
|
+
"migration": "Anonymous extra routes that need the caller's IP should read\n`deps.clientIp` instead of reading `X-Forwarded-For` (or any other\nheader) themselves — the latter is attacker-controlled unless you also\nknow the exact trusted-hop count. Tests that build an\n`AnonymousExtraRouteDeps` literal by hand must add a `clientIp` string."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "0.321.0",
|
|
37
|
+
"type": "improvement",
|
|
38
|
+
"title": "feature-ast resolves imported constants and registrar wrappers without building a TypeScript program",
|
|
39
|
+
"detail": "Imported `const` initializers (named imports, aliases, `export { X as Y }`,\nre-export chains, `export *`) and imported registrar-wrapper functions are\nresolved through `ts.resolveModuleName` plus a syntactic export walk.\nSame-file lookups walk enclosing block scopes instead of the file top level. Parse\noutput is unchanged; parsing a feature file drops from hundreds of\nmilliseconds to a few milliseconds."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"version": "0.321.0",
|
|
43
|
+
"type": "breaking",
|
|
44
|
+
"title": "webhook.send step auth secretRef renamed to secret; MultiStreamApplyContext gained an optional secrets field",
|
|
45
|
+
"detail": "`r.step.webhook.send`'s `auth` union renamed `secretRef` → `secret` (see\nthe step-dispatcher entry for the full rationale). `MultiStreamApplyContext`\n(and `createMultiStreamApplyContext`'s deps) gained an optional\n`secrets?: SecretsContext` field, mirroring `files`/`derivatives` — present\nwhen the app booted with the secrets feature, letting a saga/process-\nmanager MSP apply read a tenant secret with its own audit context. The\nserver's MSP consumer wiring now threads the boot-time `AppContext.secrets`\nthrough automatically; `rebuildMultiStreamProjection`'s rebuild context\ndeliberately does not carry `secrets` (rebuild must stay side-effect-free).",
|
|
46
|
+
"migration": "Rename `auth.secretRef` → `auth.secret` at every `r.step.webhook.send`\ncall site. No action needed for `MultiStreamApplyContext` consumers that\ndon't read `ctx.secrets` — the field is optional and additive."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"version": "0.321.0",
|
|
50
|
+
"type": "improvement",
|
|
51
|
+
"title": "New event-store primitive transferAggregateStreams for tenant-to-tenant aggregate moves",
|
|
52
|
+
"detail": "`transferAggregateStreams(db, { sourceTenantId, destinationTenantId,\naggregateType, aggregateIds, transferredBy })` (event-store/transfer.ts)\nmoves an aggregate's events, drops its snapshot, and carries its archive\nmarker from one tenant to another, then appends one\n`kumiko:system:aggregate.transferred` system event per moved aggregate\n(own fresh stream, destination tenant, payload carries aggregateType/\naggregateId/sourceTenantId/destinationTenantId). An id with no events\nunder the given tenant/aggregateType is left untouched and gets no event.\nBacked by db/queries/event-store-transfer.ts.",
|
|
53
|
+
"migration": "No action for existing consumers — this is a new, additive primitive."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"version": "0.321.0",
|
|
57
|
+
"type": "fix",
|
|
58
|
+
"title": "upgrade-cli now collects every package's changes.json, not just framework's",
|
|
59
|
+
"detail": "`findCoreChangelogFile(cwd): string | null` is replaced by\n`findPackageChangelogFiles(cwd): string[]`. In the framework repo itself\nit collects every `packages/<dir>/src/changes.json` except\n`packages/bundled-features` (bundled features are already collected per\nfeature dir). In a consumer repo it walks up to 10 levels looking for\n`node_modules/@cosmicdrift/*/src/changes.json`, skipping\n`kumiko-bundled-features`, deduping by package name (nearest\n`node_modules` wins) and by realpath (symlinked workspace copies).",
|
|
60
|
+
"migration": "No action needed — `kumiko upgrade` now surfaces more relevant breaking\nchanges than before, it never hides ones it previously showed."
|
|
61
|
+
},
|
|
2
62
|
{
|
|
3
63
|
"version": "0.320.0",
|
|
4
64
|
"type": "breaking",
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
VersionConflictError as EventStoreVersionConflict,
|
|
19
19
|
getStreamVersion,
|
|
20
20
|
} from "../event-store";
|
|
21
|
+
import {
|
|
22
|
+
assertIrreversibleOperationAllowed,
|
|
23
|
+
isIrreversibleEntityVerb,
|
|
24
|
+
} from "../pipeline/irreversible-operation-gate";
|
|
21
25
|
import { generateId } from "../utils";
|
|
22
26
|
import { applyEntityEvent } from "./apply-entity-event";
|
|
23
27
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
@@ -586,6 +590,9 @@ export function createWriteVerbs(
|
|
|
586
590
|
},
|
|
587
591
|
|
|
588
592
|
async delete(payload, user, db) {
|
|
593
|
+
if (isIrreversibleEntityVerb("delete", entity)) {
|
|
594
|
+
assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
|
|
595
|
+
}
|
|
589
596
|
const runner = tenantDbRunner(db);
|
|
590
597
|
const existing = await loadById(payload.id, db);
|
|
591
598
|
if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -684,6 +691,7 @@ export function createWriteVerbs(
|
|
|
684
691
|
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
685
692
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
686
693
|
async forget(payload, user, db) {
|
|
694
|
+
assertIrreversibleOperationAllowed(`${entityName}.forget`);
|
|
687
695
|
const runner = tenantDbRunner(db);
|
|
688
696
|
const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
|
|
689
697
|
if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Raw SQL backing event-store/transfer.ts — the framework's own tenant-transfer
|
|
2
|
+
// primitive for streams. Kept in db/queries alongside the rest of the
|
|
3
|
+
// event-store's raw statements (see event-store.ts, event-store-admin.ts,
|
|
4
|
+
// backfill-pii.ts): the one place in the repo allowed to write tenant_id
|
|
5
|
+
// directly onto kumiko_events / kumiko_snapshots / kumiko_archived_streams.
|
|
6
|
+
|
|
7
|
+
import type { AnyDb } from "../query";
|
|
8
|
+
import { asRawClient } from "../query";
|
|
9
|
+
|
|
10
|
+
export type TransferEventRowsParams = {
|
|
11
|
+
readonly sourceTenantId: string;
|
|
12
|
+
readonly destinationTenantId: string;
|
|
13
|
+
readonly aggregateType: string;
|
|
14
|
+
readonly aggregateIds: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
|
|
18
|
+
// per-event-row hits in-process instead of relying on invalid SQL.
|
|
19
|
+
export async function transferEventRows(
|
|
20
|
+
db: AnyDb,
|
|
21
|
+
params: TransferEventRowsParams,
|
|
22
|
+
): Promise<readonly string[]> {
|
|
23
|
+
const rows = (await asRawClient(db).unsafe(
|
|
24
|
+
`UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
|
|
25
|
+
`WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4) ` +
|
|
26
|
+
`RETURNING "aggregate_id" AS id`,
|
|
27
|
+
[params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
|
|
28
|
+
)) as ReadonlyArray<{ id: string }>;
|
|
29
|
+
return [...new Set(rows.map((row) => row.id))];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DeleteSnapshotsForAggregatesParams = {
|
|
33
|
+
readonly tenantId: string;
|
|
34
|
+
readonly aggregateIds: readonly string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
|
|
38
|
+
// dropped rather than rewritten, forcing a full replay from the just-moved
|
|
39
|
+
// events on next read instead of carrying the snapshot's own generation
|
|
40
|
+
// bookkeeping across the tenant boundary.
|
|
41
|
+
export async function deleteSnapshotsForAggregates(
|
|
42
|
+
db: AnyDb,
|
|
43
|
+
params: DeleteSnapshotsForAggregatesParams,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
await asRawClient(db).unsafe(
|
|
46
|
+
`DELETE FROM "kumiko_snapshots" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2)`,
|
|
47
|
+
[params.tenantId, params.aggregateIds],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TransferArchivedStreamRowsParams = {
|
|
52
|
+
readonly sourceTenantId: string;
|
|
53
|
+
readonly destinationTenantId: string;
|
|
54
|
+
readonly aggregateType: string;
|
|
55
|
+
readonly aggregateIds: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// An archived stream's marker must move with it — leaving it behind would
|
|
59
|
+
// make the stream writable again the instant it lands in the destination
|
|
60
|
+
// tenant (archive.ts's isStreamArchived check is itself tenant-scoped).
|
|
61
|
+
// `aggregate_type` is part of the match, same as transferEventRows — an
|
|
62
|
+
// archived marker for a DIFFERENT aggregate type that happens to share the
|
|
63
|
+
// moved aggregate's id must stay in the source tenant.
|
|
64
|
+
export async function transferArchivedStreamRows(
|
|
65
|
+
db: AnyDb,
|
|
66
|
+
params: TransferArchivedStreamRowsParams,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
await asRawClient(db).unsafe(
|
|
69
|
+
`UPDATE "kumiko_archived_streams" SET "tenant_id" = $1 ` +
|
|
70
|
+
`WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4)`,
|
|
71
|
+
[params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -110,6 +110,34 @@ describe("defineEntityWriteHandler", () => {
|
|
|
110
110
|
// @ts-expect-error access is required since fw#2855 — no implicit openToAll.
|
|
111
111
|
expect(() => defineEntityCreateHandler("note", noteEntity)).toThrow();
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test("delete on an entity without softDelete defaults agent.risk to high", () => {
|
|
115
|
+
const def = defineEntityDeleteHandler("note", noteEntity, adminAccess);
|
|
116
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("high");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("delete on an entity without softDelete preserves other agent hints while forcing risk high", () => {
|
|
120
|
+
const def = defineEntityDeleteHandler("note", noteEntity, {
|
|
121
|
+
access: { roles: ["Admin"] },
|
|
122
|
+
agent: { expose: false },
|
|
123
|
+
});
|
|
124
|
+
expect(def.agent).toEqual({ expose: false, risk: "high" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("delete on an entity without softDelete throws when agent.risk is explicitly set below high", () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
defineEntityDeleteHandler("note", noteEntity, {
|
|
130
|
+
access: { roles: ["Admin"] },
|
|
131
|
+
agent: { risk: "mid" },
|
|
132
|
+
}),
|
|
133
|
+
).toThrow(/agent\.risk must be "high"/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("delete on a softDelete entity leaves agent hints untouched", () => {
|
|
137
|
+
const def = defineEntityDeleteHandler("note", noteEntitySoftDelete, adminAccess);
|
|
138
|
+
expect(def.agent).toBeUndefined();
|
|
139
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("mid");
|
|
140
|
+
});
|
|
113
141
|
});
|
|
114
142
|
|
|
115
143
|
describe("defineEntityQueryHandler", () => {
|
|
@@ -39,7 +39,7 @@ describe("buildWebhookSendStep", () => {
|
|
|
39
39
|
method: "PUT",
|
|
40
40
|
headers: { "X-Custom": "val" },
|
|
41
41
|
body: { event: "test" },
|
|
42
|
-
auth: { kind: "bearer",
|
|
42
|
+
auth: { kind: "bearer", secret: "MY_SECRET" },
|
|
43
43
|
retry: { times: 5, backoff: "linear" },
|
|
44
44
|
mode: "deferred",
|
|
45
45
|
});
|
|
@@ -125,7 +125,7 @@ describe("webhook.send run", () => {
|
|
|
125
125
|
|
|
126
126
|
it("passes auth config through when provided", async () => {
|
|
127
127
|
const stepDef = getStep("webhook.send");
|
|
128
|
-
const auth = { kind: "bearer" as const,
|
|
128
|
+
const auth = { kind: "bearer" as const, secret: "WEBHOOK_TOKEN" };
|
|
129
129
|
|
|
130
130
|
await stepDef!.run({ url: "https://hooks.example/secured", mode: "deferred", auth }, mockCtx);
|
|
131
131
|
|
|
@@ -16,6 +16,7 @@ import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
|
|
|
16
16
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
|
|
17
17
|
import type {
|
|
18
18
|
AccessRule,
|
|
19
|
+
AgentHandlerHints,
|
|
19
20
|
DeriveContext,
|
|
20
21
|
EntityDefinition,
|
|
21
22
|
EscapeHatchDeclaration,
|
|
@@ -213,6 +214,23 @@ function isInTotalsMatchPair(entity: EntityDefinition, fieldName: string): boole
|
|
|
213
214
|
);
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
// delete on an entity without softDelete hard-purges the row — the agent
|
|
218
|
+
// risk gate only protects it if this handler resolves to "high".
|
|
219
|
+
function resolveEntityWriteAgentHints(
|
|
220
|
+
name: string,
|
|
221
|
+
verb: (typeof WRITE_VERBS)[number],
|
|
222
|
+
entity: EntityDefinition,
|
|
223
|
+
agent: AgentHandlerHints | undefined,
|
|
224
|
+
): AgentHandlerHints | undefined {
|
|
225
|
+
if (verb !== "delete" || entity.softDelete) return agent;
|
|
226
|
+
if (agent?.risk !== undefined && agent.risk !== "high") {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`"${name}": delete on an entity without softDelete is irreversible — agent.risk must be "high" (got "${agent.risk}").`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return { ...agent, risk: "high" };
|
|
232
|
+
}
|
|
233
|
+
|
|
216
234
|
function assertExcludableFields(
|
|
217
235
|
name: string,
|
|
218
236
|
verb: (typeof WRITE_VERBS)[number],
|
|
@@ -253,6 +271,7 @@ export function defineEntityWriteHandler(
|
|
|
253
271
|
`"${name}": restore is only valid for entities declared with softDelete: true.`,
|
|
254
272
|
);
|
|
255
273
|
}
|
|
274
|
+
const agentHints = resolveEntityWriteAgentHints(name, verb, entity, options.agent);
|
|
256
275
|
const excludedFields = options.excludeFields ?? [];
|
|
257
276
|
assertExcludableFields(name, verb, entity, excludedFields);
|
|
258
277
|
|
|
@@ -378,7 +397,7 @@ export function defineEntityWriteHandler(
|
|
|
378
397
|
handler,
|
|
379
398
|
access: options.access,
|
|
380
399
|
...(options.description !== undefined && { description: options.description }),
|
|
381
|
-
...(
|
|
400
|
+
...(agentHints !== undefined && { agent: agentHints }),
|
|
382
401
|
};
|
|
383
402
|
}
|
|
384
403
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ORIGIN_VALUE as ALIASED } from "./origin";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cycle-b";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cycle-a";
|