@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +2 -0
- package/packages/bundled-features/src/agent-tools/types.ts +2 -0
- package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +2 -0
- package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +8 -7
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +7 -12
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +13 -17
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +12 -8
- package/packages/bundled-features/src/compliance-profiles/handlers/set-profile.write.ts +19 -36
- 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/delivery/delivery-service.ts +1 -1
- package/packages/bundled-features/src/managed-pages/handlers/by-tenant-published.query.ts +1 -10
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +9 -37
- 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/by-slug.query.ts +1 -10
- package/packages/bundled-features/src/template-resolver/handlers/by-tenant.query.ts +1 -10
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +0 -7
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +0 -7
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -12
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -15
- 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 -11
- package/packages/bundled-features/src/template-resolver/seeding.ts +1 -2
- 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/tenant-lifecycle/feature.ts +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
- package/packages/bundled-features/src/user-data-rights/feature.ts +2 -0
- package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +11 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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 },
|
|
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
|
-
//
|
|
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(
|
|
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, {
|
|
@@ -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
|
-
//
|
|
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);
|
|
@@ -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
|
|