@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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Agent-risk floor for irreversible operations (delete on a non-softDelete
|
|
2
|
+
// entity, forget on any entity): the directly-dispatched entry handler must
|
|
3
|
+
// resolve agent.risk "high", or the executor gate refuses before any DB read.
|
|
4
|
+
// Real HTTP calls + setupTestStack — never createTestDispatcher.
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
9
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
10
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
11
|
+
import type { Registry } from "../../engine/types";
|
|
12
|
+
import { resolveAgentExposure } from "../../engine/types";
|
|
13
|
+
import { resetEventStore, setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
14
|
+
import { waitFor } from "../../testing";
|
|
15
|
+
|
|
16
|
+
const admin = TestUsers.admin;
|
|
17
|
+
const NONEXISTENT_ID = "00000000-0000-4000-8000-00000000dead";
|
|
18
|
+
|
|
19
|
+
const hardEntity = createEntity({
|
|
20
|
+
table: "gate_hard_items",
|
|
21
|
+
fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
|
|
22
|
+
});
|
|
23
|
+
const hardTable = buildEntityTable("hard", hardEntity);
|
|
24
|
+
const hardExecutor = createEventStoreExecutor(hardTable, hardEntity, { entityName: "hard" });
|
|
25
|
+
|
|
26
|
+
const softEntity = createEntity({
|
|
27
|
+
table: "gate_soft_items",
|
|
28
|
+
fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
|
|
29
|
+
softDelete: true,
|
|
30
|
+
});
|
|
31
|
+
const softTable = buildEntityTable("soft", softEntity);
|
|
32
|
+
const softExecutor = createEventStoreExecutor(softTable, softEntity, { entityName: "soft" });
|
|
33
|
+
|
|
34
|
+
const jobForgetRuns: Array<{ readonly id: string }> = [];
|
|
35
|
+
|
|
36
|
+
const gateFeature = defineFeature("gatetest", (r) => {
|
|
37
|
+
r.entity("hard", hardEntity);
|
|
38
|
+
r.entity("soft", softEntity);
|
|
39
|
+
|
|
40
|
+
r.crud("hard", hardEntity, {
|
|
41
|
+
write: { access: { roles: ["Admin"] } },
|
|
42
|
+
read: { access: { roles: ["Admin"] } },
|
|
43
|
+
});
|
|
44
|
+
r.crud("soft", softEntity, {
|
|
45
|
+
write: { access: { roles: ["Admin"] } },
|
|
46
|
+
read: { access: { roles: ["Admin"] } },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
r.writeHandler(
|
|
50
|
+
"forget-hard-mid",
|
|
51
|
+
z.object({ id: z.uuid() }),
|
|
52
|
+
async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
|
|
53
|
+
{ access: { roles: ["Admin"] } },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
r.writeHandler(
|
|
57
|
+
"forget-hard-high",
|
|
58
|
+
z.object({ id: z.uuid() }),
|
|
59
|
+
async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
|
|
60
|
+
{ access: { roles: ["Admin"] }, agent: { risk: "high" } },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
r.writeHandler(
|
|
64
|
+
"delete-hard-mid",
|
|
65
|
+
z.object({ id: z.uuid() }),
|
|
66
|
+
async (event, ctx) => hardExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
|
|
67
|
+
{ access: { roles: ["Admin"] } },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
r.writeHandler(
|
|
71
|
+
"delete-soft-mid",
|
|
72
|
+
z.object({ id: z.uuid() }),
|
|
73
|
+
async (event, ctx) => softExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
|
|
74
|
+
{ access: { roles: ["Admin"] } },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
r.writeHandler(
|
|
78
|
+
"delegate-write-to-forget-high",
|
|
79
|
+
z.object({ id: z.uuid() }),
|
|
80
|
+
async (event, ctx) => ctx.write("gatetest:write:forget-hard-high", { id: event.payload.id }),
|
|
81
|
+
{ access: { roles: ["Admin"] } },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
r.writeHandler(
|
|
85
|
+
"delegate-write-as-to-forget-high",
|
|
86
|
+
z.object({ id: z.uuid() }),
|
|
87
|
+
async (event, ctx) =>
|
|
88
|
+
ctx.writeAs(event.user, "gatetest:write:forget-hard-high", { id: event.payload.id }),
|
|
89
|
+
{ access: { roles: ["Admin"] } },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
r.job("forgetHardJob", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
93
|
+
const id = payload["id"] as string; // @cast-boundary dynamic-key
|
|
94
|
+
await hardExecutor.forget({ id }, ctx.systemUser, ctx.db);
|
|
95
|
+
jobForgetRuns.push({ id });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function errorReason(details: unknown): string | undefined {
|
|
100
|
+
if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
|
|
101
|
+
return typeof details.reason === "string" ? details.reason : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let stack: TestStack;
|
|
105
|
+
|
|
106
|
+
beforeAll(async () => {
|
|
107
|
+
stack = await setupTestStack({ features: [gateFeature], jobs: { consumerLane: "worker" } });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(async () => {
|
|
111
|
+
await stack.cleanup();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(async () => {
|
|
115
|
+
await resetEventStore(stack, ["gate_hard_items", "gate_soft_items"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
async function createHard(label: string): Promise<string> {
|
|
119
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
120
|
+
"gatetest:write:hard:create",
|
|
121
|
+
{ label },
|
|
122
|
+
admin,
|
|
123
|
+
);
|
|
124
|
+
return id;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function createSoft(label: string): Promise<string> {
|
|
128
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
129
|
+
"gatetest:write:soft:create",
|
|
130
|
+
{ label },
|
|
131
|
+
admin,
|
|
132
|
+
);
|
|
133
|
+
return id;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The entity-convention "detail" query returns `data: null` for a missing
|
|
137
|
+
// row instead of an HTTP error — assert via queryOk + null, not queryErr.
|
|
138
|
+
async function hardRowExists(id: string): Promise<boolean> {
|
|
139
|
+
const row = await stack.http.queryOk("gatetest:query:hard:detail", { id }, admin);
|
|
140
|
+
return row !== null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe("executor.forget — always irreversible", () => {
|
|
144
|
+
test("mid-risk handler is denied, row survives", async () => {
|
|
145
|
+
const id = await createHard("survives-mid-forget");
|
|
146
|
+
const err = await stack.http.writeErr("gatetest:write:forget-hard-mid", { id }, admin);
|
|
147
|
+
expect(err.httpStatus).toBe(403);
|
|
148
|
+
expect(err.code).toBe("access_denied");
|
|
149
|
+
expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
|
|
150
|
+
expect(err.message).toContain("gatetest:write:forget-hard-mid");
|
|
151
|
+
expect(await hardRowExists(id)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("high-risk handler succeeds, row is gone", async () => {
|
|
155
|
+
const id = await createHard("gone-after-high-forget");
|
|
156
|
+
await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
|
|
157
|
+
expect(await hardRowExists(id)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("nonexistent id at mid-risk still answers 403, not 404", async () => {
|
|
161
|
+
const err = await stack.http.writeErr(
|
|
162
|
+
"gatetest:write:forget-hard-mid",
|
|
163
|
+
{ id: NONEXISTENT_ID },
|
|
164
|
+
admin,
|
|
165
|
+
);
|
|
166
|
+
expect(err.httpStatus).toBe(403);
|
|
167
|
+
expect(err.code).toBe("access_denied");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("executor.delete — irreversible only without softDelete", () => {
|
|
172
|
+
test("mid-risk handler on a hard-delete entity is denied", async () => {
|
|
173
|
+
const id = await createHard("survives-mid-delete");
|
|
174
|
+
const err = await stack.http.writeErr("gatetest:write:delete-hard-mid", { id }, admin);
|
|
175
|
+
expect(err.httpStatus).toBe(403);
|
|
176
|
+
expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("mid-risk handler on a softDelete entity succeeds", async () => {
|
|
180
|
+
const id = await createSoft("soft-delete-ok");
|
|
181
|
+
await stack.http.writeOk("gatetest:write:delete-soft-mid", { id }, admin);
|
|
182
|
+
const row = await stack.http.queryOk("gatetest:query:soft:detail", { id }, admin);
|
|
183
|
+
expect(row).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("delegation via ctx.write/writeAs does not inherit the inner handler's risk", () => {
|
|
188
|
+
test("mid handler delegating via ctx.write to a high forget handler is denied", async () => {
|
|
189
|
+
const id = await createHard("delegate-write-denied");
|
|
190
|
+
const err = await stack.http.writeErr(
|
|
191
|
+
"gatetest:write:delegate-write-to-forget-high",
|
|
192
|
+
{ id },
|
|
193
|
+
admin,
|
|
194
|
+
);
|
|
195
|
+
expect(err.httpStatus).toBe(403);
|
|
196
|
+
expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
|
|
197
|
+
expect(await hardRowExists(id)).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("mid handler delegating via ctx.writeAs to a high forget handler is denied", async () => {
|
|
201
|
+
const id = await createHard("delegate-write-as-denied");
|
|
202
|
+
const err = await stack.http.writeErr(
|
|
203
|
+
"gatetest:write:delegate-write-as-to-forget-high",
|
|
204
|
+
{ id },
|
|
205
|
+
admin,
|
|
206
|
+
);
|
|
207
|
+
expect(err.httpStatus).toBe(403);
|
|
208
|
+
expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
|
|
209
|
+
expect(await hardRowExists(id)).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("calling the high handler directly still succeeds", async () => {
|
|
213
|
+
const id = await createHard("direct-high-ok");
|
|
214
|
+
await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
|
|
215
|
+
expect(await hardRowExists(id)).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("standard entity-convention delete handler", () => {
|
|
220
|
+
test("resolves to high risk on a non-softDelete entity, mid on a softDelete one", () => {
|
|
221
|
+
const registry = stack.registry as Registry;
|
|
222
|
+
const hardDelete = registry.getWriteHandler("gatetest:write:hard:delete");
|
|
223
|
+
const softDelete = registry.getWriteHandler("gatetest:write:soft:delete");
|
|
224
|
+
if (!hardDelete || !softDelete) throw new Error("delete handlers missing from registry");
|
|
225
|
+
expect(resolveAgentExposure(hardDelete, "write").risk).toBe("high");
|
|
226
|
+
expect(resolveAgentExposure(softDelete, "write").risk).toBe("mid");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("succeeds over HTTP on the non-softDelete entity", async () => {
|
|
230
|
+
const id = await createHard("standard-delete-ok");
|
|
231
|
+
await stack.http.writeOk("gatetest:write:hard:delete", { id }, admin);
|
|
232
|
+
expect(await hardRowExists(id)).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("jobs have no entry handler — the gate stays out of their way", () => {
|
|
237
|
+
test("a job calling executor.forget without a write-handler entry runs through", async () => {
|
|
238
|
+
jobForgetRuns.length = 0;
|
|
239
|
+
const id = await createHard("job-forget-ok");
|
|
240
|
+
await stack.jobRunner?.dispatch("gatetest:job:forget-hard-job", {
|
|
241
|
+
id,
|
|
242
|
+
tenantId: admin.tenantId,
|
|
243
|
+
});
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(jobForgetRuns.some((run) => run.id === id)).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
expect(await hardRowExists(id)).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -13,6 +13,7 @@ import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
|
13
13
|
import type { defineTransitions } from "../engine/state-machine";
|
|
14
14
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
15
15
|
import type {
|
|
16
|
+
AgentRisk,
|
|
16
17
|
AggregateStreamHandle,
|
|
17
18
|
AppContext,
|
|
18
19
|
AppendEventArgs,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
SessionUser,
|
|
29
30
|
WriteResult,
|
|
30
31
|
} from "../engine/types";
|
|
31
|
-
import { isRateLimitDisabled } from "../engine/types";
|
|
32
|
+
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types";
|
|
32
33
|
import type { TenantId } from "../engine/types/identifiers";
|
|
33
34
|
import {
|
|
34
35
|
FeatureDisabledError,
|
|
@@ -877,6 +878,25 @@ export async function buildHandlerContext(
|
|
|
877
878
|
: handlerContext;
|
|
878
879
|
}
|
|
879
880
|
|
|
881
|
+
// Resolves the agent risk the irreversible-operation gate should attribute to
|
|
882
|
+
// `type` when it becomes the request's entry handler — same defaulting rule
|
|
883
|
+
// (resolveAgentExposure) the agent manifest itself uses, so the two never diverge.
|
|
884
|
+
function resolveEntryHandlerRisk(
|
|
885
|
+
registry: Registry,
|
|
886
|
+
type: string,
|
|
887
|
+
operation: "query" | "write" | "stream",
|
|
888
|
+
): AgentRisk {
|
|
889
|
+
if (operation === "write") {
|
|
890
|
+
return resolveAgentExposure(registry.getWriteHandler(type) ?? {}, "write").risk;
|
|
891
|
+
}
|
|
892
|
+
if (operation === "stream") {
|
|
893
|
+
// StreamHandlerDef carries no `agent` hint — streams can never declare
|
|
894
|
+
// "high", so nested irreversible writes stay conservatively denied.
|
|
895
|
+
return "mid";
|
|
896
|
+
}
|
|
897
|
+
return resolveAgentExposure(registry.getQueryHandler(type) ?? {}, "query").risk;
|
|
898
|
+
}
|
|
899
|
+
|
|
880
900
|
// Wrap handler execution in a dispatcher.handler span AND emit the standard
|
|
881
901
|
// dispatcher metrics (duration + error counter). Errors are re-thrown so
|
|
882
902
|
// control flow stays identical to the uninstrumented path.
|
|
@@ -916,7 +936,11 @@ export async function runHandlerInstrumented<T>(
|
|
|
916
936
|
// #3043 — everything the handler writes, including entity-executor
|
|
917
937
|
// writes below it, is attributed to this handler.
|
|
918
938
|
const result = await runWithOrigin(
|
|
919
|
-
{
|
|
939
|
+
{
|
|
940
|
+
handler: type,
|
|
941
|
+
feature: registry.getHandlerFeature(type),
|
|
942
|
+
entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, operation) },
|
|
943
|
+
},
|
|
920
944
|
inner,
|
|
921
945
|
);
|
|
922
946
|
if (operation === "write" && isFailedWriteResult(result)) {
|
|
@@ -978,8 +1002,13 @@ export async function* runStreamInstrumented<T>(
|
|
|
978
1002
|
// generator suspension, so wrapping inner() once would leave every event a
|
|
979
1003
|
// stream writes unattributed.
|
|
980
1004
|
const inScope = <R>(fn: () => R): R =>
|
|
981
|
-
runWithOrigin(
|
|
982
|
-
|
|
1005
|
+
runWithOrigin(
|
|
1006
|
+
{
|
|
1007
|
+
handler: type,
|
|
1008
|
+
feature: registry.getHandlerFeature(type),
|
|
1009
|
+
entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, "stream") },
|
|
1010
|
+
},
|
|
1011
|
+
() => observabilityContext.run({ activeSpan: span }, fn),
|
|
983
1012
|
);
|
|
984
1013
|
try {
|
|
985
1014
|
let next = await inScope(() => it.next());
|
package/src/pipeline/index.ts
CHANGED
|
@@ -50,6 +50,10 @@ export type { PruneEventsOptions, PruneEventsResult } from "./event-retention";
|
|
|
50
50
|
export { ConsumerLagError, pruneEvents } from "./event-retention";
|
|
51
51
|
export type { IdempotencyGuard } from "./idempotency";
|
|
52
52
|
export { createIdempotencyGuard } from "./idempotency";
|
|
53
|
+
export {
|
|
54
|
+
assertIrreversibleOperationAllowed,
|
|
55
|
+
isIrreversibleEntityVerb,
|
|
56
|
+
} from "./irreversible-operation-gate";
|
|
53
57
|
export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pipeline";
|
|
54
58
|
export { createLifecycleHooks } from "./lifecycle-pipeline";
|
|
55
59
|
export type { MspRebuildDeps } from "./msp-rebuild";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { requestContext } from "../api/request-context";
|
|
2
|
+
import type { EntityDefinition } from "../engine/types";
|
|
3
|
+
import { AccessDeniedError, FrameworkReasons } from "../errors";
|
|
4
|
+
|
|
5
|
+
// forget hard-purges regardless of softDelete; delete is only irreversible
|
|
6
|
+
// when the entity has no soft-delete recovery path.
|
|
7
|
+
export function isIrreversibleEntityVerb(
|
|
8
|
+
verb: "delete" | "forget",
|
|
9
|
+
entity: EntityDefinition,
|
|
10
|
+
): boolean {
|
|
11
|
+
return verb === "forget" || !entity.softDelete;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// No entryHandler means the call didn't originate from a directly-dispatched
|
|
15
|
+
// handler (job, MSP/consumer apply, seed, afterCommit hook, HTTP route
|
|
16
|
+
// without a handler) — those are outside agent reach and stay ungated.
|
|
17
|
+
export function assertIrreversibleOperationAllowed(operation: string): void {
|
|
18
|
+
const entryHandler = requestContext.get()?.entryHandler;
|
|
19
|
+
// skip: no entry handler (see above) or the entry handler already resolves "high".
|
|
20
|
+
if (!entryHandler || entryHandler.risk === "high") return;
|
|
21
|
+
throw new AccessDeniedError({
|
|
22
|
+
message:
|
|
23
|
+
`Handler "${entryHandler.qn}" performs an irreversible ${operation} but resolves agent risk ` +
|
|
24
|
+
`"${entryHandler.risk}" — declare agent: { risk: "high" } on "${entryHandler.qn}". The ` +
|
|
25
|
+
"directly called handler must carry it; delegating via ctx.write/writeAs does not.",
|
|
26
|
+
details: {
|
|
27
|
+
reason: FrameworkReasons.irreversibleOperationRequiresHighRisk,
|
|
28
|
+
handler: entryHandler.qn,
|
|
29
|
+
operation,
|
|
30
|
+
resolvedRisk: entryHandler.risk,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DerivativesContext } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
2
|
import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
|
|
3
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-types/secrets-types";
|
|
3
4
|
import type { DbRunner } from "../db/connection";
|
|
4
5
|
import type { AppendEventArgs, AppendEventFn, Registry, TenantId } from "../engine/types";
|
|
5
6
|
import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
|
|
@@ -32,6 +33,8 @@ export type MultiStreamApplyContextDeps = {
|
|
|
32
33
|
readonly files?: FileContext;
|
|
33
34
|
// Same DerivativesContext the outer AppContext carries — mirrors `files`.
|
|
34
35
|
readonly derivatives?: DerivativesContext;
|
|
36
|
+
// Same SecretsContext the outer AppContext carries, mirrors `files`.
|
|
37
|
+
readonly secrets?: SecretsContext;
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export function createMultiStreamApplyContext(
|
|
@@ -41,6 +44,7 @@ export function createMultiStreamApplyContext(
|
|
|
41
44
|
registry: deps.registry,
|
|
42
45
|
...(deps.files ? { files: deps.files } : {}),
|
|
43
46
|
...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
|
|
47
|
+
...(deps.secrets ? { secrets: deps.secrets } : {}),
|
|
44
48
|
appendEvent: (async (args: AppendEventArgs) => {
|
|
45
49
|
await appendDomainEventCore(
|
|
46
50
|
{
|
package/src/schema-cli.ts
CHANGED
|
@@ -115,7 +115,11 @@ export async function runSchemaCli(
|
|
|
115
115
|
// migration, since the crashed process still records the migration as
|
|
116
116
|
// applied and the rebuild is never retried.
|
|
117
117
|
await ensureTemporalPolyfill();
|
|
118
|
-
|
|
118
|
+
// `--help`/`-h`/`help` fall through to the same "no subcommand" usage
|
|
119
|
+
// branch below (undefined) — a real request for help, not a typo that
|
|
120
|
+
// should fail loud like an unrecognized subcommand does.
|
|
121
|
+
const rawSub = argv[0];
|
|
122
|
+
const sub = rawSub === "--help" || rawSub === "-h" || rawSub === "help" ? undefined : rawSub;
|
|
119
123
|
const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
|
|
120
124
|
const migrationsDir = resolvePath(appCwd, "kumiko/migrations");
|
|
121
125
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveTranslationValue, translationValueOtherText } from "../plural";
|
|
3
|
+
|
|
4
|
+
const messageCount = {
|
|
5
|
+
de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
|
|
6
|
+
en: { one: "{count} unread message", other: "{count} unread messages" },
|
|
7
|
+
pl: {
|
|
8
|
+
one: "{count} nieprzeczytana wiadomość",
|
|
9
|
+
few: "{count} nieprzeczytane wiadomości",
|
|
10
|
+
many: "{count} nieprzeczytanych wiadomości",
|
|
11
|
+
other: "{count} nieprzeczytanej wiadomości",
|
|
12
|
+
},
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
describe("resolveTranslationValue — plain strings", () => {
|
|
16
|
+
test("interpolates {name} placeholders", () => {
|
|
17
|
+
expect(resolveTranslationValue("Hello, {name}!", "en", { name: "Ada" })).toBe("Hello, Ada!");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("leaves an unknown placeholder untouched", () => {
|
|
21
|
+
expect(resolveTranslationValue("Hello, {name}!", "en")).toBe("Hello, {name}!");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("resolveTranslationValue — plural forms", () => {
|
|
26
|
+
test("en: singular vs plural", () => {
|
|
27
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 1 })).toBe("1 unread message");
|
|
28
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 0 })).toBe("0 unread messages");
|
|
29
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 2 })).toBe("2 unread messages");
|
|
30
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 5 })).toBe("5 unread messages");
|
|
31
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 21 })).toBe(
|
|
32
|
+
"21 unread messages",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("de: singular vs plural", () => {
|
|
37
|
+
expect(resolveTranslationValue(messageCount.de, "de", { count: 1 })).toBe(
|
|
38
|
+
"1 ungelesene Nachricht",
|
|
39
|
+
);
|
|
40
|
+
expect(resolveTranslationValue(messageCount.de, "de", { count: 5 })).toBe(
|
|
41
|
+
"5 ungelesene Nachrichten",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("pl: one/few/many CLDR categories", () => {
|
|
46
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 1 })).toBe(
|
|
47
|
+
"1 nieprzeczytana wiadomość",
|
|
48
|
+
);
|
|
49
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 2 })).toBe(
|
|
50
|
+
"2 nieprzeczytane wiadomości",
|
|
51
|
+
);
|
|
52
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 5 })).toBe(
|
|
53
|
+
"5 nieprzeczytanych wiadomości",
|
|
54
|
+
);
|
|
55
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 21 })).toBe(
|
|
56
|
+
"21 nieprzeczytanych wiadomości",
|
|
57
|
+
);
|
|
58
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 22 })).toBe(
|
|
59
|
+
"22 nieprzeczytane wiadomości",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a formal BCP-47 subtag still resolves plural categories (renderer's <locale>-x-formal tier)", () => {
|
|
64
|
+
expect(resolveTranslationValue(messageCount.pl, "pl-x-formal", { count: 2 })).toBe(
|
|
65
|
+
"2 nieprzeczytane wiadomości",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("falls back to `other` when the selected category has no form", () => {
|
|
70
|
+
const partial = { one: "one item", other: "{count} items" };
|
|
71
|
+
// pl "5" selects "many", which `partial` doesn't define.
|
|
72
|
+
expect(resolveTranslationValue(partial, "pl", { count: 5 })).toBe("5 items");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("falls back to `other` when count is missing", () => {
|
|
76
|
+
expect(resolveTranslationValue(messageCount.en, "en")).toBe("{count} unread messages");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("falls back to `other` when count is not a finite number", () => {
|
|
80
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: "many" })).toBe(
|
|
81
|
+
"many unread messages",
|
|
82
|
+
);
|
|
83
|
+
// count is present (interpolated verbatim) but non-finite, so the plural
|
|
84
|
+
// category still falls back to `other`.
|
|
85
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: Number.NaN })).toBe(
|
|
86
|
+
"NaN unread messages",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("an invalid locale tag falls back to `other` without throwing", () => {
|
|
91
|
+
expect(() =>
|
|
92
|
+
resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 }),
|
|
93
|
+
).not.toThrow();
|
|
94
|
+
expect(resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 })).toBe(
|
|
95
|
+
"1 unread messages",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("translationValueOtherText", () => {
|
|
101
|
+
test("returns a plain string as-is", () => {
|
|
102
|
+
expect(translationValueOtherText("Welcome")).toBe("Welcome");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns the `other` form for a plural entry", () => {
|
|
106
|
+
expect(translationValueOtherText(messageCount.en)).toBe("{count} unread messages");
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/ui-types/index.ts
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
|
|
3
|
+
export type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
|
|
4
|
+
|
|
5
|
+
// Hermes on older Expo/React Native builds doesn't ship Intl.PluralRules —
|
|
6
|
+
// fall back to `other` there instead of hand-rolling CLDR plural rules.
|
|
7
|
+
const hasPluralRulesSupport = typeof Intl !== "undefined" && typeof Intl.PluralRules === "function";
|
|
8
|
+
|
|
9
|
+
// `null` cache entries mark a locale tag Intl.PluralRules rejected, so a bad
|
|
10
|
+
// tag doesn't retry the constructor (and its exception) on every call.
|
|
11
|
+
const pluralRulesCache = new Map<string, Intl.PluralRules | null>();
|
|
12
|
+
|
|
13
|
+
function pluralRulesFor(locale: string): Intl.PluralRules | null {
|
|
14
|
+
if (!hasPluralRulesSupport) return null;
|
|
15
|
+
const cached = pluralRulesCache.get(locale);
|
|
16
|
+
if (cached !== undefined) return cached;
|
|
17
|
+
let rules: Intl.PluralRules | null;
|
|
18
|
+
try {
|
|
19
|
+
rules = new Intl.PluralRules(locale);
|
|
20
|
+
} catch {
|
|
21
|
+
rules = null;
|
|
22
|
+
}
|
|
23
|
+
pluralRulesCache.set(locale, rules);
|
|
24
|
+
return rules;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isFiniteCount(value: unknown): value is number {
|
|
28
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pluralFormText(forms: PluralForms, locale: string, count: unknown): string {
|
|
32
|
+
if (!isFiniteCount(count)) return forms.other;
|
|
33
|
+
const rules = pluralRulesFor(locale);
|
|
34
|
+
if (rules === null) return forms.other;
|
|
35
|
+
const category = rules.select(count);
|
|
36
|
+
return forms[category] ?? forms.other;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function interpolate(
|
|
40
|
+
template: string,
|
|
41
|
+
params: Readonly<Record<string, unknown>> | undefined,
|
|
42
|
+
): string {
|
|
43
|
+
if (params === undefined) return template;
|
|
44
|
+
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
|
|
45
|
+
const value = params[name];
|
|
46
|
+
return value !== undefined ? String(value) : `{${name}}`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Text-only reading of a TranslationValue — the CLDR `other` form for a
|
|
51
|
+
* plural entry, or the string as-is. For call sites that render a single
|
|
52
|
+
* string (agent manifests, labels) without a locale/count context. */
|
|
53
|
+
export function translationValueOtherText(value: TranslationValue): string {
|
|
54
|
+
return typeof value === "string" ? value : value.other;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve a TranslationValue to display text: a plural entry picks its CLDR
|
|
58
|
+
* category via `Intl.PluralRules` from `params.count`, falling back to
|
|
59
|
+
* `other` when `count` is missing, non-finite, or `locale` is invalid.
|
|
60
|
+
* `{name}` interpolation then applies to the resolved string — an unknown
|
|
61
|
+
* placeholder is left as-is. */
|
|
62
|
+
export function resolveTranslationValue(
|
|
63
|
+
value: TranslationValue,
|
|
64
|
+
locale: string,
|
|
65
|
+
params?: Readonly<Record<string, unknown>>,
|
|
66
|
+
): string {
|
|
67
|
+
const text = typeof value === "string" ? value : pluralFormText(value, locale, params?.["count"]);
|
|
68
|
+
return interpolate(text, params);
|
|
69
|
+
}
|