@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.
- package/package.json +9 -6
- 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__/backing-secrets.integration.test.ts +188 -0
- package/src/config/__tests__/cascade.integration.test.ts +111 -1
- package/src/config/__tests__/config.integration.test.ts +60 -0
- 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/feature.ts +5 -2
- package/src/config/handlers/cascade.query.ts +13 -2
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/handlers/reset.write.ts +23 -2
- package/src/config/handlers/set.write.ts +36 -2
- package/src/config/handlers/values.query.ts +39 -13
- package/src/config/index.ts +1 -1
- package/src/config/read-redaction.ts +54 -0
- package/src/config/resolver.ts +163 -4
- 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/config/write-helpers.ts +37 -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/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
- package/src/jobs/feature.ts +13 -0
- package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
- package/src/legal-pages/README.md +16 -13
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
- package/src/legal-pages/feature.ts +9 -4
- package/src/legal-pages/markdown.ts +6 -56
- package/src/legal-pages/security-headers.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +1 -1
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
- package/src/managed-pages/branding.ts +142 -0
- package/src/managed-pages/css-gate.ts +24 -0
- package/src/managed-pages/feature.ts +246 -0
- package/src/managed-pages/handlers/branding.query.ts +30 -0
- package/src/managed-pages/handlers/by-slug.query.ts +35 -0
- package/src/managed-pages/handlers/set.write.ts +113 -0
- package/src/managed-pages/index.ts +30 -0
- package/src/managed-pages/screens/branding-screen.ts +85 -0
- package/src/managed-pages/screens/page-screens.ts +82 -0
- package/src/managed-pages/seeding.ts +99 -0
- package/src/managed-pages/table.ts +58 -0
- package/src/page-render/__tests__/branding.test.ts +57 -0
- package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
- package/src/page-render/__tests__/markdown.test.ts +41 -0
- package/src/page-render/branding.ts +99 -0
- package/src/page-render/css-sanitize.ts +344 -0
- package/src/page-render/index.ts +13 -0
- package/src/page-render/layout.ts +100 -0
- package/src/page-render/markdown.ts +39 -0
- package/src/page-render/security-headers.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +1 -0
- package/src/subscription-stripe/runtime.ts +1 -0
- 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
|
@@ -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)
|
package/src/config/feature.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ConfigAccessorFactory,
|
|
5
5
|
type ConfigKeyHandle,
|
|
6
6
|
type ConfigKeyType,
|
|
7
|
+
type ConfigSecretsReader,
|
|
7
8
|
type ConfigValue,
|
|
8
9
|
defineFeature,
|
|
9
10
|
type FeatureDefinition,
|
|
@@ -59,6 +60,7 @@ export function createConfigAccessor(
|
|
|
59
60
|
tenantId: TenantId,
|
|
60
61
|
userId: string,
|
|
61
62
|
db: DbConnection | TenantDb,
|
|
63
|
+
secrets?: ConfigSecretsReader,
|
|
62
64
|
): ConfigAccessor {
|
|
63
65
|
async function configAccessor(
|
|
64
66
|
qualifiedKey: string,
|
|
@@ -72,7 +74,7 @@ export function createConfigAccessor(
|
|
|
72
74
|
const qualifiedKey = typeof keyOrHandle === "string" ? keyOrHandle : keyOrHandle.name;
|
|
73
75
|
const keyDef = registry.getConfigKey(qualifiedKey);
|
|
74
76
|
if (!keyDef) return undefined;
|
|
75
|
-
return resolver.get(qualifiedKey, keyDef, tenantId, userId, db);
|
|
77
|
+
return resolver.get(qualifiedKey, keyDef, tenantId, userId, db, secrets);
|
|
76
78
|
}
|
|
77
79
|
return configAccessor;
|
|
78
80
|
}
|
|
@@ -83,7 +85,8 @@ export function createConfigAccessorFactory(
|
|
|
83
85
|
registry: Registry,
|
|
84
86
|
resolver: ConfigResolver,
|
|
85
87
|
): ConfigAccessorFactory {
|
|
86
|
-
return ({ user, db }) =>
|
|
88
|
+
return ({ user, db, secrets }) =>
|
|
89
|
+
createConfigAccessor(registry, resolver, user.tenantId, user.id, db, secrets);
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
// Single point of truth for "this handler needs the resolver". Throws a
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { requireConfigResolver } from "../feature";
|
|
8
|
+
import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
|
|
8
9
|
import { hasConfigAccess } from "../write-helpers";
|
|
9
10
|
|
|
10
11
|
const MASKED = "••••••";
|
|
@@ -43,14 +44,24 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
43
44
|
query.user.tenantId,
|
|
44
45
|
query.user.id,
|
|
45
46
|
db,
|
|
47
|
+
ctx.secrets,
|
|
46
48
|
);
|
|
47
49
|
|
|
48
50
|
const result: Record<string, ConfigCascade> = {};
|
|
49
|
-
for (const [key,
|
|
51
|
+
for (const [key, rawCascade] of cascades) {
|
|
50
52
|
const keyDef = keyDefs.get(key);
|
|
51
53
|
if (!keyDef) continue;
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
// Redact the inherited platform value (system-row, app-override,
|
|
56
|
+
// computed, default) BEFORE masking — masking alone leaves hasValue=true
|
|
57
|
+
// and would still leak "it is set" to a tenant.
|
|
58
|
+
const cascade = shouldRedactInherited(keyDef, query.user.roles)
|
|
59
|
+
? redactInheritedCascade(rawCascade)
|
|
60
|
+
: rawCascade;
|
|
61
|
+
|
|
62
|
+
// backing="secrets" is masked like encrypted — the resolver revealed the
|
|
63
|
+
// plaintext for internal reads, but it must never reach the cascade UI.
|
|
64
|
+
if (keyDef.encrypted || keyDef.backing === "secrets") {
|
|
54
65
|
const maskedLevels: ConfigCascadeLevel[] = cascade.levels.map((l) => ({
|
|
55
66
|
...l,
|
|
56
67
|
value: l.hasValue ? MASKED : l.value,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ConfigScopes,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
8
|
import { z } from "zod";
|
|
4
9
|
import { configValueEntity, configValuesTable } from "../table";
|
|
5
10
|
import { findConfigRow, prepareConfigWrite } from "../write-helpers";
|
|
@@ -28,7 +33,23 @@ export const resetWrite = defineWriteHandler({
|
|
|
28
33
|
scope: event.payload.scope,
|
|
29
34
|
});
|
|
30
35
|
if (!prep.ok) return prep.failure;
|
|
31
|
-
const { scope, tenantId, userId } = prep;
|
|
36
|
+
const { keyDef, scope, tenantId, userId } = prep;
|
|
37
|
+
|
|
38
|
+
// backing="secrets": clear the secret from the secrets store. delete() is
|
|
39
|
+
// idempotent (returns false if absent) — mirrors the config no-op contract.
|
|
40
|
+
if (keyDef.backing === "secrets") {
|
|
41
|
+
if (!ctx.secrets) {
|
|
42
|
+
throw new InternalError({
|
|
43
|
+
message:
|
|
44
|
+
`[config:write:reset] key "${event.payload.key}" declares backing="secrets" but ` +
|
|
45
|
+
`ctx.secrets is not wired — provide extraContext.secrets (and a MasterKeyProvider).`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
await ctx.secrets.delete(SYSTEM_TENANT_ID, event.payload.key, {
|
|
49
|
+
deletedBy: event.user.id,
|
|
50
|
+
});
|
|
51
|
+
return { isSuccess: true, data: { key: event.payload.key, scope } };
|
|
52
|
+
}
|
|
32
53
|
|
|
33
54
|
const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
|
|
34
55
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
ConfigScopes,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
8
|
import { z } from "zod";
|
|
5
9
|
import { requireConfigEncryption } from "../feature";
|
|
6
10
|
import { configValueEntity, configValuesTable } from "../table";
|
|
@@ -8,6 +12,7 @@ import {
|
|
|
8
12
|
findConfigRow,
|
|
9
13
|
prepareConfigWrite,
|
|
10
14
|
validateBounds,
|
|
15
|
+
validatePattern,
|
|
11
16
|
validateScope,
|
|
12
17
|
validateType,
|
|
13
18
|
} from "../write-helpers";
|
|
@@ -52,6 +57,35 @@ export const setWrite = defineWriteHandler({
|
|
|
52
57
|
const boundsError = validateBounds(event.payload.value, keyDef);
|
|
53
58
|
if (boundsError) return writeFailure(boundsError);
|
|
54
59
|
|
|
60
|
+
const patternError = validatePattern(event.payload.value, keyDef);
|
|
61
|
+
if (patternError) return writeFailure(patternError);
|
|
62
|
+
|
|
63
|
+
// backing="secrets": persist into the secrets store (system tenant, own
|
|
64
|
+
// envelope encryption + audit) instead of config_values. Same JSON
|
|
65
|
+
// serialization as a config row so the read path round-trips via
|
|
66
|
+
// deserializeValue. system-scope is guaranteed by the boot-guard.
|
|
67
|
+
if (keyDef.backing === "secrets") {
|
|
68
|
+
if (!ctx.secrets) {
|
|
69
|
+
throw new InternalError({
|
|
70
|
+
message:
|
|
71
|
+
`[config:write:set] key "${event.payload.key}" declares backing="secrets" but ` +
|
|
72
|
+
`ctx.secrets is not wired — provide extraContext.secrets (and a MasterKeyProvider).`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
await ctx.secrets.set(
|
|
76
|
+
SYSTEM_TENANT_ID,
|
|
77
|
+
event.payload.key,
|
|
78
|
+
JSON.stringify(event.payload.value),
|
|
79
|
+
{
|
|
80
|
+
updatedBy: event.user.id,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
isSuccess: true,
|
|
85
|
+
data: { key: event.payload.key, value: event.payload.value, scope },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
55
89
|
let serialized = JSON.stringify(event.payload.value);
|
|
56
90
|
if (keyDef.encrypted) {
|
|
57
91
|
const encryption = requireConfigEncryption(ctx, "config:write:set");
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ConfigKeyDefinition,
|
|
2
3
|
type ConfigScope,
|
|
3
4
|
type ConfigValueSource,
|
|
4
5
|
defineQueryHandler,
|
|
5
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { requireConfigResolver } from "../feature";
|
|
8
|
-
import {
|
|
9
|
+
import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
|
|
9
10
|
import { hasConfigAccess } from "../write-helpers";
|
|
10
11
|
|
|
12
|
+
const MASKED = "••••••";
|
|
13
|
+
|
|
11
14
|
export const valuesQuery = defineQueryHandler({
|
|
12
15
|
name: "values",
|
|
13
16
|
schema: z.object({}),
|
|
@@ -19,7 +22,25 @@ export const valuesQuery = defineQueryHandler({
|
|
|
19
22
|
const resolver = requireConfigResolver(ctx, "config:query:values");
|
|
20
23
|
|
|
21
24
|
const allKeys = registry.getAllConfigKeys();
|
|
22
|
-
const
|
|
25
|
+
const keyDefs = new Map<string, ConfigKeyDefinition>();
|
|
26
|
+
const filteredKeys: string[] = [];
|
|
27
|
+
for (const [qualifiedKey, keyDef] of allKeys) {
|
|
28
|
+
if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
|
|
29
|
+
keyDefs.set(qualifiedKey, keyDef);
|
|
30
|
+
filteredKeys.push(qualifiedKey);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Resolve through the full cascade (rows → app-override → computed →
|
|
34
|
+
// default) — the same path as config:query:cascade — so the mask shows the
|
|
35
|
+
// inherited default (e.g. an ENV-bridged app-override), not only DB rows.
|
|
36
|
+
const cascades = await resolver.getCascadeBatch(
|
|
37
|
+
filteredKeys,
|
|
38
|
+
keyDefs,
|
|
39
|
+
query.user.tenantId,
|
|
40
|
+
query.user.id,
|
|
41
|
+
db,
|
|
42
|
+
ctx.secrets,
|
|
43
|
+
);
|
|
23
44
|
|
|
24
45
|
const result: Record<
|
|
25
46
|
string,
|
|
@@ -30,22 +51,27 @@ export const valuesQuery = defineQueryHandler({
|
|
|
30
51
|
}
|
|
31
52
|
> = {};
|
|
32
53
|
|
|
33
|
-
for (const [qualifiedKey, keyDef] of
|
|
34
|
-
|
|
54
|
+
for (const [qualifiedKey, keyDef] of keyDefs) {
|
|
55
|
+
const rawCascade = cascades.get(qualifiedKey);
|
|
56
|
+
if (!rawCascade) continue;
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
58
|
+
// Redact inherited platform rungs BEFORE masking — masking alone leaves
|
|
59
|
+
// a value present and would leak "it is set" to a tenant-side viewer.
|
|
60
|
+
const cascade = shouldRedactInherited(keyDef, query.user.roles)
|
|
61
|
+
? redactInheritedCascade(rawCascade)
|
|
62
|
+
: rawCascade;
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
// backing="secrets" carries a credential — mask it like an encrypted
|
|
65
|
+
// key so the plaintext (which the resolver revealed for internal reads)
|
|
66
|
+
// never reaches the UI response.
|
|
67
|
+
let value: string | number | boolean | undefined;
|
|
68
|
+
if (keyDef.encrypted || keyDef.backing === "secrets") {
|
|
69
|
+
value = cascade.value !== undefined ? MASKED : undefined;
|
|
44
70
|
} else {
|
|
45
|
-
value =
|
|
71
|
+
value = cascade.value;
|
|
46
72
|
}
|
|
47
73
|
|
|
48
|
-
result[qualifiedKey] = { value, scope: keyDef.scope, source };
|
|
74
|
+
result[qualifiedKey] = { value, scope: keyDef.scope, source: cascade.source };
|
|
49
75
|
}
|
|
50
76
|
|
|
51
77
|
return result;
|
package/src/config/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export {
|
|
|
21
21
|
collectMissingRequiredConfig,
|
|
22
22
|
} from "./handlers/readiness.query";
|
|
23
23
|
export type { AppConfigOverrides, ConfigResolver } from "./resolver";
|
|
24
|
-
export { createConfigResolver, validateAppOverrides } from "./resolver";
|
|
24
|
+
export { buildEnvConfigOverrides, createConfigResolver, validateAppOverrides } from "./resolver";
|
|
25
25
|
export { configValuesTable } from "./table";
|
|
26
26
|
|
|
27
27
|
// Boot helper for runDevApp / runProdApp: pulls every ConfigSeedDef from
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigCascade,
|
|
3
|
+
ConfigCascadeLevel,
|
|
4
|
+
ConfigKeyDefinition,
|
|
5
|
+
ConfigValueSource,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
const SYSTEM_ADMIN_ROLE = "SystemAdmin";
|
|
9
|
+
|
|
10
|
+
// The viewer's own cascade rungs. Every other rung (system-row, app-override,
|
|
11
|
+
// computed, default) carries a platform-inherited value — for an
|
|
12
|
+
// inheritedToTenant:false key those must stay hidden from a tenant-side viewer.
|
|
13
|
+
const OWN_SOURCES: ReadonlySet<ConfigValueSource> = new Set(["user-row", "tenant-row"]);
|
|
14
|
+
|
|
15
|
+
// A SystemAdmin owns the platform-level values and may always see them. Every
|
|
16
|
+
// other viewer (TenantAdmin, User) is tenant-side — for an
|
|
17
|
+
// inheritedToTenant:false key they must learn neither the inherited platform
|
|
18
|
+
// value nor that it is set.
|
|
19
|
+
export function mayViewInheritedValue(roles: readonly string[]): boolean {
|
|
20
|
+
return roles.includes(SYSTEM_ADMIN_ROLE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function shouldRedactInherited(
|
|
24
|
+
keyDef: Pick<ConfigKeyDefinition, "inheritedToTenant">,
|
|
25
|
+
roles: readonly string[],
|
|
26
|
+
): boolean {
|
|
27
|
+
return keyDef.inheritedToTenant === false && !mayViewInheritedValue(roles);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Strips every platform-inherited level (system-row, app-override, computed,
|
|
31
|
+
// default — anything that is not the viewer's own user-/tenant-row) so a
|
|
32
|
+
// tenant-side viewer sees an inheritedToTenant:false key as if no platform
|
|
33
|
+
// value existed, then recomputes the winning level among the survivors. A
|
|
34
|
+
// no-op when the cascade carries no inherited value.
|
|
35
|
+
export function redactInheritedCascade(cascade: ConfigCascade): ConfigCascade {
|
|
36
|
+
let redacted = false;
|
|
37
|
+
const levels: ConfigCascadeLevel[] = cascade.levels.map((level) => {
|
|
38
|
+
if (!OWN_SOURCES.has(level.source) && level.hasValue) {
|
|
39
|
+
redacted = true;
|
|
40
|
+
return { ...level, value: undefined, hasValue: false, isActive: false };
|
|
41
|
+
}
|
|
42
|
+
return { ...level, isActive: false };
|
|
43
|
+
});
|
|
44
|
+
if (!redacted) return cascade;
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < levels.length; i++) {
|
|
47
|
+
const level = levels[i];
|
|
48
|
+
if (level?.hasValue) {
|
|
49
|
+
levels[i] = { ...level, isActive: true };
|
|
50
|
+
return { value: level.value, source: level.source, levels };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { value: undefined, source: "missing", levels };
|
|
54
|
+
}
|