@cosmicdrift/kumiko-samples 0.354.1 → 0.356.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +2 -0
  4. package/packages/bundled-features/src/agent-tools/types.ts +2 -0
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +2 -0
  6. package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +8 -7
  7. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +7 -12
  8. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +13 -17
  9. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +12 -8
  10. package/packages/bundled-features/src/compliance-profiles/handlers/set-profile.write.ts +19 -36
  11. package/packages/bundled-features/src/custom-fields/handlers/define-system-field.write.ts +10 -17
  12. package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +5 -2
  13. package/packages/bundled-features/src/custom-fields/lib/define-or-resurrect.ts +12 -3
  14. package/packages/bundled-features/src/delivery/delivery-service.ts +1 -1
  15. package/packages/bundled-features/src/managed-pages/handlers/by-tenant-published.query.ts +1 -10
  16. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +9 -37
  17. package/packages/bundled-features/src/shared/index.ts +1 -0
  18. package/packages/bundled-features/src/shared/require-for-tenant.ts +18 -0
  19. package/packages/bundled-features/src/template-resolver/handlers/by-slug.query.ts +1 -10
  20. package/packages/bundled-features/src/template-resolver/handlers/by-tenant.query.ts +1 -10
  21. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +0 -7
  22. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +0 -7
  23. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -12
  24. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -15
  25. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +7 -6
  26. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +7 -11
  27. package/packages/bundled-features/src/template-resolver/seeding.ts +1 -2
  28. package/packages/bundled-features/src/tenant/cancel-pending-invitation.ts +3 -1
  29. package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +22 -8
  30. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +19 -11
  31. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
  32. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  33. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -0
  34. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
  35. package/packages/bundled-features/src/user-data-rights/feature.ts +2 -0
  36. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +5 -0
  37. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +11 -1
  38. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +4 -1
@@ -2,7 +2,6 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
3
  import {
4
4
  type ContentCollectionDefinition,
5
- crossTenantOverrideDenied,
6
5
  defineQueryHandler,
7
6
  } from "@cosmicdrift/kumiko-framework/engine";
8
7
  import * as z from "zod";
@@ -36,12 +35,6 @@ export function makeCollectionListQuery(collection: ContentCollectionDefinition)
36
35
  : `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.`,
37
36
  handler: async (query, ctx) => {
38
37
  const override = query.payload.tenantIdOverride;
39
- const overrideDenied = crossTenantOverrideDenied(
40
- query.user,
41
- override,
42
- "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
43
- );
44
- if (overrideDenied) throw overrideDenied;
45
38
  const tenantId = override ?? query.user.tenantId;
46
39
  const rows = castTenantRows<CollectionEntryRow>(
47
40
  await selectMany(ctx.db, store.table, {
@@ -1,12 +1,11 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  type ContentCollectionDefinition,
4
- crossTenantOverrideDenied,
5
4
  defineWriteHandler,
6
5
  type TenantId,
7
6
  } from "@cosmicdrift/kumiko-framework/engine";
8
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
7
  import * as z from "zod";
8
+ import { requireForTenant } from "../../shared/index.js";
10
9
  import {
11
10
  type CollectionEntryRow,
12
11
  collectionStore,
@@ -41,18 +40,11 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
41
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.`
42
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.`,
43
42
  handler: async (event, ctx) => {
44
- const db = ctx.db;
45
43
  const override = event.payload.tenantIdOverride;
46
- const overrideDenied = crossTenantOverrideDenied(
47
- event.user,
48
- override,
49
- "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
50
- );
51
- if (overrideDenied) return writeFailure(overrideDenied);
52
44
  // @cast-boundary engine-payload — override is a zod-validated string, the
53
45
  // user's own tenantId is already TenantId-branded.
54
46
  const tenantId = (override ?? event.user.tenantId) as TenantId;
55
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
47
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
56
48
  // Scoped to the acting user even under a tenant override: a SystemAdmin
57
49
  // writing into another tenant still writes their own entry, never
58
50
  // someone else's signature.
@@ -81,8 +73,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
81
73
  folder: event.payload.folder ?? null,
82
74
  },
83
75
  },
84
- executorUser,
76
+ event.user,
85
77
  db,
78
+ { streamTenantId },
86
79
  );
87
80
  if (!result.isSuccess) return result;
88
81
  return {
@@ -104,8 +97,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
104
97
  ...store.createDefaults(tenantId),
105
98
  ...owner,
106
99
  },
107
- executorUser,
100
+ event.user,
108
101
  db,
102
+ { streamTenantId },
109
103
  );
110
104
  if (!result.isSuccess) return result;
111
105
  return {
@@ -1,12 +1,11 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
- crossTenantOverrideDenied,
4
3
  defineWriteHandler,
5
4
  SYSTEM_TENANT_ID,
6
5
  type TenantId,
7
6
  } from "@cosmicdrift/kumiko-framework/engine";
8
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
7
  import * as z from "zod";
8
+ import { requireForTenant } from "../../shared/index.js";
10
9
  import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants.js";
11
10
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
12
11
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.js";
@@ -50,21 +49,11 @@ export const setWrite = defineWriteHandler({
50
49
  description:
51
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.",
52
51
  handler: async (event, ctx) => {
53
- const db = ctx.db;
54
52
  const override = event.payload.tenantIdOverride;
55
- const overrideDenied = crossTenantOverrideDenied(
56
- event.user,
57
- override,
58
- "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
59
- );
60
- if (overrideDenied) return writeFailure(overrideDenied);
61
53
  // @cast-boundary engine-payload — override is a zod-validated string, the
62
54
  // user's own tenantId is already TenantId-branded.
63
55
  const tenantId = (override ?? event.user.tenantId) as TenantId;
64
- // With an override the executor user has to move to the target tenant too,
65
- // otherwise the event-store stream lookup runs against event.user.tenantId
66
- // and reports a version conflict although the projection row exists.
67
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
56
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
68
57
 
69
58
  const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
70
59
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
@@ -106,8 +95,9 @@ export const setWrite = defineWriteHandler({
106
95
  folder: fields.folder,
107
96
  },
108
97
  },
109
- executorUser,
98
+ event.user,
110
99
  db,
100
+ { streamTenantId },
111
101
  );
112
102
  if (!result.isSuccess) return result;
113
103
  return {
@@ -116,7 +106,9 @@ export const setWrite = defineWriteHandler({
116
106
  };
117
107
  }
118
108
 
119
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
109
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
110
+ streamTenantId,
111
+ });
120
112
  if (!result.isSuccess) return result;
121
113
  return {
122
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
@@ -1,12 +1,12 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
- crossTenantOverrideDenied,
4
3
  defineWriteHandler,
5
4
  SYSTEM_TENANT_ID,
6
5
  type TenantId,
7
6
  } from "@cosmicdrift/kumiko-framework/engine";
8
7
  import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
8
  import * as z from "zod";
9
+ import { requireForTenant } from "../../shared/index.js";
10
10
  import type { TemplateResourceRow } from "../table.js";
11
11
  import { templateResourcesTable } from "../table.js";
12
12
  import { executor, upsertPayloadSchema } from "./shared.js";
@@ -26,14 +26,7 @@ export const upsertTenantWrite = defineWriteHandler({
26
26
  description:
27
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.",
28
28
  handler: async (event, ctx) => {
29
- const db = ctx.db;
30
29
  const override = event.payload.tenantIdOverride;
31
- const overrideDenied = crossTenantOverrideDenied(
32
- event.user,
33
- override,
34
- "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
35
- );
36
- if (overrideDenied) return writeFailure(overrideDenied);
37
30
  // upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
38
31
  // scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
39
32
  // (Resolver-Logik trennt sauber zwischen system+tenant). SystemAdmin muss
@@ -50,7 +43,7 @@ export const upsertTenantWrite = defineWriteHandler({
50
43
  // event.user.tenantId schon TenantId-branded; union als TenantId casten
51
44
  // ist legit (override ist UUID-Format-validiert in schema).
52
45
  const tenantId = (override ?? event.user.tenantId) as TenantId;
53
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
46
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
54
47
 
55
48
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
56
49
  tenantId,
@@ -77,8 +70,9 @@ export const upsertTenantWrite = defineWriteHandler({
77
70
  if (existing) {
78
71
  const result = await executor.update(
79
72
  { id: existing.id, version: existing.version, changes: fields },
80
- executorUser,
73
+ event.user,
81
74
  db,
75
+ { streamTenantId },
82
76
  );
83
77
  if (!result.isSuccess) return result;
84
78
  return {
@@ -87,7 +81,9 @@ export const upsertTenantWrite = defineWriteHandler({
87
81
  };
88
82
  }
89
83
 
90
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
84
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
85
+ streamTenantId,
86
+ });
91
87
  if (!result.isSuccess) return result;
92
88
  // @cast-boundary db-row — executor.create returnt Record-row aus
93
89
  // INSERT RETURNING; shape { id } ist garantiert weil PK in der
@@ -51,8 +51,7 @@ async function resolveExistingForEventStoreSeed(
51
51
  `seed orphan check: projection ${id} has no events for tenant ${tenantId} but unscoped stream version is ${unscoped} — refusing delete/recreate to avoid wiping a live stream`,
52
52
  );
53
53
  }
54
- // @cast-boundary orphan projection cleanup: empty stream, no executor verb;
55
- // plain table ident trips guard-direct-entity-writes (infra EXCLUDE follow-up).
54
+ // The cast strips the executor-only brand that WritableTable rejects; the orphan row has no events, so no executor verb applies.
56
55
  await deleteMany(tdb, templateResourcesTable as EntityTableMeta, { id, tenantId });
57
56
  return null;
58
57
  }
@@ -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, {
@@ -155,6 +155,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
155
155
  fileProviderResolver: ctx._fileProviderResolver,
156
156
  searchAdapter: ctx.searchAdapter,
157
157
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
158
+ escapeHatchAuditLog: ctx.log,
158
159
  actor: ctx.systemUser.id,
159
160
  });
160
161
  },
@@ -14,6 +14,7 @@ import {
14
14
  VersionConflictError,
15
15
  } from "@cosmicdrift/kumiko-framework/event-store";
16
16
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
17
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
17
18
  import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
18
19
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
19
20
  import { tenantEntity, tenantTable } from "../tenant/index.js";
@@ -283,6 +284,7 @@ export async function runNextDestructionStage(args: {
283
284
  readonly fileProviderResolver?: FileProviderResolver;
284
285
  readonly searchAdapter?: SearchAdapter;
285
286
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
287
+ readonly escapeHatchAuditLog?: Logger;
286
288
  readonly actor?: string;
287
289
  }): Promise<StageTickResult> {
288
290
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
@@ -308,6 +310,7 @@ export async function runNextDestructionStage(args: {
308
310
  fileProviderResolver: args.fileProviderResolver,
309
311
  searchAdapter: args.searchAdapter,
310
312
  escapeHatchAuditSink: args.escapeHatchAuditSink,
313
+ escapeHatchAuditLog: args.escapeHatchAuditLog,
311
314
  actor: args.actor,
312
315
  deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
313
316
  };
@@ -410,6 +413,7 @@ export async function runTenantDestructionSweep(args: {
410
413
  readonly fileProviderResolver?: FileProviderResolver;
411
414
  readonly searchAdapter?: SearchAdapter;
412
415
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
416
+ readonly escapeHatchAuditLog?: Logger;
413
417
  readonly actor?: string;
414
418
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
415
419
  const T = getTemporal();
@@ -457,6 +461,7 @@ export async function runTenantDestructionSweep(args: {
457
461
  fileProviderResolver: args.fileProviderResolver,
458
462
  searchAdapter: args.searchAdapter,
459
463
  escapeHatchAuditSink: args.escapeHatchAuditSink,
464
+ escapeHatchAuditLog: args.escapeHatchAuditLog,
460
465
  actor: args.actor,
461
466
  });
462
467
  if (!result.error && !result.halted) advanced++;
@@ -27,6 +27,7 @@ import {
27
27
  type TenantResourceExtensionName,
28
28
  } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
30
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
30
31
  import {
31
32
  createEscapeHatchReporter,
32
33
  UNATTRIBUTED_ACTOR,
@@ -61,6 +62,7 @@ export type DestructionStageCtx = {
61
62
  // systemUser.id); runTenantDataHooks uses them to attribute+audit any
62
63
  // EXT_TENANT_DATA usage's declared escapeHatch.
63
64
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
65
+ readonly escapeHatchAuditLog?: Logger;
64
66
  readonly actor?: string;
65
67
  // Epoch ms the stage must hand back by returning { done: false }.
66
68
  readonly deadlineAt: number;
@@ -136,6 +138,7 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<StageRunOut
136
138
  tenantId: ctx.tenantId,
137
139
  actor: ctx.actor ?? UNATTRIBUTED_ACTOR,
138
140
  sink: ctx.escapeHatchAuditSink,
141
+ log: ctx.escapeHatchAuditLog ?? createFallbackLogger("tenant-lifecycle"),
139
142
  });
140
143
  const hookCtx: TenantDataHookCtx = {
141
144
  db: createTenantDb(ctx.db, ctx.tenantId, "tenant", undefined, undefined, undefined, {
@@ -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);
@@ -663,6 +663,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
663
663
  appExportDownloadUrl: opts.appExportDownloadUrl,
664
664
  }),
665
665
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
666
+ escapeHatchAuditLog: ctx.log,
666
667
  actor: ctx.systemUser.id,
667
668
  metrics: exportMetrics,
668
669
  });
@@ -732,6 +733,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
732
733
  }),
733
734
  ...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
734
735
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
736
+ escapeHatchAuditLog: ctx.log,
735
737
  actor: ctx.systemUser.id,
736
738
  });
737
739
 
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
1
2
  // Async Export-Job Worker (S2.U3 Atom 3b) — pure Pipeline-Function.
2
3
  //
3
4
  // Spec: docs/plans/architecture/user-data-rights.md "Async Export-Pipeline".
@@ -176,6 +177,7 @@ export interface RunExportJobsArgs {
176
177
  // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
177
178
  // systemUser.id); threaded into runUserExport's per-hook TenantDb.
178
179
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
180
+ readonly escapeHatchAuditLog?: Logger;
179
181
  readonly actor?: string;
180
182
 
181
183
  /**
@@ -248,6 +250,7 @@ export async function runExportJobs(args: RunExportJobsArgs): Promise<RunExportJ
248
250
  now,
249
251
  job,
250
252
  escapeHatchAuditSink: args.escapeHatchAuditSink,
253
+ escapeHatchAuditLog: args.escapeHatchAuditLog,
251
254
  actor: args.actor,
252
255
  });
253
256
  if (outcome.kind === "done") {
@@ -369,6 +372,7 @@ async function processJob(args: {
369
372
  now: Instant;
370
373
  job: JobRow;
371
374
  escapeHatchAuditSink?: EscapeHatchAuditSink;
375
+ escapeHatchAuditLog?: Logger;
372
376
  actor?: string;
373
377
  }): Promise<ProcessOutcome> {
374
378
  const { db, registry, buildStorageProvider, now, job } = args;
@@ -415,6 +419,7 @@ async function processJob(args: {
415
419
  userId: job.userId,
416
420
  now,
417
421
  escapeHatchAuditSink: args.escapeHatchAuditSink,
422
+ escapeHatchAuditLog: args.escapeHatchAuditLog,
418
423
  actor: args.actor,
419
424
  });
420
425