@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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 +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
- package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/field-access.ts +1 -1
- package/src/custom-fields/db/queries/projection.ts +13 -5
- package/src/custom-fields/db/queries/quota.ts +1 -1
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/lib/field-access.ts +4 -0
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +1 -1
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
- package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
- package/src/foundation-shared/config-helpers.ts +7 -3
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
- package/src/tier-engine/feature.ts +8 -2
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
// `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`
|
|
11
11
|
// referenziert.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
useDispatcher,
|
|
15
|
+
usePrimitives,
|
|
16
|
+
useQuery,
|
|
17
|
+
useTranslation,
|
|
18
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
14
19
|
import { type ReactNode, useState } from "react";
|
|
15
20
|
import { CustomFieldsHandlers, CustomFieldsQueries } from "../constants";
|
|
16
21
|
|
|
@@ -35,8 +40,13 @@ export function CustomFieldsFormSection({
|
|
|
35
40
|
readonly entityId: string | null;
|
|
36
41
|
}): ReactNode {
|
|
37
42
|
const { Banner, Button, Field, Input, Text } = usePrimitives();
|
|
43
|
+
const t = useTranslation();
|
|
38
44
|
const dispatcher = useDispatcher();
|
|
39
|
-
const query = useQuery<FieldDefinitionListResponse>(
|
|
45
|
+
const query = useQuery<FieldDefinitionListResponse>(
|
|
46
|
+
CustomFieldsQueries.fieldDefinitionList,
|
|
47
|
+
{},
|
|
48
|
+
{ enabled: entityId !== null },
|
|
49
|
+
);
|
|
40
50
|
const [pending, setPending] = useState<Readonly<Record<string, string>>>({});
|
|
41
51
|
const [saving, setSaving] = useState(false);
|
|
42
52
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
@@ -44,21 +54,21 @@ export function CustomFieldsFormSection({
|
|
|
44
54
|
if (entityId === null) {
|
|
45
55
|
return (
|
|
46
56
|
<Banner variant="info" testId="custom-fields-form-create-mode">
|
|
47
|
-
<Text>
|
|
57
|
+
<Text>{t("custom-fields.form.createMode")}</Text>
|
|
48
58
|
</Banner>
|
|
49
59
|
);
|
|
50
60
|
}
|
|
51
61
|
if (query.loading && query.data === null) {
|
|
52
62
|
return (
|
|
53
63
|
<Banner variant="loading" testId="custom-fields-form-loading">
|
|
54
|
-
<Text>
|
|
64
|
+
<Text>{t("custom-fields.form.loading")}</Text>
|
|
55
65
|
</Banner>
|
|
56
66
|
);
|
|
57
67
|
}
|
|
58
68
|
if (query.error) {
|
|
59
69
|
return (
|
|
60
70
|
<Banner variant="error" testId="custom-fields-form-error">
|
|
61
|
-
<Text>{query.error.i18nKey}</Text>
|
|
71
|
+
<Text>{t(query.error.i18nKey, query.error.i18nParams)}</Text>
|
|
62
72
|
</Banner>
|
|
63
73
|
);
|
|
64
74
|
}
|
|
@@ -71,7 +81,7 @@ export function CustomFieldsFormSection({
|
|
|
71
81
|
if (matchingFields.length === 0) {
|
|
72
82
|
return (
|
|
73
83
|
<Banner variant="info" testId="custom-fields-form-empty">
|
|
74
|
-
<Text>
|
|
84
|
+
<Text>{t("custom-fields.form.empty", { entityName })}</Text>
|
|
75
85
|
</Banner>
|
|
76
86
|
);
|
|
77
87
|
}
|
|
@@ -91,7 +101,7 @@ export function CustomFieldsFormSection({
|
|
|
91
101
|
value,
|
|
92
102
|
});
|
|
93
103
|
if (!result.isSuccess) {
|
|
94
|
-
setErrorKey(result.error?.i18nKey ?? "custom-fields
|
|
104
|
+
setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
}
|
|
@@ -123,11 +133,11 @@ export function CustomFieldsFormSection({
|
|
|
123
133
|
disabled={saving || !dirty}
|
|
124
134
|
testId="custom-fields-form-save"
|
|
125
135
|
>
|
|
126
|
-
{saving ? "
|
|
136
|
+
{saving ? t("custom-fields.form.saving") : t("custom-fields.form.save")}
|
|
127
137
|
</Button>
|
|
128
138
|
{errorKey !== null && (
|
|
129
139
|
<Banner variant="error" testId="custom-fields-form-save-error">
|
|
130
|
-
<Text>{errorKey}</Text>
|
|
140
|
+
<Text>{t(errorKey)}</Text>
|
|
131
141
|
</Banner>
|
|
132
142
|
)}
|
|
133
143
|
</div>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default translation bundle for the custom-fields UI. customFieldsClient()
|
|
3
|
+
// hangs it into the LocaleProvider as a fallback bundle — apps override
|
|
4
|
+
// individual keys via `customFieldsClient({ translations: { de: { ... } } })`.
|
|
5
|
+
//
|
|
6
|
+
// Keys follow `custom-fields.<area>.<slug>`. `custom-fields.errors.*` mirror
|
|
7
|
+
// the i18nKeys the server-side handlers emit (e.g. `custom-fields:save-failed`).
|
|
8
|
+
|
|
9
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
|
|
11
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
12
|
+
de: {
|
|
13
|
+
"custom-fields.form.createMode": "Speichere zuerst den Eintrag, um Custom-Felder zu setzen.",
|
|
14
|
+
"custom-fields.form.loading": "Lädt…",
|
|
15
|
+
"custom-fields.form.empty": 'Keine Custom-Felder für "{entityName}" definiert.',
|
|
16
|
+
"custom-fields.form.save": "Custom-Felder speichern",
|
|
17
|
+
"custom-fields.form.saving": "Speichert…",
|
|
18
|
+
"custom-fields.errors.loadFailed": "Custom-Felder konnten nicht geladen werden.",
|
|
19
|
+
"custom-fields.errors.saveFailed": "Speichern fehlgeschlagen.",
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
"custom-fields.form.createMode": "Save the entity first to add custom field values.",
|
|
23
|
+
"custom-fields.form.loading": "Loading…",
|
|
24
|
+
"custom-fields.form.empty": 'No custom fields defined for "{entityName}".',
|
|
25
|
+
"custom-fields.form.save": "Save custom fields",
|
|
26
|
+
"custom-fields.form.saving": "Saving…",
|
|
27
|
+
"custom-fields.errors.loadFailed": "Could not load custom fields.",
|
|
28
|
+
"custom-fields.errors.saveFailed": "Save failed.",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -171,8 +171,8 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
171
171
|
const customFields = row["customFields"];
|
|
172
172
|
if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
|
|
173
173
|
return {
|
|
174
|
-
...row,
|
|
175
174
|
...(customFields as Record<string, unknown>), // @cast-boundary db-row jsonb runtime-untyped
|
|
175
|
+
...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
178
|
return row;
|
|
@@ -40,6 +40,14 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
|
|
|
40
40
|
return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// The anonymize strip filters rows by `WHERE userIdColumn = userId`. A host
|
|
44
|
+
// anonymize hook on the SAME entity that nulls that column (e.g. inserted_by_id
|
|
45
|
+
// = NULL) would, if it ran first, leave the strip matching 0 rows → sensitive
|
|
46
|
+
// jsonb PII silently retained (DSGVO Art. 17 violation). A negative order makes
|
|
47
|
+
// runForgetCleanup run this strip before any default-order (0) owner-nulling
|
|
48
|
+
// hook, independent of feature registration order.
|
|
49
|
+
const ORDER_REDACT_BEFORE_OWNER_MUTATION = -100;
|
|
50
|
+
|
|
43
51
|
export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
|
|
44
52
|
r: TReg,
|
|
45
53
|
opts: WireCustomFieldsUserDataRightsOptions,
|
|
@@ -88,6 +96,7 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
|
|
|
88
96
|
r.useExtension(EXT_USER_DATA, opts.entityName, {
|
|
89
97
|
export: exportHook,
|
|
90
98
|
delete: deleteHook,
|
|
99
|
+
order: ORDER_REDACT_BEFORE_OWNER_MUTATION,
|
|
91
100
|
});
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -42,6 +42,17 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
42
42
|
handler: async (event, ctx) => {
|
|
43
43
|
const { featureName, enabled } = event.payload;
|
|
44
44
|
|
|
45
|
+
// Guard 0: fail fast on a misconfigured app BEFORE any DB write or event
|
|
46
|
+
// append — otherwise the row + toggle-set event commit and the operator
|
|
47
|
+
// only sees the error, leaving the in-memory snapshot stale until reboot.
|
|
48
|
+
if (!getRuntime) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"[feature-toggles] set-handler called but createFeatureTogglesFeature " +
|
|
51
|
+
"was wired up without `getRuntime`. Wire the accessor in your app-config " +
|
|
52
|
+
"(production: `() => runtime` after buildServer; tests: createLateBoundHolder.get).",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
// Guard 1: featureName must be a registered feature. Otherwise we'd
|
|
46
57
|
// pile up orphan rows from typos that the gate would silently apply
|
|
47
58
|
// (if someone ever added a feature with that name later).
|
|
@@ -153,14 +164,8 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
153
164
|
// for a dispatcher tick. Other instances learn the change through
|
|
154
165
|
// the `toggle-cache-sync` MSP (see feature-toggles-feature.ts). Both
|
|
155
166
|
// paths are idempotent — Map.set is last-write-wins and the DB is
|
|
156
|
-
// the source of truth after boot-time initialize().
|
|
157
|
-
|
|
158
|
-
throw new Error(
|
|
159
|
-
"[feature-toggles] set-handler called but createFeatureTogglesFeature " +
|
|
160
|
-
"was wired up without `getRuntime`. Wire the accessor in your app-config " +
|
|
161
|
-
"(production: `() => runtime` after buildServer; tests: createLateBoundHolder.get).",
|
|
162
|
-
);
|
|
163
|
-
}
|
|
167
|
+
// the source of truth after boot-time initialize(). getRuntime presence
|
|
168
|
+
// is enforced at Guard 0, so it is non-undefined here.
|
|
164
169
|
getRuntime().apply(featureName, enabled);
|
|
165
170
|
|
|
166
171
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
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
5
|
import { clearStorage, fileProviderInMemoryFeature, listKeys } from "../feature";
|
|
5
6
|
|
|
6
7
|
describe("fileProviderInMemoryFeature — shape", () => {
|
|
@@ -33,3 +34,57 @@ describe("listKeys / clearStorage — per-tenant store helpers", () => {
|
|
|
33
34
|
expect(() => clearStorage("never-touched")).not.toThrow();
|
|
34
35
|
});
|
|
35
36
|
});
|
|
37
|
+
|
|
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
|
+
function inmemoryPlugin(): FileProviderPlugin {
|
|
45
|
+
const options = fileProviderInMemoryFeature.extensionUsages.find(
|
|
46
|
+
(u) => u.extensionName === "fileProvider" && u.entityName === "inmemory",
|
|
47
|
+
)?.options;
|
|
48
|
+
if (!isFileProviderPlugin(options)) {
|
|
49
|
+
throw new Error("file-provider-inmemory: inmemory plugin not registered with a build()");
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bytes = (s: string) => new TextEncoder().encode(s);
|
|
55
|
+
|
|
56
|
+
describe("file-provider-inmemory — build() + per-tenant store", () => {
|
|
57
|
+
test("build liefert Provider; Write erscheint in listKeys(tenant)", async () => {
|
|
58
|
+
const provider = await inmemoryPlugin().build({}, "tenant-build-1");
|
|
59
|
+
await provider.write("doc.txt", bytes("x"));
|
|
60
|
+
expect(listKeys("tenant-build-1")).toContain("doc.txt");
|
|
61
|
+
clearStorage("tenant-build-1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("selber Tenant: zwei builds liefern identitätsstabilen Storage (State bleibt)", async () => {
|
|
65
|
+
const a = await inmemoryPlugin().build({}, "tenant-stable");
|
|
66
|
+
await a.write("first.txt", bytes("1"));
|
|
67
|
+
const b = await inmemoryPlugin().build({}, "tenant-stable");
|
|
68
|
+
expect(await b.exists("first.txt")).toBe(true);
|
|
69
|
+
clearStorage("tenant-stable");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Tenant-Isolation: Write in A erscheint nicht in B", async () => {
|
|
73
|
+
const a = await inmemoryPlugin().build({}, "tenant-iso-a");
|
|
74
|
+
const b = await inmemoryPlugin().build({}, "tenant-iso-b");
|
|
75
|
+
await a.write("only-in-a.txt", bytes("a"));
|
|
76
|
+
expect(listKeys("tenant-iso-a")).toContain("only-in-a.txt");
|
|
77
|
+
expect(listKeys("tenant-iso-b")).not.toContain("only-in-a.txt");
|
|
78
|
+
expect(await b.exists("only-in-a.txt")).toBe(false);
|
|
79
|
+
clearStorage("tenant-iso-a");
|
|
80
|
+
clearStorage("tenant-iso-b");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("clearStorage leert den Tenant-Store", async () => {
|
|
84
|
+
const p = await inmemoryPlugin().build({}, "tenant-clear");
|
|
85
|
+
await p.write("gone.txt", bytes("x"));
|
|
86
|
+
expect(listKeys("tenant-clear")).toHaveLength(1);
|
|
87
|
+
clearStorage("tenant-clear");
|
|
88
|
+
expect(listKeys("tenant-clear")).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
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
5
|
import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../feature";
|
|
5
6
|
|
|
6
7
|
describe("fileProviderS3Feature — shape", () => {
|
|
@@ -52,3 +53,29 @@ describe("fileProviderS3Feature — plugin-registration", () => {
|
|
|
52
53
|
);
|
|
53
54
|
});
|
|
54
55
|
});
|
|
56
|
+
|
|
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
|
+
function s3Plugin(): FileProviderPlugin {
|
|
63
|
+
const options = fileProviderS3Feature.extensionUsages.find(
|
|
64
|
+
(u) => u.extensionName === "fileProvider" && u.entityName === "s3",
|
|
65
|
+
)?.options;
|
|
66
|
+
if (!isFileProviderPlugin(options)) {
|
|
67
|
+
throw new Error("file-provider-s3: s3 plugin not registered with a build()");
|
|
68
|
+
}
|
|
69
|
+
return options;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("fileProviderS3Feature — build() guard", () => {
|
|
73
|
+
test("build ohne ctx.config wirft (config-feature nicht gemountet)", async () => {
|
|
74
|
+
// Die tieferen Value-/Secret-Pfade (bucket/region/accessKeyId leer,
|
|
75
|
+
// secret fehlt, Happy-Path) hängen an der echten Config-/Secrets-
|
|
76
|
+
// Pipeline → S3-Integration-Test (setupTestStack + MinIO), nicht hier.
|
|
77
|
+
// Die requireNonEmpty-Werteprüfung selbst ist über
|
|
78
|
+
// foundation-shared/config-helpers abgedeckt.
|
|
79
|
+
await expect(s3Plugin().build({}, "tenant-x")).rejects.toThrow("ctx.config is missing");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -111,17 +111,45 @@ describe("s3-provider (Minio)", () => {
|
|
|
111
111
|
await expect(provider.read(key)).rejects.toThrow();
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test("writeStream round-trip via multipart writer preserves
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
114
|
+
test("writeStream round-trip via multipart writer preserves byte-exact ordering", async () => {
|
|
115
|
+
// Regression guard for the multipart-flush bug: the source chunks are
|
|
116
|
+
// deliberately NON-ALIGNED to the 5 MiB part boundary ([3,3,2] MiB → the
|
|
117
|
+
// internal part split at 5 MiB lands mid-chunk #2). The old
|
|
118
|
+
// `buffered >= STREAM_PART_SIZE` flush would emit a non-final part of an
|
|
119
|
+
// odd size at that boundary; the Bun-writer (partSize) path produces the
|
|
120
|
+
// correct part topology. Either way we verify END-TO-END byte integrity,
|
|
121
|
+
// not just total size + first/last byte.
|
|
122
|
+
//
|
|
123
|
+
// Each chunk carries a chunk-distinct content pattern (incl. a per-chunk
|
|
124
|
+
// marker in byte[0]). A re-order, dropped, or duplicated part therefore
|
|
125
|
+
// changes the readback SHA256 — a same-pattern-per-part test would not
|
|
126
|
+
// catch that. We assert both the SHA256 over the whole stream AND the
|
|
127
|
+
// per-chunk-offset marker bytes.
|
|
128
|
+
//
|
|
129
|
+
// NOTE: MinIO does NOT enforce the AWS `MinPartSize` (5 MiB non-final
|
|
130
|
+
// part) rule, so this test cannot reproduce the genuine S3 `EntityTooSmall`
|
|
131
|
+
// rejection — that needs a manual smoke against AWS/R2. What it DOES guard
|
|
132
|
+
// is byte-ordering/integrity of the multipart round-trip, which is
|
|
133
|
+
// provider-agnostic.
|
|
119
134
|
const key = uniqueKey("stream-multipart.bin");
|
|
120
135
|
const partSize = 5 * 1024 * 1024;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
const MiB = 1024 * 1024;
|
|
137
|
+
const chunkSizes = [3 * MiB, 3 * MiB, 2 * MiB]; // 8 MiB total, non-aligned to 5 MiB
|
|
138
|
+
|
|
139
|
+
function makeChunk(index: number, size: number): Uint8Array {
|
|
140
|
+
const c = new Uint8Array(size);
|
|
141
|
+
// byte[0] = chunk marker; remaining bytes mix index + position so each
|
|
142
|
+
// chunk's body is distinct (reorder/duplicate changes the hash).
|
|
143
|
+
c[0] = index;
|
|
144
|
+
for (let i = 1; i < size; i++) c[i] = (index * 31 + i) % 251;
|
|
145
|
+
return c;
|
|
146
|
+
}
|
|
147
|
+
const chunks = chunkSizes.map((size, i) => makeChunk(i, size));
|
|
148
|
+
|
|
149
|
+
// Expected hash over the concatenated source.
|
|
150
|
+
const sourceHasher = new Bun.CryptoHasher("sha256");
|
|
151
|
+
for (const c of chunks) sourceHasher.update(c);
|
|
152
|
+
const expectedHash = sourceHasher.digest("hex");
|
|
125
153
|
|
|
126
154
|
if (!provider.writeStream) throw new Error("s3 provider should implement writeStream");
|
|
127
155
|
await provider.writeStream(
|
|
@@ -132,10 +160,24 @@ describe("s3-provider (Minio)", () => {
|
|
|
132
160
|
);
|
|
133
161
|
|
|
134
162
|
const readBack = await provider.read(key);
|
|
135
|
-
|
|
163
|
+
const totalSize = chunkSizes.reduce((a, b) => a + b, 0);
|
|
164
|
+
expect(readBack.byteLength).toBe(totalSize);
|
|
136
165
|
expect(readBack.byteLength).toBeGreaterThan(partSize);
|
|
137
|
-
|
|
138
|
-
|
|
166
|
+
|
|
167
|
+
// Byte-exact integrity over the full stream — catches any mid-stream
|
|
168
|
+
// corruption / reorder / off-by-part the size+endpoints check would miss.
|
|
169
|
+
const readHasher = new Bun.CryptoHasher("sha256");
|
|
170
|
+
readHasher.update(readBack);
|
|
171
|
+
expect(readHasher.digest("hex")).toBe(expectedHash);
|
|
172
|
+
|
|
173
|
+
// Explicit per-chunk marker check at the expected source offsets — proves
|
|
174
|
+
// the parts landed in order (not just that the bytes are collectively
|
|
175
|
+
// present).
|
|
176
|
+
let offset = 0;
|
|
177
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
178
|
+
expect(readBack[offset]).toBe(i);
|
|
179
|
+
offset += chunkSizes[i] ?? 0;
|
|
180
|
+
}
|
|
139
181
|
});
|
|
140
182
|
});
|
|
141
183
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// config-helpers Unit-Tests (Phase 1, test-luecken-integration).
|
|
2
|
+
//
|
|
3
|
+
// Pinnt das Verhalten der von ai-/mail-/file-foundation geteilten
|
|
4
|
+
// Narrowing-Helfer — inkl. der non-obvious Grenzfälle: requireDefined
|
|
5
|
+
// narrowt NUR `undefined` (nicht falsy), und requireNonEmpty hat zwei
|
|
6
|
+
// getrennte Fehlerpfade (undefined vs leer), die über die Error-Message
|
|
7
|
+
// unterschieden werden.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { requireDefined, requireNonEmpty } from "../config-helpers";
|
|
11
|
+
|
|
12
|
+
describe("requireDefined", () => {
|
|
13
|
+
test("undefined → wirft mit featureName + label + Misconfig-Hinweis", () => {
|
|
14
|
+
expect(() => requireDefined(undefined, "ai-foundation", "apiKey")).toThrow(
|
|
15
|
+
"ai-foundation: 'apiKey' config key resolved to undefined — registry misconfigured (no value + no default)",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("defined Wert → unverändert zurück", () => {
|
|
20
|
+
expect(requireDefined("sk-123", "ai-foundation", "apiKey")).toBe("sk-123");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("falsy-aber-defined Werte passieren (Check ist === undefined, nicht falsy)", () => {
|
|
24
|
+
// Würde hier ein falsy-Check stehen, bräche ein numerischer Key mit Wert 0
|
|
25
|
+
// oder ein leerer-String-Default. requireNonEmpty ist der strengere Helfer.
|
|
26
|
+
expect(requireDefined(0, "f", "n")).toBe(0);
|
|
27
|
+
expect(requireDefined("", "f", "n")).toBe("");
|
|
28
|
+
expect(requireDefined(false, "f", "n")).toBe(false);
|
|
29
|
+
expect(requireDefined(null, "f", "n")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("Objekt-Wert → identische Referenz zurück (generischer Typ erhalten)", () => {
|
|
33
|
+
const cfg = { host: "smtp.example.com", port: 587 };
|
|
34
|
+
expect(requireDefined(cfg, "mail-foundation", "smtp")).toBe(cfg);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("requireNonEmpty", () => {
|
|
39
|
+
test("undefined → wirft die requireDefined-Message (delegiert, NICHT empty-Pfad)", () => {
|
|
40
|
+
expect(() => requireNonEmpty(undefined, "mail-foundation", "host")).toThrow(
|
|
41
|
+
"config key resolved to undefined",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("leerer String → wirft empty-Message mit Default-uiHint", () => {
|
|
46
|
+
expect(() => requireNonEmpty("", "file-foundation", "bucket")).toThrow(
|
|
47
|
+
"file-foundation: 'bucket' is empty — tenant must configure it before use. Set via tenant-admin UI or seed-handler.",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("leerer String mit custom uiHint → Hint landet in der Message", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
requireNonEmpty("", "ai-foundation", "model", "Choose a model in Settings → AI."),
|
|
54
|
+
).toThrow("is empty — tenant must configure it before use. Choose a model in Settings → AI.");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("non-empty Wert → unverändert zurück", () => {
|
|
58
|
+
expect(requireNonEmpty("smtp.example.com", "mail-foundation", "host")).toBe("smtp.example.com");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("reiner Whitespace → wirft empty-Message (getrimmt, gilt als leer)", () => {
|
|
62
|
+
expect(() => requireNonEmpty(" ", "mail-foundation", "host")).toThrow(
|
|
63
|
+
"mail-foundation: 'host' is empty — tenant must configure it before use.",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("umgebender Whitespace wird vom Rückgabewert getrimmt", () => {
|
|
68
|
+
expect(requireNonEmpty(" smtp.example.com ", "mail-foundation", "host")).toBe(
|
|
69
|
+
"smtp.example.com",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -50,6 +50,10 @@ export function requireDefined<T>(value: T | undefined, featureName: string, lab
|
|
|
50
50
|
* Typical use: SMTP host, S3 bucket, model id — values without which the
|
|
51
51
|
* downstream SDK would 400 with a cryptic message. The clearer "tenant
|
|
52
52
|
* must configure X via tenant-admin UI" lands at the call-site instead.
|
|
53
|
+
*
|
|
54
|
+
* Whitespace is trimmed: a whitespace-only value counts as empty, and the
|
|
55
|
+
* returned string has surrounding whitespace removed — so a stray " host "
|
|
56
|
+
* never reaches the SDK as-is.
|
|
53
57
|
*/
|
|
54
58
|
export function requireNonEmpty(
|
|
55
59
|
value: string | undefined,
|
|
@@ -57,11 +61,11 @@ export function requireNonEmpty(
|
|
|
57
61
|
label: string,
|
|
58
62
|
uiHint = "Set via tenant-admin UI or seed-handler.",
|
|
59
63
|
): string {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
64
|
+
const trimmed = requireDefined(value, featureName, label).trim();
|
|
65
|
+
if (trimmed.length === 0) {
|
|
62
66
|
throw new Error(
|
|
63
67
|
`${featureName}: '${label}' is empty — tenant must configure it before use. ${uiHint}`,
|
|
64
68
|
);
|
|
65
69
|
}
|
|
66
|
-
return
|
|
70
|
+
return trimmed;
|
|
67
71
|
}
|
package/src/secrets/feature.ts
CHANGED
|
@@ -23,21 +23,14 @@ import { tenantSecretEntity } from "./table";
|
|
|
23
23
|
export const secretsEnvSchema = z.object({
|
|
24
24
|
KUMIKO_SECRETS_MASTER_KEY_V1: z
|
|
25
25
|
.string()
|
|
26
|
-
.refine(
|
|
27
|
-
(
|
|
28
|
-
|
|
29
|
-
return Buffer.from(v, "base64").length === 32;
|
|
30
|
-
} catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
{ message: "must be base64-encoded 32 bytes (AES-256 KEK)" },
|
|
35
|
-
)
|
|
26
|
+
.refine((v) => Buffer.from(v, "base64").length === 32, {
|
|
27
|
+
message: "must be base64-encoded 32 bytes (AES-256 KEK)",
|
|
28
|
+
})
|
|
36
29
|
.describe("AES-256 master-key (KEK) for tenant-secrets encryption.")
|
|
37
30
|
.meta({ kumiko: { pulumi: { generator: "openssl rand -base64 32", secret: true } } }),
|
|
38
31
|
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: z
|
|
39
32
|
.string()
|
|
40
|
-
.regex(
|
|
33
|
+
.regex(/^[1-9]\d*$/, "must be a positive integer (V<n> selector)")
|
|
41
34
|
.default("1")
|
|
42
35
|
.describe(
|
|
43
36
|
"Pins the active KEK version. Default '1'. Bump after writing a higher KUMIKO_SECRETS_MASTER_KEY_V<n>.",
|
|
@@ -60,12 +60,12 @@ import { verifyAndParseStripeWebhook } from "./verify-webhook";
|
|
|
60
60
|
export const subscriptionStripeEnvSchema = z.object({
|
|
61
61
|
STRIPE_WEBHOOK_SECRET: z
|
|
62
62
|
.string()
|
|
63
|
-
.
|
|
63
|
+
.regex(/^whsec_/, "STRIPE_WEBHOOK_SECRET must start with 'whsec_'")
|
|
64
64
|
.describe("Stripe webhook-signing secret (`whsec_...` from the Stripe dashboard).")
|
|
65
65
|
.meta({ kumiko: { pulumi: { secret: true } } }),
|
|
66
66
|
STRIPE_API_KEY: z
|
|
67
67
|
.string()
|
|
68
|
-
.
|
|
68
|
+
.regex(/^sk_(test|live)_/, "STRIPE_API_KEY must start with 'sk_test_' or 'sk_live_'")
|
|
69
69
|
.describe("Stripe API key (`sk_live_...` / `sk_test_...`).")
|
|
70
70
|
.meta({ kumiko: { pulumi: { secret: true } } }),
|
|
71
71
|
});
|
|
@@ -20,15 +20,11 @@ export const listQuery = defineQueryHandler({
|
|
|
20
20
|
const isSystemAdmin = query.user.roles.includes("SystemAdmin");
|
|
21
21
|
const where: Record<string, unknown> = {};
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
where["tenantId"] = query.user.tenantId;
|
|
29
|
-
}
|
|
30
|
-
} else if (!query.payload.includeSystem) {
|
|
31
|
-
// SystemAdmin mit includeSystem=false → nur eigener Tenant
|
|
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) {
|
|
32
28
|
where["tenantId"] = query.user.tenantId;
|
|
33
29
|
}
|
|
34
30
|
|
|
@@ -48,7 +44,13 @@ export const listQuery = defineQueryHandler({
|
|
|
48
44
|
limit: 500,
|
|
49
45
|
})) as TemplateResourceRow[];
|
|
50
46
|
|
|
51
|
-
|
|
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) => ({
|
|
52
54
|
id: String(row.id),
|
|
53
55
|
tenantId: row.tenantId,
|
|
54
56
|
slug: row.slug,
|
|
@@ -173,6 +173,32 @@ describe("seedTenantMembership", () => {
|
|
|
173
173
|
expect(events.filter((e) => e.type === "tenant-membership.created")).toHaveLength(1);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
test("returns the membership-row id — identical across create + no-op re-seed", async () => {
|
|
177
|
+
// Both return paths (create via extractMembershipId, no-op via fetched row)
|
|
178
|
+
// must yield the same valid uuid string that the projection actually holds.
|
|
179
|
+
// Previously the return was never asserted; a no-op returning the wrong /
|
|
180
|
+
// undefined id would have gone unnoticed.
|
|
181
|
+
const created = await seedTenantMembership(stack.db, {
|
|
182
|
+
userId: ALICE_ID,
|
|
183
|
+
tenantId: TENANT_A,
|
|
184
|
+
roles: ["User"],
|
|
185
|
+
});
|
|
186
|
+
const reSeeded = await seedTenantMembership(stack.db, {
|
|
187
|
+
userId: ALICE_ID,
|
|
188
|
+
tenantId: TENANT_A,
|
|
189
|
+
roles: ["User"],
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(created.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
193
|
+
expect(reSeeded.id).toBe(created.id);
|
|
194
|
+
|
|
195
|
+
const [row] = await selectMany(stack.db, tenantMembershipsTable, {
|
|
196
|
+
userId: ALICE_ID,
|
|
197
|
+
tenantId: TENANT_A,
|
|
198
|
+
});
|
|
199
|
+
expect(row?.["id"]).toBe(created.id);
|
|
200
|
+
});
|
|
201
|
+
|
|
176
202
|
test("records the `by` user as insertedById on the projection", async () => {
|
|
177
203
|
// Audit-queries that join events → users need a stable actor. Default
|
|
178
204
|
// `by` is TestUsers.systemAdmin; override to a custom test user and
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -141,9 +141,9 @@ export async function seedTenantMembership(
|
|
|
141
141
|
tenantId: options.tenantId,
|
|
142
142
|
});
|
|
143
143
|
if (existing) {
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
return { id: existing
|
|
144
|
+
// Same validation as the create-path — a missing/non-string id throws
|
|
145
|
+
// instead of silently returning `undefined as string`.
|
|
146
|
+
return { id: extractMembershipId(existing) };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
const result = await executor.create(
|