@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.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/CHANGELOG.md +91 -0
- package/package.json +22 -13
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +32 -2
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +34 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +44 -4
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +10 -12
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +90 -1
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +42 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-tenant.query.ts +56 -0
- package/src/text-content/handlers/set.write.ts +1 -1
- package/src/text-content/web/client-plugin.ts +113 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +23 -13
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +310 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +333 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
|
|
3
|
+
// in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
|
|
4
|
+
// und liefert den treeProvider der Text-Blocks aus der by-tenant Query
|
|
5
|
+
// lädt, nach Slug-Prefix gruppiert und als TreeNode[] emitted.
|
|
6
|
+
//
|
|
7
|
+
// **Slug-Gruppierung**: Slugs der Form `<prefix>:<rest>` oder `<prefix>/<rest>`
|
|
8
|
+
// werden unter einem `<prefix>`-Container-Knoten gruppiert. Slugs ohne
|
|
9
|
+
// Trenner landen als Top-Level-Knoten. Beispiele:
|
|
10
|
+
// - "page:index:hero.title" → folder "page", label "index:hero.title"
|
|
11
|
+
// - "imprint" → root-node, label "imprint"
|
|
12
|
+
// V.1.3+ kann mehrstufige Hierarchien einführen wenn realer Bedarf zeigt.
|
|
13
|
+
//
|
|
14
|
+
// **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
|
|
15
|
+
// "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
|
|
16
|
+
//
|
|
17
|
+
// **Fetch statt Subscribe**: V.1.1 ist Fetch-once beim Mount. Unsubscribe
|
|
18
|
+
// ist no-op. V.1.3+ kann SSE-driven Re-Emit einbauen wenn text-block-
|
|
19
|
+
// updated-Events propagiert werden.
|
|
20
|
+
|
|
21
|
+
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
23
|
+
import { TextContentQueries } from "../constants";
|
|
24
|
+
|
|
25
|
+
type BlockSummary = {
|
|
26
|
+
readonly slug: string;
|
|
27
|
+
readonly lang: string;
|
|
28
|
+
readonly title: string;
|
|
29
|
+
readonly body: string | null;
|
|
30
|
+
readonly updatedAt: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ByTenantResponse = {
|
|
34
|
+
readonly data: { readonly blocks: readonly BlockSummary[] };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Folder-Name = alles vor dem ersten ":" oder "/", oder undefined wenn
|
|
38
|
+
// der Slug keinen Trenner enthält (dann landet er als Root-Node).
|
|
39
|
+
function getFolderName(slug: string): string | undefined {
|
|
40
|
+
const sepIdx = slug.search(/[:/]/);
|
|
41
|
+
if (sepIdx === -1) return undefined;
|
|
42
|
+
return slug.slice(0, sepIdx);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function groupBlocksBySlugPrefix(blocks: readonly BlockSummary[]): readonly TreeNode[] {
|
|
46
|
+
const rootNodes: TreeNode[] = [];
|
|
47
|
+
const folders = new Map<string, TreeNode[]>();
|
|
48
|
+
|
|
49
|
+
for (const block of blocks) {
|
|
50
|
+
const node: TreeNode = {
|
|
51
|
+
label: block.title || block.slug,
|
|
52
|
+
target: {
|
|
53
|
+
featureId: "text-content",
|
|
54
|
+
action: "edit",
|
|
55
|
+
args: { slug: block.slug, lang: block.lang },
|
|
56
|
+
},
|
|
57
|
+
state: block.body ? "filled" : "stub",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const folderName = getFolderName(block.slug);
|
|
61
|
+
if (folderName === undefined) {
|
|
62
|
+
rootNodes.push(node);
|
|
63
|
+
} else {
|
|
64
|
+
const existing = folders.get(folderName) ?? [];
|
|
65
|
+
existing.push(node);
|
|
66
|
+
folders.set(folderName, existing);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const [name, children] of folders) {
|
|
71
|
+
rootNodes.push({
|
|
72
|
+
label: name,
|
|
73
|
+
icon: "folder",
|
|
74
|
+
state: "filled",
|
|
75
|
+
children,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return rootNodes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
|
|
83
|
+
fetch("/api/query", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
type: TextContentQueries.byTenant,
|
|
88
|
+
payload: {},
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
.then((r) => r.json())
|
|
92
|
+
.then((data: ByTenantResponse) => {
|
|
93
|
+
const nodes = groupBlocksBySlugPrefix(data.data.blocks);
|
|
94
|
+
emit(nodes);
|
|
95
|
+
})
|
|
96
|
+
.catch(() => {
|
|
97
|
+
// V.1.3+ TODO: state="error"-Knoten + Reload-Action statt empty.
|
|
98
|
+
emit([]);
|
|
99
|
+
});
|
|
100
|
+
return () => {};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function textContentClient(): ClientFeatureDefinition {
|
|
104
|
+
return {
|
|
105
|
+
name: "text-content",
|
|
106
|
+
treeProvider,
|
|
107
|
+
treeActions: {
|
|
108
|
+
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
109
|
+
list: {},
|
|
110
|
+
create: { args: { folder: "" as string } },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public exports für die Browser-Seite des text-content Features.
|
|
3
|
+
// Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
|
|
4
|
+
// konsumiert — die Server-Seite (defineFeature) lebt in
|
|
5
|
+
// `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
|
|
6
|
+
// React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
|
|
7
|
+
|
|
8
|
+
export { textContentClient } from "./client-plugin";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// auto-default-tier hook regression test — beweist dass beim
|
|
2
|
+
// `tenant:write:create` der postSave-Hook von createTierEngineFeature
|
|
3
|
+
// fired und automatisch ein tier-assignment-row mit `defaultTier`
|
|
4
|
+
// schreibt.
|
|
5
|
+
//
|
|
6
|
+
// **Warum dieser Test existiert (2026-05-10):**
|
|
7
|
+
// Der auto-default-tier-Hook wurde in Sprint 8a Phase 2 hinzugefügt aber
|
|
8
|
+
// nie direkt getestet. Bei Sprint 8c (Studio-Mount mit auto-default-
|
|
9
|
+
// compliance-companion-hook) flog der Bug auf: `ctx.db as DbConnection`
|
|
10
|
+
// war ein Type-Lie — TenantDb exposed select/insert/update/delete, NICHT
|
|
11
|
+
// execute(). Der event-store-append (event-store.ts:102) ruft
|
|
12
|
+
// `db.execute(sql\`SELECT pg_notify(...)\`)` → TypeError. Fix:
|
|
13
|
+
// `ctx.db.raw as DbConnection` (Pattern aus signup-confirm.write.ts:107).
|
|
14
|
+
//
|
|
15
|
+
// Pin-Verträge:
|
|
16
|
+
// 1. tenant:write:create fired postSave-Hook mit isNew=true
|
|
17
|
+
// 2. Hook erstellt tier-assignment-row im NEUEN tenant (nicht im caller-
|
|
18
|
+
// tenant — Memory `feedback_event_store_tenant_consistency`)
|
|
19
|
+
// 3. Idempotency: tenant-update fired keinen weiteren row
|
|
20
|
+
|
|
21
|
+
import { composeFeatures } from "@cosmicdrift/kumiko-dev-server/compose-features";
|
|
22
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
23
|
+
import {
|
|
24
|
+
createTestUser,
|
|
25
|
+
setupTestStack,
|
|
26
|
+
type TestStack,
|
|
27
|
+
unsafePushTables,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { eq } from "drizzle-orm";
|
|
30
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
31
|
+
import { configValuesTable } from "../../config";
|
|
32
|
+
import { TenantHandlers, tenantMembershipsTable, tenantTable } from "../../tenant";
|
|
33
|
+
import { userTable } from "../../user";
|
|
34
|
+
import type { TierMap } from "../compose-app";
|
|
35
|
+
import { tierAssignmentEntity } from "../entity";
|
|
36
|
+
import { createTierEngineFeature } from "../feature";
|
|
37
|
+
|
|
38
|
+
const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
|
|
39
|
+
free: { features: [], caps: { maxItems: 1 } },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const tierAssignmentTable = buildDrizzleTable("tier-assignment", tierAssignmentEntity);
|
|
43
|
+
|
|
44
|
+
const features = composeFeatures(
|
|
45
|
+
[createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP })],
|
|
46
|
+
{ includeBundled: true },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
let stack: TestStack;
|
|
50
|
+
const PLATFORM_TENANT = "00000000-0000-4000-8000-000000000001";
|
|
51
|
+
const sysadmin = createTestUser({
|
|
52
|
+
id: "platform-sysadmin",
|
|
53
|
+
tenantId: PLATFORM_TENANT,
|
|
54
|
+
roles: ["SystemAdmin"],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
stack = await setupTestStack({ features });
|
|
59
|
+
await unsafePushTables(stack.db, {
|
|
60
|
+
config_values: configValuesTable,
|
|
61
|
+
users: userTable,
|
|
62
|
+
tenants: tenantTable,
|
|
63
|
+
tenant_memberships: tenantMembershipsTable,
|
|
64
|
+
tier_assignments: tierAssignmentTable,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => stack?.cleanup());
|
|
69
|
+
|
|
70
|
+
describe("auto-default-tier postSave hook on tenant-create", () => {
|
|
71
|
+
test("sysadmin creates a new tenant → free tier-assignment-row angelegt", async () => {
|
|
72
|
+
const data = (await stack.http.writeOk<Record<string, unknown>>(
|
|
73
|
+
TenantHandlers.create,
|
|
74
|
+
{ key: "test-tenant-1", name: "Test Tenant One" },
|
|
75
|
+
sysadmin,
|
|
76
|
+
))!;
|
|
77
|
+
const newTenantId = data["id"] as string;
|
|
78
|
+
expect(typeof newTenantId).toBe("string");
|
|
79
|
+
|
|
80
|
+
const rows = await stack.db
|
|
81
|
+
.select()
|
|
82
|
+
.from(tierAssignmentTable)
|
|
83
|
+
.where(eq(tierAssignmentTable["tenantId"], newTenantId));
|
|
84
|
+
expect(rows.length).toBe(1);
|
|
85
|
+
expect((rows[0] as Record<string, unknown>)["tier"]).toBe("free");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("idempotency: tenant-update fired keinen weiteren tier-assignment-row", async () => {
|
|
89
|
+
const created = (await stack.http.writeOk<Record<string, unknown>>(
|
|
90
|
+
TenantHandlers.create,
|
|
91
|
+
{ key: "test-tenant-2", name: "Test Tenant Two" },
|
|
92
|
+
sysadmin,
|
|
93
|
+
))!;
|
|
94
|
+
const tenantId = created["id"] as string;
|
|
95
|
+
|
|
96
|
+
const existing = (await stack.db
|
|
97
|
+
.select()
|
|
98
|
+
.from(tenantTable)
|
|
99
|
+
.where(eq(tenantTable["id"], tenantId))) as Array<{ id: string; version: number }>;
|
|
100
|
+
const currentVersion = existing[0]!.version;
|
|
101
|
+
|
|
102
|
+
await stack.http.writeOk(
|
|
103
|
+
TenantHandlers.update,
|
|
104
|
+
{
|
|
105
|
+
id: tenantId,
|
|
106
|
+
version: currentVersion,
|
|
107
|
+
changes: { name: "Test Tenant Two — Renamed" },
|
|
108
|
+
},
|
|
109
|
+
sysadmin,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const rows = await stack.db
|
|
113
|
+
.select()
|
|
114
|
+
.from(tierAssignmentTable)
|
|
115
|
+
.where(eq(tierAssignmentTable["tenantId"], tenantId));
|
|
116
|
+
expect(rows.length).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -197,7 +197,7 @@ export function createTierEngineFeature<
|
|
|
197
197
|
// Invalidation: tier-assignment events update the cache.
|
|
198
198
|
r.entityHook("postSave", "tier-assignment", async (result) => {
|
|
199
199
|
// result.data has tenantId + tier (after entity-update merge)
|
|
200
|
-
const data = result.data as { tenantId?: unknown; tier?: unknown };
|
|
200
|
+
const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
|
|
201
201
|
// skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
|
|
202
202
|
// entity-events sind beide fields immer strings; ein malformed-payload
|
|
203
203
|
// (custom-handler-bug) würde hier silent zum cache-skip führen statt
|
|
@@ -210,7 +210,7 @@ export function createTierEngineFeature<
|
|
|
210
210
|
);
|
|
211
211
|
});
|
|
212
212
|
r.entityHook("postDelete", "tier-assignment", async (payload) => {
|
|
213
|
-
const data = payload.data as { tenantId?: unknown };
|
|
213
|
+
const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
|
|
214
214
|
// skip: gleiche type-guard semantik wie postSave-hook oben.
|
|
215
215
|
if (typeof data.tenantId !== "string") return;
|
|
216
216
|
cache.delete(data.tenantId as TenantId);
|
|
@@ -235,16 +235,16 @@ export function createTierEngineFeature<
|
|
|
235
235
|
"tenant",
|
|
236
236
|
async (result, ctx) => {
|
|
237
237
|
// result-shape: kumiko-framework's SaveContext mit isNew + data
|
|
238
|
-
const saveResult = result as { isNew?: unknown; data?: unknown };
|
|
238
|
+
const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
|
|
239
239
|
// skip: nur bei tenant-create (initial) — tenant-updates feuern
|
|
240
240
|
// auch postSave aber wir wollen kein neues tier-assignment bei
|
|
241
241
|
// re-keying oder name-update.
|
|
242
242
|
if (saveResult.isNew !== true) return;
|
|
243
|
-
const data = saveResult.data as { id?: unknown };
|
|
243
|
+
const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
|
|
244
244
|
// skip: defensive type-guard. Tenant-entity hat id zwingend, aber
|
|
245
245
|
// CrudExecutor's payload-shape ist runtime-unknown.
|
|
246
246
|
if (typeof data.id !== "string") return;
|
|
247
|
-
const newTenantId = data.id as TenantId;
|
|
247
|
+
const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
|
|
248
248
|
const aggregateId = tierAssignmentAggregateId(newTenantId);
|
|
249
249
|
|
|
250
250
|
// skip: defensive — inTransaction phase hat ctx.db immer gesetzt,
|
|
@@ -253,12 +253,22 @@ export function createTierEngineFeature<
|
|
|
253
253
|
if (!ctx.db) return;
|
|
254
254
|
|
|
255
255
|
// ctx.db ist im inTransaction-phase eine TenantDb (tenant-scoped
|
|
256
|
-
// proxy auf die echte TX). Für event-store-
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
256
|
+
// proxy auf die echte TX). Für event-store-Pfade brauchen wir
|
|
257
|
+
// die rohe DbConnection — TenantDb exposes nur select/insert/
|
|
258
|
+
// update/delete, NICHT execute (event-store-append.ts:102 ruft
|
|
259
|
+
// db.execute(sql`SELECT pg_notify(...)`) → TypeError sonst).
|
|
260
|
+
// Pattern matched signup-confirm.write.ts:107 (.raw), nicht
|
|
261
|
+
// `as DbConnection` — das ist Type-Lie der erst beim ersten
|
|
262
|
+
// .execute()-Call crashed.
|
|
263
|
+
//
|
|
264
|
+
// AppContext.db ist union (DbConnection | TenantDb). Im
|
|
265
|
+
// inTransaction-phase garantiert TenantDb — der dispatcher
|
|
266
|
+
// wrapped vorher (siehe pipeline/dispatcher.ts createTenantDb-
|
|
267
|
+
// Aufruf). TypeGuard via `"raw" in ...` ist robuster als
|
|
268
|
+
// `as TenantDb` gegen future refactor.
|
|
269
|
+
// skip: defensive — sollte im inTransaction nie greifen.
|
|
270
|
+
if (!("raw" in ctx.db)) return;
|
|
271
|
+
const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
|
|
262
272
|
|
|
263
273
|
// Idempotency: stream-existence-check vor create. Pattern aus
|
|
264
274
|
// seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
|
|
@@ -266,7 +276,7 @@ export function createTierEngineFeature<
|
|
|
266
276
|
const [streamRow] = (await rawDb
|
|
267
277
|
.select({ v: maxFn(eventsTable.version) })
|
|
268
278
|
.from(eventsTable)
|
|
269
|
-
.where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[];
|
|
279
|
+
.where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[]; // @cast-boundary db-row
|
|
270
280
|
// skip: idempotency — aggregate-stream existiert schon (re-replay
|
|
271
281
|
// nach projection-rebuild oder hook-retry). create() würde
|
|
272
282
|
// version_conflict werfen + tenant-create rollback'n. Pattern aus
|
|
@@ -326,7 +336,7 @@ export function createTierEngineFeature<
|
|
|
326
336
|
// Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
|
|
327
337
|
// Bedürfnis entsteht.
|
|
328
338
|
type AssignmentRow = { tenantId: string; tier: string };
|
|
329
|
-
const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[];
|
|
339
|
+
const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[]; // @cast-boundary db-row
|
|
330
340
|
for (const row of rows) {
|
|
331
341
|
cache.set(
|
|
332
342
|
row.tenantId as TenantId,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Drift-Guard fuer USER_STATUS (S2.D2.5 N1).
|
|
2
|
+
//
|
|
3
|
+
// Plus: USER_STATUS_OPTIONS wird nicht direkt exportiert (private im
|
|
4
|
+
// schema/user.ts), wird aber via createSelectField in der Entity
|
|
5
|
+
// referenziert. Wenn USER_STATUS-Object erweitert wird ohne dass das
|
|
6
|
+
// Tuple synchron mitwaechst, faengt der Test es ab — entity.fields.status
|
|
7
|
+
// liefert die options-Liste.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { USER_STATUS, userEntity } from "../schema/user";
|
|
11
|
+
|
|
12
|
+
describe("USER_STATUS — Drift-Guard (S2.D2.5 N1)", () => {
|
|
13
|
+
test("Snapshot-Vergleich: USER_STATUS-Object und entity.fields.status.options synchron", () => {
|
|
14
|
+
const objectValues = Object.values(USER_STATUS).sort();
|
|
15
|
+
// entity.fields.status ist createSelectField — options ist die Tuple
|
|
16
|
+
const statusField = userEntity.fields["status"] as { options: readonly string[] };
|
|
17
|
+
const optionValues = [...statusField.options].sort();
|
|
18
|
+
|
|
19
|
+
expect(optionValues).toEqual(objectValues);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("USER_STATUS-Snapshot — explizit zu updaten bei Aenderungen", () => {
|
|
23
|
+
expect(USER_STATUS).toMatchInlineSnapshot(`
|
|
24
|
+
{
|
|
25
|
+
"Active": "active",
|
|
26
|
+
"Deleted": "deleted",
|
|
27
|
+
"DeletionRequested": "deletionRequested",
|
|
28
|
+
"Restricted": "restricted",
|
|
29
|
+
}
|
|
30
|
+
`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("USER_STATUS-Werte sind camelCase (Convention fuer status-Strings)", () => {
|
|
34
|
+
// Erwartung: alle Werte starten mit lowercase und enthalten kein Leerzeichen
|
|
35
|
+
for (const value of Object.values(USER_STATUS)) {
|
|
36
|
+
expect(value).toMatch(/^[a-z][a-zA-Z]*$/);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -30,7 +30,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
30
30
|
const condition =
|
|
31
31
|
query.payload.email !== undefined
|
|
32
32
|
? eq(userTable["email"], query.payload.email)
|
|
33
|
-
: eq(userTable["id"], query.payload.id as string);
|
|
33
|
+
: eq(userTable["id"], query.payload.id as string); // @cast-boundary engine-payload
|
|
34
34
|
|
|
35
35
|
const [row] = await ctx.db.select().from(userTable).where(condition).limit(1);
|
|
36
36
|
return (row as DbRow) ?? null;
|
package/src/user/index.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
export { UserCommandSchemas } from "./command-schemas";
|
|
2
2
|
export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants";
|
|
3
3
|
export { createUserFeature } from "./feature";
|
|
4
|
-
export {
|
|
4
|
+
export type { UserStatus } from "./schema/user";
|
|
5
|
+
export {
|
|
6
|
+
USER_ANONYMIZED_DISPLAY_NAME,
|
|
7
|
+
USER_ANONYMIZED_EMAIL_DOMAIN,
|
|
8
|
+
USER_ANONYMIZED_EMAIL_PREFIX,
|
|
9
|
+
USER_DELETED_DISPLAY_NAME,
|
|
10
|
+
USER_DELETED_EMAIL_PREFIX,
|
|
11
|
+
USER_STATUS,
|
|
12
|
+
userEntity,
|
|
13
|
+
userTable,
|
|
14
|
+
} from "./schema/user";
|
package/src/user/schema/user.ts
CHANGED
|
@@ -3,9 +3,54 @@ import {
|
|
|
3
3
|
access,
|
|
4
4
|
createBooleanField,
|
|
5
5
|
createEntity,
|
|
6
|
+
createSelectField,
|
|
6
7
|
createTextField,
|
|
8
|
+
createTimestampField,
|
|
7
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* User-Lifecycle-Status (S2.U1). Single source of truth — Auth-Middleware
|
|
13
|
+
* (S2.U6), Forget-Job (S2.U5) und Restriction-Handler nutzen diese
|
|
14
|
+
* Constants statt Magic-Strings (Memory feedback_role_naming_drift —
|
|
15
|
+
* gleiches Pattern wie ROLES.SystemAdmin).
|
|
16
|
+
*/
|
|
17
|
+
export const USER_STATUS = {
|
|
18
|
+
Active: "active",
|
|
19
|
+
Restricted: "restricted",
|
|
20
|
+
DeletionRequested: "deletionRequested",
|
|
21
|
+
Deleted: "deleted",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type UserStatus = (typeof USER_STATUS)[keyof typeof USER_STATUS];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Anonymize-Display-Strings fuer userDeleteHook (S2.H1). Constants statt
|
|
28
|
+
* Magic-Strings damit i18n-Mapping moeglich + drift-fest. Default-DE,
|
|
29
|
+
* App-Author kann via i18n-System uebersetzen wenn gewuenscht.
|
|
30
|
+
*/
|
|
31
|
+
export const USER_DELETED_DISPLAY_NAME = "[Geloescht]";
|
|
32
|
+
export const USER_ANONYMIZED_DISPLAY_NAME = "[Anonymisiert]";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Email-Pseudonyme nach Forget. `<prefix>-<userId>@anonymized.invalid`
|
|
36
|
+
* — der userId-Suffix ist als Pseudo-Audit-Marker fuer Operator
|
|
37
|
+
* (Tracing-fall) erlaubt; user-id selbst ist UUID, kein PII.
|
|
38
|
+
* `.invalid`-TLD ist RFC2606-reserviert — niemals deliverbare Email.
|
|
39
|
+
*/
|
|
40
|
+
export const USER_DELETED_EMAIL_PREFIX = "deleted";
|
|
41
|
+
export const USER_ANONYMIZED_EMAIL_PREFIX = "anonymized";
|
|
42
|
+
export const USER_ANONYMIZED_EMAIL_DOMAIN = "anonymized.invalid";
|
|
43
|
+
|
|
44
|
+
// Tuple form fuer createSelectField (erfordert non-empty readonly tuple).
|
|
45
|
+
// Object.values(USER_STATUS) waere string[] — statisches Tuple ist
|
|
46
|
+
// type-sicher.
|
|
47
|
+
const USER_STATUS_OPTIONS = [
|
|
48
|
+
USER_STATUS.Active,
|
|
49
|
+
USER_STATUS.Restricted,
|
|
50
|
+
USER_STATUS.DeletionRequested,
|
|
51
|
+
USER_STATUS.Deleted,
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
9
54
|
// User entity — tenant-agnostic. A single user can belong to multiple tenants
|
|
10
55
|
// via tenantMemberships. No tenantId column on this table.
|
|
11
56
|
export const userEntity = createEntity({
|
|
@@ -63,6 +108,37 @@ export const userEntity = createEntity({
|
|
|
63
108
|
default: "[]",
|
|
64
109
|
access: { write: access.privileged },
|
|
65
110
|
}),
|
|
111
|
+
|
|
112
|
+
// S2.U1: User-Lifecycle-Status für user-data-rights (Sprint 2).
|
|
113
|
+
// - "active": Normaler State, alle Operationen erlaubt
|
|
114
|
+
// - "restricted": Art. 18 Restriction — Auth-Middleware blockiert
|
|
115
|
+
// Schreib-Endpoints, Read bleibt erlaubt damit
|
|
116
|
+
// User das Banner sieht + lift-restriction klicken kann
|
|
117
|
+
// - "deletionRequested": delete-account aufgerufen, gracePeriodEnd
|
|
118
|
+
// gesetzt, User kann via cancel-deletion zurueck
|
|
119
|
+
// auf "active". Auth-Middleware blockiert wie
|
|
120
|
+
// "restricted".
|
|
121
|
+
// - "deleted": Forget executed nach Grace, Row anonymisiert via
|
|
122
|
+
// softDelete. Auth-Middleware blockt Login.
|
|
123
|
+
//
|
|
124
|
+
// Schreibrecht privileged: nur die request-deletion / restrict / lift /
|
|
125
|
+
// execute-forget-Handler (alle SYSTEM-context) duerfen status flippen.
|
|
126
|
+
status: createSelectField({
|
|
127
|
+
required: true,
|
|
128
|
+
default: USER_STATUS.Active,
|
|
129
|
+
options: USER_STATUS_OPTIONS,
|
|
130
|
+
access: { write: access.privileged },
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
// Wann darf der pending-Forget tatsaechlich ausgefuehrt werden?
|
|
134
|
+
// Cron-Job in user-data-rights checkt taeglich gracePeriodEnd < now()
|
|
135
|
+
// und triggert dann die EXT_USER_DATA-Hooks. NULL solange kein
|
|
136
|
+
// Forget pending — wird beim delete-account-Call gesetzt
|
|
137
|
+
// (= now() + Compliance-Profile.userRights.gracePeriod), beim
|
|
138
|
+
// cancel-deletion zurueckgesetzt.
|
|
139
|
+
gracePeriodEnd: createTimestampField({
|
|
140
|
+
access: { write: access.privileged },
|
|
141
|
+
}),
|
|
66
142
|
},
|
|
67
143
|
});
|
|
68
144
|
|
package/src/user/seeding.ts
CHANGED
|
@@ -57,7 +57,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
|
|
|
57
57
|
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
58
58
|
|
|
59
59
|
const existing = await fetchOne(db, userTable, eq(userTable["email"], options.email));
|
|
60
|
-
if (existing) return existing["id"] as string;
|
|
60
|
+
if (existing) return existing["id"] as string; // @cast-boundary db-row
|
|
61
61
|
|
|
62
62
|
const result = await userExecutor.create(
|
|
63
63
|
{
|
|
@@ -86,7 +86,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
|
|
|
86
86
|
// als ehrlicher Throw rauskommt statt downstream als undefined-Bug.
|
|
87
87
|
function extractId(data: unknown, who: string): string {
|
|
88
88
|
if (typeof data === "object" && data !== null && "id" in data) {
|
|
89
|
-
const id = (data as { id: unknown }).id;
|
|
89
|
+
const id = (data as { id: unknown }).id; // @cast-boundary engine-bridge
|
|
90
90
|
if (typeof id === "string") return id;
|
|
91
91
|
}
|
|
92
92
|
throw new Error(`${who}: executor.create result has no string id (got ${JSON.stringify(data)})`);
|