@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +6 -8
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
@@ -53,22 +53,6 @@ export function scheduleReference(scheduleId: string, period: string): string {
53
53
  return `schedule:${scheduleId}:${period}`;
54
54
  }
55
55
 
56
- // A Storno mirror carries reference = the reversed tx's id, so any tx whose
57
- // reference names another tx in the set marks that other tx reversed. Shared
58
- // by mergeScheduleActuals (below) and confirm-schedule-period.write.ts's
59
- // idempotency check (684/4) — same detection, kept in one place so a fix
60
- // can't land in one call-site and drift from the other.
61
- export function findReversedIds(
62
- rows: readonly { readonly id: string; readonly reference: string | null }[],
63
- ): Set<string> {
64
- const ids = new Set(rows.map((r) => r.id));
65
- return new Set(
66
- rows
67
- .filter((r) => r.reference !== null && ids.has(r.reference))
68
- .map((r) => r.reference as string),
69
- );
70
- }
71
-
72
56
  // "YYYY-MM" ⇄ a month index (year*12 + monthOfYear), so window math is pure
73
57
  // integer arithmetic — no Date object (no-date-api guard) and no DST/timezone
74
58
  // drift. isoMonth tolerates a full ISO date ("2026-01-15" → "2026-01").
@@ -121,7 +105,14 @@ export function mergeScheduleActuals(
121
105
  transactions: readonly LedgerTxRow[],
122
106
  asOf: string,
123
107
  ): ScheduleMonth[] {
124
- const reversedTxIds = findReversedIds(transactions);
108
+ const txIds = new Set(transactions.map((t) => t.id));
109
+ // A Storno mirror carries reference = the reversed tx's id, so any tx whose
110
+ // reference names another tx marks that other tx reversed.
111
+ const reversedTxIds = new Set(
112
+ transactions
113
+ .filter((t) => t.reference != null && txIds.has(t.reference))
114
+ .map((t) => t.reference as string),
115
+ );
125
116
  const asOfIndex = monthIndex(isoMonth(asOf));
126
117
 
127
118
  return projection.map((projected) => {
@@ -1,7 +1,6 @@
1
1
  // @runtime client
2
- // Client-safe ledger surface: QN constants, the pure recurring helpers
3
- // (projection + Soll/Ist merge), and the Zod payload schemas (for client-side
4
- // pre-validation before dispatch) — nothing else. The full `../ledger` entry
2
+ // Client-safe ledger surface: QN constants + the pure recurring helpers
3
+ // (projection + Soll/Ist merge), nothing else. The full `../ledger` entry
5
4
  // re-exports the feature/handlers/executor, which pull bun-db/postgres — a
6
5
  // browser bundle that imports from there fails on Node builtins. A client screen
7
6
  // (e.g. a rent-cashflow view) imports the dispatch QNs + the pure forecast/merge
@@ -26,7 +26,9 @@ type ByslugQueryBody = {
26
26
  data: { title: string; body: string | null; updatedAt: string } | null;
27
27
  };
28
28
 
29
- // 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
29
+ // Legal-Content ändert sich selten ein 60s-Shared-Cache-Fenster spart den
30
+ // Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst die Content-Query),
31
+ // ohne dass Edits spürbar stale wirken.
30
32
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
31
33
 
32
34
  // legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
12
  import { randomBytes } from "node:crypto";
13
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
14
  import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
15
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
16
  import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
@@ -24,7 +24,6 @@ import {
24
24
  } from "@cosmicdrift/kumiko-framework/stack";
25
25
  import {
26
26
  createMutableMasterKeyProvider,
27
- createTestEnvelopeCipher,
28
27
  type MutableMasterKeyProvider,
29
28
  } from "@cosmicdrift/kumiko-framework/testing";
30
29
  import { z } from "zod";
@@ -88,8 +87,8 @@ let providerRef: MutableMasterKeyProvider;
88
87
  const testEncryptionKey = randomBytes(32).toString("base64");
89
88
 
90
89
  beforeAll(async () => {
91
- const encryption = createTestEnvelopeCipher(testEncryptionKey);
92
- resolver = createConfigResolver({ cipher: encryption });
90
+ const encryption = createEncryptionProvider(testEncryptionKey);
91
+ resolver = createConfigResolver({ encryption });
93
92
 
94
93
  // Master-key for the secrets-feature. Production env shape:
95
94
  // KUMIKO_SECRETS_MASTER_KEY_V1=<base64 32 bytes>
@@ -2,8 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { type BrandingTokens, EMPTY_BRANDING } from "../../page-render";
3
3
  import { coerceBranding } from "../branding";
4
4
 
5
- // IO-boundary coercion: untrusted `unknown` BrandingTokens, no `as` cast
6
- // every missing/non-string field collapses to "" instead of throwing.
5
+ // coerceBranding is the IO boundary for the branding query's wire response:
6
+ // untrusted `unknown` BrandingTokens with no `as` cast. Every missing or
7
+ // non-string field must collapse to "" so a malformed/empty response renders
8
+ // the unbranded default rather than throwing — and an attacker-controlled
9
+ // non-string (e.g. a logoUrl object) can never leak through as a live render
10
+ // token. Exercised only indirectly by the integration path before this.
7
11
 
8
12
  describe("coerceBranding", () => {
9
13
  const FULL: BrandingTokens = {
@@ -28,7 +28,9 @@ import { pageEntity } from "./table";
28
28
  // Alias (publicstatus = "Admin") müssen TenantAdmin granten/mappen.
29
29
  const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
30
30
 
31
- // 60s-shared-cache saves the origin-revalidate roundtrip; CMS edits are live within 60s.
31
+ // Published CMS-Content ändert sich selten ein 60s-Shared-Cache-Fenster
32
+ // spart den Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst Page- +
33
+ // Branding-Query), Edits sind nach spätestens 60s live.
32
34
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
33
35
 
34
36
  // QN-Konstante als dokumentierter Public-Contract — der Render-Pfad ruft
@@ -6,7 +6,7 @@
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
10
  import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
12
12
  import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
@@ -18,7 +18,6 @@ import {
18
18
  unsafeCreateEntityTable,
19
19
  unsafePushTables,
20
20
  } from "@cosmicdrift/kumiko-framework/stack";
21
- import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
22
21
  import { createConfigFeature } from "../../config";
23
22
  import { createConfigAccessorFactory } from "../../config/feature";
24
23
  import { createConfigResolver } from "../../config/resolver";
@@ -134,8 +133,8 @@ let stack: TestStack;
134
133
  let db: DbConnection;
135
134
 
136
135
  beforeAll(async () => {
137
- const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
138
- const resolver = createConfigResolver({ cipher: encryption });
136
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
137
+ const resolver = createConfigResolver({ encryption });
139
138
  const masterKeyProvider = createEnvMasterKeyProvider({
140
139
  env: {
141
140
  KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
@@ -26,12 +26,7 @@ import {
26
26
  } from "@cosmicdrift/kumiko-framework/db";
27
27
  import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
28
28
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
29
- import {
30
- decodeStoredEnvelope,
31
- encodeStoredEnvelope,
32
- rewrapDek,
33
- } from "@cosmicdrift/kumiko-framework/secrets";
34
- import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
29
+ import { rewrapDek } from "@cosmicdrift/kumiko-framework/secrets";
35
30
  import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table";
36
31
 
37
32
  const DEFAULT_BATCH_SIZE = 100;
@@ -52,7 +47,7 @@ export type RotateJobResult = {
52
47
  readonly migrated: number;
53
48
  readonly failed: number;
54
49
  readonly batchesProcessed: number;
55
- readonly stoppedReason: ChunkedMigrationStopReason;
50
+ readonly stoppedReason: "empty" | "timeout" | "signal" | "too_many_failures";
56
51
  };
57
52
 
58
53
  export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
@@ -76,6 +71,11 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
76
71
  ? Date.now() + payload.maxDurationMs
77
72
  : Number.POSITIVE_INFINITY;
78
73
 
74
+ let migrated = 0;
75
+ let failed = 0;
76
+ let batchesProcessed = 0;
77
+ let stoppedReason: RotateJobResult["stoppedReason"] = "empty";
78
+
79
79
  // Reuse a TenantDb-per-tenant map so we don't rebuild the wrapper for
80
80
  // each row in the same tenant. Rotation typically hits one tenant in a
81
81
  // batch; the map trims an allocation without adding complexity.
@@ -89,80 +89,101 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
89
89
  return existing;
90
90
  }
91
91
 
92
- type SecretRow = {
93
- id: string;
94
- tenantId: string;
95
- version: number;
96
- envelope: StoredEnvelope;
97
- kekVersion: number;
98
- };
99
-
100
- // A partial batch means the ne-filter is exhausted — the next re-query
101
- // would only re-serve rows that failed above; end the run instead.
102
- let sawPartialBatch = false;
103
- async function nextBatch(): Promise<readonly SecretRow[]> {
104
- if (sawPartialBatch) return [];
105
- const batch = await selectMany<SecretRow>(
106
- db,
107
- tenantSecretsTable,
108
- { kekVersion: { ne: provider.currentVersion() } },
109
- { limit: batchSize },
110
- );
111
- if (batch.length < batchSize) sawPartialBatch = true;
112
- return batch;
113
- }
92
+ while (true) {
93
+ if (ctx.signal?.aborted) {
94
+ stoppedReason = "signal";
95
+ break;
96
+ }
97
+ if (Date.now() >= deadline) {
98
+ stoppedReason = "timeout";
99
+ break;
100
+ }
101
+
102
+ const targetVersion = provider.currentVersion();
103
+ const batch = await selectMany<{
104
+ id: string;
105
+ tenantId: string;
106
+ version: number;
107
+ envelope: StoredEnvelope;
108
+ kekVersion: number;
109
+ }>(db, tenantSecretsTable, { kekVersion: { ne: targetVersion } }, { limit: batchSize });
110
+
111
+ if (batch.length === 0) break;
112
+
113
+ batchesProcessed++;
114
114
 
115
- async function migrateRow(row: SecretRow): Promise<"migrated" | "skipped" | "failed"> {
116
- const rotated = await rewrapDek(decodeStoredEnvelope(row.envelope), provider);
117
- if (rotated.kekVersion === row.kekVersion) return "skipped";
118
-
119
- const actor: SessionUser = {
120
- id: "system",
121
- tenantId: row.tenantId as TenantId,
122
- roles: SYSTEM_ROLES,
123
- };
124
- const result = await executor.update(
125
- {
126
- id: row.id,
127
- version: row.version,
128
- changes: {
129
- envelope: encodeStoredEnvelope(rotated),
115
+ if (failed >= maxFailures) {
116
+ stoppedReason = "too_many_failures";
117
+ break;
118
+ }
119
+
120
+ for (const row of batch) {
121
+ if (failed >= maxFailures) {
122
+ stoppedReason = "too_many_failures";
123
+ break;
124
+ }
125
+ try {
126
+ const oldEnvelope = {
127
+ ciphertext: Buffer.from(row.envelope.ciphertext, "base64"),
128
+ iv: Buffer.from(row.envelope.iv, "base64"),
129
+ authTag: Buffer.from(row.envelope.authTag, "base64"),
130
+ encryptedDek: Buffer.from(row.envelope.encryptedDek, "base64"),
131
+ kekVersion: row.envelope.kekVersion,
132
+ };
133
+ const rotated = await rewrapDek(oldEnvelope, provider);
134
+
135
+ if (rotated.kekVersion === row.kekVersion) continue;
136
+
137
+ const newEnvelope: StoredEnvelope = {
138
+ ciphertext: rotated.ciphertext.toString("base64"),
139
+ iv: rotated.iv.toString("base64"),
140
+ authTag: rotated.authTag.toString("base64"),
141
+ encryptedDek: rotated.encryptedDek.toString("base64"),
130
142
  kekVersion: rotated.kekVersion,
131
- },
132
- },
133
- actor,
134
- tdbFor(row.tenantId as TenantId),
135
- );
136
-
137
- // version_conflict == another writer (secrets.set or a parallel
138
- // rotation worker) beat us — the row is already in a valid state,
139
- // potentially even past target.
140
- if (!result.isSuccess) {
141
- if (result.error.code === "version_conflict") return "skipped";
142
- ctx.log?.warn?.(`[secrets:rotate] executor rejected row ${row.id}`, {
143
- code: result.error.code,
144
- });
145
- return "failed";
143
+ };
144
+
145
+ const actor: SessionUser = {
146
+ id: "system",
147
+ tenantId: row.tenantId as TenantId,
148
+ roles: SYSTEM_ROLES,
149
+ };
150
+
151
+ const result = await executor.update(
152
+ {
153
+ id: row.id,
154
+ version: row.version,
155
+ changes: {
156
+ envelope: newEnvelope,
157
+ kekVersion: rotated.kekVersion,
158
+ },
159
+ },
160
+ actor,
161
+ tdbFor(row.tenantId as TenantId),
162
+ );
163
+
164
+ // version_conflict == another writer (secrets.set or a parallel
165
+ // rotation worker) beat us. Count as "skipped" and move on — the
166
+ // row is already in a valid state, potentially even past target.
167
+ if (!result.isSuccess) {
168
+ if (result.error.code === "version_conflict") continue;
169
+ failed++;
170
+ ctx.log?.warn?.(`[secrets:rotate] executor rejected row ${row.id}`, {
171
+ code: result.error.code,
172
+ });
173
+ continue;
174
+ }
175
+ } catch (err) {
176
+ failed++;
177
+ ctx.log?.warn?.(`[secrets:rotate] failed to rotate row ${row.id}`, { err });
178
+ continue;
179
+ }
180
+ migrated++;
146
181
  }
147
- return "migrated";
182
+
183
+ if (stoppedReason === "too_many_failures") break;
184
+ if (batch.length < batchSize) break;
148
185
  }
149
186
 
150
- const outcome = await runChunkedMigration<SecretRow>({
151
- nextBatch,
152
- migrateRow,
153
- maxFailures,
154
- deadlineAt: deadline,
155
- signal: ctx.signal,
156
- onRowError: (row, err) => {
157
- ctx.log?.warn?.(`[secrets:rotate] failed to rotate row ${row.id}`, { err });
158
- },
159
- });
160
-
161
- const result: RotateJobResult = {
162
- migrated: outcome.migrated,
163
- failed: outcome.failed,
164
- batchesProcessed: outcome.batchesProcessed,
165
- stoppedReason: outcome.stoppedReason,
166
- };
187
+ const result: RotateJobResult = { migrated, failed, batchesProcessed, stoppedReason };
167
188
  ctx.log?.info?.(`[secrets:rotate] complete: ${JSON.stringify(result)}`);
168
189
  };
@@ -25,13 +25,10 @@ import {
25
25
  createDekCache,
26
26
  createSecret,
27
27
  type DekCache,
28
- decodeStoredEnvelope,
29
28
  decryptValue,
30
- encodeStoredEnvelope,
31
29
  encryptValue,
32
30
  type MasterKeyProvider,
33
31
  type SecretsContext,
34
- withDekCache,
35
32
  } from "@cosmicdrift/kumiko-framework/secrets";
36
33
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
37
34
  import { z } from "zod";
@@ -82,9 +79,37 @@ function resolveKey(keyOrHandle: string | { readonly name: string }): string {
82
79
  return typeof keyOrHandle === "string" ? keyOrHandle : keyOrHandle.name;
83
80
  }
84
81
 
82
+ // Wrap a provider so its unwrapDek goes through the cache. Lets decryptValue
83
+ // use the full provider contract without knowing about caching — separation
84
+ // of concerns: decryptValue handles crypto, cache handles cost.
85
+ function cachedProvider(provider: MasterKeyProvider, cache: DekCache): MasterKeyProvider {
86
+ return {
87
+ wrapDek: provider.wrapDek.bind(provider),
88
+ unwrapDek: (encryptedDek, version) => cache.unwrapDek(encryptedDek, version, provider),
89
+ currentVersion: provider.currentVersion.bind(provider),
90
+ isAvailable: provider.isAvailable.bind(provider),
91
+ };
92
+ }
93
+
94
+ function decodeEnvelope(stored: StoredEnvelope): {
95
+ ciphertext: Buffer;
96
+ iv: Buffer;
97
+ authTag: Buffer;
98
+ encryptedDek: Buffer;
99
+ kekVersion: number;
100
+ } {
101
+ return {
102
+ ciphertext: Buffer.from(stored.ciphertext, "base64"),
103
+ iv: Buffer.from(stored.iv, "base64"),
104
+ authTag: Buffer.from(stored.authTag, "base64"),
105
+ encryptedDek: Buffer.from(stored.encryptedDek, "base64"),
106
+ kekVersion: stored.kekVersion,
107
+ };
108
+ }
109
+
85
110
  export function createSecretsContext(opts: SecretsContextOptions): SecretsContext {
86
111
  const { db, masterKeyProvider } = opts;
87
- const provider = withDekCache(masterKeyProvider, opts.dekCache ?? createDekCache());
112
+ const provider = cachedProvider(masterKeyProvider, opts.dekCache ?? createDekCache());
88
113
 
89
114
  type SecretLookupRow = {
90
115
  readonly id: string;
@@ -108,7 +133,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
108
133
  if (!auditCtx) {
109
134
  const existing = await lookup(tenantId, key);
110
135
  if (!existing) return undefined;
111
- const plaintext = await decryptValue(decodeStoredEnvelope(existing.envelope), provider);
136
+ const plaintext = await decryptValue(decodeEnvelope(existing.envelope), provider);
112
137
  return createSecret(plaintext);
113
138
  }
114
139
 
@@ -117,7 +142,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
117
142
  // type doesn't widen to the transaction object cleanly.
118
143
  const envelope = await selectTenantSecretEnvelope(tx, tenantId, key);
119
144
  if (!envelope) return undefined;
120
- const pt = await decryptValue(decodeStoredEnvelope(envelope), provider);
145
+ const pt = await decryptValue(decodeEnvelope(envelope), provider);
121
146
 
122
147
  // One event per read on its own aggregate-stream (fresh UUID as
123
148
  // aggregateId). Avoids version-conflicts between parallel reads —
@@ -165,7 +190,13 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
165
190
  async set(tenantId, keyOrHandle, value, setOpts = {}) {
166
191
  const key = resolveKey(keyOrHandle);
167
192
  const envelope = await encryptValue(value, masterKeyProvider);
168
- const stored: StoredEnvelope = encodeStoredEnvelope(envelope);
193
+ const stored: StoredEnvelope = {
194
+ ciphertext: envelope.ciphertext.toString("base64"),
195
+ iv: envelope.iv.toString("base64"),
196
+ authTag: envelope.authTag.toString("base64"),
197
+ encryptedDek: envelope.encryptedDek.toString("base64"),
198
+ kekVersion: envelope.kekVersion,
199
+ };
169
200
  const metadata: StoredMetadata = {
170
201
  ...(setOpts.redact ? { redactedPreview: setOpts.redact(value) } : {}),
171
202
  ...(setOpts.hint ? { hint: setOpts.hint } : {}),
@@ -13,7 +13,6 @@ import {
13
13
  createNumberField,
14
14
  createTextField,
15
15
  } from "@cosmicdrift/kumiko-framework/engine";
16
- import type { StoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
17
16
 
18
17
  // Envelope stored as a single jsonb blob. All ops are upsert-by-(tenantId, key)
19
18
  // so there's no value in decomposing the envelope into separate columns —
@@ -23,11 +22,13 @@ import type { StoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
23
22
  // `WHERE kek_version != currentVersion()` with an index on just that column
24
23
  // without deserializing the jsonb. Duplicated inside envelope too — the two
25
24
  // always stay in sync via the write path.
26
- //
27
- // StoredEnvelope itself is canonical in @cosmicdrift/kumiko-framework/secrets
28
- // (shared with EnvelopeCipher for TEXT-column stores) — re-exported here so
29
- // existing consumers keep their import path.
30
- export type { StoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
25
+ export type StoredEnvelope = {
26
+ readonly ciphertext: string; // base64
27
+ readonly iv: string; // base64
28
+ readonly authTag: string; // base64
29
+ readonly encryptedDek: string; // base64
30
+ readonly kekVersion: number;
31
+ };
31
32
 
32
33
  export type StoredMetadata = {
33
34
  readonly redactedPreview?: string;
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
4
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
6
7
  setupTestStack,
@@ -9,10 +10,7 @@ import {
9
10
  unsafeCreateEntityTable,
10
11
  unsafePushTables,
11
12
  } from "@cosmicdrift/kumiko-framework/stack";
12
- import {
13
- createLateBoundHolder,
14
- createTestEnvelopeCipher,
15
- } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
16
14
  import { AuthHandlers } from "../../auth-email-password/constants";
17
15
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
18
16
  import { createConfigFeature } from "../../config";
@@ -51,8 +49,8 @@ const encryptionKey = randomBytes(32).toString("base64");
51
49
  const TENANT: TenantId = testTenantId(1);
52
50
 
53
51
  beforeAll(async () => {
54
- const encryption = createTestEnvelopeCipher(encryptionKey);
55
- const resolver = createConfigResolver({ cipher: encryption });
52
+ const encryption = createEncryptionProvider(encryptionKey);
53
+ const resolver = createConfigResolver({ encryption });
56
54
  const bound = sessionCallbacksFromLateBound(callbacks);
57
55
  const baseRevoker = bound.asMassRevoker();
58
56
 
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
4
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
6
7
  setupTestStack,
@@ -11,7 +12,6 @@ import {
11
12
  } from "@cosmicdrift/kumiko-framework/stack";
12
13
  import {
13
14
  createLateBoundHolder,
14
- createTestEnvelopeCipher,
15
15
  deleteRows,
16
16
  resetTestTables,
17
17
  updateRows,
@@ -47,8 +47,8 @@ const encryptionKey = randomBytes(32).toString("base64");
47
47
  const TENANT: TenantId = testTenantId(1);
48
48
 
49
49
  beforeAll(async () => {
50
- const encryption = createTestEnvelopeCipher(encryptionKey);
51
- const resolver = createConfigResolver({ cipher: encryption });
50
+ const encryption = createEncryptionProvider(encryptionKey);
51
+ const resolver = createConfigResolver({ encryption });
52
52
  const bound = sessionCallbacksFromLateBound(callbacks);
53
53
 
54
54
  stack = await setupTestStack({
@@ -10,46 +10,19 @@ import { userSessionEntity } from "./schema/user-session";
10
10
  import type { SessionMassRevoker } from "./session-callbacks";
11
11
 
12
12
  export type SessionsFeatureOptions = {
13
- // A successful update on the `user` entity that changes the `passwordHash`
14
- // column triggers a mass-revoke of every live session for that user.
15
- // Industry-standard "password-change signs you out everywhere" flow,
16
- // including the session that did the change itself — the client has
13
+ // When wired, a successful update on the `user` entity that changes the
14
+ // `passwordHash` column triggers a mass-revoke of every live session for
15
+ // that user. Industry-standard "password-change signs you out everywhere"
16
+ // flow, including the session that did the change itself — the client has
17
17
  // to re-login after a password change.
18
18
  //
19
19
  // Runs as an afterCommit postSave hook: the password-change commits first,
20
20
  // then the sessions are revoked. Best-effort — if the mass-revoker throws,
21
21
  // the password change is NOT rolled back (a password change with a stale
22
22
  // session still wins over a user-visible error on the change itself).
23
- //
24
- // Default: run{Prod,Dev}App bind their own sessionMassRevoker via
25
- // `bindAutoRevokeOnPasswordChange` (secure-by-default). Set this option
26
- // only to supply a custom revoker — an explicit value wins over the
27
- // runtime binding.
28
23
  readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
29
24
  };
30
25
 
31
- export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
32
-
33
- // Reads the late-bind setter off a mounted sessions feature's exports.
34
- // run{Prod,Dev}App call it once the DB connection is concrete — the feature
35
- // itself is constructed in app run-config long before a db exists, so the
36
- // revoker can't be a constructor argument.
37
- export function bindAutoRevokeFromFeature(
38
- feature: FeatureDefinition,
39
- ): BindAutoRevokeOnPasswordChange | undefined {
40
- const exports = feature.exports;
41
- if (exports && typeof exports === "object" && "bindAutoRevokeOnPasswordChange" in exports) {
42
- const { bindAutoRevokeOnPasswordChange } = exports as {
43
- bindAutoRevokeOnPasswordChange: unknown;
44
- };
45
- if (typeof bindAutoRevokeOnPasswordChange === "function") {
46
- // @cast-boundary exports-walk — feature.exports is untyped by design
47
- return bindAutoRevokeOnPasswordChange as BindAutoRevokeOnPasswordChange;
48
- }
49
- }
50
- return undefined;
51
- }
52
-
53
26
  // The sessions feature registers the read_user_sessions table (as an
54
27
  // unmanaged direct-write store, NOT an r.entity — see below) and the three
55
28
  // user-facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
@@ -120,26 +93,20 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
120
93
  // "the handler didn't touch this column", which is exactly the signal
121
94
  // we want to skip on. Works for both direct user:update calls and any
122
95
  // other handler that happens to write the column.
123
- let autoRevoke = options?.autoRevokeOnPasswordChange;
124
- r.entityHook("postSave", "user", async (ctx) => {
125
- // skip: nothing bound stateless-JWT deployments without a runtime
126
- // that calls bindAutoRevokeOnPasswordChange keep the old behavior.
127
- if (!autoRevoke) return;
128
- // skip: brand-new user, no sessions can possibly exist yet. The
129
- // initial passwordHash on a user:create would trip the second guard
130
- // otherwise — every registration would do a mass-revoke roundtrip
131
- // for a user who literally has no rows in user_sessions.
132
- if (ctx.isNew) return;
133
- // skip: handler didn't touch passwordHash, nothing to revoke
134
- if (ctx.changes["passwordHash"] === undefined) return;
135
- await autoRevoke(String(ctx.id));
136
- });
137
-
138
- const bindAutoRevokeOnPasswordChange: BindAutoRevokeOnPasswordChange = (revoker) => {
139
- // explicit constructor option wins over the runtime binding
140
- autoRevoke ??= revoker;
141
- };
96
+ const autoRevoke = options?.autoRevokeOnPasswordChange;
97
+ if (autoRevoke) {
98
+ r.entityHook("postSave", "user", async (ctx) => {
99
+ // skip: brand-new user, no sessions can possibly exist yet. The
100
+ // initial passwordHash on a user:create would trip the second guard
101
+ // otherwise every registration would do a mass-revoke roundtrip
102
+ // for a user who literally has no rows in user_sessions.
103
+ if (ctx.isNew) return;
104
+ // skip: handler didn't touch passwordHash, nothing to revoke
105
+ if (ctx.changes["passwordHash"] === undefined) return;
106
+ await autoRevoke(String(ctx.id));
107
+ });
108
+ }
142
109
 
143
- return { handlers, queries, bindAutoRevokeOnPasswordChange };
110
+ return { handlers, queries };
144
111
  });
145
112
  }
@@ -6,8 +6,8 @@ export {
6
6
  SessionHandlers,
7
7
  SessionQueries,
8
8
  } from "./constants";
9
- export type { BindAutoRevokeOnPasswordChange, SessionsFeatureOptions } from "./feature";
10
- export { bindAutoRevokeFromFeature, createSessionsFeature } from "./feature";
9
+ export type { SessionsFeatureOptions } from "./feature";
10
+ export { createSessionsFeature } from "./feature";
11
11
  export { userSessionEntity, userSessionTable } from "./schema/user-session";
12
12
  export type {
13
13
  SessionCallbacks,