@cosmicdrift/kumiko-bundled-features 0.164.0 → 0.165.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 (25) hide show
  1. package/package.json +7 -6
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +103 -2
  4. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +1 -1
  5. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +12 -4
  6. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +24 -1
  7. package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +26 -0
  8. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +74 -0
  9. package/src/config/__tests__/cascade.integration.test.ts +3 -18
  10. package/src/config/resolver.ts +6 -49
  11. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +147 -0
  12. package/src/document-ingest-foundation/__tests__/feature.test.ts +67 -0
  13. package/src/document-ingest-foundation/entity.ts +36 -0
  14. package/src/document-ingest-foundation/events.ts +25 -0
  15. package/src/document-ingest-foundation/feature.ts +108 -0
  16. package/src/document-ingest-foundation/index.ts +11 -0
  17. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +48 -0
  18. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +6 -5
  19. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -3
  20. package/src/shared/entities-of.ts +17 -0
  21. package/src/shared/index.ts +1 -0
  22. package/src/tenant-lifecycle/boot-checks.ts +1 -16
  23. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +50 -73
  24. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +120 -0
  25. package/src/user-data-rights/boot-checks.ts +1 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.164.0",
3
+ "version": "0.165.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>",
@@ -57,6 +57,7 @@
57
57
  "./mail-transport-inmemory": "./src/mail-transport-inmemory/index.ts",
58
58
  "./file-foundation": "./src/file-foundation/index.ts",
59
59
  "./file-provider-s3": "./src/file-provider-s3/index.ts",
60
+ "./document-ingest-foundation": "./src/document-ingest-foundation/index.ts",
60
61
  "./file-provider-s3-env": "./src/file-provider-s3-env/index.ts",
61
62
  "./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
62
63
  "./files": "./src/files/index.ts",
@@ -117,11 +118,11 @@
117
118
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
118
119
  },
119
120
  "dependencies": {
120
- "@cosmicdrift/kumiko-dispatcher-live": "0.164.0",
121
- "@cosmicdrift/kumiko-framework": "0.164.0",
122
- "@cosmicdrift/kumiko-headless": "0.164.0",
123
- "@cosmicdrift/kumiko-renderer": "0.164.0",
124
- "@cosmicdrift/kumiko-renderer-web": "0.164.0",
121
+ "@cosmicdrift/kumiko-dispatcher-live": "0.165.0",
122
+ "@cosmicdrift/kumiko-framework": "0.165.0",
123
+ "@cosmicdrift/kumiko-headless": "0.165.0",
124
+ "@cosmicdrift/kumiko-renderer": "0.165.0",
125
+ "@cosmicdrift/kumiko-renderer-web": "0.165.0",
125
126
  "@mollie/api-client": "^4.5.0",
126
127
  "imapflow": "^1.3.3",
127
128
  "mailparser": "^3.9.8",
@@ -21,7 +21,6 @@ import { createConfigFeature } from "../../config/feature";
21
21
  import { createConfigResolver } from "../../config/resolver";
22
22
  import { configValuesTable } from "../../config/table";
23
23
  import { createFeatureTogglesFeature } from "../../feature-toggles/feature";
24
- import { globalFeatureStateTable } from "../../feature-toggles/global-feature-state-table";
25
24
  import { JobQueries } from "../../jobs/constants";
26
25
  import { createJobsFeature } from "../../jobs/feature";
27
26
  import { jobRunLogsTable, jobRunsTable } from "../../jobs/job-run-table";
@@ -79,10 +78,12 @@ beforeAll(async () => {
79
78
  await unsafeCreateEntityTable(stack.db, userEntity);
80
79
  await unsafeCreateEntityTable(stack.db, tenantEntity);
81
80
  await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
81
+ // globalFeatureStateTable is auto-provisioned by setupTestStack — no
82
+ // manual push needed (that used to mask a missing r.storeTable()
83
+ // registration entirely).
82
84
  await unsafePushTables(stack.db, {
83
85
  configValuesTable,
84
86
  tenantMembershipsTable,
85
- globalFeatureStateTable,
86
87
  jobRunsTable,
87
88
  jobRunLogsTable,
88
89
  });
@@ -1,6 +1,6 @@
1
1
  // Runtime on/off switch for self-signup (#self-registration-toggle). Pins:
2
2
  // 1. Default (no override row) → signup-request works.
3
- // 2. runtime.apply(off) → signup-request 403 feature_disabled, no mail sent.
3
+ // 2. runtime.apply(off) → signup-request still 200 (silent no-op, anti-enumeration), no mail sent.
4
4
  // 3. runtime.apply(on) → works again.
5
5
  // Mirrors samples/recipes/feature-toggles' runtime.apply() pattern — flips the
6
6
  // in-memory snapshot directly instead of going through the set-handler/HTTP,
@@ -91,11 +91,13 @@ beforeAll(async () => {
91
91
 
92
92
  await unsafeCreateEntityTable(stack.db, userEntity);
93
93
  await unsafeCreateEntityTable(stack.db, tenantEntity);
94
+ // globalFeatureStateTable is auto-provisioned by setupTestStack — no
95
+ // manual push needed (that used to mask a missing r.storeTable()
96
+ // registration entirely).
94
97
  await unsafePushTables(stack.db, {
95
98
  configValuesTable,
96
99
  tenantMembershipsTable,
97
100
  notificationPreferencesTable,
98
- globalFeatureStateTable,
99
101
  });
100
102
 
101
103
  runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
@@ -166,3 +168,102 @@ describe("auth-self-registration toggle", () => {
166
168
  });
167
169
  });
168
170
  });
171
+
172
+ // Regression for Finding 1 (#1468): the suite above always composes the
173
+ // companion toggle feature alongside feature-toggles. An app that mounts
174
+ // createFeatureTogglesFeature + a real effectiveFeatures resolver but
175
+ // forgets (or never knew to) compose createAuthSelfRegistrationToggleFeature
176
+ // must not go silently signup-dead — ctx.hasFeature() is checked against a
177
+ // feature name the registry never registered.
178
+ describe("auth-self-registration toggle — companion feature NOT composed", () => {
179
+ const bareEmailTransport = createInMemoryTransport();
180
+ let bareStack: TestStack;
181
+ let bareRuntime: GlobalFeatureToggleRuntime;
182
+
183
+ beforeAll(async () => {
184
+ const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("bare-runtime");
185
+ let effective: () => ReadonlySet<string> = () => new Set();
186
+
187
+ bareStack = await setupTestStack({
188
+ features: [
189
+ createConfigFeature(),
190
+ createUserFeature(),
191
+ createTenantFeature(),
192
+ createTemplateResolverFeature(),
193
+ createRendererFoundationFeature(),
194
+ createDeliveryFeature(),
195
+ createRendererSimpleFeature(),
196
+ createChannelEmailFeature({
197
+ transport: bareEmailTransport,
198
+ renderer: simpleRenderer,
199
+ resolveEmail: async () => "unused@test.local",
200
+ }),
201
+ createAuthEmailPasswordFeature({
202
+ signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
203
+ }),
204
+ // Deliberately NO createAuthSelfRegistrationToggleFeature().
205
+ createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
206
+ ],
207
+ effectiveFeatures: () => effective(),
208
+ extraContext: (deps) => ({
209
+ ...createDeliveryTestContext(deps),
210
+ configResolver: createConfigResolver(),
211
+ }),
212
+ systemHooks: [],
213
+ anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
214
+ authConfig: {
215
+ membershipQuery: "tenant:query:memberships",
216
+ loginHandler: AuthHandlers.login,
217
+ signup: {
218
+ requestHandler: AuthHandlers.signupRequest,
219
+ confirmHandler: AuthHandlers.signupConfirm,
220
+ },
221
+ },
222
+ });
223
+
224
+ await unsafeCreateEntityTable(bareStack.db, userEntity);
225
+ await unsafeCreateEntityTable(bareStack.db, tenantEntity);
226
+ await unsafePushTables(bareStack.db, {
227
+ configValuesTable,
228
+ tenantMembershipsTable,
229
+ notificationPreferencesTable,
230
+ });
231
+
232
+ bareRuntime = new GlobalFeatureToggleRuntime(bareStack.db, bareStack.registry);
233
+ await bareRuntime.initialize();
234
+ effective = bareRuntime.effectiveFeatures;
235
+ runtimeHolder.set(bareRuntime);
236
+ });
237
+
238
+ afterAll(async () => {
239
+ await bareStack.cleanup();
240
+ });
241
+
242
+ // KNOWN GAP (found while writing this regression pin, not yet decided):
243
+ // ctx.hasFeature() checks the effective-feature SET, which only ever
244
+ // contains registered feature names (computeEffectiveFeatures iterates
245
+ // registry.features). A name nothing ever registered is never a member,
246
+ // so hasFeature("auth-self-registration") is false here — signup-request
247
+ // silently no-ops (200, anti-enumeration success shape, but no mail) the
248
+ // moment an app wires a real effectiveFeatures resolver without also
249
+ // composing createAuthSelfRegistrationToggleFeature. This pins the
250
+ // CURRENT (broken) behavior rather than asserting the desired one —
251
+ // flagged for a follow-up decision, not silently fixed here.
252
+ test("[KNOWN GAP] signup-request silently drops mail when the companion toggle feature was never composed", async () => {
253
+ const res = await bareStack.http.raw("POST", "/api/auth/signup-request", {
254
+ email: "not-toggle-aware@example.com",
255
+ });
256
+ expect(res.status).toBe(200);
257
+ expect(bareEmailTransport.sent).toHaveLength(0);
258
+ });
259
+
260
+ test("[KNOWN GAP] signup-registration-status reports enabled:false with no toggle feature mounted", async () => {
261
+ const res = await bareStack.http.raw("POST", "/api/query", {
262
+ type: "auth-email-password:query:signup-registration-status",
263
+ payload: {},
264
+ });
265
+ expect((await res.json()) as { data?: { enabled: boolean } }).toMatchObject({
266
+ data: { enabled: false },
267
+ });
268
+ });
269
+ });
@@ -331,7 +331,7 @@ describe("LoginScreen", () => {
331
331
  fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
332
332
  fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
333
333
  await waitFor(() => {
334
- expect(screen.getByRole("alert").textContent).toMatch(/MFA|Zwei-Faktor|nicht unterstützt/i);
334
+ expect(screen.getByRole("alert").textContent).toContain("unterstützt keine Zwei-Faktor");
335
335
  });
336
336
  });
337
337
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
8
  import { randomBytes } from "node:crypto";
9
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
10
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
11
11
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
12
  import {
@@ -38,7 +38,7 @@ import { base32Decode } from "../base32";
38
38
  import { mfaRequiredConfigHandle } from "../config";
39
39
  import { AuthMfaHandlers } from "../constants";
40
40
  import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
41
- import { userMfaEntity } from "../schema/user-mfa";
41
+ import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
42
42
  import { currentTotpCode } from "../totp";
43
43
 
44
44
  let stack: TestStack;
@@ -163,7 +163,7 @@ describe("POST /auth/mfa/preauth-confirm", () => {
163
163
 
164
164
  test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
165
165
  const email = "wrongcode@example.com";
166
- const { preauthSetupToken } = await loginBlockedByEnforcement(email);
166
+ const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
167
167
  const { setupToken } = await startEnrollment(preauthSetupToken, email);
168
168
 
169
169
  const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
@@ -176,11 +176,16 @@ describe("POST /auth/mfa/preauth-confirm", () => {
176
176
  expect(body.isSuccess).toBe(false);
177
177
  expect(body.error.details.reason).toBe("invalid_totp_code");
178
178
  expect(body.token).toBeUndefined();
179
+ // "no enrollment persisted" is the actual claim in the test name — the
180
+ // reason string alone doesn't prove it. Scoped to this test's own user:
181
+ // the table isn't truncated between tests in this file.
182
+ const rows = await selectMany(stack.db, userMfaTable, { userId });
183
+ expect(rows).toHaveLength(0);
179
184
  });
180
185
 
181
186
  test("replaying the same setupToken after a successful confirm → invalid_setup_token (burned), not a second enrollment", async () => {
182
187
  const email = "race@example.com";
183
- const { preauthSetupToken } = await loginBlockedByEnforcement(email);
188
+ const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
184
189
  const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
185
190
  const code = currentTotpCode(secret);
186
191
 
@@ -200,6 +205,9 @@ describe("POST /auth/mfa/preauth-confirm", () => {
200
205
  expect(second.status).not.toBe(200);
201
206
  const secondBody = await second.json();
202
207
  expect(secondBody.error.details.reason).toBe("invalid_setup_token");
208
+ // "not a second enrollment" — pin the row count, not just the error reason.
209
+ const rows = await selectMany(stack.db, userMfaTable, { userId });
210
+ expect(rows).toHaveLength(1);
203
211
  });
204
212
 
205
213
  test("a session-issued setupToken (no tenantId) is rejected, not silently accepted", async () => {
@@ -163,9 +163,32 @@ describe("POST /auth/mfa/preauth-enable-start", () => {
163
163
  accountLabel: "unenrolled@example.com",
164
164
  });
165
165
 
166
- expect(res.status).not.toBe(200);
166
+ expect(res.status).toBe(422);
167
+ const body = await res.json();
168
+ expect(body.isSuccess).toBe(false);
169
+ expect(body.error.details.reason).toBe("invalid_challenge_token");
170
+ expect(body.setupToken).toBeUndefined();
171
+ });
172
+
173
+ test("structurally-valid preauthSetupToken signed with the WRONG secret (setup instead of challenge) is rejected", async () => {
174
+ // EnableStartPreauthOptions warns explicitly about this secret mix-up —
175
+ // signing with SETUP_TOKEN_SECRET instead of the CHALLENGE_TOKEN_SECRET
176
+ // that preauth-enable-start actually verifies against must fail exactly
177
+ // like a garbage token, not silently pass signature verification.
178
+ const { token: wrongSecretToken } = signMfaPreauthSetupToken(
179
+ { userId: "11111111-1111-4111-8111-111111111111", tenantId: TENANT_ID },
180
+ 10,
181
+ SETUP_TOKEN_SECRET,
182
+ );
183
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
184
+ preauthSetupToken: wrongSecretToken,
185
+ accountLabel: "unenrolled@example.com",
186
+ });
187
+
188
+ expect(res.status).toBe(422);
167
189
  const body = await res.json();
168
190
  expect(body.isSuccess).toBe(false);
191
+ expect(body.error.details.reason).toBe("invalid_challenge_token");
169
192
  expect(body.setupToken).toBeUndefined();
170
193
  });
171
194
 
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Temporal } from "temporal-polyfill";
3
+ import { signMfaChallengeToken, verifyMfaChallengeToken } from "../mfa-challenge-token";
3
4
  import { signMfaPreauthSetupToken, verifyMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
4
5
 
5
6
  const SECRET = "test-mfa-preauth-setup-secret-at-least-32-bytes!!";
@@ -34,6 +35,31 @@ describe("mfa-preauth-setup-token", () => {
34
35
  });
35
36
  });
36
37
 
38
+ test("a genuine mfa-challenge token cannot be replayed as a preauth-setup token, and vice versa", () => {
39
+ // Both bodies share the identical JSON shape ({userId, tenantId,
40
+ // expiresAtMs}), so this only fails if the domain-separated signing
41
+ // string (`mfa-challenge:` vs `mfa-preauth-setup:`) actually differs.
42
+ const { token: challengeToken } = signMfaChallengeToken(
43
+ { userId: USER_ID, tenantId: TENANT_ID },
44
+ 10,
45
+ SECRET,
46
+ );
47
+ expect(verifyMfaPreauthSetupToken(challengeToken, SECRET)).toEqual({
48
+ ok: false,
49
+ reason: "bad_signature",
50
+ });
51
+
52
+ const { token: setupToken } = signMfaPreauthSetupToken(
53
+ { userId: USER_ID, tenantId: TENANT_ID },
54
+ 10,
55
+ SECRET,
56
+ );
57
+ expect(verifyMfaChallengeToken(setupToken, SECRET)).toEqual({
58
+ ok: false,
59
+ reason: "bad_signature",
60
+ });
61
+ });
62
+
37
63
  test("tampered signature → bad_signature", () => {
38
64
  const { token } = signMfaPreauthSetupToken(
39
65
  { userId: USER_ID, tenantId: TENANT_ID },
@@ -199,4 +199,78 @@ describe("MfaSetupPreauthScreen", () => {
199
199
  );
200
200
  expect(screen.queryByRole("button", { name: "Zurück zum Login" })).toBeNull();
201
201
  });
202
+
203
+ test("confirm schlägt mit invalid_totp_code fehl → übersetztes Banner, onSuccess NICHT gefeuert, Formular weiter bedienbar", async () => {
204
+ const onSuccess = mock(() => {});
205
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
206
+ const url = String(input);
207
+ if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
208
+ if (url.includes("preauth-confirm")) {
209
+ return jsonResponse({ isSuccess: false, error: "invalid_totp_code" });
210
+ }
211
+ return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
212
+ }) as unknown as typeof fetch;
213
+
214
+ render(
215
+ <Wrapper>
216
+ <MfaSetupPreauthScreen
217
+ preauthSetupToken="preauth-1"
218
+ accountLabel="user@example.com"
219
+ onSuccess={onSuccess}
220
+ />
221
+ </Wrapper>,
222
+ );
223
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
224
+ await waitFor(() => {
225
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
226
+ });
227
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
228
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
229
+ target: { value: "000000" },
230
+ });
231
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
232
+
233
+ await waitFor(() => {
234
+ expect(screen.getByText("Ungültiger Code. Bitte erneut versuchen.")).toBeTruthy();
235
+ });
236
+ expect(onSuccess).not.toHaveBeenCalled();
237
+ // Form stays usable — QR/recovery section is still there, a retry is possible.
238
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
239
+ const confirm = screen.getByRole("button", {
240
+ name: "Einrichtung abschließen",
241
+ }) as HTMLButtonElement;
242
+ expect(confirm.disabled).toBe(false);
243
+ });
244
+
245
+ test("confirm schlägt mit invalid_setup_token fehl → übersetztes Banner (Neustart-Pfad)", async () => {
246
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
247
+ const url = String(input);
248
+ if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
249
+ if (url.includes("preauth-confirm")) {
250
+ return jsonResponse({ isSuccess: false, error: "invalid_setup_token" });
251
+ }
252
+ return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
253
+ }) as unknown as typeof fetch;
254
+
255
+ render(
256
+ <Wrapper>
257
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
258
+ </Wrapper>,
259
+ );
260
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
261
+ await waitFor(() => {
262
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
263
+ });
264
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
265
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
266
+ target: { value: "123456" },
267
+ });
268
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
269
+
270
+ await waitFor(() => {
271
+ expect(
272
+ screen.getByText("Die Einrichtung ist abgelaufen. Bitte erneut starten."),
273
+ ).toBeTruthy();
274
+ });
275
+ });
202
276
  });
@@ -528,10 +528,9 @@ describe("config:query:cascade handler", () => {
528
528
  });
529
529
  });
530
530
 
531
- // getAll / getAllWithSource are bulk readers used by admin UIs never
532
- // reached via the cascade query handlers above, so they stay uncovered
533
- // unless called directly on the resolver.
534
- describe("getAll / getAllWithSource", () => {
531
+ // getAll is a bulk reader never reached via the cascade query handlers
532
+ // above, so it stays uncovered unless called directly on the resolver.
533
+ describe("getAll", () => {
535
534
  test("getAll picks the most specific row (user > tenant > system)", async () => {
536
535
  await stack.http.writeOk(
537
536
  ConfigHandlers.set,
@@ -551,18 +550,4 @@ describe("getAll / getAllWithSource", () => {
551
550
  // System seed still present for keys without a more-specific row.
552
551
  expect(JSON.parse(all.get(SYSTEM_KEY)?.value ?? "null")).toBe("SEED_SYSTEM");
553
552
  });
554
-
555
- test("getAllWithSource tags the winning row's source", async () => {
556
- const withSource = await resolver.getAllWithSource(tenantAdmin.tenantId, tenantAdmin.id, db);
557
-
558
- const userRow = withSource.get(USER_KEY);
559
- expect(userRow?.source).toBe("user-row");
560
- expect(JSON.parse(userRow?.value ?? "null")).toBe("USER_BULK");
561
-
562
- const tenantRow = withSource.get(TENANT_KEY);
563
- expect(tenantRow?.source).toBe("tenant-row");
564
-
565
- const systemRow = withSource.get(SYSTEM_KEY);
566
- expect(systemRow?.source).toBe("system-row");
567
- });
568
553
  });
@@ -12,7 +12,6 @@ import type {
12
12
  ConfigKeyDefinition,
13
13
  ConfigResolver,
14
14
  ConfigSecretsReader,
15
- ConfigStoredRowWithSource,
16
15
  ConfigValueSource,
17
16
  ConfigValueWithSource,
18
17
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -441,12 +440,12 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
441
440
  return { value: undefined, source: "missing" };
442
441
  },
443
442
 
444
- // NOTE: getAll / getAllWithSource read only config_values rows and do NOT
445
- // dispatch to the secrets store (unlike get/getCascade/getWithSource). A
446
- // backing="secrets" key has no config_values row, so it is simply ABSENT
447
- // from the result map — never a stale/undefined value. Callers needing a
448
- // secrets-backed value must use get/getCascade; these bulk readers are for
449
- // the config_values-backed keys only.
443
+ // NOTE: getAll reads only config_values rows and does NOT dispatch to the
444
+ // secrets store (unlike get/getCascade/getWithSource). A backing="secrets"
445
+ // key has no config_values row, so it is simply ABSENT from the result map
446
+ // — never a stale/undefined value. Callers needing a secrets-backed value
447
+ // must use get/getCascade; this bulk reader is for config_values-backed
448
+ // keys only.
450
449
  async getAll(tenantId, userId, db) {
451
450
  // Only load rows relevant to this user/tenant (system + tenant + user scope)
452
451
  const rows = await selectConfigRowsForScope(db, SYSTEM_TENANT_ID, tenantId, userId);
@@ -467,48 +466,6 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
467
466
  return result;
468
467
  },
469
468
 
470
- async getAllWithSource(tenantId, userId, db) {
471
- // Load ALL potentially relevant rows (user + tenant + system)
472
- const rows = await selectConfigRowsForScope(db, SYSTEM_TENANT_ID, tenantId, userId);
473
-
474
- const result = new Map<string, ConfigStoredRowWithSource>();
475
-
476
- // Group rows by key so we can determine the winner and its source
477
- const groups = new Map<string, ConfigRow[]>();
478
- for (const r of rows) {
479
- const g = groups.get(r.key) ?? [];
480
- g.push(r);
481
- groups.set(r.key, g);
482
- }
483
-
484
- for (const [key, keyRows] of groups) {
485
- const specificityOf = (candidate: ConfigRow) =>
486
- (candidate.userId !== null ? 2 : 0) + (candidate.tenantId !== SYSTEM_TENANT_ID ? 1 : 0);
487
-
488
- const first = keyRows[0];
489
- if (!first) continue;
490
- let winner: ConfigRow = first;
491
- for (const r of keyRows) {
492
- if (specificityOf(r) > specificityOf(winner)) {
493
- winner = r;
494
- }
495
- }
496
-
497
- let source: ConfigValueSource;
498
- if (winner.userId !== null) {
499
- source = "user-row";
500
- } else if (winner.tenantId !== SYSTEM_TENANT_ID) {
501
- source = "tenant-row";
502
- } else {
503
- source = "system-row";
504
- }
505
-
506
- result.set(key, { ...winner, source });
507
- }
508
-
509
- return result;
510
- },
511
-
512
469
  async getCascade(
513
470
  qualifiedKey,
514
471
  keyDef,
@@ -0,0 +1,147 @@
1
+ // document-ingest-foundation — fileRef.created MSP integration test.
2
+ //
3
+ // Proves the end-to-end trigger flow: upload → fileRef.created →
4
+ // documentIngest.requested, gated on mime/size (files-post-processing
5
+ // pattern, kumiko-framework#1497).
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import {
10
+ createInMemoryFileProvider,
11
+ type InMemoryFileProvider,
12
+ } from "@cosmicdrift/kumiko-framework/files";
13
+ import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { createConfigFeature } from "../../config";
15
+ import { documentIngestFoundationFeature } from "../feature";
16
+
17
+ let stack: TestStack;
18
+ let provider: InMemoryFileProvider;
19
+
20
+ const admin = TestUsers.admin;
21
+
22
+ const pdfBytes = new TextEncoder().encode("%PDF-1.4 minimal");
23
+ const pngBytes = new Uint8Array([
24
+ 0x89,
25
+ 0x50,
26
+ 0x4e,
27
+ 0x47,
28
+ 0x0d,
29
+ 0x0a,
30
+ 0x1a,
31
+ 0x0a,
32
+ ...Array(64).fill(0),
33
+ ]);
34
+ const textBytes = new TextEncoder().encode("plain text, not a supported mime type");
35
+
36
+ beforeAll(async () => {
37
+ provider = createInMemoryFileProvider();
38
+ stack = await setupTestStack({
39
+ features: [createConfigFeature(), documentIngestFoundationFeature],
40
+ files: { storageProvider: provider },
41
+ });
42
+ });
43
+
44
+ afterAll(async () => {
45
+ await stack.cleanup();
46
+ });
47
+
48
+ beforeEach(async () => {
49
+ provider.clear();
50
+ stack.events.reset();
51
+ await asRawClient(stack.db).unsafe(
52
+ `TRUNCATE kumiko_events, kumiko_event_consumers, file_refs RESTART IDENTITY CASCADE`,
53
+ );
54
+ await stack.eventDispatcher?.ensureRegistered();
55
+ });
56
+
57
+ async function uploadFile(
58
+ fileName: string,
59
+ content: Uint8Array,
60
+ mimeType: string,
61
+ ): Promise<{ id: string; storageKey: string }> {
62
+ const token = await stack.jwt.sign(admin);
63
+ const formData = new FormData();
64
+ formData.append("file", new File([Buffer.from(content)], fileName, { type: mimeType }));
65
+ const res = await stack.app.request("/api/files", {
66
+ method: "POST",
67
+ headers: { Authorization: `Bearer ${token}` },
68
+ body: formData,
69
+ });
70
+ expect(res.status).toBe(201);
71
+ const body = (await res.json()) as { id: string; storageKey: string };
72
+ return body;
73
+ }
74
+
75
+ async function loadIngestRequestedEvents(): Promise<{ payload: Record<string, unknown> }[]> {
76
+ const rows = await asRawClient(stack.db).unsafe(
77
+ `SELECT payload FROM kumiko_events WHERE type = $1`,
78
+ ["document-ingest-foundation:event:document-ingest-requested"],
79
+ );
80
+ return rows as { payload: Record<string, unknown> }[];
81
+ }
82
+
83
+ describe("fileRef.created → documentIngest.requested", () => {
84
+ test("PDF upload requests ingest with the fileRef pointer, no binary", async () => {
85
+ const { id, storageKey } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
86
+
87
+ await stack.eventDispatcher?.runOnce();
88
+
89
+ const rows = await loadIngestRequestedEvents();
90
+ expect(rows).toHaveLength(1);
91
+ const payload = rows[0]?.payload;
92
+ expect(payload?.["fileRefId"]).toBe(id);
93
+ expect(payload?.["storageKey"]).toBe(storageKey);
94
+ expect(payload?.["mimeType"]).toBe("application/pdf");
95
+ expect(payload?.["data"]).toBeUndefined();
96
+ expect(payload?.["binary"]).toBeUndefined();
97
+ });
98
+
99
+ test("image/png upload also requests ingest (Phase-1 mime allowlist)", async () => {
100
+ await uploadFile("scan.png", pngBytes, "image/png");
101
+
102
+ await stack.eventDispatcher?.runOnce();
103
+
104
+ expect(await loadIngestRequestedEvents()).toHaveLength(1);
105
+ });
106
+
107
+ test("unsupported mime type is skipped — no ingest requested", async () => {
108
+ await uploadFile("notes.txt", textBytes, "text/plain");
109
+
110
+ await stack.eventDispatcher?.runOnce();
111
+
112
+ expect(await loadIngestRequestedEvents()).toHaveLength(0);
113
+ });
114
+
115
+ test("oversized file is skipped before the mime check — no ingest requested", async () => {
116
+ // Real uploads can't exceed file-routes.ts' 10mb unconstrained-upload
117
+ // default, well below this feature's 25mb domain cap — so an oversized
118
+ // fileRef.created is inserted directly (bypassing the upload route) to
119
+ // prove the MSP's own size-check fires independently of it.
120
+ await asRawClient(stack.db).unsafe(
121
+ `
122
+ INSERT INTO kumiko_events
123
+ (tenant_id, aggregate_type, aggregate_id, version, type, payload, metadata, created_at, created_by)
124
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
125
+ `,
126
+ [
127
+ admin.tenantId,
128
+ "fileRef",
129
+ "00000000-0000-4000-8000-0000000000ff",
130
+ 1,
131
+ "fileRef.created",
132
+ JSON.stringify({
133
+ storageKey: "huge.pdf",
134
+ fileName: "huge.pdf",
135
+ mimeType: "application/pdf",
136
+ size: 26 * 1024 * 1024,
137
+ }),
138
+ "{}",
139
+ admin.id,
140
+ ],
141
+ );
142
+
143
+ await stack.eventDispatcher?.runOnce();
144
+
145
+ expect(await loadIngestRequestedEvents()).toHaveLength(0);
146
+ });
147
+ });