@cosmicdrift/kumiko-bundled-features 0.287.0 → 0.289.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 (32) hide show
  1. package/package.json +10 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +110 -3
  3. package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
  4. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
  5. package/src/billing-foundation/changes.json +6 -0
  6. package/src/billing-foundation/subscription-tier-sync.ts +9 -4
  7. package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
  8. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
  9. package/src/files-tenant-data/hooks.ts +101 -3
  10. package/src/managed-pages/handlers/branding.query.ts +1 -0
  11. package/src/managed-pages/handlers/by-slug.query.ts +1 -0
  12. package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
  13. package/src/seo/handlers/seo-config.query.ts +1 -0
  14. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  15. package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
  16. package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
  17. package/src/tenant-handover/changes.json +8 -0
  18. package/src/tenant-handover/events.ts +26 -0
  19. package/src/tenant-handover/feature.ts +35 -0
  20. package/src/tenant-handover/grant.ts +43 -0
  21. package/src/tenant-handover/handlers/claim.write.ts +143 -0
  22. package/src/tenant-handover/index.ts +9 -0
  23. package/src/tenant-handover/move-entity-graph.ts +228 -0
  24. package/src/tenant-handover/transfer-graph.ts +44 -0
  25. package/src/tenant-lifecycle/stages.ts +2 -0
  26. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
  27. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
  28. package/src/user-data-rights/changes.json +6 -0
  29. package/src/user-data-rights/deletion-token.ts +9 -11
  30. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
  31. package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
  32. package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
@@ -25,6 +25,25 @@ export type StartGracePeriodResult =
25
25
  //
26
26
  // The user row is tenant-agnostic (account-wide deletion), so it is read via
27
27
  // ctx.db.global(userTable); only the grace period duration is tenant-configured.
28
+ // That read only supplies email/locale for the caller's notification — it is
29
+ // NOT the transition's guard. The guard is `expect: { status: Active }` on
30
+ // the lifecycle write itself (#3024): the executor re-checks it against a
31
+ // fresh row right before writing, so a concurrent caller that already moved
32
+ // the user off Active is rejected even though this read saw Active.
33
+ //
34
+ // `additionalExpect` lets a caller fold its own precondition into the SAME
35
+ // atomic write — the confirm-by-token path uses it to spend a row-bound
36
+ // grant's anchor (pendingDeletionRequestId) in the same statement that flips
37
+ // status, per shared/row-bound-grant.ts's "write in the same statement that
38
+ // spends the anchor" contract. The write also always clears
39
+ // pendingDeletionRequestId: a request-by-email token is meant for one
40
+ // confirm only, and leaving the id in place would let it re-arm later if
41
+ // something ever moves the user back to Active without going through
42
+ // cancel-deletion's explicit null (restrict/lift-restriction can't today,
43
+ // since status is a single field and Restricted/DeletionRequested are
44
+ // mutually exclusive — but nulling it here doesn't depend on that staying
45
+ // true). No-op for the authenticated request-deletion path, which never set
46
+ // a pendingDeletionRequestId to begin with.
28
47
  //
29
48
  // `gracePeriod` and `lifecycleRunner` are resolved by the caller: both
30
49
  // require an escalation (reading the tenant compliance profile, appending to
@@ -35,6 +54,7 @@ export async function startDeletionGracePeriod(
35
54
  userId: string,
36
55
  gracePeriod: DurationSpec,
37
56
  lifecycleRunner: DbRunner,
57
+ additionalExpect?: Readonly<Record<string, string | number | boolean | null>>,
38
58
  ): Promise<StartGracePeriodResult> {
39
59
  const userRow = await ctx.db
40
60
  .global(userTable)
@@ -47,7 +67,17 @@ export async function startDeletionGracePeriod(
47
67
  }),
48
68
  };
49
69
  }
50
- if (userRow["status"] !== USER_STATUS.Active) {
70
+
71
+ const T = getTemporal();
72
+ const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
73
+
74
+ const { applied } = await updateUserLifecycle(
75
+ lifecycleRunner,
76
+ userId,
77
+ { status: USER_STATUS.DeletionRequested, gracePeriodEnd, pendingDeletionRequestId: null },
78
+ { expect: { status: USER_STATUS.Active, ...additionalExpect } },
79
+ );
80
+ if (!applied) {
51
81
  return {
52
82
  ok: false,
53
83
  error: new UnprocessableError("user_not_in_active_state", {
@@ -56,14 +86,6 @@ export async function startDeletionGracePeriod(
56
86
  };
57
87
  }
58
88
 
59
- const T = getTemporal();
60
- const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
61
-
62
- await updateUserLifecycle(lifecycleRunner, userId, {
63
- status: USER_STATUS.DeletionRequested,
64
- gracePeriodEnd,
65
- });
66
-
67
89
  return {
68
90
  ok: true,
69
91
  gracePeriodEnd,
@@ -21,30 +21,68 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
21
21
  // kann abweichen).
22
22
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
23
23
 
24
+ export type UpdateUserLifecycleOptions = {
25
+ // Declarative "genau einmal"-precondition (#3024): the update only applies
26
+ // if the row still holds these field values at write time. Forwarded
27
+ // as-is to the executor's `expect:` — see event-store-executor-write.ts
28
+ // for the atomicity story. Omitted: behaves exactly as before (unconditional
29
+ // overwrite, skipOptimisticLock). Scalars only — see the executor's
30
+ // `expect:` doc for why (`!==` comparison, no object/Date/Instant values).
31
+ readonly expect?: Readonly<Record<string, string | number | boolean | null>>;
32
+ };
33
+
24
34
  // `conn` is ctx.db.unsafeRaw(reason) (regular handlers) or the open tx
25
35
  // (forget-cleanup sub-tx) — keeps the event append atomic with the write.
36
+ //
37
+ // Returns `{ applied: false }` instead of throwing only when `options.expect`
38
+ // was set AND the executor rejected the write because the precondition no
39
+ // longer held (precondition_failed) or a concurrent writer won the race on
40
+ // the same stream version (version_conflict) — both mean "someone else's
41
+ // transition already landed", the expected outcome for a caller that opted
42
+ // into `expect`. Any other failure, or a failure with no `expect` set (every
43
+ // pre-#3024 caller), still throws — those callers never checked a return
44
+ // value, so silently dropping their write would be silent data loss.
26
45
  export async function updateUserLifecycle(
27
46
  conn: DbRunner,
28
47
  userId: string,
29
48
  changes: Record<string, unknown>,
30
- ): Promise<void> {
49
+ options?: UpdateUserLifecycleOptions,
50
+ ): Promise<{ readonly applied: boolean }> {
31
51
  // user ist systemStream (#497): der Executor-Choke-Point addressiert den
32
52
  // Stream immer auf SYSTEM_TENANT_ID — ein Rescope auf die row-tenant_id
33
53
  // waere wirkungslos. "system"-Mode, damit loadById auch Legacy-Rows findet,
34
54
  // deren tenant_id noch vor dem #762-Backfill-Rebuild steht.
55
+ // skipOptimisticLock stays true (kept correct, not just carried over): it
56
+ // was set from this function's very first version (#494) because every
57
+ // caller here is a server-side business transition (restrict/lift-
58
+ // restriction/grace-period/cancel/forget), never a client edit-conflict UI
59
+ // round-trip — none of them read-then-hold a row version to pass as
60
+ // `payload.version`, so the executor's version gate would reject every
61
+ // call outright without it (`update()` requires `payload.version` unless
62
+ // skipOptimisticLock is set). #3024 replaces the safety that decision gave
63
+ // up with the purpose-built `expect:` precondition above, checked fresh at
64
+ // write time — the mechanism now genuinely matches the caller's shape
65
+ // (a business-field guard) instead of a repurposed version check.
35
66
  const tenantDb = createTenantDb(conn, SYSTEM_TENANT_ID, "system");
36
67
  const result = await userExecutor.update(
37
68
  { id: userId, changes },
38
69
  createSystemUser(SYSTEM_TENANT_ID),
39
70
  tenantDb,
40
- { skipOptimisticLock: true },
71
+ { skipOptimisticLock: true, ...(options?.expect && { expect: options.expect }) },
41
72
  );
42
73
 
43
- if (!result.isSuccess) {
44
- throw new InternalError({
45
- message: `user lifecycle update failed for ${userId}: ${result.error.code}`,
46
- });
74
+ if (result.isSuccess) return { applied: true };
75
+
76
+ if (
77
+ options?.expect &&
78
+ (result.error.code === "precondition_failed" || result.error.code === "version_conflict")
79
+ ) {
80
+ return { applied: false };
47
81
  }
82
+
83
+ throw new InternalError({
84
+ message: `user lifecycle update failed for ${userId}: ${result.error.code}`,
85
+ });
48
86
  }
49
87
 
50
88
  // #494 Bestandsdaten-Reconcile: Rows, deren Lifecycle-State der alte