@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.
Files changed (46) hide show
  1. package/package.json +8 -6
  2. package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
  3. package/src/config/__tests__/config.integration.test.ts +60 -0
  4. package/src/config/feature.ts +5 -2
  5. package/src/config/handlers/cascade.query.ts +4 -1
  6. package/src/config/handlers/readiness.query.ts +1 -0
  7. package/src/config/handlers/reset.write.ts +23 -2
  8. package/src/config/handlers/set.write.ts +36 -2
  9. package/src/config/handlers/values.query.ts +5 -1
  10. package/src/config/resolver.ts +93 -3
  11. package/src/config/write-helpers.ts +37 -0
  12. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
  13. package/src/jobs/feature.ts +13 -0
  14. package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
  15. package/src/legal-pages/README.md +16 -13
  16. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
  17. package/src/legal-pages/feature.ts +9 -4
  18. package/src/legal-pages/markdown.ts +6 -56
  19. package/src/legal-pages/security-headers.ts +1 -0
  20. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
  21. package/src/managed-pages/branding.ts +142 -0
  22. package/src/managed-pages/css-gate.ts +24 -0
  23. package/src/managed-pages/feature.ts +246 -0
  24. package/src/managed-pages/handlers/branding.query.ts +30 -0
  25. package/src/managed-pages/handlers/by-slug.query.ts +35 -0
  26. package/src/managed-pages/handlers/set.write.ts +113 -0
  27. package/src/managed-pages/index.ts +30 -0
  28. package/src/managed-pages/screens/branding-screen.ts +85 -0
  29. package/src/managed-pages/screens/page-screens.ts +82 -0
  30. package/src/managed-pages/seeding.ts +99 -0
  31. package/src/managed-pages/table.ts +58 -0
  32. package/src/page-render/__tests__/branding.test.ts +57 -0
  33. package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
  34. package/src/page-render/__tests__/markdown.test.ts +41 -0
  35. package/src/page-render/branding.ts +99 -0
  36. package/src/page-render/css-sanitize.ts +344 -0
  37. package/src/page-render/index.ts +13 -0
  38. package/src/page-render/layout.ts +100 -0
  39. package/src/page-render/markdown.ts +39 -0
  40. package/src/page-render/security-headers.ts +16 -0
  41. package/src/subscription-stripe/__tests__/feature.test.ts +3 -2
  42. package/src/subscription-stripe/__tests__/runtime.test.ts +12 -10
  43. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +24 -12
  44. package/src/subscription-stripe/constants.ts +6 -5
  45. package/src/subscription-stripe/feature.ts +69 -50
  46. 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.50.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.45.0",
82
- "@cosmicdrift/kumiko-framework": "0.45.0",
83
- "@cosmicdrift/kumiko-headless": "0.45.0",
84
- "@cosmicdrift/kumiko-renderer": "0.45.0",
85
- "@cosmicdrift/kumiko-renderer-web": "0.45.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
+ });
@@ -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 }) => createConfigAccessor(registry, resolver, user.tenantId, user.id, 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
- if (keyDef.encrypted) {
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,
@@ -98,6 +98,7 @@ export async function collectMissingRequiredConfig(
98
98
  user.tenantId,
99
99
  user.id,
100
100
  ctx.db,
101
+ ctx.secrets,
101
102
  );
102
103
  for (const [qualifiedKey, keyDef] of candidates) {
103
104
  const value = cascades.get(qualifiedKey)?.value;
@@ -1,5 +1,10 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
- import { ConfigScopes, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
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 { ConfigScopes, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
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;
@@ -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(qualifiedKey, keyDef, tenantId, userId, db);
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(qualifiedKey, keyDef, tenantId, userId, db): Promise<ConfigCascade> {
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
  }