@cosmicdrift/kumiko-framework 0.351.0 → 0.353.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.
Files changed (66) hide show
  1. package/dist/api/pii-leak-guard.js +3 -0
  2. package/dist/api/route-registrars.d.ts +2 -1
  3. package/dist/api/route-registrars.js +8 -0
  4. package/dist/api/server.js +11 -3
  5. package/dist/api/sse-route.js +11 -1
  6. package/dist/bun-db/query.js +7 -2
  7. package/dist/compliance/sub-processors.js +5 -0
  8. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  9. package/dist/crypto/pii-field-encryption.js +2 -3
  10. package/dist/db/entity-table-meta.js +11 -0
  11. package/dist/db/event-store-executor-context.js +20 -25
  12. package/dist/db/migrate-generator.js +9 -1
  13. package/dist/db/queries/backfill-pii.d.ts +1 -0
  14. package/dist/db/queries/backfill-pii.js +13 -0
  15. package/dist/db/queries/seed-context.d.ts +17 -0
  16. package/dist/db/queries/seed-context.js +26 -0
  17. package/dist/db/queries/shadow-swap.js +5 -2
  18. package/dist/engine/boot-validator/access-declarations.js +16 -1
  19. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  20. package/dist/engine/boot-validator/entity-handler.js +30 -0
  21. package/dist/engine/boot-validator/index.js +2 -1
  22. package/dist/engine/boot-validator/query-refs.js +7 -2
  23. package/dist/engine/boot-validator/screens.js +60 -22
  24. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  25. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  26. package/dist/engine/build-app-schema.js +33 -2
  27. package/dist/engine/build-config-feature-schema.js +6 -0
  28. package/dist/engine/dedupe-features.js +17 -1
  29. package/dist/engine/entity-handlers.js +15 -6
  30. package/dist/engine/feature-builder-state.d.ts +2 -7
  31. package/dist/engine/feature-config-events-jobs.js +3 -0
  32. package/dist/engine/feature-entity-handlers.js +1 -1
  33. package/dist/engine/i18n-key.js +14 -3
  34. package/dist/engine/index.d.ts +1 -1
  35. package/dist/engine/index.js +1 -1
  36. package/dist/engine/pipeline.js +18 -1
  37. package/dist/engine/project-app-schema-for-roles.js +8 -2
  38. package/dist/engine/role-assignment.d.ts +2 -0
  39. package/dist/engine/role-assignment.js +2 -0
  40. package/dist/engine/schema-builder.js +8 -0
  41. package/dist/engine/where-rule-lint.js +6 -3
  42. package/dist/env/index.d.ts +4 -0
  43. package/dist/env/index.js +4 -0
  44. package/dist/es-ops/context.js +34 -2
  45. package/dist/es-ops/index.d.ts +1 -1
  46. package/dist/es-ops/types.d.ts +20 -0
  47. package/dist/event-store/index.d.ts +1 -0
  48. package/dist/event-store/index.js +1 -0
  49. package/dist/event-store/tenant-db-append.d.ts +8 -0
  50. package/dist/event-store/tenant-db-append.js +16 -0
  51. package/dist/files/file-ref-entity.d.ts +6 -0
  52. package/dist/files/file-ref-table.d.ts +6 -0
  53. package/dist/files/file-routes.js +3 -3
  54. package/dist/files/types.js +21 -1
  55. package/dist/observability/metrics-wiring.d.ts +1 -1
  56. package/dist/observability/metrics-wiring.js +2 -2
  57. package/dist/pipeline/dispatch-batch.js +5 -1
  58. package/dist/pipeline/dispatch-write.js +36 -82
  59. package/dist/pipeline/member-reader.js +1 -0
  60. package/dist/pipeline/pre-handler-gates.d.ts +9 -0
  61. package/dist/pipeline/pre-handler-gates.js +104 -0
  62. package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
  63. package/dist/pipeline/pre-transaction-reservations.js +34 -21
  64. package/dist/testing/shared-entities.d.ts +10 -0
  65. package/package.json +4 -4
  66. package/src/changes.json +57 -0
@@ -1,12 +1,20 @@
1
+ import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import type { SessionUser } from "../engine/types/index.js";
2
3
  import { type WriteErrorInfo } from "../errors/index.js";
3
4
  import type { BatchCommand, DispatchContext } from "./dispatch-shared.js";
4
5
  import type { WriteOrigin } from "./write-origin.js";
5
- export declare function runAsCommand<T>(reservedType: string | undefined, fn: () => Promise<T>): Promise<T>;
6
- export declare function consumeReservation(type: string): boolean;
6
+ type ReservationConfirm = ReservationHandle["confirmInTransaction"];
7
+ export type ConsumedReservation = {
8
+ readonly confirmInTransaction: ReservationConfirm | undefined;
9
+ };
10
+ export declare function runAsCommand<T>(reserved: {
11
+ readonly type: string;
12
+ readonly reservation: ConsumedReservation;
13
+ } | undefined, fn: () => Promise<T>): Promise<T>;
14
+ export declare function consumeReservation(type: string): ConsumedReservation | undefined;
7
15
  export type PreTransactionReservations = {
8
16
  readonly isSuccess: true;
9
- readonly reservedIndexes: ReadonlySet<number>;
17
+ readonly reservedCommands: ReadonlyMap<number, ConsumedReservation>;
10
18
  releaseAll(): Promise<void>;
11
19
  } | {
12
20
  readonly isSuccess: false;
@@ -14,3 +22,4 @@ export type PreTransactionReservations = {
14
22
  readonly failedIndex: number;
15
23
  };
16
24
  export declare function reserveBeforeTransaction(ctx: DispatchContext, commands: readonly BatchCommand[], user: SessionUser, inheritedOrigin: WriteOrigin | undefined): Promise<PreTransactionReservations>;
25
+ export {};
@@ -1,19 +1,33 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { hasAccess } from "../engine/access.js";
3
2
  import { toWriteErrorInfo } from "../errors/index.js";
4
3
  import { createFallbackLogger } from "../logging/utils.js";
5
- import { buildHandlerContext, checkFeatureEnabled, isMemberResolutionPrincipal, } from "./dispatch-shared.js";
4
+ import { buildHandlerContext, isMemberResolutionPrincipal } from "./dispatch-shared.js";
6
5
  import { wrapToKumiko } from "./dispatcher-utils.js";
6
+ import { runPreHandlerGates } from "./pre-handler-gates.js";
7
7
  import { effectiveWriteOrigin, rootWriteOrigin } from "./write-origin.js";
8
8
  // One-shot permission per top-level command: the handler that reserved before the transaction
9
9
  // consumes it on entry. A capped handler reached any other way (nested ctx.write, writeAs, a
10
10
  // second execution of the same type) finds it spent and is rejected instead of running unreserved.
11
11
  const reservedForCommand = new AsyncLocalStorage();
12
- export function runAsCommand(reservedType, fn) {
13
- return reservedForCommand.run(new Set(reservedType === undefined ? [] : [reservedType]), fn);
12
+ export function runAsCommand(reserved, fn) {
13
+ const store = new Map();
14
+ if (reserved)
15
+ store.set(reserved.type, reserved.reservation);
16
+ return reservedForCommand.run(store, fn);
14
17
  }
15
18
  export function consumeReservation(type) {
16
- return reservedForCommand.getStore()?.delete(type) === true;
19
+ const store = reservedForCommand.getStore();
20
+ const reservation = store?.get(type);
21
+ store?.delete(type);
22
+ return reservation;
23
+ }
24
+ function toReservationHandle(taken) {
25
+ if (taken === undefined)
26
+ return undefined;
27
+ if (typeof taken === "function") {
28
+ return { release: taken, confirmInTransaction: async () => { } };
29
+ }
30
+ return taken;
17
31
  }
18
32
  async function releaseAll(ctx, releases) {
19
33
  const log = createFallbackLogger("dispatcher", ctx.appContext.log);
@@ -26,36 +40,35 @@ async function releaseAll(ctx, releases) {
26
40
  }
27
41
  }
28
42
  }
29
- // Same phase as the access/schema gates but before any transaction: a caller who would be denied
30
- // or sends an invalid payload never reserves anything.
43
+ // Same gates as the in-transaction run, but before any transaction: a caller who is rate limited,
44
+ // denied or sends an invalid payload never reserves anything, so a rejected request cannot consume
45
+ // capacity or trigger the reservation's side effects.
31
46
  export async function reserveBeforeTransaction(ctx, commands, user, inheritedOrigin) {
32
47
  const releases = [];
33
- const reservedIndexes = new Set();
48
+ const reservedCommands = new Map();
34
49
  if (isMemberResolutionPrincipal(user))
35
- return { isSuccess: true, reservedIndexes, releaseAll: async () => { } };
50
+ return { isSuccess: true, reservedCommands, releaseAll: async () => { } };
36
51
  for (const [index, command] of commands.entries()) {
37
52
  try {
38
53
  const handler = ctx.registry.getWriteHandler(command.type);
39
54
  if (!handler?.reserveBeforeTransaction)
40
55
  continue;
41
- if (!hasAccess(user, handler.access))
42
- continue;
43
- if (await checkFeatureEnabled(ctx, command.type, user.tenantId))
44
- continue;
45
- const parsed = handler.schema.safeParse(command.payload);
46
- if (!parsed.success)
47
- continue;
56
+ const gates = await runPreHandlerGates(ctx, handler, command.type, command.payload, user, "charge");
57
+ if (!gates.isSuccess) {
58
+ await releaseAll(ctx, releases);
59
+ return { isSuccess: false, error: gates.error, failedIndex: index };
60
+ }
48
61
  const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, command.type, user), inheritedOrigin);
49
62
  const handlerContext = await buildHandlerContext(ctx, command.type, user, origin);
50
- const release = await handler.reserveBeforeTransaction({ type: command.type, payload: parsed.data, user }, handlerContext);
51
- if (release)
52
- releases.push(release);
53
- reservedIndexes.add(index);
63
+ const handle = toReservationHandle(await handler.reserveBeforeTransaction({ type: command.type, payload: gates.payload, user }, handlerContext));
64
+ if (handle)
65
+ releases.push(handle.release);
66
+ reservedCommands.set(index, { confirmInTransaction: handle?.confirmInTransaction });
54
67
  }
55
68
  catch (error) {
56
69
  await releaseAll(ctx, releases);
57
70
  return { isSuccess: false, error: toWriteErrorInfo(wrapToKumiko(error)), failedIndex: index };
58
71
  }
59
72
  }
60
- return { isSuccess: true, reservedIndexes, releaseAll: () => releaseAll(ctx, releases) };
73
+ return { isSuccess: true, reservedCommands, releaseAll: () => releaseAll(ctx, releases) };
61
74
  }
@@ -3,6 +3,7 @@ export declare const sharedWidgetEntity: import("@cosmicdrift/kumiko-types/field
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const sharedWidgetTable: import("../db/table-builder.js").EntityT
26
27
  readonly type: "text";
27
28
  readonly description?: string;
28
29
  readonly maxLength?: number;
30
+ readonly minLength?: number;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -49,6 +51,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
49
51
  readonly type: "text";
50
52
  readonly description?: string;
51
53
  readonly maxLength?: number;
54
+ readonly minLength?: number;
52
55
  readonly required?: boolean;
53
56
  readonly searchable?: boolean;
54
57
  readonly sortable?: boolean;
@@ -70,6 +73,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
70
73
  readonly type: "text";
71
74
  readonly description?: string;
72
75
  readonly maxLength?: number;
76
+ readonly minLength?: number;
73
77
  readonly required?: boolean;
74
78
  readonly searchable?: boolean;
75
79
  readonly sortable?: boolean;
@@ -91,6 +95,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
91
95
  readonly type: "text";
92
96
  readonly description?: string;
93
97
  readonly maxLength?: number;
98
+ readonly minLength?: number;
94
99
  readonly required?: boolean;
95
100
  readonly searchable?: boolean;
96
101
  readonly sortable?: boolean;
@@ -117,6 +122,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
117
122
  readonly type: "text";
118
123
  readonly description?: string;
119
124
  readonly maxLength?: number;
125
+ readonly minLength?: number;
120
126
  readonly required?: boolean;
121
127
  readonly searchable?: boolean;
122
128
  readonly sortable?: boolean;
@@ -138,6 +144,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
138
144
  readonly type: "text";
139
145
  readonly description?: string;
140
146
  readonly maxLength?: number;
147
+ readonly minLength?: number;
141
148
  readonly required?: boolean;
142
149
  readonly searchable?: boolean;
143
150
  readonly sortable?: boolean;
@@ -159,6 +166,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
159
166
  readonly type: "text";
160
167
  readonly description?: string;
161
168
  readonly maxLength?: number;
169
+ readonly minLength?: number;
162
170
  readonly required?: boolean;
163
171
  readonly searchable?: boolean;
164
172
  readonly sortable?: boolean;
@@ -185,6 +193,7 @@ export declare const sharedItemEntity: import("@cosmicdrift/kumiko-types/fields"
185
193
  readonly type: "text";
186
194
  readonly description?: string;
187
195
  readonly maxLength?: number;
196
+ readonly minLength?: number;
188
197
  readonly required?: boolean;
189
198
  readonly searchable?: boolean;
190
199
  readonly sortable?: boolean;
@@ -227,6 +236,7 @@ export declare const sharedItemTable: import("../db/table-builder.js").EntityTab
227
236
  readonly type: "text";
228
237
  readonly description?: string;
229
238
  readonly maxLength?: number;
239
+ readonly minLength?: number;
230
240
  readonly required?: boolean;
231
241
  readonly searchable?: boolean;
232
242
  readonly sortable?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.351.0",
3
+ "version": "0.353.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>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.351.0",
210
- "@cosmicdrift/kumiko-types": "0.351.0",
209
+ "@cosmicdrift/kumiko-http": "0.353.0",
210
+ "@cosmicdrift/kumiko-types": "0.353.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.351.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.353.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,61 @@
1
1
  [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "improvement",
5
+ "title": "Boot validation rejects public-intake handlers without rate limit and serial-id unmanaged entities with recordOwned fields",
6
+ "detail": "Boot validation rejects `personalData: \"public-intake\"` handlers with `rateLimit: { disabled: true }` and unmanaged `idType: \"serial\"` entities with `recordOwned` fields\nBoth combinations were silently accepted before: the first left an anonymous personal-data write without its only abuse protection, the second produced record-owned ciphertext that `forgetSubject` can never shred."
7
+ },
8
+ {
9
+ "version": "0.353.0",
10
+ "type": "improvement",
11
+ "title": "r.crud accepts per-verb agent hints and dedupeFeatures no longer drops a same-named feature that registers different handlers",
12
+ "detail": "`r.crud` takes per-verb `agents` hints, and same-named features only dedupe when their registered surface matches\n`registerEntityCrud` accepts `agents: { delete: { risk: \"high\" } }` so one irreversible verb can be raised without raising create/update. `dedupeFeatures` additionally requires equal entity and handler key sets before collapsing two same-named features with equal `dedupeOptions`; otherwise it throws the existing duplicate-feature error instead of silently dropping the second one. `i18nKey()` marks live on a `globalThis` registry so two installed framework copies agree."
13
+ },
14
+ {
15
+ "version": "0.353.0",
16
+ "type": "fix",
17
+ "title": "Event piiFields reject owner field \"id\" instead of encrypting under a phantom user key",
18
+ "detail": "`defineEvent` rejects `personal: { of: \"id\" }` in `piiFields` at boot\nOn entities `of: \"id\"` means record-owned, but on events it encrypted under a user key named after the payload id, which a forget never reaches, so crypto-shredding silently did nothing. Declare a record-owned event field with `personal: \"self\"` instead."
19
+ },
20
+ {
21
+ "version": "0.353.0",
22
+ "type": "fix",
23
+ "title": "Webhook signature 401 hides verify() error details; boot rejects silently dropped drawer prefills, colliding secret field ids and unlintable where-rule tables",
24
+ "detail": "Signature extra routes no longer return the verify() error text in the 401 body, drawer prefill and secret field ids fail at boot when they would silently misbehave\nA non-`ExtraRouteRejection` throw from a signature route's `verify()` now answers with the generic message \"signature verification failed\" and logs the detail server-side. Boot now rejects a drawer `params` prefill that targets a sensitive or password field or a field the target layout does not render, a secrets screen whose field ids collide across hyphenated feature names, and a where-rule subquery on a table whose column set cannot be derived."
25
+ },
26
+ {
27
+ "version": "0.353.0",
28
+ "type": "fix",
29
+ "title": "Unknown status tones fail boot and secretMint confirm actions respect role gating",
30
+ "detail": "Stricter boot checks and role projection for screens and handlers\nInline handler registration without `options.access` now throws immediately, a section with `fields: []` and `groups: []` fails boot, and unknown tones in `header.statusTones` or select `optionTones` fail boot instead of dropping the badge colour. The renderer falls back to the value heuristic for an unknown tone, and `statusToneForOptionTone` now returns `undefined` for it. `secretMint` confirm-step actions are stripped for roles that cannot see the target screen.\nEntity convention `create` handlers keep tenant-filtered lookups even when the handler declares `escapeHatch`/`crossTenant`. Duplicate `waitForEvent` steps on the same `awaits` event are rejected when the workflow pipeline is built. An empty `PROMETHEUS_METRICS_TOKEN` counts as unset instead of failing boot.\n`GET /api/sse` now closes itself when the JWT it was opened with expires. File uploads must attach to a registered file field (a non-file field answers 400 `unresolvable_field`), and a `.docx` upload must be a ZIP containing `word/` entries. Signature extra routes outside `/api` get the request-body cap (`maxRequestBytes`) before their body is read. `EXT_USER_DATA` registrations with neither an export nor a delete hook fail boot. The dashboard updated-at stamp follows live refetches and retries.\nText fields accept `minLength` (enforced by the generated write schema), `enumOption` renders array values per entry, list columns can opt out of sorting with `sortable: false`, and `BUILT_IN_MEMBERSHIP_ROLES` exposes the ranked membership roles. The PAT list translates its scopes column, MFA code fields enforce their minimum length, plan checkout no longer repeats the billing-enabled and active-subscription gates."
31
+ },
32
+ {
33
+ "version": "0.352.0",
34
+ "type": "improvement",
35
+ "title": "appendEventInTenantDb and getStreamVersionInTenantDb append and read stream versions through a TenantDb, optionally running inline projections",
36
+ "detail": "`appendEventInTenantDb` appends an event on a TenantDb's own connection or transaction\nFramework-side code that holds a `TenantDb` (for example inside `runInOwnTransaction`) could not append to the event store without a raw runner. `appendEventInTenantDb(tenantDb, event, { registry })` and `getStreamVersionInTenantDb(tenantDb, aggregateId)` from `@cosmicdrift/kumiko-framework/event-store` do it: the tenant is always the TenantDb's own, the append goes through `append()` (PII encryption, origin stamping), and with `registry` the inline projections run on the same runner."
37
+ },
38
+ {
39
+ "version": "0.352.0",
40
+ "type": "fix",
41
+ "title": "Rate-limited, denied or invalid callers no longer consume cap usage or trigger soft-warn, because pre-handler gates run before reserveBeforeTransaction"
42
+ },
43
+ {
44
+ "version": "0.352.0",
45
+ "type": "improvement",
46
+ "title": "reserveBeforeTransaction may return a ReservationHandle whose confirmInTransaction settles the reservation inside the handler transaction"
47
+ },
48
+ {
49
+ "version": "0.352.0",
50
+ "type": "improvement",
51
+ "title": "Seeds can find template rows with ctx.findTemplateResources",
52
+ "detail": "Seeds can replace a raw SELECT … FROM read_template_resources with ctx.findTemplateResources, which binds every filter value as a parameter."
53
+ },
54
+ {
55
+ "version": "0.352.0",
56
+ "type": "improvement",
57
+ "title": "Boot validation and the client schema cover select optionsAvailabilityQuery on entity and screen fields"
58
+ },
2
59
  {
3
60
  "version": "0.349.0",
4
61
  "type": "improvement",