@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +6 -8
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
@@ -0,0 +1,31 @@
1
+ // user-feature runs with r.systemScope() but events land on a concrete
2
+ // tenant stream. The row.version tracks whichever stream the user's last
3
+ // modification wrote to — and that tenant is NOT discoverable from the
4
+ // row alone.
5
+ //
6
+ // Strategy: prioritize lastActiveTenantId (most likely holds the latest
7
+ // event), fall through to the remaining memberships in insertion order,
8
+ // and let the caller try each one in sequence. The first stream whose
9
+ // version matches row.version wins; the rest are bypassed.
10
+ //
11
+ // This is pragmatic — the real fix is to scope user events to
12
+ // SYSTEM_TENANT_ID when the feature is r.systemScope(), which is a
13
+ // framework-level change tracked separately. Until then, "try each
14
+ // tenant the user belongs to" is robust against non-deterministic
15
+ // memberships-query ordering (tenant:query:memberships has no ORDER BY).
16
+
17
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
+
19
+ export function orderTenantsByPreference(
20
+ memberships: readonly { readonly tenantId: TenantId }[],
21
+ lastActiveTenantId: string | null | undefined,
22
+ ): TenantId[] {
23
+ if (memberships.length === 0) return [];
24
+ const ids = memberships.map((m) => m.tenantId);
25
+ if (!lastActiveTenantId) return ids;
26
+ // Move lastActiveTenantId to the front; preserve relative order of rest.
27
+ const preferred = ids.find((id) => id === lastActiveTenantId);
28
+ if (!preferred) return ids;
29
+ const rest = ids.filter((id) => id !== preferred);
30
+ return [preferred, ...rest];
31
+ }
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
19
19
  import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer-web";
20
- import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
20
+ import { createContext, type ReactNode, useContext } from "react";
21
21
 
22
22
  // Wrappt die zentrierte Auth-Card in ihre Umgebung. Default = Fullscreen-
23
23
  // zentriert (Standalone-Auth-Page). Eine Apex-/Marketing-Chrome reicht über
@@ -106,20 +106,3 @@ export function parseUrlToken(paramName = "token"): string {
106
106
  if (typeof window === "undefined") return "";
107
107
  return new URLSearchParams(window.location.search).get(paramName) ?? "";
108
108
  }
109
-
110
- // Reads the magic-link token from `?<paramName>=` once on mount, then strips
111
- // that param from the URL via history.replaceState so the single-use token
112
- // doesn't linger in browser history / Referer (#774). An explicit `override`
113
- // (server-injected token) short-circuits both the URL read and the scrub.
114
- export function useUrlToken(override?: string, paramName = "token"): string {
115
- const [token] = useState(() => override ?? parseUrlToken(paramName));
116
- useEffect(() => {
117
- if (override !== undefined) return;
118
- if (typeof window === "undefined") return;
119
- const url = new URL(window.location.href);
120
- if (!url.searchParams.has(paramName)) return;
121
- url.searchParams.delete(paramName);
122
- window.history.replaceState(window.history.state, "", url.toString());
123
- }, [override, paramName]);
124
- return token;
125
- }
@@ -32,9 +32,10 @@ export function makeAuthGate(
32
32
  return AuthGate;
33
33
  }
34
34
 
35
- // SessionProvider + AuthGate as one gate, so a public gate in front (e.g.
36
- // /calculator) doesn't mount the session bootstrap — createKumikoApp stacks
37
- // providers outside all gates, so SessionProvider can't be a provider anymore.
35
+ /** SessionProvider + AuthGate als ein Gate damit öffentliche Gates davor
36
+ * (z.B. /rechner) den Session-Bootstrap nicht mounten. createKumikoApp
37
+ * stackt providers außerhalb aller gates; SessionProvider darf deshalb
38
+ * kein provider mehr sein. */
38
39
  export function makeSessionAuthGate(
39
40
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
40
41
  loginProps?: LoginScreenProps,
@@ -19,7 +19,7 @@
19
19
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
20
20
  import { type FormEvent, type ReactNode, useContext, useState } from "react";
21
21
  import { csrfHeader } from "./auth-client";
22
- import { AuthCard, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
22
+ import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
23
23
  import { SessionContext, UNAUTHENTICATED } from "./session";
24
24
 
25
25
  export type InviteAcceptScreenProps = {
@@ -47,7 +47,7 @@ export function InviteAcceptScreen({
47
47
  // <SessionProvider> ancestor. Read the context directly and fall back to
48
48
  // the anonymous state when no provider wraps this screen (632/1).
49
49
  const session = useContext(SessionContext) ?? UNAUTHENTICATED;
50
- const token = useUrlToken(tokenProp);
50
+ const [token] = useState(() => tokenProp ?? parseUrlToken());
51
51
  const [mode, setMode] = useState<Mode>(() =>
52
52
  session.status === "authenticated" ? "loggedin" : "anon-existing",
53
53
  );
@@ -5,15 +5,20 @@
5
5
  // Code (anti-enumeration); UI zeigt unified "Link ungültig oder
6
6
  // abgelaufen"-message.
7
7
  //
8
- // Token-Quelle ist read-once via useUrlToken(): liest `?token=...` beim
9
- // Mount und scrubbt den Param danach aus der URL (#774). Apps die das
10
- // anders brauchen (server-injected Token-Prop) reichen `token` als Prop
11
- // durch dann bleibt die URL unangetastet.
8
+ // Token-Quelle ist read-once: wir lesen via parseUrlToken() einmalig
9
+ // im useState-Initializer. Apps die das anders brauchen (server-
10
+ // injected Token-Prop, andere Parameter-Namen) reichen einen
11
+ // expliziten `token` als Prop durch.
12
12
 
13
13
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
14
14
  import { type FormEvent, type ReactNode, useState } from "react";
15
15
  import { resetPassword } from "./auth-client";
16
- import { AuthCard, authButtonClass, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
16
+ import {
17
+ AuthCard,
18
+ authButtonClass,
19
+ authMutedLinkClass,
20
+ parseUrlToken,
21
+ } from "./auth-form-primitives";
17
22
 
18
23
  export type ResetPasswordScreenProps = {
19
24
  readonly title?: string;
@@ -32,7 +37,7 @@ export function ResetPasswordScreen({
32
37
  }: ResetPasswordScreenProps): ReactNode {
33
38
  const t = useTranslation();
34
39
  const { Form, Field, Input, Button, Banner } = usePrimitives();
35
- const token = useUrlToken(tokenProp);
40
+ const [token] = useState(() => tokenProp ?? parseUrlToken());
36
41
  const [newPassword, setNewPassword] = useState("");
37
42
  const [confirmPassword, setConfirmPassword] = useState("");
38
43
  const [submitting, setSubmitting] = useState(false);
@@ -6,9 +6,9 @@
6
6
  // Server JWT + Cookies (Auto-Login!) und liefert tenantKey für den
7
7
  // Post-Signup-Redirect.
8
8
  //
9
- // Token-Quelle ist read-once via useUrlToken: liest `?token=...` beim
10
- // Mount und scrubbt den Param danach aus der URL (#774). Apps die einen
11
- // server-injected Token nutzen, reichen `token` als Prop durch.
9
+ // Token-Quelle ist read-once: parseUrlToken im useState-Initializer.
10
+ // Apps die einen anderen URL-Param nutzen, reichen `token` als Prop
11
+ // durch.
12
12
  //
13
13
  // Nach success: redirect via window.location.assign zu loggedInHref.
14
14
  // Default-Pattern ist "/<tenantKey>/" — die App reicht ein Template
@@ -18,7 +18,7 @@
18
18
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
19
  import { type FormEvent, type ReactNode, useState } from "react";
20
20
  import { confirmSignup } from "./auth-client";
21
- import { AuthCard, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
21
+ import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
22
22
 
23
23
  export type SignupCompleteScreenProps = {
24
24
  readonly title?: string;
@@ -44,7 +44,7 @@ export function SignupCompleteScreen({
44
44
  }: SignupCompleteScreenProps): ReactNode {
45
45
  const t = useTranslation();
46
46
  const { Form, Field, Input, Button, Banner } = usePrimitives();
47
- const token = useUrlToken(tokenProp);
47
+ const [token] = useState(() => tokenProp ?? parseUrlToken());
48
48
  const [password, setPassword] = useState("");
49
49
  const [confirmPassword, setConfirmPassword] = useState("");
50
50
  const [submitting, setSubmitting] = useState(false);
@@ -33,9 +33,7 @@ export type UserMenuProps = {
33
33
  * controlliert der Caller — wir packen nur den Frame drumrum. */
34
34
  readonly children?: ReactNode;
35
35
  /** "pill" (Default) = kompakter Topbar-Trigger; "sidebar" = volle NavUser-
36
- * Row (Avatar + Name + Email) für den `sidebarFooter`-Slot der App-Shell.
37
- * Requires a `SidebarProvider` ancestor (547/3) — the default App-Shell
38
- * `sidebarFooter` slot already provides one. */
36
+ * Row (Avatar + Name + Email) für den `sidebarFooter`-Slot der App-Shell. */
39
37
  readonly variant?: UserMenuVariant;
40
38
  };
41
39
 
@@ -13,7 +13,12 @@
13
13
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
14
14
  import { type ReactNode, useEffect, useRef, useState } from "react";
15
15
  import { verifyEmail } from "./auth-client";
16
- import { AuthCard, authButtonClass, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
16
+ import {
17
+ AuthCard,
18
+ authButtonClass,
19
+ authMutedLinkClass,
20
+ parseUrlToken,
21
+ } from "./auth-form-primitives";
17
22
 
18
23
  export type VerifyEmailScreenProps = {
19
24
  readonly title?: string;
@@ -31,7 +36,7 @@ export function VerifyEmailScreen({
31
36
  loginHref = "/login",
32
37
  }: VerifyEmailScreenProps): ReactNode {
33
38
  const t = useTranslation();
34
- const token = useUrlToken(tokenProp);
39
+ const [token] = useState(() => tokenProp ?? parseUrlToken());
35
40
  const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
36
41
  const startedRef = useRef(false);
37
42
 
@@ -1,7 +1,11 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
- import { type DbConnection, seedConfigValues } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ createEncryptionProvider,
6
+ type DbConnection,
7
+ seedConfigValues,
8
+ } from "@cosmicdrift/kumiko-framework/db";
5
9
  import {
6
10
  access,
7
11
  createSeed,
@@ -20,10 +24,7 @@ import {
20
24
  TestUsers,
21
25
  unsafePushTables,
22
26
  } from "@cosmicdrift/kumiko-framework/stack";
23
- import {
24
- createTestEnvelopeCipher,
25
- expectErrorIncludes,
26
- } from "@cosmicdrift/kumiko-framework/testing";
27
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
27
28
  import { z } from "zod";
28
29
  import { ConfigHandlers, ConfigQueries } from "../constants";
29
30
  import { createConfigAccessor, createConfigAccessorFactory, createConfigFeature } from "../feature";
@@ -256,8 +257,8 @@ const transportFeature = defineFeature("transport", (r) => {
256
257
  const testEncryptionKey = randomBytes(32).toString("base64");
257
258
 
258
259
  beforeAll(async () => {
259
- const encryption = createTestEnvelopeCipher(testEncryptionKey);
260
- resolver = createConfigResolver({ cipher: encryption });
260
+ const encryption = createEncryptionProvider(testEncryptionKey);
261
+ resolver = createConfigResolver({ encryption });
261
262
 
262
263
  stack = await setupTestStack({
263
264
  features: [
@@ -1,9 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { deserializeValue } from "../resolver";
3
3
 
4
- // Type mismatches are coerced, not rejected (number via Number(), boolean
5
- // only via literal true/"true", text via String()) pinned so a refactor
6
- // can't quietly change that.
4
+ // deserializeValue is the read boundary for every config value: the DB stores
5
+ // the JSON-encoded raw string, this turns it back into a typed primitive per the
6
+ // key's declared `type`. The coercion has non-obvious paths worth pinning so a
7
+ // refactor can't quietly change them: a stored value whose JSON type disagrees
8
+ // with the declared type is NOT rejected — it is coerced (number via Number(),
9
+ // boolean only via literal true / the string "true", text via String()).
7
10
 
8
11
  describe("deserializeValue", () => {
9
12
  test("null raw short-circuits to undefined before any parse", () => {
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import {
5
5
  access,
6
6
  type ConfigCascade,
@@ -14,7 +14,6 @@ import {
14
14
  TestUsers,
15
15
  unsafePushTables,
16
16
  } from "@cosmicdrift/kumiko-framework/stack";
17
- import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
18
17
  import { ConfigHandlers, ConfigQueries } from "../constants";
19
18
  import { createConfigAccessorFactory, createConfigFeature } from "../feature";
20
19
  import { type ConfigResolver, createConfigResolver } from "../resolver";
@@ -77,8 +76,8 @@ const platformFeature = defineFeature("platform", (r) => {
77
76
  });
78
77
 
79
78
  beforeAll(async () => {
80
- const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
81
- resolver = createConfigResolver({ cipher: encryption });
79
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
80
+ resolver = createConfigResolver({ encryption });
82
81
 
83
82
  stack = await setupTestStack({
84
83
  features: [configFeature, platformFeature],
@@ -1,4 +1,4 @@
1
- import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import type { DbConnection, EncryptionProvider, TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  type ConfigAccessor,
4
4
  type ConfigAccessorFactory,
@@ -13,10 +13,8 @@ import {
13
13
  type TenantId,
14
14
  } from "@cosmicdrift/kumiko-framework/engine";
15
15
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
16
- import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
17
16
  import { cascadeQuery } from "./handlers/cascade.query";
18
17
  import { readinessQuery } from "./handlers/readiness.query";
19
- import { reencryptJob } from "./handlers/reencrypt.job";
20
18
  import { resetWrite } from "./handlers/reset.write";
21
19
  import { schemaQuery } from "./handlers/schema.query";
22
20
  import { setWrite } from "./handlers/set.write";
@@ -29,7 +27,7 @@ export type ConfigContext = { readonly config: ConfigAccessor };
29
27
  export function createConfigFeature(): FeatureDefinition {
30
28
  return defineFeature("config", (r) => {
31
29
  r.describe(
32
- "Stores per-tenant (and optionally per-user) configuration values with a multi-layer cascade: user-row \u2192 tenant-row \u2192 system-row \u2192 app-override (deploy-time `AppConfigOverrides`) \u2192 computed \u2192 feature default. Access a value in handlers via `ctx.config(handle)`, declare keys with `r.config({ keys: { ... } })` inside a feature's registry callback, and optionally mark them `encrypted: true` to route storage through the envelope cipher (versioned master key). Use this feature whenever a tenant admin needs to customise behaviour at runtime without a code deploy.",
30
+ "Stores per-tenant (and optionally per-user) configuration values with a multi-layer cascade: user-row \u2192 tenant-row \u2192 system-row \u2192 app-override (deploy-time `AppConfigOverrides`) \u2192 computed \u2192 feature default. Access a value in handlers via `ctx.config(handle)`, declare keys with `r.config({ keys: { ... } })` inside a feature's registry callback, and optionally mark them `encrypted: true` to route storage through an `EncryptionProvider`. Use this feature whenever a tenant admin needs to customise behaviour at runtime without a code deploy.",
33
31
  );
34
32
  r.uiHints({
35
33
  displayLabel: "Tenant Config Store",
@@ -57,12 +55,6 @@ export function createConfigFeature(): FeatureDefinition {
57
55
  readiness: r.queryHandler(readinessQuery),
58
56
  };
59
57
 
60
- // Migration + KEK-rotation for encrypted values (legacy-format →
61
- // envelope, old kekVersion → current). Manual trigger — ops runs it
62
- // once after adding a new master key version or before retiring the
63
- // legacy CONFIG_ENCRYPTION_KEY.
64
- r.job("reencrypt", { trigger: { manual: true } }, reencryptJob);
65
-
66
58
  return { handlers, queries };
67
59
  });
68
60
  }
@@ -124,14 +116,16 @@ export function requireConfigResolver(ctx: HandlerContext, handlerName: string):
124
116
  // lazily so apps that never wire encryption still boot (and only crash
125
117
  // if a handler tries to write an encrypted key without the provider in
126
118
  // place, pointing at the exact wiring gap).
127
- export function requireConfigEncryption(ctx: HandlerContext, handlerName: string): EnvelopeCipher {
119
+ export function requireConfigEncryption(
120
+ ctx: HandlerContext,
121
+ handlerName: string,
122
+ ): EncryptionProvider {
128
123
  if (!ctx.configEncryption) {
129
124
  throw new InternalError({
130
125
  message:
131
126
  `[${handlerName}] ctx.configEncryption missing — at least one config key declares ` +
132
- `encrypted: true, so the boot wiring must pass an EnvelopeCipher via ` +
133
- `extraContext.configEncryption (run{Prod,Dev}App wire it automatically from ` +
134
- `KUMIKO_SECRETS_MASTER_KEY_V<n>; same instance the resolver was built with).`,
127
+ `encrypted: true, so the boot wiring must pass an EncryptionProvider via ` +
128
+ `extraContext.configEncryption (same instance the resolver was built with).`,
135
129
  });
136
130
  }
137
131
  return ctx.configEncryption;
@@ -88,8 +88,8 @@ export const setWrite = defineWriteHandler({
88
88
 
89
89
  let serialized = JSON.stringify(event.payload.value);
90
90
  if (keyDef.encrypted) {
91
- const cipher = requireConfigEncryption(ctx, "config:write:set");
92
- serialized = await cipher.encrypt(serialized, { tenantId });
91
+ const encryption = requireConfigEncryption(ctx, "config:write:set");
92
+ serialized = encryption.encrypt(serialized);
93
93
  }
94
94
 
95
95
  const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
@@ -1,7 +1,6 @@
1
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
1
+ import type { DbConnection, EncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { seedConfigValues } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
- import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
5
4
  import { configValueEntity, configValuesTable } from "./table";
6
5
 
7
6
  export {
@@ -31,8 +30,8 @@ export { configValuesTable } from "./table";
31
30
  export function seedAllConfigValues(
32
31
  registry: Registry,
33
32
  db: DbConnection,
34
- cipher?: EnvelopeCipher,
33
+ encryption?: EncryptionProvider,
35
34
  ): Promise<{ created: number; skipped: number }> {
36
35
  const seeds = registry.getAllConfigSeeds();
37
- return seedConfigValues(seeds, configValuesTable, configValueEntity, registry, db, cipher);
36
+ return seedConfigValues(seeds, configValuesTable, configValueEntity, registry, db, encryption);
38
37
  }
@@ -1,5 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbConnection, EncryptionProvider, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type {
4
4
  ConfigCascade,
5
5
  ConfigCascadeLevel,
@@ -12,7 +12,6 @@ import type {
12
12
  } from "@cosmicdrift/kumiko-framework/engine";
13
13
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
15
- import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
16
15
  import { assertUnreachable, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
17
16
  import { selectConfigRowsForKeys, selectConfigRowsForScope } from "./db/queries/resolver";
18
17
  import { configValuesTable } from "./table";
@@ -64,33 +63,10 @@ export function deserializeValue(
64
63
  export type AppConfigOverrides = ReadonlyMap<string, string | number | boolean>;
65
64
 
66
65
  export type ConfigResolverOptions = {
67
- // Envelope cipher for `encrypted: true` keys. run{Prod,Dev}App wire it
68
- // automatically from the secrets master key — apps only pass their own
69
- // to override (tests, custom providers).
70
- cipher?: EnvelopeCipher;
66
+ encryption?: EncryptionProvider;
71
67
  appOverrides?: AppConfigOverrides;
72
68
  };
73
69
 
74
- // Decrypt gate for encrypted keys: an encrypted row without a cipher MUST
75
- // throw — passing the ciphertext through as the "value" (the pre-envelope
76
- // behavior) silently feeds base64 garbage to whatever consumes the key
77
- // (e.g. an SMTP password) and is a debugging nightmare.
78
- async function decryptEncrypted(
79
- cipher: EnvelopeCipher | undefined,
80
- raw: string,
81
- qualifiedKey: string,
82
- ): Promise<string> {
83
- if (!cipher) {
84
- throw new InternalError({
85
- message:
86
- `[config] key "${qualifiedKey}" is encrypted but no cipher is wired — the boot must ` +
87
- `provide a master key (KUMIKO_SECRETS_MASTER_KEY_V<n>) or pass ConfigResolverOptions.cipher.`,
88
- i18nKey: "config.errors.cipher_missing",
89
- });
90
- }
91
- return cipher.decrypt(raw);
92
- }
93
-
94
70
  // backing="secrets" keys store their value in the secrets store (flat per
95
71
  // (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
96
72
  // (getWithSource + buildCascade) route the system rung here. Missing reader =
@@ -125,7 +101,7 @@ async function buildCascade(
125
101
  userId: string | null,
126
102
  ) => Promise<ConfigRow | null> | ConfigRow | null,
127
103
  appOverrides: AppConfigOverrides | undefined,
128
- cipher: EnvelopeCipher | undefined,
104
+ encryption: EncryptionProvider | undefined,
129
105
  secretsReader: ConfigSecretsReader | undefined,
130
106
  ): Promise<ConfigCascade> {
131
107
  type Lookup = {
@@ -203,8 +179,8 @@ async function buildCascade(
203
179
  const row = await fetchRow(lookup.tenantId, lookup.userId);
204
180
  if (row?.value !== null && row?.value !== undefined) {
205
181
  let raw = row.value;
206
- if (keyDef.encrypted) {
207
- raw = await decryptEncrypted(cipher, raw, qualifiedKey);
182
+ if (keyDef.encrypted && encryption) {
183
+ raw = encryption.decrypt(raw);
208
184
  }
209
185
  if (activeIndex === -1) activeIndex = levels.length;
210
186
  levels.push({
@@ -289,7 +265,7 @@ async function buildCascade(
289
265
  }
290
266
 
291
267
  export function createConfigResolver(options: ConfigResolverOptions = {}): ConfigResolver {
292
- const { cipher, appOverrides } = options;
268
+ const { encryption, appOverrides } = options;
293
269
  async function findRow(
294
270
  key: string,
295
271
  tenantId: string,
@@ -380,8 +356,8 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
380
356
  const row = await findRow(qualifiedKey, lookup.tenantId, lookup.userId, db);
381
357
  if (row?.value !== null && row?.value !== undefined) {
382
358
  let raw = row.value;
383
- if (keyDef.encrypted) {
384
- raw = await decryptEncrypted(cipher, raw, qualifiedKey);
359
+ if (keyDef.encrypted && encryption) {
360
+ raw = encryption.decrypt(raw);
385
361
  }
386
362
  return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
387
363
  }
@@ -494,7 +470,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
494
470
  db,
495
471
  (tid, uid) => findRow(qualifiedKey, tid, uid, db),
496
472
  appOverrides,
497
- cipher,
473
+ encryption,
498
474
  secretsReader,
499
475
  );
500
476
  },
@@ -536,7 +512,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
536
512
  (tid, uid) =>
537
513
  keyRows.find((r) => r.tenantId === tid && (r.userId ?? null) === uid) ?? null,
538
514
  appOverrides,
539
- cipher,
515
+ encryption,
540
516
  secretsReader,
541
517
  );
542
518
  result.set(key, cascade);
@@ -22,10 +22,6 @@ import {
22
22
  SYSTEM_TENANT_ID,
23
23
  } from "@cosmicdrift/kumiko-framework/engine";
24
24
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
25
- import {
26
- createProjectionStateTable,
27
- rebuildProjection,
28
- } from "@cosmicdrift/kumiko-framework/pipeline";
29
25
  import {
30
26
  createTestUser,
31
27
  setupTestStack,
@@ -88,7 +84,6 @@ beforeAll(async () => {
88
84
  await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
89
85
  await unsafeCreateEntityTable(stack.db, propertyEntity);
90
86
  await createEventsTable(stack.db);
91
- await createProjectionStateTable(stack.db);
92
87
  });
93
88
 
94
89
  afterAll(async () => {
@@ -924,63 +919,3 @@ describe("custom-fields integration — update-tenant-field (Bug-Bash D2)", () =
924
919
  expect(causeMessage).toContain("update-tenant-field");
925
920
  });
926
921
  });
927
-
928
- describe("custom-fields integration — official host-entity rebuild (#759)", () => {
929
- test("rebuildProjection replays customField.set/cleared + fieldDefinition.deleted", async () => {
930
- await defineField("property", "color", "text");
931
- await defineField("property", "doomed", "text");
932
- const propId = "dddddddd-dddd-4000-8000-00000000000d";
933
- await createProperty(propId, "RebuildOfficial");
934
- await setCustomField("property", propId, "color", "blue");
935
- await setCustomField("property", propId, "doomed", "bye");
936
- // fieldDef-delete cascade removes the key — the rebuild must preserve the
937
- // removal (fieldDefinition.deleted rides the extra "field-definition" source).
938
- await stack.http.writeOk(
939
- "custom-fields:write:delete-tenant-field",
940
- { entityName: "property", fieldKey: "doomed" },
941
- admin,
942
- );
943
- await stack.eventDispatcher?.runOnce();
944
-
945
- let row = (await listProperties()).rows.find((r) => r["id"] === propId);
946
- expect(row?.["color"]).toBe("blue");
947
- expect(row?.["doomed"]).toBeUndefined();
948
-
949
- // The routine schema-migration path: full official rebuild of the host
950
- // entity's implicit projection. Pre-#759 this reset every custom_fields
951
- // jsonb to its default '{}' with no recovery path.
952
- const result = await rebuildProjection("property-test:projection:property-entity", {
953
- db: stack.db,
954
- registry: stack.registry,
955
- });
956
- expect(result.eventsProcessed).toBeGreaterThan(0);
957
-
958
- row = (await listProperties()).rows.find((r) => r["id"] === propId);
959
- expect(row?.["name"]).toBe("RebuildOfficial");
960
- expect(row?.["color"]).toBe("blue");
961
- expect(row?.["doomed"]).toBeUndefined();
962
- });
963
-
964
- test("clear-custom-field removal survives the official rebuild", async () => {
965
- await defineField("property", "flag", "text");
966
- await defineField("property", "keep", "text");
967
- const propId = "eeeeeeee-eeee-4000-8000-00000000000e";
968
- await createProperty(propId, "ClearSurvives");
969
- await setCustomField("property", propId, "flag", "on");
970
- await setCustomField("property", propId, "keep", "yes");
971
- await clearCustomField("property", propId, "flag");
972
- await stack.eventDispatcher?.runOnce();
973
-
974
- await rebuildProjection("property-test:projection:property-entity", {
975
- db: stack.db,
976
- registry: stack.registry,
977
- });
978
-
979
- const row = (await listProperties()).rows.find((r) => r["id"] === propId);
980
- // Kontrollfeld beweist dass der Rebuild die set-Events real replayt hat —
981
- // ein leeres {} wuerde "flag undefined" trivial erfuellen.
982
- expect(row?.["keep"]).toBe("yes");
983
- expect(row?.["flag"]).toBeUndefined();
984
- expect(row?.["name"]).toBe("ClearSurvives");
985
- });
986
- });
@@ -31,7 +31,6 @@ export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
31
31
  // Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
32
32
  // these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
33
33
  // through r.defineEvent/toKebab, so the dot MUST stay.
34
- export const FIELD_DEFINITION_AGGREGATE_TYPE = "field-definition";
35
34
  export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
36
35
  export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
37
36
  // Qualified at registration via r.defineEvent — final name is
@@ -15,12 +15,6 @@ export const customFieldSetSchema = z.object({
15
15
  // self-projected into the host row by the write handler and must never enter
16
16
  // the immutable log. Non-sensitive sets always carry the value.
17
17
  value: z.unknown().optional(),
18
- // Explicit protocol marker (527/1) — `value === undefined` alone is also a
19
- // valid-looking "clear" shape; `_sensitive` names the reason so a future
20
- // reader can't mistake one for the other. Optional so historical events
21
- // (persisted before this field existed) still parse: apply-side readers
22
- // keep `value === undefined` as their actual branch condition.
23
- _sensitive: z.literal(true).optional(),
24
18
  });
25
19
  export type CustomFieldSetPayload = z.infer<typeof customFieldSetSchema>;
26
20
 
@@ -1,6 +1,5 @@
1
1
  import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
3
- import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants";
4
3
  import { fieldDefinitionExecutor } from "../executor";
5
4
  import { customFieldsFeature } from "../feature";
6
5
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
@@ -31,7 +30,7 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
31
30
  if (result.isSuccess) {
32
31
  await ctx.unsafeAppendEvent({
33
32
  aggregateId,
34
- aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
33
+ aggregateType: "field-definition",
35
34
  type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
36
35
  payload: {
37
36
  entityName: payload.entityName,
@@ -1,6 +1,5 @@
1
1
  import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
3
- import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants";
4
3
  import { fieldDefinitionExecutor } from "../executor";
5
4
  import { customFieldsFeature } from "../feature";
6
5
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
@@ -39,7 +38,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
39
38
  if (result.isSuccess) {
40
39
  await ctx.unsafeAppendEvent({
41
40
  aggregateId,
42
- aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
41
+ aggregateType: "field-definition",
43
42
  type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
44
43
  payload: { entityName: payload.entityName, fieldKey: payload.fieldKey, tenantId },
45
44
  });
@@ -135,7 +135,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
135
135
  aggregateType: payload.entityName,
136
136
  type: customFieldsFeature.exports.setEvent.name,
137
137
  payload: sensitive
138
- ? { fieldKey: payload.fieldKey, _sensitive: true as const }
138
+ ? { fieldKey: payload.fieldKey }
139
139
  : { fieldKey: payload.fieldKey, value: payload.value },
140
140
  });
141
141