@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,188 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import {
5
+ access,
6
+ type ConfigCascade,
7
+ createSystemConfig,
8
+ defineFeature,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
12
+ import {
13
+ setupTestStack,
14
+ type TestStack,
15
+ TestUsers,
16
+ unsafePushTables,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import { z } from "zod";
19
+ import { createSecretsContext } from "../../secrets/secrets-context";
20
+ import { tenantSecretsTable } from "../../secrets/table";
21
+ import { ConfigHandlers, ConfigQueries } from "../constants";
22
+ import { createConfigAccessorFactory, createConfigFeature } from "../feature";
23
+ import { createConfigResolver } from "../resolver";
24
+ import { configValuesTable } from "../table";
25
+
26
+ // Proves the generic backing="secrets" dispatch end-to-end over real HTTP:
27
+ // a system-scoped config key with backing:"secrets" stores/reads/clears through
28
+ // the secrets store (envelope-encrypted, system tenant) instead of the
29
+ // config_values projection — while the value is masked in the query handlers
30
+ // yet revealed for the owning feature's internal ctx.config read.
31
+
32
+ const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000000";
33
+ const API_KEY = "billing:config:api-key";
34
+ const PLAIN_KEY = "billing:config:webhook-path";
35
+
36
+ const systemAdmin = TestUsers.systemAdmin; // roles ["SystemAdmin"]
37
+
38
+ const billingFeature = defineFeature("billing", (r) => {
39
+ r.requires("config");
40
+ r.config({
41
+ keys: {
42
+ // backing:"secrets" — value lives in the secrets store, not config_values.
43
+ apiKey: createSystemConfig("text", {
44
+ backing: "secrets",
45
+ write: access.systemAdmin,
46
+ read: access.admin,
47
+ }),
48
+ // Control: a plain system config key (config_values, no secrets dispatch).
49
+ webhookPath: createSystemConfig("text", {
50
+ default: "/hooks",
51
+ write: access.systemAdmin,
52
+ read: access.admin,
53
+ }),
54
+ },
55
+ });
56
+ // Internal-read probe: a handler that reads its own secrets-backed key via
57
+ // ctx.config — must receive the revealed plaintext, not the mask.
58
+ r.queryHandler(
59
+ "peek-api-key",
60
+ z.object({}),
61
+ async (_query, ctx) => {
62
+ if (!ctx.config) throw new Error("ctx.config not wired");
63
+ return { value: await ctx.config(API_KEY) };
64
+ },
65
+ { access: { roles: ["SystemAdmin"] } },
66
+ );
67
+ });
68
+
69
+ let stack: TestStack;
70
+
71
+ beforeAll(async () => {
72
+ const masterKeyProvider = createEnvMasterKeyProvider({
73
+ env: {
74
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
75
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
76
+ },
77
+ });
78
+
79
+ stack = await setupTestStack({
80
+ features: [createConfigFeature(), billingFeature],
81
+ extraContext: ({ db, registry }) => {
82
+ const resolver = createConfigResolver();
83
+ return {
84
+ configResolver: resolver,
85
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
86
+ secrets: createSecretsContext({ db, masterKeyProvider }),
87
+ };
88
+ },
89
+ });
90
+ await unsafePushTables(stack.db, { configValuesTable, tenantSecretsTable });
91
+ await createEventsTable(stack.db);
92
+ });
93
+
94
+ afterAll(async () => {
95
+ await stack.cleanup();
96
+ });
97
+
98
+ type Cascades = Record<string, ConfigCascade>;
99
+
100
+ describe("config backing=secrets — write dispatch", () => {
101
+ test("set routes the value into the secrets store, not config_values", async () => {
102
+ await stack.http.writeOk(
103
+ ConfigHandlers.set,
104
+ { key: API_KEY, value: "sk-live-abc123", scope: "system" },
105
+ systemAdmin,
106
+ );
107
+
108
+ const secretRows = await selectMany(stack.db, tenantSecretsTable, {
109
+ tenantId: SYSTEM_TENANT,
110
+ key: API_KEY,
111
+ });
112
+ expect(secretRows).toHaveLength(1);
113
+
114
+ const configRows = await selectMany(stack.db, configValuesTable, { key: API_KEY });
115
+ expect(configRows).toHaveLength(0);
116
+ });
117
+
118
+ test("the stored secret is an envelope, never the plaintext", async () => {
119
+ const [row] = await selectMany(stack.db, tenantSecretsTable, {
120
+ tenantId: SYSTEM_TENANT,
121
+ key: API_KEY,
122
+ });
123
+ // No column of the stored row may carry the plaintext — the secrets
124
+ // envelope must have encrypted it.
125
+ expect(JSON.stringify(row)).not.toContain("sk-live-abc123");
126
+ });
127
+ });
128
+
129
+ describe("config backing=secrets — read dispatch", () => {
130
+ test("the owning feature reads the revealed plaintext via ctx.config", async () => {
131
+ const res = await stack.http.queryOk<{ value: unknown }>(
132
+ "billing:query:peek-api-key",
133
+ {},
134
+ systemAdmin,
135
+ );
136
+ // JSON round-trip: set serialized "sk-live-abc123" → resolver reveals +
137
+ // deserializes back to the original string.
138
+ expect(res.value).toBe("sk-live-abc123");
139
+ });
140
+
141
+ test("config:query:cascade masks the value AND every level", async () => {
142
+ const res = await stack.http.queryOk<Cascades>(
143
+ ConfigQueries.cascade,
144
+ { keys: [API_KEY] },
145
+ systemAdmin,
146
+ );
147
+ const cascade = res[API_KEY];
148
+ expect(cascade?.value).toBe("••••••");
149
+ expect(cascade?.source).toBe("system-row");
150
+ const systemLevel = cascade?.levels.find((l) => l.source === "system-row");
151
+ expect(systemLevel?.hasValue).toBe(true);
152
+ expect(systemLevel?.value).toBe("••••••");
153
+ expect(JSON.stringify(cascade)).not.toContain("sk-live-abc123");
154
+ });
155
+
156
+ test("config:query:values masks the value", async () => {
157
+ const res = await stack.http.queryOk<Record<string, { value: unknown; source: string }>>(
158
+ ConfigQueries.values,
159
+ {},
160
+ systemAdmin,
161
+ );
162
+ expect(res[API_KEY]?.value).toBe("••••••");
163
+ expect(res[API_KEY]?.source).toBe("system-row");
164
+ // The plain control key still resolves transparently.
165
+ expect(res[PLAIN_KEY]?.value).toBe("/hooks");
166
+ expect(res[PLAIN_KEY]?.source).toBe("default");
167
+ });
168
+ });
169
+
170
+ describe("config backing=secrets — reset dispatch", () => {
171
+ test("reset clears the secret; the key falls back to unset", async () => {
172
+ await stack.http.writeOk(ConfigHandlers.reset, { key: API_KEY, scope: "system" }, systemAdmin);
173
+
174
+ const secretRows = await selectMany(stack.db, tenantSecretsTable, {
175
+ tenantId: SYSTEM_TENANT,
176
+ key: API_KEY,
177
+ });
178
+ expect(secretRows).toHaveLength(0);
179
+
180
+ const res = await stack.http.queryOk<{ value: unknown }>(
181
+ "billing:query:peek-api-key",
182
+ {},
183
+ systemAdmin,
184
+ );
185
+ // No keyDef.default on apiKey → genuinely unset after the secret is gone.
186
+ expect(res.value).toBeUndefined();
187
+ });
188
+ });
@@ -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", () => {
@@ -181,6 +181,22 @@ const integrationFeature = defineFeature("integration", (r) => {
181
181
 
182
182
  const configFeature = createConfigFeature();
183
183
 
184
+ // Pattern-validated text key (managed-cms phase 3 core change): set.write runs
185
+ // keyDef.pattern as a hard-reject gate, same posture as bounds. The regex
186
+ // allows empty (clear) | a CSS hex color.
187
+ const patternFeature = defineFeature("patterned", (r) => {
188
+ r.requires("config");
189
+ r.config({
190
+ keys: {
191
+ hexColor: createTenantConfig("text", {
192
+ default: "",
193
+ write: access.roles("Admin"),
194
+ pattern: { regex: "^$|^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" },
195
+ }),
196
+ },
197
+ });
198
+ });
199
+
184
200
  // Scenario 11: Config seeding — feature with deploy-time defaults
185
201
  const seedFeature = defineFeature("seeddemo", (r) => {
186
202
  r.requires("config");
@@ -244,6 +260,7 @@ beforeAll(async () => {
244
260
  probeFeature,
245
261
  seedFeature,
246
262
  transportFeature,
263
+ patternFeature,
247
264
  ],
248
265
  // Wire `ctx.config()` for real handlers: pass the resolver-bound factory
249
266
  // so the dispatcher can mint a per-user accessor inside buildHandlerContext.
@@ -1460,3 +1477,46 @@ describe("scenario 11: config seeding", () => {
1460
1477
  expect(await configFn(SEED_THEME)).toBe("red");
1461
1478
  });
1462
1479
  });
1480
+
1481
+ // --- Pattern validation (text keys) ---
1482
+
1483
+ describe("pattern validation", () => {
1484
+ const HEX_KEY = "patterned:config:hex-color";
1485
+ const admin = createTestUser({ id: 99, roles: ["Admin"] });
1486
+
1487
+ test("valid value passes the regex", async () => {
1488
+ const res = await stack.http.writeOk<{ value: string }>(
1489
+ "config:write:set",
1490
+ { key: HEX_KEY, value: "#abc123" },
1491
+ admin,
1492
+ );
1493
+ expect(res).toMatchObject({ value: "#abc123" });
1494
+ });
1495
+
1496
+ test("empty value passes (allow-empty branch)", async () => {
1497
+ const res = await stack.http.writeOk<{ value: string }>(
1498
+ "config:write:set",
1499
+ { key: HEX_KEY, value: "" },
1500
+ admin,
1501
+ );
1502
+ expect(res).toMatchObject({ value: "" });
1503
+ });
1504
+
1505
+ test("non-matching value is hard-rejected with invalid_format", async () => {
1506
+ const error = await stack.http.writeErr(
1507
+ "config:write:set",
1508
+ { key: HEX_KEY, value: "tomato" },
1509
+ admin,
1510
+ );
1511
+ expectErrorIncludes(error, "invalid_format");
1512
+ });
1513
+
1514
+ test("style-breakout attempt is rejected (no CSS injection survives)", async () => {
1515
+ const error = await stack.http.writeErr(
1516
+ "config:write:set",
1517
+ { key: HEX_KEY, value: "#fff;}</style><script>" },
1518
+ admin,
1519
+ );
1520
+ expectErrorIncludes(error, "invalid_format");
1521
+ });
1522
+ });
@@ -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
+ });