@cosmicdrift/kumiko-bundled-features 0.355.0 → 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.
@@ -4,6 +4,7 @@ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
4
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,9 +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 tenantId = (tenantOverride ?? event.user.tenantId); // @cast-boundary engine-payload
48
- 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);
49
41
  // Override-Validation: muss parseables JSON-Object sein UND dem
50
42
  // ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
51
43
  // mit Top-Level + Sub-Level-Whitelist via .strict()). Tippfehler
@@ -72,7 +64,7 @@ export const setProfileWrite = defineWriteHandler({
72
64
  }
73
65
  }
74
66
  // Upsert: existierenden Eintrag suchen
75
- const existing = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
67
+ const existing = (await fetchOne(db, tenantComplianceProfileTable, {
76
68
  tenantId: tenantId,
77
69
  })); // @cast-boundary db-runner
78
70
  if (existing) {
@@ -83,7 +75,7 @@ export const setProfileWrite = defineWriteHandler({
83
75
  profileKey: event.payload.profileKey,
84
76
  override: event.payload.override ?? null,
85
77
  },
86
- }, executorUser, ctx.db);
78
+ }, event.user, db, { streamTenantId });
87
79
  if (!result.isSuccess)
88
80
  return result;
89
81
  return {
@@ -95,7 +87,7 @@ export const setProfileWrite = defineWriteHandler({
95
87
  profileKey: event.payload.profileKey,
96
88
  override: event.payload.override ?? null,
97
89
  tenantId,
98
- }, executorUser, ctx.db);
90
+ }, event.user, db, { streamTenantId });
99
91
  if (!result.isSuccess)
100
92
  return result;
101
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
  }
@@ -1,7 +1,8 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
5
6
  import { pageEntity, pagesTable } from "../table.js";
6
7
  const slugSchema = z
7
8
  .string()
@@ -14,7 +15,6 @@ const langSchema = z
14
15
  .max(8)
15
16
  .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "lang must be ISO 639-1 (e.g. de, en, en-us)");
16
17
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
17
- const SET_PAGE_TENANT_OVERRIDE_REASON = "SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
18
18
  // Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
19
19
  // Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
20
20
  // für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
@@ -36,23 +36,12 @@ export const setWrite = defineWriteHandler({
36
36
  }),
37
37
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
38
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.",
39
- escapeHatch: {
40
- grants: ["unsafeRaw"],
41
- reason: SET_PAGE_TENANT_OVERRIDE_REASON,
42
- },
43
39
  handler: async (event, ctx) => {
44
- const db = ctx.db;
45
40
  const override = event.payload.tenantIdOverride;
46
- const tenantId = override ?? event.user.tenantId;
47
- // override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
48
- 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
49
42
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
50
43
  // on both the existing-check and the executor's stream reads; re-scope to the target.
51
- // Safe: the override branch is SystemAdmin-gated above.
52
- const scopedDb = override !== undefined
53
- ? createTenantDb(db.unsafeRaw(), override, // @cast-boundary engine-bridge
54
- "tenant")
55
- : db;
44
+ const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
56
45
  const existing = await fetchOne(scopedDb, pagesTable, {
57
46
  tenantId,
58
47
  slug: event.payload.slug,
@@ -71,7 +60,7 @@ export const setWrite = defineWriteHandler({
71
60
  ogImage: event.payload.ogImage !== undefined ? event.payload.ogImage : existing.ogImage,
72
61
  published: event.payload.published !== undefined ? event.payload.published : existing.published,
73
62
  },
74
- }, executorUser, scopedDb);
63
+ }, event.user, scopedDb, { streamTenantId });
75
64
  if (!result.isSuccess)
76
65
  return result;
77
66
  return {
@@ -88,7 +77,7 @@ export const setWrite = defineWriteHandler({
88
77
  ogImage: event.payload.ogImage ?? null,
89
78
  published: event.payload.published ?? false,
90
79
  tenantId,
91
- }, executorUser, scopedDb);
80
+ }, event.user, scopedDb, { streamTenantId });
92
81
  if (!result.isSuccess)
93
82
  return result;
94
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,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { requireForTenant } from "../../shared/index.js";
4
5
  import { collectionStore, DEFAULT_COLLECTION_ACCESS, } from "./collection-shared.js";
5
6
  import { contentFormatSchema, folderSchema, localeSchema, slugSchema } from "./shared.js";
6
7
  // Upsert inside one collection. Mirrors set.write for text-blocks, but the
@@ -29,12 +30,11 @@ export function makeCollectionSetWrite(collection) {
29
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.`
30
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.`,
31
32
  handler: async (event, ctx) => {
32
- const db = ctx.db;
33
33
  const override = event.payload.tenantIdOverride;
34
34
  // @cast-boundary engine-payload — override is a zod-validated string, the
35
35
  // user's own tenantId is already TenantId-branded.
36
36
  const tenantId = (override ?? event.user.tenantId);
37
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
37
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
38
38
  // Scoped to the acting user even under a tenant override: a SystemAdmin
39
39
  // writing into another tenant still writes their own entry, never
40
40
  // someone else's signature.
@@ -59,7 +59,7 @@ export function makeCollectionSetWrite(collection) {
59
59
  contentFormat: event.payload.contentFormat,
60
60
  folder: event.payload.folder ?? null,
61
61
  },
62
- }, executorUser, db);
62
+ }, event.user, db, { streamTenantId });
63
63
  if (!result.isSuccess)
64
64
  return result;
65
65
  return {
@@ -78,7 +78,7 @@ export function makeCollectionSetWrite(collection) {
78
78
  folder: event.payload.folder ?? null,
79
79
  ...store.createDefaults(tenantId),
80
80
  ...owner,
81
- }, executorUser, db);
81
+ }, event.user, db, { streamTenantId });
82
82
  if (!result.isSuccess)
83
83
  return result;
84
84
  return {
@@ -1,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { requireForTenant } from "../../shared/index.js";
4
5
  import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants.js";
5
6
  import { templateResourcesTable } from "../table.js";
6
7
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.js";
@@ -42,15 +43,11 @@ export const setWrite = defineWriteHandler({
42
43
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
43
44
  description: "Creates or overwrites one content-tree resource addressed by slug, kind and locale, carrying a title and folder and going live on save with no draft stage; a SystemAdmin may target the system tenant, where shared legal and marketing copy lives.",
44
45
  handler: async (event, ctx) => {
45
- const db = ctx.db;
46
46
  const override = event.payload.tenantIdOverride;
47
47
  // @cast-boundary engine-payload — override is a zod-validated string, the
48
48
  // user's own tenantId is already TenantId-branded.
49
49
  const tenantId = (override ?? event.user.tenantId);
50
- // With an override the executor user has to move to the target tenant too,
51
- // otherwise the event-store stream lookup runs against event.user.tenantId
52
- // and reports a version conflict although the projection row exists.
53
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
50
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
54
51
  const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
55
52
  const existing = await fetchOne(db, templateResourcesTable, {
56
53
  tenantId,
@@ -87,7 +84,7 @@ export const setWrite = defineWriteHandler({
87
84
  contentFormat: fields.contentFormat,
88
85
  folder: fields.folder,
89
86
  },
90
- }, executorUser, db);
87
+ }, event.user, db, { streamTenantId });
91
88
  if (!result.isSuccess)
92
89
  return result;
93
90
  return {
@@ -95,7 +92,9 @@ export const setWrite = defineWriteHandler({
95
92
  data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
96
93
  };
97
94
  }
98
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
95
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
96
+ streamTenantId,
97
+ });
99
98
  if (!result.isSuccess)
100
99
  return result;
101
100
  return {
@@ -1,5 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { requireForTenant } from "../../shared/index.js";
3
4
  import { templateResourcesTable } from "../table.js";
4
5
  import { executor, upsertPayloadSchema } from "./shared.js";
5
6
  // System-Template anlegen/updaten. Liegt unter SYSTEM_TENANT_ID,
@@ -12,13 +13,10 @@ export const upsertSystemWrite = defineWriteHandler({
12
13
  access: { roles: ["SystemAdmin"] },
13
14
  description: "Creates or overwrites a system-default template under the system tenant with its full variable schema and linked resources, active straight away with no draft stage; SystemAdmin only, since tenants shadow these defaults through upsert-tenant instead.",
14
15
  handler: async (event, ctx) => {
15
- const db = ctx.db;
16
16
  // @cast-boundary engine-payload — SYSTEM_TENANT_ID is a UUID literal,
17
17
  // asserted to the branded TenantId (parseTenantId equivalent).
18
18
  const tenantId = SYSTEM_TENANT_ID;
19
- // The executor user must carry SYSTEM_TENANT as tenantId, otherwise the
20
- // event store looks up the stream under user.tenantId → conflict.
21
- const executorUser = { ...event.user, tenantId };
19
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
22
20
  const existing = await fetchOne(db, templateResourcesTable, {
23
21
  tenantId,
24
22
  slug: event.payload.slug,
@@ -41,7 +39,7 @@ export const upsertSystemWrite = defineWriteHandler({
41
39
  status: "active",
42
40
  };
43
41
  if (existing) {
44
- const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, executorUser, db);
42
+ const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, event.user, db, { streamTenantId });
45
43
  if (!result.isSuccess)
46
44
  return result;
47
45
  return {
@@ -49,7 +47,9 @@ export const upsertSystemWrite = defineWriteHandler({
49
47
  data: { id: String(existing.id), slug: event.payload.slug, isNew: false },
50
48
  };
51
49
  }
52
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
50
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
51
+ streamTenantId,
52
+ });
53
53
  if (!result.isSuccess)
54
54
  return result;
55
55
  // @cast-boundary db-row — executor.create returnt Record-row aus
@@ -2,6 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
5
6
  import { templateResourcesTable } from "../table.js";
6
7
  import { executor, upsertPayloadSchema } from "./shared.js";
7
8
  // Tenant-Override anlegen/updaten. Liegt unter event.user.tenantId,
@@ -18,7 +19,6 @@ export const upsertTenantWrite = defineWriteHandler({
18
19
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
19
20
  description: "Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live; it refuses a system-tenant target, which is what upsert-system is for.",
20
21
  handler: async (event, ctx) => {
21
- const db = ctx.db;
22
22
  const override = event.payload.tenantIdOverride;
23
23
  // upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
24
24
  // scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
@@ -34,7 +34,7 @@ export const upsertTenantWrite = defineWriteHandler({
34
34
  // event.user.tenantId schon TenantId-branded; union als TenantId casten
35
35
  // ist legit (override ist UUID-Format-validiert in schema).
36
36
  const tenantId = (override ?? event.user.tenantId);
37
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
37
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
38
38
  const existing = await fetchOne(db, templateResourcesTable, {
39
39
  tenantId,
40
40
  slug: event.payload.slug,
@@ -56,7 +56,7 @@ export const upsertTenantWrite = defineWriteHandler({
56
56
  status: event.payload.status,
57
57
  };
58
58
  if (existing) {
59
- const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, executorUser, db);
59
+ const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, event.user, db, { streamTenantId });
60
60
  if (!result.isSuccess)
61
61
  return result;
62
62
  return {
@@ -64,7 +64,9 @@ export const upsertTenantWrite = defineWriteHandler({
64
64
  data: { id: String(existing.id), slug: event.payload.slug, isNew: false },
65
65
  };
66
66
  }
67
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
67
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
68
+ streamTenantId,
69
+ });
68
70
  if (!result.isSuccess)
69
71
  return result;
70
72
  // @cast-boundary db-row — executor.create returnt Record-row aus
@@ -1,8 +1,8 @@
1
1
  import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import type { Redis } from "ioredis";
5
5
  export declare function cancelPendingInvitation(db: TenantDb, invitation: {
6
6
  readonly id: string;
7
7
  readonly version: number;
8
- }, actor: SessionUser, redis: Redis | undefined): Promise<WriteFailure | undefined>;
8
+ }, actor: SessionUser, redis: Redis | undefined, streamTenantId?: TenantId): Promise<WriteFailure | undefined>;
@@ -5,12 +5,12 @@ import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } fr
5
5
  const invitationExecutor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, { entityName: "tenant-invitation" });
6
6
  // Shared by cancel-invitation and remove-member so the cancel behavior
7
7
  // (status transition + token invalidation) cannot drift between them.
8
- export async function cancelPendingInvitation(db, invitation, actor, redis) {
8
+ export async function cancelPendingInvitation(db, invitation, actor, redis, streamTenantId) {
9
9
  const updateResult = await invitationExecutor.update({
10
10
  id: invitation.id,
11
11
  version: invitation.version,
12
12
  changes: { status: INVITATION_STATUS.cancelled },
13
- }, actor, db);
13
+ }, actor, db, { streamTenantId });
14
14
  if (!updateResult.isSuccess)
15
15
  return updateResult;
16
16
  // Delete the token from Redis (if still there). If Redis is
@@ -1,6 +1,6 @@
1
1
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor, } from "@cosmicdrift/kumiko-framework/db";
3
- import { createSystemUser, defineWriteHandler, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createSystemUser, defineWriteHandler, parseTenantId, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError, NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
6
6
  import * as z from "zod";
@@ -20,7 +20,7 @@ async function cancelPendingInvitationsOfUser(db, options) {
20
20
  const email = (await decryptStoredPii(user.email, "email", "tenant:remove-member")).toLowerCase();
21
21
  const pendingInvitations = await selectMany(db, tenantInvitationsTable, { tenantId: options.tenantId, email, status: INVITATION_STATUS.pending });
22
22
  for (const invitation of pendingInvitations) {
23
- const failure = await cancelPendingInvitation(db, invitation, options.actor, options.redis);
23
+ const failure = await cancelPendingInvitation(db, invitation, options.actor, options.redis, options.streamTenantId);
24
24
  if (failure)
25
25
  return failure;
26
26
  }
@@ -43,11 +43,14 @@ export const removeMemberWrite = defineWriteHandler({
43
43
  });
44
44
  }
45
45
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin manages memberships across tenants");
46
- const existing = await fetchOne(db, tenantMembershipsTable, {
47
- userId: event.payload.userId,
48
- tenantId: event.payload.tenantId,
49
- });
50
- if (!existing) {
46
+ const streamTenantId = parseTenantId(event.payload.tenantId);
47
+ const existing = streamTenantId === null
48
+ ? undefined
49
+ : await fetchOne(db, tenantMembershipsTable, {
50
+ userId: event.payload.userId,
51
+ tenantId: streamTenantId,
52
+ });
53
+ if (!existing || streamTenantId === null) {
51
54
  return writeFailure(new NotFoundError("membership", undefined, {
52
55
  i18nKey: "tenant.errors.membershipNotFound",
53
56
  i18nParams: { userId: event.payload.userId, tenantId: event.payload.tenantId },
@@ -75,14 +78,14 @@ export const removeMemberWrite = defineWriteHandler({
75
78
  });
76
79
  }
77
80
  const result = await executor.delete({ id: existing["id"] }, // @cast-boundary db-row
78
- event.user, db);
81
+ event.user, db, { streamTenantId });
79
82
  if (!result.isSuccess)
80
83
  return result;
81
- // Actor tenant = invitation tenant so the update hits the invitation's stream.
82
84
  const cancelFailure = await cancelPendingInvitationsOfUser(db, {
83
85
  userId: event.payload.userId,
84
86
  tenantId: event.payload.tenantId,
85
- actor: { ...event.user, tenantId: event.payload.tenantId },
87
+ actor: event.user,
88
+ streamTenantId,
86
89
  redis: ctx.redis,
87
90
  });
88
91
  if (cancelFailure !== undefined)
@@ -1,6 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
- import { access, assignableAppRolesOf, createSystemUser, defineWriteHandler, findForbiddenRoleAssignment, mergeAssignedRoles, SYSTEM_ROLE, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { access, assignableAppRolesOf, createSystemUser, defineWriteHandler, findForbiddenRoleAssignment, mayOverrideTenant, mergeAssignedRoles, parseTenantId, SYSTEM_ROLE, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
6
6
  import * as z from "zod";
@@ -17,7 +17,12 @@ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user"
17
17
  /** SystemAdmin may pass payload.tenantId (cross-tenant); the members actionForm
18
18
  * only sends userId+roles, so fall back to the active session tenant. */
19
19
  function resolveTargetTenantId(isSystem, payloadTenantId, sessionTenantId) {
20
- return isSystem ? (payloadTenantId ?? sessionTenantId) : sessionTenantId;
20
+ if (!isSystem || payloadTenantId === undefined)
21
+ return sessionTenantId;
22
+ return parseTenantId(payloadTenantId) ?? undefined;
23
+ }
24
+ function foreignStreamTenant(user, targetTenantId) {
25
+ return targetTenantId === user.tenantId ? undefined : targetTenantId;
21
26
  }
22
27
  // Editing your OWN roles keeps the current session: roles are re-derived per
23
28
  // request, so the new roles apply at once without a re-login. Other sessions
@@ -49,7 +54,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
49
54
  });
50
55
  }
51
56
  const isSystemActor = event.user.roles.includes(SYSTEM_ROLE);
52
- const isSystem = event.user.roles.includes("SystemAdmin") || isSystemActor;
57
+ const isSystem = mayOverrideTenant(event.user) || isSystemActor;
53
58
  const assignableAppRoles = assignableAppRolesOf(ctx.registry);
54
59
  const targetTenantId = resolveTargetTenantId(isSystem, event.payload.tenantId, event.user.tenantId);
55
60
  if (!targetTenantId) {
@@ -117,16 +122,14 @@ export const updateMemberRolesWrite = defineWriteHandler({
117
122
  if (revoker) {
118
123
  await ctx.writeAs(createSystemUser(targetTenantId), REVOKE_ALL_SESSIONS_QN, revokeSessionsPayload(event.payload.userId, targetTenantId, event.user));
119
124
  }
120
- // Stream tenant follows the actor via streamTenantFor — use the
121
- // membership tenant so SystemAdmin/cross-tenant and TenantAdmin paths
122
- // hit the same stream seedTenantMembership/invite-accept wrote.
123
- // Keep the real actor for audit metadata; only override tenantId so
124
- // streamTenantFor writes into the membership tenant (#2401).
125
+ // The membership stream lives on the membership's tenant (where seedTenantMembership/
126
+ // invite-accept wrote it); a cross-tenant operator addresses it without becoming a member.
127
+ // Same-tenant writes run on a tenant-mode db, which refuses a stream override.
125
128
  const result = await executor.update({
126
129
  id: row["id"], // @cast-boundary db-row
127
130
  version: row["version"], // @cast-boundary db-row
128
131
  changes: { roles: JSON.stringify(nextRoles) },
129
- }, { ...event.user, tenantId: targetTenantId }, db);
132
+ }, event.user, db, { streamTenantId: foreignStreamTenant(event.user, targetTenantId) });
130
133
  return withResponseData(result, {
131
134
  ...event.payload,
132
135
  roles: nextRoles,
@@ -1,7 +1,8 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { buildEntityTable, createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-framework/db";
2
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
5
6
  import { tierAssignmentAggregateId } from "../aggregate-id.js";
6
7
  import { TierAssignmentSources } from "../constants.js";
7
8
  import { tierAssignmentEntity } from "../entity.js";
@@ -9,13 +10,8 @@ import { tierAssignmentEntity } from "../entity.js";
9
10
  // Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
10
11
  // Gratis-Self-Upgrade).
11
12
  //
12
- // **Cross-tenant-Mechanik:** ein "system"-mode TenantDb auf den Ziel-Tenant legt
13
- // KEINEN Tenant-Filter an (tenant-db.ts:141 — mode==="system" überspringt ihn);
14
- // der executor-user wird ebenfalls auf den Ziel-Tenant gestellt, sonst landet das
15
- // Event im Stream des Admins (Memory feedback_event_store_tenant_consistency).
16
- // Das set.write-"override-user"-Muster trägt NICHT für beliebige Tenants — es
17
- // funktioniert nur für SYSTEM_TENANT_ID (immer im IN-Filter). Dies ist das
18
- // auto-default-Hook-Muster (feature.ts), generalisiert auf einen Request-Handler.
13
+ // Cross-tenant mechanics: ctx.forTenant(tenantId) yields a tenant-mode db bound to the target plus
14
+ // the streamTenantId, so the event lands in the target's stream with the operator as actor.
19
15
  //
20
16
  // `source: TierAssignmentSources.manual` marks the grant so the billing sync skips it.
21
17
  // Upsert: one aggregate per tenant (deterministic aggregate id).
@@ -31,7 +27,6 @@ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEn
31
27
  const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEntity, {
32
28
  entityName: "tier-assignment",
33
29
  });
34
- const SET_TENANT_TIER_REASON = "SystemAdmin assigns the tier of the tenant named in the payload";
35
30
  export function createSetTenantTierWrite(opts = {}) {
36
31
  return defineWriteHandler({
37
32
  name: "set-tenant-tier",
@@ -46,10 +41,6 @@ export function createSetTenantTierWrite(opts = {}) {
46
41
  }),
47
42
  access: { roles: ["SystemAdmin"] },
48
43
  agent: { risk: "high" },
49
- escapeHatch: {
50
- grants: ["unsafeRaw"],
51
- reason: SET_TENANT_TIER_REASON,
52
- },
53
44
  handler: async (event, ctx) => {
54
45
  // Without a commit sink (unit harness) there is no transaction to wait for.
55
46
  const afterCommit = (tenantId, tier) => {
@@ -60,9 +51,7 @@ export function createSetTenantTierWrite(opts = {}) {
60
51
  opts.onAssigned?.(tenantId, tier);
61
52
  };
62
53
  const tenantId = event.payload.tenantId; // @cast-boundary engine-bridge
63
- const rawDb = ctx.db.unsafeRaw();
64
- const tdb = createTenantDb(rawDb, tenantId, "system");
65
- const systemUser = { ...event.user, tenantId };
54
+ const { db: tdb, streamTenantId } = requireForTenant(ctx, tenantId);
66
55
  const tier = event.payload.tier;
67
56
  const existing = await fetchOne(tdb, tierAssignmentTable, { tenantId });
68
57
  if (existing) {
@@ -70,7 +59,7 @@ export function createSetTenantTierWrite(opts = {}) {
70
59
  id: existing.id,
71
60
  version: existing.version,
72
61
  changes: { tier, source: TierAssignmentSources.manual },
73
- }, systemUser, tdb);
62
+ }, event.user, tdb, { streamTenantId });
74
63
  if (!result.isSuccess)
75
64
  return result;
76
65
  afterCommit(tenantId, tier);
@@ -81,7 +70,7 @@ export function createSetTenantTierWrite(opts = {}) {
81
70
  tier,
82
71
  source: TierAssignmentSources.manual,
83
72
  tenantId,
84
- }, systemUser, tdb);
73
+ }, event.user, tdb, { streamTenantId });
85
74
  if (!result.isSuccess)
86
75
  return result;
87
76
  afterCommit(tenantId, tier);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.355.0",
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>",
@@ -516,12 +516,12 @@
516
516
  }
517
517
  },
518
518
  "dependencies": {
519
- "@cosmicdrift/kumiko-dispatcher-live": "0.355.0",
520
- "@cosmicdrift/kumiko-framework": "0.355.0",
521
- "@cosmicdrift/kumiko-headless": "0.355.0",
522
- "@cosmicdrift/kumiko-renderer": "0.355.0",
523
- "@cosmicdrift/kumiko-renderer-web": "0.355.0",
524
- "@cosmicdrift/kumiko-types": "0.355.0",
519
+ "@cosmicdrift/kumiko-dispatcher-live": "0.356.0",
520
+ "@cosmicdrift/kumiko-framework": "0.356.0",
521
+ "@cosmicdrift/kumiko-headless": "0.356.0",
522
+ "@cosmicdrift/kumiko-renderer": "0.356.0",
523
+ "@cosmicdrift/kumiko-renderer-web": "0.356.0",
524
+ "@cosmicdrift/kumiko-types": "0.356.0",
525
525
  "@mollie/api-client": "^4.5.0",
526
526
  "@node-rs/argon2": "^2.0.2",
527
527
  "@types/mailparser": "^3.4.6",
@@ -1050,8 +1050,8 @@
1050
1050
  ],
1051
1051
  "devDependencies": {
1052
1052
  "@testing-library/user-event": "^14.6.1",
1053
- "@cosmicdrift/kumiko-locale-de": "0.355.0",
1054
- "@cosmicdrift/kumiko-locale-es": "0.355.0",
1053
+ "@cosmicdrift/kumiko-locale-de": "0.356.0",
1054
+ "@cosmicdrift/kumiko-locale-es": "0.356.0",
1055
1055
  "jsqr": "^1.4.0"
1056
1056
  }
1057
1057
  }