@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 +8 -7
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +16 -0
- package/src/file-derivatives/changes.json +1 -0
- package/src/file-derivatives/feature.ts +33 -0
- package/src/file-derivatives/index.ts +2 -0
- package/src/file-derivatives/presets.ts +6 -0
- package/src/tenant/handlers/invitations.query.ts +8 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +104 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
127
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-types": "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,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
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
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, [
|
|
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
|
+
});
|