@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 +9 -9
- package/src/audit/__tests__/audit-screens.boot.test.ts +40 -1
- package/src/audit/__tests__/audit.integration.test.ts +96 -2
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +8 -1
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +18 -4
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +2 -0
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +132 -9
- package/src/file-foundation/changes.json +9 -1
- package/src/file-foundation/feature.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "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 = [
|
|
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 {
|
|
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: [
|
|
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: [
|
|
45
|
+
features: [
|
|
46
|
+
probeFeature,
|
|
47
|
+
createConfigFeature(),
|
|
48
|
+
createTenantFeature(),
|
|
49
|
+
createUserFeature(),
|
|
50
|
+
createAuditFeature(),
|
|
51
|
+
],
|
|
45
52
|
extraContext: ({ db }) => ({ _escapeHatchAuditSink: createEscapeHatchAuditSink({ db }) }),
|
|
46
53
|
});
|
|
47
54
|
});
|
package/src/audit/changes.json
CHANGED
|
@@ -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",
|
package/src/audit/feature.ts
CHANGED
|
@@ -35,8 +35,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
35
35
|
recommended: false,
|
|
36
36
|
});
|
|
37
37
|
r.translations({ keys: AUDIT_I18N });
|
|
38
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
}),
|