@cosmicdrift/kumiko-framework 0.288.0 → 0.289.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/changes.json +57 -0
- package/src/compliance/__tests__/sub-processors.test.ts +10 -0
- package/src/compliance/sub-processors.ts +14 -1
- package/src/crypto/__tests__/kek-source.test.ts +298 -0
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kek-source.ts +189 -0
- package/src/crypto/kms-wiring.ts +20 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +191 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
- package/src/db/event-store-executor-context.ts +79 -0
- package/src/db/event-store-executor-write.ts +42 -5
- package/src/db/index.ts +1 -0
- package/src/db/tenant-db.ts +8 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -4
- package/src/engine/extensions/storage-provider.ts +17 -2
- package/src/engine/extensions/tenant-data.ts +9 -0
- package/src/engine/factories.ts +2 -0
- package/src/engine/screen-helpers.ts +17 -0
- package/src/errors/classes.ts +24 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/member-resolution.ts +12 -0
- package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
- package/src/event-store/provenance-append.ts +9 -1
- package/src/files/__tests__/file-handle.test.ts +28 -1
- package/src/files/file-handle.ts +26 -7
- package/src/files/index.ts +1 -1
- package/src/i18n/required-surface-keys.ts +24 -12
- package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
- package/src/pipeline/dispatch-shared.ts +14 -13
- package/src/pipeline/dispatch-stream.ts +8 -3
- package/src/pipeline/dispatch-write.ts +3 -2
- package/src/pipeline/event-dispatcher.ts +18 -5
- package/src/pipeline/member-read-only-transaction.ts +2 -6
- package/src/stack/table-helpers.ts +6 -0
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
ACTION_FORM_ENTITY,
|
|
4
4
|
CONFIG_EDIT_ENTITY,
|
|
5
5
|
fieldLabelKey,
|
|
6
|
+
PROJECTION_DETAIL_ENTITY,
|
|
6
7
|
requiredKeysFromNav,
|
|
7
8
|
requiredKeysFromScreen,
|
|
8
9
|
requiredKeysFromWorkspace,
|
|
9
10
|
screenTitleKey,
|
|
11
|
+
WRITE_FORM_SECTION_ENTITY,
|
|
10
12
|
} from "../../i18n/required-surface-keys";
|
|
11
13
|
import { i18nKey } from "../i18n-key";
|
|
12
14
|
import type {
|
|
@@ -279,3 +281,197 @@ describe("dot-form label opt-in (fw#2313)", () => {
|
|
|
279
281
|
expect(requiredKeysFromNav(nav)).toContain("fw2313.optin.nav.beta");
|
|
280
282
|
});
|
|
281
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("requiredKeysFromScreen reads section.groups (fw#2986)", () => {
|
|
286
|
+
test("entityEdit: group fields and group titles, override honored", () => {
|
|
287
|
+
const screen: EntityEditScreenDefinition = {
|
|
288
|
+
id: "component-edit",
|
|
289
|
+
type: "entityEdit",
|
|
290
|
+
entity: "component",
|
|
291
|
+
fieldLabels: { name: "publicstatus:override.name" },
|
|
292
|
+
layout: {
|
|
293
|
+
sections: [
|
|
294
|
+
{
|
|
295
|
+
title: "publicstatus:section.basics",
|
|
296
|
+
fields: [],
|
|
297
|
+
groups: [
|
|
298
|
+
{ title: "publicstatus:group.identity", fields: ["name"] },
|
|
299
|
+
{ title: "publicstatus:group.state", fields: [{ field: "status" }] },
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
306
|
+
expect(keys).toContain("publicstatus:section.basics");
|
|
307
|
+
expect(keys).toContain("publicstatus:group.identity");
|
|
308
|
+
expect(keys).toContain("publicstatus:group.state");
|
|
309
|
+
expect(keys).toContain("publicstatus:override.name");
|
|
310
|
+
expect(keys).not.toContain(fieldLabelKey("publicstatus", "component", "name"));
|
|
311
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("entityEdit: a section with fields AND groups yields the union of both", () => {
|
|
315
|
+
const screen: EntityEditScreenDefinition = {
|
|
316
|
+
id: "component-edit",
|
|
317
|
+
type: "entityEdit",
|
|
318
|
+
entity: "component",
|
|
319
|
+
layout: {
|
|
320
|
+
sections: [
|
|
321
|
+
{
|
|
322
|
+
fields: ["name"],
|
|
323
|
+
groups: [{ title: "publicstatus:group.state", fields: ["status"] }],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
329
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", "component", "name"));
|
|
330
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("actionForm: group fields and group titles", () => {
|
|
334
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
335
|
+
id: "incident-open-form",
|
|
336
|
+
type: "actionForm",
|
|
337
|
+
handler: "publicstatus:write:incident:open",
|
|
338
|
+
fields: { title: { type: "text" }, note: { type: "text" } },
|
|
339
|
+
fieldLabels: { title: "publicstatus:override.title" },
|
|
340
|
+
layout: {
|
|
341
|
+
sections: [
|
|
342
|
+
{
|
|
343
|
+
fields: [],
|
|
344
|
+
groups: [{ title: "publicstatus:group.body", fields: ["title", "note"] }],
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
expect(keys).toContain("publicstatus:group.body");
|
|
350
|
+
expect(keys).toContain("publicstatus:override.title");
|
|
351
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "note"));
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("secretMint: group fields and group titles in mint and confirm layout", () => {
|
|
355
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
356
|
+
id: "token-mint",
|
|
357
|
+
type: "secretMint",
|
|
358
|
+
handler: "publicstatus:write:token:mint",
|
|
359
|
+
fields: { label: { type: "text" } },
|
|
360
|
+
layout: {
|
|
361
|
+
sections: [
|
|
362
|
+
{
|
|
363
|
+
fields: [],
|
|
364
|
+
groups: [{ title: "publicstatus:group.mint", fields: ["label"] }],
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
reveal: { fields: [{ field: "token", label: "publicstatus:reveal.token" }] },
|
|
369
|
+
confirm: {
|
|
370
|
+
handler: "publicstatus:write:token:confirm",
|
|
371
|
+
fields: { ack: { type: "boolean" } },
|
|
372
|
+
layout: {
|
|
373
|
+
sections: [
|
|
374
|
+
{
|
|
375
|
+
fields: [],
|
|
376
|
+
groups: [{ title: "publicstatus:group.confirm", fields: ["ack"] }],
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
expect(keys).toContain("publicstatus:group.mint");
|
|
383
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "label"));
|
|
384
|
+
expect(keys).toContain("publicstatus:group.confirm");
|
|
385
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "ack"));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("configEdit: group fields and group titles, override honored", () => {
|
|
389
|
+
const screen: ConfigEditScreenDefinition = {
|
|
390
|
+
id: "settings-retention",
|
|
391
|
+
type: "configEdit",
|
|
392
|
+
scope: "tenant",
|
|
393
|
+
configKeys: {
|
|
394
|
+
days: "publicstatus:config:retentionDays",
|
|
395
|
+
mode: "publicstatus:config:mode",
|
|
396
|
+
},
|
|
397
|
+
fieldLabels: { days: "publicstatus:override.retentionDays" },
|
|
398
|
+
fields: { days: { type: "number" }, mode: { type: "text" } },
|
|
399
|
+
layout: {
|
|
400
|
+
sections: [
|
|
401
|
+
{
|
|
402
|
+
fields: [],
|
|
403
|
+
groups: [{ title: "publicstatus:group.retention", fields: ["days", "mode"] }],
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
409
|
+
expect(keys).toContain("publicstatus:group.retention");
|
|
410
|
+
expect(keys).toContain("publicstatus:override.retentionDays");
|
|
411
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "mode"));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("configEdit: dot-form group titles need treatDotFormAsKey, like section titles", () => {
|
|
415
|
+
const screen: ConfigEditScreenDefinition = {
|
|
416
|
+
id: "settings-retention",
|
|
417
|
+
type: "configEdit",
|
|
418
|
+
scope: "tenant",
|
|
419
|
+
configKeys: { days: "publicstatus:config:retentionDays" },
|
|
420
|
+
fields: { days: { type: "number" } },
|
|
421
|
+
layout: {
|
|
422
|
+
sections: [
|
|
423
|
+
{
|
|
424
|
+
fields: [],
|
|
425
|
+
groups: [{ title: "publicstatus.group.retention", fields: ["days"] }],
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
expect(requiredKeysFromScreen("publicstatus", screen)).not.toContain(
|
|
431
|
+
"publicstatus.group.retention",
|
|
432
|
+
);
|
|
433
|
+
expect(requiredKeysFromScreen("publicstatus", screen, { treatDotFormAsKey: true })).toContain(
|
|
434
|
+
"publicstatus.group.retention",
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("projectionDetail: group fields and group titles, override honored", () => {
|
|
439
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
440
|
+
id: "incident-detail",
|
|
441
|
+
type: "projectionDetail",
|
|
442
|
+
query: "publicstatus:query:incident:detail",
|
|
443
|
+
fieldLabels: { severity: "publicstatus:override.severity" },
|
|
444
|
+
layout: {
|
|
445
|
+
sections: [
|
|
446
|
+
{
|
|
447
|
+
fields: [],
|
|
448
|
+
groups: [{ title: "publicstatus:group.facts", fields: ["severity", "openedAt"] }],
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
expect(keys).toContain("publicstatus:group.facts");
|
|
454
|
+
expect(keys).toContain("publicstatus:override.severity");
|
|
455
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", PROJECTION_DETAIL_ENTITY, "openedAt"));
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("projectionDetail: writeForm sections keep the WRITE_FORM_SECTION_ENTITY namespace", () => {
|
|
459
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
460
|
+
id: "incident-detail",
|
|
461
|
+
type: "projectionDetail",
|
|
462
|
+
query: "publicstatus:query:incident:detail",
|
|
463
|
+
layout: {
|
|
464
|
+
sections: [
|
|
465
|
+
{
|
|
466
|
+
kind: "writeForm",
|
|
467
|
+
title: "publicstatus:section.comment",
|
|
468
|
+
handler: "publicstatus:write:incident:comment",
|
|
469
|
+
fieldDefs: { body: { type: "text" } },
|
|
470
|
+
fields: ["body"],
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", WRITE_FORM_SECTION_ENTITY, "body"));
|
|
476
|
+
});
|
|
477
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../define-feature";
|
|
4
|
+
import { validateHandlerAccess } from "../entity-handler";
|
|
5
|
+
|
|
6
|
+
describe("validateHandlerAccess — anonymous handlers require a rateLimit", () => {
|
|
7
|
+
test("an anonymous handler with no rateLimit throws, naming the handler", () => {
|
|
8
|
+
const feature = defineFeature("rl-anon", (r) => {
|
|
9
|
+
r.writeHandler(
|
|
10
|
+
"note:create",
|
|
11
|
+
z.object({ title: z.string() }),
|
|
12
|
+
async () => ({
|
|
13
|
+
isSuccess: true as const,
|
|
14
|
+
data: {},
|
|
15
|
+
}),
|
|
16
|
+
{
|
|
17
|
+
access: { roles: ["anonymous", "Admin"] },
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/rl-anon:write:note:create/);
|
|
23
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/declares no rateLimit/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("an anonymous handler with rateLimit: { disabled: true, reason } boots fine", () => {
|
|
27
|
+
const feature = defineFeature("rl-anon", (r) => {
|
|
28
|
+
r.writeHandler(
|
|
29
|
+
"note:create",
|
|
30
|
+
z.object({ title: z.string() }),
|
|
31
|
+
async () => ({
|
|
32
|
+
isSuccess: true as const,
|
|
33
|
+
data: {},
|
|
34
|
+
}),
|
|
35
|
+
{
|
|
36
|
+
access: { roles: ["anonymous", "Admin"] },
|
|
37
|
+
rateLimit: { disabled: true, reason: "internal replay path" },
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(() => validateHandlerAccess(feature)).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("an anonymous handler with rateLimit: { per: 'ip', ... } boots fine", () => {
|
|
46
|
+
const feature = defineFeature("rl-anon", (r) => {
|
|
47
|
+
r.writeHandler(
|
|
48
|
+
"note:create",
|
|
49
|
+
z.object({ title: z.string() }),
|
|
50
|
+
async () => ({
|
|
51
|
+
isSuccess: true as const,
|
|
52
|
+
data: {},
|
|
53
|
+
}),
|
|
54
|
+
{
|
|
55
|
+
access: { roles: ["anonymous", "Admin"] },
|
|
56
|
+
rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(() => validateHandlerAccess(feature)).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -177,15 +177,21 @@ export function validateAnonymousRateLimit(
|
|
|
177
177
|
access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
|
|
178
178
|
rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
|
|
179
179
|
): void {
|
|
180
|
-
// skip: handler doesn't opt into rate-limit, no user-bucket risk
|
|
181
|
-
if (!rateLimit) return;
|
|
182
|
-
// skip: disabled declarations carry no `.per` to bucket on
|
|
183
|
-
if (isRateLimitDisabled(rateLimit)) return;
|
|
184
180
|
// skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
|
|
185
181
|
// the user-bucket footgun doesn't apply
|
|
186
182
|
if (!("roles" in access)) return;
|
|
187
183
|
// skip: handler doesn't list anonymous, regular role-rate-limit is fine
|
|
188
184
|
if (!access.roles.includes("anonymous")) return;
|
|
185
|
+
// skip: disabled declarations carry no `.per` to bucket on
|
|
186
|
+
if (isRateLimitDisabled(rateLimit)) return;
|
|
187
|
+
if (!rateLimit) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but declares no ` +
|
|
190
|
+
`rateLimit — an anonymous, internet-facing endpoint needs an upper bound. Set rateLimit: ` +
|
|
191
|
+
`{ per: "ip", limit: ..., windowSeconds: ... }, or the documented exception ` +
|
|
192
|
+
`rateLimit: { disabled: true, reason: "..." }.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
189
195
|
// skip: rate-limit is already keyed on something safe (ip / tenant)
|
|
190
196
|
if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per)) return;
|
|
191
197
|
throw new Error(
|
|
@@ -3,17 +3,32 @@
|
|
|
3
3
|
// Mirror of tenant-data.ts, but the destroyTenant hook takes (tenantId, ctx)
|
|
4
4
|
// rather than just (ctx) — runExtensionDestroyHooks (tenant-lifecycle/stages.ts)
|
|
5
5
|
// passes tenantId as its own positional arg for every EXT_*_RESOURCE-style
|
|
6
|
-
// extension point, not just this one. ctx here
|
|
6
|
+
// extension point, not just this one. ctx here guarantees tenantId, db, plus
|
|
7
7
|
// the optional fileProviderResolver/log; the richer stage-runner ctx
|
|
8
8
|
// (tenant-lifecycle's DestructionStageCtx) is a structural superset, so a hook
|
|
9
9
|
// typed against this minimal ctx is safely assignable wherever that richer ctx
|
|
10
10
|
// is passed.
|
|
11
|
-
|
|
11
|
+
//
|
|
12
|
+
// `db` is a raw DbRunner, NOT a tenant-scoped TenantDb — unlike
|
|
13
|
+
// TenantDataHookCtx.db (EXT_TENANT_DATA), runExtensionDestroyHooks hands every
|
|
14
|
+
// EXT_*_RESOURCE-style hook (this one included) the stage runner's own
|
|
15
|
+
// DestructionStageCtx.db straight through, with no tenant filter and no
|
|
16
|
+
// escapeHatch/unsafeRaw gate — there is nothing to declare, because nothing
|
|
17
|
+
// ever wraps it. That's deliberate for this stage family: wiping a whole
|
|
18
|
+
// search index, an S3 prefix, or infra resources is inherently a cross-tenant,
|
|
19
|
+
// bulk operation, not a row-scoped one. A hook that needs a plain read across
|
|
20
|
+
// tenants (kumiko-framework#3035: does a foreign tenant's file_refs row still
|
|
21
|
+
// reference a key under THIS tenant's storage prefix) uses `db` directly via
|
|
22
|
+
// executeRawQueryRead — there is no `ctx.systemDb` here (that belongs to
|
|
23
|
+
// HandlerContext / r.systemScope() write-handler dispatch, a different code
|
|
24
|
+
// path this stage runner never goes through).
|
|
12
25
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
26
|
+
import type { DbRunner } from "../../db/connection";
|
|
13
27
|
import type { TenantId } from "../types";
|
|
14
28
|
|
|
15
29
|
export interface StorageProviderHookCtx {
|
|
16
30
|
readonly tenantId: TenantId;
|
|
31
|
+
readonly db: DbRunner;
|
|
17
32
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
18
33
|
readonly log?: (message: string) => void;
|
|
19
34
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Mirror of engine/extensions/user-data.ts at tenant granularity.
|
|
4
4
|
// tenant-lifecycle orchestrates destroy via registry.getExtensionUsages(EXT_TENANT_DATA).
|
|
5
5
|
|
|
6
|
+
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
6
7
|
import type { TenantDb } from "../../db/tenant-db";
|
|
7
8
|
import type { Registry, TenantId } from "../types";
|
|
8
9
|
|
|
@@ -12,6 +13,14 @@ export interface TenantDataHookCtx {
|
|
|
12
13
|
readonly db: TenantDb;
|
|
13
14
|
readonly registry: Registry;
|
|
14
15
|
readonly tenantId: TenantId;
|
|
16
|
+
// Threaded through from DestructionStageCtx (tenant-lifecycle/stages.ts),
|
|
17
|
+
// which resolves it unconditionally for every stage. "app-data" is the
|
|
18
|
+
// only stage where a fileRef row still exists to read a storageKey off —
|
|
19
|
+
// the "files" stage's prefix sweep runs after this stage already purged
|
|
20
|
+
// the rows (kumiko-framework#3035). Undefined when no file-provider is
|
|
21
|
+
// wired; a hook that needs it must treat that as "nothing to clean up".
|
|
22
|
+
readonly fileProviderResolver?: FileProviderResolver;
|
|
23
|
+
readonly log?: (message: string) => void;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
|
package/src/engine/factories.ts
CHANGED
|
@@ -472,6 +472,8 @@ export function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
|
|
|
472
472
|
readonly retention?: RetentionDef;
|
|
473
473
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
474
474
|
readonly tenancy?: T;
|
|
475
|
+
/** See EntityDefinition.transferable. */
|
|
476
|
+
readonly transferable?: boolean;
|
|
475
477
|
}): F extends FieldsMap ? EntityDefinition<F, T> : never {
|
|
476
478
|
return {
|
|
477
479
|
softDelete: false,
|
|
@@ -98,6 +98,23 @@ export function isWriteFormEditSection(section: EditSectionSpec): section is Edi
|
|
|
98
98
|
return section.kind === "writeForm";
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Structural shape of every section that can carry `fields` and/or `groups` —
|
|
102
|
+
* matches EditWriteFormSection (no `groups`) too, so callers need no narrowing. */
|
|
103
|
+
export type FieldsOrGroupsSection = {
|
|
104
|
+
readonly fields: readonly EditFieldSpec[];
|
|
105
|
+
readonly groups?: readonly {
|
|
106
|
+
readonly title: string;
|
|
107
|
+
readonly fields: readonly EditFieldSpec[];
|
|
108
|
+
}[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Union of both sources, unlike the boot-validator's either-or flattening: that
|
|
112
|
+
// one runs after the fields-XOR-groups check, collectors run without it and must
|
|
113
|
+
// not drop a source (fw#2986).
|
|
114
|
+
export function sectionFieldSpecs(section: FieldsOrGroupsSection): readonly EditFieldSpec[] {
|
|
115
|
+
return [...section.fields, ...(section.groups?.flatMap((group) => group.fields) ?? [])];
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
// Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
|
|
102
119
|
// authors who branch on the three FieldRenderer variants without manual
|
|
103
120
|
// "format" in renderer checks.
|
package/src/errors/classes.ts
CHANGED
|
@@ -138,6 +138,30 @@ export class VersionConflictError extends ConflictError {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// `update()`'s optional `expect:` precondition (kumiko-framework#3024) —
|
|
142
|
+
// a freshly-read row no longer matches the caller's declared field values.
|
|
143
|
+
// Distinct from VersionConflictError: that one guards the stream version,
|
|
144
|
+
// this one guards an arbitrary business field a lifecycle transition
|
|
145
|
+
// declared ("only from status Active"). Same 409 family: the client can
|
|
146
|
+
// resolve it by re-checking the entity's current state.
|
|
147
|
+
export type PreconditionFailedDetails = {
|
|
148
|
+
readonly entityId: number | string;
|
|
149
|
+
readonly field: string;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export class PreconditionFailedError extends ConflictError {
|
|
153
|
+
override readonly code: string = "precondition_failed";
|
|
154
|
+
|
|
155
|
+
constructor(details: PreconditionFailedDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">) {
|
|
156
|
+
super({
|
|
157
|
+
message: `precondition failed for entity ${details.entityId}: field "${details.field}" no longer matches the expected value`,
|
|
158
|
+
i18nKey: opts?.i18nKey ?? "errors.preconditionFailed",
|
|
159
|
+
details,
|
|
160
|
+
...(opts?.cause && { cause: opts.cause }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
141
165
|
// The caller retried a write with an idempotencyKey it had already used —
|
|
142
166
|
// the event-store's partial unique index on metadata.idempotencyKey caught
|
|
143
167
|
// the duplicate. Distinct from unique_violation/version_conflict: this is
|
package/src/errors/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type {
|
|
|
2
2
|
FeatureDisabledDetails,
|
|
3
3
|
FieldIssue,
|
|
4
4
|
NotFoundDetails,
|
|
5
|
+
PreconditionFailedDetails,
|
|
5
6
|
RateLimitDetails,
|
|
6
7
|
UnconfiguredDetails,
|
|
7
8
|
UniqueViolationDetails,
|
|
@@ -16,6 +17,7 @@ export {
|
|
|
16
17
|
IdempotentReplayError,
|
|
17
18
|
InternalError,
|
|
18
19
|
NotFoundError,
|
|
20
|
+
PreconditionFailedError,
|
|
19
21
|
RateLimitError,
|
|
20
22
|
UnconfiguredError,
|
|
21
23
|
UniqueViolationError,
|
|
@@ -25,6 +27,7 @@ export {
|
|
|
25
27
|
} from "./classes";
|
|
26
28
|
export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error";
|
|
27
29
|
export { isKumikoError, KumikoError } from "./kumiko-error";
|
|
30
|
+
export { memberResolutionReadOnlyDenied } from "./member-resolution";
|
|
28
31
|
export type { AgentReason, FrameworkReason } from "./reasons";
|
|
29
32
|
export { AgentReasons, FrameworkReasons } from "./reasons";
|
|
30
33
|
export type { ErrorLogEntry, ErrorResponseBody } from "./serialize";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AccessDeniedError } from "./classes";
|
|
2
|
+
import { FrameworkReasons } from "./reasons";
|
|
3
|
+
|
|
4
|
+
// Lives in errors/ (not pipeline/) so db/tenant-db.ts can throw it without importing pipeline.
|
|
5
|
+
export function memberResolutionReadOnlyDenied(cause?: unknown): AccessDeniedError {
|
|
6
|
+
return new AccessDeniedError({
|
|
7
|
+
message:
|
|
8
|
+
"a resolved member principal (ctx.queryAsMember) cannot write or reach raw SQL — read-only",
|
|
9
|
+
details: { reason: FrameworkReasons.memberResolutionReadOnly },
|
|
10
|
+
...(cause instanceof Error && { cause }),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
2
2
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
3
3
|
import { asRawClient, transaction } from "../../db/query";
|
|
4
4
|
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
5
|
-
import { InternalError } from "../../errors";
|
|
5
|
+
import { AccessDeniedError, InternalError } from "../../errors";
|
|
6
6
|
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
7
7
|
import { generateId as uuid } from "../../utils";
|
|
8
8
|
import { createEventsTable, loadAggregate, VersionConflictError } from "../index";
|
|
@@ -12,6 +12,14 @@ let testDb: BunTestDb;
|
|
|
12
12
|
let tdb: TenantDb;
|
|
13
13
|
|
|
14
14
|
const tenantA = uuid();
|
|
15
|
+
const tenantB = uuid();
|
|
16
|
+
|
|
17
|
+
async function countAllEvents(): Promise<number> {
|
|
18
|
+
const rows = await asRawClient(testDb.db).unsafe<{ n: string }>(
|
|
19
|
+
"SELECT count(*)::text AS n FROM kumiko_events",
|
|
20
|
+
);
|
|
21
|
+
return Number(rows[0]?.n);
|
|
22
|
+
}
|
|
15
23
|
|
|
16
24
|
function provenanceEvent(overrides: Partial<ProvenanceEventInput>): ProvenanceEventInput {
|
|
17
25
|
return {
|
|
@@ -106,4 +114,28 @@ describe("appendProvenanceEvent", () => {
|
|
|
106
114
|
const events = await loadAggregate(testDb.db, event.aggregateId, tenantA);
|
|
107
115
|
expect(events).toHaveLength(0);
|
|
108
116
|
});
|
|
117
|
+
|
|
118
|
+
test("a foreign event.tenantId is rejected and writes no row at all", async () => {
|
|
119
|
+
const event = provenanceEvent({ tenantId: tenantB });
|
|
120
|
+
const before = await countAllEvents();
|
|
121
|
+
|
|
122
|
+
await expect(appendProvenanceEvent(tdb, event)).rejects.toThrow(AccessDeniedError);
|
|
123
|
+
await expect(appendProvenanceEvent(tdb, event)).rejects.toThrow(
|
|
124
|
+
new RegExp(`"${tenantB}".+"${tenantA}"`),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(await countAllEvents()).toBe(before);
|
|
128
|
+
expect(await loadAggregate(testDb.db, event.aggregateId, tenantB)).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a mode: "system" TenantDb is rejected for a foreign event.tenantId too', async () => {
|
|
132
|
+
const systemTdb = createTenantDb(testDb.db, tenantA, "system");
|
|
133
|
+
const event = provenanceEvent({ tenantId: tenantB });
|
|
134
|
+
const before = await countAllEvents();
|
|
135
|
+
|
|
136
|
+
await expect(appendProvenanceEvent(systemTdb, event)).rejects.toThrow(AccessDeniedError);
|
|
137
|
+
|
|
138
|
+
expect(await countAllEvents()).toBe(before);
|
|
139
|
+
expect(await loadAggregate(testDb.db, event.aggregateId, tenantB)).toHaveLength(0);
|
|
140
|
+
});
|
|
109
141
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { runInSavepointIfSupported } from "../db/query";
|
|
2
2
|
import { type TenantDb, unsafeRawForDeclaredStep, withUnsafeRawGrant } from "../db/tenant-db";
|
|
3
3
|
import type { TenantId } from "../engine/types";
|
|
4
|
-
import { InternalError } from "../errors";
|
|
4
|
+
import { AccessDeniedError, InternalError } from "../errors";
|
|
5
5
|
import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
|
|
6
6
|
import { append, type EventMetadata, getStreamVersion } from "./event-store";
|
|
7
7
|
|
|
@@ -38,6 +38,14 @@ export async function appendProvenanceEvent(
|
|
|
38
38
|
}
|
|
39
39
|
const granted = withUnsafeRawGrant(db, { reason: PROVENANCE_APPEND_REASON });
|
|
40
40
|
const runner = unsafeRawForDeclaredStep(granted, PROVENANCE_APPEND_REASON);
|
|
41
|
+
// Unconditional, including mode "system": crossTenantRebinders keeps the
|
|
42
|
+
// original tenantId and only flips the mode, so a system-scoped db must not
|
|
43
|
+
// append for a foreign tenant either.
|
|
44
|
+
if (event.tenantId !== db.tenantId) {
|
|
45
|
+
throw new AccessDeniedError({
|
|
46
|
+
message: `appendProvenanceEvent: event tenant "${event.tenantId}" does not match the TenantDb's tenant "${db.tenantId}".`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
41
49
|
// Bun.SQL/postgres.js abort the whole surrounding begin() on a statement
|
|
42
50
|
// error (25P02) even when the caller's catch swallows it — the savepoint
|
|
43
51
|
// confines that to this scope. Pool connections have no ambient tx to poison.
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createFileContext,
|
|
4
|
+
createFileHandle,
|
|
5
|
+
deriveKey,
|
|
6
|
+
storageKeyStemPrefix,
|
|
7
|
+
} from "../file-handle";
|
|
3
8
|
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
4
9
|
|
|
5
10
|
describe("deriveKey", () => {
|
|
@@ -24,6 +29,28 @@ describe("deriveKey", () => {
|
|
|
24
29
|
});
|
|
25
30
|
});
|
|
26
31
|
|
|
32
|
+
describe("storageKeyStemPrefix", () => {
|
|
33
|
+
test("matches the original key and every derived variant", () => {
|
|
34
|
+
const prefix = storageKeyStemPrefix("tenant/vehicle/1/photo/abc.jpg");
|
|
35
|
+
expect("tenant/vehicle/1/photo/abc.jpg".startsWith(prefix)).toBe(true);
|
|
36
|
+
expect(deriveKey("tenant/vehicle/1/photo/abc.jpg", "medium").startsWith(prefix)).toBe(true);
|
|
37
|
+
expect(deriveKey("tenant/vehicle/1/photo/abc.jpg", "thumb").startsWith(prefix)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("does not match an unrelated key with the same directory", () => {
|
|
41
|
+
const prefix = storageKeyStemPrefix("tenant/vehicle/1/photo/abc.jpg");
|
|
42
|
+
expect("tenant/vehicle/1/photo/abcdef.jpg".startsWith(prefix)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("appends a dot for a key without an extension", () => {
|
|
46
|
+
expect(storageKeyStemPrefix("tenant/bar")).toBe("tenant/bar.");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("only splits on the last segment — earlier dots stay", () => {
|
|
50
|
+
expect(storageKeyStemPrefix("archive.v2/foo.jpg")).toBe("archive.v2/foo.");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
27
54
|
describe("FileHandle", () => {
|
|
28
55
|
test("read/write round-trip through the provider", async () => {
|
|
29
56
|
const provider = createInMemoryFileProvider();
|
package/src/files/file-handle.ts
CHANGED
|
@@ -49,16 +49,35 @@ export function createFileContext(resolve: () => Promise<FileStorageProvider>):
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Splits a key into the part before the last dot of its last path segment
|
|
53
|
+
// (the "stem") and the extension including the dot. No dot in the last
|
|
54
|
+
// segment: the whole key is the stem, extension is empty.
|
|
55
|
+
function splitKeyExtension(key: string): { readonly stem: string; readonly ext: string } {
|
|
56
|
+
const lastSlash = key.lastIndexOf("/");
|
|
57
|
+
const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
|
|
58
|
+
const lastDot = lastSegment.lastIndexOf(".");
|
|
59
|
+
if (lastDot === -1) return { stem: key, ext: "" };
|
|
60
|
+
const stem = key.slice(0, key.length - lastSegment.length + lastDot);
|
|
61
|
+
const ext = lastSegment.slice(lastDot);
|
|
62
|
+
return { stem, ext };
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
// Inserts a suffix before the file extension. Keys without an extension get
|
|
53
66
|
// the suffix appended with a dot: `foo/bar` + `"small"` → `foo/bar.small`.
|
|
54
67
|
// Keys with a dot earlier in the path (e.g. `archive.v2/foo.jpg`) correctly
|
|
55
68
|
// split on the LAST segment only.
|
|
56
69
|
export function deriveKey(key: string, suffix: string): string {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
const { stem, ext } = splitKeyExtension(key);
|
|
71
|
+
return ext === "" ? `${key}.${suffix}` : `${stem}.${suffix}${ext}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The prefix that covers a key AND every `derive()`d variant of it —
|
|
75
|
+
// `foo/bar.jpg` → `foo/bar.`, matching `foo/bar.jpg` and `foo/bar.medium.jpg`
|
|
76
|
+
// alike, since derive() only ever inserts a suffix between the stem and the
|
|
77
|
+
// extension. Used by the tenant-handover destroy sweep (kumiko-framework#3035)
|
|
78
|
+
// to reach a moved fileRef's original plus every already-rendered derivative,
|
|
79
|
+
// none of which have their own `file_refs` row.
|
|
80
|
+
export function storageKeyStemPrefix(key: string): string {
|
|
81
|
+
const { stem, ext } = splitKeyExtension(key);
|
|
82
|
+
return ext === "" ? `${key}.` : `${stem}.`;
|
|
64
83
|
}
|
package/src/files/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { createFilesFeature } from "./feature";
|
|
|
2
2
|
export type { FileContext, FileHandle } from "./file-handle";
|
|
3
3
|
// `createFileHandle` is an implementation detail — construct handles via
|
|
4
4
|
// `createFileContext(provider).ref(key)`, which is the AppContext surface.
|
|
5
|
-
export { createFileContext, deriveKey } from "./file-handle";
|
|
5
|
+
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle";
|
|
6
6
|
export { fileRefEntity } from "./file-ref-entity";
|
|
7
7
|
export { fileRefsTable } from "./file-ref-table";
|
|
8
8
|
export type {
|