@cosmicdrift/kumiko-bundled-features 0.48.0 → 0.50.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 (58) hide show
  1. package/package.json +2 -1
  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__/cascade.integration.test.ts +111 -1
  19. package/src/config/__tests__/env-overrides.test.ts +134 -0
  20. package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
  21. package/src/config/__tests__/read-redaction.test.ts +112 -0
  22. package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
  23. package/src/config/constants.ts +3 -1
  24. package/src/config/handlers/cascade.query.ts +9 -1
  25. package/src/config/handlers/values.query.ts +34 -12
  26. package/src/config/index.ts +1 -1
  27. package/src/config/read-redaction.ts +54 -0
  28. package/src/config/resolver.ts +70 -1
  29. package/src/config/web/client-plugin.ts +24 -0
  30. package/src/config/web/i18n.ts +25 -0
  31. package/src/config/web/index.ts +3 -0
  32. package/src/custom-fields/aggregate-id.ts +1 -0
  33. package/src/custom-fields/wire-for-entity.ts +1 -0
  34. package/src/delivery/upsert-preference.ts +1 -0
  35. package/src/file-provider-inmemory/feature.ts +1 -1
  36. package/src/file-provider-s3/feature.ts +1 -1
  37. package/src/mail-transport-inmemory/feature.ts +1 -1
  38. package/src/mail-transport-smtp/feature.ts +1 -1
  39. package/src/step-dispatcher/mail-runner.ts +1 -0
  40. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +1 -1
  41. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +1 -1
  42. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +1 -1
  43. package/src/subscription-stripe/constants.ts +0 -4
  44. package/src/subscription-stripe/runtime.ts +5 -2
  45. package/src/subscription-stripe/verify-webhook.ts +1 -0
  46. package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
  47. package/src/tenant/handlers/list.query.ts +1 -1
  48. package/src/tenant/handlers/memberships.query.ts +16 -15
  49. package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
  50. package/src/tenant/handlers/update.write.ts +1 -1
  51. package/src/text-content/api.ts +1 -0
  52. package/src/tier-engine/aggregate-id.ts +1 -0
  53. package/src/user/handlers/me.query.ts +1 -1
  54. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  55. package/src/user-data-rights/deletion-token.ts +2 -0
  56. package/src/user-data-rights/feature.ts +1 -1
  57. package/src/user-data-rights/run-export-jobs.ts +2 -0
  58. package/src/user-profile/handlers/change-email.write.ts +1 -0
@@ -23,7 +23,7 @@ import {
23
23
  } from "@cosmicdrift/kumiko-framework/stack";
24
24
  import { ConfigHandlers, ConfigQueries } from "../constants";
25
25
  import { createConfigAccessorFactory, createConfigFeature } from "../feature";
26
- import { type ConfigResolver, createConfigResolver } from "../resolver";
26
+ import { buildEnvConfigOverrides, type ConfigResolver, createConfigResolver } from "../resolver";
27
27
  import { configValueEntity, configValuesTable } from "../table";
28
28
 
29
29
  let stack: TestStack;
@@ -47,6 +47,13 @@ const cascadeFeature = defineFeature("cascade-test", (r) => {
47
47
  read: access.all,
48
48
  write: access.all,
49
49
  }),
50
+ // User-scope key whose only stored row is a system-row (via seed).
51
+ // Exercises the user → tenant → SYSTEM_TENANT_ID cascade rung.
52
+ userInheritKey: createUserConfig("text", {
53
+ default: "DEFAULT_USER_INHERIT",
54
+ read: access.all,
55
+ write: access.all,
56
+ }),
50
57
  systemKey: createSystemConfig("text", {
51
58
  default: "DEFAULT_SYSTEM",
52
59
  read: access.systemAdmin,
@@ -69,10 +76,20 @@ const cascadeFeature = defineFeature("cascade-test", (r) => {
69
76
  read: access.all,
70
77
  write: access.all,
71
78
  }),
79
+ // End-to-end seam key: a plain scope factory carrying an `env` binding
80
+ // so buildEnvConfigOverrides reads it off the real registry under the
81
+ // qualified name define-feature assigns.
82
+ envKey: createSystemConfig("text", {
83
+ env: "CASCADE_ENV_VALUE",
84
+ default: "DEFAULT_ENV",
85
+ read: access.all,
86
+ write: access.all,
87
+ }),
72
88
  },
73
89
  seeds: {
74
90
  tenantKey: createTenantSeed({ value: "SEED_TENANT" }),
75
91
  systemKey: createSystemSeed({ value: "SEED_SYSTEM" }),
92
+ userInheritKey: createSystemSeed({ value: "SEED_SYSTEM_FOR_USER" }),
76
93
  },
77
94
  });
78
95
  });
@@ -81,10 +98,12 @@ const configFeature = createConfigFeature();
81
98
 
82
99
  const TENANT_KEY = "cascade-test:config:tenant-key";
83
100
  const USER_KEY = "cascade-test:config:user-key";
101
+ const USER_INHERIT_KEY = "cascade-test:config:user-inherit-key";
84
102
  const SYSTEM_KEY = "cascade-test:config:system-key";
85
103
  const NUMBER_KEY = "cascade-test:config:number-key";
86
104
  const BOOLEAN_KEY = "cascade-test:config:boolean-key";
87
105
  const COMPUTED_KEY = "cascade-test:config:computed-key";
106
+ const ENV_KEY = "cascade-test:config:env-key";
88
107
 
89
108
  beforeAll(async () => {
90
109
  resolver = createConfigResolver();
@@ -225,6 +244,36 @@ describe("getCascade", () => {
225
244
  expect(tenantLevel?.isActive).toBe(false);
226
245
  });
227
246
 
247
+ test("user-scope key falls through to system-row when no user/tenant row exists", async () => {
248
+ // No user-row, no tenant-row for this key — only a seeded system-row.
249
+ // Before the user-cascade gained a SYSTEM_TENANT_ID rung, this resolved
250
+ // straight to the static default, skipping the operator-set system value.
251
+ const keyDef = stack.registry.getConfigKey(USER_INHERIT_KEY);
252
+ expect(keyDef).toBeDefined();
253
+
254
+ const cascade = await resolver.getCascade(
255
+ USER_INHERIT_KEY,
256
+ keyDef!,
257
+ tenantAdmin.tenantId,
258
+ tenantAdmin.id,
259
+ db,
260
+ );
261
+
262
+ const userLevel = cascade.levels.find((l) => l.source === "user-row");
263
+ expect(userLevel?.hasValue).toBe(false);
264
+ const tenantLevel = cascade.levels.find((l) => l.source === "tenant-row");
265
+ expect(tenantLevel?.hasValue).toBe(false);
266
+
267
+ const systemLevel = cascade.levels.find((l) => l.source === "system-row");
268
+ expect(systemLevel).toBeDefined();
269
+ expect(systemLevel?.hasValue).toBe(true);
270
+ expect(systemLevel?.value).toBe("SEED_SYSTEM_FOR_USER");
271
+ expect(systemLevel?.isActive).toBe(true);
272
+
273
+ expect(cascade.value).toBe("SEED_SYSTEM_FOR_USER");
274
+ expect(cascade.source).toBe("system-row");
275
+ });
276
+
228
277
  test("system-scope key with system-row + default", async () => {
229
278
  const keyDef = stack.registry.getConfigKey(SYSTEM_KEY);
230
279
  expect(keyDef).toBeDefined();
@@ -279,6 +328,35 @@ describe("getCascadeBatch", () => {
279
328
  );
280
329
  expect(cascades.size).toBe(0);
281
330
  });
331
+
332
+ test("user-scope key resolves its system-row via the batch preload", async () => {
333
+ // The batch path preloads rows with selectConfigRowsForKeys (no scope
334
+ // gate) and matches them per (tenantId, userId) in buildCascade. This
335
+ // pins that a user-scope key's system-row is preloaded AND surfaced —
336
+ // the single-key path proves the lookup, this proves the preload feeds it.
337
+ const keyDef = stack.registry.getConfigKey(USER_INHERIT_KEY);
338
+ expect(keyDef).toBeDefined();
339
+ const keyDefs = new Map<string, ConfigKeyDefinition<ConfigKeyType>>([
340
+ [USER_INHERIT_KEY, keyDef!],
341
+ ]);
342
+
343
+ const cascades = await resolver.getCascadeBatch(
344
+ [USER_INHERIT_KEY],
345
+ keyDefs,
346
+ tenantAdmin.tenantId,
347
+ tenantAdmin.id,
348
+ db,
349
+ );
350
+
351
+ const cascade = cascades.get(USER_INHERIT_KEY);
352
+ expect(cascade).toBeDefined();
353
+ const systemLevel = cascade?.levels.find((l) => l.source === "system-row");
354
+ expect(systemLevel?.hasValue).toBe(true);
355
+ expect(systemLevel?.value).toBe("SEED_SYSTEM_FOR_USER");
356
+ expect(systemLevel?.isActive).toBe(true);
357
+ expect(cascade?.value).toBe("SEED_SYSTEM_FOR_USER");
358
+ expect(cascade?.source).toBe("system-row");
359
+ });
282
360
  });
283
361
 
284
362
  describe("cascade levels — non-DB sources", () => {
@@ -330,6 +408,38 @@ describe("cascade levels — non-DB sources", () => {
330
408
  expect(cascade.value).toBe(true);
331
409
  expect(cascade.source).toBe("app-override");
332
410
  });
411
+
412
+ test("end-to-end: env-declared key bridges through the real registry to the resolver", async () => {
413
+ // The single flow none of the per-layer tests cover: a key declared via
414
+ // createSystemConfig({ env }) on the REAL registry → its qualified
415
+ // name (define-feature-assigned) → buildEnvConfigOverrides emits exactly
416
+ // that key off getAllConfigKeys → resolver resolves it as app-override.
417
+ // A mismatch in key-qualification across registry/bridge/resolver would
418
+ // leave the per-layer stub/registry tests green and only break on the
419
+ // first real consumer. This pins the seam at a real qualified string.
420
+ const keyDef = stack.registry.getConfigKey(ENV_KEY);
421
+ expect(keyDef).toBeDefined();
422
+ expect(keyDef?.env).toBe("CASCADE_ENV_VALUE");
423
+
424
+ const overrides = buildEnvConfigOverrides(stack.registry, {
425
+ CASCADE_ENV_VALUE: "from-env",
426
+ });
427
+ expect(overrides.get(ENV_KEY)).toBe("from-env");
428
+
429
+ const envResolver = createConfigResolver({ appOverrides: overrides });
430
+ const result = await envResolver.getWithSource(
431
+ ENV_KEY,
432
+ keyDef!,
433
+ tenantAdmin.tenantId,
434
+ tenantAdmin.id,
435
+ db,
436
+ );
437
+
438
+ // No stored rows for this key → the env-bridged override wins over the
439
+ // declared default ("DEFAULT_ENV").
440
+ expect(result.source).toBe("app-override");
441
+ expect(result.value).toBe("from-env");
442
+ });
333
443
  });
334
444
 
335
445
  describe("reset cycle regression", () => {
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type ConfigKeyDefinition,
4
+ createSystemConfig,
5
+ createTenantConfig,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { buildEnvConfigOverrides } from "../resolver";
8
+
9
+ // Registry stub exposing the two methods buildEnvConfigOverrides reads:
10
+ // getAllConfigKeys (iterate declared keys) + getConfigKey (validate).
11
+ function registryStub(keys: Record<string, ConfigKeyDefinition>) {
12
+ const map: ReadonlyMap<string, ConfigKeyDefinition> = new Map(Object.entries(keys));
13
+ return {
14
+ getAllConfigKeys: () => map,
15
+ getConfigKey: (key: string) => keys[key],
16
+ };
17
+ }
18
+
19
+ describe("buildEnvConfigOverrides", () => {
20
+ test("bridges a set env var into the override map (number, coerced)", () => {
21
+ const reg = registryStub({
22
+ "billing:config:timeout": createSystemConfig("number", {
23
+ env: "BILLING_TIMEOUT",
24
+ }),
25
+ });
26
+ const result = buildEnvConfigOverrides(reg, { BILLING_TIMEOUT: "42" });
27
+ expect(result.get("billing:config:timeout")).toBe(42);
28
+ expect(result.size).toBe(1);
29
+ });
30
+
31
+ test("text value passes through verbatim", () => {
32
+ const reg = registryStub({
33
+ "app:config:url": createSystemConfig("text", { env: "SERVICE_URL" }),
34
+ });
35
+ const result = buildEnvConfigOverrides(reg, { SERVICE_URL: "https://x.test" });
36
+ expect(result.get("app:config:url")).toBe("https://x.test");
37
+ });
38
+
39
+ test("boolean coercion accepts true/false/1/0 case-insensitively", () => {
40
+ const reg = registryStub({
41
+ "a:config:flag": createSystemConfig("boolean", { env: "FLAG" }),
42
+ });
43
+ expect(buildEnvConfigOverrides(reg, { FLAG: "true" }).get("a:config:flag")).toBe(true);
44
+ expect(buildEnvConfigOverrides(reg, { FLAG: "1" }).get("a:config:flag")).toBe(true);
45
+ expect(buildEnvConfigOverrides(reg, { FLAG: "TRUE" }).get("a:config:flag")).toBe(true);
46
+ expect(buildEnvConfigOverrides(reg, { FLAG: "false" }).get("a:config:flag")).toBe(false);
47
+ expect(buildEnvConfigOverrides(reg, { FLAG: "0" }).get("a:config:flag")).toBe(false);
48
+ });
49
+
50
+ test("boolean coercion rejects a non-boolean string (fail-fast at boot)", () => {
51
+ const reg = registryStub({
52
+ "a:config:flag": createSystemConfig("boolean", { env: "FLAG" }),
53
+ });
54
+ expect(() => buildEnvConfigOverrides(reg, { FLAG: "maybe" })).toThrow(
55
+ /expects a boolean.*got "maybe"/i,
56
+ );
57
+ });
58
+
59
+ test("number coercion rejects a non-numeric string", () => {
60
+ const reg = registryStub({
61
+ "a:config:n": createSystemConfig("number", { env: "N" }),
62
+ });
63
+ expect(() => buildEnvConfigOverrides(reg, { N: "abc" })).toThrow(
64
+ /expects a number.*got "abc"/i,
65
+ );
66
+ });
67
+
68
+ test("number coercion trims whitespace", () => {
69
+ const reg = registryStub({
70
+ "a:config:n": createSystemConfig("number", { env: "N" }),
71
+ });
72
+ expect(buildEnvConfigOverrides(reg, { N: " 5 " }).get("a:config:n")).toBe(5);
73
+ });
74
+
75
+ test("undefined env var → key skipped (falls through to its cascade)", () => {
76
+ const reg = registryStub({
77
+ "a:config:x": createSystemConfig("text", { env: "MISSING" }),
78
+ });
79
+ const result = buildEnvConfigOverrides(reg, {});
80
+ expect(result.size).toBe(0);
81
+ });
82
+
83
+ test("empty-string env var → skipped (must not clobber a declared default)", () => {
84
+ const reg = registryStub({
85
+ "a:config:x": createSystemConfig("text", { env: "EMPTY" }),
86
+ });
87
+ const result = buildEnvConfigOverrides(reg, { EMPTY: "" });
88
+ expect(result.size).toBe(0);
89
+ });
90
+
91
+ test("keys without an env field are ignored even if a same-named var exists", () => {
92
+ const reg = registryStub({
93
+ "a:config:no-env": createSystemConfig("text", {}),
94
+ });
95
+ // No env declared → never bridged, regardless of the environment.
96
+ const result = buildEnvConfigOverrides(reg, {
97
+ A_CONFIG_NO_ENV: "value",
98
+ "a:config:no-env": "v",
99
+ });
100
+ expect(result.size).toBe(0);
101
+ });
102
+
103
+ test("select value must be one of the declared options", () => {
104
+ const reg = registryStub({
105
+ "a:config:theme": createSystemConfig("select", {
106
+ env: "THEME",
107
+ options: ["light", "dark"],
108
+ }),
109
+ });
110
+ expect(buildEnvConfigOverrides(reg, { THEME: "dark" }).get("a:config:theme")).toBe("dark");
111
+ expect(() => buildEnvConfigOverrides(reg, { THEME: "purple" })).toThrow(/not in options/i);
112
+ });
113
+
114
+ test("number env value outside bounds fails (validateAppOverrides gate)", () => {
115
+ const reg = registryStub({
116
+ "a:config:n": createSystemConfig("number", {
117
+ env: "N",
118
+ bounds: { min: 1, max: 100 },
119
+ }),
120
+ });
121
+ expect(() => buildEnvConfigOverrides(reg, { N: "999" })).toThrow(/above bounds\.max/i);
122
+ });
123
+
124
+ test("bridges only the env-declaring keys out of a mixed registry", () => {
125
+ const reg = registryStub({
126
+ "a:config:bridged": createSystemConfig("number", { env: "BRIDGED" }),
127
+ "a:config:plain": createTenantConfig("text", {}),
128
+ "a:config:unset": createSystemConfig("text", { env: "UNSET" }),
129
+ });
130
+ const result = buildEnvConfigOverrides(reg, { BRIDGED: "7" });
131
+ expect(result.size).toBe(1);
132
+ expect(result.get("a:config:bridged")).toBe(7);
133
+ });
134
+ });
@@ -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)
@@ -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 = "••••••";
@@ -46,10 +47,17 @@ export const cascadeQuery = defineQueryHandler({
46
47
  );
47
48
 
48
49
  const result: Record<string, ConfigCascade> = {};
49
- for (const [key, cascade] of cascades) {
50
+ for (const [key, rawCascade] of cascades) {
50
51
  const keyDef = keyDefs.get(key);
51
52
  if (!keyDef) continue;
52
53
 
54
+ // Redact the inherited platform value (system-row, app-override,
55
+ // computed, default) BEFORE masking — masking alone leaves hasValue=true
56
+ // and would still leak "it is set" to a tenant.
57
+ const cascade = shouldRedactInherited(keyDef, query.user.roles)
58
+ ? redactInheritedCascade(rawCascade)
59
+ : rawCascade;
60
+
53
61
  if (keyDef.encrypted) {
54
62
  const maskedLevels: ConfigCascadeLevel[] = cascade.levels.map((l) => ({
55
63
  ...l,