@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.335.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 (242) hide show
  1. package/dist/admin-shell/overview-panels.js +2 -2
  2. package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
  3. package/dist/agent-tools/agent-doc-lint.js +33 -3
  4. package/dist/agent-tools/agent-manifest.js +47 -6
  5. package/dist/agent-tools/filter-ops.d.ts +2 -0
  6. package/dist/agent-tools/filter-ops.js +4 -0
  7. package/dist/agent-tools/tool-catalog.js +1 -4
  8. package/dist/agent-tools/tool-dispatch.js +1 -4
  9. package/dist/audit/constants.d.ts +1 -1
  10. package/dist/audit/constants.js +1 -1
  11. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  12. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  13. package/dist/auth-email-password/handlers/login.write.js +2 -0
  14. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  15. package/dist/auth-email-password/i18n.js +2 -0
  16. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  17. package/dist/auth-email-password/web/auth-client.js +1 -1
  18. package/dist/auth-email-password/web/auth-gate.js +6 -8
  19. package/dist/auth-email-password/web/index.d.ts +1 -1
  20. package/dist/auth-email-password/web/index.js +1 -1
  21. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  22. package/dist/auth-email-password/web/login-screen.js +1 -1
  23. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  25. package/dist/auth-email-password/web/session.d.ts +4 -0
  26. package/dist/auth-email-password/web/session.js +15 -0
  27. package/dist/auth-mfa/feature.js +3 -2
  28. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  29. package/dist/auth-mfa/handlers/verify.write.js +2 -0
  30. package/dist/auth-mfa/hmac-token-codec.js +3 -0
  31. package/dist/auth-mfa/mfa-challenge-token.js +4 -0
  32. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
  33. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  34. package/dist/auth-mfa/web/mfa-client.js +4 -10
  35. package/dist/billing-foundation/billing-info-query.js +1 -1
  36. package/dist/billing-foundation/checkout-core.d.ts +5 -5
  37. package/dist/billing-foundation/checkout-core.js +10 -6
  38. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  39. package/dist/cap-counter/book-cap-usage.js +15 -5
  40. package/dist/cap-counter/enforce-cap.js +9 -3
  41. package/dist/cap-counter/feature.js +3 -2
  42. package/dist/cap-counter/i18n.d.ts +1 -0
  43. package/dist/cap-counter/i18n.js +6 -5
  44. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  45. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  46. package/dist/cap-overview/i18n.js +5 -0
  47. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  48. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  49. package/dist/channel-email/email-channel.js +12 -1
  50. package/dist/channel-in-app/tables.js +2 -6
  51. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  52. package/dist/config/db/queries/resolver.js +3 -1
  53. package/dist/config/handlers/set.write.js +8 -4
  54. package/dist/config/web/i18n.js +1 -0
  55. package/dist/config/write-helpers.d.ts +1 -1
  56. package/dist/config/write-helpers.js +11 -5
  57. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  58. package/dist/custom-fields/constants.js +1 -4
  59. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  60. package/dist/data-retention/run-retention-cleanup.js +100 -44
  61. package/dist/delivery/types.d.ts +2 -1
  62. package/dist/delivery/types.js +7 -0
  63. package/dist/delivery/unsubscribe.js +5 -11
  64. package/dist/derivatives-sharp/render.js +27 -9
  65. package/dist/document-ingest-foundation/feature.js +5 -5
  66. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  67. package/dist/document-ingest-foundation/providers.js +6 -0
  68. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  69. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  70. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  71. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  72. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  73. package/dist/files-tenant-data/index.js +3 -2
  74. package/dist/folders/handlers/clear-folder.write.js +4 -11
  75. package/dist/folders/handlers/set-folder.write.js +4 -10
  76. package/dist/form-draft/feature.js +3 -2
  77. package/dist/form-draft/handlers/discard.write.js +7 -8
  78. package/dist/form-draft/handlers/get.query.js +7 -1
  79. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  80. package/dist/foundation-shared/host-allowlist.js +14 -0
  81. package/dist/foundation-shared/index.d.ts +1 -0
  82. package/dist/foundation-shared/index.js +1 -0
  83. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  84. package/dist/foundation-shared/mail-host-policy.js +3 -13
  85. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  86. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  87. package/dist/jobs/handlers/list.query.d.ts +7 -2
  88. package/dist/jobs/handlers/list.query.js +9 -2
  89. package/dist/jobs/handlers/retry.write.js +3 -2
  90. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  91. package/dist/jobs/job-run-logger.js +18 -2
  92. package/dist/jobs/tenant-job-failure-table.js +2 -6
  93. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  94. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  95. package/dist/ledger/subject-reference.d.ts +4 -0
  96. package/dist/ledger/subject-reference.js +7 -0
  97. package/dist/legal-pages/feature.js +8 -1
  98. package/dist/managed-pages/handlers/branding.query.js +1 -1
  99. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  100. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  101. package/dist/metrics/compute.js +7 -1
  102. package/dist/metrics/window.js +5 -0
  103. package/dist/notes-history/handlers/add-note.write.js +4 -9
  104. package/dist/notes-history-user-data/hooks.js +8 -1
  105. package/dist/personal-access-tokens/constants.d.ts +1 -0
  106. package/dist/personal-access-tokens/constants.js +1 -0
  107. package/dist/personal-access-tokens/feature.js +2 -2
  108. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  109. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  110. package/dist/personal-access-tokens/i18n.js +12 -13
  111. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  112. package/dist/personal-access-tokens/screens.js +5 -1
  113. package/dist/rate-limiting/feature.js +2 -0
  114. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  115. package/dist/rate-limiting/i18n.d.ts +10 -0
  116. package/dist/rate-limiting/i18n.js +10 -0
  117. package/dist/secrets/feature.js +3 -2
  118. package/dist/seo/handlers/seo-config.query.js +1 -1
  119. package/dist/sessions/constants.d.ts +1 -0
  120. package/dist/sessions/constants.js +1 -0
  121. package/dist/sessions/feature.js +5 -3
  122. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  123. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  124. package/dist/sessions/handlers/revoke.write.js +2 -2
  125. package/dist/sessions/schema/user-session.js +2 -1
  126. package/dist/sessions/screens.js +5 -1
  127. package/dist/sessions/session-callbacks.js +2 -2
  128. package/dist/shared/index.d.ts +1 -1
  129. package/dist/shared/index.js +1 -1
  130. package/dist/shared/lockout-counter.js +1 -1
  131. package/dist/shared/parent-visibility.d.ts +5 -0
  132. package/dist/shared/parent-visibility.js +17 -1
  133. package/dist/shared/row-bound-grant.js +20 -9
  134. package/dist/shared/single-use-token-store.js +5 -13
  135. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  136. package/dist/step-dispatcher/webhook-runner.js +18 -17
  137. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  138. package/dist/subscription-mollie/plugin-methods.js +7 -1
  139. package/dist/subscription-mollie/verify-webhook.js +6 -0
  140. package/dist/subscription-stripe/plugin-methods.js +25 -6
  141. package/dist/subscription-stripe/verify-webhook.js +13 -6
  142. package/dist/tags/handlers/assign-tag.write.js +4 -10
  143. package/dist/tags/handlers/remove-tag.write.js +4 -11
  144. package/dist/template-resolver/api.d.ts +2 -1
  145. package/dist/template-resolver/api.js +1 -1
  146. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  147. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  148. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  149. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  150. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  151. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  152. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  153. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  154. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  155. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  156. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  157. package/dist/template-resolver/handlers/list.query.js +1 -1
  158. package/dist/template-resolver/seeding.js +41 -39
  159. package/dist/template-resolver/table.d.ts +8 -5
  160. package/dist/template-resolver/testing.js +2 -2
  161. package/dist/template-resolver/user-content-table.d.ts +2 -5
  162. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  163. package/dist/template-resolver-user-data/hooks.js +1 -1
  164. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  165. package/dist/tenant/cancel-pending-invitation.js +22 -0
  166. package/dist/tenant/feature.js +4 -2
  167. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +17 -4
  170. package/dist/tenant/handlers/remove-member.write.js +9 -14
  171. package/dist/tenant/handlers/team-list.query.js +2 -2
  172. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  173. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  174. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  175. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  176. package/dist/tenant/i18n.js +4 -0
  177. package/dist/tenant/index.d.ts +1 -1
  178. package/dist/tenant/index.js +1 -1
  179. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  180. package/dist/tenant/last-tenant-admin.js +5 -3
  181. package/dist/tenant/seeding.d.ts +4 -0
  182. package/dist/tenant/seeding.js +9 -2
  183. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  184. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  185. package/dist/tenant-lifecycle/feature.js +2 -1
  186. package/dist/tenant-lifecycle/stages.js +2 -2
  187. package/dist/tier-engine/feature.js +3 -2
  188. package/dist/user/command-schemas.d.ts +2 -2
  189. package/dist/user/handlers/create.write.d.ts +1 -1
  190. package/dist/user/handlers/create.write.js +8 -2
  191. package/dist/user/handlers/update.write.d.ts +1 -1
  192. package/dist/user/handlers/update.write.js +8 -2
  193. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  194. package/dist/user-data-rights/feature.js +22 -9
  195. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  196. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  197. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  198. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  199. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  200. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  201. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  202. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  203. package/dist/user-data-rights/run-export-jobs.js +74 -41
  204. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  205. package/dist/user-data-rights/run-user-export.js +2 -2
  206. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
  207. package/dist/user-profile/feature.js +6 -1
  208. package/dist/user-profile/handlers/change-email.write.js +2 -0
  209. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  210. package/dist/workflow-runner/event-subscriber.js +29 -9
  211. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  212. package/dist/workflow-runner/pending-projection.js +8 -1
  213. package/package.json +11 -9
  214. package/src/agent-tools/changes.json +12 -0
  215. package/src/auth-email-password/changes.json +18 -0
  216. package/src/auth-mfa/changes.json +6 -0
  217. package/src/billing-foundation/changes.json +7 -0
  218. package/src/cap-counter/changes.json +7 -0
  219. package/src/cap-overview/changes.json +12 -0
  220. package/src/channel-email/changes.json +6 -0
  221. package/src/config/changes.json +6 -0
  222. package/src/crypto-shredding/changes.json +6 -0
  223. package/src/data-retention/changes.json +12 -0
  224. package/src/delivery/changes.json +7 -0
  225. package/src/derivatives-sharp/changes.json +11 -0
  226. package/src/document-ingest-foundation/changes.json +12 -0
  227. package/src/inbound-mail-foundation/changes.json +12 -0
  228. package/src/jobs/changes.json +13 -0
  229. package/src/legal-pages/changes.json +8 -1
  230. package/src/managed-pages/changes.json +5 -0
  231. package/src/metrics/changes.json +6 -0
  232. package/src/rate-limiting/changes.json +5 -0
  233. package/src/secrets/changes.json +7 -0
  234. package/src/sessions/changes.json +5 -0
  235. package/src/shared/changes.json +6 -0
  236. package/src/step-dispatcher/changes.json +5 -0
  237. package/src/subscription-mollie/changes.json +7 -0
  238. package/src/subscription-stripe/changes.json +11 -0
  239. package/src/template-resolver/changes.json +7 -0
  240. package/src/tenant/changes.json +29 -0
  241. package/src/user-data-rights/changes.json +22 -0
  242. package/src/workflow-runner/changes.json +6 -0
@@ -8,14 +8,16 @@ import { tenantMembershipsTable } from "./membership-table.js";
8
8
  // (dispatcher batch wraps handlers in transaction — xact lock holds through update).
9
9
  export const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
10
10
  /** Refuse demoting/removing the last TenantAdmin for `tenantId`. */
11
- export async function assertNotLastTenantAdmin(db, tenantId, userId) {
11
+ export async function assertNotLastTenantAdmin(db, tenantId, userId, action = "demote") {
12
12
  await acquireNamespacedAdvisoryLock(db, LAST_TENANT_ADMIN_LOCK_NAMESPACE, tenantId);
13
13
  const allMemberships = await selectMany(db, tenantMembershipsTable, { tenantId });
14
14
  const adminCount = allMemberships.filter((m) => parseRoles(m["roles"]).includes("TenantAdmin")).length;
15
15
  if (adminCount <= 1) {
16
16
  return writeFailure(new ConflictError({
17
- message: "cannot demote the last tenant admin",
18
- i18nKey: "tenant.errors.cannotDemoteLastTenantAdmin",
17
+ message: `cannot ${action} the last tenant admin`,
18
+ i18nKey: action === "remove"
19
+ ? "tenant.errors.cannotRemoveLastTenantAdmin"
20
+ : "tenant.errors.cannotDemoteLastTenantAdmin",
19
21
  details: {
20
22
  reason: TenantErrors.lastTenantAdmin,
21
23
  userId,
@@ -38,6 +38,10 @@ export declare function fireEntityPostSave(hooks: SeedTenantHooks | undefined, p
38
38
  * a second call for the same `id` is a no-op (no update path). Same
39
39
  * TX-semantics as the real `TenantHandlers.create`, minus the SystemAdmin-
40
40
  * access-check and minus ConflictError-on-duplicate.
41
+ *
42
+ * `hooks` run only for the call that actually creates the tenant: a call that
43
+ * finds the tenant/stream already present (or loses a concurrent create race)
44
+ * returns without firing postSave.
41
45
  */
42
46
  export declare function seedTenant(db: DbRunner, options: SeedTenantOptions, hooks?: SeedTenantHooks): Promise<{
43
47
  id: TenantId;
@@ -124,6 +124,10 @@ target) {
124
124
  * a second call for the same `id` is a no-op (no update path). Same
125
125
  * TX-semantics as the real `TenantHandlers.create`, minus the SystemAdmin-
126
126
  * access-check and minus ConflictError-on-duplicate.
127
+ *
128
+ * `hooks` run only for the call that actually creates the tenant: a call that
129
+ * finds the tenant/stream already present (or loses a concurrent create race)
130
+ * returns without firing postSave.
127
131
  */
128
132
  export async function seedTenant(db, options, hooks) {
129
133
  const by = options.by ?? TestUsers.systemAdmin;
@@ -146,8 +150,11 @@ export async function seedTenant(db, options, hooks) {
146
150
  const result = await tenantExecutor.create({ id: options.id, key: options.key, name: options.name }, by, tdb);
147
151
  if (!result.isSuccess) {
148
152
  // Same idempotency case as the stream-version check above, only detected
149
- // one instruction later: a dispatcher cycle appended to the stream between
150
- // that read and this create. Both paths return without firing postSave.
153
+ // one instruction later: a concurrent writer (e.g. a parallel seedTenant
154
+ // for the same id) created the stream between the version read above and
155
+ // this create; the executor confines the failed append to a savepoint, so
156
+ // the surrounding TX stays usable. Both paths return without firing
157
+ // postSave — hooks only run for the call that actually created the tenant.
151
158
  if (result.error.code === "version_conflict")
152
159
  return { id: options.id };
153
160
  throw new Error(`seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`);
@@ -1,2 +1,3 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void;
3
+ export declare function validateTenantDestroyHookShapes(features: readonly FeatureDefinition[]): void;
@@ -1,4 +1,4 @@
1
- import { EXT_TENANT_DATA, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { entitiesOf } from "../shared/index.js";
3
3
  // V4: tenantOwned-entity-without-hook gate. Mirrors user-data-rights' V3
4
4
  // (validateGdprPiiHookCoverage) but for EXT_TENANT_DATA. Registered as this
@@ -34,3 +34,29 @@ export function validateTenantDataHookCoverage(features) {
34
34
  }
35
35
  }
36
36
  }
37
+ const TENANT_RESOURCE_EXTENSIONS = new Set([
38
+ EXT_STORAGE_PROVIDER,
39
+ EXT_SEARCH_ADAPTER,
40
+ EXT_EXTERNAL_RESOURCE,
41
+ EXT_INFRA_RESOURCE,
42
+ ]);
43
+ // Malformed destroy registrations would otherwise only throw when a tenant
44
+ // destruction reaches the stage, possibly months after deploy, and stall the
45
+ // whole pipeline (stages.ts keeps its own throw as defense in depth). JS
46
+ // plugins and `as` casts slip past the type check.
47
+ export function validateTenantDestroyHookShapes(features) {
48
+ for (const feature of features) {
49
+ for (const usage of feature.extensionUsages) {
50
+ const isTenantData = usage.extensionName === EXT_TENANT_DATA;
51
+ if (!isTenantData && !TENANT_RESOURCE_EXTENSIONS.has(usage.extensionName))
52
+ continue;
53
+ const isValid = isTenantData
54
+ ? isTenantDataExtensionHooks(usage.options)
55
+ : isTenantResourceExtensionHooks(usage.options);
56
+ if (isValid)
57
+ continue;
58
+ const requiredFn = isTenantData ? "destroy" : "destroyTenant";
59
+ throw new Error(`[kumiko:boot] ${usage.extensionName} registration for "${usage.entityName}" (feature "${feature.name}") has no ${requiredFn} function — tenant destroy would fail at this stage.`);
60
+ }
61
+ }
62
+ }
@@ -1,5 +1,5 @@
1
1
  import { defineFeature, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, } from "@cosmicdrift/kumiko-framework/engine";
2
- import { validateTenantDataHookCoverage } from "./boot-checks.js";
2
+ import { validateTenantDataHookCoverage, validateTenantDestroyHookShapes } from "./boot-checks.js";
3
3
  import { DESTRUCTION_CANCELLED_EVENT_SHORT, DESTRUCTION_REQUESTED_EVENT_SHORT, TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, TENANT_DESTRUCTION_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
4
4
  import { destructionCancelledSchema, destructionRequestedSchema, tenantDestructionCompletedSchema, tenantDestructionFailedSchema, tenantDestructionStageAbandonedSchema, tenantDestructionStageFailedSchema, tenantDestructionStageStartedSchema, tenantDestructionStageSucceededSchema, tenantDestructionStartedSchema, } from "./events.js";
5
5
  import { cancelDestructionWrite } from "./handlers/cancel-destruction.write.js";
@@ -36,6 +36,7 @@ export function createTenantLifecycleFeature() {
36
36
  // EXT_TENANT_DATA: its own mount is the trigger, matching the original
37
37
  // guard's "tenant-lifecycle mounted" gate exactly.
38
38
  r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
39
+ r.bootCheck(({ features }) => validateTenantDestroyHookShapes(features));
39
40
  r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
40
41
  piiFields: "none",
41
42
  });
@@ -2,7 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { createSystemUser, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, extensionUsageEscapeHatchReason, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
5
- import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
5
+ import { createEscapeHatchReporter, UNATTRIBUTED_ACTOR, } from "@cosmicdrift/kumiko-framework/pipeline";
6
6
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
7
7
  import { tenantEntity, tenantMembershipEntity, tenantMembershipsTable, tenantTable, } from "../tenant/index.js";
8
8
  import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
@@ -30,7 +30,7 @@ async function runTenantDataHooks(ctx) {
30
30
  const report = createEscapeHatchReporter({
31
31
  handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
32
32
  tenantId: ctx.tenantId,
33
- actor: ctx.actor ?? "system",
33
+ actor: ctx.actor ?? UNATTRIBUTED_ACTOR,
34
34
  sink: ctx.escapeHatchAuditSink,
35
35
  });
36
36
  const hookCtx = {
@@ -129,11 +129,12 @@ export function createTierEngineFeature(opts = {}) {
129
129
  // Standard-CRUD via Helper.
130
130
  r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, {
131
131
  ...writeAccess,
132
- description: "Creates the tier-assignment row of the caller's tenant with a tier name and its source; prefer `set-tenant-tier`, which upserts and refreshes the feature gate.",
132
+ // Bypasses the feature-gate refresh that set-tenant-tier performs.
133
+ agent: { expose: false },
133
134
  }));
134
135
  r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, {
135
136
  ...writeAccess,
136
- description: "Updates an existing tier-assignment row by id; prefer `set-tenant-tier`, which resolves the tenant's row itself and refreshes the feature gate.",
137
+ agent: { expose: false },
137
138
  }));
138
139
  // Reads.
139
140
  r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, {
@@ -3,7 +3,7 @@ export declare const UserCommandSchemas: {
3
3
  email: import("zod").ZodEmail;
4
4
  passwordHash: import("zod").ZodOptional<import("zod").ZodString>;
5
5
  displayName: import("zod").ZodString;
6
- locale: import("zod").ZodOptional<import("zod").ZodString>;
6
+ locale: import("zod").ZodOptional<import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<string, string>>>;
7
7
  timezone: import("zod").ZodOptional<import("zod").ZodString>;
8
8
  roles: import("zod").ZodOptional<import("zod").ZodUnion<readonly [import("zod").ZodArray<import("zod").ZodString>, import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<string[], string>>]>>;
9
9
  }, import("zod/v4/core").$strip>;
@@ -12,7 +12,7 @@ export declare const UserCommandSchemas: {
12
12
  version: import("zod").ZodNumber;
13
13
  changes: import("zod").ZodObject<{
14
14
  displayName: import("zod").ZodOptional<import("zod").ZodString>;
15
- locale: import("zod").ZodOptional<import("zod").ZodString>;
15
+ locale: import("zod").ZodOptional<import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<string, string>>>;
16
16
  timezone: import("zod").ZodOptional<import("zod").ZodString>;
17
17
  email: import("zod").ZodOptional<import("zod").ZodEmail>;
18
18
  passwordHash: import("zod").ZodOptional<import("zod").ZodString>;
@@ -3,7 +3,7 @@ export declare const createWrite: import("@cosmicdrift/kumiko-types/define-handl
3
3
  email: z.ZodEmail;
4
4
  passwordHash: z.ZodOptional<z.ZodString>;
5
5
  displayName: z.ZodString;
6
- locale: z.ZodOptional<z.ZodString>;
6
+ locale: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
7
7
  timezone: z.ZodOptional<z.ZodString>;
8
8
  roles: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>]>>;
9
9
  }, z.core.$strip>, import("@cosmicdrift/kumiko-types/hooks").SaveContext, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -2,7 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler, findForbiddenRoleAssignment, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { AccessDeniedError, ConflictError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
- import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
5
+ import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
6
6
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
7
7
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
8
8
  import * as z from "zod";
@@ -28,7 +28,13 @@ export const createWrite = defineWriteHandler({
28
28
  email: z.email(),
29
29
  passwordHash: z.string().optional(),
30
30
  displayName: z.string().min(1).max(100),
31
- locale: z.string().min(2).max(10).refine(isValidLocaleTag, "invalid locale tag").optional(),
31
+ locale: z
32
+ .string()
33
+ .min(2)
34
+ .max(10)
35
+ .refine(isValidLocaleTag, "invalid locale tag")
36
+ .transform(canonicalizeLocaleTag)
37
+ .optional(),
32
38
  timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
33
39
  // Field-level write access (privileged) is defense-in-depth — create is
34
40
  // already system/SystemAdmin-only.
@@ -4,7 +4,7 @@ export declare const updateWrite: import("@cosmicdrift/kumiko-types/define-handl
4
4
  version: z.ZodNumber;
5
5
  changes: z.ZodObject<{
6
6
  displayName: z.ZodOptional<z.ZodString>;
7
- locale: z.ZodOptional<z.ZodString>;
7
+ locale: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
8
8
  timezone: z.ZodOptional<z.ZodString>;
9
9
  email: z.ZodOptional<z.ZodEmail>;
10
10
  passwordHash: z.ZodOptional<z.ZodString>;
@@ -1,7 +1,7 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
4
- import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
4
+ import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
5
5
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
6
6
  import * as z from "zod";
7
7
  import { UserErrors } from "../constants.js";
@@ -24,7 +24,13 @@ export const updateWrite = defineWriteHandler({
24
24
  version: z.number(),
25
25
  changes: z.object({
26
26
  displayName: z.string().min(1).max(100).optional(),
27
- locale: z.string().min(2).max(10).refine(isValidLocaleTag, "invalid locale tag").optional(),
27
+ locale: z
28
+ .string()
29
+ .min(2)
30
+ .max(10)
31
+ .refine(isValidLocaleTag, "invalid locale tag")
32
+ .transform(canonicalizeLocaleTag)
33
+ .optional(),
28
34
  timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
29
35
  email: z.email().optional(),
30
36
  passwordHash: z.string().optional(),
@@ -8,5 +8,7 @@ export type ExportJobCleanupCandidate = {
8
8
  readonly requestedFromTenantId: TenantId;
9
9
  readonly downloadStorageKey: string | null;
10
10
  readonly expiresAt: Temporal.Instant | null;
11
+ readonly completedAt: Temporal.Instant | null;
12
+ readonly startedAt: Temporal.Instant | null;
11
13
  };
12
14
  export declare function selectExportJobsForStorageCleanup(db: DbConnection, doneStatus: string, failedStatus: string): Promise<readonly ExportJobCleanupCandidate[]>;
@@ -25,7 +25,7 @@ import { isMailTransportAvailable, makeDefaultDeletionExecutedEmail, makeDefault
25
25
  import { makeTenantMailTransportResolver } from "./lib/mail-transport-resolver.js";
26
26
  import { resolveAppTenantModel } from "./lib/resolve-tenant-model.js";
27
27
  import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver.js";
28
- import { EXPORT_CLEANUP_BACKLOG_AGE_METRIC, runExportJobs, } from "./run-export-jobs.js";
28
+ import { EXPORT_CLEANUP_BACKLOG_AGE_METRIC, EXPORT_CLEANUP_LAST_RUN_TIMESTAMP_METRIC, runExportJobs, } from "./run-export-jobs.js";
29
29
  import { runForgetCleanup, sanitizeReasonForLog, } from "./run-forget-cleanup.js";
30
30
  import { downloadAttemptEntity } from "./schema/download-attempt.js";
31
31
  import { exportDownloadTokenEntity } from "./schema/download-token.js";
@@ -156,12 +156,18 @@ export function createUserDataRightsFeature(opts = {}) {
156
156
  "userDataRights.privacyCenter.field.gracePeriodEnd": { en: "Scheduled deletion date" },
157
157
  "userDataRights.privacyCenter.export.title": { en: "Export your data (Art. 20)" },
158
158
  "userDataRights.privacyCenter.restriction.title": { en: "Restrict processing (Art. 18)" },
159
+ "userDataRights.privacyCenter.restriction.explainer": {
160
+ en: "Freeze your account: processing of your data is paused and you are signed out. Lifting the restriction afterwards is only possible via support.",
161
+ },
159
162
  "userDataRights.privacyCenter.restriction.restrict": { en: "Restrict account" },
160
163
  "userDataRights.privacyCenter.restriction.dialogTitle": { en: "Restrict your account?" },
161
164
  "userDataRights.privacyCenter.restriction.dialogDescription": {
162
165
  en: "You will be signed out immediately and cannot sign in again until support lifts the restriction.",
163
166
  },
164
167
  "userDataRights.privacyCenter.deletion.title": { en: "Delete account (Art. 17)" },
168
+ "userDataRights.privacyCenter.deletion.explainer": {
169
+ en: "Request deletion of your account. Until the grace period ends you can cancel the deletion.",
170
+ },
165
171
  "userDataRights.privacyCenter.deletion.delete": { en: "Delete account" },
166
172
  "userDataRights.privacyCenter.deletion.cancel": { en: "Cancel deletion" },
167
173
  "userDataRights.privacyCenter.deletion.dialogTitle": { en: "Delete your account?" },
@@ -215,7 +221,7 @@ export function createUserDataRightsFeature(opts = {}) {
215
221
  // when `showDeletion` is false, and status covers restriction
216
222
  // AND deletion state, so it can't hang off the optional section.
217
223
  title: i18nKey("userDataRights.privacyCenter.restriction.title"),
218
- description: "userDataRights.privacyCenter.restriction.explainer",
224
+ description: i18nKey("userDataRights.privacyCenter.restriction.explainer"),
219
225
  fields: [
220
226
  {
221
227
  field: "status",
@@ -227,7 +233,7 @@ export function createUserDataRightsFeature(opts = {}) {
227
233
  ? [
228
234
  {
229
235
  title: i18nKey("userDataRights.privacyCenter.deletion.title"),
230
- description: "userDataRights.privacyCenter.deletion.explainer",
236
+ description: i18nKey("userDataRights.privacyCenter.deletion.explainer"),
231
237
  // gracePeriodEnd is only meaningful once a deletion is
232
238
  // actually pending — hide it instead of showing an empty
233
239
  // date when status isn't deletionRequested.
@@ -269,6 +275,8 @@ export function createUserDataRightsFeature(opts = {}) {
269
275
  handler: UserDataRightsHandlers.cancelDeletion,
270
276
  visible: { field: "status", eq: "deletionRequested" },
271
277
  style: "secondary",
278
+ // Declared: the id's last segment ("deletion") would otherwise resolve to the destructive trash icon.
279
+ icon: "x",
272
280
  },
273
281
  ]
274
282
  : []),
@@ -372,15 +380,20 @@ export function createUserDataRightsFeature(opts = {}) {
372
380
  // Surfaces a stalled export-cleanup cron: if the daily
373
381
  // pass stops running, done-status export bundles keep sitting in
374
382
  // storage past their TTL+grace with downloadStorageKey still set
375
- // (unencrypted PII at rest). Emitted from the same job that cleans up
376
- // (see handler below) — a dead cron makes the metric go stale too,
377
- // which an `absent()` alert catches without a second liveness signal.
378
- // Scope: Done-status bundles only — Failed-status cleanup candidates
379
- // have no expiresAt/TTL (immediate cleanup, see storageCleanupPass).
383
+ // (unencrypted PII at rest). Gauges keep their last value in-process, so
384
+ // a dead cron does NOT make the backlog gauge absent; the last-run
385
+ // timestamp gauge below is the liveness signal.
386
+ // Scope: Done-status bundles past expiresAt+grace plus Failed-status
387
+ // orphans (no TTL, due from completedAt ?? startedAt; see storageCleanupPass).
380
388
  r.metric(EXPORT_CLEANUP_BACKLOG_AGE_METRIC, {
381
389
  type: "gauge",
382
390
  unit: "seconds",
383
- description: "Age in seconds of the oldest done-status export bundle whose expiresAt+grace has passed but downloadStorageKey is still set. 0 when the storage-cleanup pass has no backlog.",
391
+ description: "Age in seconds of the oldest export bundle (done: expiresAt+grace passed; failed: orphaned ZIP since completedAt or startedAt) whose downloadStorageKey is still set. 0 when the storage-cleanup pass has no backlog.",
392
+ });
393
+ r.metric(EXPORT_CLEANUP_LAST_RUN_TIMESTAMP_METRIC, {
394
+ type: "gauge",
395
+ unit: "seconds",
396
+ description: "Unix timestamp of the last export storage-cleanup pass that completed. Alert on time() minus this value exceeding the cron interval plus a buffer; the backlog-age gauge alone stays at its last value when the cron stops.",
384
397
  });
385
398
  // S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
386
399
  const RUN_EXPORT_JOBS_REASON = "processes pending export jobs across every tenant";
@@ -1,9 +1,9 @@
1
- import { createSystemUser, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { USER_STATUS, userTable } from "../../user/index.js";
5
5
  import { redeemDeletionToken } from "../deletion-token.js";
6
- import { startDeletionGracePeriod } from "./deletion-grace-period.js";
6
+ import { resolveGracePeriod, startDeletionGracePeriod } from "./deletion-grace-period.js";
7
7
  // Generischer 422 für jeden Token-Fehlerpfad (malformed / bad_signature /
8
8
  // expired / kein Secret) — kein Signal ob ein Token zu einem User gehört.
9
9
  function invalidToken() {
@@ -55,11 +55,8 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
55
55
  "SYSTEM_TENANT_ID user stream.",
56
56
  },
57
57
  agent: { expose: false },
58
- rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
58
+ rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
59
59
  handler: async (event, ctx) => {
60
- // @cast-boundary engine-payload — queryAs returns unknown, narrowed to
61
- // the compliance-profile shape.
62
- const profile = (await ctx.queryAs(createSystemUser(event.user.tenantId), "compliance-profiles:query:for-tenant", {}));
63
60
  let gracePeriodEndIso;
64
61
  // The row's requestId is part of the verify key, so a token from a
65
62
  // cancelled or superseded cycle fails. Every error path ends in the same
@@ -71,7 +68,7 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
71
68
  secret: opts.deletionTokenSecret,
72
69
  loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
73
70
  commitDeletion: async (userId, requestId) => {
74
- const res = await startDeletionGracePeriod(ctx, userId, profile.profile.userRights.gracePeriod, ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"), { pendingDeletionRequestId: requestId });
71
+ const res = await startDeletionGracePeriod(ctx, userId, () => resolveGracePeriod(ctx, event.user.tenantId), ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"), { pendingDeletionRequestId: requestId });
75
72
  if (!res.ok)
76
73
  return false;
77
74
  gracePeriodEndIso = res.gracePeriodEnd.toString();
@@ -1,9 +1,10 @@
1
1
  import { type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
- import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
6
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
7
+ export declare function resolveGracePeriod(ctx: HandlerContext, tenantId: string): Promise<DurationSpec>;
7
8
  export type StartGracePeriodResult = {
8
9
  readonly ok: true;
9
10
  readonly gracePeriodEnd: Instant;
@@ -13,5 +14,5 @@ export type StartGracePeriodResult = {
13
14
  readonly ok: false;
14
15
  readonly error: UnprocessableError;
15
16
  };
16
- export declare function startDeletionGracePeriod(ctx: HandlerContext, userId: string, gracePeriod: DurationSpec, lifecycleRunner: DbRunner, additionalExpect?: Readonly<Record<string, string | number | boolean | null>>): Promise<StartGracePeriodResult>;
17
+ export declare function startDeletionGracePeriod(ctx: HandlerContext, userId: string, resolveGracePeriodSpec: () => Promise<DurationSpec>, lifecycleRunner: DbRunner, additionalExpect?: Readonly<Record<string, string | number | boolean | null>>): Promise<StartGracePeriodResult>;
17
18
  export {};
@@ -1,9 +1,21 @@
1
- import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
1
+ import { addDurationSpec, } from "@cosmicdrift/kumiko-framework/compliance";
2
+ import { createSystemUser, declareEscapeHatch, } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
4
5
  import { decryptStoredPii } from "../../shared/index.js";
5
6
  import { USER_STATUS, userTable } from "../../user/index.js";
6
7
  import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
8
+ // Single trust-boundary cast for the compliance-profile lookup: the query
9
+ // handler's declared return type is EffectiveComplianceProfile, but queryAs
10
+ // only hands back unknown. Needs the calling handler's escapeHatch (system
11
+ // queryAs).
12
+ export async function resolveGracePeriod(ctx, tenantId) {
13
+ declareEscapeHatch({
14
+ reason: "reads the tenant compliance profile via queryAs(SYSTEM, ...) to compute the grace period end; the calling user's own identity has no read access to that tenant-config projection",
15
+ });
16
+ const profile = (await ctx.queryAs(createSystemUser(tenantId), "compliance-profiles:query:for-tenant", {})); // @cast-boundary engine-payload
17
+ return profile.profile.userRights.gracePeriod;
18
+ }
7
19
  // Flips an active user to DeletionRequested and sets gracePeriodEnd from the
8
20
  // caller-supplied grace period. Shared between the authenticated
9
21
  // request-deletion path (event.user) and the anonymous confirm-by-token path
@@ -13,7 +25,7 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
13
25
  // ctx.db.global(userTable); only the grace period duration is tenant-configured.
14
26
  // That read only supplies email/locale for the caller's notification — it is
15
27
  // NOT the transition's guard. The guard is `expect: { status: Active }` on
16
- // the lifecycle write itself (#3024): the executor re-checks it against a
28
+ // the lifecycle write itself: the executor re-checks it against a
17
29
  // fresh row right before writing, so a concurrent caller that already moved
18
30
  // the user off Active is rejected even though this read saw Active.
19
31
  //
@@ -31,11 +43,13 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
31
43
  // true). No-op for the authenticated request-deletion path, which never set
32
44
  // a pendingDeletionRequestId to begin with.
33
45
  //
34
- // `gracePeriod` and `lifecycleRunner` are resolved by the caller: both
35
- // require an escalation (reading the tenant compliance profile, appending to
36
- // the SYSTEM_TENANT_ID user stream) that only the declaring handler's
37
- // escapeHatch covers.
38
- export async function startDeletionGracePeriod(ctx, userId, gracePeriod, lifecycleRunner, additionalExpect) {
46
+ // `resolveGracePeriodSpec` and `lifecycleRunner` are supplied by the caller:
47
+ // both require an escalation (reading the tenant compliance profile,
48
+ // appending to the SYSTEM_TENANT_ID user stream) that only the declaring
49
+ // handler's escapeHatch covers. The grace period is a lazy provider so the
50
+ // privileged compliance-profile query only runs for a user that is still
51
+ // Active, not for replayed tokens or already-requested deletions.
52
+ export async function startDeletionGracePeriod(ctx, userId, resolveGracePeriodSpec, lifecycleRunner, additionalExpect) {
39
53
  const userRow = await ctx.db
40
54
  .global(userTable)
41
55
  .fetchOne({ id: userId });
@@ -47,8 +61,16 @@ export async function startDeletionGracePeriod(ctx, userId, gracePeriod, lifecyc
47
61
  }),
48
62
  };
49
63
  }
64
+ if (userRow["status"] !== USER_STATUS.Active) {
65
+ return {
66
+ ok: false,
67
+ error: new UnprocessableError("user_not_in_active_state", {
68
+ details: { currentStatus: userRow["status"] },
69
+ }),
70
+ };
71
+ }
50
72
  const T = getTemporal();
51
- const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
73
+ const gracePeriodEnd = addDurationSpec(T.Now.instant(), await resolveGracePeriodSpec());
52
74
  const { applied } = await updateUserLifecycle(lifecycleRunner, userId, { status: USER_STATUS.DeletionRequested, gracePeriodEnd, pendingDeletionRequestId: null }, { expect: { status: USER_STATUS.Active, ...additionalExpect } });
53
75
  if (!applied) {
54
76
  return {
@@ -56,7 +56,7 @@ export const downloadByTokenQuery = defineQueryHandler({
56
56
  // 30 attempts/min/IP reicht fuer legitime User (mehrere Klicks bei
57
57
  // Connection-Abbruch); blockiert automatisierte Probing-Loops.
58
58
  // Memory `feedback_security_default_on`.
59
- rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
59
+ rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
60
60
  escapeHatch: {
61
61
  reason: DOWNLOAD_BY_TOKEN_REASON,
62
62
  },
@@ -34,7 +34,7 @@ export function createRequestDeletionByEmailHandler(opts = {}) {
34
34
  // Mails a deletion link to any address the caller names.
35
35
  agent: { risk: "high" },
36
36
  // Defense-in-depth gegen Email-Probing auf dem anonymen Endpoint.
37
- rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
37
+ rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
38
38
  escapeHatch: {
39
39
  reason: APPEND_LIFECYCLE_EVENT_REASON,
40
40
  },
@@ -1,10 +1,10 @@
1
1
  import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
2
- import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
5
  import { USER_STATUS } from "../../user/index.js";
6
6
  import { isMailTransportAvailable, makeDefaultDeletionRequestedEmail, } from "../lib/default-mailers.js";
7
- import { startDeletionGracePeriod } from "./deletion-grace-period.js";
7
+ import { resolveGracePeriod, startDeletionGracePeriod } from "./deletion-grace-period.js";
8
8
  // POST /api/user/request-deletion (S2.U5a) — DSGVO Art. 17 Forget-Antrag.
9
9
  // Flippt status=Active → deletionRequested, setzt gracePeriodEnd aus dem
10
10
  // Compliance-Profile (geteilte Logik: startDeletionGracePeriod). Account-
@@ -29,10 +29,7 @@ export function createRequestDeletionHandler(opts = {}) {
29
29
  description: "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
30
30
  agent: { risk: "high" },
31
31
  handler: async (event, ctx) => {
32
- // @cast-boundary engine-payload — queryAs returns unknown, narrowed to
33
- // the compliance-profile shape.
34
- const profile = (await ctx.queryAs(createSystemUser(event.user.tenantId), "compliance-profiles:query:for-tenant", {}));
35
- const res = await startDeletionGracePeriod(ctx, event.user.id, profile.profile.userRights.gracePeriod, ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"));
32
+ const res = await startDeletionGracePeriod(ctx, event.user.id, () => resolveGracePeriod(ctx, event.user.tenantId), ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"));
36
33
  if (!res.ok)
37
34
  return writeFailure(res.error);
38
35
  const { gracePeriodEnd, userEmail, userLocale } = res;
@@ -26,25 +26,18 @@ const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityNam
26
26
  // longer held (precondition_failed) or a concurrent writer won the race on
27
27
  // the same stream version (version_conflict) — both mean "someone else's
28
28
  // transition already landed", the expected outcome for a caller that opted
29
- // into `expect`. Any other failure, or a failure with no `expect` set (every
30
- // pre-#3024 caller), still throws — those callers never checked a return
31
- // value, so silently dropping their write would be silent data loss.
29
+ // into `expect`. Any other failure, or a failure with no `expect` set, still
30
+ // throws — those callers never checked a return value, so silently dropping
31
+ // their write would be silent data loss.
32
32
  export async function updateUserLifecycle(conn, userId, changes, options) {
33
33
  // user ist systemStream (#497): der Executor-Choke-Point addressiert den
34
34
  // Stream immer auf SYSTEM_TENANT_ID — ein Rescope auf die row-tenant_id
35
35
  // waere wirkungslos. "system"-Mode, damit loadById auch Legacy-Rows findet,
36
36
  // deren tenant_id noch vor dem #762-Backfill-Rebuild steht.
37
- // skipOptimisticLock stays true (kept correct, not just carried over): it
38
- // was set from this function's very first version (#494) because every
39
- // caller here is a server-side business transition (restrict/lift-
40
- // restriction/grace-period/cancel/forget), never a client edit-conflict UI
41
- // round-trip — none of them read-then-hold a row version to pass as
42
- // `payload.version`, so the executor's version gate would reject every
43
- // call outright without it (`update()` requires `payload.version` unless
44
- // skipOptimisticLock is set). #3024 replaces the safety that decision gave
45
- // up with the purpose-built `expect:` precondition above, checked fresh at
46
- // write time — the mechanism now genuinely matches the caller's shape
47
- // (a business-field guard) instead of a repurposed version check.
37
+ // skipOptimisticLock: every caller is a server-side business transition
38
+ // (restrict/lift-restriction/grace-period/cancel/forget) that holds no row
39
+ // version to pass as `payload.version`, which update() requires otherwise.
40
+ // Callers needing a guard use `options.expect` instead.
48
41
  const tenantDb = createTenantDb(conn, SYSTEM_TENANT_ID, "system");
49
42
  const result = await userExecutor.update({ id: userId, changes }, createSystemUser(SYSTEM_TENANT_ID), tenantDb, { skipOptimisticLock: true, ...(options?.expect && { expect: options.expect }) });
50
43
  if (result.isSuccess)
@@ -5,6 +5,7 @@ import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability"
5
5
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
6
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
7
7
  export declare const EXPORT_CLEANUP_BACKLOG_AGE_METRIC = "export_cleanup_backlog_age";
8
+ export declare const EXPORT_CLEANUP_LAST_RUN_TIMESTAMP_METRIC = "export_cleanup_last_run_timestamp";
8
9
  export declare const tokenCrud: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
9
10
  /**
10
11
  * Notification-Callback fuer den done-Pfad. Pattern matched