@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.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 (81) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
  3. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
  4. package/src/auth-email-password/feature.ts +9 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
  6. package/src/auth-email-password/handlers/login.write.ts +10 -5
  7. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
  8. package/src/auth-mfa/feature.ts +3 -0
  9. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  10. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
  11. package/src/compliance-profiles/feature.ts +18 -5
  12. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  13. package/src/compliance-profiles/index.ts +1 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  15. package/src/config/__tests__/require-system-db.test.ts +46 -0
  16. package/src/config/feature.ts +28 -0
  17. package/src/config/handlers/cascade.query.ts +2 -2
  18. package/src/config/handlers/readiness.query.ts +19 -6
  19. package/src/config/handlers/reencrypt.job.ts +79 -21
  20. package/src/config/handlers/values.query.ts +2 -2
  21. package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
  22. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  23. package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
  24. package/src/delivery/feature.ts +13 -3
  25. package/src/delivery/handlers/log.query.ts +10 -4
  26. package/src/delivery/handlers/preferences.query.ts +14 -1
  27. package/src/delivery/handlers/set-preference.write.ts +12 -1
  28. package/src/delivery/index.ts +1 -1
  29. package/src/delivery/jobs.ts +20 -10
  30. package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
  31. package/src/derivatives-sharp/render.ts +55 -15
  32. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  33. package/src/feature-toggles/constants.ts +6 -0
  34. package/src/feature-toggles/handlers/list.query.ts +12 -1
  35. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  36. package/src/feature-toggles/handlers/set.write.ts +18 -3
  37. package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
  38. package/src/file-derivatives/feature.ts +12 -13
  39. package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
  40. package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
  41. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
  42. package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
  43. package/src/form-draft/constants.ts +13 -0
  44. package/src/form-draft/db/queries/cleanup.ts +4 -1
  45. package/src/form-draft/db/queries/draft-count.ts +20 -0
  46. package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
  47. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
  48. package/src/form-draft/handlers/cleanup.job.ts +6 -0
  49. package/src/form-draft/handlers/discard.write.ts +6 -0
  50. package/src/form-draft/handlers/save.write.ts +15 -2
  51. package/src/form-draft/schemas.ts +16 -2
  52. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  53. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  54. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
  55. package/src/jobs/handlers/detail.query.ts +7 -1
  56. package/src/jobs/handlers/list.query.ts +8 -1
  57. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  58. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  59. package/src/jobs/handlers/retry.write.ts +7 -1
  60. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
  61. package/src/personal-access-tokens/feature.ts +4 -1
  62. package/src/personal-access-tokens/roles.ts +11 -3
  63. package/src/readiness/handlers/status.query.ts +2 -1
  64. package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
  65. package/src/sessions/feature.ts +18 -10
  66. package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
  67. package/src/sessions/index.ts +1 -1
  68. package/src/sessions/session-callbacks.ts +7 -1
  69. package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
  70. package/src/tenant/handlers/remove-member.write.ts +23 -1
  71. package/src/tenant/handlers/update-member-roles.write.ts +23 -1
  72. package/src/tenant/handlers/update.write.ts +35 -2
  73. package/src/tenant/invitation-table.ts +8 -1
  74. package/src/user/__tests__/user.integration.test.ts +21 -0
  75. package/src/user/handlers/create.write.ts +10 -3
  76. package/src/user/handlers/find-for-auth.query.ts +9 -1
  77. package/src/user/handlers/me.query.ts +10 -1
  78. package/src/user/handlers/update.write.ts +12 -2
  79. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
  80. package/src/user-data-rights/feature.ts +10 -7
  81. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
@@ -71,7 +71,14 @@ export const tenantInvitationEntity = createEntity({
71
71
  }),
72
72
  // userId des einladenden Admins (für Audit-Trail "wer hat eingeladen").
73
73
  // Self-referencing ownerField: the field's own value IS the owner userId.
74
- invitedBy: createTextField({ required: true, userOwned: { ownerField: "invitedBy" } }),
74
+ // lookupable: the delete-hook filters "invitations sent by this user"
75
+ // via the generated blind-index column instead of a full-tenant
76
+ // decrypt-and-scan (see user-data-rights-defaults' invitation hook).
77
+ invitedBy: createTextField({
78
+ required: true,
79
+ userOwned: { ownerField: "invitedBy" },
80
+ lookupable: true,
81
+ }),
75
82
  // UI-Anzeige — Wahrheit liegt in Redis-TTL.
76
83
  expiresAt: createTimestampField({ required: true }),
77
84
  },
@@ -1,10 +1,12 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import {
4
5
  createTestUser,
5
6
  setupTestStack,
6
7
  type TestStack,
7
8
  TestUsers,
9
+ testTenantId,
8
10
  unsafeCreateEntityTable,
9
11
  } from "@cosmicdrift/kumiko-framework/stack";
10
12
  import { expectErrorIncludes, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
@@ -151,6 +153,25 @@ describe("scenario 2b: find-for-auth is system-only", () => {
151
153
  expect(body).toContain("access_denied");
152
154
  expect(body).not.toContain("must-never-leave-the-server");
153
155
  });
156
+
157
+ // The "system" role reaches find-for-auth from any tenant — user rows are
158
+ // tenant-agnostic identity records, not scoped to the caller's own tenant.
159
+ test("system caller from a different tenant still resolves the user", async () => {
160
+ const created = await seedUser({
161
+ email: "cross-tenant@example.com",
162
+ displayName: "CrossTenant",
163
+ passwordHash: "cross-tenant-hash",
164
+ });
165
+
166
+ const foreignSystemCaller = createSystemUser(testTenantId(99));
167
+ const result = await stack.http.queryOk<{ id: number } | null>(
168
+ UserQueries.findForAuth,
169
+ { email: "cross-tenant@example.com" },
170
+ foreignSystemCaller,
171
+ );
172
+
173
+ expect(result).toMatchObject({ id: created.id });
174
+ });
154
175
  });
155
176
 
156
177
  // --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
@@ -1,7 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
- import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
6
6
  import { z } from "zod";
7
7
  import { UserErrors } from "../constants";
@@ -35,7 +35,14 @@ export const createWrite = defineWriteHandler({
35
35
  }),
36
36
  access: { roles: ["system", "SystemAdmin"] },
37
37
  handler: async (event, ctx) => {
38
- const existing = await fetchOne<{ id: string }>(ctx.db, userTable, {
38
+ if (!ctx.systemDb) {
39
+ throw new InternalError({ message: "user:create requires r.systemScope()" });
40
+ }
41
+ const db = ctx.systemDb.acknowledgeCrossTenant(
42
+ "user rows are tenant-agnostic identity records; uniqueness check and create need no tenant filter",
43
+ );
44
+
45
+ const existing = await fetchOne<{ id: string }>(db, userTable, {
39
46
  email: event.payload.email,
40
47
  });
41
48
 
@@ -49,6 +56,6 @@ export const createWrite = defineWriteHandler({
49
56
  );
50
57
  }
51
58
 
52
- return crud.create(event.payload, event.user, ctx.db);
59
+ return crud.create(event.payload, event.user, db);
53
60
  },
54
61
  });
@@ -1,5 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
4
5
  import { userTable } from "../schema/user";
5
6
 
@@ -33,6 +34,13 @@ export const findForAuthQuery = defineQueryHandler({
33
34
  ? { email: query.payload.email }
34
35
  : { id: query.payload.id as string }; // @cast-boundary engine-payload
35
36
 
36
- return (await fetchOne(ctx.db, userTable, where)) ?? null;
37
+ if (!ctx.systemDb) {
38
+ throw new InternalError({ message: "user:find-for-auth requires r.systemScope()" });
39
+ }
40
+ const db = ctx.systemDb.acknowledgeCrossTenant(
41
+ "identity resolution for auth is intentionally cross-tenant",
42
+ );
43
+
44
+ return (await fetchOne(db, userTable, where)) ?? null;
37
45
  },
38
46
  });
@@ -1,5 +1,6 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
4
5
  import { userEntity, userTable } from "../schema/user";
5
6
 
@@ -11,5 +12,13 @@ export const meQuery = defineQueryHandler({
11
12
  name: "user:me",
12
13
  schema: z.object({}),
13
14
  access: { openToAll: true },
14
- handler: async (query, ctx) => crud.detail({ id: query.user.id }, query.user, ctx.db), // @wrapper-known semantic-alias
15
+ handler: async (query, ctx) => {
16
+ if (!ctx.systemDb) {
17
+ throw new InternalError({ message: "user:me requires r.systemScope()" });
18
+ }
19
+ const db = ctx.systemDb.acknowledgeCrossTenant(
20
+ "user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter",
21
+ );
22
+ return crud.detail({ id: query.user.id }, query.user, db); // @wrapper-known semantic-alias
23
+ },
15
24
  });
@@ -1,6 +1,10 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
3
- import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import {
4
+ AccessDeniedError,
5
+ InternalError,
6
+ writeFailure,
7
+ } from "@cosmicdrift/kumiko-framework/errors";
4
8
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
5
9
  import { z } from "zod";
6
10
  import { UserErrors } from "../constants";
@@ -51,6 +55,12 @@ export const updateWrite = defineWriteHandler({
51
55
  }),
52
56
  );
53
57
  }
54
- return crud.update(event.payload, event.user, ctx.db);
58
+ if (!ctx.systemDb) {
59
+ throw new InternalError({ message: "user:update requires r.systemScope()" });
60
+ }
61
+ const db = ctx.systemDb.acknowledgeCrossTenant(
62
+ "user rows are tenant-agnostic identity records; self/admin update needs no tenant filter",
63
+ );
64
+ return crud.update(event.payload, event.user, db);
55
65
  },
56
66
  });
@@ -489,11 +489,10 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
489
489
  );
490
490
  expect(foreignTenantResult.isSuccess).toBe(true);
491
491
 
492
- // Pins the hook boundary directly: tenantInvitationExportHook itself must
493
- // hand back ciphertext, not plaintext — decryption is the central sweep's
494
- // job (decryptSnippetFields in run-user-export.ts), not the hook's. If the
495
- // hook ever started decrypting locally, this assertion would catch it even
496
- // though the runUserExport() assertion below would stay green either way.
492
+ // Pins the hook boundary: invitation `email` is subject-KMS PII, not a
493
+ // field-encrypted column, so decryptSnippetFields would leave ciphertext
494
+ // in the GDPR bundle. The hook decrypts locally (same as invitations
495
+ // query) — assert plaintext here so a regression to raw ciphertext fails.
497
496
  const hookCtx: UserDataHookCtx = {
498
497
  db: stack.db,
499
498
  registry: stack.registry,
@@ -501,7 +500,8 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
501
500
  userId: ALICE_ID,
502
501
  };
503
502
  const rawSnippet = await tenantInvitationExportHook(hookCtx);
504
- expect(isPiiCiphertext(rawSnippet?.rows[0]?.["email"])).toBe(true);
503
+ expect(isPiiCiphertext(rawSnippet?.rows[0]?.["email"])).toBe(false);
504
+ expect(rawSnippet?.rows[0]?.["email"]).toBe(INVITE_EMAIL);
505
505
 
506
506
  const bundle = await runUserExport({
507
507
  db: stack.db,
@@ -281,18 +281,21 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
281
281
 
282
282
  r.translations({ keys: USER_DATA_RIGHTS_I18N });
283
283
 
284
- // Dormant Self-Service-Screen (Art. 15/17/18/20): Export, Aktivitäts-
285
- // protokoll, Einschränkung, Löschung in einem Screen. Kein r.nav — die
286
- // App platziert ihn im eingeloggten Bereich. Die React-Component kommt
287
- // client-seitig aus userDataRightsClient() (web/). access openToAll, weil
288
- // kein App-Rollenname portabel ist; die per-User-Handler erzwingen Auth
289
- // server-seitig, und der Screen ist ohne r.nav nirgends sichtbar bis die
290
- // App ihn aktiv im authed-Bereich verlinkt.
284
+ // Dormant self-service screen (Art. 15/17/18/20): export, activity log,
285
+ // restriction, deletion in one screen. No r.nav — the app places it in
286
+ // its logged-in area. The React component comes from userDataRightsClient()
287
+ // (web/) client-side. access is openToAll because no app role name is
288
+ // portable; the per-user handlers enforce auth server-side, and the
289
+ // screen is invisible without r.nav until the app actively links it in
290
+ // its authed area. dormant: true so createKumikoApp's boot diagnostic
291
+ // (#2025) doesn't flag apps that haven't (yet) navved the screen as
292
+ // having a missing client plugin (#2034).
291
293
  r.screen({
292
294
  id: PRIVACY_CENTER_SCREEN_ID,
293
295
  type: "custom",
294
296
  renderer: { react: { __component: "PrivacyCenterScreen" } },
295
297
  access: { openToAll: true },
298
+ dormant: true,
296
299
  });
297
300
 
298
301
  // Magic-link path (anonymous): the email link carries the token as a
@@ -6,7 +6,7 @@ import {
6
6
  type UserDataExportHook,
7
7
  type UserDataHookCtx,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import { decryptStoredPii } from "../../shared";
9
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared";
10
10
  import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
11
11
  import { userTable } from "../../user";
12
12
  import { featureMounted } from "./feature-mounted";
@@ -34,6 +34,11 @@ const crud = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEn
34
34
 
35
35
  const INVITED_BY_ANONYMIZED = "anonymized";
36
36
 
37
+ // Bounded, not Promise.all — each decrypt hits the KMS adapter's own small
38
+ // dedicated pool (PgKmsAdapter default max: 4). Same reasoning as
39
+ // tenant/handlers/invitations.query.ts's KMS_POOL_CONCURRENCY.
40
+ const KMS_POOL_CONCURRENCY = 4;
41
+
37
42
  async function resolveUserEmail(ctx: UserDataHookCtx): Promise<string | null> {
38
43
  if (ctx.userEmailBeforeDelete) return ctx.userEmailBeforeDelete.toLowerCase();
39
44
  const row = (await fetchOne(ctx.db, userTable, { id: ctx.userId })) as {
@@ -59,12 +64,23 @@ export const tenantInvitationExportHook: UserDataExportHook = async (ctx) => {
59
64
  if (rows.length === 0) return null;
60
65
  return {
61
66
  entity: "tenant-invitation",
62
- rows: rows.map((r) => ({
63
- email: r["email"],
64
- role: r["role"],
65
- status: r["status"],
66
- expiresAt: String(r["expiresAt"] ?? ""),
67
- })),
67
+ // Stored `email` is subject-KMS ciphertext (pii:true, not field-encrypted).
68
+ // decryptSnippetFields only sweeps collectEncryptedFieldNames — it would
69
+ // leave this as a base64 blob in the GDPR export. Decrypt here, same as
70
+ // invitations.query.ts and resolveUserEmail above.
71
+ rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, async (r) => {
72
+ const rawEmail = r["email"];
73
+ const decryptedEmail =
74
+ typeof rawEmail === "string"
75
+ ? await decryptStoredPii(rawEmail, "email", "user-data-rights:invitation-hook")
76
+ : rawEmail;
77
+ return {
78
+ email: decryptedEmail,
79
+ role: r["role"],
80
+ status: r["status"],
81
+ expiresAt: String(r["expiresAt"] ?? ""),
82
+ };
83
+ }),
68
84
  };
69
85
  };
70
86
 
@@ -98,28 +114,19 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
98
114
  }
99
115
  }
100
116
 
101
- // `invitedBy` has no `lookupable`/blind-index column — it's ciphertext
102
- // under an active KMS, so `{ invitedBy: ctx.userId }` would compare a
103
- // plaintext userId against an encrypted column and always match zero
104
- // rows. Load every invitation in the tenant instead (small per-tenant
105
- // row count) and decrypt `invitedBy` per row to find this user's own
106
- // invites-sent. See tenant/handlers/invitations.query.ts for the same
107
- // decrypt-on-read pattern.
108
- const tenantInvitations = await selectMany<Record<string, unknown>>(
109
- ctx.db,
110
- tenantInvitationsTable,
111
- { tenantId: ctx.tenantId },
112
- );
113
- for (const row of tenantInvitations) {
117
+ // `invitedBy` is `lookupable` — the query compiler matches the plaintext
118
+ // filter value against the generated blind-index column
119
+ // (`invitedByBidx`), so this selects only this user's own invites-sent
120
+ // without loading + decrypting every invitation in the tenant. See
121
+ // tenant/handlers/invitations.query.ts for the same lookupable-filter
122
+ // pattern on `email`.
123
+ const inviterRows = await selectMany<Record<string, unknown>>(ctx.db, tenantInvitationsTable, {
124
+ tenantId: ctx.tenantId,
125
+ invitedBy: ctx.userId,
126
+ });
127
+ for (const row of inviterRows) {
114
128
  const id = row["id"]; // @cast-boundary db-row
115
- const invitedByRaw = row["invitedBy"];
116
- if (typeof id !== "string" || typeof invitedByRaw !== "string") continue;
117
- const invitedBy = await decryptStoredPii(
118
- invitedByRaw,
119
- "invitedBy",
120
- "user-data-rights:invitation-hook",
121
- );
122
- if (invitedBy !== ctx.userId) continue;
129
+ if (typeof id !== "string") continue;
123
130
  await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, tdb, {
124
131
  skipOptimisticLock: true,
125
132
  });