@cosmicdrift/kumiko-bundled-features 0.311.0 → 0.313.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 (77) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +8 -0
  3. package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
  4. package/src/agent-tools/agent-manifest.ts +7 -1
  5. package/src/agent-tools/changes.json +8 -1
  6. package/src/audit/changes.json +6 -0
  7. package/src/audit/feature.ts +1 -2
  8. package/src/audit/i18n.ts +3 -0
  9. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
  10. package/src/auth-email-password/changes.json +6 -0
  11. package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
  12. package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
  13. package/src/auth-email-password/signup-token-store.ts +87 -1
  14. package/src/auth-email-password/web/auth-client.ts +10 -1
  15. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
  16. package/src/data-retention/changes.json +6 -0
  17. package/src/data-retention/run-retention-cleanup.ts +160 -18
  18. package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
  19. package/src/delivery/address-opt-out.ts +87 -0
  20. package/src/delivery/changes.json +7 -0
  21. package/src/delivery/db/queries/address-opt-outs.ts +23 -0
  22. package/src/delivery/delivery-service.ts +32 -0
  23. package/src/delivery/feature.ts +9 -1
  24. package/src/delivery/index.ts +5 -0
  25. package/src/delivery/tables.ts +37 -0
  26. package/src/delivery/unsubscribe.ts +101 -16
  27. package/src/delivery/upsert-preference.ts +1 -1
  28. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +621 -43
  29. package/src/document-ingest-foundation/__tests__/feature.test.ts +20 -3
  30. package/src/document-ingest-foundation/__tests__/providers.test.ts +204 -0
  31. package/src/document-ingest-foundation/boot-checks.ts +58 -0
  32. package/src/document-ingest-foundation/changes.json +18 -0
  33. package/src/document-ingest-foundation/events.ts +3 -0
  34. package/src/document-ingest-foundation/executor.ts +12 -0
  35. package/src/document-ingest-foundation/feature.ts +125 -72
  36. package/src/document-ingest-foundation/file-ref-liveness.ts +10 -0
  37. package/src/document-ingest-foundation/forget-extract-with-file-ref.ts +84 -0
  38. package/src/document-ingest-foundation/index.ts +17 -0
  39. package/src/document-ingest-foundation/providers.ts +78 -0
  40. package/src/document-ingest-foundation/tenant-destroy-hook.ts +3 -7
  41. package/src/document-ingest-foundation/write-document-extract.ts +48 -0
  42. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
  43. package/src/file-derivatives/changes.json +6 -0
  44. package/src/file-derivatives/feature.ts +5 -0
  45. package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
  46. package/src/shared/index.ts +10 -1
  47. package/src/shared/run-in-sub-transaction.ts +35 -0
  48. package/src/shared/signup-handover.ts +67 -0
  49. package/src/shared/single-use-token-store.ts +25 -5
  50. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
  51. package/src/subscription-stripe/changes.json +9 -1
  52. package/src/subscription-stripe/feature.ts +13 -1
  53. package/src/subscription-stripe/plugin-methods.ts +17 -2
  54. package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
  55. package/src/tenant/changes.json +6 -0
  56. package/src/tenant/index.ts +1 -0
  57. package/src/tenant/is-tenant-serving-public-content.ts +13 -0
  58. package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
  59. package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
  60. package/src/tenant/web/member-roles-cell.tsx +11 -2
  61. package/src/tenant/web/member-status-cell.tsx +6 -2
  62. package/src/tenant/web/translate-or-raw.ts +13 -0
  63. package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
  64. package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
  65. package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
  66. package/src/tenant-handover/changes.json +12 -0
  67. package/src/tenant-handover/events.ts +6 -0
  68. package/src/tenant-handover/feature.ts +11 -0
  69. package/src/tenant-handover/handlers/claim.write.ts +8 -25
  70. package/src/tenant-handover/move-entity-graph.ts +30 -1
  71. package/src/tenant-handover/root-anchor.ts +50 -0
  72. package/src/tenant-handover/signup-handover-provider.ts +62 -0
  73. package/src/tenant-handover/transfer-graph.ts +6 -2
  74. package/src/user-data-rights/changes.json +6 -0
  75. package/src/user-data-rights/feature.ts +1 -2
  76. package/src/user-data-rights/i18n.ts +3 -0
  77. package/src/user-data-rights/run-forget-cleanup.ts +1 -38
@@ -23,13 +23,25 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
23
23
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
24
24
  import {
25
25
  defineWriteHandler,
26
+ type HandlerContext,
26
27
  type SessionUser,
27
28
  type TenantId,
28
29
  } from "@cosmicdrift/kumiko-framework/engine";
29
- import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
30
+ import {
31
+ ConflictError,
32
+ InternalError,
33
+ reraiseAsKumikoError,
34
+ type WriteErrorInfo,
35
+ writeFailure,
36
+ } from "@cosmicdrift/kumiko-framework/errors";
30
37
  import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
31
38
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
32
39
  import * as z from "zod";
40
+ import {
41
+ findSignupHandoverProvider,
42
+ SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE,
43
+ type SignupHandoverBinding,
44
+ } from "../../shared";
33
45
  // kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
34
46
  import { tenantTable } from "../../tenant/schema/tenant";
35
47
  import { invalidSignupToken, signupEmailAlreadyRegistered } from "../errors";
@@ -38,8 +50,10 @@ import { passwordSchema } from "../password-policy";
38
50
  import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
39
51
  import {
40
52
  burnSignupToken,
53
+ deleteSignupHandover,
41
54
  deleteSignupToken,
42
55
  getEmailForSignupToken,
56
+ getSignupHandover,
43
57
  unburnSignupToken,
44
58
  } from "../signup-token-store";
45
59
 
@@ -58,11 +72,57 @@ export type SignupConfirmData = {
58
72
  readonly kind: "auth-session";
59
73
  readonly session: SessionUser;
60
74
  readonly tenantKey: string;
75
+ // Present only when a bound tenant-handover grant existed AND its claim
76
+ // actually succeeded (see the handler body below).
77
+ readonly handover?: { readonly entityType: string; readonly id: string };
61
78
  };
62
79
 
63
80
  const SIGNUP_CONFIRM_PROVISION_REASON =
64
81
  "provisions a new tenant, its first user and membership before any tenant context exists";
65
82
 
83
+ // UnprocessableError's `.code` is always the fixed literal "unprocessable" —
84
+ // the distinguishing reason slug lives in `details.reason` (see
85
+ // KumikoError's own reasonSlug getter in kumiko-error.ts). Comparing `.code`
86
+ // directly against SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE would never
87
+ // match; this reads the same field the framework itself uses.
88
+ function isBenignClaimRejection(error: WriteErrorInfo): boolean {
89
+ return (
90
+ typeof error.details === "object" &&
91
+ error.details !== null &&
92
+ (error.details as Record<string, unknown>)["reason"] ===
93
+ SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE
94
+ );
95
+ }
96
+
97
+ type ClaimedHandover = { readonly entityType: string; readonly id: string };
98
+
99
+ // Rides the caller's tx. A benign claim rejection (nothing was written) logs
100
+ // and yields undefined; any other failure — including a real DB error, which
101
+ // leaves the shared tx unusable regardless of catching it here — throws, so
102
+ // the whole signup rolls back instead of leaving a partially moved transfer
103
+ // graph next to a committed account.
104
+ async function claimBoundHandover(
105
+ ctx: HandlerContext,
106
+ session: SessionUser,
107
+ binding: SignupHandoverBinding,
108
+ ): Promise<ClaimedHandover | undefined> {
109
+ const provider = findSignupHandoverProvider(ctx.registry);
110
+ if (!provider) {
111
+ ctx.log?.warn(
112
+ "signup-confirm: handover binding present but no signupHandover provider mounted",
113
+ );
114
+ return undefined;
115
+ }
116
+ const claim = provider.mintClaim(binding);
117
+ const claimResult = await ctx.writeAs(session, claim.qualifiedName, claim.payload);
118
+ if (claimResult.isSuccess) return { entityType: binding.entityType, id: binding.rowId };
119
+ if (!isBenignClaimRejection(claimResult.error)) throw reraiseAsKumikoError(claimResult.error);
120
+ ctx.log?.warn("signup-confirm: handover grant did not redeem", {
121
+ entityType: binding.entityType,
122
+ });
123
+ return undefined;
124
+ }
125
+
66
126
  export function createSignupConfirmHandler() {
67
127
  return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
68
128
  name: "signup-confirm",
@@ -141,10 +201,6 @@ export function createSignupConfirmHandler() {
141
201
  throw err;
142
202
  }
143
203
 
144
- // Cleanup beider Token-Lookup-Keys. Burn-Key bleibt für die
145
- // restliche Burn-TTL als Replay-Schutz.
146
- await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
147
-
148
204
  // SessionUser für JWT-Mint. Roles aus INITIAL_SIGNUP_ROLES
149
205
  // damit DB-write (provisionSignupAccount) und Session-claim
150
206
  // dieselbe Quelle teilen — sonst hätten zwei Stellen "TenantAdmin"
@@ -156,6 +212,19 @@ export function createSignupConfirmHandler() {
156
212
  roles: [...INITIAL_SIGNUP_ROLES],
157
213
  };
158
214
 
215
+ // Claim BEFORE spending the signup token or the binding below and
216
+ // before `committed` flips: if the claim throws, the `finally` still
217
+ // unburns and the activation link stays retryable.
218
+ const handoverBinding = await getSignupHandover(ctx.redis, event.payload.token);
219
+ const handover = handoverBinding
220
+ ? await claimBoundHandover(ctx, session, handoverBinding)
221
+ : undefined;
222
+
223
+ // Drop both token lookup keys; the burn key stays for its remaining
224
+ // TTL as replay protection.
225
+ await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
226
+ if (handoverBinding) await deleteSignupHandover(ctx.redis, event.payload.token);
227
+
159
228
  committed = true;
160
229
  return {
161
230
  isSuccess: true,
@@ -163,6 +232,7 @@ export function createSignupConfirmHandler() {
163
232
  kind: "auth-session",
164
233
  session,
165
234
  tenantKey,
235
+ ...(handover !== undefined && { handover }),
166
236
  },
167
237
  };
168
238
  } finally {
@@ -26,6 +26,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import { Temporal } from "temporal-polyfill";
28
28
  import * as z from "zod";
29
+ import { findSignupHandoverProvider, type SignupHandoverBinding } from "../../shared";
29
30
  import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
30
31
  import type { AuthMailLocale } from "../email-templates";
31
32
  import { renderActivationEmail } from "../email-templates";
@@ -34,15 +35,30 @@ import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
34
35
  import {
35
36
  invalidateExistingSignupToken,
36
37
  normalizeEmail,
38
+ storeSignupHandover,
37
39
  storeSignupToken,
40
+ takeOverSignupHandoverForResend,
38
41
  } from "../signup-token-store";
39
42
 
40
43
  const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
41
44
 
42
45
  const SignupRequestSchema = z.object({
43
46
  email: z.email(),
47
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up,
48
+ // offlot-app#454) — see shared/signup-handover.ts for why the grant is
49
+ // verified here and rebound to the signup token instead of the client
50
+ // carrying it through the activation link.
51
+ handover: z
52
+ .object({
53
+ entityType: z.string().min(1),
54
+ token: z.string().min(1),
55
+ })
56
+ .optional(),
44
57
  });
45
58
 
59
+ const HANDOVER_GRANT_VERIFY_REASON =
60
+ "verifies a tenant-handover grant against its root row in the source tenant (read-only anchor lookup) before binding it to the signup token";
61
+
46
62
  export type SignupRequestData =
47
63
  | {
48
64
  readonly kind: "signup-requested";
@@ -79,6 +95,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
79
95
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
80
96
  description:
81
97
  "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
98
+ escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON },
82
99
  handler: async (event, ctx) => {
83
100
  // Silent no-op when off, matching the route's own always-200
84
101
  // anti-enumeration contract (registerTokenRequestRoute swallows every
@@ -114,6 +131,12 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
114
131
  // between lookup paths that lowercase differently (or not at all).
115
132
  const email = event.payload.email;
116
133
 
134
+ // Read off the OLD token's binding before it's invalidated below — a
135
+ // resend without a fresh `handover` in the payload (the common case:
136
+ // the visitor just retyped their email) must still carry the earlier
137
+ // verified grant over to the new token instead of silently dropping it.
138
+ const priorBinding = await takeOverSignupHandoverForResend(ctx.redis, email);
139
+
117
140
  // At most one live signup token per email: invalidate whatever's
118
141
  // there before minting the new one (see signup-token-store.ts).
119
142
  await invalidateExistingSignupToken(ctx.redis, email);
@@ -131,6 +154,43 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
131
154
 
132
155
  await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
133
156
 
157
+ // A handover grant in this request re-verifies and replaces the prior
158
+ // binding; anything short of a fresh, verified grant (no provider
159
+ // mounted, or verification failed) falls back to it instead —
160
+ // dropping a still-live binding on a bad request would strand the
161
+ // visitor's run. A throw here must never skip the mail below, or the
162
+ // signup itself silently breaks for a bug in a provider this handler
163
+ // doesn't own.
164
+ let binding: SignupHandoverBinding | null = priorBinding;
165
+ if (event.payload.handover) {
166
+ const { entityType, token: grantToken } = event.payload.handover;
167
+ const provider = findSignupHandoverProvider(ctx.registry);
168
+ if (!provider) {
169
+ ctx.log?.warn(
170
+ "signup-request: handover payload present but no signupHandover provider mounted",
171
+ );
172
+ } else {
173
+ try {
174
+ const verified = await provider.verifyGrant({
175
+ db: ctx.db.unsafeRaw(HANDOVER_GRANT_VERIFY_REASON),
176
+ registry: ctx.registry,
177
+ entityType,
178
+ token: grantToken,
179
+ });
180
+ if (verified) {
181
+ binding = verified;
182
+ } else {
183
+ ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
184
+ }
185
+ } catch {
186
+ ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
187
+ }
188
+ }
189
+ }
190
+ if (binding) {
191
+ await storeSignupHandover(ctx.redis, { token, binding, ttlSeconds });
192
+ }
193
+
134
194
  // normalizeEmail from the store — one source of truth for
135
195
  // normalization; the delivery recipient + lookup path consistently
136
196
  // get the same format.
@@ -11,7 +11,12 @@
11
11
  // `signup:`-prefix.
12
12
 
13
13
  import type Redis from "ioredis";
14
- import { createSingleUseTokenStore } from "../shared";
14
+ import * as z from "zod";
15
+ import {
16
+ createSingleUseTokenStore,
17
+ hashSingleUseToken,
18
+ type SignupHandoverBinding,
19
+ } from "../shared";
15
20
 
16
21
  /** Email normalization — single source for every lookup layer (used
17
22
  * internally by the store AND by callers that need a consistent form
@@ -67,3 +72,84 @@ export async function deleteSignupToken(
67
72
  /** Burn release for failed-confirm paths (DB error etc.) so a legitimate
68
73
  * retry isn't blocked by a stale burn marker. */
69
74
  export const unburnSignupToken = store.unburn;
75
+
76
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up,
77
+ // offlot-app#454): signup-request binds a verified tenant-handover grant to
78
+ // its own signup token, in a SIBLING Redis namespace keyed the same way
79
+ // (sha256(token)) so it stays byte-compatible with the token store above
80
+ // without going through it — the binding has its own lifecycle (survives a
81
+ // resend, is read once by signup-confirm) that doesn't fit the token↔email
82
+ // pairing. Only {entityType, rowId, sourceTenantId} is ever stored — never
83
+ // the grant token or the email (see shared/signup-handover.ts).
84
+ const SIGNUP_HANDOVER_KEY_PREFIX = "signup:handover:";
85
+
86
+ const signupHandoverBindingSchema = z.object({
87
+ entityType: z.string().min(1),
88
+ rowId: z.string().min(1),
89
+ sourceTenantId: z.string().min(1),
90
+ });
91
+
92
+ function signupHandoverKeyForHash(tokenHash: string): string {
93
+ return `${SIGNUP_HANDOVER_KEY_PREFIX}${tokenHash}`;
94
+ }
95
+
96
+ function signupHandoverKey(token: string): string {
97
+ return signupHandoverKeyForHash(hashSingleUseToken(token));
98
+ }
99
+
100
+ function parseSignupHandoverBinding(raw: string): SignupHandoverBinding | null {
101
+ let json: unknown;
102
+ try {
103
+ json = JSON.parse(raw);
104
+ } catch {
105
+ return null;
106
+ }
107
+ const parsed = signupHandoverBindingSchema.safeParse(json);
108
+ return parsed.success ? parsed.data : null;
109
+ }
110
+
111
+ /** Binds a verified grant to this signup token, TTL-matched to the token
112
+ * itself so the binding never outlives (or is killed before) it. */
113
+ export async function storeSignupHandover(
114
+ redis: Redis,
115
+ args: { token: string; binding: SignupHandoverBinding; ttlSeconds: number },
116
+ ): Promise<void> {
117
+ await redis.set(
118
+ signupHandoverKey(args.token),
119
+ JSON.stringify(args.binding),
120
+ "EX",
121
+ args.ttlSeconds,
122
+ );
123
+ }
124
+
125
+ /** Reads the binding for a still-live signup token, if any. */
126
+ export async function getSignupHandover(
127
+ redis: Redis,
128
+ token: string,
129
+ ): Promise<SignupHandoverBinding | null> {
130
+ const raw = await redis.get(signupHandoverKey(token));
131
+ return raw === null ? null : parseSignupHandoverBinding(raw);
132
+ }
133
+
134
+ /** Carries a still-live binding over to a resend, before the old token is
135
+ * invalidated: reads the old token's hash off the store's by-email entry
136
+ * (never the token itself), fetches the binding under that hash, and
137
+ * deletes it — the fresh token gets its own storeSignupHandover call with
138
+ * the same binding right after. Null when there was no live binding. */
139
+ export async function takeOverSignupHandoverForResend(
140
+ redis: Redis,
141
+ email: string,
142
+ ): Promise<SignupHandoverBinding | null> {
143
+ const oldTokenHash = await store.getTokenHashForSubject(redis, normalizeEmail(email));
144
+ if (oldTokenHash === null) return null;
145
+ const raw = await redis.get(signupHandoverKeyForHash(oldTokenHash));
146
+ if (raw === null) return null;
147
+ await redis.del(signupHandoverKeyForHash(oldTokenHash));
148
+ return parseSignupHandoverBinding(raw);
149
+ }
150
+
151
+ /** Cleanup after signup-confirm has read (and acted on, or logged a failed
152
+ * claim for) the binding. */
153
+ export async function deleteSignupHandover(redis: Redis, token: string): Promise<void> {
154
+ await redis.del(signupHandoverKey(token));
155
+ }
@@ -316,12 +316,18 @@ export async function confirmAccountUnlock(
316
316
  // User auf /signup/complete?token=… wo er sein Password setzt.
317
317
  export async function requestSignup(
318
318
  email: string,
319
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up,
320
+ // offlot-app#454): forwards a row-bound tenant-handover grant so the
321
+ // server can verify + rebind it to the signup token. The grant never
322
+ // travels any further than this request — never in the URL, the mail,
323
+ // or the confirm response.
324
+ handover?: { readonly entityType: string; readonly token: string },
319
325
  ): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
320
326
  const res = await fetch("/api/auth/signup-request", {
321
327
  method: "POST",
322
328
  credentials: "same-origin",
323
329
  headers: { "Content-Type": "application/json", ...csrfHeader() },
324
- body: JSON.stringify({ email }),
330
+ body: JSON.stringify({ email, ...(handover !== undefined && { handover }) }),
325
331
  });
326
332
  if (res.ok) return { ok: true };
327
333
  return { ok: false, error: await parseTokenFailure(res) };
@@ -334,6 +340,9 @@ export async function requestSignup(
334
340
  export type SignupConfirmSuccess = {
335
341
  readonly user: { readonly id: string; readonly tenantId: string; readonly roles: string[] };
336
342
  readonly tenantKey: string;
343
+ // Present only when a bound handover grant existed and its claim
344
+ // succeeded — see signup-confirm.write.ts.
345
+ readonly handover?: { readonly entityType: string; readonly id: string };
337
346
  };
338
347
 
339
348
  export async function confirmSignup(
@@ -0,0 +1,160 @@
1
+ // Refs #2057 (partial) — hardDelete must crypto-shred the purged row's own
2
+ // KMS subject key AFTER the forget, mirroring run-forget-cleanup.ts:477-503's
3
+ // forget → eraseKey → nullBlindIndexesForSubject ordering. Only recordOwned
4
+ // and self-pii subjects belong to the row being deleted; a userOwned field's
5
+ // subject is the REFERENCED user, whose key may still protect that user's
6
+ // other live rows elsewhere — hardDelete of one row must never erase it.
7
+
8
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import {
11
+ configurePiiSubjectKms,
12
+ InMemoryKmsAdapter,
13
+ KeyErasedError,
14
+ } from "@cosmicdrift/kumiko-framework/crypto";
15
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
16
+ import {
17
+ setupTestStack,
18
+ type TestStack,
19
+ testTenantId,
20
+ unsafeCreateEntityTable,
21
+ } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
23
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
25
+ import { runRetentionCleanup } from "../run-retention-cleanup";
26
+
27
+ // Entity A: `personal: { of: "id" }` → recordOwned — the row IS its own
28
+ // subject. hardDelete must erase this key.
29
+ const recordOwnedEntity = createEntity({
30
+ table: "read_c9_record_owned",
31
+ fields: {
32
+ note: createTextField({ required: true, personal: { of: "id" }, find: "none" }),
33
+ },
34
+ retention: { keepFor: "30d", strategy: "hardDelete" },
35
+ });
36
+
37
+ // Entity B: `personal: { of: "ownerUserId" }` → userOwned — the subject is
38
+ // the REFERENCED user, not this row. hardDelete of this row must NEVER
39
+ // erase the owner's key (they may own other, still-live rows).
40
+ const userOwnedEntity = createEntity({
41
+ table: "read_c9_user_owned",
42
+ fields: {
43
+ ownerUserId: createTextField({
44
+ required: true,
45
+ personal: false,
46
+ reason: "technical_reference",
47
+ }),
48
+ note: createTextField({ required: true, personal: { of: "ownerUserId" }, find: "none" }),
49
+ },
50
+ retention: { keepFor: "30d", strategy: "hardDelete" },
51
+ });
52
+
53
+ const c9Feature = defineFeature("c9-retention-kms-fixtures", (r) => {
54
+ r.entity("c9-record-owned", recordOwnedEntity);
55
+ r.entity("c9-user-owned", userOwnedEntity);
56
+ });
57
+
58
+ let stack: TestStack;
59
+ let kms: InMemoryKmsAdapter;
60
+ let now: ReturnType<ReturnType<typeof getTemporal>["Now"]["instant"]>;
61
+ let pastIso: string;
62
+
63
+ beforeAll(async () => {
64
+ stack = await setupTestStack({ features: [createDataRetentionFeature(), c9Feature] });
65
+ for (const e of [tenantRetentionOverrideEntity, recordOwnedEntity, userOwnedEntity]) {
66
+ await unsafeCreateEntityTable(stack.db, e);
67
+ }
68
+ now = getTemporal().Now.instant();
69
+ pastIso = now.subtract({ hours: 60 * 24 }).toString(); // 60 days old, past the cutoff
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await stack.cleanup();
74
+ });
75
+
76
+ beforeEach(async () => {
77
+ for (const t of ["read_c9_record_owned", "read_c9_user_owned"]) {
78
+ await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
79
+ }
80
+ kms = new InMemoryKmsAdapter();
81
+ configurePiiSubjectKms(kms);
82
+ });
83
+
84
+ afterEach(() => {
85
+ resetPiiSubjectKmsForTests();
86
+ });
87
+
88
+ // Ciphertext content is irrelevant to this test — it only proves which
89
+ // subject's key eraseKey was (not) called for, not the field encryption
90
+ // round-trip. The subject's key is pre-created here the same way the real
91
+ // write path creates it on first encrypt (getOrCreateDek), so eraseKey's
92
+ // effect is observable via getKey.
93
+ async function seedRecordOwned(tenantId: string, insertedAtIso: string): Promise<string> {
94
+ const rows = (await asRawClient(stack.db).unsafe(
95
+ `INSERT INTO read_c9_record_owned (tenant_id, note, inserted_at) VALUES ($1, $2, $3::timestamptz) RETURNING id::text AS id`,
96
+ [tenantId, "pii:placeholder", insertedAtIso],
97
+ )) as { id: string }[];
98
+ const rowId = rows[0]?.id;
99
+ if (!rowId) throw new Error("seedRecordOwned: insert returned no id");
100
+ await kms.createKey({ kind: "record", entity: "c9-record-owned", id: rowId });
101
+ return rowId;
102
+ }
103
+
104
+ async function seedUserOwned(
105
+ tenantId: string,
106
+ ownerUserId: string,
107
+ insertedAtIso: string,
108
+ ): Promise<string> {
109
+ const rows = (await asRawClient(stack.db).unsafe(
110
+ `INSERT INTO read_c9_user_owned (tenant_id, owner_user_id, note, inserted_at) VALUES ($1, $2, $3, $4::timestamptz) RETURNING id::text AS id`,
111
+ [tenantId, ownerUserId, "pii:placeholder", insertedAtIso],
112
+ )) as { id: string }[];
113
+ const rowId = rows[0]?.id;
114
+ if (!rowId) throw new Error("seedUserOwned: insert returned no id");
115
+ await kms.createKey({ kind: "user", userId: ownerUserId });
116
+ return rowId;
117
+ }
118
+
119
+ describe("runRetentionCleanup :: hardDelete crypto-shredding (Refs #2057)", () => {
120
+ test("erases a recordOwned row's own key, leaves a userOwned row's owner key untouched", async () => {
121
+ const tenantId = testTenantId(9);
122
+ const ownerUserId = crypto.randomUUID();
123
+ const recordRowId = await seedRecordOwned(tenantId, pastIso);
124
+ const userRowId = await seedUserOwned(tenantId, ownerUserId, pastIso);
125
+
126
+ // Sanity: both keys exist and are readable before cleanup runs.
127
+ await expect(
128
+ kms.getKey({ kind: "record", entity: "c9-record-owned", id: recordRowId }),
129
+ ).resolves.toBeInstanceOf(Buffer);
130
+ await expect(kms.getKey({ kind: "user", userId: ownerUserId })).resolves.toBeInstanceOf(Buffer);
131
+
132
+ const result = await runRetentionCleanup({
133
+ db: stack.db,
134
+ registry: stack.registry,
135
+ tenantId,
136
+ tenantPreset: null,
137
+ now,
138
+ });
139
+
140
+ expect(result.hardDeleted).toBe(2);
141
+
142
+ const recordRows = await asRawClient(stack.db).unsafe(
143
+ `SELECT id FROM read_c9_record_owned WHERE tenant_id = $1`,
144
+ [tenantId],
145
+ );
146
+ expect(recordRows).toHaveLength(0);
147
+ await expect(
148
+ kms.getKey({ kind: "record", entity: "c9-record-owned", id: recordRowId }),
149
+ ).rejects.toThrow(KeyErasedError);
150
+
151
+ const userRows = await asRawClient(stack.db).unsafe(
152
+ `SELECT id FROM read_c9_user_owned WHERE tenant_id = $1`,
153
+ [tenantId],
154
+ );
155
+ expect(userRows).toHaveLength(0);
156
+ expect(userRowId).toBeTruthy();
157
+ // The owner's key must survive — it is not this row's own subject.
158
+ await expect(kms.getKey({ kind: "user", userId: ownerUserId })).resolves.toBeInstanceOf(Buffer);
159
+ });
160
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.313.0",
4
+ "type": "fix",
5
+ "title": "data-retention hardDelete now erases a purged row's own KMS subject key",
6
+ "detail": "Refs #2057 (partial fix — the hardDelete path only). run-retention-\ncleanup's hardDelete forgot the row's event history via the executor but\nnever crypto-shredded its KMS subject key, leaving the DEK live after the\nrow was gone. It now erases the key in the same per-row sub-transaction as the\nforget (an eraseKey throw rolls the forget back too), mirroring run-\nforget-cleanup.ts's forget -> eraseKey -> nullBlindIndexesForSubject\nordering. Only the row's own recordOwned/self-pii subjects are erased;\na userOwned or tenantOwned field's subject is the REFERENCED user/tenant,\nnot this row, and may still protect other live rows elsewhere — those\nkeys are never touched. No migration needed."
7
+ },
2
8
  {
3
9
  "version": "0.309.0",
4
10
  "type": "breaking",