@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.
- package/package.json +5 -5
- package/src/auth-email-password/email-templates.ts +4 -0
- package/src/auth-email-password/errors.ts +84 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -10
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
- package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
- package/src/auth-email-password/handlers/login.write.ts +7 -51
- package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
- package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
- package/src/cap-counter/enforce-cap.ts +5 -0
- package/src/compliance-profiles/README.md +1 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -1
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
- package/src/custom-fields/db/queries/retention.ts +1 -0
- package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
- package/src/custom-fields/run-retention.ts +4 -22
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
- package/src/custom-fields/wire-for-entity.ts +4 -12
- package/src/custom-fields/wire-user-data-rights.ts +3 -22
- package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
- package/src/file-foundation/feature.ts +13 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
- package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
- package/src/files/README.md +1 -1
- package/src/subscription-stripe/feature.ts +5 -2
- package/src/template-resolver/feature.ts +1 -2
- package/src/template-resolver/handlers/list.query.ts +7 -14
- package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
- package/src/tenant/command-schemas.ts +1 -1
- package/src/tenant/feature.ts +1 -2
- package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
- package/src/user-data-rights/README.md +8 -8
- package/src/template-resolver/handlers/archive.write.ts +0 -39
- package/src/template-resolver/handlers/publish.write.ts +0 -42
- package/src/tenant/handlers/disable.write.ts +0 -18
- 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 =
|
|
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
|
|
100
|
-
const raw = pending[field.fieldKey];
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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",
|
package/src/files/README.md
CHANGED
|
@@ -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(
|
|
69
|
-
|
|
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/
|
|
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
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
if (
|
|
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
|
-
|
|
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
|
|
package/src/tenant/feature.ts
CHANGED
|
@@ -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);
|