@cosmicdrift/kumiko-bundled-features 0.57.1 → 0.57.2
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/config/__tests__/config.integration.test.ts +1 -4
- package/src/config/__tests__/env-overrides.test.ts +21 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +1 -4
- package/src/config/resolver.ts +12 -5
- package/src/jobs/feature.ts +1 -3
- package/src/jobs/handlers/projection-rebuild.job.ts +1 -7
- package/src/page-render/__tests__/security-headers.test.ts +22 -0
- package/src/page-render/security-headers.ts +4 -1
- package/src/user-data-rights/__tests__/request-deletion-url.test.ts +21 -0
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +10 -1
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +19 -0
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.57.
|
|
3
|
+
"version": "0.57.2",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -175,10 +175,7 @@ const integrationFeature = defineFeature("integration", (r) => {
|
|
|
175
175
|
default: "initial",
|
|
176
176
|
write: access.roles("Admin"),
|
|
177
177
|
}),
|
|
178
|
-
//
|
|
179
|
-
// privileged boolean (billing-live = machine OR human-SystemAdmin) and
|
|
180
|
-
// an encrypted system secret (api-key). Both are surfaced to a human
|
|
181
|
-
// SystemAdmin by build-config-feature-schema and must be SET-able by them.
|
|
178
|
+
// Privileged + encrypted system keys must be SET-able by a human SystemAdmin (the derived Stripe screen surfaces both).
|
|
182
179
|
billingLive: createSystemConfig("boolean", {
|
|
183
180
|
default: false,
|
|
184
181
|
write: access.privileged,
|
|
@@ -88,6 +88,27 @@ describe("buildEnvConfigOverrides", () => {
|
|
|
88
88
|
expect(result.size).toBe(0);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
test("whitespace-only env var → skipped (semantically empty, must not clobber default)", () => {
|
|
92
|
+
// Number key: pre-fix `Number(" ".trim())` was 0 and finite → silently
|
|
93
|
+
// resolved to 0 instead of falling through to the declared default.
|
|
94
|
+
const reg = registryStub({
|
|
95
|
+
"a:config:n": createSystemConfig("number", { env: "N" }),
|
|
96
|
+
"a:config:x": createSystemConfig("text", { env: "X" }),
|
|
97
|
+
});
|
|
98
|
+
const result = buildEnvConfigOverrides(reg, { N: " ", X: "\t\n" });
|
|
99
|
+
expect(result.size).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("select value is trimmed before option membership (so ` dark` resolves)", () => {
|
|
103
|
+
const reg = registryStub({
|
|
104
|
+
"a:config:theme": createSystemConfig("select", {
|
|
105
|
+
env: "THEME",
|
|
106
|
+
options: ["light", "dark"],
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
expect(buildEnvConfigOverrides(reg, { THEME: " dark " }).get("a:config:theme")).toBe("dark");
|
|
110
|
+
});
|
|
111
|
+
|
|
91
112
|
test("keys without an env field are ignored even if a same-named var exists", () => {
|
|
92
113
|
const reg = registryStub({
|
|
93
114
|
"a:config:no-env": createSystemConfig("text", {}),
|
|
@@ -106,10 +106,7 @@ describe("prepareConfigWrite", () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
test("privileged key (system + SystemAdmin) is writable by a human SystemAdmin", () => {
|
|
109
|
-
//
|
|
110
|
-
// (`["system","SystemAdmin"]`) key to a human SystemAdmin (e.g. Stripe
|
|
111
|
-
// billing-live). The write must succeed — "system in the write-set"
|
|
112
|
-
// means machine-OR-operator, not machine-only.
|
|
109
|
+
// "system" in the write-set means machine-OR-operator, not machine-only — a human SystemAdmin must still be able to write.
|
|
113
110
|
const privilegedKey = createSystemConfig("boolean", { write: access.privileged });
|
|
114
111
|
const result = prepareConfigWrite({
|
|
115
112
|
registry: registryStub({ "ns:config:billing-live": privilegedKey }),
|
package/src/config/resolver.ts
CHANGED
|
@@ -384,6 +384,12 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
384
384
|
return { value: undefined, source: "missing" };
|
|
385
385
|
},
|
|
386
386
|
|
|
387
|
+
// NOTE: getAll / getAllWithSource read only config_values rows and do NOT
|
|
388
|
+
// dispatch to the secrets store (unlike get/getCascade/getWithSource). A
|
|
389
|
+
// backing="secrets" key has no config_values row, so it is simply ABSENT
|
|
390
|
+
// from the result map — never a stale/undefined value. Callers needing a
|
|
391
|
+
// secrets-backed value must use get/getCascade; these bulk readers are for
|
|
392
|
+
// the config_values-backed keys only.
|
|
387
393
|
async getAll(tenantId, userId, db) {
|
|
388
394
|
// Only load rows relevant to this user/tenant (system + tenant + user scope)
|
|
389
395
|
const rows = await selectConfigRowsForScope(db, SYSTEM_TENANT_ID, tenantId, userId);
|
|
@@ -613,8 +619,8 @@ export function validateAppOverrides(
|
|
|
613
619
|
// resolving to the wrong value. Reuses validateAppOverrides for the
|
|
614
620
|
// existence / bounds / options / computed-conflict gates.
|
|
615
621
|
//
|
|
616
|
-
// undefined OR
|
|
617
|
-
// variable must not clobber a declared default.
|
|
622
|
+
// undefined OR blank env vars are skipped — an unset, empty (`FOO=`) or
|
|
623
|
+
// whitespace-only (`FOO=" "`) variable must not clobber a declared default.
|
|
618
624
|
|
|
619
625
|
type EnvSource = Readonly<Record<string, string | undefined>>;
|
|
620
626
|
|
|
@@ -646,8 +652,9 @@ function coerceEnvValue(
|
|
|
646
652
|
`ENV config bridge: "${envName}" → "${qualifiedKey}" expects a boolean (true/false/1/0), got "${raw}".`,
|
|
647
653
|
);
|
|
648
654
|
}
|
|
649
|
-
// text | select —
|
|
650
|
-
|
|
655
|
+
// text | select — trimmed so e.g. `THEME=" dark"` resolves to a real option;
|
|
656
|
+
// select-option membership is validated by validateAppOverrides.
|
|
657
|
+
return raw.trim();
|
|
651
658
|
}
|
|
652
659
|
|
|
653
660
|
export function buildEnvConfigOverrides(
|
|
@@ -659,7 +666,7 @@ export function buildEnvConfigOverrides(
|
|
|
659
666
|
const envName = keyDef.env;
|
|
660
667
|
if (!envName) continue;
|
|
661
668
|
const raw = env[envName];
|
|
662
|
-
if (raw === undefined || raw === "") continue;
|
|
669
|
+
if (raw === undefined || raw.trim() === "") continue;
|
|
663
670
|
record[qualifiedKey] = coerceEnvValue(qualifiedKey, envName, keyDef.type, raw);
|
|
664
671
|
}
|
|
665
672
|
return validateAppOverrides(registry, record);
|
package/src/jobs/feature.ts
CHANGED
|
@@ -153,9 +153,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
153
153
|
},
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
// Framework-provided
|
|
157
|
-
// Available whenever `jobs` is composed — `enqueueProjectionRebuild` dispatches
|
|
158
|
-
// it; every run is tracked in read_job_runs + retryable via jobs:write:retry.
|
|
156
|
+
// Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
|
|
159
157
|
r.job(
|
|
160
158
|
"projectionRebuild",
|
|
161
159
|
{ trigger: { manual: true }, schema: projectionRebuildPayloadSchema },
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
// Single-run projection-rebuild worker (
|
|
2
|
-
// Replays the event log into one projection via the framework's
|
|
3
|
-
// `rebuildProjection`. Triggered manually — typically through
|
|
4
|
-
// `enqueueProjectionRebuild` (migrations) as the self-service repair for an
|
|
5
|
-
// emptied projection, a deliberate manual rebuild, or a post-upcaster refill.
|
|
6
|
-
// Run-tracking (read_job_runs + read_job_run_logs) and retry come for free
|
|
7
|
-
// from the jobs feature that registers this worker.
|
|
1
|
+
// Single-run projection-rebuild worker (`jobs:job:projection-rebuild`); manually triggered, typically via enqueueProjectionRebuild to refill an emptied projection.
|
|
8
2
|
|
|
9
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
4
|
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { securePageHeaders } from "../security-headers";
|
|
3
|
+
|
|
4
|
+
describe("securePageHeaders", () => {
|
|
5
|
+
test("merges caller headers alongside the security defaults", () => {
|
|
6
|
+
const h = securePageHeaders({ "content-type": "text/html; charset=utf-8" });
|
|
7
|
+
expect(h["content-type"]).toBe("text/html; charset=utf-8");
|
|
8
|
+
expect(h["x-content-type-options"]).toBe("nosniff");
|
|
9
|
+
expect(h["content-security-policy"]).toContain("script-src 'none'");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("a caller can NEVER override a hardened security header", () => {
|
|
13
|
+
const h = securePageHeaders({
|
|
14
|
+
"content-security-policy": "default-src *",
|
|
15
|
+
"x-frame-options": "ALLOWALL",
|
|
16
|
+
});
|
|
17
|
+
expect(h["content-security-policy"]).toBe(
|
|
18
|
+
"script-src 'none'; object-src 'none'; base-uri 'none'",
|
|
19
|
+
);
|
|
20
|
+
expect(h["x-frame-options"]).toBe("SAMEORIGIN");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -11,6 +11,9 @@ const PUBLIC_PAGE_SECURITY_HEADERS = {
|
|
|
11
11
|
"referrer-policy": "strict-origin-when-cross-origin",
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
|
+
// Security headers spread LAST so a caller's `extra` can never override a
|
|
15
|
+
// hardened default (CSP/nosniff/frame-options); extra only adds non-security
|
|
16
|
+
// headers like content-type/cache-control/vary.
|
|
14
17
|
export function securePageHeaders(extra: Record<string, string>): Record<string, string> {
|
|
15
|
-
return { ...
|
|
18
|
+
return { ...extra, ...PUBLIC_PAGE_SECURITY_HEADERS };
|
|
16
19
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildDeletionVerifyUrl } from "../handlers/request-deletion-by-email.write";
|
|
3
|
+
|
|
4
|
+
describe("buildDeletionVerifyUrl", () => {
|
|
5
|
+
test("appends ?token to a plain base URL", () => {
|
|
6
|
+
expect(buildDeletionVerifyUrl("https://app.example.com/delete/confirm", "tok-123")).toBe(
|
|
7
|
+
"https://app.example.com/delete/confirm?token=tok-123",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("appends &token when the base already carries query params (not a second ?)", () => {
|
|
12
|
+
const url = buildDeletionVerifyUrl("https://app.example.com/confirm?lang=de", "tok-123");
|
|
13
|
+
expect(url).toBe("https://app.example.com/confirm?lang=de&token=tok-123");
|
|
14
|
+
expect(url.match(/\?/g)).toHaveLength(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("URL-encodes a token with reserved characters", () => {
|
|
18
|
+
const url = new URL(buildDeletionVerifyUrl("https://app.example.com/c", "a b&c=d"));
|
|
19
|
+
expect(url.searchParams.get("token")).toBe("a b&c=d");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -35,6 +35,15 @@ export type RequestDeletionByEmailOptions = {
|
|
|
35
35
|
readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// URL-safe append: handles a base URL that already carries query params
|
|
39
|
+
// (`?lang=de` → `?lang=de&token=…`) instead of producing an invalid
|
|
40
|
+
// `?lang=de?token=…`. searchParams.set encodes the token.
|
|
41
|
+
export function buildDeletionVerifyUrl(base: string, token: string): string {
|
|
42
|
+
const url = new URL(base);
|
|
43
|
+
url.searchParams.set("token", token);
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
// Anonymer Apex-Flow Schritt 1: "Account-Löschung beantragen" per Email.
|
|
39
48
|
// DSGVO-relevant gerade wenn der User sich NICHT mehr einloggen kann
|
|
40
49
|
// (Lockout). Email → Magic-Link → confirm-deletion-by-token.
|
|
@@ -71,7 +80,7 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
|
|
|
71
80
|
DELETION_VERIFY_TTL_MINUTES,
|
|
72
81
|
opts.deletionTokenSecret,
|
|
73
82
|
);
|
|
74
|
-
const verifyUrl =
|
|
83
|
+
const verifyUrl = buildDeletionVerifyUrl(opts.deletionVerifyUrl, token);
|
|
75
84
|
|
|
76
85
|
if (opts.sendDeletionVerificationEmail) {
|
|
77
86
|
try {
|
|
@@ -31,6 +31,14 @@ function makeDispatcher(ok: boolean, calls: WriteCall[]): Dispatcher {
|
|
|
31
31
|
} as unknown as Dispatcher;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function makeThrowingDispatcher(): Dispatcher {
|
|
35
|
+
return {
|
|
36
|
+
write: async () => {
|
|
37
|
+
throw new Error("network down");
|
|
38
|
+
},
|
|
39
|
+
} as unknown as Dispatcher;
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
function renderWith(ui: ReactElement, dispatcher: Dispatcher): void {
|
|
35
43
|
render(
|
|
36
44
|
<PrimitivesProvider value={defaultPrimitives}>
|
|
@@ -100,4 +108,15 @@ describe("ConfirmAccountDeletionScreen", () => {
|
|
|
100
108
|
await waitFor(() => expect(screen.getByText(/ungültig oder abgelaufen/)).toBeTruthy());
|
|
101
109
|
expect(screen.queryByText(/vorgemerkt/)).toBeNull();
|
|
102
110
|
});
|
|
111
|
+
|
|
112
|
+
test("write wirft → generischer Error-Banner, NICHT invalidToken", async () => {
|
|
113
|
+
window.history.replaceState({}, "", "/delete-account/confirm?token=tok-123");
|
|
114
|
+
renderWith(<ConfirmAccountDeletionScreen />, makeThrowingDispatcher());
|
|
115
|
+
|
|
116
|
+
fireEvent.click(screen.getByRole("button"));
|
|
117
|
+
|
|
118
|
+
await waitFor(() => expect(screen.getByText(/schief gegangen/)).toBeTruthy());
|
|
119
|
+
expect(screen.queryByText(/ungültig oder abgelaufen/)).toBeNull();
|
|
120
|
+
expect(screen.queryByText(/vorgemerkt/)).toBeNull();
|
|
121
|
+
});
|
|
103
122
|
});
|
|
@@ -11,7 +11,7 @@ import { type ReactNode, useState } from "react";
|
|
|
11
11
|
|
|
12
12
|
const CONFIRM_BY_TOKEN = "user-data-rights:write:confirm-deletion-by-token";
|
|
13
13
|
|
|
14
|
-
type Phase = "idle" | "submitting" | "success" | "missing" | "invalid";
|
|
14
|
+
type Phase = "idle" | "submitting" | "success" | "missing" | "invalid" | "error";
|
|
15
15
|
|
|
16
16
|
function readToken(): string {
|
|
17
17
|
if (typeof window === "undefined") return "";
|
|
@@ -37,7 +37,7 @@ export function ConfirmAccountDeletionScreen({
|
|
|
37
37
|
const res = await dispatcher.write(CONFIRM_BY_TOKEN, { token });
|
|
38
38
|
setPhase(res.isSuccess ? "success" : "invalid");
|
|
39
39
|
} catch {
|
|
40
|
-
setPhase("
|
|
40
|
+
setPhase("error");
|
|
41
41
|
}
|
|
42
42
|
};
|
|
43
43
|
|
|
@@ -66,6 +66,9 @@ export function ConfirmAccountDeletionScreen({
|
|
|
66
66
|
{phase === "invalid" && (
|
|
67
67
|
<Banner variant="error">{t("userDataRights.deletion.confirm.invalidToken")}</Banner>
|
|
68
68
|
)}
|
|
69
|
+
{phase === "error" && (
|
|
70
|
+
<Banner variant="error">{t("userDataRights.deletion.confirm.error")}</Banner>
|
|
71
|
+
)}
|
|
69
72
|
<Button
|
|
70
73
|
type="button"
|
|
71
74
|
variant="danger"
|