@cosmicdrift/kumiko-bundled-features 0.199.2 → 0.200.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 +8 -7
- package/src/compliance-profiles-ops/__tests__/ops.boot.test.ts +42 -0
- package/src/compliance-profiles-ops/__tests__/ops.integration.test.ts +78 -0
- package/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +54 -0
- package/src/compliance-profiles-ops/index.ts +34 -0
- package/src/delivery/delivery-service.ts +10 -2
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +17 -3
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +13 -0
- package/src/step-dispatcher/feature.ts +0 -1
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +11 -7
- package/src/tenant/handlers/active-tenant-ids.query.ts +9 -1
- package/src/tenant/handlers/add-member.write.ts +10 -2
- package/src/tenant/handlers/cancel-invitation.write.ts +27 -16
- package/src/tenant/handlers/create.write.ts +19 -9
- package/src/tenant/handlers/invitations.query.ts +9 -1
- package/src/tenant/handlers/list.query.ts +11 -1
- package/src/tenant/handlers/me.query.ts +11 -3
- package/src/tenant/handlers/members.query.ts +10 -2
- package/src/tenant/handlers/memberships.query.ts +21 -10
- package/src/tenant/handlers/remove-member.write.ts +10 -2
- package/src/tenant/handlers/resolve-user-ids.query.ts +12 -2
- package/src/tenant/handlers/toggle-enabled.write.ts +15 -4
- package/src/tenant/handlers/update-member-roles.write.ts +14 -6
- package/src/tier-engine/feature.ts +22 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.200.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>",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"./admin-shell/web": "./src/admin-shell/web/index.ts",
|
|
26
26
|
"./compliance-profiles": "./src/compliance-profiles/index.ts",
|
|
27
27
|
"./compliance-profiles/web": "./src/compliance-profiles/web/index.ts",
|
|
28
|
+
"./compliance-profiles-ops": "./src/compliance-profiles-ops/index.ts",
|
|
28
29
|
"./config": "./src/config/index.ts",
|
|
29
30
|
"./crypto-shredding": "./src/crypto-shredding/index.ts",
|
|
30
31
|
"./config/web": "./src/config/web/index.ts",
|
|
@@ -125,12 +126,12 @@
|
|
|
125
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
126
127
|
},
|
|
127
128
|
"dependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.200.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.200.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.200.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.200.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.200.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.200.0",
|
|
134
135
|
"@mollie/api-client": "^4.5.0",
|
|
135
136
|
"@node-rs/argon2": "^2.0.2",
|
|
136
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
5
|
+
import { createConfigFeature } from "../../config/feature";
|
|
6
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
7
|
+
import { complianceProfilesOpsFeature } from "../index";
|
|
8
|
+
|
|
9
|
+
describe("compliance-profiles-ops (#2089)", () => {
|
|
10
|
+
test("declares systemScope and requires compliance-profiles + tenant", () => {
|
|
11
|
+
expect(complianceProfilesOpsFeature.systemScope).toBe(true);
|
|
12
|
+
expect(complianceProfilesOpsFeature.requires).toContain("compliance-profiles");
|
|
13
|
+
expect(complianceProfilesOpsFeature.requires).toContain("tenant");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("tenants-missing-profile is SystemAdmin-only", () => {
|
|
17
|
+
expect(
|
|
18
|
+
rolesOf(complianceProfilesOpsFeature.queryHandlers["tenants-missing-profile"]?.access),
|
|
19
|
+
).toEqual(["SystemAdmin"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("boot-validates alongside compliance-profiles + tenant + config", () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
validateBoot([
|
|
25
|
+
createConfigFeature(),
|
|
26
|
+
createTenantFeature(),
|
|
27
|
+
createComplianceProfilesFeature(),
|
|
28
|
+
complianceProfilesOpsFeature,
|
|
29
|
+
]),
|
|
30
|
+
).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("boot fails without tenant mounted (hard requires)", () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
validateBoot([
|
|
36
|
+
createConfigFeature(),
|
|
37
|
+
createComplianceProfilesFeature(),
|
|
38
|
+
complianceProfilesOpsFeature,
|
|
39
|
+
]),
|
|
40
|
+
).toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import {
|
|
4
|
+
createTestUser,
|
|
5
|
+
setupTestStack,
|
|
6
|
+
type TestStack,
|
|
7
|
+
testTenantId,
|
|
8
|
+
unsafeCreateEntityTable,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import {
|
|
11
|
+
createComplianceProfilesFeature,
|
|
12
|
+
tenantComplianceProfileEntity,
|
|
13
|
+
} from "../../compliance-profiles";
|
|
14
|
+
import { seedComplianceProfile } from "../../compliance-profiles/seeding";
|
|
15
|
+
import { createConfigFeature } from "../../config/feature";
|
|
16
|
+
import { TenantHandlers } from "../../tenant/constants";
|
|
17
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
18
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
19
|
+
import { complianceProfilesOpsFeature } from "../index";
|
|
20
|
+
|
|
21
|
+
const TENANTS_MISSING_PROFILE = "compliance-profiles-ops:query:tenants-missing-profile";
|
|
22
|
+
|
|
23
|
+
let stack: TestStack;
|
|
24
|
+
|
|
25
|
+
const opsAdmin = createTestUser({ id: 900, tenantId: testTenantId(900), roles: ["SystemAdmin"] });
|
|
26
|
+
const tenantWithProfile = testTenantId(901);
|
|
27
|
+
const tenantWithoutProfile = testTenantId(902);
|
|
28
|
+
const disabledTenantWithoutProfile = testTenantId(903);
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
stack = await setupTestStack({
|
|
32
|
+
features: [
|
|
33
|
+
createConfigFeature(),
|
|
34
|
+
createTenantFeature(),
|
|
35
|
+
createComplianceProfilesFeature(),
|
|
36
|
+
complianceProfilesOpsFeature,
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
40
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
41
|
+
await createEventsTable(stack.db);
|
|
42
|
+
|
|
43
|
+
for (const [id, key] of [
|
|
44
|
+
[tenantWithProfile, "with-profile"],
|
|
45
|
+
[tenantWithoutProfile, "without-profile"],
|
|
46
|
+
[disabledTenantWithoutProfile, "disabled-without-profile"],
|
|
47
|
+
] as const) {
|
|
48
|
+
await stack.http.writeOk(TenantHandlers.create, { id, key, name: key }, opsAdmin);
|
|
49
|
+
}
|
|
50
|
+
await stack.http.writeOk(TenantHandlers.disable, { id: disabledTenantWithoutProfile }, opsAdmin);
|
|
51
|
+
await seedComplianceProfile(stack.db, { tenantId: tenantWithProfile, profileKey: "eu-dsgvo" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await stack.cleanup();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("tenants-missing-profile (#2089)", () => {
|
|
59
|
+
test("SystemAdmin sees only the enabled tenant without a profile", async () => {
|
|
60
|
+
const result = await stack.http.queryOk<{
|
|
61
|
+
tenants: readonly { id: string; name: string }[];
|
|
62
|
+
}>(TENANTS_MISSING_PROFILE, {}, opsAdmin);
|
|
63
|
+
|
|
64
|
+
const ids = result.tenants.map((t) => t.id);
|
|
65
|
+
expect(ids).toContain(tenantWithoutProfile);
|
|
66
|
+
expect(ids).not.toContain(tenantWithProfile);
|
|
67
|
+
expect(ids).not.toContain(disabledTenantWithoutProfile);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("TenantAdmin gets 403", async () => {
|
|
71
|
+
const tenantAdmin = createTestUser({
|
|
72
|
+
id: 904,
|
|
73
|
+
tenantId: tenantWithoutProfile,
|
|
74
|
+
roles: ["TenantAdmin"],
|
|
75
|
+
});
|
|
76
|
+
expect((await stack.http.query(TENANTS_MISSING_PROFILE, {}, tenantAdmin)).status).toBe(403);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
2
|
+
import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { tenantComplianceProfileTable } from "../../compliance-profiles";
|
|
6
|
+
import { tenantTable } from "../../tenant";
|
|
7
|
+
|
|
8
|
+
// SystemAdmin platform-wide counterpart to needs-profile (#2089).
|
|
9
|
+
//
|
|
10
|
+
// #2084 stopped needs-profile from nagging a TenantAdmin who can no longer
|
|
11
|
+
// reach a picker narrowed to access.systemAdmin — correct, but it left no
|
|
12
|
+
// one else able to notice: needs-profile stays TenantAdmin-only on purpose
|
|
13
|
+
// (widening its call-access was explicitly rejected in #2084), so a
|
|
14
|
+
// SystemAdmin who owns the platform-only picker has no way to see which
|
|
15
|
+
// tenants still run on minimal-no-region. This query fills that gap
|
|
16
|
+
// instead of touching needs-profile: every enabled tenant that has no row
|
|
17
|
+
// in tenantComplianceProfile at all, tenant-wide rather than for the
|
|
18
|
+
// caller's own tenant.
|
|
19
|
+
export const tenantsMissingProfileQuery = defineQueryHandler({
|
|
20
|
+
name: "tenants-missing-profile",
|
|
21
|
+
schema: z.object({}),
|
|
22
|
+
access: { roles: [ROLES.SystemAdmin] },
|
|
23
|
+
handler: async (_query, ctx): Promise<TenantsMissingProfileResponse> => {
|
|
24
|
+
if (!ctx.systemDb) {
|
|
25
|
+
throw new InternalError({
|
|
26
|
+
message:
|
|
27
|
+
"[compliance-profiles-ops] tenants-missing-profile requires ctx.systemDb — the " +
|
|
28
|
+
"compliance-profiles-ops feature must stay r.systemScope() (see feature.ts).",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
32
|
+
"platform operator scans every tenant for a missing compliance-profile selection",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const tenants = await db.selectMany<{ id: TenantId; name: string }>(tenantTable, {
|
|
36
|
+
isEnabled: true,
|
|
37
|
+
});
|
|
38
|
+
const profileRows = await db.selectMany<{ tenantId: TenantId }>(
|
|
39
|
+
tenantComplianceProfileTable,
|
|
40
|
+
{},
|
|
41
|
+
);
|
|
42
|
+
const tenantIdsWithProfile = new Set(profileRows.map((row) => row.tenantId));
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
tenants: tenants
|
|
46
|
+
.filter((tenant) => !tenantIdsWithProfile.has(tenant.id))
|
|
47
|
+
.map((tenant) => ({ id: tenant.id, name: tenant.name })),
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
interface TenantsMissingProfileResponse {
|
|
53
|
+
readonly tenants: readonly { readonly id: TenantId; readonly name: string }[];
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// compliance-profiles-ops — platform-wide SystemAdmin visibility into
|
|
2
|
+
// tenants missing a compliance-profile selection (#2089).
|
|
3
|
+
//
|
|
4
|
+
// needs-profile (compliance-profiles) tells a TenantAdmin they must pick a
|
|
5
|
+
// profile, but stays TenantAdmin-only by design (#2084) — a SystemAdmin who
|
|
6
|
+
// owns a picker narrowed to access.systemAdmin has no query at all. Kept as
|
|
7
|
+
// a separate feature (mirrors folders-user-data/notes-history-user-data)
|
|
8
|
+
// rather than folded into compliance-profiles: this is the one genuinely
|
|
9
|
+
// cross-tenant capability in the picker's orbit, and it alone needs
|
|
10
|
+
// r.systemScope() + a hard r.requires("tenant") — apps that only want the
|
|
11
|
+
// per-tenant picker (the vast majority) stay unaffected.
|
|
12
|
+
|
|
13
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { tenantsMissingProfileQuery } from "./handlers/tenants-missing-profile.query";
|
|
15
|
+
|
|
16
|
+
export const complianceProfilesOpsFeature = defineFeature("compliance-profiles-ops", (r) => {
|
|
17
|
+
r.describe(
|
|
18
|
+
"Platform-wide SystemAdmin counterpart to `compliance-profiles`' `needs-profile` query (#2089): `tenants-missing-profile` lists every enabled tenant with no row in `tenantComplianceProfile` at all, tenant-wide instead of scoped to the caller's own tenant. Mount alongside `compliance-profiles` and `tenant` when an operator UI needs to see which tenants still silently run on `minimal-no-region`.",
|
|
19
|
+
);
|
|
20
|
+
r.uiHints({
|
|
21
|
+
displayLabel: "Compliance Profiles · Operator Visibility",
|
|
22
|
+
category: "compliance",
|
|
23
|
+
recommended: false,
|
|
24
|
+
});
|
|
25
|
+
r.systemScope();
|
|
26
|
+
r.requires("compliance-profiles");
|
|
27
|
+
r.requires("tenant");
|
|
28
|
+
|
|
29
|
+
const queries = {
|
|
30
|
+
tenantsMissingProfile: r.queryHandler(tenantsMissingProfileQuery),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return { queries };
|
|
34
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { createTenantDb, createUncheckedSystemDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { NotifyPriority, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
@@ -158,10 +158,18 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
158
158
|
}
|
|
159
159
|
const systemUser = createSystemUser(tenantId);
|
|
160
160
|
const tenantDb = createTenantDb(db, tenantId, "system");
|
|
161
|
+
// Hand-built context, not routed through the dispatcher — tenantUserIdsQuery is
|
|
162
|
+
// typically an r.systemScope() handler, fail-closed on ctx.db, so this needs both.
|
|
161
163
|
// @cast-boundary engine-payload — generic query-handler return for typed convention
|
|
162
164
|
return (await handler.handler(
|
|
163
165
|
{ type: tenantUserIdsQuery, payload: { tenantId }, user: systemUser },
|
|
164
|
-
{
|
|
166
|
+
{
|
|
167
|
+
db: tenantDb,
|
|
168
|
+
dbOutsideTransaction: tenantDb,
|
|
169
|
+
systemDb: createUncheckedSystemDb(tenantDb),
|
|
170
|
+
registry,
|
|
171
|
+
...bridgeStub(),
|
|
172
|
+
},
|
|
165
173
|
)) as readonly string[];
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type FeatureDefinition,
|
|
17
17
|
SYSTEM_TENANT_ID,
|
|
18
18
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
19
20
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
20
21
|
import { createEventDispatcher, type EventConsumer } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
21
22
|
import {
|
|
@@ -63,7 +64,17 @@ function widgetFeature(): FeatureDefinition {
|
|
|
63
64
|
r.writeHandler(
|
|
64
65
|
"widget:create",
|
|
65
66
|
z.object({ name: z.string().min(1).max(100), active: z.boolean().optional() }),
|
|
66
|
-
async (event, ctx) =>
|
|
67
|
+
async (event, ctx) => {
|
|
68
|
+
if (!ctx.systemDb) {
|
|
69
|
+
throw new InternalError({
|
|
70
|
+
message: "widget:create requires ctx.systemDb — is r.systemScope() still set?",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
74
|
+
"widget catalog is system-wide in this test",
|
|
75
|
+
);
|
|
76
|
+
return widgetCrud.create(event.payload, event.user, db);
|
|
77
|
+
},
|
|
67
78
|
{ access: { roles: ["SystemAdmin"] } },
|
|
68
79
|
);
|
|
69
80
|
});
|
|
@@ -89,10 +100,13 @@ function widgetAuditFeature(): FeatureDefinition {
|
|
|
89
100
|
|
|
90
101
|
r.hook("postSave", { allOf: "widget" }, async (result, ctx) => {
|
|
91
102
|
if (result.kind !== "save" || !result.isNew) return;
|
|
92
|
-
if (!ctx.
|
|
103
|
+
if (!ctx.systemDb) return;
|
|
93
104
|
const name = result.changes!["name"] as string | undefined;
|
|
94
105
|
if (!name) return;
|
|
95
|
-
|
|
106
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
107
|
+
"widget audit sink is system-wide in this test",
|
|
108
|
+
);
|
|
109
|
+
await seedRow(db, widgetAuditTable, {
|
|
96
110
|
id: generateId(),
|
|
97
111
|
widgetName: name,
|
|
98
112
|
version: 1,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createStepDispatcherFeature } from "../feature";
|
|
4
|
+
|
|
5
|
+
describe("step-dispatcher (fw#2068)", () => {
|
|
6
|
+
test("does not declare systemScope — no handler reads ctx.db/ctx.systemDb", () => {
|
|
7
|
+
expect(createStepDispatcherFeature().systemScope).toBeFalsy();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("boot-validates standalone", () => {
|
|
11
|
+
expect(() => validateBoot([createStepDispatcherFeature()])).not.toThrow();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { buildServer, type JwtHelper } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createTenantDb,
|
|
5
|
+
createUncheckedSystemDb,
|
|
6
|
+
type DbConnection,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
4
8
|
import {
|
|
5
9
|
createRegistry,
|
|
6
10
|
defineFeature,
|
|
@@ -80,6 +84,9 @@ beforeAll(async () => {
|
|
|
80
84
|
getActiveTenantIds: async () => {
|
|
81
85
|
const handler = registry.getQueryHandler(TenantQueries.activeTenantIds);
|
|
82
86
|
if (!handler) return [];
|
|
87
|
+
// Hand-built context, not routed through the dispatcher — active-tenant-ids
|
|
88
|
+
// is an r.systemScope() handler, fail-closed on ctx.db, needs ctx.systemDb too.
|
|
89
|
+
const systemModeDb = createTenantDb(db, "00000000-0000-4000-8000-000000000000", "system");
|
|
83
90
|
const result = await handler.handler(
|
|
84
91
|
{
|
|
85
92
|
type: TenantQueries.activeTenantIds,
|
|
@@ -91,12 +98,9 @@ beforeAll(async () => {
|
|
|
91
98
|
},
|
|
92
99
|
},
|
|
93
100
|
{
|
|
94
|
-
db:
|
|
95
|
-
dbOutsideTransaction:
|
|
96
|
-
|
|
97
|
-
"00000000-0000-4000-8000-000000000000",
|
|
98
|
-
"system",
|
|
99
|
-
),
|
|
101
|
+
db: systemModeDb,
|
|
102
|
+
dbOutsideTransaction: systemModeDb,
|
|
103
|
+
systemDb: createUncheckedSystemDb(systemModeDb),
|
|
100
104
|
registry,
|
|
101
105
|
...bridgeStub(),
|
|
102
106
|
},
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
SYSTEM_ROLE,
|
|
5
5
|
type TenantId,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { tenantTable } from "../schema/tenant";
|
|
9
10
|
|
|
@@ -12,9 +13,16 @@ export const activeTenantIdsQuery = defineQueryHandler({
|
|
|
12
13
|
schema: z.object({}),
|
|
13
14
|
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
14
15
|
handler: async (_query, ctx) => {
|
|
16
|
+
if (!ctx.systemDb) {
|
|
17
|
+
throw new InternalError({
|
|
18
|
+
message:
|
|
19
|
+
"tenant:query:activeTenantIds requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("lists active tenant ids platform-wide");
|
|
15
23
|
// tenants.id is a uuid string (TenantId), not a numeric surrogate.
|
|
16
24
|
// Brand at the DB parse boundary — getActiveTenantIds consumers expect TenantId[].
|
|
17
|
-
const rows = await selectMany<{ id: TenantId }>(
|
|
25
|
+
const rows = await selectMany<{ id: TenantId }>(db, tenantTable, { isEnabled: true });
|
|
18
26
|
return rows.map((r) => r.id);
|
|
19
27
|
},
|
|
20
28
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { TenantErrors } from "../constants";
|
|
7
7
|
import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles";
|
|
@@ -20,7 +20,15 @@ export const addMemberWrite = defineWriteHandler({
|
|
|
20
20
|
}),
|
|
21
21
|
access: { roles: ["SystemAdmin"] },
|
|
22
22
|
handler: async (event, ctx) => {
|
|
23
|
-
|
|
23
|
+
if (!ctx.systemDb) {
|
|
24
|
+
throw new InternalError({
|
|
25
|
+
message:
|
|
26
|
+
"tenant:write:addMember requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
30
|
+
"SystemAdmin manages memberships across tenants",
|
|
31
|
+
);
|
|
24
32
|
const forbidden = findForbiddenMembershipRole(event.payload.roles);
|
|
25
33
|
if (forbidden !== undefined) return writeFailure(reservedMembershipRoleError(forbidden));
|
|
26
34
|
const existing = await fetchOne(db, tenantMembershipsTable, {
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
// Cancel
|
|
1
|
+
// Cancel handler for pending invitations.
|
|
2
2
|
//
|
|
3
|
-
// Admin
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// - DB
|
|
7
|
-
// - Token
|
|
3
|
+
// Admin sees a pending invitation and decides to withdraw it (user
|
|
4
|
+
// shouldn't join after all, wrong email typed, etc.).
|
|
5
|
+
// Effect:
|
|
6
|
+
// - DB row.status → "cancelled"
|
|
7
|
+
// - Token deleted from Redis (tracked in invite-token-store)
|
|
8
8
|
//
|
|
9
|
-
// Idempotent:
|
|
10
|
-
// invitation = no-op + 200.
|
|
9
|
+
// Idempotent: cancelling an already-cancelled / accepted / expired
|
|
10
|
+
// invitation = no-op + 200. Cancelling a non-existent invitation
|
|
11
11
|
// = invitation_not_found.
|
|
12
12
|
|
|
13
13
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
14
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
15
15
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
-
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
16
|
+
import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
17
17
|
import { z } from "zod";
|
|
18
|
-
// kumiko-lint-ignore cross-feature-import cancel needs invite-token-store
|
|
18
|
+
// kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
|
|
19
19
|
import {
|
|
20
20
|
deleteInviteToken,
|
|
21
21
|
getTokenForInvitation,
|
|
@@ -39,9 +39,20 @@ export const cancelInvitationWrite = defineWriteHandler({
|
|
|
39
39
|
schema: CancelInvitationSchema,
|
|
40
40
|
access: { roles: access.admin },
|
|
41
41
|
handler: async (event, ctx) => {
|
|
42
|
-
|
|
42
|
+
if (!ctx.systemDb) {
|
|
43
|
+
throw new InternalError({
|
|
44
|
+
message:
|
|
45
|
+
"tenant:write:cancel-invitation requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const db = ctx.systemDb.assertTenantMatch(event.user.tenantId);
|
|
49
|
+
const invitation = await fetchOne(db, tenantInvitationsTable, {
|
|
43
50
|
id: event.payload.invitationId,
|
|
51
|
+
tenantId: event.user.tenantId,
|
|
44
52
|
});
|
|
53
|
+
// The tenantId check below is redundant with the where-clause above but
|
|
54
|
+
// kept as defense in depth — this is a security-cutover diff, not the
|
|
55
|
+
// place to also drop an existing check.
|
|
45
56
|
if (!invitation || invitation["tenantId"] !== event.user.tenantId) {
|
|
46
57
|
return writeFailure(
|
|
47
58
|
new NotFoundError("tenantInvitation", event.payload.invitationId, {
|
|
@@ -50,7 +61,7 @@ export const cancelInvitationWrite = defineWriteHandler({
|
|
|
50
61
|
);
|
|
51
62
|
}
|
|
52
63
|
|
|
53
|
-
// Idempotent:
|
|
64
|
+
// Idempotent: already !pending → no-op success.
|
|
54
65
|
if (invitation["status"] !== INVITATION_STATUS.pending) {
|
|
55
66
|
return { isSuccess: true, data: { id: event.payload.invitationId, alreadyDone: true } };
|
|
56
67
|
}
|
|
@@ -63,13 +74,13 @@ export const cancelInvitationWrite = defineWriteHandler({
|
|
|
63
74
|
changes: { status: INVITATION_STATUS.cancelled },
|
|
64
75
|
},
|
|
65
76
|
event.user,
|
|
66
|
-
|
|
77
|
+
db,
|
|
67
78
|
);
|
|
68
79
|
if (!updateResult.isSuccess) return updateResult;
|
|
69
80
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
81
|
+
// Delete the token from Redis (if still there). If Redis is
|
|
82
|
+
// unavailable or the token already expired: not a problem, the DB
|
|
83
|
+
// row is the single source of truth for the UI.
|
|
73
84
|
if (ctx.redis) {
|
|
74
85
|
const token = await getTokenForInvitation(ctx.redis, event.payload.invitationId);
|
|
75
86
|
if (token) {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
6
|
|
|
6
7
|
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
7
8
|
|
|
8
|
-
// Optional `id`: SystemAdmin-only handler —
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// Optional `id`: SystemAdmin-only handler — legitimate path for seeds and
|
|
10
|
+
// external provisioning (SCIM, IdP sync, migration from existing systems),
|
|
11
|
+
// where the tenant must be created with a caller-chosen UUID. When unset,
|
|
12
|
+
// Postgres assigns a new UUID via gen_random_uuid().
|
|
12
13
|
export const createWrite = defineWriteHandler({
|
|
13
14
|
name: "create",
|
|
14
15
|
schema: z.object({
|
|
@@ -16,10 +17,19 @@ export const createWrite = defineWriteHandler({
|
|
|
16
17
|
key: z.string().min(1).max(50),
|
|
17
18
|
name: z.string().min(1).max(200),
|
|
18
19
|
}),
|
|
19
|
-
// "system" + "SystemAdmin" —
|
|
20
|
-
//
|
|
21
|
-
// (roles=["system"])
|
|
22
|
-
// human-
|
|
20
|
+
// "system" + "SystemAdmin" — symmetric to update-member-roles. Ops
|
|
21
|
+
// tooling (seed migrations + sample recipes) uses the system user
|
|
22
|
+
// (roles=["system"]) as the executor; "SystemAdmin" stays the real
|
|
23
|
+
// human-operator path via the UI.
|
|
23
24
|
access: { roles: ["system", "SystemAdmin"] },
|
|
24
|
-
handler: async (event, ctx) =>
|
|
25
|
+
handler: async (event, ctx) => {
|
|
26
|
+
if (!ctx.systemDb) {
|
|
27
|
+
throw new InternalError({
|
|
28
|
+
message:
|
|
29
|
+
"tenant:write:create requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("creating a tenant is inherently cross-tenant");
|
|
33
|
+
return crud.create(event.payload, event.user, db);
|
|
34
|
+
},
|
|
25
35
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared";
|
|
5
6
|
import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
|
|
@@ -23,7 +24,14 @@ export const invitationsQuery = defineQueryHandler({
|
|
|
23
24
|
schema: z.object({}),
|
|
24
25
|
access: { roles: access.admin },
|
|
25
26
|
handler: async (query, ctx) => {
|
|
26
|
-
|
|
27
|
+
if (!ctx.systemDb) {
|
|
28
|
+
throw new InternalError({
|
|
29
|
+
message:
|
|
30
|
+
"tenant:query:invitations requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
34
|
+
const rows = await selectMany<Record<string, unknown>>(db, tenantInvitationsTable, {
|
|
27
35
|
tenantId: query.user.tenantId,
|
|
28
36
|
status: INVITATION_STATUS.pending,
|
|
29
37
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
6
|
|
|
@@ -13,5 +14,14 @@ export const listQuery = defineQueryHandler({
|
|
|
13
14
|
search: z.string().optional(),
|
|
14
15
|
}),
|
|
15
16
|
access: { roles: ["SystemAdmin"] },
|
|
16
|
-
handler: async (query, ctx) =>
|
|
17
|
+
handler: async (query, ctx) => {
|
|
18
|
+
if (!ctx.systemDb) {
|
|
19
|
+
throw new InternalError({
|
|
20
|
+
message:
|
|
21
|
+
"tenant:query:list requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin lists tenants platform-wide");
|
|
25
|
+
return crud.list(query.payload, query.user, db); // @wrapper-known semantic-alias
|
|
26
|
+
},
|
|
17
27
|
});
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { tenantTable } from "../schema/tenant";
|
|
5
6
|
|
|
6
|
-
// Direct query — query
|
|
7
|
-
//
|
|
7
|
+
// Direct query — query handlers don't have a tenant-crud handle. A direct
|
|
8
|
+
// select is trivial: WHERE id = tenantId (both UUID). No CRUD detour needed.
|
|
8
9
|
export const meQuery = defineQueryHandler({
|
|
9
10
|
name: "me",
|
|
10
11
|
schema: z.object({}),
|
|
11
12
|
access: { openToAll: true },
|
|
12
13
|
handler: async (query, ctx) => {
|
|
13
|
-
|
|
14
|
+
if (!ctx.systemDb) {
|
|
15
|
+
throw new InternalError({
|
|
16
|
+
message:
|
|
17
|
+
"tenant:query:me requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
21
|
+
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
14
22
|
return row ?? null;
|
|
15
23
|
},
|
|
16
24
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared";
|
|
@@ -20,13 +21,20 @@ export const membersQuery = defineQueryHandler({
|
|
|
20
21
|
schema: z.object({}),
|
|
21
22
|
access: { roles: access.admin },
|
|
22
23
|
handler: async (query, ctx) => {
|
|
23
|
-
|
|
24
|
+
if (!ctx.systemDb) {
|
|
25
|
+
throw new InternalError({
|
|
26
|
+
message:
|
|
27
|
+
"tenant:query:members requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
31
|
+
const rows = await selectMany(db, tenantMembershipsTable, {
|
|
24
32
|
tenantId: query.user.tenantId,
|
|
25
33
|
});
|
|
26
34
|
|
|
27
35
|
const userIds = [...new Set(rows.map((row) => row["userId"]))];
|
|
28
36
|
const users =
|
|
29
|
-
userIds.length > 0 ? await selectMany<UserRow>(
|
|
37
|
+
userIds.length > 0 ? await selectMany<UserRow>(db, userTable, { id: userIds }) : [];
|
|
30
38
|
const userById = new Map(users.map((u) => [String(u.id), u]));
|
|
31
39
|
|
|
32
40
|
const decrypted = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { tenantMembershipsTable } from "../membership-table";
|
|
@@ -12,14 +13,24 @@ export const membershipsQuery = defineQueryHandler({
|
|
|
12
13
|
// directly by tenant admins managing memberships in the admin UI.
|
|
13
14
|
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
14
15
|
handler: async (query, ctx) => {
|
|
15
|
-
|
|
16
|
+
if (!ctx.systemDb) {
|
|
17
|
+
throw new InternalError({
|
|
18
|
+
message:
|
|
19
|
+
"tenant:query:memberships requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
23
|
+
"resolves memberships for an arbitrary userId across all tenants",
|
|
24
|
+
);
|
|
25
|
+
const rows = await selectMany(db, tenantMembershipsTable, { userId: query.payload.userId });
|
|
16
26
|
if (rows.length === 0) return [];
|
|
17
27
|
|
|
18
|
-
// tenantName/tenantKey
|
|
19
|
-
//
|
|
20
|
-
//
|
|
28
|
+
// tenantName/tenantKey make memberships distinguishable in the UI
|
|
29
|
+
// (otherwise just the UUID prefix — seed tenants with a 00000000-…
|
|
30
|
+
// prefix would be indistinguishable). A single IN-batch over all
|
|
31
|
+
// tenantIds instead of fetchOne per membership (#324).
|
|
21
32
|
type TenantRow = { id: unknown; name?: unknown; key?: unknown; isEnabled?: unknown };
|
|
22
|
-
const tenants = await selectMany<TenantRow>(
|
|
33
|
+
const tenants = await selectMany<TenantRow>(db, tenantTable, {
|
|
23
34
|
id: rows.map((row) => row["tenantId"]),
|
|
24
35
|
});
|
|
25
36
|
const tenantById = new Map<unknown, TenantRow>(tenants.map((t) => [t.id, t]));
|
|
@@ -27,11 +38,11 @@ export const membershipsQuery = defineQueryHandler({
|
|
|
27
38
|
return rows
|
|
28
39
|
.map((row) => {
|
|
29
40
|
const tenant = tenantById.get(row["tenantId"]);
|
|
30
|
-
// Disabled
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
41
|
+
// Disabled tenants (tenant:write:disable) don't count as a
|
|
42
|
+
// membership: login doesn't pick them, /auth/tenants doesn't list
|
|
43
|
+
// them, switch-tenant answers not_a_member. Only the explicit
|
|
44
|
+
// false filters — a missing tenant row (projection drift) should
|
|
45
|
+
// not lock out every member's login.
|
|
35
46
|
if (tenant !== undefined && tenant.isEnabled === false) return null;
|
|
36
47
|
return {
|
|
37
48
|
...row,
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
defineWriteHandler,
|
|
6
6
|
withResponseData,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
11
11
|
|
|
@@ -23,7 +23,15 @@ export const removeMemberWrite = defineWriteHandler({
|
|
|
23
23
|
schema: z.object({ userId: z.string(), tenantId: z.string() }),
|
|
24
24
|
access: { roles: ["SystemAdmin"] },
|
|
25
25
|
handler: async (event, ctx) => {
|
|
26
|
-
|
|
26
|
+
if (!ctx.systemDb) {
|
|
27
|
+
throw new InternalError({
|
|
28
|
+
message:
|
|
29
|
+
"tenant:write:removeMember requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
33
|
+
"SystemAdmin manages memberships across tenants",
|
|
34
|
+
);
|
|
27
35
|
const existing = await fetchOne(db, tenantMembershipsTable, {
|
|
28
36
|
userId: event.payload.userId,
|
|
29
37
|
tenantId: event.payload.tenantId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { tenantMembershipsTable } from "../membership-table";
|
|
5
6
|
|
|
@@ -14,17 +15,26 @@ export const resolveUserIdsQuery = defineQueryHandler({
|
|
|
14
15
|
}),
|
|
15
16
|
access: { roles: [SYSTEM_ROLE] },
|
|
16
17
|
handler: async (query, ctx) => {
|
|
18
|
+
if (!ctx.systemDb) {
|
|
19
|
+
throw new InternalError({
|
|
20
|
+
message:
|
|
21
|
+
"tenant:query:resolveUserIds requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
25
|
+
"cross-feature lookup by arbitrary tenantId/userId",
|
|
26
|
+
);
|
|
17
27
|
const { tenantId, userId } = query.payload;
|
|
18
28
|
|
|
19
29
|
if (tenantId !== undefined) {
|
|
20
|
-
const rows = await selectMany<{ userId: number }>(
|
|
30
|
+
const rows = await selectMany<{ userId: number }>(db, tenantMembershipsTable, {
|
|
21
31
|
tenantId,
|
|
22
32
|
});
|
|
23
33
|
return rows.map((r) => r.userId);
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
if (userId !== undefined) {
|
|
27
|
-
const rows = await selectMany(
|
|
37
|
+
const rows = await selectMany(db, tenantMembershipsTable, { userId });
|
|
28
38
|
return rows.length > 0 ? [userId] : [];
|
|
29
39
|
}
|
|
30
40
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
6
|
|
|
@@ -8,14 +9,24 @@ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "
|
|
|
8
9
|
// Admin flip: last-writer-wins is fine. SystemAdmin is the only caller and
|
|
9
10
|
// there's no meaningful concurrent-edit race on this single boolean.
|
|
10
11
|
function createToggleTenantHandler(enable: boolean) {
|
|
12
|
+
const verbName = enable ? "enable" : "disable";
|
|
11
13
|
return defineWriteHandler({
|
|
12
|
-
name:
|
|
14
|
+
name: verbName,
|
|
13
15
|
schema: z.object({ id: z.uuid() }),
|
|
14
16
|
access: { roles: ["SystemAdmin"] },
|
|
15
|
-
handler: async (event, ctx) =>
|
|
16
|
-
|
|
17
|
+
handler: async (event, ctx) => {
|
|
18
|
+
if (!ctx.systemDb) {
|
|
19
|
+
throw new InternalError({
|
|
20
|
+
message: `tenant:write:${verbName} requires ctx.systemDb — is r.systemScope() still set on the tenant feature?`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
24
|
+
"SystemAdmin enables/disables tenants platform-wide",
|
|
25
|
+
);
|
|
26
|
+
return crud.update({ id: event.payload.id, changes: { isEnabled: enable } }, event.user, db, {
|
|
17
27
|
skipOptimisticLock: true,
|
|
18
|
-
})
|
|
28
|
+
}); // @wrapper-known semantic-alias
|
|
29
|
+
},
|
|
19
30
|
});
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
defineWriteHandler,
|
|
6
6
|
withResponseData,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles";
|
|
11
11
|
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
@@ -26,13 +26,21 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
26
26
|
tenantId: z.string(),
|
|
27
27
|
roles: z.array(z.string()).min(1),
|
|
28
28
|
}),
|
|
29
|
-
// "system" + "SystemAdmin" —
|
|
30
|
-
//
|
|
31
|
-
// migrations
|
|
32
|
-
// human-
|
|
29
|
+
// "system" + "SystemAdmin" — symmetric to tenant:write:create. The
|
|
30
|
+
// system user (createSystemUser, roles=["system"]) needs access for
|
|
31
|
+
// seed migrations and other ops-tooling paths. SystemAdmin is the real
|
|
32
|
+
// human-operator path via the UI.
|
|
33
33
|
access: { roles: ["system", "SystemAdmin"] },
|
|
34
34
|
handler: async (event, ctx) => {
|
|
35
|
-
|
|
35
|
+
if (!ctx.systemDb) {
|
|
36
|
+
throw new InternalError({
|
|
37
|
+
message:
|
|
38
|
+
"tenant:write:updateMemberRoles requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
42
|
+
"SystemAdmin manages memberships across tenants",
|
|
43
|
+
);
|
|
36
44
|
const forbidden = findForbiddenMembershipRole(event.payload.roles);
|
|
37
45
|
if (forbidden !== undefined) return writeFailure(reservedMembershipRoleError(forbidden));
|
|
38
46
|
const existing = await fetchOne(db, tenantMembershipsTable, {
|
|
@@ -340,28 +340,28 @@ export function createTierEngineFeature<
|
|
|
340
340
|
const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
|
|
341
341
|
const aggregateId = tierAssignmentAggregateId(newTenantId);
|
|
342
342
|
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// skip: defensive —
|
|
363
|
-
if (!("raw" in
|
|
364
|
-
const rawDb =
|
|
343
|
+
// tenant is an r.systemScope() feature, so this cross-feature hook gets a
|
|
344
|
+
// fail-closed ctx.db — reach for ctx.systemDb instead (see entity-handlers.ts).
|
|
345
|
+
const db = ctx.systemDb
|
|
346
|
+
? ctx.systemDb.acknowledgeCrossTenant(
|
|
347
|
+
`tier-engine auto-default-tier hook on r.systemScope() tenant write (${newTenantId})`,
|
|
348
|
+
)
|
|
349
|
+
: ctx.db;
|
|
350
|
+
// skip: defensive — inTransaction phase always sets db, but AppContext's
|
|
351
|
+
// type makes it optional. Throwing would be overreach (lifecycle
|
|
352
|
+
// blocking), silent-skip is defensive-soft.
|
|
353
|
+
if (!db) return;
|
|
354
|
+
|
|
355
|
+
// db is a TenantDb in the inTransaction phase (tenant-scoped proxy over
|
|
356
|
+
// the real tx). Event-store paths need the raw DbConnection — TenantDb
|
|
357
|
+
// only exposes select/insert/update/delete, not execute
|
|
358
|
+
// (event-store-append.ts:102 calls db.execute(sql`SELECT pg_notify(...)`)
|
|
359
|
+
// → TypeError otherwise). Pattern matches signup-confirm.write.ts:107
|
|
360
|
+
// (.raw), not `as DbConnection` — that's a type-lie that only crashes on
|
|
361
|
+
// the first .execute() call.
|
|
362
|
+
// skip: defensive — should never trip in the inTransaction phase.
|
|
363
|
+
if (!("raw" in db)) return;
|
|
364
|
+
const rawDb = db.raw as DbConnection; // @cast-boundary db-runner
|
|
365
365
|
|
|
366
366
|
// Idempotency: stream-existence-check vor create. Pattern aus
|
|
367
367
|
// seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
|