@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.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 (125) hide show
  1. package/package.json +6 -8
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
@@ -44,24 +44,34 @@ const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
44
44
 
45
45
  /** Stub-SecretsContext: liest aus einer in-memory-map, matcht per
46
46
  * qualified-name. backing:"secrets" persistiert config-Werte JSON-
47
- * serialisiert — der Stub spiegelt das per Default, damit der Runtime-
48
- * parseStoredSecret denselben Pfad nimmt wie gegen den echten Store.
49
- * `raw: true` speichert UN-serialisiert (482/1) um parseStoredSecret's
50
- * Fehlerpfad zu treffen: ein Credential, das der Store un-JSON-kodiert
51
- * zurückgibt (Korruption oder außerhalb des backing:"secrets"-Roundtrips
52
- * geschrieben) muss laut failen, nicht still Müll liefern. Ignoriert
53
- * auditCtx. */
54
- function stubSecrets(
55
- values: Record<string, string>,
56
- opts?: { readonly raw?: boolean },
57
- ): SecretsContext {
47
+ * serialisiert — der Stub spiegelt das, damit der Runtime-parseStoredSecret
48
+ * denselben Pfad nimmt wie gegen den echten Store. Ignoriert auditCtx. */
49
+ function stubSecrets(values: Record<string, string>): SecretsContext {
58
50
  const nameOf = (k: string | { readonly name: string }): string =>
59
51
  typeof k === "string" ? k : k.name;
60
52
  return {
61
53
  get: async (_tenantId, key) => {
62
54
  const value = values[nameOf(key)];
63
- if (value === undefined) return undefined;
64
- return createSecret(opts?.raw ? value : JSON.stringify(value));
55
+ return value === undefined ? undefined : createSecret(JSON.stringify(value));
56
+ },
57
+ has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
58
+ set: async () => undefined,
59
+ delete: async () => false,
60
+ };
61
+ }
62
+
63
+ /** Wie stubSecrets, aber speichert den Wert ROH (kein JSON.stringify) — um
64
+ * parseStoredSecret's Fehlerpfad zu treffen: ein Credential, das der Store
65
+ * un-JSON-kodiert zurückgibt (Korruption oder ein außerhalb des
66
+ * backing:"secrets"-Roundtrips geschriebener Wert) muss laut failen, nicht
67
+ * still Müll liefern. */
68
+ function rawSecretsStub(values: Record<string, string>): SecretsContext {
69
+ const nameOf = (k: string | { readonly name: string }): string =>
70
+ typeof k === "string" ? k : k.name;
71
+ return {
72
+ get: async (_tenantId, key) => {
73
+ const value = values[nameOf(key)];
74
+ return value === undefined ? undefined : createSecret(value); // RAW, not JSON
65
75
  },
66
76
  has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
67
77
  set: async () => undefined,
@@ -154,7 +164,7 @@ describe("StripeCtxRuntime.clientForCtx", () => {
154
164
  // throw, not silently fall through to undefined/fallback.
155
165
  const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
156
166
  const ctx = stubCtx({
157
- secrets: stubSecrets({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }, { raw: true }),
167
+ secrets: rawSecretsStub({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }),
158
168
  });
159
169
  await expect(rt.ctx.clientForCtx(ctx)).rejects.toThrow(
160
170
  /Invalid JSON in subscription-stripe credential/,
@@ -20,7 +20,7 @@ import {
20
20
  type SubscriptionProviderPlugin,
21
21
  subscriptionAggregateId,
22
22
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
23
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
23
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
24
24
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
25
25
  import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
26
26
  import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
@@ -31,7 +31,6 @@ import {
31
31
  testTenantId,
32
32
  unsafePushTables,
33
33
  } from "@cosmicdrift/kumiko-framework/stack";
34
- import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
35
34
  import { Hono } from "hono";
36
35
  import Stripe from "stripe";
37
36
  import { configValuesTable, createConfigFeature } from "../../config";
@@ -80,8 +79,8 @@ beforeAll(async () => {
80
79
  priceToTier: PRICE_TO_TIER,
81
80
  });
82
81
 
83
- const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
84
- const resolver = createConfigResolver({ cipher: encryption });
82
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
83
+ const resolver = createConfigResolver({ encryption });
85
84
  const masterKeyProvider = createEnvMasterKeyProvider({
86
85
  env: {
87
86
  KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
@@ -471,8 +470,8 @@ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
471
470
 
472
471
  beforeAll(async () => {
473
472
  const stripeFeature = createSubscriptionStripeFeature({ priceToTier: PRICE_TO_TIER });
474
- const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
475
- const resolver = createConfigResolver({ cipher: encryption });
473
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
474
+ const resolver = createConfigResolver({ encryption });
476
475
  const masterKeyProvider = createEnvMasterKeyProvider({
477
476
  env: {
478
477
  KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
@@ -358,11 +358,6 @@ describe("tags integration — multi-tenant isolation", () => {
358
358
  // "Admin", not "TenantAdmin"). Default-mounted tags deny that same user.
359
359
  describe("tags integration — openToAll access model", () => {
360
360
  let openStack: TestStack;
361
- // Dedicated stack (477/1), not the outer file's `stack` — a describe-level
362
- // `--test-name-pattern` run of just this block would otherwise crash on an
363
- // undefined outer `stack` instead of failing cleanly, and this block
364
- // shouldn't depend on describe-execution order for its setup to exist.
365
- let defaultStack: TestStack;
366
361
  // role deliberately not in DEFAULT_TAG_ROLES nor "Admin" — proves openToAll,
367
362
  // not an accidental role match.
368
363
  const unprivileged = createTestUser({ roles: ["Viewer"] });
@@ -374,16 +369,10 @@ describe("tags integration — openToAll access model", () => {
374
369
  await unsafeCreateEntityTable(openStack.db, tagEntity);
375
370
  await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
376
371
  await createEventsTable(openStack.db);
377
-
378
- defaultStack = await setupTestStack({ features: [tagsFeature] });
379
- await unsafeCreateEntityTable(defaultStack.db, tagEntity);
380
- await unsafeCreateEntityTable(defaultStack.db, tagAssignmentEntity);
381
- await createEventsTable(defaultStack.db);
382
372
  });
383
373
 
384
374
  afterAll(async () => {
385
375
  await openStack.cleanup();
386
- await defaultStack.cleanup();
387
376
  });
388
377
 
389
378
  test("a non-tag-role user can create, assign, list and remove", async () => {
@@ -420,7 +409,7 @@ describe("tags integration — openToAll access model", () => {
420
409
  });
421
410
 
422
411
  test("the SAME user is denied on a default-role-mounted feature", async () => {
423
- const denied = await defaultStack.http.writeErr(
412
+ const denied = await stack.http.writeErr(
424
413
  TagsHandlers.createTag,
425
414
  { name: "nope" },
426
415
  unprivileged,
@@ -6,12 +6,7 @@ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/eng
6
6
  export const tagEntity = createEntity({
7
7
  table: "read_tags",
8
8
  fields: {
9
- // Catalog labels ("urgent", "billing"), not user-identifying content —
10
- // allowPlaintext silences the user-content heuristic (456/5). A tenant
11
- // COULD name a tag after a person; if that becomes a real requirement,
12
- // this needs a userOwned annotation + forget/export hooks in the
13
- // user-data-rights pipeline (none exist for tags today).
14
- name: createTextField({ required: true, maxLength: 64, allowPlaintext: "catalog-label" }),
9
+ name: createTextField({ required: true, maxLength: 64 }),
15
10
  // Optional UI hint (hex or token). No enforcement — purely for rendering.
16
11
  color: createTextField({ maxLength: 32 }),
17
12
  // Optional entity-type scope (GitLab project-vs-group labels): empty = global
@@ -41,17 +41,10 @@ export const deleteTagPayloadSchema = z.object({
41
41
  export type DeleteTagPayload = z.infer<typeof deleteTagPayloadSchema>;
42
42
 
43
43
  // assign + remove share the (tag, entity) reference shape.
44
- // entityType/entityId are app-supplied, unlike tagId (always a real tag's
45
- // UUID) — tagAssignmentAggregateId joins all four with "|" to derive the
46
- // stream id, so a literal "|" in either could shift tuple boundaries and
47
- // collide with an unrelated (tenant, tag, entity) combination (456/3).
48
- // Rejecting it here is cheaper and non-breaking compared to changing the
49
- // derivation's separator, which would re-key every existing stream.
50
- const NO_PIPE = /^[^|]*$/;
51
44
  const entityTagRef = {
52
45
  tagId: z.string().min(1).max(64),
53
- entityType: z.string().min(1).max(64).regex(NO_PIPE, 'entityType must not contain "|"'),
54
- entityId: z.string().min(1).max(128).regex(NO_PIPE, 'entityId must not contain "|"'),
46
+ entityType: z.string().min(1).max(64),
47
+ entityId: z.string().min(1).max(128),
55
48
  } as const;
56
49
 
57
50
  export const assignTagPayloadSchema = z.object(entityTagRef);
@@ -308,7 +308,7 @@ export function TagManager({
308
308
  <div className="flex gap-2">
309
309
  <Button
310
310
  variant="primary"
311
- disabled={busy || editName.trim() === ""}
311
+ disabled={busy}
312
312
  onClick={() => saveEdit(tag)}
313
313
  testId={`tag-manager-save-${tag.id}`}
314
314
  >
@@ -74,11 +74,10 @@ export async function assertConsumerHandlesNotFound(
74
74
  const { tenantId } = opts;
75
75
  let rejected = false;
76
76
  let err: unknown;
77
- const slug = `conformance-not-found-${crypto.randomUUID()}`;
78
77
  try {
79
78
  await consumer.resolve({
80
79
  tenantId,
81
- slug,
80
+ slug: "conformance-not-found-slug",
82
81
  kind: "mail-html",
83
82
  locale: "de",
84
83
  });
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
5
  import {
6
6
  createTestUser,
@@ -10,11 +10,7 @@ import {
10
10
  unsafeCreateEntityTable,
11
11
  unsafePushTables,
12
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
- import {
14
- createTestEnvelopeCipher,
15
- expectErrorIncludes,
16
- rolesOf,
17
- } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
18
14
  import { createConfigAccessor, createConfigFeature } from "../../config";
19
15
  import { ConfigHandlers, ConfigQueries } from "../../config/constants";
20
16
  import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
@@ -37,8 +33,8 @@ const tenantFeature = createTenantFeature();
37
33
  const testEncryptionKey = randomBytes(32).toString("base64");
38
34
 
39
35
  beforeAll(async () => {
40
- const encryption = createTestEnvelopeCipher(testEncryptionKey);
41
- resolver = createConfigResolver({ cipher: encryption });
36
+ const encryption = createEncryptionProvider(testEncryptionKey);
37
+ resolver = createConfigResolver({ encryption });
42
38
 
43
39
  stack = await setupTestStack({
44
40
  features: [configFeature, tenantFeature],
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
5
  import {
6
6
  createTestUser,
@@ -11,10 +11,7 @@ import {
11
11
  unsafeCreateEntityTable,
12
12
  unsafePushTables,
13
13
  } from "@cosmicdrift/kumiko-framework/stack";
14
- import {
15
- createTestEnvelopeCipher,
16
- expectErrorIncludes,
17
- } from "@cosmicdrift/kumiko-framework/testing";
14
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
18
15
  import { createConfigFeature } from "../../config";
19
16
  import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
20
17
  import { configValuesTable } from "../../config/table";
@@ -41,8 +38,8 @@ const tenantFeature = createTenantFeature();
41
38
  const testEncryptionKey = randomBytes(32).toString("base64");
42
39
 
43
40
  beforeAll(async () => {
44
- const encryption = createTestEnvelopeCipher(testEncryptionKey);
45
- resolver = createConfigResolver({ cipher: encryption });
41
+ const encryption = createEncryptionProvider(testEncryptionKey);
42
+ resolver = createConfigResolver({ encryption });
46
43
 
47
44
  stack = await setupTestStack({
48
45
  features: [configFeature, tenantFeature, tierEngineFeature],
@@ -109,32 +109,6 @@ describe("scenario 2: field-level read access", () => {
109
109
  });
110
110
  });
111
111
 
112
- // --- find-for-auth is system-only: it returns the raw row incl. passwordHash,
113
- // and every query handler is reachable via POST /api/query — so even a
114
- // SystemAdmin (assignable app role) must be denied over HTTP. ---
115
-
116
- describe("scenario 2b: find-for-auth is system-only", () => {
117
- test("SystemAdmin over HTTP is denied — passwordHash must not leak", async () => {
118
- await seedUser({
119
- email: "auth-lookup@example.com",
120
- displayName: "AuthLookup",
121
- passwordHash: "must-never-leave-the-server",
122
- });
123
-
124
- const res = await stack.http.queryWithHeaders(
125
- UserQueries.findForAuth,
126
- { email: "auth-lookup@example.com" },
127
- systemAdmin,
128
- {},
129
- );
130
-
131
- expect(res.status).toBeGreaterThanOrEqual(400);
132
- const body = await res.text();
133
- expect(body).toContain("access_denied");
134
- expect(body).not.toContain("must-never-leave-the-server");
135
- });
136
- });
137
-
138
112
  // --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
139
113
 
140
114
  describe("scenario 3: self-update + field-level write access", () => {
@@ -7,12 +7,10 @@ import { userTable } from "../schema/user";
7
7
  // by email OR id (exactly one, enforced by the schema). Used by the auth
8
8
  // features via ctx.queryAs(systemUser, ...).
9
9
  //
10
- // Access is system-only, NOT access.privileged: every query handler is
11
- // reachable via POST /api/query, and "SystemAdmin" is an assignable app
12
- // role privileged would hand any SystemAdmin the raw passwordHash over
13
- // HTTP. The "system" role can't be minted into a session (only
14
- // createSystemUser() carries it), so system-only keeps this handler
15
- // internal to ctx.queryAs(systemUser, ...) callers.
10
+ // Field-level read rules allow passwordHash for the "privileged" role set,
11
+ // so system callers see everything; any other caller is filtered even if
12
+ // they somehow reach this handler. Access is also restricted to privileged
13
+ // regular users or tenant admins cannot call this at all.
16
14
  export const findForAuthQuery = defineQueryHandler({
17
15
  name: "user:find-for-auth",
18
16
  schema: z
@@ -26,7 +24,7 @@ export const findForAuthQuery = defineQueryHandler({
26
24
  (v) => (v.email !== undefined) !== (v.id !== undefined),
27
25
  { message: "exactly one of email or id must be set" },
28
26
  ),
29
- access: { roles: access.system },
27
+ access: { roles: access.privileged },
30
28
  handler: async (query, ctx) => {
31
29
  const where =
32
30
  query.payload.email !== undefined
package/src/user/index.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  export { UserCommandSchemas } from "./command-schemas";
2
2
  export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants";
3
- export {
4
- backfillUserStreamTenants,
5
- type UserStreamBackfillResult,
6
- } from "./db/queries/stream-tenant-backfill";
7
3
  export { createUserFeature } from "./feature";
8
4
  export type { UserStatus } from "./schema/user";
9
5
  export {
@@ -10,6 +10,7 @@
10
10
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
13
14
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
14
15
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
16
  import {
@@ -24,11 +25,7 @@ import {
24
25
  unsafeCreateEntityTable,
25
26
  unsafePushTables,
26
27
  } from "@cosmicdrift/kumiko-framework/stack";
27
- import {
28
- createTestEnvelopeCipher,
29
- resetTestTables,
30
- updateRows,
31
- } from "@cosmicdrift/kumiko-framework/testing";
28
+ import { resetTestTables, updateRows } from "@cosmicdrift/kumiko-framework/testing";
32
29
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
33
30
  import {
34
31
  createComplianceProfilesFeature,
@@ -103,8 +100,8 @@ const noSignedUrlProviderFeature = defineFeature("test-no-signed-url-provider",
103
100
  });
104
101
 
105
102
  beforeAll(async () => {
106
- const encryption = createTestEnvelopeCipher(testEncryptionKey);
107
- const resolver = createConfigResolver({ cipher: encryption });
103
+ const encryption = createEncryptionProvider(testEncryptionKey);
104
+ const resolver = createConfigResolver({ encryption });
108
105
 
109
106
  stack = await setupTestStack({
110
107
  features: [
@@ -14,6 +14,7 @@
14
14
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { randomBytes } from "node:crypto";
16
16
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
17
18
  import {
18
19
  createRegistry,
19
20
  type Registry,
@@ -39,7 +40,6 @@ import {
39
40
  } from "@cosmicdrift/kumiko-framework/stack";
40
41
  import {
41
42
  createLateBoundHolder,
42
- createTestEnvelopeCipher,
43
43
  resetTestTables,
44
44
  updateRows,
45
45
  } from "@cosmicdrift/kumiko-framework/testing";
@@ -97,8 +97,8 @@ function buildFeatures() {
97
97
  }
98
98
 
99
99
  beforeAll(async () => {
100
- const encryption = createTestEnvelopeCipher(encryptionKey);
101
- const resolver = createConfigResolver({ cipher: encryption });
100
+ const encryption = createEncryptionProvider(encryptionKey);
101
+ const resolver = createConfigResolver({ encryption });
102
102
  const bound = sessionCallbacksFromLateBound(callbacks);
103
103
 
104
104
  stack = await setupTestStack({
@@ -13,6 +13,7 @@
13
13
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
14
  import { randomBytes } from "node:crypto";
15
15
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
16
17
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
17
18
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
19
  import {
@@ -25,7 +26,6 @@ import {
25
26
  } from "@cosmicdrift/kumiko-framework/stack";
26
27
  import {
27
28
  createLateBoundHolder,
28
- createTestEnvelopeCipher,
29
29
  resetTestTables,
30
30
  updateRows,
31
31
  } from "@cosmicdrift/kumiko-framework/testing";
@@ -65,8 +65,8 @@ const ALICE_EMAIL = "alice.restrict@example.com";
65
65
  const ALICE_PW = "alice-pw-long-enough";
66
66
 
67
67
  beforeAll(async () => {
68
- const encryption = createTestEnvelopeCipher(encryptionKey);
69
- const resolver = createConfigResolver({ cipher: encryption });
68
+ const encryption = createEncryptionProvider(encryptionKey);
69
+ const resolver = createConfigResolver({ encryption });
70
70
  const bound = sessionCallbacksFromLateBound(callbacks);
71
71
 
72
72
  stack = await setupTestStack({
@@ -249,7 +249,9 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
249
249
  r.queryHandler(myAuditLogQuery);
250
250
  r.queryHandler(listDownloadAttemptsQuery);
251
251
 
252
- // Read-only GDPR operator inspector (SystemAdmin) screens stay inert until an app navs them.
252
+ // Read-only operator inspector over the GDPR read-models (SystemAdmin).
253
+ // Convention list/detail handlers so entityList/entityEdit resolve by QN;
254
+ // the screens stay inert until an app navs them (opt-in at wire time).
253
255
  r.queryHandler(exportJobListQuery);
254
256
  r.queryHandler(exportJobDetailQuery);
255
257
  r.queryHandler(downloadAttemptListQuery);
@@ -63,11 +63,13 @@ export const downloadByJobQuery = defineQueryHandler({
63
63
  const userId = query.user.id;
64
64
  const jobId = query.payload.jobId;
65
65
  const tenantId = query.user.tenantId;
66
- // IP + UA aus dem request-scoped Kontext (von requestIdMiddleware
67
- // befuellt) — server-trusted, anders als ein vom Client mitgeschickter
68
- // Wert (603/2).
66
+ // IP aus dem request-scoped Kontext (von requestIdMiddleware aus
67
+ // x-forwarded-for befuellt) — server-trusted, anders als ein vom Client
68
+ // mitgeschickter Wert. UA steht nicht im RequestContext; der Audit-Row
69
+ // laesst sie null (best-effort, via requestId in den Server-Logs
70
+ // cross-referenzierbar).
69
71
  const auditIp = requestContext.get()?.ip ?? null;
70
- const auditUa = requestContext.get()?.userAgent ?? null;
72
+ const auditUa: string | null = null;
71
73
 
72
74
  // Step 1-2: job-lookup + cross-user-isolation
73
75
  // ctx.db.raw weil tenant-agnostisch — Alice in Tenant B sucht den
@@ -1,7 +1,7 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
- import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import { USER_STATUS, userEntity, userTable } from "../../user";
7
7
 
@@ -21,6 +21,16 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
21
21
  // kann abweichen).
22
22
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
23
23
 
24
+ // Stream-Tenant = die framework-injizierte tenant_id der read_users-Row. Der
25
+ // Reducer setzt sie aus `user.created`.tenantId, ein Rebuild rekonstruiert sie
26
+ // daraus — sie IST also der Stream-Key des Aggregats. Die Row direkt zu lesen
27
+ // (statt das created-Event zu joinen) deckt auch direkt-geseedete Rows ab.
28
+ async function streamTenantOf(conn: DbRunner, userId: string): Promise<TenantId | null> {
29
+ const row = await fetchOne<{ tenantId?: string }>(conn, userTable, { id: userId });
30
+ // @cast-boundary db-row — tenant_id ist eine TenantId-shaped uuid-Spalte.
31
+ return row?.tenantId ? (row.tenantId as TenantId) : null;
32
+ }
33
+
24
34
  // `conn` ist ctx.db.raw (regulaere Handler) ODER die offene tx (forget-cleanup
25
35
  // Sub-Tx) — so bleibt der Event-Append atomar mit dem umgebenden Write.
26
36
  export async function updateUserLifecycle(
@@ -28,14 +38,20 @@ export async function updateUserLifecycle(
28
38
  userId: string,
29
39
  changes: Record<string, unknown>,
30
40
  ): Promise<void> {
31
- // user ist systemStream (#497): der Executor-Choke-Point addressiert den
32
- // Stream immer auf SYSTEM_TENANT_ID — ein Rescope auf die row-tenant_id
33
- // waere wirkungslos. "system"-Mode, damit loadById auch Legacy-Rows findet,
34
- // deren tenant_id noch vor dem #762-Backfill-Rebuild steht.
35
- const tenantDb = createTenantDb(conn, SYSTEM_TENANT_ID, "system");
41
+ const streamTenantId = await streamTenantOf(conn, userId);
42
+ if (!streamTenantId) {
43
+ throw new InternalError({
44
+ message: `read_users row ${userId} has no tenant_id cannot rescope lifecycle write to its stream tenant`,
45
+ });
46
+ }
47
+
48
+ // Rescope BEIDE Achsen auf den Stream-Tenant: der db-Arg treibt loadById +
49
+ // den Stream-Read, der user-Arg die Event-tenantId + Ownership. Nur beide
50
+ // zusammen halten created + updated auf einem Stream.
51
+ const tenantDb = createTenantDb(conn, streamTenantId, "tenant");
36
52
  const result = await userExecutor.update(
37
53
  { id: userId, changes },
38
- createSystemUser(SYSTEM_TENANT_ID),
54
+ createSystemUser(streamTenantId),
39
55
  tenantDb,
40
56
  { skipOptimisticLock: true },
41
57
  );
@@ -316,7 +316,6 @@ async function processUser(args: {
316
316
  await entry.deleteHook(
317
317
  {
318
318
  db: tx,
319
- registry,
320
319
  tenantId,
321
320
  userId,
322
321
  buildStorageProvider,
@@ -22,11 +22,6 @@
22
22
 
23
23
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
24
24
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
25
- import {
26
- collectEncryptedFieldNames,
27
- configuredEntityFieldEncryption,
28
- decryptEntityFieldValues,
29
- } from "@cosmicdrift/kumiko-framework/db";
30
25
  import {
31
26
  EXT_USER_DATA,
32
27
  type Registry,
@@ -142,9 +137,8 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
142
137
  for (const tenantId of tenantList) {
143
138
  const entities: UserDataExportSnippet[] = [];
144
139
  for (const entry of hookEntries) {
145
- const rawSnippet = await entry.exportHook({ db, registry, tenantId, userId });
146
- if (rawSnippet === null) continue;
147
- const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
140
+ const snippet = await entry.exportHook({ db, tenantId, userId });
141
+ if (snippet === null) continue;
148
142
  entities.push(snippet);
149
143
  if (snippet.fileRefs) {
150
144
  for (const ref of snippet.fileRefs) {
@@ -175,14 +169,12 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
175
169
  if (tenantList.length === 0 && hookEntries.length > 0) {
176
170
  const orphanEntities: UserDataExportSnippet[] = [];
177
171
  for (const entry of hookEntries) {
178
- const rawSnippet = await entry.exportHook({
172
+ const snippet = await entry.exportHook({
179
173
  db,
180
- registry,
181
174
  tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
182
175
  userId,
183
176
  });
184
- if (rawSnippet === null) continue;
185
- const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
177
+ if (snippet === null) continue;
186
178
  orphanEntities.push(snippet);
187
179
  if (snippet.fileRefs) {
188
180
  for (const ref of snippet.fileRefs) {
@@ -211,37 +203,6 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
211
203
  };
212
204
  }
213
205
 
214
- // Art. 20 verlangt die DATEN, nicht deren Ciphertext: Export-Hooks lesen
215
- // raw rows an der Executor-Decrypt-Schicht vorbei, darum entschluesselt
216
- // dieser zentrale Pass encrypted entity fields nach jedem Hook. Ohne
217
- // konfigurierten Cipher wird ein expliziter Marker exportiert statt den
218
- // base64-Blob als "Wert" auszuliefern (leak-by-confusion).
219
- const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
220
-
221
- async function decryptSnippetFields(
222
- registry: Registry,
223
- hookEntityName: string,
224
- snippet: UserDataExportSnippet,
225
- ): Promise<UserDataExportSnippet> {
226
- const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
227
- if (!entity) return snippet;
228
- const encryptedFields = collectEncryptedFieldNames(entity);
229
- if (encryptedFields.size === 0) return snippet;
230
-
231
- const cipher = configuredEntityFieldEncryption();
232
- const rows = await Promise.all(
233
- snippet.rows.map(async (row) => {
234
- if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
235
- const out = { ...row };
236
- for (const name of encryptedFields) {
237
- if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
238
- }
239
- return out;
240
- }),
241
- );
242
- return { ...snippet, rows };
243
- }
244
-
245
206
  // Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
246
207
  // Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
247
208
  // Hooks finden hier nichts (no-op).
@@ -5,7 +5,7 @@
5
5
  // Provider-Wrapper lokal (Dependency-Richtung renderer-web → bundled-features
6
6
  // verbietet test-utils-Import).
7
7
 
8
- import { afterEach, describe, expect, test } from "bun:test";
8
+ import { describe, expect, test } from "bun:test";
9
9
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
10
10
  import {
11
11
  createStaticLocaleResolver,
@@ -27,12 +27,6 @@ const stubDispatcher = {
27
27
 
28
28
  const ROUTES = { requestPath: "/account/delete", confirmPath: "/account/delete/confirm" };
29
29
 
30
- // renderGate mutates the shared happy-dom document's history (570/1) — reset
31
- // so a later, unrelated test file doesn't inherit this suite's last path.
32
- afterEach(() => {
33
- window.history.replaceState({}, "", "/");
34
- });
35
-
36
30
  function renderGate(path: string, gate: ReactNode): ReturnType<typeof within> {
37
31
  window.history.replaceState({}, "", path);
38
32
  const { container } = render(
@@ -45,10 +45,5 @@ export function userDataRightsClient(
45
45
  },
46
46
  };
47
47
  if (options?.publicDeletion === undefined) return base;
48
- // Extend, not overwrite (570/2) — base.gates is empty today, but a spread
49
- // that clobbers it instead of appending would silently drop future gates.
50
- return {
51
- ...base,
52
- gates: [...(base.gates ?? []), makePublicDeletionGate(options.publicDeletion)],
53
- };
48
+ return { ...base, gates: [makePublicDeletionGate(options.publicDeletion)] };
54
49
  }