@cosmicdrift/kumiko-bundled-features 0.5.1 → 0.6.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 CHANGED
@@ -1,5 +1,98 @@
1
1
  # @cosmicdrift/kumiko-bundled-features
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8489d18: feat(es-ops): Phase 1.5 — tenantIdOverride + dry-run-validator + E2E-Test + Doku
8
+
9
+ Phase 1.5 schließt die Lücken aus Phase 1 die den ersten Driver-Use-Case
10
+ (publicstatus admin-roles) blockten. Siehe Retro:
11
+ `kumiko-platform/docs/plans/features/es-ops-phase1-retro.md` (PR #9).
12
+
13
+ **A1 — tenantIdOverride:**
14
+ `SeedMigrationContext.systemWriteAs(qn, payload, tenantIdOverride?)`.
15
+ Default SYSTEM_TENANT_ID (unverändert für System-scope-Aggregates wie
16
+ config-values). Mit override: `createSystemUser(tenantIdOverride)` als
17
+ Executor, damit der Event-Store-Executor den Aggregate-Stream im
18
+ richtigen Tenant findet. Fix für die `version_conflict`-Klasse-Bug
19
+ (Memory `feedback_event_store_tenant_consistency.md`).
20
+
21
+ **A2 — dry-run-validator:**
22
+ Runner parsed seed-files vor `migration.run()` per regex
23
+ `systemWriteAs\(["']([^"']+)["']`, sammelt handler-QNs, validiert
24
+ gegen `registry.getWriteHandler(qn)`. Fail-fast mit klarer Message
25
+
26
+ - Datei + QN statt zur Runtime "handler not found". Catched camelCase-
27
+ typos (kebab-case-vs-camelCase Drift) + andere QN-Drift zur Boot-Zeit.
28
+ runProdApp reicht den richtigen Registry rein (`registry` neu in
29
+ RunPendingSeedMigrationsArgs).
30
+
31
+ **A3 — E2E-Test:**
32
+ `packages/bundled-features/src/__tests__/es-ops-e2e.integration.ts`
33
+ mit `setupTestStack`-Pattern: tenant+config Features echt geladen,
34
+ echtes Membership-Aggregate via TenantHandlers.addMember im Demo-Tenant,
35
+ seed-migration ruft update-member-roles mit tenantIdOverride → write
36
+ geht durch, Marker landed, Event in Store, Read-Model aktualisiert.
37
+ Plus typo-Test: seed mit camelCase fail-t Dry-Run mit
38
+ `/dry-run found.*unknown handler-QN/`. **TDD-First**: ohne A1+A2 wäre
39
+ der test rot.
40
+
41
+ **A4 — Doku:**
42
+ `framework/src/es-ops/README.md` erweitert um „Wann brauche ich
43
+ tenantIdOverride?" + „Deployment-Anforderungen" (Docker COPY, Idempotenz,
44
+ Multi-Replica) + „Lokaler Smoke vor Push". Recipe-README + seed-files
45
+ auf neue API aktualisiert.
46
+
47
+ **A5 — Smoke-Skript-Template:**
48
+ `samples/recipes/seed-migration/scripts/smoke.ts` als copy-paste-Template
49
+ für App-Authors: Bun-runnable, offline (read-only, kein DB-Write),
50
+ validiert Module-Load + QN-Resolution + System-User-Access. Recipe-
51
+ README dokumentiert Pflicht-Pattern.
52
+
53
+ **Bonus-Fix:**
54
+ `tenant:write:create`-access auf `["system", "SystemAdmin"]` erweitert
55
+ (symmetrisch zu update-member-roles). Aufgedeckt durch Recipe-Smoke +
56
+ initial-tenants-Seed. Pinning-Test in `tenant.integration.ts` updated.
57
+
58
+ **Test-State:** 45/45 grün (Pre-Push). Typecheck clean. Biome clean.
59
+ as-cast-Audit clean. Guard-silent-skip clean. Recipe-Smoke clean.
60
+
61
+ **Folge-Step (separater PR):** publicstatus driver-sample reaktivieren
62
+ mit lokalem Pre-Push-Smoke gegen publicstatus' echtes Feature-Set.
63
+
64
+ ### Patch Changes
65
+
66
+ - Updated dependencies [8489d18]
67
+ - @cosmicdrift/kumiko-framework@0.6.0
68
+ - @cosmicdrift/kumiko-dispatcher-live@0.6.0
69
+ - @cosmicdrift/kumiko-renderer@0.6.0
70
+ - @cosmicdrift/kumiko-renderer-web@0.6.0
71
+
72
+ ## 0.5.2
73
+
74
+ ### Patch Changes
75
+
76
+ - 4f0d781: fix(tenant): updateMemberRoles erlaubt "system"-Rolle (symmetrisch zu create)
77
+
78
+ Drift innerhalb des tenant-Features: `tenant:write:create` akzeptierte
79
+ `["system", "SystemAdmin"]`, `tenant:write:update-member-roles` aber
80
+ nur `["SystemAdmin"]`. Konsequenz: ops-tooling und seed-migrations
81
+ (`createSystemUser` mit `roles: ["system"]`) konnten den Handler nicht
82
+ aufrufen — `access_denied`.
83
+
84
+ Live entdeckt beim ersten Driver-Sample der es-ops Phase 1: publicstatus
85
+ seed `2026-05-20-fix-admin-roles.ts` rief `update-member-roles` via
86
+ `systemWriteAs` → access_denied → Pod CrashLoopBackOff.
87
+
88
+ Plus access-rule-Pinning-Test in `tenant.integration.ts`-scenario-7.
89
+
90
+ - Updated dependencies [4f0d781]
91
+ - @cosmicdrift/kumiko-framework@0.5.2
92
+ - @cosmicdrift/kumiko-dispatcher-live@0.5.2
93
+ - @cosmicdrift/kumiko-renderer@0.5.2
94
+ - @cosmicdrift/kumiko-renderer-web@0.5.2
95
+
3
96
  ## 0.5.1
4
97
 
5
98
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.5.1",
3
+ "version": "0.6.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>",
@@ -74,10 +74,10 @@
74
74
  "@aws-sdk/client-s3": "^3.1045.0",
75
75
  "@aws-sdk/lib-storage": "^3.1045.0",
76
76
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
77
- "@cosmicdrift/kumiko-dispatcher-live": "0.5.1",
78
- "@cosmicdrift/kumiko-framework": "0.5.1",
79
- "@cosmicdrift/kumiko-renderer": "0.5.1",
80
- "@cosmicdrift/kumiko-renderer-web": "0.5.1",
77
+ "@cosmicdrift/kumiko-dispatcher-live": "0.6.0",
78
+ "@cosmicdrift/kumiko-framework": "0.6.0",
79
+ "@cosmicdrift/kumiko-renderer": "0.6.0",
80
+ "@cosmicdrift/kumiko-renderer-web": "0.6.0",
81
81
  "@mollie/api-client": "^4.5.0",
82
82
  "@node-rs/argon2": "^2.0.2",
83
83
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,211 @@
1
+ // @no-server-stack: seed-runner ist boot-time-Code, kein HTTP-route.
2
+ // setupTestStack/buildServer würden eine Hono-app aufziehen die wir nicht
3
+ // brauchen — der seed-runner ruft dispatcher.write direkt vor dem
4
+ // entrypoint.start(). Pattern matched die echte run-prod-app.ts-Integration
5
+ // (siehe run-prod-app.ts:632 — createDispatcher mit identical ctx-shape
6
+ // inline gebaut bevor entrypoint.start()).
7
+ //
8
+ // End-to-End-Integration-Test gegen real-Stack (Phase 1.5 / A3).
9
+ // Catched die Bug-Klassen die runner.integration.ts mit Mock-Dispatcher
10
+ // NICHT abdeckt:
11
+ // - handler-QN-Resolution (Bug 3)
12
+ // - access-rule-realität (Bug 4)
13
+ // - tenantId-stream-matching (Bug 5)
14
+ //
15
+ // Setup: createTestDb + tenant/config-features. Echtes Aggregate im
16
+ // Demo-Tenant via TenantHandlers.addMember. Seed-migration ruft
17
+ // updateMemberRoles auf — MUSS tenantIdOverride nutzen sonst
18
+ // version_conflict.
19
+
20
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { createRegistry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
24
+ import {
25
+ createEsOperationsTable,
26
+ createSeedMigrationContext,
27
+ esOperationsTable,
28
+ runPendingSeedMigrations,
29
+ } from "@cosmicdrift/kumiko-framework/es-ops";
30
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
31
+ import { createDispatcher, type Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
32
+ import {
33
+ createTestDb,
34
+ type TestDb,
35
+ TestUsers,
36
+ unsafeCreateEntityTable,
37
+ unsafePushTables,
38
+ } from "@cosmicdrift/kumiko-framework/stack";
39
+ import { sql } from "drizzle-orm";
40
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
41
+ import { createConfigFeature } from "../config/feature";
42
+ import { createConfigResolver } from "../config/resolver";
43
+ import { configValuesTable } from "../config/table";
44
+ import { TenantHandlers } from "../tenant/constants";
45
+ import { createTenantFeature } from "../tenant/feature";
46
+ import { tenantMembershipsTable } from "../tenant/membership-table";
47
+ import { tenantEntity } from "../tenant/schema/tenant";
48
+
49
+ let testDb: TestDb;
50
+ let dispatcher: Dispatcher;
51
+ let registry: ReturnType<typeof createRegistry>;
52
+
53
+ const systemAdmin = TestUsers.systemAdmin;
54
+ // Demo-Tenant — NICHT SYSTEM_TENANT. Echter App-Realität (publicstatus
55
+ // hat seine Memberships in Demo-Tenants `...0001` / `...0002`).
56
+ const demoTenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
57
+ const adminUserId = "01900000-0000-7000-8000-000000000aaa";
58
+
59
+ beforeAll(async () => {
60
+ testDb = await createTestDb();
61
+ await unsafeCreateEntityTable(testDb.db, tenantEntity);
62
+ await unsafePushTables(testDb.db, { tenantMembershipsTable, configValuesTable });
63
+ await createEventsTable(testDb.db);
64
+ await createEsOperationsTable(testDb.db);
65
+
66
+ registry = createRegistry([createConfigFeature(), createTenantFeature()]);
67
+ const resolver = createConfigResolver();
68
+ dispatcher = createDispatcher(registry, {
69
+ db: testDb.db,
70
+ redis: undefined as never,
71
+ entityCache: undefined as never,
72
+ registry,
73
+ configResolver: resolver,
74
+ });
75
+ });
76
+
77
+ afterAll(async () => {
78
+ await testDb.cleanup();
79
+ });
80
+
81
+ beforeEach(async () => {
82
+ await testDb.db.execute(sql`
83
+ TRUNCATE kumiko_es_operations, kumiko_events, read_tenants, read_tenant_memberships
84
+ `);
85
+ });
86
+
87
+ function makeTempSeedsDir(files: readonly { name: string; content: string }[]): string {
88
+ const dir = mkdtempSync(join(tmpdir(), "es-ops-e2e-"));
89
+ for (const f of files) writeFileSync(join(dir, f.name), f.content);
90
+ return dir;
91
+ }
92
+
93
+ describe("es-ops Phase 1.5 — E2E gegen real-Stack", () => {
94
+ test("seed-migration kann updateMemberRoles auf aggregate in Demo-Tenant aufrufen (tenantIdOverride)", async () => {
95
+ // Setup-Stage: tenant + membership ES-konform via Handler erstellen.
96
+ // event lebt im demoTenant-stream.
97
+ const tenantRes = await dispatcher.write(
98
+ TenantHandlers.create,
99
+ { id: demoTenantId, key: "demo", name: "Demo Tenant" },
100
+ { ...systemAdmin, tenantId: demoTenantId },
101
+ );
102
+ expect(tenantRes.isSuccess).toBe(true);
103
+
104
+ const addRes = await dispatcher.write(
105
+ TenantHandlers.addMember,
106
+ { userId: adminUserId, tenantId: demoTenantId, roles: ["Admin"] },
107
+ { ...systemAdmin, tenantId: demoTenantId },
108
+ );
109
+ expect(addRes.isSuccess).toBe(true);
110
+
111
+ // Plant: seed migriert die Rolle. KEY: tenantIdOverride = demoTenantId,
112
+ // sonst greift SYSTEM_TENANT_ID-default und write geht in version_conflict.
113
+ const dir = makeTempSeedsDir([
114
+ {
115
+ name: "2026-05-21-add-tenant-admin.ts",
116
+ content: `
117
+ export default {
118
+ description: "ergänze TenantAdmin auf admin-membership im demo-tenant",
119
+ run: async (ctx) => {
120
+ const memberships = await ctx.findMembershipsOfUser("${adminUserId}");
121
+ for (const m of memberships) {
122
+ if (m.roles.includes("TenantAdmin")) continue;
123
+ await ctx.systemWriteAs(
124
+ "tenant:write:update-member-roles",
125
+ { userId: "${adminUserId}", tenantId: m.tenantId, roles: [...m.roles, "TenantAdmin"] },
126
+ m.tenantId, // ← tenantIdOverride (Phase 1.5 API)
127
+ );
128
+ }
129
+ },
130
+ };
131
+ `,
132
+ },
133
+ ]);
134
+
135
+ try {
136
+ const result = await runPendingSeedMigrations({
137
+ db: testDb.db,
138
+ seedsDir: dir,
139
+ appliedBy: "boot",
140
+ registry,
141
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
142
+ logger: () => {},
143
+ });
144
+ expect(result.appliedIds).toEqual(["2026-05-21-add-tenant-admin"]);
145
+
146
+ // Verify (a) marker
147
+ const markers = await testDb.db.select().from(esOperationsTable);
148
+ expect(markers).toHaveLength(1);
149
+ expect(markers[0]?.id).toBe("2026-05-21-add-tenant-admin");
150
+
151
+ // Verify (b) event in store mit tenant-membership.updated
152
+ const events = (await testDb.db.execute(
153
+ sql`SELECT type, tenant_id::text AS tenant_id FROM kumiko_events ORDER BY id`,
154
+ )) as unknown as readonly { type: string; tenant_id: string }[];
155
+ const updateEvents = events.filter((e) => e.type === "tenant-membership.updated");
156
+ expect(updateEvents).toHaveLength(1);
157
+ expect(updateEvents[0]?.tenant_id).toBe(demoTenantId);
158
+
159
+ // Verify (c) read-model aktualisiert
160
+ const memberships = (await testDb.db.execute(
161
+ sql`SELECT roles FROM read_tenant_memberships WHERE user_id = ${adminUserId}`,
162
+ )) as unknown as readonly { roles: string }[];
163
+ expect(JSON.parse(memberships[0]?.roles ?? "[]")).toEqual(["Admin", "TenantAdmin"]);
164
+ } finally {
165
+ rmSync(dir, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test("seed-migration mit handler-QN-typo fail-t dry-run (vor write)", async () => {
170
+ // Phase 1.5 / A2 — seed-dry-run-validator soll camelCase-typo catchen
171
+ // BEVOR der dispatcher den write versucht.
172
+ const dir = makeTempSeedsDir([
173
+ {
174
+ name: "2026-05-21-bad-qn.ts",
175
+ content: `
176
+ export default {
177
+ description: "uses camelCase typo for handler-QN",
178
+ run: async (ctx) => {
179
+ await ctx.systemWriteAs(
180
+ "tenant:write:updateMemberRoles", // ← camelCase typo
181
+ { userId: "x", tenantId: "y", roles: ["z"] },
182
+ "y",
183
+ );
184
+ },
185
+ };
186
+ `,
187
+ },
188
+ ]);
189
+ try {
190
+ await expect(
191
+ runPendingSeedMigrations({
192
+ db: testDb.db,
193
+ seedsDir: dir,
194
+ appliedBy: "boot",
195
+ registry, // ← dry-run sieht den typo, wirft mit klarer message
196
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
197
+ logger: () => {},
198
+ }),
199
+ ).rejects.toThrow(
200
+ // Phase 1.5 / A2 — Dry-Run-validator wirft mit der spezifischen
201
+ // Phrase "dry-run found ... unknown handler-QN" + dem qn im body.
202
+ /dry-run found.*unknown handler-QN/,
203
+ );
204
+
205
+ const markers = await testDb.db.select().from(esOperationsTable);
206
+ expect(markers).toHaveLength(0);
207
+ } finally {
208
+ rmSync(dir, { recursive: true, force: true });
209
+ }
210
+ });
211
+ });
@@ -327,7 +327,9 @@ describe("scenario 6: config integration with tenant", () => {
327
327
 
328
328
  describe("scenario 7: access rules on handlers", () => {
329
329
  test("all handlers have correct access rules", async () => {
330
+ // "system" für seed-migrations + ops-tooling, "SystemAdmin" für UI-Operator
330
331
  expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.create)?.access)).toEqual([
332
+ "system",
331
333
  "SystemAdmin",
332
334
  ]);
333
335
  expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.update)?.access)).toEqual([
@@ -337,6 +339,11 @@ describe("scenario 7: access rules on handlers", () => {
337
339
  expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.disable)?.access)).toEqual([
338
340
  "SystemAdmin",
339
341
  ]);
342
+ // updateMemberRoles akzeptiert "system" (für seed-migrations + ops-tooling)
343
+ // PLUS "SystemAdmin" (echter Operator-Pfad). Symmetrisch zu create.
344
+ expect(
345
+ rolesOf(stack.registry.getWriteHandler(TenantHandlers.updateMemberRoles)?.access),
346
+ ).toEqual(["system", "SystemAdmin"]);
340
347
  expect(rolesOf(stack.registry.getQueryHandler(TenantQueries.list)?.access)).toEqual([
341
348
  "SystemAdmin",
342
349
  ]);
@@ -16,6 +16,10 @@ export const createWrite = defineWriteHandler({
16
16
  key: z.string().min(1).max(50),
17
17
  name: z.string().min(1).max(200),
18
18
  }),
19
- access: { roles: ["SystemAdmin"] },
19
+ // "system" + "SystemAdmin" — symmetrisch zu update-member-roles.
20
+ // ops-tooling (seed-migrations + sample-recipes) nutzen System-User
21
+ // (roles=["system"]) als Executor; "SystemAdmin" bleibt der echte
22
+ // human-Operator-Pfad über die UI.
23
+ access: { roles: ["system", "SystemAdmin"] },
20
24
  handler: async (event, ctx) => crud.create(event.payload, event.user, ctx.db),
21
25
  });
@@ -16,7 +16,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
16
16
  tenantId: z.string(),
17
17
  roles: z.array(z.string()).min(1),
18
18
  }),
19
- access: { roles: ["SystemAdmin"] },
19
+ // "system" + "SystemAdmin" — symmetrisch zu tenant:write:create. System-
20
+ // User (createSystemUser, roles=["system"]) braucht den Access für seed-
21
+ // migrations + andere ops-tooling-Pfade. SystemAdmin ist der echte
22
+ // human-Operator-Pfad über die UI.
23
+ access: { roles: ["system", "SystemAdmin"] },
20
24
  handler: async (event, ctx) => {
21
25
  const db = ctx.db;
22
26
  const existing = await fetchOne(