@cosmicdrift/kumiko-bundled-features 0.188.0 → 0.190.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.188.0",
3
+ "version": "0.190.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>",
@@ -67,6 +67,7 @@
67
67
  "./document-ingest-foundation": "./src/document-ingest-foundation/index.ts",
68
68
  "./file-provider-s3-env": "./src/file-provider-s3-env/index.ts",
69
69
  "./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
70
+ "./file-derivatives": "./src/file-derivatives/index.ts",
70
71
  "./files": "./src/files/index.ts",
71
72
  "./user-data-rights": "./src/user-data-rights/index.ts",
72
73
  "./user-data-rights/web": "./src/user-data-rights/web/index.ts",
@@ -123,12 +124,12 @@
123
124
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
124
125
  },
125
126
  "dependencies": {
126
- "@cosmicdrift/kumiko-dispatcher-live": "0.188.0",
127
- "@cosmicdrift/kumiko-framework": "0.188.0",
128
- "@cosmicdrift/kumiko-headless": "0.188.0",
129
- "@cosmicdrift/kumiko-renderer": "0.188.0",
130
- "@cosmicdrift/kumiko-renderer-web": "0.188.0",
131
- "@cosmicdrift/kumiko-types": "0.188.0",
127
+ "@cosmicdrift/kumiko-dispatcher-live": "0.190.0",
128
+ "@cosmicdrift/kumiko-framework": "0.190.0",
129
+ "@cosmicdrift/kumiko-headless": "0.190.0",
130
+ "@cosmicdrift/kumiko-renderer": "0.190.0",
131
+ "@cosmicdrift/kumiko-renderer-web": "0.190.0",
132
+ "@cosmicdrift/kumiko-types": "0.190.0",
132
133
  "@mollie/api-client": "^4.5.0",
133
134
  "imapflow": "^1.3.3",
134
135
  "mailparser": "^3.9.8",
@@ -282,4 +282,20 @@ describe("auth flows with active KMS + blind index", () => {
282
282
  });
283
283
  expect(loginRes.status).toBe(200);
284
284
  });
285
+
286
+ test("tenant:query:invitations decrypts the invited email under active KMS (#1931)", async () => {
287
+ await inviteEmail(CAROL_EMAIL, "Editor");
288
+
289
+ const list = (await stack.http.queryOk(
290
+ "tenant:query:invitations",
291
+ {},
292
+ aliceSession(),
293
+ )) as Array<{ email: string; invitedBy: string; status: string }>;
294
+
295
+ expect(list).toHaveLength(1);
296
+ expect(isPiiCiphertext(list[0]?.email)).toBe(false);
297
+ expect(list[0]?.email).toBe(CAROL_EMAIL);
298
+ expect(isPiiCiphertext(list[0]?.invitedBy)).toBe(false);
299
+ expect(list[0]?.invitedBy).toBe(aliceId);
300
+ });
285
301
  });
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,33 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // Same extension-point/provider-feature pattern as file-foundation — see
4
+ // r.describe below for what this feature does. Unlike `fileProvider`,
5
+ // renderer selection is MIME-type-deterministic, not per-tenant
6
+ // configurable, so this feature has no `r.config` and no
7
+ // `r.extensionSelector`.
8
+
9
+ import { defineFeature, EXT_DERIVATIVE_RENDERER } from "@cosmicdrift/kumiko-framework/engine";
10
+
11
+ const FEATURE_NAME = "file-derivatives";
12
+
13
+ export const fileDerivativesFeature = defineFeature(FEATURE_NAME, (r) => {
14
+ r.describe(
15
+ "Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws.",
16
+ );
17
+ r.uiHints({
18
+ displayLabel: "File Derivatives",
19
+ category: "storage",
20
+ recommended: false,
21
+ });
22
+ // Needs a storage provider to read the original + write the variant —
23
+ // without file-foundation there's nothing to derive from or write to.
24
+ r.requires("file-foundation");
25
+
26
+ r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
27
+ onRegister: () => {
28
+ // No side-effects at register-time — resolution (exact MIME match,
29
+ // then `<type>/*` wildcard) happens at request-time in
30
+ // resolveRenderer, mirrors file-foundation's fileProvider point.
31
+ },
32
+ });
33
+ });
@@ -0,0 +1,2 @@
1
+ export { fileDerivativesFeature } from "./feature";
2
+ export { card, full, hero, thumb } from "./presets";
@@ -0,0 +1,6 @@
1
+ import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
2
+
3
+ export const thumb = { maxEdge: 160, fit: "cover", format: "webp" } as const satisfies VariantSpec;
4
+ export const card = { maxEdge: 640, fit: "inside", format: "webp" } as const satisfies VariantSpec;
5
+ export const hero = { maxEdge: 1600, fit: "inside", format: "webp" } as const satisfies VariantSpec;
6
+ export const full = { maxEdge: 2560, fit: "inside", format: "webp" } as const satisfies VariantSpec;
@@ -35,9 +35,14 @@ export const invitationsQuery = defineQueryHandler({
35
35
  typeof email === "string"
36
36
  ? await decryptStoredPii(email, "email", "tenant:invitations")
37
37
  : email;
38
- // invitedBy is a plain userId (invitation-table.ts: no `pii: true`) —
39
- // never encrypted at write time, so it needs no decrypt (#1252).
40
- return { ...row, email: decryptedEmail };
38
+ const invitedBy = row["invitedBy"];
39
+ // `userOwned` alone marks a field as a PII subject field, so invitedBy
40
+ // is encrypted at write time even without `pii: true`.
41
+ const decryptedInvitedBy =
42
+ typeof invitedBy === "string"
43
+ ? await decryptStoredPii(invitedBy, "invitedBy", "tenant:invitations")
44
+ : invitedBy;
45
+ return { ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy };
41
46
  });
42
47
  },
43
48
  });
@@ -14,14 +14,18 @@
14
14
  // - Orphan-User (0 Memberships): user-Profil-Hook laeuft trotzdem
15
15
  // ueber Pseudo-Tenant.
16
16
 
17
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
18
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
19
19
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
20
  import {
21
+ configureBlindIndexKey,
21
22
  configurePiiSubjectKms,
22
23
  encryptPiiFieldValues,
23
24
  InMemoryKmsAdapter,
25
+ isPiiCiphertext,
24
26
  } from "@cosmicdrift/kumiko-framework/crypto";
27
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
28
+ import { createSystemUser, type UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
25
29
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
26
30
  import {
27
31
  setupTestStack,
@@ -30,17 +34,21 @@ import {
30
34
  unsafePushTables,
31
35
  } from "@cosmicdrift/kumiko-framework/stack";
32
36
  import {
37
+ resetBlindIndexKeyForTests,
33
38
  resetPiiSubjectKmsForTests,
34
39
  resetTestTables,
35
40
  seedRow,
36
41
  } from "@cosmicdrift/kumiko-framework/testing";
37
42
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
38
43
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
44
+ import { configValueEntity, createConfigFeature } from "../../config";
39
45
  import { createDataRetentionFeature } from "../../data-retention";
40
46
  import { createFilesFeature } from "../../files";
41
47
  import { createSessionsFeature, userSessionEntity } from "../../sessions";
48
+ import { createTenantFeature, tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
42
49
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
43
50
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
51
+ import { tenantInvitationExportHook } from "../../user-data-rights-defaults/hooks/tenant-invitation.userdata-hook";
44
52
  import { createUserDataRightsFeature } from "../feature";
45
53
  import { runUserExport } from "../run-user-export";
46
54
 
@@ -67,6 +75,8 @@ beforeAll(async () => {
67
75
  createComplianceProfilesFeature(),
68
76
  authFoundationFeature,
69
77
  createSessionsFeature(),
78
+ createConfigFeature(),
79
+ createTenantFeature(),
70
80
  createUserDataRightsFeature(),
71
81
  createUserDataRightsDefaultsFeature(),
72
82
  ],
@@ -74,6 +84,8 @@ beforeAll(async () => {
74
84
 
75
85
  await unsafeCreateEntityTable(stack.db, userEntity);
76
86
  await unsafeCreateEntityTable(stack.db, userSessionEntity);
87
+ await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
88
+ await unsafeCreateEntityTable(stack.db, configValueEntity);
77
89
  await asRawClient(stack.db).unsafe(`
78
90
  CREATE TABLE IF NOT EXISTS read_tenant_memberships (
79
91
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -101,7 +113,12 @@ afterAll(async () => {
101
113
  });
102
114
 
103
115
  beforeEach(async () => {
104
- await resetTestTables(stack.db, [userTable, "read_tenant_memberships", fileRefsTable]);
116
+ await resetTestTables(stack.db, [
117
+ userTable,
118
+ "read_tenant_memberships",
119
+ fileRefsTable,
120
+ tenantInvitationsTable,
121
+ ]);
105
122
  });
106
123
 
107
124
  const NOW = () => getTemporal().Now.instant();
@@ -365,3 +382,88 @@ describe("runUserExport :: PII-Subject-Ciphertexte (#820)", () => {
365
382
  expect(json).toContain("[encrypted:unavailable]");
366
383
  });
367
384
  });
385
+
386
+ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
387
+ const BIDX_KEY = Buffer.alloc(32, 9).toString("base64");
388
+ const INVITE_EMAIL = "alice.invite@example.com";
389
+
390
+ afterEach(() => {
391
+ resetPiiSubjectKmsForTests();
392
+ resetBlindIndexKeyForTests();
393
+ });
394
+
395
+ test("tenant-invitation export snippet contains plaintext email even though the hook does not decrypt locally", async () => {
396
+ // KMS + bidx MUST be configured before the executor write so the row
397
+ // lands encrypted with a real blind index, matching the KMS-active
398
+ // production shape (order matches anonymous-deletion-kms.integration.test.ts).
399
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
400
+ configureBlindIndexKey(BIDX_KEY);
401
+
402
+ await seedUser(ALICE_ID, { email: INVITE_EMAIL, displayName: "Alice Invite" });
403
+ await seedMembership(ALICE_ID, TENANT_A);
404
+
405
+ // Real executor write path — the same one tenantInvitationExportHook's
406
+ // sibling delete hook and the invite-create handler use — so the row
407
+ // carries genuine ciphertext + a real email_bidx, not a hand-seeded stand-in.
408
+ const invitationExecutor = createEventStoreExecutor(
409
+ tenantInvitationsTable,
410
+ tenantInvitationEntity,
411
+ {
412
+ entityName: "tenant-invitation",
413
+ },
414
+ );
415
+ const systemUser = createSystemUser(TENANT_A);
416
+ const tenantDb = createTenantDb(stack.db, TENANT_A, "system");
417
+ const createResult = await invitationExecutor.create(
418
+ {
419
+ email: INVITE_EMAIL,
420
+ role: "Member",
421
+ status: "pending",
422
+ invitedBy: ALICE_ID,
423
+ expiresAt: NOW().add({ seconds: 7 * 24 * 60 * 60 }),
424
+ },
425
+ systemUser,
426
+ tenantDb,
427
+ );
428
+ expect(createResult.isSuccess).toBe(true);
429
+ const invitationId = (createResult as { data: { id: string } }).data.id;
430
+
431
+ const rawRows = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
432
+ `SELECT email, email_bidx FROM read_tenant_invitations WHERE id = $1`,
433
+ [invitationId],
434
+ );
435
+ expect(isPiiCiphertext(rawRows[0]?.["email"])).toBe(true);
436
+ expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
437
+
438
+ // Pins the hook boundary directly: tenantInvitationExportHook itself must
439
+ // hand back ciphertext, not plaintext — decryption is the central sweep's
440
+ // job (decryptSnippetFields in run-user-export.ts), not the hook's. If the
441
+ // hook ever started decrypting locally, this assertion would catch it even
442
+ // though the runUserExport() assertion below would stay green either way.
443
+ const hookCtx: UserDataHookCtx = {
444
+ db: stack.db,
445
+ registry: stack.registry,
446
+ tenantId: TENANT_A,
447
+ userId: ALICE_ID,
448
+ };
449
+ const rawSnippet = await tenantInvitationExportHook(hookCtx);
450
+ expect(isPiiCiphertext(rawSnippet?.rows[0]?.["email"])).toBe(true);
451
+
452
+ const bundle = await runUserExport({
453
+ db: stack.db,
454
+ registry: stack.registry,
455
+ userId: ALICE_ID,
456
+ now: NOW(),
457
+ });
458
+
459
+ const tenantA = bundle.tenants.find((t) => t.tenantId === TENANT_A);
460
+ expect(tenantA).toBeDefined();
461
+ // Critical check: the entity-name-keyed selectMany lookup must actually
462
+ // find the row via the blind-index OR-branch — a silent miss here would
463
+ // mean the hook found nothing and the bidx hypothesis is wrong.
464
+ const invitationSnippet = tenantA?.entities.find((e) => e.entity === "tenant-invitation");
465
+ expect(invitationSnippet).toBeDefined();
466
+ expect(invitationSnippet?.rows).toHaveLength(1);
467
+ expect(invitationSnippet?.rows[0]?.["email"]).toBe(INVITE_EMAIL);
468
+ });
469
+ });