@cosmicdrift/kumiko-bundled-features 0.166.0 → 0.167.1

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 (32) hide show
  1. package/package.json +7 -9
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +4 -2
  3. package/src/auth-email-password/__tests__/login-gates.test.ts +35 -1
  4. package/src/auth-email-password/auth-user-row.ts +2 -0
  5. package/src/auth-email-password/handlers/login.write.ts +9 -1
  6. package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +5 -6
  7. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +1 -1
  8. package/src/config/__tests__/pii-encrypted.integration.test.ts +1 -1
  9. package/src/config/__tests__/tz-resolution.integration.test.ts +101 -0
  10. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +2 -6
  11. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +1 -1
  12. package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +1 -1
  13. package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +2 -5
  14. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +1 -2
  15. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +2 -5
  16. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +2 -6
  17. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +2 -5
  18. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +1 -2
  19. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +6 -3
  20. package/src/sessions/__tests__/sessions.integration.test.ts +2 -2
  21. package/src/tenant/__tests__/members-query-kms.integration.test.ts +1 -2
  22. package/src/tier-engine/compose-app.ts +14 -0
  23. package/src/user/__tests__/user.integration.test.ts +2 -1
  24. package/src/user/handlers/create.write.ts +2 -0
  25. package/src/user/handlers/update.write.ts +2 -0
  26. package/src/user/i18n.ts +1 -0
  27. package/src/user/schema/user.ts +6 -1
  28. package/src/user/screens.ts +1 -1
  29. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +5 -3
  30. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +5 -6
  31. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +5 -2
  32. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.166.0",
3
+ "version": "0.167.1",
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>",
@@ -119,11 +119,12 @@
119
119
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
120
120
  },
121
121
  "dependencies": {
122
- "@cosmicdrift/kumiko-dispatcher-live": "0.166.0",
123
- "@cosmicdrift/kumiko-framework": "0.166.0",
124
- "@cosmicdrift/kumiko-headless": "0.166.0",
125
- "@cosmicdrift/kumiko-renderer": "0.166.0",
126
- "@cosmicdrift/kumiko-renderer-web": "0.166.0",
122
+ "@cosmicdrift/kumiko-dispatcher-live": "0.167.1",
123
+ "@cosmicdrift/kumiko-framework": "0.167.1",
124
+ "@cosmicdrift/kumiko-headless": "0.167.1",
125
+ "@cosmicdrift/kumiko-renderer": "0.167.1",
126
+ "@cosmicdrift/kumiko-renderer-web": "0.167.1",
127
+ "@cosmicdrift/kumiko-types": "0.167.1",
127
128
  "@mollie/api-client": "^4.5.0",
128
129
  "imapflow": "^1.3.3",
129
130
  "mailparser": "^3.9.8",
@@ -147,9 +148,6 @@
147
148
  "README.md",
148
149
  "LICENSE"
149
150
  ],
150
- "peerDependencies": {
151
- "@cosmicdrift/kumiko-types": "^0.166.0"
152
- },
153
151
  "devDependencies": {
154
152
  "@testing-library/user-event": "^14.6.1",
155
153
  "@types/mailparser": "^3.4.6",
@@ -13,8 +13,6 @@ import {
13
13
  decryptPiiFieldValues,
14
14
  InMemoryKmsAdapter,
15
15
  isPiiCiphertext,
16
- resetBlindIndexKeyForTests,
17
- resetPiiSubjectKmsForTests,
18
16
  } from "@cosmicdrift/kumiko-framework/crypto";
19
17
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
20
18
  import {
@@ -23,6 +21,10 @@ import {
23
21
  unsafeCreateEntityTable,
24
22
  unsafePushTables,
25
23
  } from "@cosmicdrift/kumiko-framework/stack";
24
+ import {
25
+ resetBlindIndexKeyForTests,
26
+ resetPiiSubjectKmsForTests,
27
+ } from "@cosmicdrift/kumiko-framework/testing";
26
28
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
27
29
  import { createConfigFeature } from "../../config";
28
30
  import { createConfigResolver } from "../../config/resolver";
@@ -1,7 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { USER_STATUS } from "../../user";
3
4
  import type { AuthUserRow } from "../auth-user-row";
4
- import { gateEnforceAccountStatus, gateEnforceEmailVerified } from "../handlers/login.write";
5
+ import {
6
+ gateBuildSession,
7
+ gateEnforceAccountStatus,
8
+ gateEnforceEmailVerified,
9
+ } from "../handlers/login.write";
10
+
11
+ // resolveAuthClaims is the only ctx member gateBuildSession touches.
12
+ const authClaimsCtx = {
13
+ resolveAuthClaims: async () => ({}),
14
+ } as unknown as HandlerContext;
5
15
 
6
16
  function row(over: Partial<AuthUserRow> = {}): AuthUserRow {
7
17
  return { id: "00000000-0000-4000-8000-000000000001", passwordHash: "x", ...over };
@@ -36,4 +46,28 @@ describe("login.write gates (fw#1284)", () => {
36
46
  test("gateEnforceAccountStatus passes active", () => {
37
47
  expect(gateEnforceAccountStatus(row({ status: USER_STATUS.Active })).ok).toBe(true);
38
48
  });
49
+
50
+ test("gateBuildSession carries the user's timezone into the session", async () => {
51
+ const g = await gateBuildSession(
52
+ authClaimsCtx,
53
+ "00000000-0000-4000-8000-000000000001",
54
+ "00000000-0000-4000-8000-000000000002",
55
+ ["User"],
56
+ "Asia/Tokyo",
57
+ );
58
+ expect(g.ok).toBe(true);
59
+ if (g.ok) expect(g.value.session.timezone).toBe("Asia/Tokyo");
60
+ });
61
+
62
+ test("gateBuildSession omits timezone when the user never set one (fw#1636)", async () => {
63
+ const g = await gateBuildSession(
64
+ authClaimsCtx,
65
+ "00000000-0000-4000-8000-000000000001",
66
+ "00000000-0000-4000-8000-000000000002",
67
+ ["User"],
68
+ null,
69
+ );
70
+ expect(g.ok).toBe(true);
71
+ if (g.ok) expect(g.value.session.timezone).toBeUndefined();
72
+ });
39
73
  });
@@ -17,6 +17,8 @@ export type AuthUserRow = {
17
17
  readonly isDeleted?: boolean | null;
18
18
  readonly emailVerified?: boolean | null;
19
19
  readonly lastActiveTenantId?: TenantId | string | null;
20
+ // IANA zone — threaded into SessionUser.timezone at login (fw#1636).
21
+ readonly timezone?: string | null;
20
22
  // JSON-encoded string[] — globale Rollen die parallel zu tenant-membership-
21
23
  // roles gelten (z.B. SystemAdmin, BillingAdmin). Caller deserialisiert via
22
24
  // parseRoles() vor dem Merge in die Session.
@@ -224,11 +224,13 @@ export async function gateBuildSession(
224
224
  userId: string,
225
225
  tenantId: TenantId,
226
226
  mergedRoles: readonly string[],
227
+ timezone?: string | null,
227
228
  ): Promise<GateOutcome<{ readonly kind: "auth-session"; readonly session: SessionUser }>> {
228
229
  const baseSession: SessionUser = {
229
230
  id: userId,
230
231
  tenantId,
231
232
  roles: mergedRoles,
233
+ ...(timezone !== null && timezone !== undefined && { timezone }),
232
234
  };
233
235
  const claims = await ctx.resolveAuthClaims(baseSession);
234
236
  const session: SessionUser =
@@ -294,7 +296,13 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
294
296
  return { isSuccess: true, data: mfaGate.value };
295
297
  }
296
298
 
297
- const sessionGate = await gateBuildSession(ctx, found.id, chosen.tenantId, mergedRoles);
299
+ const sessionGate = await gateBuildSession(
300
+ ctx,
301
+ found.id,
302
+ chosen.tenantId,
303
+ mergedRoles,
304
+ found.timezone,
305
+ );
298
306
  if (!sessionGate.ok) return sessionGate.result;
299
307
  return { isSuccess: true, data: sessionGate.value };
300
308
  },
@@ -10,11 +10,7 @@
10
10
  // non-string userOwned field. enable-confirm failed for EVERY real deployment
11
11
  // with crypto-shredding configured, while every test here stayed green.
12
12
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
- import {
14
- configurePiiSubjectKms,
15
- InMemoryKmsAdapter,
16
- resetPiiSubjectKmsForTests,
17
- } from "@cosmicdrift/kumiko-framework/crypto";
13
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
18
14
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
19
15
  import {
20
16
  createTestUser,
@@ -23,7 +19,10 @@ import {
23
19
  unsafeCreateEntityTable,
24
20
  unsafePushTables,
25
21
  } from "@cosmicdrift/kumiko-framework/stack";
26
- import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
22
+ import {
23
+ createTestEnvelopeCipher,
24
+ resetPiiSubjectKmsForTests,
25
+ } from "@cosmicdrift/kumiko-framework/testing";
27
26
  import { createConfigFeature } from "../../config";
28
27
  import { createConfigResolver } from "../../config/resolver";
29
28
  import { configValuesTable } from "../../config/table";
@@ -20,7 +20,6 @@ import {
20
20
  InMemoryKmsAdapter,
21
21
  isPiiCiphertext,
22
22
  PII_ERASED_SENTINEL,
23
- resetPiiSubjectKmsForTests,
24
23
  } from "@cosmicdrift/kumiko-framework/crypto";
25
24
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
26
25
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
@@ -36,6 +35,7 @@ import {
36
35
  testTenantId,
37
36
  unsafeCreateEntityTable,
38
37
  } from "@cosmicdrift/kumiko-framework/stack";
38
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
39
39
  import {
40
40
  ComplianceProfileHandlers,
41
41
  createComplianceProfilesFeature,
@@ -12,7 +12,6 @@ import {
12
12
  InMemoryKmsAdapter,
13
13
  isPiiCiphertext,
14
14
  PII_CIPHERTEXT_PREFIX,
15
- resetPiiSubjectKmsForTests,
16
15
  } from "@cosmicdrift/kumiko-framework/crypto";
17
16
  import {
18
17
  access,
@@ -27,6 +26,7 @@ import {
27
26
  TestUsers,
28
27
  unsafePushTables,
29
28
  } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
30
30
  import { ConfigHandlers, ConfigQueries } from "../constants";
31
31
  import { createConfigAccessorFactory, createConfigFeature } from "../feature";
32
32
  import { createConfigResolver } from "../resolver";
@@ -0,0 +1,101 @@
1
+ // buildHandlerContext resolves ctx.tz.tenant from tenant:config:timezone and
2
+ // ctx.tz.user from SessionUser.timezone (fw#1636). "tenant" here is a
3
+ // standalone probe feature registering the same qualified config key the
4
+ // real bundled `tenant` feature uses — decoupled from that feature's
5
+ // entities/handlers, but exercising the identical runtime path.
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ createTestUser,
10
+ setupTestStack,
11
+ type TestStack,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { z } from "zod";
15
+ import { createConfigAccessorFactory, createConfigFeature } from "../feature";
16
+ import { createConfigResolver } from "../resolver";
17
+ import { configValuesTable } from "../table";
18
+
19
+ const tenantFeature = defineFeature("tenant", (r) => {
20
+ r.requires("config");
21
+ r.config({
22
+ keys: {
23
+ timezone: createTenantConfig("select", {
24
+ default: "UTC",
25
+ options: ["UTC", "Europe/Berlin", "Asia/Tokyo"],
26
+ write: access.roles("Admin"),
27
+ }),
28
+ },
29
+ });
30
+ });
31
+
32
+ const probeFeature = defineFeature("probe", (r) => {
33
+ r.requires("tenant");
34
+ r.writeHandler(
35
+ "read-tz",
36
+ z.object({}),
37
+ async (_event, ctx) => ({
38
+ isSuccess: true,
39
+ data: { tenant: ctx.tz.tenant, user: ctx.tz.user },
40
+ }),
41
+ { access: { openToAll: true } },
42
+ );
43
+ });
44
+
45
+ describe("buildHandlerContext ctx.tz resolution", () => {
46
+ let stack: TestStack;
47
+
48
+ beforeAll(async () => {
49
+ const resolver = createConfigResolver();
50
+ stack = await setupTestStack({
51
+ features: [createConfigFeature(), tenantFeature, probeFeature],
52
+ extraContext: ({ registry }) => ({
53
+ configResolver: resolver,
54
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
55
+ }),
56
+ });
57
+ await unsafePushTables(stack.db, { configValuesTable });
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await stack.cleanup();
62
+ });
63
+
64
+ test("defaults to UTC when no tenant config value is set and SessionUser has no timezone", async () => {
65
+ const user = createTestUser({ id: 10 });
66
+ const res = await stack.http.writeOk<{ tenant: string; user: string }>(
67
+ "probe:write:read-tz",
68
+ {},
69
+ user,
70
+ );
71
+ expect(res).toEqual({ tenant: "UTC", user: "UTC" });
72
+ });
73
+
74
+ test("ctx.tz.tenant reads tenant:config:timezone", async () => {
75
+ const admin = createTestUser({ id: 11, roles: ["Admin"] });
76
+ await stack.http.writeOk(
77
+ "config:write:set",
78
+ { key: "tenant:config:timezone", value: "Europe/Berlin" },
79
+ admin,
80
+ );
81
+
82
+ const res = await stack.http.writeOk<{ tenant: string; user: string }>(
83
+ "probe:write:read-tz",
84
+ {},
85
+ admin,
86
+ );
87
+ expect(res).toEqual({ tenant: "Europe/Berlin", user: "Europe/Berlin" });
88
+ });
89
+
90
+ test("ctx.tz.user reads SessionUser.timezone independently of tenant", async () => {
91
+ // Same tenant as the previous test (Europe/Berlin already set) — proves
92
+ // user overrides without needing to touch tenant config.
93
+ const user = createTestUser({ id: 12, timezone: "Asia/Tokyo" });
94
+ const res = await stack.http.writeOk<{ tenant: string; user: string }>(
95
+ "probe:write:read-tz",
96
+ {},
97
+ user,
98
+ );
99
+ expect(res).toEqual({ tenant: "Europe/Berlin", user: "Asia/Tokyo" });
100
+ });
101
+ });
@@ -9,15 +9,11 @@
9
9
 
10
10
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
11
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
- import {
13
- configurePiiSubjectKms,
14
- InMemoryKmsAdapter,
15
- resetPiiSubjectKmsForTests,
16
- } from "@cosmicdrift/kumiko-framework/crypto";
12
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
17
13
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
14
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
15
  import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
20
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
16
+ import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
21
17
  import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
22
18
  import { createCryptoShreddingFeature } from "../feature";
23
19
 
@@ -12,7 +12,6 @@ import {
12
12
  InMemoryKmsAdapter,
13
13
  isPiiCiphertext,
14
14
  PII_ERASED_SENTINEL,
15
- resetPiiSubjectKmsForTests,
16
15
  } from "@cosmicdrift/kumiko-framework/crypto";
17
16
  import {
18
17
  defineFeature,
@@ -28,6 +27,7 @@ import {
28
27
  TestUsers,
29
28
  unsafePushTables,
30
29
  } from "@cosmicdrift/kumiko-framework/stack";
30
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
31
31
  import { z } from "zod";
32
32
  import { createChannelEmailFeature } from "../../channel-email/feature";
33
33
  import { createInMemoryTransport } from "../../channel-email/types";
@@ -12,7 +12,6 @@ import {
12
12
  configurePiiSubjectKms,
13
13
  InMemoryKmsAdapter,
14
14
  PII_ERASED_SENTINEL,
15
- resetPiiSubjectKmsForTests,
16
15
  } from "@cosmicdrift/kumiko-framework/crypto";
17
16
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
18
17
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
@@ -22,6 +21,7 @@ import {
22
21
  testTenantId,
23
22
  unsafeCreateEntityTable,
24
23
  } from "@cosmicdrift/kumiko-framework/stack";
24
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
25
25
  import { createConfigFeature } from "../../config";
26
26
  import { documentExtractEntity, documentExtractsTable } from "../entity";
27
27
  import { readIngestPages, writeIngestPages } from "../pages";
@@ -7,11 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
9
9
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
- import {
11
- configurePiiSubjectKms,
12
- InMemoryKmsAdapter,
13
- resetPiiSubjectKmsForTests,
14
- } from "@cosmicdrift/kumiko-framework/crypto";
10
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
15
11
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
16
12
  import {
17
13
  createSystemUser,
@@ -31,6 +27,7 @@ import {
31
27
  import {
32
28
  createMutableMasterKeyProvider,
33
29
  type MutableMasterKeyProvider,
30
+ resetPiiSubjectKmsForTests,
34
31
  } from "@cosmicdrift/kumiko-framework/testing";
35
32
  import { Hono } from "hono";
36
33
  import {
@@ -20,7 +20,6 @@ import {
20
20
  InMemoryKmsAdapter,
21
21
  isPiiCiphertext,
22
22
  PII_ERASED_SENTINEL,
23
- resetPiiSubjectKmsForTests,
24
23
  } from "@cosmicdrift/kumiko-framework/crypto";
25
24
  import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
26
25
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
@@ -39,7 +38,7 @@ import {
39
38
  testTenantId,
40
39
  unsafeCreateEntityTable,
41
40
  } from "@cosmicdrift/kumiko-framework/stack";
42
- import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
41
+ import { bridgeStub, resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
43
42
  import {
44
43
  createComplianceProfilesFeature,
45
44
  tenantComplianceProfileEntity,
@@ -7,11 +7,7 @@
7
7
 
8
8
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
9
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
- import {
11
- configurePiiSubjectKms,
12
- InMemoryKmsAdapter,
13
- resetPiiSubjectKmsForTests,
14
- } from "@cosmicdrift/kumiko-framework/crypto";
10
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
15
11
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
16
12
  import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
17
13
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -22,6 +18,7 @@ import {
22
18
  testTenantId,
23
19
  unsafeCreateEntityTable,
24
20
  } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
25
22
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
26
23
  import {
27
24
  createComplianceProfilesFeature,
@@ -6,11 +6,7 @@
6
6
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
7
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
8
8
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
- import {
10
- configurePiiSubjectKms,
11
- InMemoryKmsAdapter,
12
- resetPiiSubjectKmsForTests,
13
- } from "@cosmicdrift/kumiko-framework/crypto";
9
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
14
10
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
11
  import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
16
12
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -21,7 +17,7 @@ import {
21
17
  testTenantId,
22
18
  unsafeCreateEntityTable,
23
19
  } from "@cosmicdrift/kumiko-framework/stack";
24
- import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
20
+ import { resetPiiSubjectKmsForTests, waitFor } from "@cosmicdrift/kumiko-framework/testing";
25
21
  import {
26
22
  createComplianceProfilesFeature,
27
23
  tenantComplianceProfileEntity,
@@ -16,11 +16,7 @@ import { randomBytes } from "node:crypto";
16
16
  import { connect } from "node:net";
17
17
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
18
18
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
19
- import {
20
- configurePiiSubjectKms,
21
- InMemoryKmsAdapter,
22
- resetPiiSubjectKmsForTests,
23
- } from "@cosmicdrift/kumiko-framework/crypto";
19
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
24
20
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
21
  import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
26
22
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -36,6 +32,7 @@ import {
36
32
  import {
37
33
  createMutableMasterKeyProvider,
38
34
  type MutableMasterKeyProvider,
35
+ resetPiiSubjectKmsForTests,
39
36
  waitFor,
40
37
  } from "@cosmicdrift/kumiko-framework/testing";
41
38
  import { createTransport } from "nodemailer";
@@ -13,7 +13,6 @@ import {
13
13
  InMemoryKmsAdapter,
14
14
  isPiiCiphertext,
15
15
  PII_ERASED_SENTINEL,
16
- resetPiiSubjectKmsForTests,
17
16
  } from "@cosmicdrift/kumiko-framework/crypto";
18
17
  import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
19
18
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -24,7 +23,7 @@ import {
24
23
  type TestRedis,
25
24
  unsafePushTables,
26
25
  } from "@cosmicdrift/kumiko-framework/stack";
27
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
26
+ import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
28
27
  import { createJobsFeature } from "../feature";
29
28
  import { createJobRunLogger, JOB_RUN_STARTED_EVENT } from "../job-run-logger";
30
29
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
@@ -7,8 +7,6 @@ import {
7
7
  configurePiiSubjectKms,
8
8
  InMemoryKmsAdapter,
9
9
  isPiiCiphertext,
10
- resetBlindIndexKeyForTests,
11
- resetPiiSubjectKmsForTests,
12
10
  } from "@cosmicdrift/kumiko-framework/crypto";
13
11
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
12
  import {
@@ -18,7 +16,12 @@ import {
18
16
  unsafeCreateEntityTable,
19
17
  unsafePushTables,
20
18
  } from "@cosmicdrift/kumiko-framework/stack";
21
- import { createTestEnvelopeCipher, deleteRows } from "@cosmicdrift/kumiko-framework/testing";
19
+ import {
20
+ createTestEnvelopeCipher,
21
+ deleteRows,
22
+ resetBlindIndexKeyForTests,
23
+ resetPiiSubjectKmsForTests,
24
+ } from "@cosmicdrift/kumiko-framework/testing";
22
25
  import { Temporal } from "temporal-polyfill";
23
26
  import { AuthHandlers } from "../../auth-email-password/constants";
24
27
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
@@ -7,8 +7,6 @@ import {
7
7
  configurePiiSubjectKms,
8
8
  InMemoryKmsAdapter,
9
9
  isPiiCiphertext,
10
- resetBlindIndexKeyForTests,
11
- resetPiiSubjectKmsForTests,
12
10
  } from "@cosmicdrift/kumiko-framework/crypto";
13
11
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
12
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -24,6 +22,8 @@ import {
24
22
  createLateBoundHolder,
25
23
  createTestEnvelopeCipher,
26
24
  deleteRows,
25
+ resetBlindIndexKeyForTests,
26
+ resetPiiSubjectKmsForTests,
27
27
  resetTestTables,
28
28
  updateRows,
29
29
  } from "@cosmicdrift/kumiko-framework/testing";
@@ -11,7 +11,6 @@ import {
11
11
  configurePiiSubjectKms,
12
12
  InMemoryKmsAdapter,
13
13
  isPiiCiphertext,
14
- resetPiiSubjectKmsForTests,
15
14
  } from "@cosmicdrift/kumiko-framework/crypto";
16
15
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
17
16
  import {
@@ -21,7 +20,7 @@ import {
21
20
  unsafeCreateEntityTable,
22
21
  unsafePushTables,
23
22
  } from "@cosmicdrift/kumiko-framework/stack";
24
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
23
+ import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
25
24
  import { createConfigFeature } from "../../config";
26
25
  import { configValuesTable } from "../../config/table";
27
26
  import { createUserFeature } from "../../user/feature";
@@ -84,6 +84,20 @@ export type ComposedApp<TCaps extends Readonly<Record<string, unknown>>> = {
84
84
  * delivered something the platform doesn't understand. Failing loud at boot
85
85
  * is safer than silently mounting the wrong feature-set and hoping nobody
86
86
  * notices when "Pro" turns out to mean "Free".
87
+ *
88
+ * **No production callers, and that is not rot.** This is the BOOT-TIME half
89
+ * of tier composition — which features get mounted at all. The RUNTIME half,
90
+ * which features a given tenant may see out of everything mounted, is
91
+ * `createTierEngineFeature`'s `resolver(tenantId) => ReadonlySet<string>`
92
+ * (feature.ts). Both read the same tierMap at opposite ends. Apps use the
93
+ * resolver today; nobody composes per-tenant feature sets at boot yet, so an
94
+ * app that only needs caps merges them locally rather than constructing a
95
+ * featureRegistry it has no other use for (kumiko-studio's
96
+ * `resolvePlatformCaps`, studio#154/1 → PR #162 — a deliberate, typed copy of
97
+ * step 4, not a workaround). Whether the boot-time half ever gets a caller is
98
+ * a product question: kumiko-studio's BYO pivot (Sprint 8a Phase 3a)
99
+ * differentiates tiers by CAPS, not by feature set. Delete this only once no
100
+ * app plans to differentiate by feature set — the engine is app-agnostic.
87
101
  */
88
102
  export function composeApp<TCaps extends Readonly<Record<string, unknown>>>(
89
103
  input: ComposeAppInput<TCaps>,
@@ -64,8 +64,9 @@ describe("scenario 1: create + me", () => {
64
64
  id: created.id,
65
65
  email: "marc@example.com",
66
66
  displayName: "Marc",
67
- locale: "de", // comes from the entity's default — client didn't send it
68
67
  });
68
+ // No entity-level default — client didn't send a locale, so it stays unset.
69
+ expect(me["locale"] == null).toBe(true);
69
70
  });
70
71
 
71
72
  test("normal user cannot create another user", async () => {
@@ -2,6 +2,7 @@ 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
4
  import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
5
6
  import { z } from "zod";
6
7
  import { UserErrors } from "../constants";
7
8
  import { userEntity, userTable } from "../schema/user";
@@ -23,6 +24,7 @@ export const createWrite = defineWriteHandler({
23
24
  passwordHash: z.string().optional(),
24
25
  displayName: z.string().min(1).max(100),
25
26
  locale: z.string().min(2).max(10).optional(),
27
+ timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
26
28
  // Globale Rollen — JSON-encoded string[]. Optional weil der Default
27
29
  // im Entity-Schema "[]" ist; setzen wenn man einen SystemAdmin (oder
28
30
  // andere globale Rollen) anlegt. Field-Access (write: privileged) auf
@@ -1,6 +1,7 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
4
5
  import { z } from "zod";
5
6
  import { UserErrors } from "../constants";
6
7
  import { userEntity, userTable } from "../schema/user";
@@ -22,6 +23,7 @@ export const updateWrite = defineWriteHandler({
22
23
  changes: z.object({
23
24
  displayName: z.string().min(1).max(100).optional(),
24
25
  locale: z.string().min(2).max(10).optional(),
26
+ timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
25
27
  email: z.email().optional(),
26
28
  passwordHash: z.string().optional(),
27
29
  lastActiveTenantId: z.string().optional(),
package/src/user/i18n.ts CHANGED
@@ -8,6 +8,7 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
8
8
  "user:entity:user:field:status": { de: "Status", en: "Status" },
9
9
  "user:entity:user:field:emailVerified": { de: "E-Mail bestätigt", en: "Email verified" },
10
10
  "user:entity:user:field:locale": { de: "Sprache", en: "Locale" },
11
+ "user:entity:user:field:timezone": { de: "Zeitzone", en: "Timezone" },
11
12
  "user:entity:user:field:status:option:active": { de: "Aktiv", en: "Active" },
12
13
  "user:entity:user:field:status:option:restricted": { de: "Eingeschränkt", en: "Restricted" },
13
14
  "user:entity:user:field:status:option:deletionRequested": {
@@ -6,6 +6,7 @@ import {
6
6
  createSelectField,
7
7
  createTextField,
8
8
  createTimestampField,
9
+ createTzField,
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
10
11
 
11
12
  /**
@@ -87,7 +88,11 @@ export const userEntity = createEntity({
87
88
  pii: true,
88
89
  searchable: true,
89
90
  }),
90
- locale: createTextField({ maxLength: 10, default: "de" }),
91
+ // No default here — tenant-settings owns the app-wide locale default; see #1637.
92
+ locale: createTextField({ maxLength: 10 }),
93
+ // No default here — mirrors locale: ctx.tz falls back to tenant.timezone
94
+ // (then "UTC") when unset, see buildHandlerContext (fw#1636).
95
+ timezone: createTzField(),
91
96
 
92
97
  // Which tenant should this user land in on next login. Set by the login
93
98
  // handler (SYSTEM), read by the login flow + UI for deep-linking.
@@ -36,7 +36,7 @@ export const userEditScreen: EntityEditScreenDefinition = {
36
36
  sections: [
37
37
  {
38
38
  columns: 2,
39
- fields: ["email", "displayName", "locale", "emailVerified"],
39
+ fields: ["email", "displayName", "locale", "timezone", "emailVerified"],
40
40
  },
41
41
  ],
42
42
  },
@@ -11,8 +11,6 @@ import {
11
11
  configurePiiSubjectKms,
12
12
  InMemoryKmsAdapter,
13
13
  isPiiCiphertext,
14
- resetBlindIndexKeyForTests,
15
- resetPiiSubjectKmsForTests,
16
14
  } from "@cosmicdrift/kumiko-framework/crypto";
17
15
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
16
  import {
@@ -21,7 +19,11 @@ import {
21
19
  testTenantId,
22
20
  unsafeCreateEntityTable,
23
21
  } from "@cosmicdrift/kumiko-framework/stack";
24
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
22
+ import {
23
+ resetBlindIndexKeyForTests,
24
+ resetPiiSubjectKmsForTests,
25
+ resetTestTables,
26
+ } from "@cosmicdrift/kumiko-framework/testing";
25
27
  import {
26
28
  createComplianceProfilesFeature,
27
29
  tenantComplianceProfileEntity,
@@ -6,11 +6,7 @@
6
6
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
8
8
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
- import {
10
- buildEntityTable,
11
- configureEntityFieldEncryption,
12
- resetEntityFieldEncryptionCacheForTests,
13
- } from "@cosmicdrift/kumiko-framework/db";
9
+ import { buildEntityTable, configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
14
10
  import {
15
11
  createEntity,
16
12
  createRegistry,
@@ -20,7 +16,10 @@ import {
20
16
  type UserDataExportHook,
21
17
  } from "@cosmicdrift/kumiko-framework/engine";
22
18
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
23
- import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
19
+ import {
20
+ createTestEnvelopeCipher,
21
+ resetEntityFieldEncryptionCacheForTests,
22
+ } from "@cosmicdrift/kumiko-framework/testing";
24
23
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
25
24
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
26
25
  import { createDataRetentionFeature } from "../../data-retention";
@@ -21,7 +21,6 @@ import {
21
21
  configurePiiSubjectKms,
22
22
  encryptPiiFieldValues,
23
23
  InMemoryKmsAdapter,
24
- resetPiiSubjectKmsForTests,
25
24
  } from "@cosmicdrift/kumiko-framework/crypto";
26
25
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
27
26
  import {
@@ -30,7 +29,11 @@ import {
30
29
  unsafeCreateEntityTable,
31
30
  unsafePushTables,
32
31
  } from "@cosmicdrift/kumiko-framework/stack";
33
- import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
32
+ import {
33
+ resetPiiSubjectKmsForTests,
34
+ resetTestTables,
35
+ seedRow,
36
+ } from "@cosmicdrift/kumiko-framework/testing";
34
37
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
35
38
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
36
39
  import { createDataRetentionFeature } from "../../data-retention";
@@ -46,7 +46,7 @@ export const userExportHook: UserDataExportHook = async (ctx) => {
46
46
  id: string;
47
47
  email: string;
48
48
  displayName: string;
49
- locale: string;
49
+ locale: string | null;
50
50
  emailVerified: boolean;
51
51
  } | null; // @cast-boundary db-runner
52
52