@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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/compliance-profiles/handlers/set-profile.write.ts +18 -24
- package/packages/bundled-features/src/custom-fields/handlers/define-system-field.write.ts +10 -17
- package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +5 -2
- package/packages/bundled-features/src/custom-fields/lib/define-or-resurrect.ts +12 -3
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +8 -25
- package/packages/bundled-features/src/shared/index.ts +1 -0
- package/packages/bundled-features/src/shared/require-for-tenant.ts +18 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -4
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -7
- package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +7 -6
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +7 -4
- package/packages/bundled-features/src/tenant/cancel-pending-invitation.ts +3 -1
- package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +22 -8
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +19 -11
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
//
|
|
36
|
-
//
|
|
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
|
|
61
|
-
const
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
30
|
-
const result = await fieldDefinitionExecutor.delete({ id: aggregateId },
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
event.user,
|
|
114
96
|
scopedDb,
|
|
97
|
+
{ streamTenantId },
|
|
115
98
|
);
|
|
116
99
|
if (!result.isSuccess) return result;
|
|
117
100
|
return {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
|
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
|
|
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
|
-
|
|
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 },
|
|
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(
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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:
|
|
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:
|
|
44
|
-
):
|
|
45
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
event.user,
|
|
113
98
|
tdb,
|
|
99
|
+
{ streamTenantId },
|
|
114
100
|
);
|
|
115
101
|
if (!result.isSuccess) return result;
|
|
116
102
|
afterCommit(tenantId, tier);
|