@cosmicdrift/kumiko-bundled-features 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 (48) hide show
  1. package/dist/agent-tools/agent-manifest.js +2 -0
  2. package/dist/agent-tools/types.d.ts +2 -0
  3. package/dist/audit/escape-hatch-audit-sink.d.ts +1 -0
  4. package/dist/audit/escape-hatch-audit-sink.js +2 -0
  5. package/dist/audit/handlers/resolve-audit-tenant.js +8 -4
  6. package/dist/billing-foundation/consumer-protection/register-consent.js +1 -2
  7. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +1 -1
  8. package/dist/billing-foundation/consumer-protection/register-termination.js +1 -2
  9. package/dist/cap-overview/handlers/caps-usage.query.js +8 -5
  10. package/dist/compliance-profiles/handlers/set-profile.write.js +15 -26
  11. package/dist/custom-fields/handlers/define-system-field.write.js +8 -16
  12. package/dist/custom-fields/handlers/delete-system-field.write.js +5 -2
  13. package/dist/custom-fields/lib/define-or-resurrect.d.ts +2 -1
  14. package/dist/custom-fields/lib/define-or-resurrect.js +11 -4
  15. package/dist/delivery/delivery-service.js +1 -1
  16. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -4
  17. package/dist/managed-pages/handlers/set.write.js +7 -22
  18. package/dist/shared/index.d.ts +1 -0
  19. package/dist/shared/index.js +1 -0
  20. package/dist/shared/require-for-tenant.d.ts +2 -0
  21. package/dist/shared/require-for-tenant.js +9 -0
  22. package/dist/template-resolver/handlers/by-slug.query.js +1 -4
  23. package/dist/template-resolver/handlers/by-tenant.query.js +1 -4
  24. package/dist/template-resolver/handlers/collection-item.query.js +1 -4
  25. package/dist/template-resolver/handlers/collection-list.query.js +1 -4
  26. package/dist/template-resolver/handlers/collection-set.write.js +5 -9
  27. package/dist/template-resolver/handlers/set.write.js +7 -12
  28. package/dist/template-resolver/handlers/upsert-system.write.js +6 -6
  29. package/dist/template-resolver/handlers/upsert-tenant.write.js +7 -8
  30. package/dist/template-resolver/seeding.js +1 -2
  31. package/dist/tenant/cancel-pending-invitation.d.ts +2 -2
  32. package/dist/tenant/cancel-pending-invitation.js +2 -2
  33. package/dist/tenant/handlers/remove-member.write.js +13 -10
  34. package/dist/tenant/handlers/update-member-roles.write.js +12 -9
  35. package/dist/tenant-lifecycle/feature.js +1 -0
  36. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +3 -0
  37. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -0
  38. package/dist/tenant-lifecycle/stages.d.ts +2 -0
  39. package/dist/tenant-lifecycle/stages.js +2 -0
  40. package/dist/tier-engine/handlers/set-tenant-tier.write.js +7 -18
  41. package/dist/user-data-rights/feature.js +2 -0
  42. package/dist/user-data-rights/run-export-jobs.d.ts +2 -0
  43. package/dist/user-data-rights/run-export-jobs.js +2 -0
  44. package/dist/user-data-rights/run-forget-cleanup.d.ts +2 -0
  45. package/dist/user-data-rights/run-forget-cleanup.js +4 -0
  46. package/dist/user-data-rights/run-user-export.d.ts +2 -0
  47. package/dist/user-data-rights/run-user-export.js +2 -0
  48. package/package.json +9 -9
@@ -163,6 +163,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
163
163
  return undefined;
164
164
  }
165
165
  const entity = getHandlerEntity(qn);
166
+ const excludedFields = "excludedFields" in def ? def.excludedFields : undefined;
166
167
  return {
167
168
  qn,
168
169
  kind,
@@ -170,6 +171,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
170
171
  risk: exposure.risk,
171
172
  inputSchema,
172
173
  ...(entity !== undefined && { entity }),
174
+ ...(excludedFields !== undefined && { excludedFields }),
173
175
  };
174
176
  }
175
177
  function buildHandlers(queryHandlers, writeHandlers, roles, getHandlerEntity, denyQns) {
@@ -130,6 +130,8 @@ export type AgentManifestHandler = {
130
130
  readonly risk: AgentRisk;
131
131
  readonly inputSchema: Readonly<Record<string, unknown>>;
132
132
  readonly entity?: string;
133
+ /** Fields this generic create/update handler never writes; sending one fails validation. */
134
+ readonly excludedFields?: readonly string[];
133
135
  };
134
136
  export type AgentManifestScreen = {
135
137
  readonly id: string;
@@ -15,6 +15,7 @@ export declare const escapeHatchUsedSchema: z.ZodObject<{
15
15
  }>;
16
16
  reason: z.ZodString;
17
17
  actor: z.ZodString;
18
+ caller: z.ZodOptional<z.ZodString>;
18
19
  targetUserId: z.ZodOptional<z.ZodString>;
19
20
  targetTenantId: z.ZodOptional<z.ZodString>;
20
21
  }, z.core.$strip>;
@@ -18,6 +18,7 @@ export const escapeHatchUsedSchema = z.object({
18
18
  kind: z.enum(escapeHatchKinds),
19
19
  reason: z.string(),
20
20
  actor: z.string(),
21
+ caller: z.string().optional(),
21
22
  targetUserId: z.string().optional(),
22
23
  targetTenantId: z.string().optional(),
23
24
  });
@@ -28,6 +29,7 @@ export function createEscapeHatchAuditSink(opts) {
28
29
  kind: event.kind,
29
30
  reason: event.reason,
30
31
  actor: event.actor,
32
+ ...(event.caller !== undefined && { caller: event.caller }),
31
33
  ...(event.target && { targetUserId: event.target.id, targetTenantId: event.target.tenantId }),
32
34
  });
33
35
  // Appends on the unbound pool, never the handler tx — an audit entry must
@@ -1,4 +1,5 @@
1
- import { crossTenantOverrideDenied } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { mayOverrideTenant } 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 { AuditScopes } from "../constants.js";
@@ -7,8 +8,11 @@ import { AuditScopes } from "../constants.js";
7
8
  export function resolveAuditScopeFilter(user, scope) {
8
9
  if (scope !== AuditScopes.system)
9
10
  return { tenantId: user.tenantId };
10
- const denied = crossTenantOverrideDenied(user, SYSTEM_TENANT_ID, "audit.errors.systemScopeRequiresSystemAdmin");
11
- if (denied)
12
- throw denied;
11
+ if (!mayOverrideTenant(user)) {
12
+ throw new AccessDeniedError({
13
+ i18nKey: "audit.errors.systemScopeRequiresSystemAdmin",
14
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
15
+ });
16
+ }
13
17
  return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
14
18
  }
@@ -1,4 +1,3 @@
1
- import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
3
2
  import { SubscriptionFoundationHandlers, SubscriptionStatuses } from "../constants.js";
4
3
  import { CHECKOUT_CONSENT_RECORDED_EVENT_SHORT, CONTRACT_CONFIRMATION_ISSUED_EVENT_SHORT, checkoutConsentRecordedPayloadSchema, contractConfirmationIssuedPayloadSchema, INVOICE_PAID_EVENT_QN, PAYMENT_RECEIVED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_QN, } from "../events.js";
@@ -48,7 +47,7 @@ export function registerConsumerProtection(r, options, consumerProtection) {
48
47
  // user), never from payload data.
49
48
  const tenantId = ctx.systemUser.tenantId;
50
49
  const currentPeriodEndIso = payload["currentPeriodEndIso"];
51
- const result = await ctx.writeAs(createSystemUser(tenantId), SubscriptionFoundationHandlers.issueContractConfirmation, {
50
+ const result = await ctx.write(SubscriptionFoundationHandlers.issueContractConfirmation, {
52
51
  consentId,
53
52
  sourceAggregateId: isSubscriptionEvent
54
53
  ? subscriptionAggregateId(tenantId)
@@ -1,3 +1,3 @@
1
- import { type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
3
3
  export declare function registerContractTermination(r: FeatureRegistrar, options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): void;
@@ -1,4 +1,3 @@
1
- import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import { SubscriptionCancelTimings, SubscriptionFoundationHandlers } from "../constants.js";
3
2
  import { CONTRACT_TERMINATION_DECLARED_EVENT_QN, CONTRACT_TERMINATION_DECLARED_EVENT_SHORT, CONTRACT_TERMINATION_REQUESTED_EVENT_SHORT, CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT, contractTerminationDeclaredPayloadSchema, contractTerminationRequestedPayloadSchema, contractTerminationUnmatchedPayloadSchema, } from "../events.js";
4
3
  import { createDeclareContractTerminationHandler, createRecordContractTerminationHandler, createRecordUnmatchedContractTerminationHandler, createTerminableSubscriptionQuery, } from "./termination-record.js";
@@ -22,7 +21,7 @@ export function registerContractTermination(r, options, consumerProtection) {
22
21
  // skip: trigger event carries no usable declaration
23
22
  if (!declared.success)
24
23
  return;
25
- const result = await ctx.writeAs(createSystemUser(ctx.systemUser.tenantId), SubscriptionFoundationHandlers.recordContractTermination, {
24
+ const result = await ctx.write(SubscriptionFoundationHandlers.recordContractTermination, {
26
25
  requestId: declared.data.requestId,
27
26
  declarationType: declared.data.declarationType,
28
27
  terminationKind: declared.data.terminationKind,
@@ -1,7 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
3
- import { crossTenantOverrideDenied, defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
4
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { defineQueryHandler, mayOverrideTenant, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { AccessDeniedError, FrameworkReasons, InternalError, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import * as z from "zod";
6
6
  import { tierAssignmentEntity } from "../../tier-engine/index.js";
7
7
  import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math.js";
@@ -24,9 +24,12 @@ export function createCapsUsageQuery(caps, usageRoles) {
24
24
  });
25
25
  }
26
26
  const override = query.payload.tenantId;
27
- const overrideDenied = crossTenantOverrideDenied(query.user, override, "cap-overview.errors.tenantOverrideRequiresSystemAdmin");
28
- if (overrideDenied)
29
- throw overrideDenied;
27
+ if (override !== undefined && !mayOverrideTenant(query.user)) {
28
+ throw new AccessDeniedError({
29
+ i18nKey: "cap-overview.errors.tenantOverrideRequiresSystemAdmin",
30
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
31
+ });
32
+ }
30
33
  const targetTenantId = override ?? query.user.tenantId;
31
34
  // Both branches return the SAME unfiltered system-mode db —
32
35
  // assertTenantMatch is a self-check on the caller, not a query
@@ -1,9 +1,10 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { complianceProfileOverrideSchema, SELECTABLE_PROFILE_KEYS, } from "@cosmicdrift/kumiko-framework/compliance";
3
3
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
4
- import { access, crossTenantOverrideDenied, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { UnprocessableError, validationErrorFromZod, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import * as z from "zod";
7
+ import { requireForTenant } from "../../shared/index.js";
7
8
  import { tenantComplianceProfileEntity, tenantComplianceProfileTable, } from "../schema/profile-selection.js";
8
9
  const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantComplianceProfileEntity, {
9
10
  entityName: "tenant-compliance-profile",
@@ -13,24 +14,16 @@ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantCompli
13
14
  // nicht eine waehlbare Production-Option. Symmetrisch zu
14
15
  // SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
15
16
  const profileKeySchema = z.enum(SELECTABLE_PROFILE_KEYS);
16
- // Tenant-Admin setzt Profile-Key + optional Override-JSON.
17
+ // Upsert: the first call inserts, later calls update, so repeating the same values
18
+ // is idempotent apart from the extra audit events.
17
19
  //
18
- // Upsert-Verhalten: erste Wahl insert, weitere update. Idempotent —
19
- // wer mit gleichen Werten zweimal aufruft, kriegt das gleiche Ergebnis
20
- // (modulo aktualisierte Audit-Events im Event-Store).
20
+ // A SystemAdmin may target another tenant via `tenantIdOverride` (operator setup,
21
+ // onboarding migrations); the dispatcher rejects the override for anyone else. The
22
+ // executor then writes into the target tenant's stream via streamTenantId while
23
+ // the operator stays the recorded actor.
21
24
  //
22
- // Cross-Tenant-Pfad: SystemAdmin kann via `tenantIdOverride` fuer einen
23
- // anderen Tenant schreiben (Plattform-Operator-Setup, Customer-
24
- // Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
25
- // executorUser.tenantId muss = ziel-tenant sein damit der event-store-
26
- // Stream-Lookup nicht miss → version_conflict gibt (Memory:
27
- // feedback_event_store_tenant_consistency).
28
- //
29
- // Validation:
30
- // - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
31
- // - override (optional) muss valides JSON-Object sein
32
- // - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
33
- // — verhindert Tippfehler die deepMerge stillschweigend ignoriert
25
+ // Override top-level keys are restricted to ALLOWED_OVERRIDE_KEYS because deepMerge
26
+ // would silently ignore a typo.
34
27
  export const setProfileWrite = defineWriteHandler({
35
28
  name: "set-profile",
36
29
  schema: z.object({
@@ -43,12 +36,8 @@ export const setProfileWrite = defineWriteHandler({
43
36
  access: { roles: access.admin },
44
37
  description: "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.",
45
38
  handler: async (event, ctx) => {
46
- const tenantOverride = event.payload.tenantIdOverride;
47
- const overrideDenied = crossTenantOverrideDenied(event.user, tenantOverride, "complianceProfiles.errors.tenantOverrideRequiresSystemAdmin");
48
- if (overrideDenied)
49
- return writeFailure(overrideDenied);
50
- const tenantId = (tenantOverride ?? event.user.tenantId); // @cast-boundary engine-payload
51
- const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
39
+ const tenantId = (event.payload.tenantIdOverride ?? event.user.tenantId); // @cast-boundary engine-payload
40
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
52
41
  // Override-Validation: muss parseables JSON-Object sein UND dem
53
42
  // ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
54
43
  // mit Top-Level + Sub-Level-Whitelist via .strict()). Tippfehler
@@ -75,7 +64,7 @@ export const setProfileWrite = defineWriteHandler({
75
64
  }
76
65
  }
77
66
  // Upsert: existierenden Eintrag suchen
78
- const existing = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
67
+ const existing = (await fetchOne(db, tenantComplianceProfileTable, {
79
68
  tenantId: tenantId,
80
69
  })); // @cast-boundary db-runner
81
70
  if (existing) {
@@ -86,7 +75,7 @@ export const setProfileWrite = defineWriteHandler({
86
75
  profileKey: event.payload.profileKey,
87
76
  override: event.payload.override ?? null,
88
77
  },
89
- }, executorUser, ctx.db);
78
+ }, event.user, db, { streamTenantId });
90
79
  if (!result.isSuccess)
91
80
  return result;
92
81
  return {
@@ -98,7 +87,7 @@ export const setProfileWrite = defineWriteHandler({
98
87
  profileKey: event.payload.profileKey,
99
88
  override: event.payload.override ?? null,
100
89
  tenantId,
101
- }, executorUser, ctx.db);
90
+ }, event.user, db, { streamTenantId });
102
91
  if (!result.isSuccess)
103
92
  return result;
104
93
  return {
@@ -1,19 +1,14 @@
1
1
  import { SYSTEM_TENANT_ID } 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 { defineFieldPayloadSchema } from "../schemas.js";
6
- // define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
7
- // Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
8
- // gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
9
- // auf den admin's eigenen platform-tenant zeigen).
10
- //
11
- // **Use-Case:** Vendor (cdgs) sagt "alle Hausverwaltungs-customers haben ab
12
- // heute ein `internalNumber`-Field". Tenant kann den Wert pro customer
13
- // setzen, aber die Definition nicht ändern oder löschen.
14
- //
15
- // **Same-scope-conflict** wie bei define-tenant-field via aggregate-version-
16
- // conflict (deterministische ID mit tenantId=SYSTEM_TENANT_ID).
7
+ // A system-wide field definition applies to every tenant. tenantId is forced to
8
+ // SYSTEM_TENANT_ID (never taken from the caller) and addressed via streamTenantId,
9
+ // so the SystemAdmin stays the recorded actor. Tenants can set values for the field
10
+ // but cannot change or delete the definition. A same-scope duplicate surfaces as an
11
+ // aggregate version conflict, because the ID is derived from SYSTEM_TENANT_ID.
17
12
  export const defineSystemFieldHandler = {
18
13
  name: "define-system-field",
19
14
  schema: defineFieldPayloadSchema,
@@ -22,10 +17,7 @@ export const defineSystemFieldHandler = {
22
17
  handler: async (event, ctx) => {
23
18
  const payload = event.payload; // @cast-boundary engine-payload
24
19
  const aggregateId = fieldDefinitionAggregateId(SYSTEM_TENANT_ID, payload.entityName, payload.fieldKey);
25
- // Override event.user.tenantId to SYSTEM_TENANT_ID for the system-scope
26
- // write. The framework's CrudExecutor writes the row with this tenantId
27
- // — the row lives in the system-scope-stream.
28
- const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
29
- return defineOrResurrectFieldDefinition(aggregateId, buildFieldDefinitionColumns(payload), systemUser, ctx.db);
20
+ const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
21
+ return defineOrResurrectFieldDefinition(aggregateId, buildFieldDefinitionColumns(payload), event.user, db, streamTenantId);
30
22
  },
31
23
  };
@@ -1,4 +1,5 @@
1
1
  import { SYSTEM_TENANT_ID } 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";
@@ -18,8 +19,10 @@ export const deleteSystemFieldHandler = {
18
19
  handler: async (event, ctx) => {
19
20
  const payload = event.payload; // @cast-boundary engine-payload
20
21
  const aggregateId = fieldDefinitionAggregateId(SYSTEM_TENANT_ID, payload.entityName, payload.fieldKey);
21
- const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
22
- const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, systemUser, ctx.db);
22
+ const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
23
+ const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, db, {
24
+ streamTenantId,
25
+ });
23
26
  // Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
24
27
  // ihrer customFields jsonb. Im selben TX = atomic.
25
28
  if (result.isSuccess) {
@@ -1,7 +1,8 @@
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
  type DefineUser = Parameters<typeof fieldDefinitionExecutor.create>[1];
4
5
  type DefineDb = Parameters<typeof fieldDefinitionExecutor.create>[2];
5
6
  type WriteResult = Awaited<ReturnType<typeof fieldDefinitionExecutor.create>>;
6
- export declare function defineOrResurrectFieldDefinition(aggregateId: string, columns: FieldDefinitionColumns, user: DefineUser, db: DefineDb): Promise<WriteResult>;
7
+ export declare function defineOrResurrectFieldDefinition(aggregateId: string, columns: FieldDefinitionColumns, user: DefineUser, db: DefineDb, streamTenantId?: TenantId): Promise<WriteResult>;
7
8
  export {};
@@ -16,12 +16,16 @@ import { fieldDefinitionExecutor } from "../executor.js";
16
16
  // fail with "current transaction is aborted". detail() (a read) + restore()
17
17
  // (which sees soft-deleted rows via a tenant-scoped fetch and only writes on
18
18
  // success) keep the tx clean until the single terminal write.
19
- export async function defineOrResurrectFieldDefinition(aggregateId, columns, user, db) {
19
+ export async function defineOrResurrectFieldDefinition(aggregateId, columns, user, db, streamTenantId) {
20
20
  const active = await fieldDefinitionExecutor.detail({ id: aggregateId }, user, db);
21
21
  if (active) {
22
- return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
22
+ return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
23
+ streamTenantId,
24
+ });
23
25
  }
24
- const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db);
26
+ const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db, {
27
+ streamTenantId,
28
+ });
25
29
  if (restored.isSuccess) {
26
30
  // restore() just un-deleted the row in this same tx; no concurrent writer
27
31
  // exists, so skip the optimistic-lock version match (we'd otherwise have to
@@ -31,10 +35,13 @@ export async function defineOrResurrectFieldDefinition(aggregateId, columns, use
31
35
  // readonly FieldDefinitionColumns isn't assignable to it.
32
36
  return fieldDefinitionExecutor.update({ id: aggregateId, changes: { ...columns } }, user, db, {
33
37
  skipOptimisticLock: true,
38
+ streamTenantId,
34
39
  });
35
40
  }
36
41
  if (restored.error.code === "not_found") {
37
- return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
42
+ return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
43
+ streamTenantId,
44
+ });
38
45
  }
39
46
  return restored;
40
47
  }
@@ -92,7 +92,7 @@ export function createDeliveryService(options) {
92
92
  tenantId,
93
93
  actor: systemUser.id,
94
94
  sink: escapeHatchAuditSink,
95
- log,
95
+ log: logError,
96
96
  });
97
97
  // ctx.db/dbOutsideTransaction are tenant-filtered even for a systemScope handler:
98
98
  // the dispatcher closes them off entirely there, so "system" mode would hand this
@@ -1,6 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
- import { crossTenantOverrideDenied, defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
5
  import { pagesTable } from "../table.js";
6
6
  export const byTenantPublishedQuery = defineQueryHandler({
@@ -15,9 +15,6 @@ export const byTenantPublishedQuery = defineQueryHandler({
15
15
  description: "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.",
16
16
  handler: async (query, ctx) => {
17
17
  const override = query.payload.tenantIdOverride;
18
- const overrideDenied = crossTenantOverrideDenied(query.user, override, "managedPages.errors.tenantOverrideRequiresSystemAdmin");
19
- if (overrideDenied)
20
- throw overrideDenied;
21
18
  const tenantId = override ?? query.user.tenantId;
22
19
  const rows = castTenantRows(await selectMany(ctx.db, pagesTable, { tenantId: tenantId, published: true }));
23
20
  return {
@@ -1,8 +1,8 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import { crossTenantOverrideDenied, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
4
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
5
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
6
6
  import { pageEntity, pagesTable } from "../table.js";
7
7
  const slugSchema = z
8
8
  .string()
@@ -15,7 +15,6 @@ const langSchema = z
15
15
  .max(8)
16
16
  .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "lang must be ISO 639-1 (e.g. de, en, en-us)");
17
17
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
18
- const SET_PAGE_TENANT_OVERRIDE_REASON = "SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
19
18
  // Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
20
19
  // Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
21
20
  // für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
@@ -37,26 +36,12 @@ export const setWrite = defineWriteHandler({
37
36
  }),
38
37
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
39
38
  description: "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.",
40
- escapeHatch: {
41
- grants: ["unsafeRaw"],
42
- reason: SET_PAGE_TENANT_OVERRIDE_REASON,
43
- },
44
39
  handler: async (event, ctx) => {
45
- const db = ctx.db;
46
40
  const override = event.payload.tenantIdOverride;
47
- const overrideDenied = crossTenantOverrideDenied(event.user, override, "managedPages.errors.tenantOverrideRequiresSystemAdmin");
48
- if (overrideDenied)
49
- return writeFailure(overrideDenied);
50
- const tenantId = override ?? event.user.tenantId;
51
- // override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
52
- const executorUser = override !== undefined ? { ...event.user, tenantId: override } : event.user; // @cast-boundary engine-bridge
41
+ const tenantId = (override ?? event.user.tenantId); // @cast-boundary engine-bridge
53
42
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
54
43
  // on both the existing-check and the executor's stream reads; re-scope to the target.
55
- // Safe: the override branch is SystemAdmin-gated above.
56
- const scopedDb = override !== undefined
57
- ? createTenantDb(db.unsafeRaw(), override, // @cast-boundary engine-bridge
58
- "tenant")
59
- : db;
44
+ const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
60
45
  const existing = await fetchOne(scopedDb, pagesTable, {
61
46
  tenantId,
62
47
  slug: event.payload.slug,
@@ -75,7 +60,7 @@ export const setWrite = defineWriteHandler({
75
60
  ogImage: event.payload.ogImage !== undefined ? event.payload.ogImage : existing.ogImage,
76
61
  published: event.payload.published !== undefined ? event.payload.published : existing.published,
77
62
  },
78
- }, executorUser, scopedDb);
63
+ }, event.user, scopedDb, { streamTenantId });
79
64
  if (!result.isSuccess)
80
65
  return result;
81
66
  return {
@@ -92,7 +77,7 @@ export const setWrite = defineWriteHandler({
92
77
  ogImage: event.payload.ogImage ?? null,
93
78
  published: event.payload.published ?? false,
94
79
  tenantId,
95
- }, executorUser, scopedDb);
80
+ }, event.user, scopedDb, { streamTenantId });
96
81
  if (!result.isSuccess)
97
82
  return result;
98
83
  return {
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
16
16
  export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
17
+ export { requireForTenant } from "./require-for-tenant.js";
17
18
  export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
18
19
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
19
20
  export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
16
16
  export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
17
+ export { requireForTenant } from "./require-for-tenant.js";
17
18
  export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
18
19
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
19
20
  export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
@@ -0,0 +1,2 @@
1
+ import type { HandlerContext, TenantId, TenantWriteTarget } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function requireForTenant(ctx: Pick<HandlerContext, "forTenant">, targetTenantId: TenantId): TenantWriteTarget;
@@ -0,0 +1,9 @@
1
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
2
+ // ctx.forTenant is absent for r.systemScope() handlers and without a db; a write handler that
3
+ // needs a cross-tenant target must fail loudly instead of falling back to ctx.db.
4
+ export function requireForTenant(ctx, targetTenantId) {
5
+ if (!ctx.forTenant) {
6
+ throw new InternalError({ message: "ctx.forTenant is not available in this handler context" });
7
+ }
8
+ return ctx.forTenant(targetTenantId);
9
+ }
@@ -1,5 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { crossTenantOverrideDenied, defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import { TEXT_BLOCK_KIND } from "../constants.js";
5
5
  import { templateResourcesTable } from "../table.js";
@@ -28,9 +28,6 @@ export const bySlugQuery = defineQueryHandler({
28
28
  description: "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.",
29
29
  handler: async (query, ctx) => {
30
30
  const override = query.payload.tenantIdOverride;
31
- const overrideDenied = crossTenantOverrideDenied(query.user, override, "templateResolver.errors.tenantOverrideRequiresSystemAdmin");
32
- if (overrideDenied)
33
- throw overrideDenied;
34
31
  const tenantId = override ?? query.user.tenantId;
35
32
  const row = await fetchOne(ctx.db, templateResourcesTable, {
36
33
  tenantId,
@@ -1,6 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
- import { crossTenantOverrideDenied, defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
5
  import { TEXT_BLOCK_KIND } from "../constants.js";
6
6
  import { templateResourcesTable } from "../table.js";
@@ -15,9 +15,6 @@ export const byTenantQuery = defineQueryHandler({
15
15
  description: "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.",
16
16
  handler: async (query, ctx) => {
17
17
  const override = query.payload.tenantIdOverride;
18
- const overrideDenied = crossTenantOverrideDenied(query.user, override, "templateResolver.errors.tenantOverrideRequiresSystemAdmin");
19
- if (overrideDenied)
20
- throw overrideDenied;
21
18
  const tenantId = override ?? query.user.tenantId;
22
19
  const rows = castTenantRows(await selectMany(ctx.db, templateResourcesTable, { tenantId, kind: TEXT_BLOCK_KIND }));
23
20
  return {
@@ -1,5 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { crossTenantOverrideDenied, defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import { collectionStore, DEFAULT_COLLECTION_ACCESS, toCollectionEntry, } from "./collection-shared.js";
5
5
  // Single entry of one collection, for the editor behind a tree node. Same
@@ -20,9 +20,6 @@ export function makeCollectionItemQuery(collection) {
20
20
  : `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.`,
21
21
  handler: async (query, ctx) => {
22
22
  const override = query.payload.tenantIdOverride;
23
- const overrideDenied = crossTenantOverrideDenied(query.user, override, "templateResolver.errors.tenantOverrideRequiresSystemAdmin");
24
- if (overrideDenied)
25
- throw overrideDenied;
26
23
  const tenantId = override ?? query.user.tenantId;
27
24
  const row = await fetchOne(ctx.db, store.table, {
28
25
  tenantId,
@@ -1,6 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
- import { crossTenantOverrideDenied, defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
5
  import { collectionStore, DEFAULT_COLLECTION_ACCESS, toCollectionEntry, } from "./collection-shared.js";
6
6
  // One list handler per declared collection: `kind`, `ownership` and `access`
@@ -25,9 +25,6 @@ export function makeCollectionListQuery(collection) {
25
25
  : `Lists the tenant-wide entries of the "${collection.id}" content collection (${collection.kind} content) with slug, locale, title and body; every caller who may reach this collection sees the same shared set.`,
26
26
  handler: async (query, ctx) => {
27
27
  const override = query.payload.tenantIdOverride;
28
- const overrideDenied = crossTenantOverrideDenied(query.user, override, "templateResolver.errors.tenantOverrideRequiresSystemAdmin");
29
- if (overrideDenied)
30
- throw overrideDenied;
31
28
  const tenantId = override ?? query.user.tenantId;
32
29
  const rows = castTenantRows(await selectMany(ctx.db, store.table, {
33
30
  tenantId,
@@ -1,7 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { crossTenantOverrideDenied, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
4
3
  import * as z from "zod";
4
+ import { requireForTenant } from "../../shared/index.js";
5
5
  import { collectionStore, DEFAULT_COLLECTION_ACCESS, } from "./collection-shared.js";
6
6
  import { contentFormatSchema, folderSchema, localeSchema, slugSchema } from "./shared.js";
7
7
  // Upsert inside one collection. Mirrors set.write for text-blocks, but the
@@ -30,15 +30,11 @@ export function makeCollectionSetWrite(collection) {
30
30
  ? `Creates or overwrites the caller's own "${collection.id}" entry at a slug and locale, live on save with no draft stage; it always writes the acting user's entry, never another user's.`
31
31
  : `Creates or overwrites the tenant-wide "${collection.id}" entry at a slug and locale, live on save with no draft stage; the change is visible to everyone who may reach this shared collection.`,
32
32
  handler: async (event, ctx) => {
33
- const db = ctx.db;
34
33
  const override = event.payload.tenantIdOverride;
35
- const overrideDenied = crossTenantOverrideDenied(event.user, override, "templateResolver.errors.tenantOverrideRequiresSystemAdmin");
36
- if (overrideDenied)
37
- return writeFailure(overrideDenied);
38
34
  // @cast-boundary engine-payload — override is a zod-validated string, the
39
35
  // user's own tenantId is already TenantId-branded.
40
36
  const tenantId = (override ?? event.user.tenantId);
41
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
37
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
42
38
  // Scoped to the acting user even under a tenant override: a SystemAdmin
43
39
  // writing into another tenant still writes their own entry, never
44
40
  // someone else's signature.
@@ -63,7 +59,7 @@ export function makeCollectionSetWrite(collection) {
63
59
  contentFormat: event.payload.contentFormat,
64
60
  folder: event.payload.folder ?? null,
65
61
  },
66
- }, executorUser, db);
62
+ }, event.user, db, { streamTenantId });
67
63
  if (!result.isSuccess)
68
64
  return result;
69
65
  return {
@@ -82,7 +78,7 @@ export function makeCollectionSetWrite(collection) {
82
78
  folder: event.payload.folder ?? null,
83
79
  ...store.createDefaults(tenantId),
84
80
  ...owner,
85
- }, executorUser, db);
81
+ }, event.user, db, { streamTenantId });
86
82
  if (!result.isSuccess)
87
83
  return result;
88
84
  return {