@cosmicdrift/kumiko-bundled-features 0.48.1 → 0.51.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 (90) hide show
  1. package/package.json +9 -6
  2. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +51 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/errors.ts +19 -0
  5. package/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  6. package/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  7. package/src/auth-email-password/handlers/signup-confirm.write.ts +22 -12
  8. package/src/auth-email-password/handlers/signup-request.write.ts +12 -10
  9. package/src/auth-email-password/i18n.ts +4 -0
  10. package/src/auth-email-password/password-hashing.ts +1 -0
  11. package/src/auth-email-password/reset-token.ts +2 -0
  12. package/src/auth-email-password/seeding.ts +19 -4
  13. package/src/auth-email-password/signup-token-store.ts +1 -0
  14. package/src/auth-email-password/verification-token.ts +2 -0
  15. package/src/billing-foundation/aggregate-id.ts +1 -0
  16. package/src/cap-counter/aggregate-id.ts +2 -0
  17. package/src/config/__tests__/app-override-visibility.integration.test.ts +143 -0
  18. package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
  19. package/src/config/__tests__/cascade.integration.test.ts +111 -1
  20. package/src/config/__tests__/config.integration.test.ts +60 -0
  21. package/src/config/__tests__/env-overrides.test.ts +134 -0
  22. package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
  23. package/src/config/__tests__/read-redaction.test.ts +112 -0
  24. package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
  25. package/src/config/constants.ts +3 -1
  26. package/src/config/feature.ts +5 -2
  27. package/src/config/handlers/cascade.query.ts +13 -2
  28. package/src/config/handlers/readiness.query.ts +1 -0
  29. package/src/config/handlers/reset.write.ts +23 -2
  30. package/src/config/handlers/set.write.ts +36 -2
  31. package/src/config/handlers/values.query.ts +39 -13
  32. package/src/config/index.ts +1 -1
  33. package/src/config/read-redaction.ts +54 -0
  34. package/src/config/resolver.ts +163 -4
  35. package/src/config/web/client-plugin.ts +24 -0
  36. package/src/config/web/i18n.ts +25 -0
  37. package/src/config/web/index.ts +3 -0
  38. package/src/config/write-helpers.ts +37 -0
  39. package/src/custom-fields/aggregate-id.ts +1 -0
  40. package/src/custom-fields/wire-for-entity.ts +1 -0
  41. package/src/delivery/upsert-preference.ts +1 -0
  42. package/src/file-provider-inmemory/feature.ts +1 -1
  43. package/src/file-provider-s3/feature.ts +1 -1
  44. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
  45. package/src/jobs/feature.ts +13 -0
  46. package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
  47. package/src/legal-pages/README.md +16 -13
  48. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
  49. package/src/legal-pages/feature.ts +9 -4
  50. package/src/legal-pages/markdown.ts +6 -56
  51. package/src/legal-pages/security-headers.ts +1 -0
  52. package/src/mail-transport-inmemory/feature.ts +1 -1
  53. package/src/mail-transport-smtp/feature.ts +1 -1
  54. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
  55. package/src/managed-pages/branding.ts +142 -0
  56. package/src/managed-pages/css-gate.ts +24 -0
  57. package/src/managed-pages/feature.ts +246 -0
  58. package/src/managed-pages/handlers/branding.query.ts +30 -0
  59. package/src/managed-pages/handlers/by-slug.query.ts +35 -0
  60. package/src/managed-pages/handlers/set.write.ts +113 -0
  61. package/src/managed-pages/index.ts +30 -0
  62. package/src/managed-pages/screens/branding-screen.ts +85 -0
  63. package/src/managed-pages/screens/page-screens.ts +82 -0
  64. package/src/managed-pages/seeding.ts +99 -0
  65. package/src/managed-pages/table.ts +58 -0
  66. package/src/page-render/__tests__/branding.test.ts +57 -0
  67. package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
  68. package/src/page-render/__tests__/markdown.test.ts +41 -0
  69. package/src/page-render/branding.ts +99 -0
  70. package/src/page-render/css-sanitize.ts +344 -0
  71. package/src/page-render/index.ts +13 -0
  72. package/src/page-render/layout.ts +100 -0
  73. package/src/page-render/markdown.ts +39 -0
  74. package/src/page-render/security-headers.ts +16 -0
  75. package/src/step-dispatcher/mail-runner.ts +1 -0
  76. package/src/subscription-stripe/runtime.ts +1 -0
  77. package/src/subscription-stripe/verify-webhook.ts +1 -0
  78. package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
  79. package/src/tenant/handlers/list.query.ts +1 -1
  80. package/src/tenant/handlers/memberships.query.ts +16 -15
  81. package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
  82. package/src/tenant/handlers/update.write.ts +1 -1
  83. package/src/text-content/api.ts +1 -0
  84. package/src/tier-engine/aggregate-id.ts +1 -0
  85. package/src/user/handlers/me.query.ts +1 -1
  86. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  87. package/src/user-data-rights/deletion-token.ts +2 -0
  88. package/src/user-data-rights/feature.ts +1 -1
  89. package/src/user-data-rights/run-export-jobs.ts +2 -0
  90. package/src/user-profile/handlers/change-email.write.ts +1 -0
@@ -0,0 +1,180 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ access,
6
+ type ConfigCascade,
7
+ createSystemConfig,
8
+ defineFeature,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ createTestUser,
12
+ setupTestStack,
13
+ type TestStack,
14
+ TestUsers,
15
+ unsafePushTables,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { ConfigHandlers, ConfigQueries } from "../constants";
18
+ import { createConfigAccessorFactory, createConfigFeature } from "../feature";
19
+ import { type ConfigResolver, createConfigResolver } from "../resolver";
20
+ import { configValuesTable } from "../table";
21
+
22
+ // Proves the inheritedToTenant:false redaction end-to-end over real HTTP: a
23
+ // tenant-side admin who is allowed to READ the key (access.admin) must never
24
+ // receive the inherited system value through either config read handler, while
25
+ // the SystemAdmin who owns the platform default still sees it.
26
+
27
+ let stack: TestStack;
28
+ let db: DbConnection;
29
+ let resolver: ConfigResolver;
30
+
31
+ const systemAdmin = TestUsers.systemAdmin; // roles ["SystemAdmin"]
32
+ const tenantAdmin = createTestUser({ id: 2 }); // roles ["Admin"], same tenant
33
+
34
+ const SMTP_HOST = "platform:config:smtp-host";
35
+ const SMTP_PASS = "platform:config:smtp-pass";
36
+ const LIST_HITS = "platform:config:list-hits";
37
+
38
+ const configFeature = createConfigFeature();
39
+
40
+ const platformFeature = defineFeature("platform", (r) => {
41
+ r.requires("config");
42
+ return r.config({
43
+ keys: {
44
+ // Hidden, plaintext: a tenant may read the key but not its system value.
45
+ smtpHost: createSystemConfig("text", {
46
+ inheritedToTenant: false,
47
+ write: access.systemAdmin,
48
+ read: access.admin,
49
+ }),
50
+ // Hidden + encrypted: composition — neither value nor "is set" may leak.
51
+ smtpPass: createSystemConfig("text", {
52
+ inheritedToTenant: false,
53
+ encrypted: true,
54
+ write: access.systemAdmin,
55
+ read: access.admin,
56
+ }),
57
+ // Control: default inheritance — a tenant sees the platform value.
58
+ listHits: createSystemConfig("number", {
59
+ default: 10,
60
+ write: access.systemAdmin,
61
+ read: access.admin,
62
+ }),
63
+ },
64
+ });
65
+ });
66
+
67
+ beforeAll(async () => {
68
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
69
+ resolver = createConfigResolver({ encryption });
70
+
71
+ stack = await setupTestStack({
72
+ features: [configFeature, platformFeature],
73
+ extraContext: ({ registry }) => ({
74
+ configResolver: resolver,
75
+ configEncryption: encryption,
76
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
77
+ }),
78
+ });
79
+ db = stack.db;
80
+ await unsafePushTables(db, { configValuesTable });
81
+
82
+ // Seed the platform (system-row) values via the real write path.
83
+ await stack.http.writeOk(
84
+ ConfigHandlers.set,
85
+ { key: SMTP_HOST, value: "smtp.internal.example.com", scope: "system" },
86
+ systemAdmin,
87
+ );
88
+ await stack.http.writeOk(
89
+ ConfigHandlers.set,
90
+ { key: SMTP_PASS, value: "s3cr3t-password", scope: "system" },
91
+ systemAdmin,
92
+ );
93
+ });
94
+
95
+ afterAll(async () => {
96
+ await stack.cleanup();
97
+ });
98
+
99
+ type Cascades = Record<string, ConfigCascade>;
100
+ const systemLevel = (c: Cascades, key: string) =>
101
+ c[key]?.levels.find((l) => l.source === "system-row");
102
+
103
+ describe("inheritedToTenant redaction — config:query:cascade", () => {
104
+ test("SystemAdmin sees the inherited system value", async () => {
105
+ const res = await stack.http.queryOk<Cascades>(
106
+ ConfigQueries.cascade,
107
+ { keys: [SMTP_HOST] },
108
+ systemAdmin,
109
+ );
110
+ expect(systemLevel(res, SMTP_HOST)?.value).toBe("smtp.internal.example.com");
111
+ expect(res[SMTP_HOST]?.value).toBe("smtp.internal.example.com");
112
+ });
113
+
114
+ test("tenant-side admin gets the system value redacted (value AND hasValue)", async () => {
115
+ const res = await stack.http.queryOk<Cascades>(
116
+ ConfigQueries.cascade,
117
+ { keys: [SMTP_HOST] },
118
+ tenantAdmin,
119
+ );
120
+ const sys = systemLevel(res, SMTP_HOST);
121
+ expect(sys?.value).toBeUndefined();
122
+ expect(sys?.hasValue).toBe(false);
123
+ expect(res[SMTP_HOST]?.value).not.toBe("smtp.internal.example.com");
124
+ });
125
+
126
+ test("composition: encrypted + inheritedToTenant:false leaks neither value nor 'is set'", async () => {
127
+ const tenant = await stack.http.queryOk<Cascades>(
128
+ ConfigQueries.cascade,
129
+ { keys: [SMTP_PASS] },
130
+ tenantAdmin,
131
+ );
132
+ const tenantSys = systemLevel(tenant, SMTP_PASS);
133
+ expect(tenantSys?.value).toBeUndefined(); // not even the "••••••" mask
134
+ expect(tenantSys?.hasValue).toBe(false);
135
+
136
+ // SystemAdmin still sees it as set, value hidden by encryption masking.
137
+ const sa = await stack.http.queryOk<Cascades>(
138
+ ConfigQueries.cascade,
139
+ { keys: [SMTP_PASS] },
140
+ systemAdmin,
141
+ );
142
+ const saSys = systemLevel(sa, SMTP_PASS);
143
+ expect(saSys?.value).toBe("••••••");
144
+ expect(saSys?.hasValue).toBe(true);
145
+ });
146
+
147
+ test("control: a transparently-inherited system key stays visible to tenants", async () => {
148
+ const res = await stack.http.queryOk<Cascades>(
149
+ ConfigQueries.cascade,
150
+ { keys: [LIST_HITS] },
151
+ tenantAdmin,
152
+ );
153
+ expect(res[LIST_HITS]?.value).toBe(10);
154
+ });
155
+ });
156
+
157
+ describe("inheritedToTenant redaction — config:query:values", () => {
158
+ test("SystemAdmin sees the inherited system value", async () => {
159
+ const res = await stack.http.queryOk<Record<string, { value: unknown; source: string }>>(
160
+ ConfigQueries.values,
161
+ {},
162
+ systemAdmin,
163
+ );
164
+ expect(res[SMTP_HOST]?.value).toBe("smtp.internal.example.com");
165
+ });
166
+
167
+ test("tenant-side admin sees the key as unset, not the inherited value", async () => {
168
+ const res = await stack.http.queryOk<Record<string, { value: unknown; source: string }>>(
169
+ ConfigQueries.values,
170
+ {},
171
+ tenantAdmin,
172
+ );
173
+ expect(res[SMTP_HOST]?.value).not.toBe("smtp.internal.example.com");
174
+ // No keyDef.default on SMTP_HOST → after redaction the key is genuinely
175
+ // unset. values.query now resolves through the cascade (same path as
176
+ // config:query:cascade), so the source is "missing", matching cascade.query
177
+ // instead of the old values.query-only synthesized "default".
178
+ expect(res[SMTP_HOST]?.source).toBe("missing");
179
+ });
180
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConfigCascade, ConfigCascadeLevel } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ mayViewInheritedValue,
5
+ redactInheritedCascade,
6
+ shouldRedactInherited,
7
+ } from "../read-redaction";
8
+
9
+ function level(
10
+ source: ConfigCascadeLevel["source"],
11
+ value: string | number | boolean | undefined,
12
+ isActive = false,
13
+ ): ConfigCascadeLevel {
14
+ return { label: source, source, value, isActive, hasValue: value !== undefined };
15
+ }
16
+
17
+ function cascade(levels: ConfigCascadeLevel[]): ConfigCascade {
18
+ const active = levels.find((l) => l.isActive);
19
+ return { value: active?.value, source: active?.source ?? "missing", levels };
20
+ }
21
+
22
+ describe("read-redaction — viewer predicate", () => {
23
+ test("only SystemAdmin may view the inherited platform value", () => {
24
+ expect(mayViewInheritedValue(["SystemAdmin"])).toBe(true);
25
+ expect(mayViewInheritedValue(["Admin", "TenantAdmin"])).toBe(false);
26
+ expect(mayViewInheritedValue([])).toBe(false);
27
+ });
28
+
29
+ test("shouldRedactInherited requires inheritedToTenant:false AND a tenant-side viewer", () => {
30
+ expect(shouldRedactInherited({ inheritedToTenant: false }, ["Admin"])).toBe(true);
31
+ expect(shouldRedactInherited({ inheritedToTenant: false }, ["SystemAdmin"])).toBe(false);
32
+ expect(shouldRedactInherited({ inheritedToTenant: undefined }, ["Admin"])).toBe(false);
33
+ expect(shouldRedactInherited({ inheritedToTenant: true }, ["Admin"])).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("read-redaction — cascade redaction strips every inherited platform rung", () => {
38
+ test("strips system-row and falls through to missing", () => {
39
+ const out = redactInheritedCascade(
40
+ cascade([level("system-row", "smtp.internal", true), level("default", undefined)]),
41
+ );
42
+ const sys = out.levels.find((l) => l.source === "system-row");
43
+ expect(sys?.value).toBeUndefined();
44
+ expect(sys?.hasValue).toBe(false);
45
+ expect(out.value).toBeUndefined();
46
+ expect(out.source).toBe("missing");
47
+ });
48
+
49
+ test("strips the app-override rung too — the #376 leak via ENV-bridged value", () => {
50
+ const out = redactInheritedCascade(
51
+ cascade([
52
+ level("system-row", "secret", true),
53
+ level("app-override", "from-env"),
54
+ level("default", undefined),
55
+ ]),
56
+ );
57
+ // The platform env value must NOT become the new winner.
58
+ expect(out.value).toBeUndefined();
59
+ expect(out.source).toBe("missing");
60
+ expect(out.levels.find((l) => l.source === "app-override")?.value).toBeUndefined();
61
+ expect(out.levels.find((l) => l.source === "app-override")?.hasValue).toBe(false);
62
+ });
63
+
64
+ test("strips computed and static default rungs", () => {
65
+ const out = redactInheritedCascade(
66
+ cascade([
67
+ level("system-row", "sys", true),
68
+ level("computed", "plan-derived"),
69
+ level("default", "schema-default"),
70
+ ]),
71
+ );
72
+ expect(out.value).toBeUndefined();
73
+ expect(out.source).toBe("missing");
74
+ expect(out.levels.find((l) => l.source === "computed")?.hasValue).toBe(false);
75
+ expect(out.levels.find((l) => l.source === "default")?.hasValue).toBe(false);
76
+ });
77
+
78
+ test("a tenant's own override survives; every platform rung is hidden", () => {
79
+ const out = redactInheritedCascade(
80
+ cascade([
81
+ level("tenant-row", "tenant-value", true),
82
+ level("system-row", "platform-default"),
83
+ level("app-override", "from-env"),
84
+ level("default", "schema-default"),
85
+ ]),
86
+ );
87
+ expect(out.value).toBe("tenant-value");
88
+ expect(out.source).toBe("tenant-row");
89
+ expect(out.levels.find((l) => l.source === "tenant-row")?.isActive).toBe(true);
90
+ for (const source of ["system-row", "app-override", "default"] as const) {
91
+ expect(out.levels.find((l) => l.source === source)?.hasValue).toBe(false);
92
+ }
93
+ });
94
+
95
+ test("a user's own override survives over tenant-row", () => {
96
+ const out = redactInheritedCascade(
97
+ cascade([
98
+ level("user-row", "user-value", true),
99
+ level("tenant-row", "tenant-value"),
100
+ level("system-row", "platform"),
101
+ ]),
102
+ );
103
+ expect(out.value).toBe("user-value");
104
+ expect(out.source).toBe("user-row");
105
+ expect(out.levels.find((l) => l.source === "tenant-row")?.value).toBe("tenant-value");
106
+ });
107
+
108
+ test("no-op when no platform rung carries a value", () => {
109
+ const input = cascade([level("tenant-row", "x", true), level("default", undefined)]);
110
+ expect(redactInheritedCascade(input)).toEqual(input);
111
+ });
112
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SETTINGS_HUB_FEATURE } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { CONFIG_FEATURE } from "../constants";
4
+
5
+ // Cross-package pin: buildAppSchema merges the generated Settings-Hub into the
6
+ // FeatureSchema named SETTINGS_HUB_FEATURE. The framework hard-codes that name
7
+ // because it cannot import bundled-features (dependency points the other way).
8
+ // This test lives where BOTH constants are visible — if the config feature is
9
+ // ever renamed, the hub would silently land in a phantom feature; this fails first.
10
+ describe("Settings-Hub feature-name pin", () => {
11
+ test("framework's SETTINGS_HUB_FEATURE equals the config feature's name", () => {
12
+ expect(SETTINGS_HUB_FEATURE).toBe(CONFIG_FEATURE);
13
+ });
14
+ });
@@ -1,4 +1,6 @@
1
- // Feature name
1
+ // @runtime client
2
+ // Pure name/string constants — browser-safe, importable from web client code
3
+ // (configClient() pins its feature name to CONFIG_FEATURE).
2
4
  export const CONFIG_FEATURE = "config" as const;
3
5
 
4
6
  // Qualified write handler names (QN format: scope:type:name)
@@ -4,6 +4,7 @@ import {
4
4
  type ConfigAccessorFactory,
5
5
  type ConfigKeyHandle,
6
6
  type ConfigKeyType,
7
+ type ConfigSecretsReader,
7
8
  type ConfigValue,
8
9
  defineFeature,
9
10
  type FeatureDefinition,
@@ -59,6 +60,7 @@ export function createConfigAccessor(
59
60
  tenantId: TenantId,
60
61
  userId: string,
61
62
  db: DbConnection | TenantDb,
63
+ secrets?: ConfigSecretsReader,
62
64
  ): ConfigAccessor {
63
65
  async function configAccessor(
64
66
  qualifiedKey: string,
@@ -72,7 +74,7 @@ export function createConfigAccessor(
72
74
  const qualifiedKey = typeof keyOrHandle === "string" ? keyOrHandle : keyOrHandle.name;
73
75
  const keyDef = registry.getConfigKey(qualifiedKey);
74
76
  if (!keyDef) return undefined;
75
- return resolver.get(qualifiedKey, keyDef, tenantId, userId, db);
77
+ return resolver.get(qualifiedKey, keyDef, tenantId, userId, db, secrets);
76
78
  }
77
79
  return configAccessor;
78
80
  }
@@ -83,7 +85,8 @@ export function createConfigAccessorFactory(
83
85
  registry: Registry,
84
86
  resolver: ConfigResolver,
85
87
  ): ConfigAccessorFactory {
86
- return ({ user, db }) => createConfigAccessor(registry, resolver, user.tenantId, user.id, db);
88
+ return ({ user, db, secrets }) =>
89
+ createConfigAccessor(registry, resolver, user.tenantId, user.id, db, secrets);
87
90
  }
88
91
 
89
92
  // Single point of truth for "this handler needs the resolver". Throws a
@@ -5,6 +5,7 @@ import {
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { z } from "zod";
7
7
  import { requireConfigResolver } from "../feature";
8
+ import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
8
9
  import { hasConfigAccess } from "../write-helpers";
9
10
 
10
11
  const MASKED = "••••••";
@@ -43,14 +44,24 @@ export const cascadeQuery = defineQueryHandler({
43
44
  query.user.tenantId,
44
45
  query.user.id,
45
46
  db,
47
+ ctx.secrets,
46
48
  );
47
49
 
48
50
  const result: Record<string, ConfigCascade> = {};
49
- for (const [key, cascade] of cascades) {
51
+ for (const [key, rawCascade] of cascades) {
50
52
  const keyDef = keyDefs.get(key);
51
53
  if (!keyDef) continue;
52
54
 
53
- if (keyDef.encrypted) {
55
+ // Redact the inherited platform value (system-row, app-override,
56
+ // computed, default) BEFORE masking — masking alone leaves hasValue=true
57
+ // and would still leak "it is set" to a tenant.
58
+ const cascade = shouldRedactInherited(keyDef, query.user.roles)
59
+ ? redactInheritedCascade(rawCascade)
60
+ : rawCascade;
61
+
62
+ // backing="secrets" is masked like encrypted — the resolver revealed the
63
+ // plaintext for internal reads, but it must never reach the cascade UI.
64
+ if (keyDef.encrypted || keyDef.backing === "secrets") {
54
65
  const maskedLevels: ConfigCascadeLevel[] = cascade.levels.map((l) => ({
55
66
  ...l,
56
67
  value: l.hasValue ? MASKED : l.value,
@@ -98,6 +98,7 @@ export async function collectMissingRequiredConfig(
98
98
  user.tenantId,
99
99
  user.id,
100
100
  ctx.db,
101
+ ctx.secrets,
101
102
  );
102
103
  for (const [qualifiedKey, keyDef] of candidates) {
103
104
  const value = cascades.get(qualifiedKey)?.value;
@@ -1,5 +1,10 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
- import { ConfigScopes, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ ConfigScopes,
4
+ defineWriteHandler,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
8
  import { z } from "zod";
4
9
  import { configValueEntity, configValuesTable } from "../table";
5
10
  import { findConfigRow, prepareConfigWrite } from "../write-helpers";
@@ -28,7 +33,23 @@ export const resetWrite = defineWriteHandler({
28
33
  scope: event.payload.scope,
29
34
  });
30
35
  if (!prep.ok) return prep.failure;
31
- const { scope, tenantId, userId } = prep;
36
+ const { keyDef, scope, tenantId, userId } = prep;
37
+
38
+ // backing="secrets": clear the secret from the secrets store. delete() is
39
+ // idempotent (returns false if absent) — mirrors the config no-op contract.
40
+ if (keyDef.backing === "secrets") {
41
+ if (!ctx.secrets) {
42
+ throw new InternalError({
43
+ message:
44
+ `[config:write:reset] key "${event.payload.key}" declares backing="secrets" but ` +
45
+ `ctx.secrets is not wired — provide extraContext.secrets (and a MasterKeyProvider).`,
46
+ });
47
+ }
48
+ await ctx.secrets.delete(SYSTEM_TENANT_ID, event.payload.key, {
49
+ deletedBy: event.user.id,
50
+ });
51
+ return { isSuccess: true, data: { key: event.payload.key, scope } };
52
+ }
32
53
 
33
54
  const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
34
55
 
@@ -1,6 +1,10 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
- import { ConfigScopes, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import {
3
+ ConfigScopes,
4
+ defineWriteHandler,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
8
  import { z } from "zod";
5
9
  import { requireConfigEncryption } from "../feature";
6
10
  import { configValueEntity, configValuesTable } from "../table";
@@ -8,6 +12,7 @@ import {
8
12
  findConfigRow,
9
13
  prepareConfigWrite,
10
14
  validateBounds,
15
+ validatePattern,
11
16
  validateScope,
12
17
  validateType,
13
18
  } from "../write-helpers";
@@ -52,6 +57,35 @@ export const setWrite = defineWriteHandler({
52
57
  const boundsError = validateBounds(event.payload.value, keyDef);
53
58
  if (boundsError) return writeFailure(boundsError);
54
59
 
60
+ const patternError = validatePattern(event.payload.value, keyDef);
61
+ if (patternError) return writeFailure(patternError);
62
+
63
+ // backing="secrets": persist into the secrets store (system tenant, own
64
+ // envelope encryption + audit) instead of config_values. Same JSON
65
+ // serialization as a config row so the read path round-trips via
66
+ // deserializeValue. system-scope is guaranteed by the boot-guard.
67
+ if (keyDef.backing === "secrets") {
68
+ if (!ctx.secrets) {
69
+ throw new InternalError({
70
+ message:
71
+ `[config:write:set] key "${event.payload.key}" declares backing="secrets" but ` +
72
+ `ctx.secrets is not wired — provide extraContext.secrets (and a MasterKeyProvider).`,
73
+ });
74
+ }
75
+ await ctx.secrets.set(
76
+ SYSTEM_TENANT_ID,
77
+ event.payload.key,
78
+ JSON.stringify(event.payload.value),
79
+ {
80
+ updatedBy: event.user.id,
81
+ },
82
+ );
83
+ return {
84
+ isSuccess: true,
85
+ data: { key: event.payload.key, value: event.payload.value, scope },
86
+ };
87
+ }
88
+
55
89
  let serialized = JSON.stringify(event.payload.value);
56
90
  if (keyDef.encrypted) {
57
91
  const encryption = requireConfigEncryption(ctx, "config:write:set");
@@ -1,13 +1,16 @@
1
1
  import {
2
+ type ConfigKeyDefinition,
2
3
  type ConfigScope,
3
4
  type ConfigValueSource,
4
5
  defineQueryHandler,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { z } from "zod";
7
8
  import { requireConfigResolver } from "../feature";
8
- import { deserializeValue } from "../resolver";
9
+ import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
9
10
  import { hasConfigAccess } from "../write-helpers";
10
11
 
12
+ const MASKED = "••••••";
13
+
11
14
  export const valuesQuery = defineQueryHandler({
12
15
  name: "values",
13
16
  schema: z.object({}),
@@ -19,7 +22,25 @@ export const valuesQuery = defineQueryHandler({
19
22
  const resolver = requireConfigResolver(ctx, "config:query:values");
20
23
 
21
24
  const allKeys = registry.getAllConfigKeys();
22
- const storedValues = await resolver.getAllWithSource(query.user.tenantId, query.user.id, db);
25
+ const keyDefs = new Map<string, ConfigKeyDefinition>();
26
+ const filteredKeys: string[] = [];
27
+ for (const [qualifiedKey, keyDef] of allKeys) {
28
+ if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
29
+ keyDefs.set(qualifiedKey, keyDef);
30
+ filteredKeys.push(qualifiedKey);
31
+ }
32
+
33
+ // Resolve through the full cascade (rows → app-override → computed →
34
+ // default) — the same path as config:query:cascade — so the mask shows the
35
+ // inherited default (e.g. an ENV-bridged app-override), not only DB rows.
36
+ const cascades = await resolver.getCascadeBatch(
37
+ filteredKeys,
38
+ keyDefs,
39
+ query.user.tenantId,
40
+ query.user.id,
41
+ db,
42
+ ctx.secrets,
43
+ );
23
44
 
24
45
  const result: Record<
25
46
  string,
@@ -30,22 +51,27 @@ export const valuesQuery = defineQueryHandler({
30
51
  }
31
52
  > = {};
32
53
 
33
- for (const [qualifiedKey, keyDef] of allKeys) {
34
- if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
54
+ for (const [qualifiedKey, keyDef] of keyDefs) {
55
+ const rawCascade = cascades.get(qualifiedKey);
56
+ if (!rawCascade) continue;
35
57
 
36
- const stored = storedValues.get(qualifiedKey);
37
- let value: string | number | boolean | undefined;
38
- const source: ConfigValueSource = stored?.source ?? "default";
58
+ // Redact inherited platform rungs BEFORE masking — masking alone leaves
59
+ // a value present and would leak "it is set" to a tenant-side viewer.
60
+ const cascade = shouldRedactInherited(keyDef, query.user.roles)
61
+ ? redactInheritedCascade(rawCascade)
62
+ : rawCascade;
39
63
 
40
- if (keyDef.encrypted) {
41
- value = stored ? "••••••" : undefined;
42
- } else if (stored?.value !== null && stored?.value !== undefined) {
43
- value = deserializeValue(stored.value, keyDef.type);
64
+ // backing="secrets" carries a credential — mask it like an encrypted
65
+ // key so the plaintext (which the resolver revealed for internal reads)
66
+ // never reaches the UI response.
67
+ let value: string | number | boolean | undefined;
68
+ if (keyDef.encrypted || keyDef.backing === "secrets") {
69
+ value = cascade.value !== undefined ? MASKED : undefined;
44
70
  } else {
45
- value = keyDef.default;
71
+ value = cascade.value;
46
72
  }
47
73
 
48
- result[qualifiedKey] = { value, scope: keyDef.scope, source };
74
+ result[qualifiedKey] = { value, scope: keyDef.scope, source: cascade.source };
49
75
  }
50
76
 
51
77
  return result;
@@ -21,7 +21,7 @@ export {
21
21
  collectMissingRequiredConfig,
22
22
  } from "./handlers/readiness.query";
23
23
  export type { AppConfigOverrides, ConfigResolver } from "./resolver";
24
- export { createConfigResolver, validateAppOverrides } from "./resolver";
24
+ export { buildEnvConfigOverrides, createConfigResolver, validateAppOverrides } from "./resolver";
25
25
  export { configValuesTable } from "./table";
26
26
 
27
27
  // Boot helper for runDevApp / runProdApp: pulls every ConfigSeedDef from
@@ -0,0 +1,54 @@
1
+ import type {
2
+ ConfigCascade,
3
+ ConfigCascadeLevel,
4
+ ConfigKeyDefinition,
5
+ ConfigValueSource,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ const SYSTEM_ADMIN_ROLE = "SystemAdmin";
9
+
10
+ // The viewer's own cascade rungs. Every other rung (system-row, app-override,
11
+ // computed, default) carries a platform-inherited value — for an
12
+ // inheritedToTenant:false key those must stay hidden from a tenant-side viewer.
13
+ const OWN_SOURCES: ReadonlySet<ConfigValueSource> = new Set(["user-row", "tenant-row"]);
14
+
15
+ // A SystemAdmin owns the platform-level values and may always see them. Every
16
+ // other viewer (TenantAdmin, User) is tenant-side — for an
17
+ // inheritedToTenant:false key they must learn neither the inherited platform
18
+ // value nor that it is set.
19
+ export function mayViewInheritedValue(roles: readonly string[]): boolean {
20
+ return roles.includes(SYSTEM_ADMIN_ROLE);
21
+ }
22
+
23
+ export function shouldRedactInherited(
24
+ keyDef: Pick<ConfigKeyDefinition, "inheritedToTenant">,
25
+ roles: readonly string[],
26
+ ): boolean {
27
+ return keyDef.inheritedToTenant === false && !mayViewInheritedValue(roles);
28
+ }
29
+
30
+ // Strips every platform-inherited level (system-row, app-override, computed,
31
+ // default — anything that is not the viewer's own user-/tenant-row) so a
32
+ // tenant-side viewer sees an inheritedToTenant:false key as if no platform
33
+ // value existed, then recomputes the winning level among the survivors. A
34
+ // no-op when the cascade carries no inherited value.
35
+ export function redactInheritedCascade(cascade: ConfigCascade): ConfigCascade {
36
+ let redacted = false;
37
+ const levels: ConfigCascadeLevel[] = cascade.levels.map((level) => {
38
+ if (!OWN_SOURCES.has(level.source) && level.hasValue) {
39
+ redacted = true;
40
+ return { ...level, value: undefined, hasValue: false, isActive: false };
41
+ }
42
+ return { ...level, isActive: false };
43
+ });
44
+ if (!redacted) return cascade;
45
+
46
+ for (let i = 0; i < levels.length; i++) {
47
+ const level = levels[i];
48
+ if (level?.hasValue) {
49
+ levels[i] = { ...level, isActive: true };
50
+ return { value: level.value, source: level.source, levels };
51
+ }
52
+ }
53
+ return { value: undefined, source: "missing", levels };
54
+ }