@cosmicdrift/kumiko-samples 0.354.1 → 0.356.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 (38) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +2 -0
  4. package/packages/bundled-features/src/agent-tools/types.ts +2 -0
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +2 -0
  6. package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +8 -7
  7. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +7 -12
  8. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +13 -17
  9. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +12 -8
  10. package/packages/bundled-features/src/compliance-profiles/handlers/set-profile.write.ts +19 -36
  11. package/packages/bundled-features/src/custom-fields/handlers/define-system-field.write.ts +10 -17
  12. package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +5 -2
  13. package/packages/bundled-features/src/custom-fields/lib/define-or-resurrect.ts +12 -3
  14. package/packages/bundled-features/src/delivery/delivery-service.ts +1 -1
  15. package/packages/bundled-features/src/managed-pages/handlers/by-tenant-published.query.ts +1 -10
  16. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +9 -37
  17. package/packages/bundled-features/src/shared/index.ts +1 -0
  18. package/packages/bundled-features/src/shared/require-for-tenant.ts +18 -0
  19. package/packages/bundled-features/src/template-resolver/handlers/by-slug.query.ts +1 -10
  20. package/packages/bundled-features/src/template-resolver/handlers/by-tenant.query.ts +1 -10
  21. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +0 -7
  22. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +0 -7
  23. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -12
  24. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -15
  25. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +7 -6
  26. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +7 -11
  27. package/packages/bundled-features/src/template-resolver/seeding.ts +1 -2
  28. package/packages/bundled-features/src/tenant/cancel-pending-invitation.ts +3 -1
  29. package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +22 -8
  30. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +19 -11
  31. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
  32. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  33. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -0
  34. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
  35. package/packages/bundled-features/src/user-data-rights/feature.ts +2 -0
  36. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +5 -0
  37. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +11 -1
  38. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.354.1",
3
+ "version": "0.356.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.354.1",
3
+ "version": "0.356.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -251,6 +251,7 @@ function buildHandlerEntry(
251
251
  }
252
252
 
253
253
  const entity = getHandlerEntity(qn);
254
+ const excludedFields = "excludedFields" in def ? def.excludedFields : undefined;
254
255
  return {
255
256
  qn,
256
257
  kind,
@@ -258,6 +259,7 @@ function buildHandlerEntry(
258
259
  risk: exposure.risk,
259
260
  inputSchema,
260
261
  ...(entity !== undefined && { entity }),
262
+ ...(excludedFields !== undefined && { excludedFields }),
261
263
  };
262
264
  }
263
265
 
@@ -152,6 +152,8 @@ export type AgentManifestHandler = {
152
152
  readonly risk: AgentRisk;
153
153
  readonly inputSchema: Readonly<Record<string, unknown>>;
154
154
  readonly entity?: string;
155
+ /** Fields this generic create/update handler never writes; sending one fails validation. */
156
+ readonly excludedFields?: readonly string[];
155
157
  };
156
158
 
157
159
  export type AgentManifestScreen = {
@@ -23,6 +23,7 @@ export const escapeHatchUsedSchema = z.object({
23
23
  kind: z.enum(escapeHatchKinds),
24
24
  reason: z.string(),
25
25
  actor: z.string(),
26
+ caller: z.string().optional(),
26
27
  targetUserId: z.string().optional(),
27
28
  targetTenantId: z.string().optional(),
28
29
  });
@@ -36,6 +37,7 @@ export function createEscapeHatchAuditSink(opts: {
36
37
  kind: event.kind,
37
38
  reason: event.reason,
38
39
  actor: event.actor,
40
+ ...(event.caller !== undefined && { caller: event.caller }),
39
41
  ...(event.target && { targetUserId: event.target.id, targetTenantId: event.target.tenantId }),
40
42
  });
41
43
  // Appends on the unbound pool, never the handler tx — an audit entry must
@@ -1,4 +1,5 @@
1
- import { crossTenantOverrideDenied, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { mayOverrideTenant, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { AccessDeniedError, FrameworkReasons } from "@cosmicdrift/kumiko-framework/errors";
2
3
  import { APP_INSTANCE_STREAM_TYPE } from "@cosmicdrift/kumiko-framework/event-store";
3
4
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-types/identifiers";
4
5
  import { type AuditScope, AuditScopes } from "../constants.js";
@@ -12,11 +13,11 @@ export function resolveAuditScopeFilter(
12
13
  scope: AuditScope | undefined,
13
14
  ): AuditScopeFilter {
14
15
  if (scope !== AuditScopes.system) return { tenantId: user.tenantId };
15
- const denied = crossTenantOverrideDenied(
16
- user,
17
- SYSTEM_TENANT_ID,
18
- "audit.errors.systemScopeRequiresSystemAdmin",
19
- );
20
- if (denied) throw denied;
16
+ if (!mayOverrideTenant(user)) {
17
+ throw new AccessDeniedError({
18
+ i18nKey: "audit.errors.systemScopeRequiresSystemAdmin",
19
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
20
+ });
21
+ }
21
22
  return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
22
23
  }
@@ -1,5 +1,4 @@
1
1
  import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
- import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
4
3
  import { SubscriptionFoundationHandlers, SubscriptionStatuses } from "../constants.js";
5
4
  import {
@@ -75,17 +74,13 @@ export function registerConsumerProtection(
75
74
  // user), never from payload data.
76
75
  const tenantId = ctx.systemUser.tenantId;
77
76
  const currentPeriodEndIso = payload["currentPeriodEndIso"];
78
- const result = await ctx.writeAs(
79
- createSystemUser(tenantId),
80
- SubscriptionFoundationHandlers.issueContractConfirmation,
81
- {
82
- consentId,
83
- sourceAggregateId: isSubscriptionEvent
84
- ? subscriptionAggregateId(tenantId)
85
- : paymentAggregateId(tenantId),
86
- ...(typeof currentPeriodEndIso === "string" && { currentPeriodEndIso }),
87
- },
88
- );
77
+ const result = await ctx.write(SubscriptionFoundationHandlers.issueContractConfirmation, {
78
+ consentId,
79
+ sourceAggregateId: isSubscriptionEvent
80
+ ? subscriptionAggregateId(tenantId)
81
+ : paymentAggregateId(tenantId),
82
+ ...(typeof currentPeriodEndIso === "string" && { currentPeriodEndIso }),
83
+ });
89
84
  if (!result.isSuccess) {
90
85
  throw new Error(
91
86
  `billing-foundation:${trigger.jobName}: issue-contract-confirmation failed: ${JSON.stringify(result.error)}`,
@@ -1,4 +1,4 @@
1
- import { createSystemUser, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { SubscriptionCancelTimings, SubscriptionFoundationHandlers } from "../constants.js";
3
3
  import {
4
4
  CONTRACT_TERMINATION_DECLARED_EVENT_QN,
@@ -59,22 +59,18 @@ export function registerContractTermination(
59
59
  const declared = contractTerminationDeclaredPayloadSchema.safeParse(payload);
60
60
  // skip: trigger event carries no usable declaration
61
61
  if (!declared.success) return;
62
- const result = await ctx.writeAs(
63
- createSystemUser(ctx.systemUser.tenantId),
64
- SubscriptionFoundationHandlers.recordContractTermination,
65
- {
66
- requestId: declared.data.requestId,
67
- declarationType: declared.data.declarationType,
68
- terminationKind: declared.data.terminationKind,
69
- channel: "public",
70
- when:
71
- declared.data.declarationType === "withdrawal"
72
- ? "none"
73
- : SubscriptionCancelTimings.periodEnd,
74
- receivedAtIso: declared.data.receivedAtIso,
75
- locale: declared.data.locale,
76
- },
77
- );
62
+ const result = await ctx.write(SubscriptionFoundationHandlers.recordContractTermination, {
63
+ requestId: declared.data.requestId,
64
+ declarationType: declared.data.declarationType,
65
+ terminationKind: declared.data.terminationKind,
66
+ channel: "public",
67
+ when:
68
+ declared.data.declarationType === "withdrawal"
69
+ ? "none"
70
+ : SubscriptionCancelTimings.periodEnd,
71
+ receivedAtIso: declared.data.receivedAtIso,
72
+ locale: declared.data.locale,
73
+ });
78
74
  if (!result.isSuccess) {
79
75
  throw new Error(
80
76
  `billing-foundation:cancel-on-public-termination-declared: record-contract-termination failed: ${JSON.stringify(result.error)}`,
@@ -1,12 +1,16 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
3
3
  import {
4
- crossTenantOverrideDenied,
5
4
  defineQueryHandler,
5
+ mayOverrideTenant,
6
6
  type QueryHandlerDefinition,
7
7
  type TenantId,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
9
+ import {
10
+ AccessDeniedError,
11
+ FrameworkReasons,
12
+ InternalError,
13
+ } from "@cosmicdrift/kumiko-framework/errors";
10
14
  import * as z from "zod";
11
15
  import { tierAssignmentEntity } from "../../tier-engine/index.js";
12
16
  import type { CapSpec, CapUsageWithMeta } from "../types.js";
@@ -39,12 +43,12 @@ export function createCapsUsageQuery(
39
43
  });
40
44
  }
41
45
  const override = query.payload.tenantId;
42
- const overrideDenied = crossTenantOverrideDenied(
43
- query.user,
44
- override,
45
- "cap-overview.errors.tenantOverrideRequiresSystemAdmin",
46
- );
47
- if (overrideDenied) throw overrideDenied;
46
+ if (override !== undefined && !mayOverrideTenant(query.user)) {
47
+ throw new AccessDeniedError({
48
+ i18nKey: "cap-overview.errors.tenantOverrideRequiresSystemAdmin",
49
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
50
+ });
51
+ }
48
52
 
49
53
  const targetTenantId = override ?? query.user.tenantId;
50
54
  // Both branches return the SAME unfiltered system-mode db —
@@ -4,18 +4,14 @@ import {
4
4
  SELECTABLE_PROFILE_KEYS,
5
5
  } from "@cosmicdrift/kumiko-framework/compliance";
6
6
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
- import {
8
- access,
9
- crossTenantOverrideDenied,
10
- defineWriteHandler,
11
- type TenantId,
12
- } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { access, defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
8
  import {
14
9
  UnprocessableError,
15
10
  validationErrorFromZod,
16
11
  writeFailure,
17
12
  } from "@cosmicdrift/kumiko-framework/errors";
18
13
  import * as z from "zod";
14
+ import { requireForTenant } from "../../shared/index.js";
19
15
  import {
20
16
  tenantComplianceProfileEntity,
21
17
  tenantComplianceProfileTable,
@@ -31,24 +27,16 @@ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantCompli
31
27
  // SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
32
28
  const profileKeySchema = z.enum(SELECTABLE_PROFILE_KEYS);
33
29
 
34
- // Tenant-Admin setzt Profile-Key + optional Override-JSON.
35
- //
36
- // Upsert-Verhalten: erste Wahl insert, weitere update. Idempotent —
37
- // wer mit gleichen Werten zweimal aufruft, kriegt das gleiche Ergebnis
38
- // (modulo aktualisierte Audit-Events im Event-Store).
30
+ // Upsert: the first call inserts, later calls update, so repeating the same values
31
+ // is idempotent apart from the extra audit events.
39
32
  //
40
- // Cross-Tenant-Pfad: SystemAdmin kann via `tenantIdOverride` fuer einen
41
- // anderen Tenant schreiben (Plattform-Operator-Setup, Customer-
42
- // Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
43
- // executorUser.tenantId muss = ziel-tenant sein damit der event-store-
44
- // Stream-Lookup nicht miss → version_conflict gibt (Memory:
45
- // feedback_event_store_tenant_consistency).
33
+ // A SystemAdmin may target another tenant via `tenantIdOverride` (operator setup,
34
+ // onboarding migrations); the dispatcher rejects the override for anyone else. The
35
+ // executor then writes into the target tenant's stream via streamTenantId while
36
+ // the operator stays the recorded actor.
46
37
  //
47
- // Validation:
48
- // - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
49
- // - override (optional) muss valides JSON-Object sein
50
- // - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
51
- // — verhindert Tippfehler die deepMerge stillschweigend ignoriert
38
+ // Override top-level keys are restricted to ALLOWED_OVERRIDE_KEYS because deepMerge
39
+ // would silently ignore a typo.
52
40
  export const setProfileWrite = defineWriteHandler({
53
41
  name: "set-profile",
54
42
  schema: z.object({
@@ -62,15 +50,8 @@ export const setProfileWrite = defineWriteHandler({
62
50
  description:
63
51
  "Sets or replaces a tenant's compliance profile key plus an optional JSON override, for onboarding or a later region change; a system admin may target a different tenant through tenantIdOverride.",
64
52
  handler: async (event, ctx) => {
65
- const tenantOverride = event.payload.tenantIdOverride;
66
- const overrideDenied = crossTenantOverrideDenied(
67
- event.user,
68
- tenantOverride,
69
- "complianceProfiles.errors.tenantOverrideRequiresSystemAdmin",
70
- );
71
- if (overrideDenied) return writeFailure(overrideDenied);
72
- const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
73
- const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
53
+ const tenantId = (event.payload.tenantIdOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
54
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
74
55
 
75
56
  // Override-Validation: muss parseables JSON-Object sein UND dem
76
57
  // ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
@@ -100,7 +81,7 @@ export const setProfileWrite = defineWriteHandler({
100
81
  }
101
82
 
102
83
  // Upsert: existierenden Eintrag suchen
103
- const existing = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
84
+ const existing = (await fetchOne(db, tenantComplianceProfileTable, {
104
85
  tenantId: tenantId,
105
86
  })) as { id: string; version: number } | null; // @cast-boundary db-runner
106
87
 
@@ -114,8 +95,9 @@ export const setProfileWrite = defineWriteHandler({
114
95
  override: event.payload.override ?? null,
115
96
  },
116
97
  },
117
- executorUser,
118
- ctx.db,
98
+ event.user,
99
+ db,
100
+ { streamTenantId },
119
101
  );
120
102
  if (!result.isSuccess) return result;
121
103
  return {
@@ -130,8 +112,9 @@ export const setProfileWrite = defineWriteHandler({
130
112
  override: event.payload.override ?? null,
131
113
  tenantId,
132
114
  },
133
- executorUser,
134
- ctx.db,
115
+ event.user,
116
+ db,
117
+ { streamTenantId },
135
118
  );
136
119
  if (!result.isSuccess) return result;
137
120
  return {
@@ -1,20 +1,15 @@
1
1
  import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { requireForTenant } from "../../shared/index.js";
2
3
  import { fieldDefinitionAggregateId } from "../aggregate-id.js";
3
4
  import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect.js";
4
5
  import { buildFieldDefinitionColumns } from "../lib/field-definition-row.js";
5
6
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas.js";
6
7
 
7
- // define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
8
- // Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
9
- // gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
10
- // auf den admin's eigenen platform-tenant zeigen).
11
- //
12
- // **Use-Case:** Vendor (cdgs) sagt "alle Hausverwaltungs-customers haben ab
13
- // heute ein `internalNumber`-Field". Tenant kann den Wert pro customer
14
- // setzen, aber die Definition nicht ändern oder löschen.
15
- //
16
- // **Same-scope-conflict** wie bei define-tenant-field via aggregate-version-
17
- // conflict (deterministische ID mit tenantId=SYSTEM_TENANT_ID).
8
+ // A system-wide field definition applies to every tenant. tenantId is forced to
9
+ // SYSTEM_TENANT_ID (never taken from the caller) and addressed via streamTenantId,
10
+ // so the SystemAdmin stays the recorded actor. Tenants can set values for the field
11
+ // but cannot change or delete the definition. A same-scope duplicate surfaces as an
12
+ // aggregate version conflict, because the ID is derived from SYSTEM_TENANT_ID.
18
13
  export const defineSystemFieldHandler: WriteHandlerDef = {
19
14
  name: "define-system-field",
20
15
  schema: defineFieldPayloadSchema,
@@ -30,16 +25,14 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
30
25
  payload.fieldKey,
31
26
  );
32
27
 
33
- // Override event.user.tenantId to SYSTEM_TENANT_ID for the system-scope
34
- // write. The framework's CrudExecutor writes the row with this tenantId
35
- // — the row lives in the system-scope-stream.
36
- const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
28
+ const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
37
29
 
38
30
  return defineOrResurrectFieldDefinition(
39
31
  aggregateId,
40
32
  buildFieldDefinitionColumns(payload),
41
- systemUser,
42
- ctx.db,
33
+ event.user,
34
+ db,
35
+ streamTenantId,
43
36
  );
44
37
  },
45
38
  };
@@ -1,4 +1,5 @@
1
1
  import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { requireForTenant } from "../../shared/index.js";
2
3
  import { fieldDefinitionAggregateId } from "../aggregate-id.js";
3
4
  import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants.js";
4
5
  import { fieldDefinitionExecutor } from "../executor.js";
@@ -26,8 +27,10 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
26
27
  payload.fieldKey,
27
28
  );
28
29
 
29
- const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
30
- const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, systemUser, ctx.db);
30
+ const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
31
+ const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, db, {
32
+ streamTenantId,
33
+ });
31
34
 
32
35
  // Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
33
36
  // ihrer customFields jsonb. Im selben TX = atomic.
@@ -1,3 +1,4 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import { fieldDefinitionExecutor } from "../executor.js";
2
3
  import type { FieldDefinitionColumns } from "./field-definition-row.js";
3
4
 
@@ -27,13 +28,18 @@ export async function defineOrResurrectFieldDefinition(
27
28
  columns: FieldDefinitionColumns,
28
29
  user: DefineUser,
29
30
  db: DefineDb,
31
+ streamTenantId?: TenantId,
30
32
  ): Promise<WriteResult> {
31
33
  const active = await fieldDefinitionExecutor.detail({ id: aggregateId }, user, db);
32
34
  if (active) {
33
- return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
35
+ return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
36
+ streamTenantId,
37
+ });
34
38
  }
35
39
 
36
- const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db);
40
+ const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db, {
41
+ streamTenantId,
42
+ });
37
43
  if (restored.isSuccess) {
38
44
  // restore() just un-deleted the row in this same tx; no concurrent writer
39
45
  // exists, so skip the optimistic-lock version match (we'd otherwise have to
@@ -43,10 +49,13 @@ export async function defineOrResurrectFieldDefinition(
43
49
  // readonly FieldDefinitionColumns isn't assignable to it.
44
50
  return fieldDefinitionExecutor.update({ id: aggregateId, changes: { ...columns } }, user, db, {
45
51
  skipOptimisticLock: true,
52
+ streamTenantId,
46
53
  });
47
54
  }
48
55
  if (restored.error.code === "not_found") {
49
- return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
56
+ return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
57
+ streamTenantId,
58
+ });
50
59
  }
51
60
  return restored;
52
61
  }
@@ -190,7 +190,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
190
190
  tenantId,
191
191
  actor: systemUser.id,
192
192
  sink: escapeHatchAuditSink,
193
- log,
193
+ log: logError,
194
194
  });
195
195
  // ctx.db/dbOutsideTransaction are tenant-filtered even for a systemScope handler:
196
196
  // the dispatcher closes them off entirely there, so "system" mode would hand this
@@ -1,9 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
- import {
4
- crossTenantOverrideDenied,
5
- defineQueryHandler,
6
- } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
7
4
  import * as z from "zod";
8
5
  import { type PageRow, pagesTable } from "../table.js";
9
6
 
@@ -33,12 +30,6 @@ export const byTenantPublishedQuery = defineQueryHandler({
33
30
  "Lists every published page of a tenant with slug, language, title and last-change time but no body; use it to enumerate the public pages for sitemap.xml or llms.txt, rather than by-slug which fetches one page's content.",
34
31
  handler: async (query, ctx) => {
35
32
  const override = query.payload.tenantIdOverride;
36
- const overrideDenied = crossTenantOverrideDenied(
37
- query.user,
38
- override,
39
- "managedPages.errors.tenantOverrideRequiresSystemAdmin",
40
- );
41
- if (overrideDenied) throw overrideDenied;
42
33
  const tenantId = override ?? query.user.tenantId;
43
34
  const rows = castTenantRows<PageRow>(
44
35
  await selectMany(ctx.db, pagesTable, { tenantId: tenantId, published: true }),
@@ -1,12 +1,8 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import {
4
- crossTenantOverrideDenied,
5
- defineWriteHandler,
6
- type TenantId,
7
- } from "@cosmicdrift/kumiko-framework/engine";
8
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
+ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
10
6
  import { type PageRow, pageEntity, pagesTable } from "../table.js";
11
7
 
12
8
  const slugSchema = z
@@ -23,9 +19,6 @@ const langSchema = z
23
19
 
24
20
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
25
21
 
26
- const SET_PAGE_TENANT_OVERRIDE_REASON =
27
- "SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
28
-
29
22
  // Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
30
23
  // Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
31
24
  // für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
@@ -48,35 +41,12 @@ export const setWrite = defineWriteHandler({
48
41
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
49
42
  description:
50
43
  "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
51
- escapeHatch: {
52
- grants: ["unsafeRaw"],
53
- reason: SET_PAGE_TENANT_OVERRIDE_REASON,
54
- },
55
44
  handler: async (event, ctx) => {
56
- const db = ctx.db;
57
45
  const override = event.payload.tenantIdOverride;
58
- const overrideDenied = crossTenantOverrideDenied(
59
- event.user,
60
- override,
61
- "managedPages.errors.tenantOverrideRequiresSystemAdmin",
62
- );
63
- if (overrideDenied) return writeFailure(overrideDenied);
64
- const tenantId = override ?? event.user.tenantId;
65
- // override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
66
- const executorUser =
67
- override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
68
-
46
+ const tenantId = (override ?? event.user.tenantId) as TenantId; // @cast-boundary engine-bridge
69
47
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
70
48
  // on both the existing-check and the executor's stream reads; re-scope to the target.
71
- // Safe: the override branch is SystemAdmin-gated above.
72
- const scopedDb =
73
- override !== undefined
74
- ? createTenantDb(
75
- db.unsafeRaw(),
76
- override as TenantId, // @cast-boundary engine-bridge
77
- "tenant",
78
- )
79
- : db;
49
+ const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
80
50
  const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
81
51
  tenantId,
82
52
  slug: event.payload.slug,
@@ -100,8 +70,9 @@ export const setWrite = defineWriteHandler({
100
70
  event.payload.published !== undefined ? event.payload.published : existing.published,
101
71
  },
102
72
  },
103
- executorUser,
73
+ event.user,
104
74
  scopedDb,
75
+ { streamTenantId },
105
76
  );
106
77
  if (!result.isSuccess) return result;
107
78
  return {
@@ -121,8 +92,9 @@ export const setWrite = defineWriteHandler({
121
92
  published: event.payload.published ?? false,
122
93
  tenantId,
123
94
  },
124
- executorUser,
95
+ event.user,
125
96
  scopedDb,
97
+ { streamTenantId },
126
98
  );
127
99
  if (!result.isSuccess) return result;
128
100
  return {
@@ -33,6 +33,7 @@ export {
33
33
  redactErrorText,
34
34
  redactUrls,
35
35
  } from "./redact.js";
36
+ export { requireForTenant } from "./require-for-tenant.js";
36
37
  export {
37
38
  type RowBoundGrantResult,
38
39
  redeemRowBoundGrant,
@@ -0,0 +1,18 @@
1
+ import type {
2
+ HandlerContext,
3
+ TenantId,
4
+ TenantWriteTarget,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
+
8
+ // ctx.forTenant is absent for r.systemScope() handlers and without a db; a write handler that
9
+ // needs a cross-tenant target must fail loudly instead of falling back to ctx.db.
10
+ export function requireForTenant(
11
+ ctx: Pick<HandlerContext, "forTenant">,
12
+ targetTenantId: TenantId,
13
+ ): TenantWriteTarget {
14
+ if (!ctx.forTenant) {
15
+ throw new InternalError({ message: "ctx.forTenant is not available in this handler context" });
16
+ }
17
+ return ctx.forTenant(targetTenantId);
18
+ }
@@ -1,8 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import {
3
- crossTenantOverrideDenied,
4
- defineQueryHandler,
5
- } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
6
3
  import * as z from "zod";
7
4
  import { TEXT_BLOCK_KIND } from "../constants.js";
8
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
@@ -33,12 +30,6 @@ export const bySlugQuery = defineQueryHandler({
33
30
  "Reads one text-block of a tenant by slug and locale together with its body and format; it is pinned to the text-block kind so mail templates and AI prompts in the same table stay unreachable through this anonymous-capable path.",
34
31
  handler: async (query, ctx) => {
35
32
  const override = query.payload.tenantIdOverride;
36
- const overrideDenied = crossTenantOverrideDenied(
37
- query.user,
38
- override,
39
- "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
40
- );
41
- if (overrideDenied) throw overrideDenied;
42
33
  const tenantId = override ?? query.user.tenantId;
43
34
  const row = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
44
35
  tenantId,
@@ -1,9 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
- import {
4
- crossTenantOverrideDenied,
5
- defineQueryHandler,
6
- } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
7
4
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
5
  import * as z from "zod";
9
6
  import { TEXT_BLOCK_KIND } from "../constants.js";
@@ -39,12 +36,6 @@ export const byTenantQuery = defineQueryHandler({
39
36
  "Lists every text-block of a tenant with slug, locale, title and body so a public content tree can be rendered in one call; use it for the whole sidebar, and by-slug when only one block is needed.",
40
37
  handler: async (query, ctx) => {
41
38
  const override = query.payload.tenantIdOverride;
42
- const overrideDenied = crossTenantOverrideDenied(
43
- query.user,
44
- override,
45
- "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
46
- );
47
- if (overrideDenied) throw overrideDenied;
48
39
  const tenantId = override ?? query.user.tenantId;
49
40
  const rows = castTenantRows<TemplateResourceRow>(
50
41
  await selectMany(ctx.db, templateResourcesTable, { tenantId, kind: TEXT_BLOCK_KIND }),
@@ -1,7 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  type ContentCollectionDefinition,
4
- crossTenantOverrideDenied,
5
4
  defineQueryHandler,
6
5
  } from "@cosmicdrift/kumiko-framework/engine";
7
6
  import * as z from "zod";
@@ -31,12 +30,6 @@ export function makeCollectionItemQuery(collection: ContentCollectionDefinition)
31
30
  : `Reads one tenant-wide "${collection.id}" entry by slug and locale, returning null when it does not exist; use it to open a single entry in the editor rather than to list the collection.`,
32
31
  handler: async (query, ctx) => {
33
32
  const override = query.payload.tenantIdOverride;
34
- const overrideDenied = crossTenantOverrideDenied(
35
- query.user,
36
- override,
37
- "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
38
- );
39
- if (overrideDenied) throw overrideDenied;
40
33
  const tenantId = override ?? query.user.tenantId;
41
34
  const row = await fetchOne<CollectionEntryRow>(ctx.db, store.table, {
42
35
  tenantId,