@cosmicdrift/kumiko-bundled-features 0.155.0 → 0.156.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 (50) hide show
  1. package/package.json +7 -6
  2. package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
  3. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
  4. package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
  5. package/src/agent-tools/index.ts +9 -0
  6. package/src/agent-tools/tool-catalog.ts +130 -0
  7. package/src/agent-tools/tool-dispatch.ts +82 -0
  8. package/src/agent-tools/types.ts +38 -0
  9. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +5 -1
  10. package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +5 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +14 -4
  12. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +89 -0
  13. package/src/auth-mfa/__tests__/totp.test.ts +2 -2
  14. package/src/auth-mfa/__tests__/verify.integration.test.ts +29 -4
  15. package/src/auth-mfa/constants.ts +4 -0
  16. package/src/auth-mfa/handlers/disable.write.ts +2 -1
  17. package/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
  18. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +4 -3
  19. package/src/auth-mfa/handlers/verify.write.ts +22 -12
  20. package/src/auth-mfa/recovery-codes.ts +2 -2
  21. package/src/auth-mfa/schema/user-mfa.ts +10 -0
  22. package/src/auth-mfa/totp.ts +18 -6
  23. package/src/auth-mfa/verify-factor.ts +30 -2
  24. package/src/auth-mfa/web/i18n.ts +2 -0
  25. package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -29
  26. package/src/auth-mfa/web/mfa-error-keys.ts +2 -0
  27. package/src/auth-mfa/web/qrcode-browser.d.ts +5 -0
  28. package/src/config/__tests__/pii-encrypted.integration.test.ts +158 -0
  29. package/src/config/__tests__/write-helpers.test.ts +23 -0
  30. package/src/config/handlers/set.write.ts +47 -1
  31. package/src/config/resolver.ts +28 -0
  32. package/src/config/write-helpers.ts +22 -0
  33. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  34. package/src/delivery/feature.ts +1 -1
  35. package/src/file-foundation/__tests__/feature.test.ts +1 -1
  36. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +3 -1
  37. package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
  38. package/src/inbound-mail-foundation/feature.ts +3 -3
  39. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +7 -11
  40. package/src/jobs/feature.ts +1 -1
  41. package/src/legal-pages/feature.ts +1 -1
  42. package/src/mail-foundation/__tests__/feature.test.ts +1 -1
  43. package/src/page-render/branding.ts +4 -3
  44. package/src/personal-access-tokens/feature.ts +1 -1
  45. package/src/sessions/__tests__/sessions.integration.test.ts +42 -0
  46. package/src/sessions/feature.ts +2 -2
  47. package/src/user-data-rights/handlers/download-by-job.query.ts +14 -7
  48. package/src/user-data-rights/handlers/download-by-token.query.ts +14 -7
  49. package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
  50. package/src/user-data-rights/lib/tenant-file-provider.ts +0 -62
@@ -2,6 +2,11 @@
2
2
  // subpath (see mfa-enable-screen.tsx's import — the main entry pulls in
3
3
  // Node-only deps like yargs/pngjs via qrcode's own package.json#browser
4
4
  // remap, which Metro doesn't honor). Same runtime shape, just re-typed.
5
+ // `export *` never re-exports a default — `import QRCode from "..."`
6
+ // resolving here relies on the consuming app's esModuleInterop/
7
+ // allowSyntheticDefaultImports (which synthesizes a default from the
8
+ // namespace). Copy this file into apps that need it, but note that
9
+ // requirement — without it, the default import comes back undefined.
5
10
  declare module "qrcode/lib/browser" {
6
11
  export * from "qrcode";
7
12
  }
@@ -0,0 +1,158 @@
1
+ // piiEncrypted config keys (kumiko-platform#231/#459): the value is
2
+ // subject-KMS-encrypted at rest (tenant-row → tenant subject, user-row →
3
+ // user subject), decrypted on read for authorized readers — unlike
4
+ // `encrypted`/`backing="secrets"`, which the `values` query always masks
5
+ // regardless of role (see config/handlers/values.query.ts).
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import {
10
+ configuredPiiSubjectKms,
11
+ configurePiiSubjectKms,
12
+ InMemoryKmsAdapter,
13
+ isPiiCiphertext,
14
+ resetPiiSubjectKmsForTests,
15
+ } from "@cosmicdrift/kumiko-framework/crypto";
16
+ import {
17
+ access,
18
+ createTenantConfig,
19
+ createUserConfig,
20
+ defineFeature,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
22
+ import {
23
+ createTestUser,
24
+ setupTestStack,
25
+ type TestStack,
26
+ TestUsers,
27
+ unsafePushTables,
28
+ } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { ConfigHandlers, ConfigQueries } from "../constants";
30
+ import { createConfigAccessorFactory, createConfigFeature } from "../feature";
31
+ import { createConfigResolver } from "../resolver";
32
+ import { configValuesTable } from "../table";
33
+
34
+ const BILLING_ADDRESS_KEY = "pii-cfg-test:config:billing-address";
35
+ const PHONE_NUMBER_KEY = "pii-cfg-test:config:phone-number";
36
+
37
+ const piiFeature = defineFeature("pii-cfg-test", (r) => {
38
+ r.requires("config");
39
+
40
+ r.config({
41
+ keys: {
42
+ billingAddress: createTenantConfig("text", {
43
+ piiEncrypted: true,
44
+ read: access.all,
45
+ write: access.all,
46
+ }),
47
+ phoneNumber: createUserConfig("text", {
48
+ piiEncrypted: true,
49
+ read: access.all,
50
+ write: access.all,
51
+ }),
52
+ },
53
+ });
54
+ });
55
+
56
+ const configFeature = createConfigFeature();
57
+
58
+ let stack: TestStack;
59
+ const kms = new InMemoryKmsAdapter();
60
+ const tenantAdmin = createTestUser({ id: 2 });
61
+
62
+ beforeAll(async () => {
63
+ const resolver = createConfigResolver();
64
+ stack = await setupTestStack({
65
+ features: [configFeature, piiFeature],
66
+ extraContext: ({ registry }) => ({
67
+ configResolver: resolver,
68
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
69
+ }),
70
+ });
71
+ await unsafePushTables(stack.db, { configValuesTable });
72
+ // One shared KMS instance for the whole file, not beforeEach/afterEach:
73
+ // config rows persist in the same DB across tests in this file, so a
74
+ // fresh adapter per test would orphan the previous test's ciphertext
75
+ // (KeyNotFoundError) the moment the values query resolves more than the
76
+ // one key that test just wrote.
77
+ configurePiiSubjectKms(kms);
78
+ });
79
+
80
+ afterAll(async () => {
81
+ resetPiiSubjectKmsForTests();
82
+ await stack.cleanup();
83
+ });
84
+
85
+ describe("piiEncrypted config keys", () => {
86
+ test("tenant-scope value is stored as tenant-subject ciphertext, read back as plaintext", async () => {
87
+ await stack.http.writeOk(
88
+ ConfigHandlers.set,
89
+ { key: BILLING_ADDRESS_KEY, value: "Musterstrasse 1, 12345 Berlin" },
90
+ tenantAdmin,
91
+ );
92
+
93
+ const row = await fetchOne<{ value: string }>(stack.db, configValuesTable, {
94
+ key: BILLING_ADDRESS_KEY,
95
+ tenantId: tenantAdmin.tenantId,
96
+ });
97
+ expect(row).toBeDefined();
98
+ expect(isPiiCiphertext(row?.value)).toBe(true);
99
+ expect(row?.value).toStartWith(`kumiko-pii:v1:tenant:${tenantAdmin.tenantId}:`);
100
+
101
+ const values = await stack.http.queryOk<
102
+ Record<string, { value: unknown; scope: string; source: string }>
103
+ >(ConfigQueries.values, {}, tenantAdmin);
104
+ expect(values[BILLING_ADDRESS_KEY]?.value).toBe("Musterstrasse 1, 12345 Berlin");
105
+ });
106
+
107
+ test("user-scope value is stored as user-subject ciphertext, distinct from tenant subject", async () => {
108
+ await stack.http.writeOk(
109
+ ConfigHandlers.set,
110
+ { key: PHONE_NUMBER_KEY, value: "+49 151 00000000" },
111
+ tenantAdmin,
112
+ );
113
+
114
+ const row = await fetchOne<{ value: string }>(stack.db, configValuesTable, {
115
+ key: PHONE_NUMBER_KEY,
116
+ tenantId: tenantAdmin.tenantId,
117
+ userId: String(tenantAdmin.id),
118
+ });
119
+ expect(row).toBeDefined();
120
+ expect(isPiiCiphertext(row?.value)).toBe(true);
121
+ expect(row?.value).toStartWith(`kumiko-pii:v1:user:${tenantAdmin.id}:`);
122
+
123
+ const values = await stack.http.queryOk<
124
+ Record<string, { value: unknown; scope: string; source: string }>
125
+ >(ConfigQueries.values, {}, tenantAdmin);
126
+ expect(values[PHONE_NUMBER_KEY]?.value).toBe("+49 151 00000000");
127
+ });
128
+
129
+ test("write with scope override to system is rejected — no subject for system-row", async () => {
130
+ // SystemAdmin so checkScopeWriteAccess passes and the request actually
131
+ // reaches the piiEncrypted+scope="system" rejection, not an earlier
132
+ // generic "you can't write system scope" denial.
133
+ const err = await stack.http.writeErr(
134
+ ConfigHandlers.set,
135
+ { key: BILLING_ADDRESS_KEY, value: "irrelevant", scope: "system" },
136
+ TestUsers.systemAdmin,
137
+ );
138
+ expect(err.code).toBe("unprocessable");
139
+ expect((err.details as { reason?: string } | undefined)?.reason).toBe("invalid_scope");
140
+ });
141
+
142
+ test("write without a configured KMS fails loud instead of silently storing plaintext", async () => {
143
+ resetPiiSubjectKmsForTests();
144
+ expect(configuredPiiSubjectKms()).toBeUndefined();
145
+
146
+ try {
147
+ const res = await stack.http.write(
148
+ ConfigHandlers.set,
149
+ { key: BILLING_ADDRESS_KEY, value: "no-kms-value" },
150
+ tenantAdmin,
151
+ );
152
+ expect(res.status).toBe(500);
153
+ } finally {
154
+ // Restore for any test running after this one in the file.
155
+ configurePiiSubjectKms(kms);
156
+ }
157
+ });
158
+ });
@@ -10,6 +10,7 @@ import type { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
10
10
  import {
11
11
  checkScopeWriteAccess,
12
12
  hasConfigAccess,
13
+ resolvePiiSubject,
13
14
  resolveScopeIds,
14
15
  validatePattern,
15
16
  validateScope,
@@ -101,6 +102,28 @@ describe("resolveScopeIds", () => {
101
102
  });
102
103
  });
103
104
 
105
+ describe("resolvePiiSubject (kumiko-platform#231/#459)", () => {
106
+ const tenant = "tenant-9" as TenantId;
107
+
108
+ test("tenant scope resolves to the tenant subject", () => {
109
+ expect(resolvePiiSubject(ConfigScopes.tenant, tenant, null)).toEqual({
110
+ kind: "tenant",
111
+ tenantId: tenant,
112
+ });
113
+ });
114
+
115
+ test("user scope resolves to the user subject — the row's target user, not necessarily the writer", () => {
116
+ expect(resolvePiiSubject(ConfigScopes.user, tenant, "user-1")).toEqual({
117
+ kind: "user",
118
+ userId: "user-1",
119
+ });
120
+ });
121
+
122
+ test("user scope without a userId throws instead of silently falling back to tenant", () => {
123
+ expect(() => resolvePiiSubject(ConfigScopes.user, tenant, null)).toThrow(/userId is null/);
124
+ });
125
+ });
126
+
104
127
  describe("validateType", () => {
105
128
  const numberKey = createTenantConfig("number", {});
106
129
  const boolKey = createTenantConfig("boolean", {});
@@ -1,16 +1,27 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
+ import {
3
+ configuredPiiSubjectKms,
4
+ encryptPiiValueForSubject,
5
+ type KmsContext,
6
+ } from "@cosmicdrift/kumiko-framework/crypto";
1
7
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
8
  import {
3
9
  ConfigScopes,
4
10
  defineWriteHandler,
5
11
  SYSTEM_TENANT_ID,
6
12
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
13
+ import {
14
+ InternalError,
15
+ UnprocessableError,
16
+ writeFailure,
17
+ } from "@cosmicdrift/kumiko-framework/errors";
8
18
  import { z } from "zod";
9
19
  import { requireConfigEncryption } from "../feature";
10
20
  import { configValueEntity, configValuesTable } from "../table";
11
21
  import {
12
22
  findConfigRow,
13
23
  prepareConfigWrite,
24
+ resolvePiiSubject,
14
25
  validateBounds,
15
26
  validatePattern,
16
27
  validateScope,
@@ -47,6 +58,19 @@ export const setWrite = defineWriteHandler({
47
58
  const scopeError = validateScope(scope, keyDef.scope, event.payload.key);
48
59
  if (scopeError) return writeFailure(scopeError);
49
60
 
61
+ // A piiEncrypted key can never be defined with scope="system" (boot-
62
+ // rejected), but an admin override write can still target scope=
63
+ // "system" for a tenant/user-scoped key (validateScope allows any
64
+ // requestedScope <= definedScope) — system-row has no subject.
65
+ if (keyDef.piiEncrypted && scope === ConfigScopes.system) {
66
+ return writeFailure(
67
+ new UnprocessableError("invalid_scope", {
68
+ i18nKey: "config.errors.invalidScope",
69
+ details: { key: event.payload.key, definedScope: keyDef.scope, requestedScope: scope },
70
+ }),
71
+ );
72
+ }
73
+
50
74
  const typeError = validateType(event.payload.value, keyDef);
51
75
  if (typeError) return writeFailure(typeError);
52
76
 
@@ -90,6 +114,28 @@ export const setWrite = defineWriteHandler({
90
114
  if (keyDef.encrypted) {
91
115
  const cipher = requireConfigEncryption(ctx, "config:write:set");
92
116
  serialized = await cipher.encrypt(serialized, { tenantId });
117
+ } else if (keyDef.piiEncrypted) {
118
+ const kms = configuredPiiSubjectKms();
119
+ if (!kms) {
120
+ throw new InternalError({
121
+ message:
122
+ `[config:write:set] key "${event.payload.key}" declares piiEncrypted=true but no ` +
123
+ `PII subject-KMS is configured — pass one via runProdApp({ kms }) / ` +
124
+ `configurePiiSubjectKms(adapter) at boot.`,
125
+ });
126
+ }
127
+ // scope="system" was already rejected above — narrow accordingly.
128
+ const subject = resolvePiiSubject(
129
+ scope as Exclude<typeof scope, "system">, // @cast-boundary runtime-checked above
130
+ tenantId,
131
+ userId,
132
+ );
133
+ const kmsCtx: KmsContext = {
134
+ requestId: requestContext.get()?.requestId ?? "config:write:set",
135
+ tenantId,
136
+ userId: String(event.user.id),
137
+ };
138
+ serialized = await encryptPiiValueForSubject(kms, subject, serialized, kmsCtx);
93
139
  }
94
140
 
95
141
  const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
@@ -1,4 +1,10 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
1
2
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import {
4
+ configuredPiiSubjectKms,
5
+ decryptPiiValueForSubject,
6
+ type KmsContext,
7
+ } from "@cosmicdrift/kumiko-framework/crypto";
2
8
  import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
9
  import type {
4
10
  ConfigCascade,
@@ -96,6 +102,24 @@ async function decryptEncrypted(
96
102
  return cipher.decrypt(raw);
97
103
  }
98
104
 
105
+ // Decrypt gate for piiEncrypted keys, mirroring decryptEncrypted above.
106
+ // The subject lives inside the ciphertext (kumiko-pii:v1:<subject>:...),
107
+ // so unlike the write side, no scope/subject resolution is needed here —
108
+ // same call works for a user-row or a tenant-row value.
109
+ async function decryptPiiEncrypted(raw: string, qualifiedKey: string): Promise<string> {
110
+ const kms = configuredPiiSubjectKms();
111
+ if (!kms) {
112
+ throw new InternalError({
113
+ message:
114
+ `[config] key "${qualifiedKey}" is piiEncrypted but no PII subject-KMS is wired — the ` +
115
+ `boot must configure one via runProdApp({ kms }) / configurePiiSubjectKms(adapter).`,
116
+ i18nKey: "config.errors.cipher_missing",
117
+ });
118
+ }
119
+ const kmsCtx: KmsContext = { requestId: requestContext.get()?.requestId ?? "config:resolver" };
120
+ return decryptPiiValueForSubject(kms, raw, kmsCtx);
121
+ }
122
+
99
123
  // backing="secrets" keys store their value in the secrets store (flat per
100
124
  // (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
101
125
  // (getWithSource + buildCascade) route the system rung here. Missing reader =
@@ -210,6 +234,8 @@ async function buildCascade(
210
234
  let raw = row.value;
211
235
  if (keyDef.encrypted) {
212
236
  raw = await decryptEncrypted(cipher, raw, qualifiedKey);
237
+ } else if (keyDef.piiEncrypted) {
238
+ raw = await decryptPiiEncrypted(raw, qualifiedKey);
213
239
  }
214
240
  if (activeIndex === -1) activeIndex = levels.length;
215
241
  levels.push({
@@ -387,6 +413,8 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
387
413
  let raw = row.value;
388
414
  if (keyDef.encrypted) {
389
415
  raw = await decryptEncrypted(cipher, raw, qualifiedKey);
416
+ } else if (keyDef.piiEncrypted) {
417
+ raw = await decryptPiiEncrypted(raw, qualifiedKey);
390
418
  }
391
419
  return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
392
420
  }
@@ -3,6 +3,7 @@
3
3
  // have to cross-import from another handler file.
4
4
 
5
5
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import type { SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
6
7
  import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
7
8
  import {
8
9
  type ConfigKeyDefinition,
@@ -198,6 +199,27 @@ export function resolveScopeIds(
198
199
  }
199
200
  }
200
201
 
202
+ // The subject a piiEncrypted config value is encrypted under, for the scope
203
+ // actually written to (kumiko-platform#231/#459). Pure — no I/O — so a bug
204
+ // here (e.g. picking the writer's identity instead of the row's target,
205
+ // see entity fields' explicit userOwned.ownerField for why that
206
+ // distinction matters) is caught by a fast unit test, not only the DB-
207
+ // backed integration test. Caller has already rejected scope="system"
208
+ // (no subject exists for it).
209
+ export function resolvePiiSubject(
210
+ scope: Exclude<ConfigScope, "system">,
211
+ tenantId: TenantId,
212
+ userId: string | null,
213
+ ): SubjectId {
214
+ if (scope === ConfigScopes.user) {
215
+ if (userId === null) {
216
+ throw new Error('resolvePiiSubject: scope="user" but userId is null');
217
+ }
218
+ return { kind: "user", userId };
219
+ }
220
+ return { kind: "tenant", tenantId };
221
+ }
222
+
201
223
  export function validateType(
202
224
  value: string | number | boolean,
203
225
  keyDef: ConfigKeyDefinition,
@@ -127,7 +127,7 @@ describe("T1.5a: custom-fields events are visible in the audit log", () => {
127
127
 
128
128
  const res = await listAudit({ aggregateType: "field-definition" });
129
129
 
130
- // The fieldDefinition is created via r.entity + r.crud, so the event-type
130
+ // The fieldDefinition is created via registerEntityCrud, so the event-type
131
131
  // follows the entity-CRUD convention `<entity>.created` (with a dot),
132
132
  // not the feature-emit-via-defineEvent convention used by set/cleared
133
133
  // (`custom-fields:event:<short>`).
@@ -36,7 +36,7 @@ export function createDeliveryFeature(): FeatureDefinition {
36
36
  r.entity("notification-preference", notificationPreferenceEntity, {
37
37
  table: notificationPreferencesTable,
38
38
  });
39
- r.unmanagedTable(deliveryAttemptsTableMeta, {
39
+ r.rawTable(deliveryAttemptsTableMeta, {
40
40
  reason: "read_side.delivery_attempt_log",
41
41
  });
42
42
 
@@ -20,7 +20,7 @@ describe("fileFoundationFeature — shape", () => {
20
20
  });
21
21
 
22
22
  describe("fileFoundationFeature.exports — typed handles", () => {
23
- test("exposes only the provider-selector config-key", () => {
23
+ test("exposes the provider-selector handle", () => {
24
24
  expect(fileFoundationFeature.exports.providerConfigKey).toBeDefined();
25
25
  expect(fileFoundationFeature.exports.providerConfigKey.name).toBe(
26
26
  "file-foundation:config:provider",
@@ -33,7 +33,9 @@ beforeAll(async () => {
33
33
  // Fail loud with an actionable message when Minio is down (otherwise
34
34
  // S3 networking errors look like provider bugs).
35
35
  try {
36
- const health = await fetch(`${endpoint.replace(/\/$/, "")}/minio/health/live`);
36
+ const health = await fetch(`${endpoint.replace(/\/$/, "")}/minio/health/live`, {
37
+ signal: AbortSignal.timeout(2000),
38
+ });
37
39
  if (!health.ok) {
38
40
  throw new Error(`HTTP ${health.status}`);
39
41
  }
@@ -44,7 +44,7 @@ describe("inboundMailFoundationFeature — shape", () => {
44
44
  test("SyncCursor + SeenMessage sind unmanaged (NICHT event-sourced, Plan §3.4)", () => {
45
45
  // Drift-Pin: als r.entity würden die Tick-State-Tabellen (a) das
46
46
  // Event-Log fluten und (b) beim Projection-Rebuild gewischt.
47
- const unmanaged = Object.keys(inboundMailFoundationFeature.unmanagedTables);
47
+ const unmanaged = Object.keys(inboundMailFoundationFeature.rawTables);
48
48
  expect(unmanaged.length).toBe(2);
49
49
  });
50
50
  });
@@ -156,12 +156,12 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
156
156
  });
157
157
 
158
158
  // Sync-Maschinerie: hochfrequenter Tick-State, bewusst NICHT
159
- // event-sourced — r.unmanagedTable hält die Migration-DDL, nimmt die
159
+ // event-sourced — r.rawTable hält die Migration-DDL, nimmt die
160
160
  // Tabellen aber aus dem Projection-Rebuild (#494/#498-Klasse).
161
- r.unmanagedTable(syncCursorTable, {
161
+ r.rawTable(syncCursorTable, {
162
162
  reason: "read_side.mail_sync_cursors_direct_write",
163
163
  });
164
- r.unmanagedTable(seenMessageTable, {
164
+ r.rawTable(seenMessageTable, {
165
165
  reason: "read_side.mail_seen_messages_direct_write",
166
166
  });
167
167
 
@@ -82,11 +82,11 @@ function probe(host: string, port: number): Promise<boolean> {
82
82
  });
83
83
  }
84
84
 
85
- const available = await probe(HOST, IMAP_PORT);
85
+ const available = (await probe(HOST, IMAP_PORT)) && (await probe(HOST, SMTP_PORT));
86
86
  const liveTest = available ? test : test.skip;
87
87
  if (!available) {
88
88
  console.warn(
89
- `imap-foundation: kein IMAP-Server auf ${HOST}:${IMAP_PORT} — Suite wird geskippt (greenmail starten)`,
89
+ `imap-foundation: kein IMAP/SMTP-Server auf ${HOST}:${IMAP_PORT}/${SMTP_PORT} — Suite wird geskippt (greenmail starten)`,
90
90
  );
91
91
  }
92
92
 
@@ -96,6 +96,7 @@ let secrets: ReturnType<typeof createSecretsContext>;
96
96
  let providerRef: MutableMasterKeyProvider;
97
97
 
98
98
  beforeAll(async () => {
99
+ if (!available) return;
99
100
  const initialKp = createEnvMasterKeyProvider({
100
101
  env: {
101
102
  KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
@@ -131,6 +132,7 @@ beforeAll(async () => {
131
132
  });
132
133
 
133
134
  afterAll(async () => {
135
+ if (!available) return;
134
136
  await stack.cleanup();
135
137
  resetPiiSubjectKmsForTests();
136
138
  });
@@ -143,13 +145,13 @@ function adminFor(tenantNumber: number) {
143
145
  });
144
146
  }
145
147
 
146
- function credentialJson(): string {
148
+ function credentialJson(passwordOverride?: string): string {
147
149
  return JSON.stringify({
148
150
  host: HOST,
149
151
  port: IMAP_PORT,
150
152
  secure: false,
151
153
  user: USER,
152
- password: PASSWORD,
154
+ password: passwordOverride ?? PASSWORD,
153
155
  });
154
156
  }
155
157
 
@@ -256,13 +258,7 @@ describe("imap-foundation — greenmail + supervisor", () => {
256
258
  await secrets.set(
257
259
  admin.tenantId,
258
260
  inboundCredentialSecretKey(connected.accountId),
259
- JSON.stringify({
260
- host: HOST,
261
- port: IMAP_PORT,
262
- secure: false,
263
- user: USER,
264
- password: "definitely-wrong",
265
- }),
261
+ credentialJson("definitely-wrong"),
266
262
  );
267
263
 
268
264
  const supervisor = createSupervisor();
@@ -38,7 +38,7 @@ export function createJobsFeature(): FeatureDefinition {
38
38
  recommended: false,
39
39
  });
40
40
  r.systemScope();
41
- r.unmanagedTable(jobRunLogsTableMeta, {
41
+ r.rawTable(jobRunLogsTableMeta, {
42
42
  reason: "read_side.job_run_logs",
43
43
  });
44
44
  // Events-only aggregate: "jobRun" has no r.entity registration, because
@@ -50,7 +50,7 @@ export type LegalPagesWrapLayout = (opts: {
50
50
  readonly title: string;
51
51
  readonly bodyHtml: string;
52
52
  readonly lang: string;
53
- readonly slug?: "imprint" | "privacy" | "terms";
53
+ readonly slug?: (typeof LEGAL_ROUTES)[number]["slug"];
54
54
  }) => string;
55
55
 
56
56
  export type LegalPagesOptions = {
@@ -28,7 +28,7 @@ describe("mailFoundationFeature — shape", () => {
28
28
  });
29
29
 
30
30
  describe("mailFoundationFeature.exports — typed handles", () => {
31
- test("exposes only the provider-selector config-key", () => {
31
+ test("exposes the provider-selector handle", () => {
32
32
  expect(mailFoundationFeature.exports.providerConfigKey).toBeDefined();
33
33
  expect(mailFoundationFeature.exports.providerConfigKey.name).toBe(
34
34
  "mail-foundation:config:provider",
@@ -73,6 +73,7 @@ export function brandingStyleBlock(tokens: BrandingTokens): string {
73
73
  decls.push(`--accent:${tokens.accentColor}`);
74
74
  }
75
75
  decls.push(`--page-max-width:${layoutMaxWidth(tokens.layoutPreset)}`);
76
+ // html-ok: decls are CSS custom-property declarations (regex-validated hex color or fixed table lookup), never raw user text.
76
77
  return `<style id="tenant-theme">:root{${decls.join(";")}}</style>`;
77
78
  }
78
79
 
@@ -91,9 +92,9 @@ export function brandingHeaderHtml(tokens: BrandingTokens): string {
91
92
  }
92
93
  if (parts.length === 0) return "";
93
94
 
94
- const inner = parts.join("\n");
95
+ const innerHtml = parts.join("\n");
95
96
  if (isSafeHttpsUrl(tokens.siteUrl)) {
96
- return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${inner}</a></header>`;
97
+ return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${innerHtml}</a></header>`;
97
98
  }
98
- return `<header class="brand-header">${inner}</header>`;
99
+ return `<header class="brand-header">${innerHtml}</header>`;
99
100
  }
@@ -53,7 +53,7 @@ export function createPersonalAccessTokensFeature(
53
53
  // Direct-write store like read_user_sessions: create/revoke write it, the
54
54
  // resolver point-reads it. r.entity would make it a rebuildable projection
55
55
  // whose replay (no token events) would wipe every live token (#498/#494).
56
- r.unmanagedTable(buildEntityTableMeta("api-token", apiTokenEntity), {
56
+ r.rawTable(buildEntityTableMeta("api-token", apiTokenEntity), {
57
57
  reason: "read_side.api_tokens_direct_write",
58
58
  // create.write encrypts `name` via encryptForDirectWrite (#820).
59
59
  piiEncryptedOnWrite: true,
@@ -511,6 +511,48 @@ describe("sessions feature — login → check → revoke → rejected", () => {
511
511
  });
512
512
  expect(asUser.status).toBe(403);
513
513
  });
514
+
515
+ // IDOR guard: session:detail/session:list are scoped by ctx.db (TenantDb)
516
+ // alone, no explicit tenant predicate in the handler — an admin from a
517
+ // different tenant must not be able to read another tenant's session rows.
518
+ test("session:detail and session:list never leak sessions across tenants", async () => {
519
+ const TENANT_2 = testTenantId(2);
520
+ const h2 = makeSessionHelpers(stack, TENANT_2);
521
+
522
+ const { userId: carolId } = await h.seedUser("carol4@example.com", "pw-long-enough");
523
+ await updateRows(
524
+ stack.db,
525
+ tenantMembershipsTable,
526
+ { roles: JSON.stringify(["Admin"]) },
527
+ { userId: carolId, tenantId: TENANT },
528
+ );
529
+ const carolAsAdmin = await h.login("carol4@example.com", "pw-long-enough");
530
+
531
+ const { userId: daveId } = await h2.seedUser("dave4@example.com", "pw-long-enough");
532
+ await updateRows(
533
+ stack.db,
534
+ tenantMembershipsTable,
535
+ { roles: JSON.stringify(["Admin"]) },
536
+ { userId: daveId, tenantId: TENANT_2 },
537
+ );
538
+ const daveAsAdmin = await h2.login("dave4@example.com", "pw-long-enough");
539
+
540
+ const detailRes = await h2.authedPost("/api/query", daveAsAdmin.token, {
541
+ type: SessionQueries.detail,
542
+ payload: { id: carolAsAdmin.sid },
543
+ });
544
+ expect(detailRes.status).toBe(200);
545
+ const detailBody = (await detailRes.json()) as { data: unknown };
546
+ expect(detailBody.data).toBeNull();
547
+
548
+ const listRes = await h2.authedPost("/api/query", daveAsAdmin.token, {
549
+ type: SessionQueries.list,
550
+ payload: {},
551
+ });
552
+ expect(listRes.status).toBe(200);
553
+ const listBody = (await listRes.json()) as { data: Array<{ id: string }> };
554
+ expect(listBody.data.map((r) => r.id)).not.toContain(carolAsAdmin.sid);
555
+ });
514
556
  });
515
557
 
516
558
  // Defense-in-depth: the sessionChecker refuses a live sid once the user it
@@ -93,10 +93,10 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
93
93
  // r.entity would make it a rebuildable implicit projection whose replay
94
94
  // finds zero session events and swaps an empty shadow over the live
95
95
  // table — wiping every active session on the next projection rebuild
96
- // (#498/#494). r.unmanagedTable keeps the migration DDL but opts the
96
+ // (#498/#494). r.rawTable keeps the migration DDL but opts the
97
97
  // table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
98
98
  // which are direct-write stores too.
99
- r.unmanagedTable(buildEntityTableMeta("user-session", userSessionEntity), {
99
+ r.rawTable(buildEntityTableMeta("user-session", userSessionEntity), {
100
100
  reason: "read_side.user_sessions_direct_write",
101
101
  // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
102
102
  piiEncryptedOnWrite: true,