@cosmicdrift/kumiko-bundled-features 0.291.0 → 0.292.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.291.0",
3
+ "version": "0.292.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>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.291.0",
137
- "@cosmicdrift/kumiko-framework": "0.291.0",
138
- "@cosmicdrift/kumiko-headless": "0.291.0",
139
- "@cosmicdrift/kumiko-renderer": "0.291.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.291.0",
141
- "@cosmicdrift/kumiko-types": "0.291.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.292.0",
137
+ "@cosmicdrift/kumiko-framework": "0.292.0",
138
+ "@cosmicdrift/kumiko-headless": "0.292.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.292.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.292.0",
141
+ "@cosmicdrift/kumiko-types": "0.292.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.291.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.291.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.292.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.292.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -1,19 +1,37 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
+ import {
5
+ isFieldsEditSection,
6
+ normalizeEditField,
7
+ normalizeListColumn,
8
+ sectionFieldSpecs,
9
+ } from "@cosmicdrift/kumiko-framework/ui-types";
4
10
  import { createConfigFeature } from "../../config/feature";
5
11
  import { createTenantFeature } from "../../tenant/feature";
12
+ import { createUserFeature } from "../../user/feature";
6
13
  import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
7
14
  import { createAuditFeature } from "../feature";
8
15
  import { AUDIT_I18N } from "../i18n";
9
16
 
10
17
  describe("audit log screen + handler access alignment", () => {
11
- const features = [createConfigFeature(), createTenantFeature(), createAuditFeature()];
18
+ const features = [
19
+ createConfigFeature(),
20
+ createTenantFeature(),
21
+ createUserFeature(),
22
+ createAuditFeature(),
23
+ ];
12
24
 
13
25
  test("boot-validates with audit-log screen registered", () => {
14
26
  expect(() => validateBoot(features)).not.toThrow();
15
27
  });
16
28
 
29
+ test("the user:user actor reference makes `user` a hard boot dependency (fw#3103)", () => {
30
+ expect(() =>
31
+ validateBoot([createConfigFeature(), createTenantFeature(), createAuditFeature()]),
32
+ ).toThrow(/requires feature "user"/);
33
+ });
34
+
17
35
  test("audit-log screen is declarative, access.admin-gated", () => {
18
36
  const audit = createAuditFeature();
19
37
  const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
@@ -63,6 +81,27 @@ describe("audit log screen + handler access alignment", () => {
63
81
  expect(AUDIT_I18N[aggregateTypeKey]?.en).not.toBe(AUDIT_I18N[aggregateIdKey]?.en);
64
82
  });
65
83
 
84
+ test("actor column and detail field declare the user:user reference (fw#3103)", () => {
85
+ const audit = createAuditFeature();
86
+ expect(audit.requires).toContain("user");
87
+
88
+ const list = audit.screens[AUDIT_LOG_SCREEN_ID];
89
+ if (list?.type !== "projectionList") throw new Error("expected a projectionList screen");
90
+ const actorColumn = list.columns.map(normalizeListColumn).find((c) => c.field === "createdBy");
91
+ expect(actorColumn?.refEntity).toBe("user:user");
92
+ expect(actorColumn?.refLabelField).toBe("displayName");
93
+
94
+ const detail = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
95
+ if (detail?.type !== "projectionDetail") throw new Error("expected a projectionDetail screen");
96
+ const actorField = detail.layout.sections
97
+ .filter(isFieldsEditSection)
98
+ .flatMap((section) => sectionFieldSpecs(section))
99
+ .map(normalizeEditField)
100
+ .find((f) => f.field === "createdBy");
101
+ expect(actorField?.refEntity).toBe("user:user");
102
+ expect(actorField?.refLabelField).toBe("displayName");
103
+ });
104
+
66
105
  test("audit queries use access.admin (screen ⊆ handler)", () => {
67
106
  const audit = createAuditFeature();
68
107
  expect(rolesOf(audit.queryHandlers["list"]?.access)).toEqual([...access.admin]);
@@ -21,9 +21,11 @@ import {
21
21
  unsafeCreateEntityTable,
22
22
  } from "@cosmicdrift/kumiko-framework/stack";
23
23
  import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
24
+ import { buildDateRangePayload, resolveDateRangeFacets } from "@cosmicdrift/kumiko-renderer";
24
25
  import { createConfigFeature } from "../../config";
25
26
  import { createTenantFeature } from "../../tenant";
26
- import { AuditQueries } from "../constants";
27
+ import { createUserFeature } from "../../user";
28
+ import { AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
27
29
  import { createAuditFeature } from "../feature";
28
30
 
29
31
  const widgetEntity = createEntity({
@@ -61,7 +63,13 @@ const otherTenantAdmin: SessionUser = createTestUser({
61
63
 
62
64
  beforeAll(async () => {
63
65
  stack = await setupTestStack({
64
- features: [widgetFeature, createConfigFeature(), createTenantFeature(), createAuditFeature()],
66
+ features: [
67
+ widgetFeature,
68
+ createConfigFeature(),
69
+ createTenantFeature(),
70
+ createUserFeature(),
71
+ createAuditFeature(),
72
+ ],
65
73
  });
66
74
  await unsafeCreateEntityTable(stack.db, widgetEntity);
67
75
  });
@@ -251,6 +259,92 @@ describe("audit: list query", () => {
251
259
  expect((untilRow.payload as { name?: string }).name).toBe("before-window");
252
260
  });
253
261
 
262
+ // fw#3104: the audit screen's dateRange facet is the only UI that produces
263
+ // these bounds. The risk is not the handler (covered above) but the
264
+ // conversion of a calendar date into an instant: pick "the 14th" as the
265
+ // upper bound and an event at 23:59 local that day has to stay in the
266
+ // result. Bounds are derived from the live screen declaration, so a change
267
+ // to either end of the chain fails here.
268
+ test("dateRange facet bounds include an event at 23:59 of the `to` day", async () => {
269
+ const screen = createAuditFeature().screens[AUDIT_LOG_SCREEN_ID];
270
+ if (screen?.type !== "projectionList") throw new Error("expected a projectionList screen");
271
+ const specs = resolveDateRangeFacets(screen.facets, (key) => key);
272
+ expect(specs).toHaveLength(1);
273
+
274
+ await createWidget(admin, "late-on-the-14th");
275
+ await createWidget(admin, "just-after-midnight");
276
+
277
+ const listed = await stack.http.queryOk<AuditResponse>(AuditQueries.list, {}, admin);
278
+ const byName = new Map(
279
+ listed.rows
280
+ .filter((r) => r.type === "widget.created")
281
+ .map((r) => [(r.payload as { name?: string }).name ?? "", r.id]),
282
+ );
283
+ // 23:59 and 00:00 Europe/Vienna, expressed as the UTC instants the
284
+ // event store stores.
285
+ const stamps: ReadonlyArray<readonly [string, string]> = [
286
+ ["late-on-the-14th", "2020-06-14T21:59:00Z"],
287
+ ["just-after-midnight", "2020-06-14T22:00:00Z"],
288
+ ];
289
+ for (const [name, createdAt] of stamps) {
290
+ const id = byName.get(name);
291
+ if (!id) throw new Error(`missing event for ${name}`);
292
+ await asRawClient(stack.db).unsafe(
293
+ `UPDATE kumiko_events SET created_at = $1::timestamptz WHERE id = $2::bigint`,
294
+ [createdAt, id],
295
+ );
296
+ }
297
+
298
+ const bounds = buildDateRangePayload(
299
+ specs,
300
+ { "createdAt.from": ["2020-06-14"], "createdAt.to": ["2020-06-14"] },
301
+ "Europe/Vienna",
302
+ );
303
+ const inRange = await stack.http.queryOk<AuditResponse>(AuditQueries.list, bounds, admin);
304
+ const names = inRange.rows.map((r) => (r.payload as { name?: string }).name);
305
+ expect(names).toContain("late-on-the-14th");
306
+ expect(names).not.toContain("just-after-midnight");
307
+ });
308
+
309
+ // Open interval from the same declaration — only one bound set must still
310
+ // reach the handler as a single param.
311
+ test("dateRange facet with only the `from` bound sends an open interval", async () => {
312
+ const screen = createAuditFeature().screens[AUDIT_LOG_SCREEN_ID];
313
+ if (screen?.type !== "projectionList") throw new Error("expected a projectionList screen");
314
+ const specs = resolveDateRangeFacets(screen.facets, (key) => key);
315
+
316
+ await createWidget(admin, "old");
317
+ await createWidget(admin, "recent");
318
+ const listed = await stack.http.queryOk<AuditResponse>(AuditQueries.list, {}, admin);
319
+ const byName = new Map(
320
+ listed.rows
321
+ .filter((r) => r.type === "widget.created")
322
+ .map((r) => [(r.payload as { name?: string }).name ?? "", r.id]),
323
+ );
324
+ for (const [name, createdAt] of [
325
+ ["old", "2020-06-01T12:00:00Z"],
326
+ ["recent", "2020-06-20T12:00:00Z"],
327
+ ] as const) {
328
+ const id = byName.get(name);
329
+ if (!id) throw new Error(`missing event for ${name}`);
330
+ await asRawClient(stack.db).unsafe(
331
+ `UPDATE kumiko_events SET created_at = $1::timestamptz WHERE id = $2::bigint`,
332
+ [createdAt, id],
333
+ );
334
+ }
335
+
336
+ const bounds = buildDateRangePayload(
337
+ specs,
338
+ { "createdAt.from": ["2020-06-10"] },
339
+ "Europe/Vienna",
340
+ );
341
+ expect(Object.keys(bounds)).toEqual(["from"]);
342
+ const sinceFrom = await stack.http.queryOk<AuditResponse>(AuditQueries.list, bounds, admin);
343
+ const names = sinceFrom.rows.map((r) => (r.payload as { name?: string }).name);
344
+ expect(names).toContain("recent");
345
+ expect(names).not.toContain("old");
346
+ });
347
+
254
348
  test("rejects inverted from/to range with validation_error", async () => {
255
349
  // from > to would silently return empty — confusing. Schema-level refine
256
350
  // turns it into a clean 400 at the gate.
@@ -15,6 +15,7 @@ import {
15
15
  import { z } from "zod";
16
16
  import { createConfigFeature } from "../../config";
17
17
  import { createTenantFeature } from "../../tenant";
18
+ import { createUserFeature } from "../../user";
18
19
  import { AuditQueries } from "../constants";
19
20
  import { createEscapeHatchAuditSink, ESCAPE_HATCH_USED_EVENT } from "../escape-hatch-audit-sink";
20
21
  import { createAuditFeature } from "../feature";
@@ -41,7 +42,13 @@ const adminOfSameTenant: SessionUser = createTestUser({ id: 10, tenantId, roles:
41
42
 
42
43
  beforeAll(async () => {
43
44
  stack = await setupTestStack({
44
- features: [probeFeature, createConfigFeature(), createTenantFeature(), createAuditFeature()],
45
+ features: [
46
+ probeFeature,
47
+ createConfigFeature(),
48
+ createTenantFeature(),
49
+ createUserFeature(),
50
+ createAuditFeature(),
51
+ ],
45
52
  extraContext: ({ db }) => ({ _escapeHatchAuditSink: createEscapeHatchAuditSink({ db }) }),
46
53
  });
47
54
  });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.292.0",
4
+ "type": "improvement",
5
+ "title": "Audit log shows actor display names instead of raw UUIDs (fw#3103)",
6
+ "detail": "The `Actor` column of `audit:screen:audit-log` and the `createdBy` field of its detail screen rendered the raw `createdBy` UUID. The framework primitive for this already existed — `ListColumnSpec.refEntity` / `refLabelField`, used by `sessions` and `delivery` — the audit feature just did not declare it. Both now carry `refEntity: \"user:user\"`, `refLabelField: \"displayName\"`, which also makes the `createdBy → user:user` relation machine-readable in the app schema instead of implicit.\n`audit:query:list` and `audit:query:details` are unchanged and still return the plain id.\nA system write (`SYSTEM_USER_ID`, the null UUID) has no `read_users` row, so the bulk reference lookup can never resolve it. `SYSTEM_REFERENCE_LABELS` gained a `user:user` entry with the new `kumiko.reference.system-user` key, so every screen referencing `user:user` — not just the audit log — renders \"System\" for it. An actor that resolves to no row at all (deleted user) keeps the existing generic fallback: the raw id, no throw.\n`SYSTEM_USER_ID` moved from `framework/engine/system-user` to `kumiko-types/identifiers`, next to `SYSTEM_TENANT_ID`, so the client-side reference-label map can read it without importing a runtime module. `engine/system-user` re-exports it — every existing import keeps working.",
7
+ "migration": "The audit feature now declares `r.requires(\"tenant\", \"user\")`. An app that mounts `createAuditFeature()` without the `user` feature fails at boot with `Feature \"audit\" requires feature \"user\" which is not registered` — mount `createUserFeature()`. Apps using `securityBaselineFeatures()` already had to mount it."
8
+ },
2
9
  {
3
10
  "version": "0.271.0",
4
11
  "type": "improvement",
@@ -35,8 +35,7 @@ export function createAuditFeature(): FeatureDefinition {
35
35
  recommended: false,
36
36
  });
37
37
  r.translations({ keys: AUDIT_I18N });
38
- // Screens resolve actor names via tenant:query:members.
39
- r.requires("tenant");
38
+ r.requires("tenant", "user");
40
39
 
41
40
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
42
41
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
@@ -57,10 +56,25 @@ export function createAuditFeature(): FeatureDefinition {
57
56
  renderer: { format: "timestamp" },
58
57
  },
59
58
  { field: "type", label: i18nKey("audit.log.col.type") },
60
- { field: "createdBy", label: i18nKey("audit.log.col.actor") },
59
+ {
60
+ field: "createdBy",
61
+ label: i18nKey("audit.log.col.actor"),
62
+ // Resolves only for SystemAdmin until fw#3107: the lookup QN this
63
+ // derives, user:query:user:list, is SystemAdmin-only.
64
+ refEntity: "user:user",
65
+ refLabelField: "displayName",
66
+ },
61
67
  ],
62
68
  searchable: true,
63
69
  defaultSort: { field: "createdAt", dir: "desc" },
70
+ facets: [
71
+ {
72
+ type: "dateRange",
73
+ field: "createdAt",
74
+ label: i18nKey("audit.log.col.when"),
75
+ params: { from: "from", to: "to" },
76
+ },
77
+ ],
64
78
  rowActions: [
65
79
  {
66
80
  kind: "navigate",
@@ -99,7 +113,7 @@ export function createAuditFeature(): FeatureDefinition {
99
113
  { field: "createdAt", renderer: { format: "timestamp" } },
100
114
  "aggregateType",
101
115
  "aggregateId",
102
- "createdBy",
116
+ { field: "createdBy", refEntity: "user:user", refLabelField: "displayName" },
103
117
  "id",
104
118
  ],
105
119
  },
@@ -33,6 +33,7 @@ import { AuditQueries } from "../../audit/constants";
33
33
  import { createAuditFeature } from "../../audit/feature";
34
34
  import { createConfigFeature } from "../../config";
35
35
  import { createTenantFeature } from "../../tenant";
36
+ import { createUserFeature } from "../../user";
36
37
  import { fieldDefinitionEntity } from "../entity";
37
38
  import { createCustomFieldsFeature } from "../feature";
38
39
  import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
@@ -86,6 +87,7 @@ beforeAll(async () => {
86
87
  propertyFeature,
87
88
  createConfigFeature(),
88
89
  createTenantFeature(),
90
+ createUserFeature(),
89
91
  createAuditFeature(),
90
92
  ],
91
93
  });
@@ -5,7 +5,15 @@
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
- import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ access,
10
+ createTenantConfig,
11
+ defineFeature,
12
+ defineWriteHandler,
13
+ FILE_PROVIDER_CONFIG_KEY,
14
+ FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
15
+ FILE_STORAGE_PROVIDER_ENV,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
9
17
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
18
  import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
11
19
  import {
@@ -25,7 +33,11 @@ import { z } from "zod";
25
33
  import { createConfigFeature } from "../../config";
26
34
  import { ConfigHandlers } from "../../config/constants";
27
35
  import { createConfigAccessorFactory } from "../../config/feature";
28
- import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
36
+ import {
37
+ buildEnvConfigOverrides,
38
+ type ConfigResolver,
39
+ createConfigResolver,
40
+ } from "../../config/resolver";
29
41
  import { configValuesTable } from "../../config/table";
30
42
  import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../../file-provider-s3";
31
43
  import { fileProviderS3EnvFeature } from "../../file-provider-s3-env";
@@ -37,15 +49,30 @@ import { createFileProviderForTenant, fileFoundationFeature } from "../feature";
37
49
  // --- Test-Handler that exercises the factory end-to-end ---
38
50
 
39
51
  const TEST_HANDLER_QN = "file-test:write:build-provider";
52
+ const PROBE_SECRET_KEY = "file-test:config:probe-secret";
40
53
  const testProbeFeature = defineFeature("file-test", (r) => {
41
54
  r.requires("config");
42
55
  r.requires("secrets");
56
+ // Envelope-cipher round-trip partner: proves the resolver still decrypts
57
+ // once it also carries ENV-bridged app-overrides.
58
+ r.config({
59
+ keys: {
60
+ probeSecret: createTenantConfig("text", {
61
+ encrypted: true,
62
+ default: "",
63
+ read: access.roles("TenantAdmin", "SystemAdmin"),
64
+ write: access.roles("TenantAdmin", "SystemAdmin"),
65
+ }),
66
+ },
67
+ });
43
68
  r.writeHandler(
44
69
  defineWriteHandler({
45
70
  name: "build-provider",
46
71
  schema: z.object({}),
47
72
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
48
73
  handler: async (event, ctx) => {
74
+ const readConfig = ctx.config;
75
+ if (!readConfig) throw new Error(`${TEST_HANDLER_QN}: ctx.config is missing`);
49
76
  const provider = await createFileProviderForTenant(
50
77
  ctx,
51
78
  event.user.tenantId,
@@ -54,6 +81,8 @@ const testProbeFeature = defineFeature("file-test", (r) => {
54
81
  return {
55
82
  isSuccess: true,
56
83
  data: {
84
+ selectedProvider: await readConfig(FILE_PROVIDER_CONFIG_KEY),
85
+ probeSecret: await readConfig(PROBE_SECRET_KEY),
57
86
  hasWrite: typeof provider.write === "function",
58
87
  hasRead: typeof provider.read === "function",
59
88
  hasDelete: typeof provider.delete === "function",
@@ -72,10 +101,10 @@ let resolver: ConfigResolver;
72
101
  let providerRef: MutableMasterKeyProvider;
73
102
 
74
103
  const testEncryptionKey = randomBytes(32).toString("base64");
104
+ const ENV_SELECTED_PROVIDER = "s3-env";
75
105
 
76
106
  beforeAll(async () => {
77
107
  const encryption = createTestEnvelopeCipher(testEncryptionKey);
78
- resolver = createConfigResolver({ cipher: encryption });
79
108
 
80
109
  const initialKp = createEnvMasterKeyProvider({
81
110
  env: {
@@ -96,12 +125,22 @@ beforeAll(async () => {
96
125
  testProbeFeature,
97
126
  ],
98
127
  masterKeyProvider: providerRef,
99
- extraContext: ({ db, registry }) => ({
100
- configResolver: resolver,
101
- configEncryption: encryption,
102
- _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
103
- secrets: createSecretsContext({ db, masterKeyProvider: providerRef }),
104
- }),
128
+ extraContext: ({ db, registry }) => {
129
+ // No app-side resolver rebuild: the provider key declares
130
+ // env: FILE_STORAGE_PROVIDER, so the generic ENV bridge selects it.
131
+ resolver = createConfigResolver({
132
+ cipher: encryption,
133
+ appOverrides: buildEnvConfigOverrides(registry, {
134
+ [FILE_STORAGE_PROVIDER_ENV]: ENV_SELECTED_PROVIDER,
135
+ }),
136
+ });
137
+ return {
138
+ configResolver: resolver,
139
+ configEncryption: encryption,
140
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
141
+ secrets: createSecretsContext({ db, masterKeyProvider: providerRef }),
142
+ };
143
+ },
105
144
  });
106
145
  db = stack.db;
107
146
 
@@ -283,3 +322,87 @@ describe("scenario 4: s3-env provider (app-wide env, no secrets)", () => {
283
322
  }
284
323
  });
285
324
  });
325
+
326
+ // --- Scenario 5: FILE_STORAGE_PROVIDER selects the provider, no app resolver ---
327
+ //
328
+ // The stack's resolver is the plain framework one — its only app-override
329
+ // comes from buildEnvConfigOverrides reading the key's `env` declaration.
330
+ // Before #3112 every app rebuilt createConfigResolver just to pin this key.
331
+
332
+ describe("scenario 5: ENV-bridged provider selection", () => {
333
+ function withS3Env<T>(run: () => Promise<T>): Promise<T> {
334
+ const saved = S3_ENV_KEYS.map((k) => [k, process.env[k]] as const);
335
+ Object.assign(process.env, {
336
+ S3_BUCKET: "env-bridge-bucket",
337
+ S3_REGION: "fsn1",
338
+ S3_ACCESS_KEY: "AKIABRIDGE",
339
+ S3_SECRET_KEY: "env-secret-not-real",
340
+ });
341
+ return run().finally(() => {
342
+ for (const [k, v] of saved) {
343
+ if (v === undefined) delete process.env[k];
344
+ else process.env[k] = v;
345
+ }
346
+ });
347
+ }
348
+
349
+ test("no tenant row → FILE_STORAGE_PROVIDER wins and the factory builds that provider", async () => {
350
+ const admin = adminFor(506);
351
+
352
+ const result = await withS3Env(
353
+ async () => (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<string, unknown>,
354
+ );
355
+ expect(result["selectedProvider"]).toBe(ENV_SELECTED_PROVIDER);
356
+ expect(result["hasWrite"]).toBe(true);
357
+ });
358
+
359
+ test("tenant row still overrides the ENV value (cascade unchanged)", async () => {
360
+ const admin = adminFor(507);
361
+
362
+ await selectS3Provider(admin);
363
+ await setConfig(admin, "file-provider-s3:config:bucket", "row-bucket");
364
+ await setConfig(admin, "file-provider-s3:config:region", "fsn1");
365
+ await setConfig(admin, "file-provider-s3:config:access-key-id", "AKIAROW");
366
+ await stack.http.writeOk(
367
+ "secrets:write:set",
368
+ { key: S3_SECRET_ACCESS_KEY.name, value: "row-secret" },
369
+ admin,
370
+ );
371
+
372
+ const result = await withS3Env(
373
+ async () => (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<string, unknown>,
374
+ );
375
+ expect(result["selectedProvider"]).toBe("s3");
376
+ });
377
+
378
+ test("boot-gate sentinel selects no provider", async () => {
379
+ // What runDevApp writes into FILE_STORAGE_PROVIDER when options.files
380
+ // already wires a provider — it names no plugin, so the bridge must not
381
+ // turn it into a selection.
382
+ expect(
383
+ buildEnvConfigOverrides(stack.registry, {
384
+ [FILE_STORAGE_PROVIDER_ENV]: FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
385
+ }).get(FILE_PROVIDER_CONFIG_KEY),
386
+ ).toBe(FILE_STORAGE_PROVIDER_BOOT_SENTINEL);
387
+
388
+ const admin = adminFor(508);
389
+ await setConfig(admin, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL);
390
+
391
+ const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
392
+ // The factory throw surfaces as internal_error; the original message sits
393
+ // in details.causeMessage.
394
+ expect(JSON.stringify(error)).toMatch(
395
+ /no provider selected — set the 'file-foundation:config:provider' config-key/,
396
+ );
397
+ });
398
+
399
+ test("encrypted config keys still round-trip through the same resolver", async () => {
400
+ const admin = adminFor(509);
401
+ await setConfig(admin, PROBE_SECRET_KEY, "cipher-round-trip");
402
+
403
+ const result = await withS3Env(
404
+ async () => (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<string, unknown>,
405
+ );
406
+ expect(result["probeSecret"]).toBe("cipher-round-trip");
407
+ });
408
+ });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.292.0",
4
+ "type": "improvement",
5
+ "title": "FILE_STORAGE_PROVIDER selects the file provider, not just the boot gate",
6
+ "detail": "The `provider` config key declares `env: \"FILE_STORAGE_PROVIDER\"`, so the ENV→app-override bridge selects the provider at boot without an app-side `createConfigResolver` rebuild. Tenant rows still override the ENV value (cascade unchanged). The boot-gate placeholder `\"configured\"` that `runDevApp` writes names no plugin, so `createFileProviderForTenant` treats it as unset and raises \"no provider selected\"; both strings are exported as `FILE_STORAGE_PROVIDER_ENV` / `FILE_STORAGE_PROVIDER_BOOT_SENTINEL`.",
7
+ "migration": "Apps on the framework's default config resolver: make sure `FILE_STORAGE_PROVIDER` holds a registered provider name (`inmemory`, `s3`, `s3-env`) — it is now the provider selection, not only a presence check. Apps that pass their own `configResolver` are unaffected and can drop their hand-rolled `file-foundation:config:provider` app-override."
8
+ }
9
+ ]
@@ -21,6 +21,7 @@ import {
21
21
  createTenantConfig,
22
22
  defineFeature,
23
23
  EXT_FILE_PROVIDER,
24
+ FILE_STORAGE_PROVIDER_ENV,
24
25
  } from "@cosmicdrift/kumiko-framework/engine";
25
26
 
26
27
  export type {
@@ -58,6 +59,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
58
59
  "provider",
59
60
  createTenantConfig("text", {
60
61
  default: "",
62
+ env: FILE_STORAGE_PROVIDER_ENV,
61
63
  write: access.roles("TenantAdmin", "SystemAdmin"),
62
64
  read: access.roles("TenantAdmin", "SystemAdmin", "User"),
63
65
  }),