@cosmicdrift/kumiko-samples 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.355.0",
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.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>",
@@ -11,6 +11,7 @@ import {
11
11
  writeFailure,
12
12
  } from "@cosmicdrift/kumiko-framework/errors";
13
13
  import * as z from "zod";
14
+ import { requireForTenant } from "../../shared/index.js";
14
15
  import {
15
16
  tenantComplianceProfileEntity,
16
17
  tenantComplianceProfileTable,
@@ -26,24 +27,16 @@ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantCompli
26
27
  // SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
27
28
  const profileKeySchema = z.enum(SELECTABLE_PROFILE_KEYS);
28
29
 
29
- // Tenant-Admin setzt Profile-Key + optional Override-JSON.
30
+ // Upsert: the first call inserts, later calls update, so repeating the same values
31
+ // is idempotent apart from the extra audit events.
30
32
  //
31
- // Upsert-Verhalten: erste Wahl insert, weitere update. Idempotent —
32
- // wer mit gleichen Werten zweimal aufruft, kriegt das gleiche Ergebnis
33
- // (modulo aktualisierte Audit-Events im Event-Store).
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.
34
37
  //
35
- // Cross-Tenant-Pfad: SystemAdmin kann via `tenantIdOverride` fuer einen
36
- // anderen Tenant schreiben (Plattform-Operator-Setup, Customer-
37
- // Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
38
- // executorUser.tenantId muss = ziel-tenant sein damit der event-store-
39
- // Stream-Lookup nicht miss → version_conflict gibt (Memory:
40
- // feedback_event_store_tenant_consistency).
41
- //
42
- // Validation:
43
- // - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
44
- // - override (optional) muss valides JSON-Object sein
45
- // - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
46
- // — 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.
47
40
  export const setProfileWrite = defineWriteHandler({
48
41
  name: "set-profile",
49
42
  schema: z.object({
@@ -57,9 +50,8 @@ export const setProfileWrite = defineWriteHandler({
57
50
  description:
58
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.",
59
52
  handler: async (event, ctx) => {
60
- const tenantOverride = event.payload.tenantIdOverride;
61
- const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
62
- 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);
63
55
 
64
56
  // Override-Validation: muss parseables JSON-Object sein UND dem
65
57
  // ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
@@ -89,7 +81,7 @@ export const setProfileWrite = defineWriteHandler({
89
81
  }
90
82
 
91
83
  // Upsert: existierenden Eintrag suchen
92
- const existing = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
84
+ const existing = (await fetchOne(db, tenantComplianceProfileTable, {
93
85
  tenantId: tenantId,
94
86
  })) as { id: string; version: number } | null; // @cast-boundary db-runner
95
87
 
@@ -103,8 +95,9 @@ export const setProfileWrite = defineWriteHandler({
103
95
  override: event.payload.override ?? null,
104
96
  },
105
97
  },
106
- executorUser,
107
- ctx.db,
98
+ event.user,
99
+ db,
100
+ { streamTenantId },
108
101
  );
109
102
  if (!result.isSuccess) return result;
110
103
  return {
@@ -119,8 +112,9 @@ export const setProfileWrite = defineWriteHandler({
119
112
  override: event.payload.override ?? null,
120
113
  tenantId,
121
114
  },
122
- executorUser,
123
- ctx.db,
115
+ event.user,
116
+ db,
117
+ { streamTenantId },
124
118
  );
125
119
  if (!result.isSuccess) return result;
126
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
  }
@@ -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, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
5
6
  import { type PageRow, pageEntity, pagesTable } from "../table.js";
6
7
 
7
8
  const slugSchema = z
@@ -18,9 +19,6 @@ const langSchema = z
18
19
 
19
20
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
20
21
 
21
- const SET_PAGE_TENANT_OVERRIDE_REASON =
22
- "SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
23
-
24
22
  // Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
25
23
  // Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
26
24
  // für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
@@ -43,29 +41,12 @@ export const setWrite = defineWriteHandler({
43
41
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
44
42
  description:
45
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.",
46
- escapeHatch: {
47
- grants: ["unsafeRaw"],
48
- reason: SET_PAGE_TENANT_OVERRIDE_REASON,
49
- },
50
44
  handler: async (event, ctx) => {
51
- const db = ctx.db;
52
45
  const override = event.payload.tenantIdOverride;
53
- const tenantId = override ?? event.user.tenantId;
54
- // override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
55
- const executorUser =
56
- override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
57
-
46
+ const tenantId = (override ?? event.user.tenantId) as TenantId; // @cast-boundary engine-bridge
58
47
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
59
48
  // on both the existing-check and the executor's stream reads; re-scope to the target.
60
- // Safe: the override branch is SystemAdmin-gated above.
61
- const scopedDb =
62
- override !== undefined
63
- ? createTenantDb(
64
- db.unsafeRaw(),
65
- override as TenantId, // @cast-boundary engine-bridge
66
- "tenant",
67
- )
68
- : db;
49
+ const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
69
50
  const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
70
51
  tenantId,
71
52
  slug: event.payload.slug,
@@ -89,8 +70,9 @@ export const setWrite = defineWriteHandler({
89
70
  event.payload.published !== undefined ? event.payload.published : existing.published,
90
71
  },
91
72
  },
92
- executorUser,
73
+ event.user,
93
74
  scopedDb,
75
+ { streamTenantId },
94
76
  );
95
77
  if (!result.isSuccess) return result;
96
78
  return {
@@ -110,8 +92,9 @@ export const setWrite = defineWriteHandler({
110
92
  published: event.payload.published ?? false,
111
93
  tenantId,
112
94
  },
113
- executorUser,
95
+ event.user,
114
96
  scopedDb,
97
+ { streamTenantId },
115
98
  );
116
99
  if (!result.isSuccess) return result;
117
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
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import * as z from "zod";
8
+ import { requireForTenant } from "../../shared/index.js";
8
9
  import {
9
10
  type CollectionEntryRow,
10
11
  collectionStore,
@@ -39,12 +40,11 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
39
40
  ? `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.`
40
41
  : `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.`,
41
42
  handler: async (event, ctx) => {
42
- const db = ctx.db;
43
43
  const override = event.payload.tenantIdOverride;
44
44
  // @cast-boundary engine-payload — override is a zod-validated string, the
45
45
  // user's own tenantId is already TenantId-branded.
46
46
  const tenantId = (override ?? event.user.tenantId) as TenantId;
47
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
47
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
48
48
  // Scoped to the acting user even under a tenant override: a SystemAdmin
49
49
  // writing into another tenant still writes their own entry, never
50
50
  // someone else's signature.
@@ -73,8 +73,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
73
73
  folder: event.payload.folder ?? null,
74
74
  },
75
75
  },
76
- executorUser,
76
+ event.user,
77
77
  db,
78
+ { streamTenantId },
78
79
  );
79
80
  if (!result.isSuccess) return result;
80
81
  return {
@@ -96,8 +97,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
96
97
  ...store.createDefaults(tenantId),
97
98
  ...owner,
98
99
  },
99
- executorUser,
100
+ event.user,
100
101
  db,
102
+ { streamTenantId },
101
103
  );
102
104
  if (!result.isSuccess) return result;
103
105
  return {
@@ -5,6 +5,7 @@ import {
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import * as z from "zod";
8
+ import { requireForTenant } from "../../shared/index.js";
8
9
  import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants.js";
9
10
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
10
11
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.js";
@@ -48,15 +49,11 @@ export const setWrite = defineWriteHandler({
48
49
  description:
49
50
  "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.",
50
51
  handler: async (event, ctx) => {
51
- const db = ctx.db;
52
52
  const override = event.payload.tenantIdOverride;
53
53
  // @cast-boundary engine-payload — override is a zod-validated string, the
54
54
  // user's own tenantId is already TenantId-branded.
55
55
  const tenantId = (override ?? event.user.tenantId) as TenantId;
56
- // With an override the executor user has to move to the target tenant too,
57
- // otherwise the event-store stream lookup runs against event.user.tenantId
58
- // and reports a version conflict although the projection row exists.
59
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
56
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
60
57
 
61
58
  const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
62
59
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
@@ -98,8 +95,9 @@ export const setWrite = defineWriteHandler({
98
95
  folder: fields.folder,
99
96
  },
100
97
  },
101
- executorUser,
98
+ event.user,
102
99
  db,
100
+ { streamTenantId },
103
101
  );
104
102
  if (!result.isSuccess) return result;
105
103
  return {
@@ -108,7 +106,9 @@ export const setWrite = defineWriteHandler({
108
106
  };
109
107
  }
110
108
 
111
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
109
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
110
+ streamTenantId,
111
+ });
112
112
  if (!result.isSuccess) return result;
113
113
  return {
114
114
  isSuccess: true as const,
@@ -4,6 +4,7 @@ import {
4
4
  SYSTEM_TENANT_ID,
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { requireForTenant } from "../../shared/index.js";
7
8
  import type { TemplateResourceRow } from "../table.js";
8
9
  import { templateResourcesTable } from "../table.js";
9
10
  import { executor, upsertPayloadSchema } from "./shared.js";
@@ -19,13 +20,10 @@ export const upsertSystemWrite = defineWriteHandler({
19
20
  description:
20
21
  "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.",
21
22
  handler: async (event, ctx) => {
22
- const db = ctx.db;
23
23
  // @cast-boundary engine-payload — SYSTEM_TENANT_ID is a UUID literal,
24
24
  // asserted to the branded TenantId (parseTenantId equivalent).
25
25
  const tenantId = SYSTEM_TENANT_ID as TenantId;
26
- // The executor user must carry SYSTEM_TENANT as tenantId, otherwise the
27
- // event store looks up the stream under user.tenantId → conflict.
28
- const executorUser = { ...event.user, tenantId };
26
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
29
27
 
30
28
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
31
29
  tenantId,
@@ -53,8 +51,9 @@ export const upsertSystemWrite = defineWriteHandler({
53
51
  if (existing) {
54
52
  const result = await executor.update(
55
53
  { id: existing.id, version: existing.version, changes: fields },
56
- executorUser,
54
+ event.user,
57
55
  db,
56
+ { streamTenantId },
58
57
  );
59
58
  if (!result.isSuccess) return result;
60
59
  return {
@@ -63,7 +62,9 @@ export const upsertSystemWrite = defineWriteHandler({
63
62
  };
64
63
  }
65
64
 
66
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
65
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
66
+ streamTenantId,
67
+ });
67
68
  if (!result.isSuccess) return result;
68
69
  // @cast-boundary db-row — executor.create returnt Record-row aus
69
70
  // INSERT RETURNING; shape { id } ist garantiert weil PK in der
@@ -6,6 +6,7 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
+ import { requireForTenant } from "../../shared/index.js";
9
10
  import type { TemplateResourceRow } from "../table.js";
10
11
  import { templateResourcesTable } from "../table.js";
11
12
  import { executor, upsertPayloadSchema } from "./shared.js";
@@ -25,7 +26,6 @@ export const upsertTenantWrite = defineWriteHandler({
25
26
  description:
26
27
  "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.",
27
28
  handler: async (event, ctx) => {
28
- const db = ctx.db;
29
29
  const override = event.payload.tenantIdOverride;
30
30
  // upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
31
31
  // scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
@@ -43,7 +43,7 @@ export const upsertTenantWrite = defineWriteHandler({
43
43
  // event.user.tenantId schon TenantId-branded; union als TenantId casten
44
44
  // ist legit (override ist UUID-Format-validiert in schema).
45
45
  const tenantId = (override ?? event.user.tenantId) as TenantId;
46
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
46
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
47
47
 
48
48
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
49
49
  tenantId,
@@ -70,8 +70,9 @@ export const upsertTenantWrite = defineWriteHandler({
70
70
  if (existing) {
71
71
  const result = await executor.update(
72
72
  { id: existing.id, version: existing.version, changes: fields },
73
- executorUser,
73
+ event.user,
74
74
  db,
75
+ { streamTenantId },
75
76
  );
76
77
  if (!result.isSuccess) return result;
77
78
  return {
@@ -80,7 +81,9 @@ export const upsertTenantWrite = defineWriteHandler({
80
81
  };
81
82
  }
82
83
 
83
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
84
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
85
+ streamTenantId,
86
+ });
84
87
  if (!result.isSuccess) return result;
85
88
  // @cast-boundary db-row — executor.create returnt Record-row aus
86
89
  // INSERT RETURNING; shape { id } ist garantiert weil PK in der
@@ -1,5 +1,5 @@
1
1
  import { createEventStoreExecutor, 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
  // kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
@@ -23,6 +23,7 @@ export async function cancelPendingInvitation(
23
23
  invitation: { readonly id: string; readonly version: number },
24
24
  actor: SessionUser,
25
25
  redis: Redis | undefined,
26
+ streamTenantId?: TenantId,
26
27
  ): Promise<WriteFailure | undefined> {
27
28
  const updateResult = await invitationExecutor.update(
28
29
  {
@@ -32,6 +33,7 @@ export async function cancelPendingInvitation(
32
33
  },
33
34
  actor,
34
35
  db,
36
+ { streamTenantId },
35
37
  );
36
38
  if (!updateResult.isSuccess) return updateResult;
37
39
 
@@ -7,7 +7,9 @@ import {
7
7
  import {
8
8
  createSystemUser,
9
9
  defineWriteHandler,
10
+ parseTenantId,
10
11
  type SessionUser,
12
+ type TenantId,
11
13
  withResponseData,
12
14
  } from "@cosmicdrift/kumiko-framework/engine";
13
15
  import {
@@ -34,6 +36,7 @@ type PendingInvitationCancel = {
34
36
  readonly userId: string;
35
37
  readonly tenantId: string;
36
38
  readonly actor: SessionUser;
39
+ readonly streamTenantId: TenantId;
37
40
  readonly redis: Redis | undefined;
38
41
  };
39
42
 
@@ -51,7 +54,13 @@ async function cancelPendingInvitationsOfUser(
51
54
  { tenantId: options.tenantId, email, status: INVITATION_STATUS.pending },
52
55
  );
53
56
  for (const invitation of pendingInvitations) {
54
- const failure = await cancelPendingInvitation(db, invitation, options.actor, options.redis);
57
+ const failure = await cancelPendingInvitation(
58
+ db,
59
+ invitation,
60
+ options.actor,
61
+ options.redis,
62
+ options.streamTenantId,
63
+ );
55
64
  if (failure) return failure;
56
65
  }
57
66
  return undefined;
@@ -79,11 +88,15 @@ export const removeMemberWrite = defineWriteHandler({
79
88
  const db = ctx.systemDb.acknowledgeCrossTenant(
80
89
  "SystemAdmin manages memberships across tenants",
81
90
  );
82
- const existing = await fetchOne(db, tenantMembershipsTable, {
83
- userId: event.payload.userId,
84
- tenantId: event.payload.tenantId,
85
- });
86
- if (!existing) {
91
+ const streamTenantId = parseTenantId(event.payload.tenantId);
92
+ const existing =
93
+ streamTenantId === null
94
+ ? undefined
95
+ : await fetchOne(db, tenantMembershipsTable, {
96
+ userId: event.payload.userId,
97
+ tenantId: streamTenantId,
98
+ });
99
+ if (!existing || streamTenantId === null) {
87
100
  return writeFailure(
88
101
  new NotFoundError("membership", undefined, {
89
102
  i18nKey: "tenant.errors.membershipNotFound",
@@ -125,14 +138,15 @@ export const removeMemberWrite = defineWriteHandler({
125
138
  { id: (existing as DbRow)["id"] as string }, // @cast-boundary db-row
126
139
  event.user,
127
140
  db,
141
+ { streamTenantId },
128
142
  );
129
143
  if (!result.isSuccess) return result;
130
144
 
131
- // Actor tenant = invitation tenant so the update hits the invitation's stream.
132
145
  const cancelFailure = await cancelPendingInvitationsOfUser(db, {
133
146
  userId: event.payload.userId,
134
147
  tenantId: event.payload.tenantId,
135
- actor: { ...event.user, tenantId: event.payload.tenantId },
148
+ actor: event.user,
149
+ streamTenantId,
136
150
  redis: ctx.redis,
137
151
  });
138
152
  if (cancelFailure !== undefined) return cancelFailure;
@@ -6,8 +6,12 @@ import {
6
6
  createSystemUser,
7
7
  defineWriteHandler,
8
8
  findForbiddenRoleAssignment,
9
+ mayOverrideTenant,
9
10
  mergeAssignedRoles,
11
+ parseTenantId,
12
+ type SessionUser,
10
13
  SYSTEM_ROLE,
14
+ type TenantId,
11
15
  withResponseData,
12
16
  } from "@cosmicdrift/kumiko-framework/engine";
13
17
  import {
@@ -40,9 +44,14 @@ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user"
40
44
  function resolveTargetTenantId(
41
45
  isSystem: boolean,
42
46
  payloadTenantId: string | undefined,
43
- sessionTenantId: string | undefined,
44
- ): string | undefined {
45
- return isSystem ? (payloadTenantId ?? sessionTenantId) : sessionTenantId;
47
+ sessionTenantId: TenantId,
48
+ ): TenantId | undefined {
49
+ if (!isSystem || payloadTenantId === undefined) return sessionTenantId;
50
+ return parseTenantId(payloadTenantId) ?? undefined;
51
+ }
52
+
53
+ function foreignStreamTenant(user: SessionUser, targetTenantId: TenantId): TenantId | undefined {
54
+ return targetTenantId === user.tenantId ? undefined : targetTenantId;
46
55
  }
47
56
 
48
57
  // Editing your OWN roles keeps the current session: roles are re-derived per
@@ -50,7 +59,7 @@ function resolveTargetTenantId(
50
59
  // of that user and any foreign edit still revoke all.
51
60
  function revokeSessionsPayload(
52
61
  targetUserId: string,
53
- targetTenantId: string,
62
+ targetTenantId: TenantId,
54
63
  actor: { readonly id: string; readonly sid?: string | undefined },
55
64
  ): { userId: string; tenantId: string; exceptSessionId?: string } {
56
65
  const payload = { userId: targetUserId, tenantId: targetTenantId };
@@ -82,7 +91,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
82
91
  }
83
92
 
84
93
  const isSystemActor = event.user.roles.includes(SYSTEM_ROLE);
85
- const isSystem = event.user.roles.includes("SystemAdmin") || isSystemActor;
94
+ const isSystem = mayOverrideTenant(event.user) || isSystemActor;
86
95
  const assignableAppRoles = assignableAppRolesOf(ctx.registry);
87
96
  const targetTenantId = resolveTargetTenantId(
88
97
  isSystem,
@@ -185,19 +194,18 @@ export const updateMemberRolesWrite = defineWriteHandler({
185
194
  );
186
195
  }
187
196
 
188
- // Stream tenant follows the actor via streamTenantFor — use the
189
- // membership tenant so SystemAdmin/cross-tenant and TenantAdmin paths
190
- // hit the same stream seedTenantMembership/invite-accept wrote.
191
- // Keep the real actor for audit metadata; only override tenantId so
192
- // streamTenantFor writes into the membership tenant (#2401).
197
+ // The membership stream lives on the membership's tenant (where seedTenantMembership/
198
+ // invite-accept wrote it); a cross-tenant operator addresses it without becoming a member.
199
+ // Same-tenant writes run on a tenant-mode db, which refuses a stream override.
193
200
  const result = await executor.update(
194
201
  {
195
202
  id: row["id"] as string, // @cast-boundary db-row
196
203
  version: row["version"] as number, // @cast-boundary db-row
197
204
  changes: { roles: JSON.stringify(nextRoles) },
198
205
  },
199
- { ...event.user, tenantId: targetTenantId },
206
+ event.user,
200
207
  db,
208
+ { streamTenantId: foreignStreamTenant(event.user, targetTenantId) },
201
209
  );
202
210
 
203
211
  return withResponseData(result, {
@@ -1,11 +1,8 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import {
3
- buildEntityTable,
4
- createEventStoreExecutor,
5
- createTenantDb,
6
- } from "@cosmicdrift/kumiko-framework/db";
2
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
3
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
9
6
  import { tierAssignmentAggregateId } from "../aggregate-id.js";
10
7
  import { TierAssignmentSources } from "../constants.js";
11
8
  import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
@@ -14,13 +11,8 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
14
11
  // Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
15
12
  // Gratis-Self-Upgrade).
16
13
  //
17
- // **Cross-tenant-Mechanik:** ein "system"-mode TenantDb auf den Ziel-Tenant legt
18
- // KEINEN Tenant-Filter an (tenant-db.ts:141 — mode==="system" überspringt ihn);
19
- // der executor-user wird ebenfalls auf den Ziel-Tenant gestellt, sonst landet das
20
- // Event im Stream des Admins (Memory feedback_event_store_tenant_consistency).
21
- // Das set.write-"override-user"-Muster trägt NICHT für beliebige Tenants — es
22
- // funktioniert nur für SYSTEM_TENANT_ID (immer im IN-Filter). Dies ist das
23
- // auto-default-Hook-Muster (feature.ts), generalisiert auf einen Request-Handler.
14
+ // Cross-tenant mechanics: ctx.forTenant(tenantId) yields a tenant-mode db bound to the target plus
15
+ // the streamTenantId, so the event lands in the target's stream with the operator as actor.
24
16
  //
25
17
  // `source: TierAssignmentSources.manual` marks the grant so the billing sync skips it.
26
18
  // Upsert: one aggregate per tenant (deterministic aggregate id).
@@ -38,8 +30,6 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
38
30
  entityName: "tier-assignment",
39
31
  });
40
32
 
41
- const SET_TENANT_TIER_REASON = "SystemAdmin assigns the tier of the tenant named in the payload";
42
-
43
33
  export type SetTenantTierOptions = {
44
34
  /** Runs after the write commits so feature.ts can update the resolver cache
45
35
  * and notify the other processes. Without a tierMap there is no resolver
@@ -68,10 +58,6 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
68
58
  }),
69
59
  access: { roles: ["SystemAdmin"] },
70
60
  agent: { risk: "high" },
71
- escapeHatch: {
72
- grants: ["unsafeRaw"],
73
- reason: SET_TENANT_TIER_REASON,
74
- },
75
61
  handler: async (event, ctx) => {
76
62
  // Without a commit sink (unit harness) there is no transaction to wait for.
77
63
  const afterCommit = (tenantId: TenantId, tier: string): void => {
@@ -80,9 +66,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
80
66
  else opts.onAssigned?.(tenantId, tier);
81
67
  };
82
68
  const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
83
- const rawDb = ctx.db.unsafeRaw();
84
- const tdb = createTenantDb(rawDb, tenantId, "system");
85
- const systemUser = { ...event.user, tenantId };
69
+ const { db: tdb, streamTenantId } = requireForTenant(ctx, tenantId);
86
70
  const tier = event.payload.tier;
87
71
 
88
72
  const existing = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
@@ -94,8 +78,9 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
94
78
  version: existing.version,
95
79
  changes: { tier, source: TierAssignmentSources.manual },
96
80
  },
97
- systemUser,
81
+ event.user,
98
82
  tdb,
83
+ { streamTenantId },
99
84
  );
100
85
  if (!result.isSuccess) return result;
101
86
  afterCommit(tenantId, tier);
@@ -109,8 +94,9 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
109
94
  source: TierAssignmentSources.manual,
110
95
  tenantId,
111
96
  },
112
- systemUser,
97
+ event.user,
113
98
  tdb,
99
+ { streamTenantId },
114
100
  );
115
101
  if (!result.isSuccess) return result;
116
102
  afterCommit(tenantId, tier);