@cosmicdrift/kumiko-bundled-features 0.156.3 → 0.157.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 (37) hide show
  1. package/package.json +6 -6
  2. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +31 -0
  3. package/src/auth-mfa/__tests__/verify.integration.test.ts +17 -19
  4. package/src/auth-mfa/constants.ts +3 -8
  5. package/src/auth-mfa/errors.ts +7 -7
  6. package/src/auth-mfa/web/i18n.ts +5 -0
  7. package/src/auth-mfa/web/index.ts +0 -1
  8. package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -2
  9. package/src/auth-mfa/web/mfa-enable-screen.tsx +5 -6
  10. package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -2
  11. package/src/delivery/feature.ts +2 -2
  12. package/src/delivery/tables.ts +2 -2
  13. package/src/feature-toggles/feature.ts +1 -1
  14. package/src/feature-toggles/global-feature-state-table.ts +1 -1
  15. package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
  16. package/src/inbound-mail-foundation/entities.ts +2 -2
  17. package/src/inbound-mail-foundation/feature.ts +3 -3
  18. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +2 -2
  19. package/src/inbound-mail-foundation/retention-sweep.ts +1 -1
  20. package/src/inbound-mail-foundation/types.ts +1 -1
  21. package/src/inbound-mail-foundation/watch-supervisor.ts +1 -1
  22. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +107 -0
  23. package/src/jobs/feature.ts +13 -2
  24. package/src/jobs/handlers/reindex-entity.job.ts +49 -0
  25. package/src/jobs/job-run-table.ts +2 -2
  26. package/src/personal-access-tokens/feature.ts +3 -3
  27. package/src/personal-access-tokens/schema/api-token.ts +3 -3
  28. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +4 -4
  29. package/src/sessions/db/queries/cleanup.ts +2 -2
  30. package/src/sessions/feature.ts +5 -5
  31. package/src/sessions/schema/user-session.ts +1 -1
  32. package/src/tenant/handlers/invitations.query.ts +8 -3
  33. package/src/tenant/handlers/members.query.ts +20 -11
  34. package/src/tenant-lifecycle/stages.ts +1 -1
  35. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +6 -6
  36. package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +0 -20
  37. package/src/auth-mfa/web/mfa-error-keys.ts +0 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.156.3",
3
+ "version": "0.157.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>",
@@ -116,11 +116,11 @@
116
116
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
117
117
  },
118
118
  "dependencies": {
119
- "@cosmicdrift/kumiko-dispatcher-live": "0.156.3",
120
- "@cosmicdrift/kumiko-framework": "0.156.3",
121
- "@cosmicdrift/kumiko-headless": "0.156.3",
122
- "@cosmicdrift/kumiko-renderer": "0.156.3",
123
- "@cosmicdrift/kumiko-renderer-web": "0.156.3",
119
+ "@cosmicdrift/kumiko-dispatcher-live": "0.157.1",
120
+ "@cosmicdrift/kumiko-framework": "0.157.1",
121
+ "@cosmicdrift/kumiko-headless": "0.157.1",
122
+ "@cosmicdrift/kumiko-renderer": "0.157.1",
123
+ "@cosmicdrift/kumiko-renderer-web": "0.157.1",
124
124
  "@mollie/api-client": "^4.5.0",
125
125
  "imapflow": "^1.3.3",
126
126
  "mailparser": "^3.9.8",
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ invalidChallengeToken,
4
+ invalidRecoveryCode,
5
+ invalidSetupToken,
6
+ invalidTotpCode,
7
+ mfaAlreadyEnabled,
8
+ mfaNotEnabled,
9
+ tooManyAttempts,
10
+ } from "../errors";
11
+ import { defaultTranslations } from "../web/i18n";
12
+
13
+ const failures = {
14
+ mfaAlreadyEnabled: mfaAlreadyEnabled(),
15
+ mfaNotEnabled: mfaNotEnabled(),
16
+ invalidSetupToken: invalidSetupToken(),
17
+ invalidTotpCode: invalidTotpCode(),
18
+ invalidRecoveryCode: invalidRecoveryCode(),
19
+ invalidChallengeToken: invalidChallengeToken(),
20
+ tooManyAttempts: tooManyAttempts(30),
21
+ };
22
+
23
+ describe("auth-mfa errors.ts i18nKey / client-bundle parity", () => {
24
+ for (const [name, failure] of Object.entries(failures)) {
25
+ test(`${name}'s i18nKey is registered in en/de translations`, () => {
26
+ const key = failure.error.i18nKey;
27
+ expect(defaultTranslations["en"]?.[key]).toBeDefined();
28
+ expect(defaultTranslations["de"]?.[key]).toBeDefined();
29
+ });
30
+ }
31
+ });
@@ -90,15 +90,26 @@ async function enableMfaFor(idSeed: number): Promise<{
90
90
  { setupToken: start.setupToken, code: currentTotpCode(secret) },
91
91
  user,
92
92
  );
93
- // verify.write.ts re-checks tenant membership the same way login.write.ts
94
- // does without a seeded row here every verify would now hit the
95
- // membership-revoked gate below instead of the scenario each test means
96
- // to exercise.
93
+ // verify.write.ts re-checks tenant membership AND the user's own row
94
+ // (status gate) the way login.write.ts does auth-mfa never writes a
95
+ // read_users row itself (enableStart/enableConfirm only touch user-mfa),
96
+ // so both need seeding here, not just per-test where a scenario cares.
97
97
  await seedRow(stack.db, tenantMembershipsTable, {
98
98
  userId: user.id,
99
99
  tenantId: user.tenantId,
100
100
  roles: JSON.stringify(["User"]),
101
101
  });
102
+ await seedRow(stack.db, userTable, {
103
+ id: user.id,
104
+ tenantId: user.tenantId,
105
+ email: `user-${user.id}@example.com`,
106
+ passwordHash: "h",
107
+ displayName: `Test User ${idSeed}`,
108
+ locale: "de",
109
+ emailVerified: true,
110
+ roles: "[]",
111
+ status: USER_STATUS.Active,
112
+ });
102
113
  return { user, secret, recoveryCodes: start.recoveryCodes };
103
114
  }
104
115
 
@@ -247,21 +258,8 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
247
258
  const { user, secret } = await enableMfaFor(6);
248
259
  const challengeToken = challengeFor(user.id, user.tenantId);
249
260
 
250
- // auth-mfa never writes a read_users row itself (enableMfaFor only
251
- // drives enable-start/enable-confirm) seed one so the status gate has
252
- // a row to actually flip, then flip it to simulate a restriction landing
253
- // between login and verify.
254
- await seedRow(stack.db, userTable, {
255
- id: user.id,
256
- tenantId: user.tenantId,
257
- email: `user-${user.id}@example.com`,
258
- passwordHash: "h",
259
- displayName: "Restricted User",
260
- locale: "de",
261
- emailVerified: true,
262
- roles: "[]",
263
- status: USER_STATUS.Active,
264
- });
261
+ // enableMfaFor already seeded the user row Active flip it to simulate
262
+ // a restriction landing between login and verify.
265
263
  await asRawClient(stack.db).unsafe(
266
264
  `UPDATE "${userTable.tableName}" SET status = $1 WHERE id = $2`,
267
265
  [USER_STATUS.Restricted, user.id],
@@ -19,10 +19,9 @@ export const AuthMfaQueries = {
19
19
  status: "auth-mfa:query:user-mfa:status",
20
20
  } as const;
21
21
 
22
- // Write-failure codes shared between errors.ts (server, mints them into
23
- // UnprocessableError) and web/mfa-error-keys.ts (client, maps them to i18n
24
- // keys) one source so a renamed code can't drift out of sync between the
25
- // two switches.
22
+ // Write-failure codes minted server-side into UnprocessableError (errors.ts).
23
+ // The client reads the i18n key straight off the write result
24
+ // (res.error.i18nKey) instead of re-deriving it from these codes.
26
25
  export const AuthMfaErrorCodes = {
27
26
  mfaAlreadyEnabled: "mfa_already_enabled",
28
27
  mfaNotEnabled: "mfa_not_enabled",
@@ -31,10 +30,6 @@ export const AuthMfaErrorCodes = {
31
30
  invalidRecoveryCode: "invalid_recovery_code",
32
31
  invalidChallengeToken: "invalid_challenge_token",
33
32
  tooManyAttempts: "too_many_attempts",
34
- // Client-only: minted by mfa-enable-screen.tsx on a network/QR-render
35
- // failure during enrollment, never by errors.ts server-side — kept here
36
- // anyway so mfa-error-keys.ts's key-parity test covers it automatically.
37
- setupFailed: "setup_failed",
38
33
  } as const;
39
34
 
40
35
  export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
@@ -10,7 +10,7 @@ export { AuthMfaErrorCodes as AuthMfaErrors } from "./constants";
10
10
  export function mfaAlreadyEnabled(): WriteFailure {
11
11
  return writeFailure(
12
12
  new UnprocessableError(AuthMfaErrorCodes.mfaAlreadyEnabled, {
13
- i18nKey: "authMfa.errors.mfaAlreadyEnabled",
13
+ i18nKey: "auth.mfa.errors.mfaAlreadyEnabled",
14
14
  }),
15
15
  );
16
16
  }
@@ -18,7 +18,7 @@ export function mfaAlreadyEnabled(): WriteFailure {
18
18
  export function mfaNotEnabled(): WriteFailure {
19
19
  return writeFailure(
20
20
  new UnprocessableError(AuthMfaErrorCodes.mfaNotEnabled, {
21
- i18nKey: "authMfa.errors.mfaNotEnabled",
21
+ i18nKey: "auth.mfa.errors.mfaNotEnabled",
22
22
  }),
23
23
  );
24
24
  }
@@ -26,7 +26,7 @@ export function mfaNotEnabled(): WriteFailure {
26
26
  export function invalidSetupToken(): WriteFailure {
27
27
  return writeFailure(
28
28
  new UnprocessableError(AuthMfaErrorCodes.invalidSetupToken, {
29
- i18nKey: "authMfa.errors.invalidSetupToken",
29
+ i18nKey: "auth.mfa.errors.invalidSetupToken",
30
30
  }),
31
31
  );
32
32
  }
@@ -34,7 +34,7 @@ export function invalidSetupToken(): WriteFailure {
34
34
  export function invalidTotpCode(): WriteFailure {
35
35
  return writeFailure(
36
36
  new UnprocessableError(AuthMfaErrorCodes.invalidTotpCode, {
37
- i18nKey: "authMfa.errors.invalidTotpCode",
37
+ i18nKey: "auth.mfa.errors.invalidTotpCode",
38
38
  }),
39
39
  );
40
40
  }
@@ -42,7 +42,7 @@ export function invalidTotpCode(): WriteFailure {
42
42
  export function invalidRecoveryCode(): WriteFailure {
43
43
  return writeFailure(
44
44
  new UnprocessableError(AuthMfaErrorCodes.invalidRecoveryCode, {
45
- i18nKey: "authMfa.errors.invalidRecoveryCode",
45
+ i18nKey: "auth.mfa.errors.invalidRecoveryCode",
46
46
  }),
47
47
  );
48
48
  }
@@ -50,7 +50,7 @@ export function invalidRecoveryCode(): WriteFailure {
50
50
  export function invalidChallengeToken(): WriteFailure {
51
51
  return writeFailure(
52
52
  new UnprocessableError(AuthMfaErrorCodes.invalidChallengeToken, {
53
- i18nKey: "authMfa.errors.invalidChallengeToken",
53
+ i18nKey: "auth.mfa.errors.invalidChallengeToken",
54
54
  }),
55
55
  );
56
56
  }
@@ -59,7 +59,7 @@ export function invalidChallengeToken(): WriteFailure {
59
59
  export function tooManyAttempts(retryAfterSeconds: number): WriteFailure {
60
60
  return writeFailure(
61
61
  new UnprocessableError(AuthMfaErrorCodes.tooManyAttempts, {
62
- i18nKey: "authMfa.errors.tooManyAttempts",
62
+ i18nKey: "auth.mfa.errors.tooManyAttempts",
63
63
  details: { retryAfterSeconds },
64
64
  }),
65
65
  );
@@ -15,6 +15,9 @@ export const defaultTranslations: TranslationsByLocale = {
15
15
  "auth.mfa.verify.submitting": "…",
16
16
  "auth.mfa.verify.backToLogin": "Zurück zum Login",
17
17
  "auth.mfa.errors.invalidCode": "Ungültiger Code. Bitte erneut versuchen.",
18
+ "auth.mfa.errors.invalidTotpCode": "Ungültiger Code. Bitte erneut versuchen.",
19
+ "auth.mfa.errors.invalidChallengeToken":
20
+ "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
18
21
  "auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
19
22
  "auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
20
23
  "auth.mfa.errors.verifyFailed": "Bestätigung fehlgeschlagen.",
@@ -66,6 +69,8 @@ export const defaultTranslations: TranslationsByLocale = {
66
69
  "auth.mfa.verify.submitting": "…",
67
70
  "auth.mfa.verify.backToLogin": "Back to login",
68
71
  "auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
72
+ "auth.mfa.errors.invalidTotpCode": "Invalid code. Please try again.",
73
+ "auth.mfa.errors.invalidChallengeToken": "Your sign-in has expired. Please sign in again.",
69
74
  "auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
70
75
  "auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",
71
76
  "auth.mfa.errors.verifyFailed": "Verification failed.",
@@ -13,7 +13,6 @@ export type { MfaDisableDialogProps } from "./mfa-disable-dialog";
13
13
  export { MfaDisableDialog } from "./mfa-disable-dialog";
14
14
  export type { MfaEnableScreenProps } from "./mfa-enable-screen";
15
15
  export { MfaEnableScreen } from "./mfa-enable-screen";
16
- export { mfaManageErrorKey } from "./mfa-error-keys";
17
16
  export type { MfaRecoveryCodesRevealProps } from "./mfa-recovery-codes-reveal";
18
17
  export { MfaRecoveryCodesReveal } from "./mfa-recovery-codes-reveal";
19
18
  export type { MfaRegenerateRecoveryDialogProps } from "./mfa-regenerate-recovery-dialog";
@@ -13,7 +13,6 @@
13
13
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
14
14
  import { type ReactNode, useState } from "react";
15
15
  import { AuthMfaHandlers } from "../constants";
16
- import { mfaManageErrorKey } from "./mfa-error-keys";
17
16
 
18
17
  export type MfaDisableDialogProps = {
19
18
  readonly open: boolean;
@@ -37,7 +36,7 @@ export function MfaDisableDialog({
37
36
  const res = await dispatcher.write<{ disabled: boolean }>(AuthMfaHandlers.disable, { code });
38
37
  setCode("");
39
38
  if (!res.isSuccess) {
40
- onError(mfaManageErrorKey(res.error.code));
39
+ onError(res.error.i18nKey);
41
40
  return;
42
41
  }
43
42
  onDisabled();
@@ -21,7 +21,6 @@ import { type ReactNode, useState } from "react";
21
21
  // kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
22
22
  import { useSession } from "../../auth-email-password/web";
23
23
  import { AuthMfaHandlers } from "../constants";
24
- import { mfaManageErrorKey } from "./mfa-error-keys";
25
24
 
26
25
  type EnableStartResponse = {
27
26
  readonly setupToken: string;
@@ -77,7 +76,7 @@ export function MfaEnableScreen({
77
76
  accountLabel: session.user?.email ?? "",
78
77
  });
79
78
  if (!res.isSuccess) {
80
- setError(res.error.code);
79
+ setError(res.error.i18nKey);
81
80
  return;
82
81
  }
83
82
  // errorCorrectionLevel "H" (~30% redundancy) — more resilient to
@@ -96,7 +95,7 @@ export function MfaEnableScreen({
96
95
  setAcknowledged(false);
97
96
  setCode("");
98
97
  } catch {
99
- setError("setup_failed");
98
+ setError("auth.mfa.errors.setupFailed");
100
99
  } finally {
101
100
  setBusy(false);
102
101
  }
@@ -112,14 +111,14 @@ export function MfaEnableScreen({
112
111
  code,
113
112
  });
114
113
  if (!res.isSuccess) {
115
- setError(res.error.code);
114
+ setError(res.error.i18nKey);
116
115
  return;
117
116
  }
118
117
  setEnabled(true);
119
118
  setSetup(null);
120
119
  onEnabled?.();
121
120
  } catch {
122
- setError("setup_failed");
121
+ setError("auth.mfa.errors.setupFailed");
123
122
  } finally {
124
123
  setBusy(false);
125
124
  }
@@ -130,7 +129,7 @@ export function MfaEnableScreen({
130
129
  <Heading>{t("auth.mfa.enable.title")}</Heading>
131
130
 
132
131
  {enabled && <Banner variant="info">{t("auth.mfa.enable.success")}</Banner>}
133
- {error !== null && <Banner variant="error">{t(mfaManageErrorKey(error))}</Banner>}
132
+ {error !== null && <Banner variant="error">{t(error)}</Banner>}
134
133
 
135
134
  {!setup && !enabled && (
136
135
  <Section
@@ -10,7 +10,6 @@
10
10
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
11
  import { type ReactNode, useState } from "react";
12
12
  import { AuthMfaHandlers } from "../constants";
13
- import { mfaManageErrorKey } from "./mfa-error-keys";
14
13
 
15
14
  export type MfaRegenerateRecoveryDialogProps = {
16
15
  readonly open: boolean;
@@ -37,7 +36,7 @@ export function MfaRegenerateRecoveryDialog({
37
36
  );
38
37
  setCode("");
39
38
  if (!res.isSuccess) {
40
- onError(mfaManageErrorKey(res.error.code));
39
+ onError(res.error.i18nKey);
41
40
  return;
42
41
  }
43
42
  onRegenerated(res.data.recoveryCodes);
@@ -22,7 +22,7 @@ import {
22
22
  export function createDeliveryFeature(): FeatureDefinition {
23
23
  return defineFeature("delivery", (r) => {
24
24
  r.describe(
25
- "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `read_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
25
+ "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
26
26
  );
27
27
  r.uiHints({
28
28
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -36,7 +36,7 @@ export function createDeliveryFeature(): FeatureDefinition {
36
36
  r.entity("notification-preference", notificationPreferenceEntity, {
37
37
  table: notificationPreferencesTable,
38
38
  });
39
- r.rawTable(deliveryAttemptsTableMeta, {
39
+ r.storeTable(deliveryAttemptsTableMeta, {
40
40
  reason: "read_side.delivery_attempt_log",
41
41
  });
42
42
 
@@ -28,7 +28,7 @@ import {
28
28
  // PK = event aggregate-id (uuid). Keeps the projection row linked back to
29
29
  // its event stream 1:1 — same convention as jobRunsTable + tenantSecretsTable.
30
30
  // Event replays stay idempotent (primary-key conflict instead of duplicate rows).
31
- export const deliveryAttemptsTable = pgTable("read_delivery_attempts", {
31
+ export const deliveryAttemptsTable = pgTable("store_delivery_attempts", {
32
32
  id: uuid("id").primaryKey(),
33
33
  tenantId: uuid("tenant_id").notNull(),
34
34
  notificationType: text("notification_type").notNull(),
@@ -53,7 +53,7 @@ export const deliveryAttemptsTable = pgTable("read_delivery_attempts", {
53
53
  // pgTable bleibt source-of-truth für Query-API; Phase 4 leitet das pgTable
54
54
  // aus dieser Meta ab.
55
55
  export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
56
- tableName: "read_delivery_attempts",
56
+ tableName: "store_delivery_attempts",
57
57
  columns: [
58
58
  { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
59
59
  { name: "tenant_id", pgType: "uuid", notNull: true },
@@ -39,7 +39,7 @@ export function createFeatureTogglesFeature(
39
39
  ): FeatureDefinition {
40
40
  return defineFeature("feature-toggles", (r) => {
41
41
  r.describe(
42
- 'Persists per-feature enabled/disabled state in the `read_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
42
+ 'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
43
43
  );
44
44
  r.uiHints({
45
45
  displayLabel: "Feature Toggles · Operator Switches",
@@ -15,7 +15,7 @@ import {
15
15
  // name IS the identity here. No tenantId: this is a global override that
16
16
  // applies across every tenant (per-tenant toggles are intentionally out of
17
17
  // scope, see core-feature-toggles.md).
18
- export const globalFeatureStateTable = pgTable("read_global_feature_state", {
18
+ export const globalFeatureStateTable = pgTable("store_global_feature_state", {
19
19
  featureName: text("feature_name").primaryKey(),
20
20
  enabled: boolean("enabled").notNull(),
21
21
  // Optimistic-lock column. The set-handler reads the existing row, then
@@ -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.rawTables);
47
+ const unmanaged = Object.keys(inboundMailFoundationFeature.storeTables);
48
48
  expect(unmanaged.length).toBe(2);
49
49
  });
50
50
  });
@@ -111,7 +111,7 @@ export const MAIL_THREAD_PII_FIELDS = collectPiiSubjectFields(mailThreadEntity);
111
111
  * Write-locked auf privileged — nur Foundation-Handler/Supervisor
112
112
  * schreiben, kein Tenant-Request. */
113
113
  export const syncCursorEntity = createEntity({
114
- table: "read_mail_sync_cursors",
114
+ table: "store_mail_sync_cursors",
115
115
  softDelete: false,
116
116
  fields: {
117
117
  accountId: createTextField({
@@ -141,7 +141,7 @@ export const syncCursorEntity = createEntity({
141
141
  * ingest-handler prüft zuerst hier (cheap), der Stream-Scan bleibt
142
142
  * Defense-in-depth. */
143
143
  export const seenMessageEntity = createEntity({
144
- table: "read_mail_seen_messages",
144
+ table: "store_mail_seen_messages",
145
145
  softDelete: false,
146
146
  fields: {
147
147
  accountId: createTextField({
@@ -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.rawTable hält die Migration-DDL, nimmt die
159
+ // event-sourced — r.storeTable hält die Migration-DDL, nimmt die
160
160
  // Tabellen aber aus dem Projection-Rebuild (#494/#498-Klasse).
161
- r.rawTable(syncCursorTable, {
161
+ r.storeTable(syncCursorTable, {
162
162
  reason: "read_side.mail_sync_cursors_direct_write",
163
163
  });
164
- r.rawTable(seenMessageTable, {
164
+ r.storeTable(seenMessageTable, {
165
165
  reason: "read_side.mail_seen_messages_direct_write",
166
166
  });
167
167
 
@@ -2,7 +2,7 @@
2
2
  // (watch-callback + fetchSince-Sync). Nimmt eine normalisierte
3
3
  // RawInboundMessage und macht atomic in EINER TX:
4
4
  //
5
- // 1. Idempotency-Check (cheap: read_mail_seen_messages; Defense-in-
5
+ // 1. Idempotency-Check (cheap: store_mail_seen_messages; Defense-in-
6
6
  // depth: Stream-Existenz auf der deterministic aggregateId).
7
7
  // 2. threadKey-Normalisierung (Provider-Thread-ID bevorzugt, sonst
8
8
  // References-Chain-Root, sonst Message-ID = Single-Message-Thread).
@@ -115,7 +115,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
115
115
  // ein früherer ingest NACH dem append aber VOR dem seen-insert
116
116
  // gestorben ist (kann in einer TX nicht passieren — aber der
117
117
  // Check ist billig und macht den Handler rebuild-robust falls
118
- // read_mail_seen_messages je getruncated wird).
118
+ // store_mail_seen_messages je getruncated wird).
119
119
  // ---------------------------------------------------------------
120
120
  const seenRows = await selectMany(ctx.db.raw, seenMessageTable, {
121
121
  accountId: payload.accountId,
@@ -13,7 +13,7 @@
13
13
  // loadAggregate leer → received wird gar nicht erst repliziert. Gleiches
14
14
  // Muster wie tenant-destroy-hook, nur per-Row-cutoff statt ganzer Tenant.
15
15
  //
16
- // read_mail_seen_messages (unmanaged, direct-write, nicht event-sourced):
16
+ // store_mail_seen_messages (unmanaged, direct-write, nicht event-sourced):
17
17
  // Dedup-Anker braucht nur das Backfill-/Replay-Fenster → seenAt-cutoff
18
18
  // via plain deleteMany, kein Stream/Archiv. Gescoped direkt über die
19
19
  // tenant_id-Spalte der Tabelle (table-builder-Konvention), kein Account-Join.
@@ -92,7 +92,7 @@ export type RawInboundMessage = {
92
92
 
93
93
  /** Provider-opaker Cursor: IMAP {uidValidity,lastUid} · Graph {deltaLink}
94
94
  * · Gmail {historyId}. Foundation persisted ihn JSON-stringified in
95
- * read_mail_sync_cursors, interpretiert ihn nie. */
95
+ * store_mail_sync_cursors, interpretiert ihn nie. */
96
96
  export type SyncCursorPayload = Readonly<Record<string, unknown>>;
97
97
 
98
98
  export type InboundFetchResult = {
@@ -63,7 +63,7 @@ export type InboundMailSupervisorDeps = {
63
63
  * Credential-Reads der Provider). */
64
64
  readonly providerCtx: InboundMailContext;
65
65
  /** App-DB — direct reads auf read_mail_accounts (Account-Snapshot)
66
- * + direct writes auf read_mail_sync_cursors (unmanaged store). */
66
+ * + direct writes auf store_mail_sync_cursors (unmanaged store). */
67
67
  readonly db: DbConnection;
68
68
  /** Standard-Dispatcher mit SystemUser — trägt ingest-message +
69
69
  * update-account. */
@@ -0,0 +1,107 @@
1
+ // #1215: the `jobs` feature registers `reindexEntity` as a manual + perTenant
2
+ // job. perTenant jobs fan out through a BullMQ wrapper that needs
3
+ // `getActiveTenantIds` (job-runner.ts) — setupTestStack's jobRunner doesn't
4
+ // wire that, so this calls the exported handler directly with a constructed
5
+ // context instead of going through jobRunner.dispatch(), same as
6
+ // inbound-mail-foundation's retention job test calls its sweep function
7
+ // directly rather than round-tripping through the queue.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import {
11
+ buildEntityTable,
12
+ createEventStoreExecutor,
13
+ createTenantDb,
14
+ } from "@cosmicdrift/kumiko-framework/db";
15
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ TestUsers,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { createJobsFeature } from "../feature";
24
+ import { reindexEntityJob } from "../handlers/reindex-entity.job";
25
+
26
+ // Resolved job name — operators trigger this via jobs:write:trigger with
27
+ // { jobName: "jobs:job:reindex-entity", payload: { entity: "..." } }.
28
+ const REINDEX_ENTITY_JOB = "jobs:job:reindex-entity";
29
+
30
+ const widgetEntity = createEntity({
31
+ table: "read_reindex_job_widgets",
32
+ fields: {
33
+ name: createTextField({ required: true, searchable: true }),
34
+ },
35
+ });
36
+
37
+ const widgetTable = buildEntityTable("widget", widgetEntity);
38
+
39
+ const widgetFeature = defineFeature("reindex-job-test", (r) => {
40
+ r.entity("widget", widgetEntity);
41
+ });
42
+
43
+ let stack: TestStack;
44
+ const admin = TestUsers.admin;
45
+
46
+ beforeAll(async () => {
47
+ stack = await setupTestStack({ features: [widgetFeature, createJobsFeature()] });
48
+ await unsafeCreateEntityTable(stack.db, widgetEntity);
49
+ await createEventsTable(stack.db);
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await stack.cleanup();
54
+ });
55
+
56
+ describe("jobs feature: reindexEntity registration", () => {
57
+ test("registers reindexEntity as jobs:job:reindex-entity — the name jobs:write:trigger dispatches by", () => {
58
+ const job = stack.registry.getJob(REINDEX_ENTITY_JOB);
59
+ expect(job).toBeDefined();
60
+ expect(job?.handler).toBe(reindexEntityJob);
61
+ expect(job?.perTenant).toBe(true);
62
+ expect(job?.trigger).toEqual({ manual: true });
63
+ });
64
+ });
65
+
66
+ describe("reindexEntityJob", () => {
67
+ test("indexes rows for the tenant resolved from ctx.systemUser", async () => {
68
+ const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
69
+ const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
70
+ const created = await executor.create({ name: "Job Backfillable Widget" }, admin, tenantDb);
71
+ if (!created.isSuccess) throw new Error("seed failed");
72
+
73
+ // No stack.eventDispatcher.runOnce() — row was never indexed live.
74
+ const preResults = await stack.search.search(admin.tenantId, "backfillable", {
75
+ filterType: "widget",
76
+ });
77
+ expect(preResults).toHaveLength(0);
78
+
79
+ await reindexEntityJob(
80
+ { entity: "widget" },
81
+ { db: stack.db, registry: stack.registry, searchAdapter: stack.search, systemUser: admin },
82
+ );
83
+
84
+ const postResults = await stack.search.search(admin.tenantId, "backfillable", {
85
+ filterType: "widget",
86
+ });
87
+ expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
88
+ });
89
+
90
+ test("skips silently when no tenant is resolvable (fan-out misfire)", async () => {
91
+ await expect(
92
+ reindexEntityJob(
93
+ { entity: "widget" },
94
+ { db: stack.db, registry: stack.registry, searchAdapter: stack.search },
95
+ ),
96
+ ).resolves.toBeUndefined();
97
+ });
98
+
99
+ test("throws when ctx.searchAdapter is missing", async () => {
100
+ await expect(
101
+ reindexEntityJob(
102
+ { entity: "widget" },
103
+ { db: stack.db, registry: stack.registry, systemUser: admin },
104
+ ),
105
+ ).rejects.toThrow(/searchAdapter/);
106
+ });
107
+ });
@@ -17,6 +17,7 @@ import {
17
17
  projectionRebuildJob,
18
18
  projectionRebuildPayloadSchema,
19
19
  } from "./handlers/projection-rebuild.job";
20
+ import { reindexEntityJob, reindexEntityPayloadSchema } from "./handlers/reindex-entity.job";
20
21
  import { retryWrite } from "./handlers/retry.write";
21
22
  import { triggerWrite } from "./handlers/trigger.write";
22
23
  import { JOBS_I18N } from "./i18n";
@@ -30,7 +31,7 @@ import { jobRunLogsTable, jobRunLogsTableMeta, jobRunsTable } from "./job-run-ta
30
31
  export function createJobsFeature(): FeatureDefinition {
31
32
  return defineFeature("jobs", (r) => {
32
33
  r.describe(
33
- "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `read_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list` and `jobs:query:details` for the operator UI.",
34
+ "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list` and `jobs:query:details` for the operator UI.",
34
35
  );
35
36
  r.uiHints({
36
37
  displayLabel: "Jobs · Audit & Operator UI",
@@ -38,7 +39,7 @@ export function createJobsFeature(): FeatureDefinition {
38
39
  recommended: false,
39
40
  });
40
41
  r.systemScope();
41
- r.rawTable(jobRunLogsTableMeta, {
42
+ r.storeTable(jobRunLogsTableMeta, {
42
43
  reason: "read_side.job_run_logs",
43
44
  });
44
45
  // Events-only aggregate: "jobRun" has no r.entity registration, because
@@ -174,6 +175,16 @@ export function createJobsFeature(): FeatureDefinition {
174
175
  projectionRebuildJob,
175
176
  );
176
177
 
178
+ // Retroactive search backfill (#1206/#1215) — manual + perTenant, so one
179
+ // `jobs:write:trigger` call with { entity } fans out to every active
180
+ // tenant (job-runner.ts perTenant dispatch applies to manual triggers
181
+ // too, not just cron).
182
+ r.job(
183
+ "reindexEntity",
184
+ { trigger: { manual: true }, perTenant: true, schema: reindexEntityPayloadSchema },
185
+ reindexEntityJob,
186
+ );
187
+
177
188
  const handlers = {
178
189
  trigger: r.writeHandler(triggerWrite),
179
190
  retry: r.writeHandler(retryWrite),
@@ -0,0 +1,49 @@
1
+ // Manual, perTenant backfill job (#1206/#1215) — indexes existing rows for
2
+ // an entity that only got `searchable: true` after rows already existed.
3
+ // perTenant fan-out (job-runner.ts) also applies to a manual dispatch, not
4
+ // just cron: one `jobs:write:trigger` call with { entity } re-runs once per
5
+ // active tenant.
6
+
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
11
+ import { z } from "zod";
12
+
13
+ export const reindexEntityPayloadSchema = z.object({
14
+ entity: z.string().min(1),
15
+ });
16
+
17
+ export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
18
+ const { entity } = reindexEntityPayloadSchema.parse(rawPayload);
19
+ if (!ctx.db) {
20
+ throw new InternalError({
21
+ message: "[jobs:reindex-entity] ctx.db missing — job context requires a database connection.",
22
+ });
23
+ }
24
+ if (!ctx.registry) {
25
+ throw new InternalError({
26
+ message: "[jobs:reindex-entity] ctx.registry missing — job context requires the registry.",
27
+ });
28
+ }
29
+ if (!ctx.searchAdapter) {
30
+ throw new InternalError({
31
+ message:
32
+ "[jobs:reindex-entity] ctx.searchAdapter missing — mount a search adapter to run this job.",
33
+ });
34
+ }
35
+ // perTenant fan-out — the job-runner enqueues one child run per active
36
+ // tenant with _tenantId set; a run without one (e.g. misconfigured
37
+ // manual dispatch outside the fan-out) has nothing scoped to reindex.
38
+ const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
39
+ if (tenantId === undefined) {
40
+ // skip: fired without a perTenant fan-out tenant — nothing scoped to reindex
41
+ return;
42
+ }
43
+ const db = ctx.db as DbConnection; // @cast-boundary db-operator
44
+ const result = await reindexEntity(db, ctx.registry, ctx.searchAdapter, entity, tenantId);
45
+ ctx.log?.info?.(
46
+ `[jobs:reindex-entity] tenant=${tenantId} reindexed ${entity}: ${result.indexedRows}/${result.scannedRows} rows indexed` +
47
+ `${result.failures.length > 0 ? ` (${result.failures.length} failed)` : ""}`,
48
+ );
49
+ };
@@ -60,7 +60,7 @@ export const jobRunsTable = buildEntityTable("job-run", jobRunEntity);
60
60
  // uuid of the parent jobRun. Existing detail-query callers treat it as an
61
61
  // opaque identifier, so the type-switch is backward-compatible at the
62
62
  // query surface.
63
- export const jobRunLogsTable = pgTable("read_job_run_logs", {
63
+ export const jobRunLogsTable = pgTable("store_job_run_logs", {
64
64
  id: serial("id").primaryKey(),
65
65
  runId: text("run_id").notNull(),
66
66
  level: text("level").notNull().$type<JobLogLevel>(),
@@ -76,7 +76,7 @@ export const jobRunLogsTable = pgTable("read_job_run_logs", {
76
76
  // pgTable bleibt source-of-truth für Query-API; Phase 4 leitet das pgTable
77
77
  // aus dieser Meta ab.
78
78
  export const jobRunLogsTableMeta: EntityTableMeta = defineUnmanagedTable({
79
- tableName: "read_job_run_logs",
79
+ tableName: "store_job_run_logs",
80
80
  columns: [
81
81
  { name: "id", pgType: "serial", notNull: true, primaryKey: true },
82
82
  { name: "run_id", pgType: "text", notNull: true },
@@ -40,7 +40,7 @@ export function createPersonalAccessTokensFeature(
40
40
  const { scopes } = options;
41
41
  return defineFeature(PAT_FEATURE, (r) => {
42
42
  r.describe(
43
- "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `read_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
43
+ "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
44
44
  );
45
45
  r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
46
46
  // Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
@@ -50,10 +50,10 @@ export function createPersonalAccessTokensFeature(
50
50
  // Resolver reads memberships + users on every PAT request to build live
51
51
  // roles — make both boot-time deps so a mis-wiring fails validateBoot.
52
52
  r.requires("user", "tenant");
53
- // Direct-write store like read_user_sessions: create/revoke write it, the
53
+ // Direct-write store like store_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.rawTable(buildEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
56
+ r.storeTable(buildEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
57
57
  reason: "read_side.api_tokens_direct_write",
58
58
  // create.write encrypts `name` via encryptForDirectWrite (#820).
59
59
  piiEncryptedOnWrite: true,
@@ -6,7 +6,7 @@ import {
6
6
  createTimestampField,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
 
9
- // One row per Personal Access Token. Like read_user_sessions this is a
9
+ // One row per Personal Access Token. Like store_user_sessions this is a
10
10
  // direct-write store (r.unmanagedTable): the create/revoke handlers write it and
11
11
  // the resolver point-reads it on the hot auth path. `tokenHash` is the SHA-256
12
12
  // of the plaintext (never the plaintext); the unique index on it makes the
@@ -14,7 +14,7 @@ import {
14
14
  // request can forge ownership/scope/hash by poking a field directly — the
15
15
  // handlers mutate them via ctx.db inside the pipeline.
16
16
  export const apiTokenEntity = createEntity({
17
- table: "read_api_tokens",
17
+ table: "store_api_tokens",
18
18
  // No softDelete: revocation is its own lifecycle (revokedAt timestamp), and
19
19
  // we keep revoked rows for the "your tokens" audit list.
20
20
  softDelete: false,
@@ -52,7 +52,7 @@ export const apiTokenEntity = createEntity({
52
52
  expiresAt: createTimestampField({ access: { write: access.privileged } }),
53
53
  revokedAt: createTimestampField({ access: { write: access.privileged } }),
54
54
  },
55
- indexes: [{ unique: true, columns: ["tokenHash"], name: "read_api_tokens_hash_unique" }],
55
+ indexes: [{ unique: true, columns: ["tokenHash"], name: "store_api_tokens_hash_unique" }],
56
56
  });
57
57
 
58
58
  // buildEntityTableMeta (not buildEntityTable): this is a direct-write store, so
@@ -19,7 +19,7 @@ import { createUserFeature } from "../../user/feature";
19
19
  import { createSessionsFeature } from "../feature";
20
20
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
21
21
 
22
- // read_user_sessions is a hot-path direct-write store: sessionCreator inserts
22
+ // store_user_sessions is a hot-path direct-write store: sessionCreator inserts
23
23
  // rows and the revoke handlers update them WITHOUT emitting lifecycle events.
24
24
  // If the table is registered as an r.entity, the framework makes it a
25
25
  // rebuildable implicit projection whose replay finds zero matching events and
@@ -27,7 +27,7 @@ import { userSessionEntity, userSessionTable } from "../schema/user-session";
27
27
  // session on the next projection rebuild (deploy / `schema apply`). #498/#494.
28
28
  //
29
29
  // Pre-fix both tests are RED: the implicit projection "sessions:projection:
30
- // user-session-entity" exists and rebuilding it empties read_user_sessions.
30
+ // user-session-entity" exists and rebuilding it empties store_user_sessions.
31
31
  // Post-fix (r.unmanagedTable) the table is no longer a rebuild target.
32
32
 
33
33
  const IMPLICIT_PROJECTION = "sessions:projection:user-session-entity";
@@ -48,7 +48,7 @@ afterAll(async () => {
48
48
 
49
49
  beforeEach(async () => {
50
50
  await asRawClient(testDb.db).unsafe(
51
- "TRUNCATE read_user_sessions, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE",
51
+ "TRUNCATE store_user_sessions, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE",
52
52
  );
53
53
  });
54
54
 
@@ -70,7 +70,7 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
70
70
  await updateRows(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
71
71
  }
72
72
 
73
- describe("sessions / read_user_sessions survives projection rebuild", () => {
73
+ describe("sessions / store_user_sessions survives projection rebuild", () => {
74
74
  test("is NOT registered as a rebuildable implicit projection", () => {
75
75
  const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
76
76
  expect(registry.getAllProjections().has(IMPLICIT_PROJECTION)).toBe(false);
@@ -7,9 +7,9 @@ export async function deleteStaleSessionsBatch(
7
7
  batchSize: number,
8
8
  ): Promise<number> {
9
9
  const rows = (await asRawClient(db).unsafe(
10
- `DELETE FROM "read_user_sessions"
10
+ `DELETE FROM "store_user_sessions"
11
11
  WHERE "id" IN (
12
- SELECT "id" FROM "read_user_sessions"
12
+ SELECT "id" FROM "store_user_sessions"
13
13
  WHERE "expires_at" < now() - ($1::int * interval '1 day')
14
14
  OR "revoked_at" < now() - ($1::int * interval '1 day')
15
15
  LIMIT $2
@@ -58,7 +58,7 @@ export function bindAutoRevokeFromFeature(
58
58
  return undefined;
59
59
  }
60
60
 
61
- // The sessions feature registers the read_user_sessions table (as an
61
+ // The sessions feature registers the store_user_sessions table (as an
62
62
  // unmanaged direct-write store, NOT an r.entity — see below) and the three
63
63
  // user-facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
64
64
  // export a sessionCreator/sessionRevoker here — those are produced by
@@ -76,7 +76,7 @@ export function bindAutoRevokeFromFeature(
76
76
  export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
77
77
  return defineFeature("sessions", (r) => {
78
78
  r.describe(
79
- "Tracks signed-in clients in the `read_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
79
+ "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
80
80
  );
81
81
  r.uiHints({
82
82
  displayLabel: "Sessions · Server-side Logout",
@@ -87,16 +87,16 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
87
87
  // gate for locked accounts) — make that a boot-time dependency so a
88
88
  // sessions-without-user wiring fails validateBoot instead of 500ing live.
89
89
  r.requires("user");
90
- // read_user_sessions is a hot-path direct-write store: sessionCreator
90
+ // store_user_sessions is a hot-path direct-write store: sessionCreator
91
91
  // inserts and the revoke handlers update rows WITHOUT emitting lifecycle
92
92
  // events (the row columns ARE the audit trail). Registering it as
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.rawTable keeps the migration DDL but opts the
96
+ // (#498/#494). r.storeTable 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.rawTable(buildEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }), {
99
+ r.storeTable(buildEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }), {
100
100
  reason: "read_side.user_sessions_direct_write",
101
101
  // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
102
102
  piiEncryptedOnWrite: true,
@@ -20,7 +20,7 @@ export const userSessionEntity = createEntity({
20
20
  // Entity-Key ist "user-session" (mit Dash), toTableName's snake-case-
21
21
  // Transform käme auf "read_user-sessions" → kein valides SQL ohne Quoting.
22
22
  // Deshalb expliziter Override auf den read_-konformen Namen.
23
- table: "read_user_sessions",
23
+ table: "store_user_sessions",
24
24
  // sid-as-PK: the sessionCreator callback generates the UUID, returns it to
25
25
  // the framework; the framework stamps it as `jti`; here the same value is
26
26
  // the row primary key. Single source of truth for the identifier.
@@ -25,9 +25,14 @@ export const invitationsQuery = defineQueryHandler({
25
25
  return Promise.all(
26
26
  (rows ?? []).map(async (row) => {
27
27
  const email = row["email"];
28
- return typeof email === "string"
29
- ? { ...row, email: await decryptStoredPii(email, "tenant:invitations") }
30
- : row;
28
+ const invitedBy = row["invitedBy"];
29
+ const decryptedEmail =
30
+ typeof email === "string" ? await decryptStoredPii(email, "tenant:invitations") : email;
31
+ const decryptedInvitedBy =
32
+ typeof invitedBy === "string"
33
+ ? await decryptStoredPii(invitedBy, "tenant:invitations")
34
+ : invitedBy;
35
+ return { ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy };
31
36
  }),
32
37
  );
33
38
  },
@@ -2,6 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
4
4
  import { z } from "zod";
5
+ import { decryptStoredPii } from "../../shared";
5
6
  import { userTable } from "../../user";
6
7
  import { tenantMembershipsTable } from "../membership-table";
7
8
 
@@ -21,16 +22,24 @@ export const membersQuery = defineQueryHandler({
21
22
  userIds.length > 0 ? await selectMany<UserRow>(ctx.db, userTable, { id: userIds }) : [];
22
23
  const userById = new Map(users.map((u) => [String(u.id), u]));
23
24
 
24
- return rows.map((row) => {
25
- const user = userById.get(String(row["userId"]));
26
- const email = typeof user?.email === "string" ? user.email : null;
27
- const displayName = typeof user?.displayName === "string" ? user.displayName : null;
28
- return {
29
- ...row,
30
- email,
31
- displayName,
32
- roles: parseRoles(row["roles"]),
33
- };
34
- });
25
+ return Promise.all(
26
+ rows.map(async (row) => {
27
+ const user = userById.get(String(row["userId"]));
28
+ const email =
29
+ typeof user?.email === "string"
30
+ ? await decryptStoredPii(user.email, "tenant:members")
31
+ : null;
32
+ const displayName =
33
+ typeof user?.displayName === "string"
34
+ ? await decryptStoredPii(user.displayName, "tenant:members")
35
+ : null;
36
+ return {
37
+ ...row,
38
+ email,
39
+ displayName,
40
+ roles: parseRoles(row["roles"]),
41
+ };
42
+ }),
43
+ );
35
44
  },
36
45
  });
@@ -109,7 +109,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
109
109
  const db = createTenantDb(ctx.db, ctx.tenantId, "system");
110
110
  // Per-row forget() through the executor, not a bulk deleteMany: memberships
111
111
  // are an ES-managed projection (add/remove/update-roles all go through
112
- // tenantMembershipCrud), so a raw table write here is eventless — a future
112
+ // tenantMembershipCrud), so a store table write here is eventless — a future
113
113
  // projection rebuild would replay the historical add-member events and
114
114
  // resurrect rows this stage removed. forget() (Art.17 hard-purge) keeps the
115
115
  // erasure rebuild-safe and gives each membership its own audit event.
@@ -146,7 +146,7 @@ async function seedUser(id: string): Promise<void> {
146
146
  describe("user-session userData-hooks", () => {
147
147
  async function seedSession(id: string, userId: string, tenantId: string): Promise<void> {
148
148
  await asRawClient(full.db).unsafe(
149
- `INSERT INTO read_user_sessions (id, user_id, tenant_id, created_at, expires_at, ip, user_agent)
149
+ `INSERT INTO store_user_sessions (id, user_id, tenant_id, created_at, expires_at, ip, user_agent)
150
150
  VALUES ($1, $2, $3, now(), now() + interval '1 day', '203.0.113.7', 'TestAgent/1.0')`,
151
151
  [id, userId, tenantId],
152
152
  );
@@ -170,11 +170,11 @@ describe("user-session userData-hooks", () => {
170
170
  await userSessionDeleteHook(ctx("sess-user-2"), "delete");
171
171
 
172
172
  const a = await rawSelect(
173
- "SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
173
+ "SELECT * FROM store_user_sessions WHERE user_id = $1 AND tenant_id = $2",
174
174
  ["sess-user-2", TENANT_A],
175
175
  );
176
176
  const b = await rawSelect(
177
- "SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
177
+ "SELECT * FROM store_user_sessions WHERE user_id = $1 AND tenant_id = $2",
178
178
  ["sess-user-2", TENANT_B],
179
179
  );
180
180
  expect(a).toHaveLength(0);
@@ -185,7 +185,7 @@ describe("user-session userData-hooks", () => {
185
185
  describe("api-token userData-hooks", () => {
186
186
  async function seedToken(id: string, userId: string): Promise<void> {
187
187
  await asRawClient(full.db).unsafe(
188
- `INSERT INTO read_api_tokens (id, user_id, tenant_id, name, token_hash, prefix, scopes, created_at)
188
+ `INSERT INTO store_api_tokens (id, user_id, tenant_id, name, token_hash, prefix, scopes, created_at)
189
189
  VALUES ($1, $2, $3, 'My MacBook', $4, 'kum_ab12', '["read"]', now())`,
190
190
  [id, userId, TENANT_A, `hash-${id}`],
191
191
  );
@@ -207,7 +207,7 @@ describe("api-token userData-hooks", () => {
207
207
 
208
208
  await apiTokenDeleteHook(ctx("pat-user-2"), "delete");
209
209
 
210
- const rows = await rawSelect("SELECT * FROM read_api_tokens WHERE user_id = $1", [
210
+ const rows = await rawSelect("SELECT * FROM store_api_tokens WHERE user_id = $1", [
211
211
  "pat-user-2",
212
212
  ]);
213
213
  expect(rows).toHaveLength(0);
@@ -371,7 +371,7 @@ describe("config-value userData-hooks", () => {
371
371
  describe("delivery-attempt userData-hooks (#799)", () => {
372
372
  async function seedAttempt(id: string, recipientId: string, tenantId: string): Promise<void> {
373
373
  await asRawClient(full.db).unsafe(
374
- `INSERT INTO read_delivery_attempts
374
+ `INSERT INTO store_delivery_attempts
375
375
  (id, tenant_id, notification_type, channel, recipient_id, recipient_address, status, priority)
376
376
  VALUES ($1, $2, 'app:notify:ping', 'email', $3, $4, 'sent', 'normal')`,
377
377
  [id, tenantId, recipientId, `user-${recipientId}@example.com`],
@@ -1,20 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { AuthMfaErrorCodes } from "../../constants";
3
- import { defaultTranslations } from "../i18n";
4
- import { mfaManageErrorKey } from "../mfa-error-keys";
5
-
6
- describe("mfaManageErrorKey", () => {
7
- for (const code of Object.values(AuthMfaErrorCodes)) {
8
- test(`${code} maps to a key registered in en/de translations`, () => {
9
- const key = mfaManageErrorKey(code);
10
- expect(defaultTranslations["en"]?.[key]).toBeDefined();
11
- expect(defaultTranslations["de"]?.[key]).toBeDefined();
12
- });
13
- }
14
-
15
- test("unknown code falls back to the generic verifyFailed key", () => {
16
- const key = mfaManageErrorKey("some_future_server_code");
17
- expect(key).toBe("auth.mfa.errors.verifyFailed");
18
- expect(defaultTranslations["en"]?.[key]).toBeDefined();
19
- });
20
- });
@@ -1,29 +0,0 @@
1
- // @runtime client
2
- // Shared error-code → i18n-key mapping for the account-management write
3
- // handlers (enable-confirm/disable/regenerate-recovery) — distinct from
4
- // mfa-verify-screen.tsx's own mapper, which covers the login-challenge
5
- // error surface (challenge_expired/rate_limited) that these handlers never
6
- // return. Centralized so enable/disable/regenerate stay in sync instead of
7
- // each re-deriving the key ad hoc (enable-screen used to template-string
8
- // the raw snake_case code straight into the key, which never matched the
9
- // camelCase keys actually registered below).
10
- import { AuthMfaErrorCodes } from "../constants";
11
-
12
- export function mfaManageErrorKey(code: string): string {
13
- switch (code) {
14
- case AuthMfaErrorCodes.invalidTotpCode:
15
- return "auth.mfa.errors.invalidCode";
16
- case AuthMfaErrorCodes.invalidRecoveryCode:
17
- return "auth.mfa.errors.invalidRecoveryCode";
18
- case AuthMfaErrorCodes.mfaAlreadyEnabled:
19
- return "auth.mfa.errors.mfaAlreadyEnabled";
20
- case AuthMfaErrorCodes.mfaNotEnabled:
21
- return "auth.mfa.errors.mfaNotEnabled";
22
- case AuthMfaErrorCodes.invalidSetupToken:
23
- return "auth.mfa.errors.invalidSetupToken";
24
- case AuthMfaErrorCodes.setupFailed:
25
- return "auth.mfa.errors.setupFailed";
26
- default:
27
- return "auth.mfa.errors.verifyFailed";
28
- }
29
- }