@cosmicdrift/kumiko-bundled-features 0.37.0 → 0.38.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.
Files changed (43) hide show
  1. package/package.json +5 -5
  2. package/src/auth-email-password/email-templates.ts +4 -0
  3. package/src/auth-email-password/errors.ts +84 -0
  4. package/src/auth-email-password/handlers/change-password.write.ts +1 -10
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
  6. package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
  7. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
  8. package/src/auth-email-password/handlers/login.write.ts +7 -51
  9. package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
  10. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
  11. package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
  12. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
  13. package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
  14. package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
  15. package/src/cap-counter/enforce-cap.ts +5 -0
  16. package/src/compliance-profiles/README.md +1 -1
  17. package/src/custom-fields/__tests__/feature.test.ts +1 -1
  18. package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
  19. package/src/custom-fields/db/queries/retention.ts +1 -0
  20. package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
  21. package/src/custom-fields/run-retention.ts +4 -22
  22. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
  23. package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
  24. package/src/custom-fields/wire-for-entity.ts +4 -12
  25. package/src/custom-fields/wire-user-data-rights.ts +3 -22
  26. package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
  27. package/src/file-foundation/feature.ts +13 -3
  28. package/src/file-foundation/index.ts +1 -0
  29. package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
  30. package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
  31. package/src/files/README.md +1 -1
  32. package/src/subscription-stripe/feature.ts +5 -2
  33. package/src/template-resolver/feature.ts +1 -2
  34. package/src/template-resolver/handlers/list.query.ts +7 -14
  35. package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
  36. package/src/tenant/command-schemas.ts +1 -1
  37. package/src/tenant/feature.ts +1 -2
  38. package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
  39. package/src/user-data-rights/README.md +8 -8
  40. package/src/template-resolver/handlers/archive.write.ts +0 -39
  41. package/src/template-resolver/handlers/publish.write.ts +0 -42
  42. package/src/tenant/handlers/disable.write.ts +0 -18
  43. package/src/tenant/handlers/enable.write.ts +0 -20
@@ -16,22 +16,14 @@
16
16
  // jsonb shape, which would be a breaking schema change.
17
17
 
18
18
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
19
+ import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
19
20
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
21
  import {
21
22
  applyRetentionRemovals,
22
23
  selectFieldDefinitionsWithSerialized,
23
24
  selectHostRowsWithCustomFields,
24
25
  } from "./db/queries/retention";
25
- import { parseSerializedField } from "./lib/parse-serialized-field";
26
-
27
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
28
- function getTableName(table: unknown): string {
29
- if (typeof table === "object" && table !== null) {
30
- const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
31
- if (typeof sym === "string") return sym;
32
- }
33
- throw new Error("custom-fields/run-retention: table missing kumiko:schema:Name symbol");
34
- }
26
+ import { isFieldDefinitionRow, parseSerializedField } from "./lib/parse-serialized-field";
35
27
 
36
28
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
37
29
 
@@ -87,7 +79,7 @@ export async function runCustomFieldsRetention(
87
79
  return { rowsScanned: 0, rowsUpdated: 0, removalsByFieldKey: {} };
88
80
  }
89
81
 
90
- const tableName = getTableName(opts.entityTable);
82
+ const tableName = extractTableName(opts.entityTable, "custom-fields/run-retention");
91
83
  const rows = await selectHostRowsWithCustomFields(opts.db, tableName, opts.tenantId);
92
84
 
93
85
  const removalsByFieldKey: Record<string, number> = {};
@@ -159,17 +151,6 @@ function asHostRow(value: unknown): HostRow | null {
159
151
  };
160
152
  }
161
153
 
162
- interface FieldDefinitionRow {
163
- readonly field_key: string;
164
- readonly serialized_field: unknown;
165
- }
166
-
167
- function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
168
- if (!value || typeof value !== "object") return false;
169
- if (!("field_key" in value)) return false;
170
- return typeof value.field_key === "string";
171
- }
172
-
173
154
  async function loadRetentionPolicies(
174
155
  db: DbRunner,
175
156
  tenantId: string,
@@ -192,6 +173,7 @@ interface InstantLike {
192
173
  readonly epochMilliseconds: number;
193
174
  }
194
175
 
176
+ // guard:dup-ok — false positive: gleiche TypeGuard-Struktur wie isFieldDefinitionRow, völlig andere Semantik
195
177
  function isInstantLike(value: unknown): value is InstantLike {
196
178
  if (!value || typeof value !== "object") return false;
197
179
  if (!("epochMilliseconds" in value)) return false;
@@ -234,3 +234,151 @@ describe("CustomFieldsFormSection", () => {
234
234
  expect(saveBtn.textContent).toBe("Save custom fields");
235
235
  });
236
236
  });
237
+
238
+ describe("CustomFieldsFormSection — clear-Pfad", () => {
239
+ test("Leeren eines gespeicherten Werts dispatched clear-custom-field (nicht skip)", async () => {
240
+ mockedQueryRows = [
241
+ {
242
+ id: "f1",
243
+ entityName: "component",
244
+ fieldKey: "vendor",
245
+ type: "text",
246
+ required: false,
247
+ displayOrder: 1,
248
+ },
249
+ ];
250
+ dispatchSpy.mockClear();
251
+
252
+ render(
253
+ <Wrapper>
254
+ <CustomFieldsFormSection
255
+ entityName="component"
256
+ entityId="row-42"
257
+ initialValues={{ vendor: "Hetzner" }}
258
+ />
259
+ </Wrapper>,
260
+ );
261
+
262
+ const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
263
+ expect(vendorInput.value).toBe("Hetzner");
264
+
265
+ fireEvent.change(vendorInput, { target: { value: "" } });
266
+ fireEvent.click(screen.getByTestId("custom-fields-form-save"));
267
+ await Promise.resolve();
268
+ await Promise.resolve();
269
+
270
+ expect(dispatchSpy).toHaveBeenCalledTimes(1);
271
+ expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:clear-custom-field", {
272
+ entityName: "component",
273
+ entityId: "row-42",
274
+ fieldKey: "vendor",
275
+ });
276
+ });
277
+
278
+ test("unveränderter Bestandswert wird beim Save NICHT erneut geschrieben", async () => {
279
+ mockedQueryRows = [
280
+ {
281
+ id: "f1",
282
+ entityName: "component",
283
+ fieldKey: "vendor",
284
+ type: "text",
285
+ required: false,
286
+ displayOrder: 1,
287
+ },
288
+ ];
289
+ dispatchSpy.mockClear();
290
+
291
+ render(
292
+ <Wrapper>
293
+ <CustomFieldsFormSection
294
+ entityName="component"
295
+ entityId="row-42"
296
+ initialValues={{ vendor: "Hetzner" }}
297
+ />
298
+ </Wrapper>,
299
+ );
300
+
301
+ const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
302
+ // Tippen + zurück auf den Bestandswert → nicht dirty, kein Write.
303
+ fireEvent.change(vendorInput, { target: { value: "Hetzner2" } });
304
+ fireEvent.change(vendorInput, { target: { value: "Hetzner" } });
305
+ fireEvent.click(screen.getByTestId("custom-fields-form-save"));
306
+ await Promise.resolve();
307
+ await Promise.resolve();
308
+
309
+ expect(dispatchSpy).not.toHaveBeenCalled();
310
+ });
311
+ });
312
+
313
+ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
314
+ test("boolean: Bestand wird als true/false-String angezeigt, Save coerced zu boolean", async () => {
315
+ mockedQueryRows = [
316
+ {
317
+ id: "f1",
318
+ entityName: "component",
319
+ fieldKey: "active",
320
+ type: "boolean",
321
+ required: false,
322
+ displayOrder: 1,
323
+ },
324
+ ];
325
+ dispatchSpy.mockClear();
326
+
327
+ render(
328
+ <Wrapper>
329
+ <CustomFieldsFormSection
330
+ entityName="component"
331
+ entityId="row-42"
332
+ initialValues={{ active: true }}
333
+ />
334
+ </Wrapper>,
335
+ );
336
+
337
+ // boolean rendert als Checkbox — Bestand steckt in checked, nicht value.
338
+ const input = document.getElementById("custom-field-active") as HTMLInputElement;
339
+ expect(input.checked).toBe(true);
340
+
341
+ fireEvent.click(input);
342
+ fireEvent.click(screen.getByTestId("custom-fields-form-save"));
343
+ await Promise.resolve();
344
+ await Promise.resolve();
345
+
346
+ expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
347
+ entityName: "component",
348
+ entityId: "row-42",
349
+ fieldKey: "active",
350
+ value: false,
351
+ });
352
+ });
353
+
354
+ test("date: Bestand erreicht den DateInput-Trigger (lokalisierte Anzeige)", () => {
355
+ mockedQueryRows = [
356
+ {
357
+ id: "f1",
358
+ entityName: "component",
359
+ fieldKey: "launchedAt",
360
+ type: "date",
361
+ required: false,
362
+ displayOrder: 1,
363
+ },
364
+ ];
365
+ dispatchSpy.mockClear();
366
+
367
+ render(
368
+ <Wrapper>
369
+ <CustomFieldsFormSection
370
+ entityName="component"
371
+ entityId="row-42"
372
+ initialValues={{ launchedAt: "2026-01-15" }}
373
+ />
374
+ </Wrapper>,
375
+ );
376
+
377
+ // DateInput ist ein Kalender-Trigger-Button (kein <input value>) mit
378
+ // lokalisierter Anzeige — der Kalender-Flow selbst ist jsdom-untauglich
379
+ // (Popover), hier zählt: der Bestand kommt im Trigger an, nicht "—".
380
+ const trigger = document.getElementById("custom-field-launchedAt") as HTMLButtonElement;
381
+ expect(trigger.textContent).toContain("2026");
382
+ expect(trigger.textContent).not.toContain("—");
383
+ });
384
+ });
@@ -92,20 +92,35 @@ export function CustomFieldsFormSection({
92
92
  );
93
93
  }
94
94
 
95
+ // Dirty heißt: weicht vom GESPEICHERTEN Wert ab — nicht von "". Sonst ist
96
+ // das Leeren eines gespeicherten Werts unsichtbar (Button disabled) und
97
+ // handleSave würde es überspringen statt zu clearen.
98
+ const initialDisplay = (field: (typeof matchingFields)[number]): string =>
99
+ displayValue(field.type, initialValues?.[field.fieldKey]);
100
+ const changedFields = matchingFields.filter((field) => {
101
+ const raw = pending[field.fieldKey];
102
+ return raw !== undefined && raw !== initialDisplay(field);
103
+ });
104
+
95
105
  const handleSave = async (): Promise<void> => {
96
106
  setSaving(true);
97
107
  setErrorKey(null);
98
108
  try {
99
- for (const field of matchingFields) {
100
- const raw = pending[field.fieldKey];
101
- if (raw === undefined || raw === "") continue;
102
- const value = coerceValue(field.type, raw);
103
- const result = await dispatcher.write(CustomFieldsHandlers.setCustomField, {
104
- entityName,
105
- entityId,
106
- fieldKey: field.fieldKey,
107
- value,
108
- });
109
+ for (const field of changedFields) {
110
+ const raw = pending[field.fieldKey] ?? "";
111
+ const result =
112
+ raw === ""
113
+ ? await dispatcher.write(CustomFieldsHandlers.clearCustomField, {
114
+ entityName,
115
+ entityId,
116
+ fieldKey: field.fieldKey,
117
+ })
118
+ : await dispatcher.write(CustomFieldsHandlers.setCustomField, {
119
+ entityName,
120
+ entityId,
121
+ fieldKey: field.fieldKey,
122
+ value: coerceValue(field.type, raw),
123
+ });
109
124
  if (!result.isSuccess) {
110
125
  setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
111
126
  return;
@@ -117,7 +132,7 @@ export function CustomFieldsFormSection({
117
132
  }
118
133
  };
119
134
 
120
- const dirty = Object.values(pending).some((v) => v !== "");
135
+ const dirty = changedFields.length > 0;
121
136
 
122
137
  return (
123
138
  <div data-testid="custom-fields-form-section">
@@ -204,6 +219,5 @@ function coerceValue(type: string, raw: string): unknown {
204
219
  function displayValue(type: string, value: unknown): string {
205
220
  if (value === undefined || value === null) return "";
206
221
  if (type === "boolean") return value === true ? "true" : "false";
207
- if (type === "number") return typeof value === "number" ? String(value) : String(value);
208
222
  return String(value);
209
223
  }
@@ -1,3 +1,4 @@
1
+ import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
1
2
  import {
2
3
  createJsonbField,
3
4
  type FeatureRegistrar,
@@ -13,15 +14,6 @@ import {
13
14
  setCustomFieldValue,
14
15
  } from "./db/queries/projection";
15
16
 
16
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
17
- function getTableName(table: unknown): string {
18
- if (typeof table === "object" && table !== null) {
19
- const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
20
- if (typeof sym === "string") return sym;
21
- }
22
- throw new Error("wire-for-entity: table missing kumiko:schema:Name symbol");
23
- }
24
-
25
17
  import type { CustomFieldClearedPayload, CustomFieldSetPayload } from "./events";
26
18
  import { customFieldsFeature } from "./feature";
27
19
 
@@ -107,7 +99,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
107
99
  // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
108
100
  // wird angelegt (create_missing=true ist default). value muss als
109
101
  // jsonb-literal kommen.
110
- const tableName = getTableName(entityTable);
102
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
111
103
  await setCustomFieldValue(
112
104
  tx,
113
105
  tableName,
@@ -124,7 +116,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
124
116
  const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
125
117
 
126
118
  // jsonb minus operator (`-`) entfernt key aus jsonb-object.
127
- const tableName = getTableName(entityTable);
119
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
128
120
  await clearCustomFieldKey(
129
121
  tx,
130
122
  tableName,
@@ -147,7 +139,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
147
139
  // ihre Rows.
148
140
  if (payload.entityName !== entityName) return;
149
141
 
150
- const tableName = getTableName(entityTable);
142
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
151
143
  // Scope cleanup to the deleted definition's owning tenant. System-scope
152
144
  // definitions apply to every tenant → cascade across all rows; tenant-
153
145
  // scope deletions must only touch that tenant's rows, else deleting one
@@ -1,5 +1,6 @@
1
1
  // T1.5c — user-data-rights wiring for custom-fields.
2
2
 
3
+ import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
3
4
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
4
5
  import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
@@ -7,16 +8,7 @@ import {
7
8
  selectFieldDefinitionsForEntity,
8
9
  stripSensitiveCustomFieldKeys,
9
10
  } from "./db/queries/user-data-rights";
10
- import { parseSerializedField } from "./lib/parse-serialized-field";
11
-
12
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
13
- function getTableName(table: unknown): string {
14
- if (typeof table === "object" && table !== null) {
15
- const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
16
- if (typeof sym === "string") return sym;
17
- }
18
- throw new Error("wire-user-data-rights: table missing kumiko:schema:Name symbol");
19
- }
11
+ import { isFieldDefinitionRow, parseSerializedField } from "./lib/parse-serialized-field";
20
12
 
21
13
  export interface WireCustomFieldsUserDataRightsOptions {
22
14
  readonly entityName: string;
@@ -52,7 +44,7 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
52
44
  r: TReg,
53
45
  opts: WireCustomFieldsUserDataRightsOptions,
54
46
  ): void {
55
- const tableName = getTableName(opts.entityTable);
47
+ const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
56
48
 
57
49
  const exportHook: UserDataExportHook = async (ctx) => {
58
50
  const rows = await selectCustomFieldsHostRows(
@@ -100,17 +92,6 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
100
92
  });
101
93
  }
102
94
 
103
- interface FieldDefinitionRow {
104
- readonly field_key: string;
105
- readonly serialized_field: unknown;
106
- }
107
-
108
- function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
109
- if (!value || typeof value !== "object") return false;
110
- if (!("field_key" in value)) return false;
111
- return typeof value.field_key === "string";
112
- }
113
-
114
95
  async function loadSensitiveFieldKeys(
115
96
  db: Parameters<UserDataExportHook>[0]["db"],
116
97
  tenantId: string,
@@ -36,11 +36,11 @@ describe("data-retention :: feature-definition smoke", () => {
36
36
  });
37
37
 
38
38
  test("tenantRetentionOverride-Entity ist registriert", () => {
39
- expect(feature.entities["tenant-retention-override"]).toBeDefined();
39
+ expect(feature.entities?.["tenant-retention-override"]).toBeDefined();
40
40
  });
41
41
 
42
42
  test("Entity-Definition hat UNIQUE(tenantId, entityName) als 1:1-Constraint", () => {
43
- const entity = feature.entities["tenant-retention-override"];
43
+ const entity = feature.entities?.["tenant-retention-override"];
44
44
  const indexes = entity?.indexes ?? [];
45
45
  const uniqueIndex = indexes.find((i) => i.unique === true);
46
46
  expect(uniqueIndex).toBeDefined();
@@ -85,6 +85,12 @@ export type FileProviderPlugin = {
85
85
  readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
86
86
  };
87
87
 
88
+ // extension-usage `options` is engine-payload (unknown) — structurally validate
89
+ // instead of casting blind.
90
+ export function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
91
+ return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
92
+ }
93
+
88
94
  // =============================================================================
89
95
  // Feature-definition
90
96
  // =============================================================================
@@ -163,7 +169,11 @@ export async function createFileProviderForTenant(
163
169
  );
164
170
  }
165
171
 
166
- // @cast-boundary engine-payload — extension-usage carries unknown options
167
- const plugin = usage.options as FileProviderPlugin;
168
- return plugin.build(ctx, tenantId);
172
+ if (!isFileProviderPlugin(usage.options)) {
173
+ throw new Error(
174
+ `${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
175
+ `extension options must be a FileProviderPlugin.`,
176
+ );
177
+ }
178
+ return usage.options.build(ctx, tenantId);
169
179
  }
@@ -5,4 +5,5 @@ export {
5
5
  type FileProviderContext,
6
6
  type FileProviderPlugin,
7
7
  fileFoundationFeature,
8
+ isFileProviderPlugin,
8
9
  } from "./feature";
@@ -1,7 +1,10 @@
1
1
  // feature.ts contract tests for file-provider-inmemory.
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
- import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
4
+ import {
5
+ type FileProviderPlugin,
6
+ isFileProviderPlugin,
7
+ } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
5
8
  import { clearStorage, fileProviderInMemoryFeature, listKeys } from "../feature";
6
9
 
7
10
  describe("fileProviderInMemoryFeature — shape", () => {
@@ -35,12 +38,6 @@ describe("listKeys / clearStorage — per-tenant store helpers", () => {
35
38
  });
36
39
  });
37
40
 
38
- // extension-usage `options` is engine-payload (unknown) — structurally validate
39
- // instead of casting blind.
40
- function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
41
- return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
42
- }
43
-
44
41
  function inmemoryPlugin(): FileProviderPlugin {
45
42
  const options = fileProviderInMemoryFeature.extensionUsages.find(
46
43
  (u) => u.extensionName === "fileProvider" && u.entityName === "inmemory",
@@ -1,7 +1,10 @@
1
1
  // feature.ts contract tests for file-provider-s3.
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
- import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
4
+ import {
5
+ type FileProviderPlugin,
6
+ isFileProviderPlugin,
7
+ } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
5
8
  import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../feature";
6
9
 
7
10
  describe("fileProviderS3Feature — shape", () => {
@@ -54,11 +57,6 @@ describe("fileProviderS3Feature — plugin-registration", () => {
54
57
  });
55
58
  });
56
59
 
57
- // extension-usage `options` is engine-payload (unknown) — structurally validate.
58
- function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
59
- return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
60
- }
61
-
62
60
  function s3Plugin(): FileProviderPlugin {
63
61
  const options = fileProviderS3Feature.extensionUsages.find(
64
62
  (u) => u.extensionName === "fileProvider" && u.entityName === "s3",
@@ -46,5 +46,5 @@ Sprint 4).
46
46
 
47
47
  ## Tests
48
48
 
49
- `__tests__/files.integration.ts` — 5 Tests die beweisen dass die Feature-
49
+ `__tests__/files.integration.test.ts` — 5 Tests die beweisen dass die Feature-
50
50
  Definition clean lädt + die PII-Markers + Tabellenname stimmen.
@@ -65,8 +65,11 @@ export const subscriptionStripeEnvSchema = z.object({
65
65
  .meta({ kumiko: { pulumi: { secret: true } } }),
66
66
  STRIPE_API_KEY: z
67
67
  .string()
68
- .regex(/^sk_(test|live)_/, "STRIPE_API_KEY must start with 'sk_test_' or 'sk_live_'")
69
- .describe("Stripe API key (`sk_live_...` / `sk_test_...`).")
68
+ .regex(
69
+ /^(sk|rk)_(test|live)_/,
70
+ "STRIPE_API_KEY must start with 'sk_test_'/'sk_live_' or a restricted 'rk_test_'/'rk_live_' key",
71
+ )
72
+ .describe("Stripe API key (`sk_live_...` / `sk_test_...`, restricted `rk_...` keys allowed).")
70
73
  .meta({ kumiko: { pulumi: { secret: true } } }),
71
74
  });
72
75
 
@@ -1,8 +1,7 @@
1
1
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
- import { archiveWrite } from "./handlers/archive.write";
3
2
  import { findByIdQuery } from "./handlers/find-by-id.query";
4
3
  import { listQuery } from "./handlers/list.query";
5
- import { publishWrite } from "./handlers/publish.write";
4
+ import { archiveWrite, publishWrite } from "./handlers/toggle-status.write";
6
5
  import { upsertSystemWrite } from "./handlers/upsert-system.write";
7
6
  import { upsertTenantWrite } from "./handlers/upsert-tenant.write";
8
7
  import { templateResourceEntity } from "./table";
@@ -1,5 +1,5 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
4
  import { RENDER_KINDS, TEMPLATE_STATUSES } from "../constants";
5
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table";
@@ -17,14 +17,13 @@ export const listQuery = defineQueryHandler({
17
17
  }),
18
18
  access: { roles: ["TenantAdmin", "SystemAdmin", "User"] },
19
19
  handler: async (query, ctx) => {
20
- const isSystemAdmin = query.user.roles.includes("SystemAdmin");
21
20
  const where: Record<string, unknown> = {};
22
21
 
23
- // TenantDb scopes non-SystemAdmin reads to [own tenant, SYSTEM reference] and
24
- // refuses a caller-narrowed where.tenantId (enforced isolation). SystemAdmin
25
- // uses a system-scoped db that sees every tenant, so narrow to own explicitly
26
- // when they don't want the cross-tenant view.
27
- if (isSystemAdmin && !query.payload.includeSystem) {
22
+ // includeSystem=false narrows to own tenant AT THE DB — for non-SystemAdmin
23
+ // TenantDb permits narrowing within its enforced [own, SYSTEM] scope, for
24
+ // SystemAdmin (system-scoped db) the where applies verbatim. Filtering at
25
+ // the DB keeps the limit meaningful (no post-filter starvation).
26
+ if (!query.payload.includeSystem) {
28
27
  where["tenantId"] = query.user.tenantId;
29
28
  }
30
29
 
@@ -44,13 +43,7 @@ export const listQuery = defineQueryHandler({
44
43
  limit: 500,
45
44
  })) as TemplateResourceRow[];
46
45
 
47
- // TenantDb always surfaces SYSTEM reference rows alongside the tenant's own;
48
- // includeSystem=false drops them here since they can't be excluded at the DB.
49
- const visible = query.payload.includeSystem
50
- ? rows
51
- : rows.filter((row) => row.tenantId !== SYSTEM_TENANT_ID);
52
-
53
- return visible.map((row) => ({
46
+ return rows.map((row) => ({
54
47
  id: String(row.id),
55
48
  tenantId: row.tenantId,
56
49
  slug: row.slug,
@@ -0,0 +1,37 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { z } from "zod";
5
+ import type { TemplateResourceRow } from "../table";
6
+ import { templateResourcesTable } from "../table";
7
+ import { executor } from "./shared";
8
+
9
+ type TemplateStatus = "active" | "archived";
10
+
11
+ function createStatusUpdateHandler(name: string, status: TemplateStatus) {
12
+ return defineWriteHandler({
13
+ name,
14
+ schema: z.object({ id: z.string().min(1) }),
15
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
16
+ handler: async (event, ctx) => {
17
+ // ctx.db is tenant-scoped: a foreign tenant's id reads as absent → NotFound.
18
+ // Cross-tenant toggling needs SystemAdmin with tenantIdOverride.
19
+ const existing = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
20
+ id: event.payload.id,
21
+ });
22
+ if (!existing) {
23
+ return writeFailure(new NotFoundError("template-resource", event.payload.id));
24
+ }
25
+ const result = await executor.update(
26
+ { id: existing.id, version: existing.version, changes: { status } },
27
+ event.user,
28
+ ctx.db,
29
+ );
30
+ if (!result.isSuccess) return result;
31
+ return { isSuccess: true as const, data: { id: String(existing.id), status } };
32
+ },
33
+ });
34
+ }
35
+
36
+ export const archiveWrite = createStatusUpdateHandler("archive", "archived");
37
+ export const publishWrite = createStatusUpdateHandler("publish", "active");
@@ -22,8 +22,8 @@
22
22
 
23
23
  import { addMemberWrite } from "./handlers/add-member.write";
24
24
  import { createWrite } from "./handlers/create.write";
25
- import { disableWrite } from "./handlers/disable.write";
26
25
  import { removeMemberWrite } from "./handlers/remove-member.write";
26
+ import { disableWrite } from "./handlers/toggle-enabled.write";
27
27
  import { updateWrite } from "./handlers/update.write";
28
28
  import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
29
29
 
@@ -9,8 +9,6 @@ import { activeTenantIdsQuery } from "./handlers/active-tenant-ids.query";
9
9
  import { addMemberWrite } from "./handlers/add-member.write";
10
10
  import { cancelInvitationWrite } from "./handlers/cancel-invitation.write";
11
11
  import { createWrite } from "./handlers/create.write";
12
- import { disableWrite } from "./handlers/disable.write";
13
- import { enableWrite } from "./handlers/enable.write";
14
12
  import { invitationsQuery } from "./handlers/invitations.query";
15
13
  import { listQuery } from "./handlers/list.query";
16
14
  import { meQuery } from "./handlers/me.query";
@@ -18,6 +16,7 @@ import { membersQuery } from "./handlers/members.query";
18
16
  import { membershipsQuery } from "./handlers/memberships.query";
19
17
  import { removeMemberWrite } from "./handlers/remove-member.write";
20
18
  import { resolveUserIdsQuery } from "./handlers/resolve-user-ids.query";
19
+ import { disableWrite, enableWrite } from "./handlers/toggle-enabled.write";
21
20
  import { updateWrite } from "./handlers/update.write";
22
21
  import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
23
22
  import { tenantInvitationEntity } from "./invitation-table";
@@ -0,0 +1,23 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { tenantEntity, tenantTable } from "../schema/tenant";
5
+
6
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
7
+
8
+ // Admin flip: last-writer-wins is fine. SystemAdmin is the only caller and
9
+ // there's no meaningful concurrent-edit race on this single boolean.
10
+ function createToggleTenantHandler(enable: boolean) {
11
+ return defineWriteHandler({
12
+ name: enable ? "enable" : "disable",
13
+ schema: z.object({ id: z.uuid() }),
14
+ access: { roles: ["SystemAdmin"] },
15
+ handler: async (event, ctx) =>
16
+ crud.update({ id: event.payload.id, changes: { isEnabled: enable } }, event.user, ctx.db, {
17
+ skipOptimisticLock: true,
18
+ }),
19
+ });
20
+ }
21
+
22
+ export const enableWrite = createToggleTenantHandler(true);
23
+ export const disableWrite = createToggleTenantHandler(false);