@cosmicdrift/kumiko-bundled-features 0.50.0 → 0.52.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 +8 -6
- package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
- package/src/config/__tests__/config.integration.test.ts +60 -0
- package/src/config/feature.ts +5 -2
- package/src/config/handlers/cascade.query.ts +4 -1
- 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 +5 -1
- package/src/config/resolver.ts +93 -3
- package/src/config/write-helpers.ts +37 -0
- 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/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/subscription-stripe/__tests__/feature.test.ts +3 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +12 -10
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +24 -12
- package/src/subscription-stripe/constants.ts +6 -5
- package/src/subscription-stripe/feature.ts +69 -50
- package/src/subscription-stripe/runtime.ts +29 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -75,14 +75,16 @@
|
|
|
75
75
|
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
76
76
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
77
77
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
78
|
+
"./managed-pages": "./src/managed-pages/index.ts",
|
|
79
|
+
"./managed-pages/seeding": "./src/managed-pages/seeding.ts",
|
|
78
80
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
79
81
|
},
|
|
80
82
|
"dependencies": {
|
|
81
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
82
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
84
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
83
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.50.0",
|
|
84
|
+
"@cosmicdrift/kumiko-framework": "0.50.0",
|
|
85
|
+
"@cosmicdrift/kumiko-headless": "0.50.0",
|
|
86
|
+
"@cosmicdrift/kumiko-renderer": "0.50.0",
|
|
87
|
+
"@cosmicdrift/kumiko-renderer-web": "0.50.0",
|
|
86
88
|
"@mollie/api-client": "^4.5.0",
|
|
87
89
|
"@node-rs/argon2": "^2.0.2",
|
|
88
90
|
"@types/nodemailer": "^8.0.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
|
+
});
|
|
@@ -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
|
+
});
|
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
|
|
@@ -44,6 +44,7 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
44
44
|
query.user.tenantId,
|
|
45
45
|
query.user.id,
|
|
46
46
|
db,
|
|
47
|
+
ctx.secrets,
|
|
47
48
|
);
|
|
48
49
|
|
|
49
50
|
const result: Record<string, ConfigCascade> = {};
|
|
@@ -58,7 +59,9 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
58
59
|
? redactInheritedCascade(rawCascade)
|
|
59
60
|
: rawCascade;
|
|
60
61
|
|
|
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") {
|
|
62
65
|
const maskedLevels: ConfigCascadeLevel[] = cascade.levels.map((l) => ({
|
|
63
66
|
...l,
|
|
64
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");
|
|
@@ -39,6 +39,7 @@ export const valuesQuery = defineQueryHandler({
|
|
|
39
39
|
query.user.tenantId,
|
|
40
40
|
query.user.id,
|
|
41
41
|
db,
|
|
42
|
+
ctx.secrets,
|
|
42
43
|
);
|
|
43
44
|
|
|
44
45
|
const result: Record<
|
|
@@ -60,8 +61,11 @@ export const valuesQuery = defineQueryHandler({
|
|
|
60
61
|
? redactInheritedCascade(rawCascade)
|
|
61
62
|
: rawCascade;
|
|
62
63
|
|
|
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.
|
|
63
67
|
let value: string | number | boolean | undefined;
|
|
64
|
-
if (keyDef.encrypted) {
|
|
68
|
+
if (keyDef.encrypted || keyDef.backing === "secrets") {
|
|
65
69
|
value = cascade.value !== undefined ? MASKED : undefined;
|
|
66
70
|
} else {
|
|
67
71
|
value = cascade.value;
|
package/src/config/resolver.ts
CHANGED
|
@@ -5,11 +5,13 @@ import type {
|
|
|
5
5
|
ConfigCascadeLevel,
|
|
6
6
|
ConfigKeyDefinition,
|
|
7
7
|
ConfigResolver,
|
|
8
|
+
ConfigSecretsReader,
|
|
8
9
|
ConfigStoredRowWithSource,
|
|
9
10
|
ConfigValueSource,
|
|
10
11
|
ConfigValueWithSource,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
13
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
13
15
|
import { assertUnreachable, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
14
16
|
import { selectConfigRowsForKeys, selectConfigRowsForScope } from "./db/queries/resolver";
|
|
15
17
|
import { configValuesTable } from "./table";
|
|
@@ -65,6 +67,26 @@ export type ConfigResolverOptions = {
|
|
|
65
67
|
appOverrides?: AppConfigOverrides;
|
|
66
68
|
};
|
|
67
69
|
|
|
70
|
+
// backing="secrets" keys store their value in the secrets store (flat per
|
|
71
|
+
// (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
|
|
72
|
+
// (getWithSource + buildCascade) route the system rung here. Missing reader =
|
|
73
|
+
// the app never wired extraContext.secrets → fail loud, never silently miss.
|
|
74
|
+
async function readBackingSecret(
|
|
75
|
+
secretsReader: ConfigSecretsReader | undefined,
|
|
76
|
+
qualifiedKey: string,
|
|
77
|
+
): Promise<string | undefined> {
|
|
78
|
+
if (!secretsReader) {
|
|
79
|
+
throw new InternalError({
|
|
80
|
+
message:
|
|
81
|
+
`[config] backing="secrets" key "${qualifiedKey}" was read without a secrets ` +
|
|
82
|
+
`reader — wire extraContext.secrets (and a MasterKeyProvider) so the secrets ` +
|
|
83
|
+
`store is reachable at request time.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const secret = await secretsReader.get(SYSTEM_TENANT_ID, qualifiedKey);
|
|
87
|
+
return secret?.reveal();
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
// Shared cascade-builder. Single-key path passes a `findRow`-bound row
|
|
69
91
|
// fetcher (one SQL per lookup); batch path passes a closure over
|
|
70
92
|
// pre-loaded rows. The builder itself is unaware of which.
|
|
@@ -80,6 +102,7 @@ async function buildCascade(
|
|
|
80
102
|
) => Promise<ConfigRow | null> | ConfigRow | null,
|
|
81
103
|
appOverrides: AppConfigOverrides | undefined,
|
|
82
104
|
encryption: EncryptionProvider | undefined,
|
|
105
|
+
secretsReader: ConfigSecretsReader | undefined,
|
|
83
106
|
): Promise<ConfigCascade> {
|
|
84
107
|
type Lookup = {
|
|
85
108
|
tenantId: string;
|
|
@@ -125,6 +148,34 @@ async function buildCascade(
|
|
|
125
148
|
let activeIndex = -1;
|
|
126
149
|
|
|
127
150
|
for (const lookup of lookups) {
|
|
151
|
+
// backing="secrets" is system-only (boot-guard), so the single system-row
|
|
152
|
+
// rung reads from the secrets store instead of config_values. The secret
|
|
153
|
+
// value is the same JSON-serialized form a config row would hold (set.write
|
|
154
|
+
// serializes before handing it to secrets), so deserializeValue applies;
|
|
155
|
+
// no config-level decrypt — the secrets envelope already returned plaintext.
|
|
156
|
+
if (keyDef.backing === "secrets" && lookup.source === "system-row") {
|
|
157
|
+
const secret = await readBackingSecret(secretsReader, qualifiedKey);
|
|
158
|
+
if (secret !== undefined) {
|
|
159
|
+
if (activeIndex === -1) activeIndex = levels.length;
|
|
160
|
+
levels.push({
|
|
161
|
+
label: lookup.label,
|
|
162
|
+
value: deserializeValue(secret, keyDef.type),
|
|
163
|
+
source: lookup.source,
|
|
164
|
+
isActive: false,
|
|
165
|
+
hasValue: true,
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
levels.push({
|
|
169
|
+
label: lookup.label,
|
|
170
|
+
value: undefined,
|
|
171
|
+
source: lookup.source,
|
|
172
|
+
isActive: false,
|
|
173
|
+
hasValue: false,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
128
179
|
const row = await fetchRow(lookup.tenantId, lookup.userId);
|
|
129
180
|
if (row?.value !== null && row?.value !== undefined) {
|
|
130
181
|
let raw = row.value;
|
|
@@ -231,10 +282,17 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
231
282
|
}
|
|
232
283
|
|
|
233
284
|
return {
|
|
234
|
-
async get(qualifiedKey, keyDef, tenantId, userId, db) {
|
|
285
|
+
async get(qualifiedKey, keyDef, tenantId, userId, db, secretsReader) {
|
|
235
286
|
// get() is a thin wrapper around getWithSource that discards the
|
|
236
287
|
// source tag. Keeps the hot-path a single implementation.
|
|
237
|
-
const result = await this.getWithSource(
|
|
288
|
+
const result = await this.getWithSource(
|
|
289
|
+
qualifiedKey,
|
|
290
|
+
keyDef,
|
|
291
|
+
tenantId,
|
|
292
|
+
userId,
|
|
293
|
+
db,
|
|
294
|
+
secretsReader,
|
|
295
|
+
);
|
|
238
296
|
return result.value;
|
|
239
297
|
},
|
|
240
298
|
|
|
@@ -244,7 +302,29 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
244
302
|
tenantId,
|
|
245
303
|
userId,
|
|
246
304
|
db,
|
|
305
|
+
secretsReader,
|
|
247
306
|
): Promise<ConfigValueWithSource> {
|
|
307
|
+
// backing="secrets": the value lives in the secrets store at the system
|
|
308
|
+
// tenant, not config_values. Read it directly (system-only by boot-guard)
|
|
309
|
+
// and skip the config-row lookups; app-override/computed/default still
|
|
310
|
+
// form the fallback ladder when the secret is unset.
|
|
311
|
+
if (keyDef.backing === "secrets") {
|
|
312
|
+
const secret = await readBackingSecret(secretsReader, qualifiedKey);
|
|
313
|
+
if (secret !== undefined) {
|
|
314
|
+
return { value: deserializeValue(secret, keyDef.type), source: "system-row" };
|
|
315
|
+
}
|
|
316
|
+
if (appOverrides?.has(qualifiedKey)) {
|
|
317
|
+
return { value: appOverrides.get(qualifiedKey), source: "app-override" };
|
|
318
|
+
}
|
|
319
|
+
if (keyDef.computed) {
|
|
320
|
+
const value = await keyDef.computed({ tenantId, userId, db });
|
|
321
|
+
return { value, source: "computed" };
|
|
322
|
+
}
|
|
323
|
+
if (keyDef.default !== undefined) {
|
|
324
|
+
return { value: keyDef.default, source: "default" };
|
|
325
|
+
}
|
|
326
|
+
return { value: undefined, source: "missing" };
|
|
327
|
+
}
|
|
248
328
|
// Resolution cascade based on scope
|
|
249
329
|
// user: userId+tenantId → tenantId → SYSTEM_TENANT_ID → default
|
|
250
330
|
// tenant: tenantId → SYSTEM_TENANT_ID → default
|
|
@@ -366,7 +446,14 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
366
446
|
return result;
|
|
367
447
|
},
|
|
368
448
|
|
|
369
|
-
async getCascade(
|
|
449
|
+
async getCascade(
|
|
450
|
+
qualifiedKey,
|
|
451
|
+
keyDef,
|
|
452
|
+
tenantId,
|
|
453
|
+
userId,
|
|
454
|
+
db,
|
|
455
|
+
secretsReader,
|
|
456
|
+
): Promise<ConfigCascade> {
|
|
370
457
|
// Single-key path uses findRow per cascade step. The batch path
|
|
371
458
|
// bulk-loads all rows up-front; both build identical levels arrays.
|
|
372
459
|
return buildCascade(
|
|
@@ -378,6 +465,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
378
465
|
(tid, uid) => findRow(qualifiedKey, tid, uid, db),
|
|
379
466
|
appOverrides,
|
|
380
467
|
encryption,
|
|
468
|
+
secretsReader,
|
|
381
469
|
);
|
|
382
470
|
},
|
|
383
471
|
|
|
@@ -387,6 +475,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
387
475
|
tenantId,
|
|
388
476
|
userId,
|
|
389
477
|
db,
|
|
478
|
+
secretsReader,
|
|
390
479
|
): Promise<ReadonlyMap<string, ConfigCascade>> {
|
|
391
480
|
if (keys.length === 0) return new Map();
|
|
392
481
|
|
|
@@ -418,6 +507,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
418
507
|
keyRows.find((r) => r.tenantId === tid && (r.userId ?? null) === uid) ?? null,
|
|
419
508
|
appOverrides,
|
|
420
509
|
encryption,
|
|
510
|
+
secretsReader,
|
|
421
511
|
);
|
|
422
512
|
result.set(key, cascade);
|
|
423
513
|
}
|