@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.
- package/package.json +2 -1
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +51 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/errors.ts +19 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +22 -12
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -10
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/password-hashing.ts +1 -0
- package/src/auth-email-password/reset-token.ts +2 -0
- package/src/auth-email-password/seeding.ts +19 -4
- package/src/auth-email-password/signup-token-store.ts +1 -0
- package/src/auth-email-password/verification-token.ts +2 -0
- package/src/billing-foundation/aggregate-id.ts +1 -0
- package/src/cap-counter/aggregate-id.ts +2 -0
- package/src/config/__tests__/app-override-visibility.integration.test.ts +143 -0
- package/src/config/__tests__/cascade.integration.test.ts +111 -1
- package/src/config/__tests__/env-overrides.test.ts +134 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
- package/src/config/__tests__/read-redaction.test.ts +112 -0
- package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
- package/src/config/constants.ts +3 -1
- package/src/config/handlers/cascade.query.ts +9 -1
- package/src/config/handlers/values.query.ts +34 -12
- package/src/config/index.ts +1 -1
- package/src/config/read-redaction.ts +54 -0
- package/src/config/resolver.ts +70 -1
- package/src/config/web/client-plugin.ts +24 -0
- package/src/config/web/i18n.ts +25 -0
- package/src/config/web/index.ts +3 -0
- package/src/custom-fields/aggregate-id.ts +1 -0
- package/src/custom-fields/wire-for-entity.ts +1 -0
- package/src/delivery/upsert-preference.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +1 -1
- package/src/file-provider-s3/feature.ts +1 -1
- package/src/mail-transport-inmemory/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +1 -1
- package/src/step-dispatcher/mail-runner.ts +1 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +1 -1
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +1 -1
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +1 -1
- package/src/subscription-stripe/constants.ts +0 -4
- package/src/subscription-stripe/runtime.ts +5 -2
- package/src/subscription-stripe/verify-webhook.ts +1 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
- package/src/tenant/handlers/list.query.ts +1 -1
- package/src/tenant/handlers/memberships.query.ts +16 -15
- package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
- package/src/tenant/handlers/update.write.ts +1 -1
- package/src/text-content/api.ts +1 -0
- package/src/tier-engine/aggregate-id.ts +1 -0
- package/src/user/handlers/me.query.ts +1 -1
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/deletion-token.ts +2 -0
- package/src/user-data-rights/feature.ts +1 -1
- package/src/user-data-rights/run-export-jobs.ts +2 -0
- 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
|
+
});
|
package/src/config/constants.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
//
|
|
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,
|
|
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,
|