@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 (111) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -69,13 +69,16 @@ const namedSearchFeature = defineFeature("named-search", (r) => {
69
69
  // executor-built table is write-locked to the executor) so the row
70
70
  // ends up with a value distinct from the event payload above. Proves
71
71
  // the search consumer reads the row, not the payload.
72
- await asRawClient(ctx.db).unsafe(
72
+ await asRawClient(ctx.db.unsafeRaw("test: raw row update bypassing the executor")).unsafe(
73
73
  `UPDATE read_named_search_notes SET label = $1 WHERE id = $2`,
74
74
  [ROW_LABEL, event.payload.id],
75
75
  );
76
76
  return { isSuccess: true as const, data: { id: event.payload.id } };
77
77
  },
78
- { access: { roles: ["Admin"] } },
78
+ {
79
+ access: { roles: ["Admin"] },
80
+ escapeHatch: { reason: "test: raw row update bypassing the executor" },
81
+ },
79
82
  );
80
83
 
81
84
  r.writeHandler(
@@ -2,18 +2,39 @@ import { describe, expect, mock, test } from "bun:test";
2
2
  import type { DbRunner } from "../../db/connection";
3
3
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
4
4
  import { createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID } from "../../engine";
5
- import type { SessionUser } from "../../engine/types";
5
+ import type {
6
+ EscapeHatchReporter,
7
+ EscapeHatchTarget,
8
+ MemberReader,
9
+ SessionUser,
10
+ } from "../../engine/types";
6
11
  import type { TenantId } from "../../engine/types/identifiers";
7
12
  import { AccessDeniedError, FrameworkReasons } from "../../errors";
8
13
  import {
9
14
  createGatedIdentitySwitch,
15
+ createGatedMemberReader,
16
+ createGatedProjectionReader,
10
17
  type IdentitySwitch,
11
18
  isIdentitySwitchAllowed,
12
19
  isSystemIdentity,
20
+ type ProjectionReader,
13
21
  type WriteAsFn,
14
22
  withHookEscapeHatchGrant,
15
23
  } from "../system-identity-switch";
16
24
 
25
+ function recordingReporter(): {
26
+ readonly report: EscapeHatchReporter;
27
+ readonly calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }>;
28
+ } {
29
+ const calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }> = [];
30
+ return {
31
+ report: (kind, reason, target) => {
32
+ calls.push({ kind, reason, target });
33
+ },
34
+ calls,
35
+ };
36
+ }
37
+
17
38
  const TENANT = "00000000-0000-4000-8000-00000000ab01" as TenantId;
18
39
 
19
40
  const normalUser: SessionUser = { id: crypto.randomUUID(), tenantId: TENANT, roles: ["User"] };
@@ -224,6 +245,69 @@ describe("createGatedIdentitySwitch", () => {
224
245
  });
225
246
  });
226
247
 
248
+ function makeUngatedProjectionReader(): {
249
+ readonly ungated: ProjectionReader;
250
+ readonly mockFn: ReturnType<typeof mock>;
251
+ } {
252
+ const mockFn = mock(async (_qn: string, _options?: { readonly unsafeAllTenants?: boolean }) => [
253
+ { ok: true },
254
+ ]);
255
+ return { ungated: mockFn as unknown as ProjectionReader, mockFn };
256
+ }
257
+
258
+ describe("createGatedProjectionReader", () => {
259
+ test("without a grant, unsafeAllTenants: true throws AccessDeniedError and never calls ungated", async () => {
260
+ const { ungated, mockFn } = makeUngatedProjectionReader();
261
+ const gated = createGatedProjectionReader('handler "x"', false, ungated);
262
+
263
+ let caught: unknown;
264
+ try {
265
+ await gated("qp:projection:widgets", { unsafeAllTenants: true });
266
+ } catch (err) {
267
+ caught = err;
268
+ }
269
+
270
+ expect(caught).toBeInstanceOf(AccessDeniedError);
271
+ const error = caught as AccessDeniedError;
272
+ expect(error.details).toEqual({ reason: FrameworkReasons.unsafeAllTenantsDenied });
273
+ expect(mockFn).not.toHaveBeenCalled();
274
+ });
275
+
276
+ test("without the unsafeAllTenants option, calls ungated directly with no report", async () => {
277
+ const { ungated, mockFn } = makeUngatedProjectionReader();
278
+ const { report, calls } = recordingReporter();
279
+ const gated = createGatedProjectionReader('handler "x"', false, ungated, {
280
+ reason: "escapeHatch reason",
281
+ report,
282
+ });
283
+
284
+ await gated("qp:projection:widgets");
285
+
286
+ expect(mockFn).toHaveBeenCalledWith("qp:projection:widgets", undefined);
287
+ expect(calls).toEqual([]);
288
+ });
289
+
290
+ test("with a grant, unsafeAllTenants: true calls ungated and reports exactly once", async () => {
291
+ const { ungated, mockFn } = makeUngatedProjectionReader();
292
+ const { report, calls } = recordingReporter();
293
+ const gated = createGatedProjectionReader('handler "x"', true, ungated, {
294
+ reason: 'r.systemScope() feature "widgets"',
295
+ report,
296
+ });
297
+
298
+ await gated("qp:projection:widgets", { unsafeAllTenants: true });
299
+
300
+ expect(mockFn).toHaveBeenCalledWith("qp:projection:widgets", { unsafeAllTenants: true });
301
+ expect(calls).toEqual([
302
+ {
303
+ kind: "unsafe-all-tenants",
304
+ reason: 'r.systemScope() feature "widgets"',
305
+ target: undefined,
306
+ },
307
+ ]);
308
+ });
309
+ });
310
+
227
311
  describe("withHookEscapeHatchGrant", () => {
228
312
  test("hook without escapeHatch does NOT inherit the handler's SYSTEM grant", async () => {
229
313
  const { ungated } = makeUngated();
@@ -403,4 +487,159 @@ describe("withHookEscapeHatchGrant", () => {
403
487
  }) as { db: TenantDb };
404
488
  expect(hookCtxWithEscapeHatch.db.unsafeRaw("test reason")).toBe(rawDb);
405
489
  });
490
+
491
+ test("handler-granted queryProjection reader: hook without escapeHatch loses the grant for unsafeAllTenants", async () => {
492
+ const { ungated, mockFn } = makeUngatedProjectionReader();
493
+ const handlerCtx = {
494
+ queryProjection: createGatedProjectionReader('handler "outer"', true, ungated),
495
+ };
496
+
497
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", undefined) as {
498
+ queryProjection: ProjectionReader;
499
+ };
500
+
501
+ await expect(
502
+ hookCtx.queryProjection("qp:projection:widgets", { unsafeAllTenants: true }),
503
+ ).rejects.toBeInstanceOf(AccessDeniedError);
504
+ expect(mockFn).not.toHaveBeenCalled();
505
+ });
506
+
507
+ test("hook's own escapeHatch grants queryProjection's unsafeAllTenants and reports with the hook's reason", async () => {
508
+ const { ungated, mockFn } = makeUngatedProjectionReader();
509
+ const { report, calls } = recordingReporter();
510
+ const handlerCtx = {
511
+ queryProjection: createGatedProjectionReader('handler "outer"', false, ungated, {
512
+ reason: undefined,
513
+ report,
514
+ }),
515
+ };
516
+
517
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", {
518
+ reason: "hook's own grant",
519
+ }) as { queryProjection: ProjectionReader };
520
+
521
+ await hookCtx.queryProjection("qp:projection:widgets", { unsafeAllTenants: true });
522
+
523
+ expect(mockFn).toHaveBeenCalledWith("qp:projection:widgets", { unsafeAllTenants: true });
524
+ expect(calls).toEqual([
525
+ { kind: "unsafe-all-tenants", reason: "hook's own grant", target: undefined },
526
+ ]);
527
+ });
528
+ });
529
+
530
+ describe("createGatedIdentitySwitch — escape-hatch reporting", () => {
531
+ test("granted switch to SYSTEM reports identity-switch once with the target", async () => {
532
+ const { ungated } = makeUngated();
533
+ const { report, calls } = recordingReporter();
534
+ const gated = createGatedIdentitySwitch('handler "x"', normalUser, true, ungated, {
535
+ reason: 'r.systemScope() feature "widgets"',
536
+ report,
537
+ });
538
+
539
+ await gated.queryAs(systemUserById, "q", {});
540
+
541
+ expect(calls).toEqual([
542
+ {
543
+ kind: "identity-switch",
544
+ reason: 'r.systemScope() feature "widgets"',
545
+ target: { id: systemUserById.id, tenantId: systemUserById.tenantId },
546
+ },
547
+ ]);
548
+ });
549
+
550
+ test("self-delegation with a grant reports nothing", async () => {
551
+ const { ungated } = makeUngated();
552
+ const { report, calls } = recordingReporter();
553
+ const gated = createGatedIdentitySwitch('handler "x"', normalUser, true, ungated, {
554
+ reason: "escapeHatch reason",
555
+ report,
556
+ });
557
+
558
+ await gated.queryAs(normalUser, "q", {});
559
+
560
+ expect(calls).toEqual([]);
561
+ });
562
+
563
+ test("a denied switch reports nothing", async () => {
564
+ const { ungated } = makeUngated();
565
+ const { report, calls } = recordingReporter();
566
+ const gated = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated, {
567
+ reason: "escapeHatch reason",
568
+ report,
569
+ });
570
+
571
+ await expect(gated.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(AccessDeniedError);
572
+
573
+ expect(calls).toEqual([]);
574
+ });
575
+
576
+ test("hook re-gating via withHookEscapeHatchGrant reports with the HOOK's reason", async () => {
577
+ const { ungated } = makeUngated();
578
+ const { report, calls } = recordingReporter();
579
+ const handlerCtx = {
580
+ ...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated, {
581
+ reason: "handler's own grant",
582
+ report,
583
+ }),
584
+ };
585
+
586
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", { reason: "hook's own grant" });
587
+ await hookCtx.queryAs(systemUserById, "q", {});
588
+
589
+ expect(calls).toEqual([
590
+ {
591
+ kind: "identity-switch",
592
+ reason: "hook's own grant",
593
+ target: { id: systemUserById.id, tenantId: systemUserById.tenantId },
594
+ },
595
+ ]);
596
+ });
597
+
598
+ test("hook re-gates and reports even when the handler itself has no grant (reason: undefined)", async () => {
599
+ const { ungated } = makeUngated();
600
+ const { report, calls } = recordingReporter();
601
+ // Handler has NO grant (hasGrant=false, reason: undefined) — the report fn
602
+ // must still be attached so a hook's own escapeHatch can use it.
603
+ const handlerCtx = {
604
+ ...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated, {
605
+ reason: undefined,
606
+ report,
607
+ }),
608
+ };
609
+
610
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", { reason: "hook reason" });
611
+ await hookCtx.queryAs(systemUserById, "q", {});
612
+
613
+ expect(calls).toEqual([
614
+ {
615
+ kind: "identity-switch",
616
+ reason: "hook reason",
617
+ target: { id: systemUserById.id, tenantId: systemUserById.tenantId },
618
+ },
619
+ ]);
620
+ });
621
+
622
+ test("queryAsMember hook re-gates and reports even when the handler itself has no grant", async () => {
623
+ const ungatedReader: MemberReader = mock(async () => ({ ok: true }));
624
+ const { report, calls } = recordingReporter();
625
+ const gatedReader = createGatedMemberReader('handler "outer"', false, ungatedReader, {
626
+ reason: undefined,
627
+ report,
628
+ tenantId: TENANT,
629
+ });
630
+ const handlerCtx = { queryAsMember: gatedReader };
631
+
632
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", { reason: "hook reason" }) as {
633
+ queryAsMember: MemberReader;
634
+ };
635
+ await hookCtx.queryAsMember(systemUserById.id, "q", {});
636
+
637
+ expect(calls).toEqual([
638
+ {
639
+ kind: "identity-switch",
640
+ reason: "hook reason",
641
+ target: { id: systemUserById.id, tenantId: TENANT },
642
+ },
643
+ ]);
644
+ });
406
645
  });
@@ -1,7 +1,7 @@
1
1
  import type { DbRow, DbTx } from "../db/connection";
2
2
  import { hasAccess } from "../engine/access";
3
3
  import { filterReadFields } from "../engine/field-access";
4
- import type { SessionUser } from "../engine/types";
4
+ import type { QueryHandlerDef, SessionUser } from "../engine/types";
5
5
  import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
6
6
  import { assertNoSecretLeak } from "../secrets";
7
7
  import type { DispatchContext } from "./dispatch-shared";
@@ -11,6 +11,7 @@ import {
11
11
  ensureFeatureEnabled,
12
12
  runHandlerInstrumented,
13
13
  } from "./dispatch-shared";
14
+ import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction";
14
15
 
15
16
  // Standalone query execution — used by the public dispatcher.query() and
16
17
  // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
@@ -48,12 +49,11 @@ async function executeQueryInner(
48
49
  // unauthorized callers must hit the cap too (otherwise the limit
49
50
  // would be a free probe-detector for valid credentials). The
50
51
  // resolver throws RateLimitError which the dispatcher's outer
51
- // wrapper turns into a 429 response. Inline-skip when the handler
52
- // didn't opt in — keeps the hot path zero-cost (no await on a
53
- // no-op promise).
54
- if (handler.rateLimit !== undefined) {
55
- await enforceRateLimit(ctx, handler.rateLimit, type, user);
56
- }
52
+ // wrapper turns into a 429 response. Apps that don't use L3 pay zero
53
+ // cost for non-systemScope handlers with no rateLimit declared;
54
+ // systemScope handlers pay one isHandlerSystemScoped lookup to check
55
+ // whether the default per-tenant limit applies.
56
+ await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
57
57
 
58
58
  // Default-deny: missing access rule is treated as "no one has access".
59
59
  // The registry boot-validator refuses to register handlers without one,
@@ -79,8 +79,27 @@ async function executeQueryInner(
79
79
  typeof parsed.data === "object" &&
80
80
  parsed.data !== null &&
81
81
  (parsed.data as Record<string, unknown>)["includeDeleted"] === true; // @cast-boundary validated-payload
82
+
83
+ // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
84
+ return user.origin === "member-resolution"
85
+ ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) =>
86
+ runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, readOnlyTx),
87
+ )
88
+ : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, tx);
89
+ }
90
+
91
+ async function runQueryHandler(
92
+ ctx: DispatchContext,
93
+ type: string,
94
+ handler: QueryHandlerDef,
95
+ payload: unknown,
96
+ includeDeleted: boolean,
97
+ user: SessionUser,
98
+ tx: DbTx | undefined,
99
+ ): Promise<unknown> {
100
+ const { registry } = ctx;
82
101
  const handlerContext = await buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
83
- let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
102
+ let result = await handler.handler({ type, payload, user }, handlerContext);
84
103
 
85
104
  // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
86
105
  // and can merge custom-fields/computed-counts/tags/etc. Each hook is