@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.57.1",
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
- // Settings-Hub system-scope proxies for the derived Stripe screen: a
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
- // The derived configEdit screen surfaces a `access.privileged`
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 }),
@@ -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 empty-string env vars are skipped — an unset (or `FOO=`)
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 — select-option membership is validated by validateAppOverrides
650
- return raw;
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);
@@ -153,9 +153,7 @@ export function createJobsFeature(): FeatureDefinition {
153
153
  },
154
154
  });
155
155
 
156
- // Framework-provided single-run rebuild job (QN `jobs:job:projection-rebuild`).
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 (QN `jobs:job:projection-rebuild`).
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 { ...PUBLIC_PAGE_SECURITY_HEADERS, ...extra };
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 = `${opts.deletionVerifyUrl}?token=${encodeURIComponent(token)}`;
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("invalid");
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"