@cosmicdrift/kumiko-bundled-features 0.338.0 → 0.339.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 (153) hide show
  1. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  2. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -4
  3. package/dist/auth-email-password/handlers/invite-info.query.js +3 -1
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -4
  5. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
  6. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  7. package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
  8. package/dist/auth-email-password/web/auth-gate.js +25 -1
  9. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
  10. package/dist/auth-email-password/web/auth-redirect.js +58 -0
  11. package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
  12. package/dist/auth-email-password/web/client-plugin.js +2 -0
  13. package/dist/auth-email-password/web/index.d.ts +2 -1
  14. package/dist/auth-email-password/web/index.js +1 -0
  15. package/dist/auth-email-password/web/session.d.ts +3 -1
  16. package/dist/auth-email-password/web/session.js +8 -2
  17. package/dist/auth-mfa/feature.js +1 -1
  18. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
  19. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
  20. package/dist/auth-mfa/handlers/verify.write.js +1 -1
  21. package/dist/auth-mfa/mfa-code-verifier.js +1 -1
  22. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  23. package/dist/billing-foundation/constants.d.ts +7 -0
  24. package/dist/billing-foundation/constants.js +8 -0
  25. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +3 -3
  26. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +3 -3
  27. package/dist/billing-foundation/consumer-protection/consent-gate.js +10 -2
  28. package/dist/billing-foundation/consumer-protection/consent-text.js +2 -2
  29. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +1 -1
  30. package/dist/billing-foundation/consumer-protection/register-termination.js +31 -3
  31. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +3 -1
  32. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -1
  33. package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
  34. package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
  35. package/dist/billing-foundation/consumer-protection/termination-pages.js +31 -2
  36. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +24 -2
  37. package/dist/billing-foundation/consumer-protection/termination-record.js +102 -34
  38. package/dist/billing-foundation/consumer-protection/termination-request.js +10 -42
  39. package/dist/billing-foundation/consumer-protection/termination-texts.js +20 -20
  40. package/dist/billing-foundation/events.d.ts +19 -0
  41. package/dist/billing-foundation/events.js +12 -0
  42. package/dist/billing-foundation/handlers/create-checkout-session.write.js +2 -2
  43. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +4 -2
  44. package/dist/billing-foundation/i18n.js +3 -0
  45. package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
  46. package/dist/billing-foundation/types.d.ts +6 -1
  47. package/dist/billing-foundation/validate-options.js +4 -0
  48. package/dist/channel-discord/discord-channel.d.ts +6 -0
  49. package/dist/channel-discord/discord-channel.js +19 -0
  50. package/dist/channel-discord/feature.d.ts +3 -0
  51. package/dist/channel-discord/feature.js +30 -0
  52. package/dist/channel-discord/index.d.ts +2 -0
  53. package/dist/channel-discord/index.js +2 -0
  54. package/dist/channel-slack/feature.d.ts +3 -0
  55. package/dist/channel-slack/feature.js +30 -0
  56. package/dist/channel-slack/index.d.ts +2 -0
  57. package/dist/channel-slack/index.js +2 -0
  58. package/dist/channel-slack/slack-channel.d.ts +5 -0
  59. package/dist/channel-slack/slack-channel.js +16 -0
  60. package/dist/channel-teams/feature.d.ts +3 -0
  61. package/dist/channel-teams/feature.js +34 -0
  62. package/dist/channel-teams/index.d.ts +2 -0
  63. package/dist/channel-teams/index.js +2 -0
  64. package/dist/channel-teams/teams-channel.d.ts +5 -0
  65. package/dist/channel-teams/teams-channel.js +38 -0
  66. package/dist/channel-telegram/feature.d.ts +3 -0
  67. package/dist/channel-telegram/feature.js +28 -0
  68. package/dist/channel-telegram/index.d.ts +2 -0
  69. package/dist/channel-telegram/index.js +2 -0
  70. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  71. package/dist/channel-telegram/telegram-channel.js +54 -0
  72. package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -6
  73. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -1
  74. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -1
  75. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -1
  76. package/dist/custom-fields/wire-user-data-rights.js +1 -1
  77. package/dist/data-retention/feature.js +1 -1
  78. package/dist/delivery/channel-context.d.ts +2 -1
  79. package/dist/delivery/channel-context.js +2 -2
  80. package/dist/delivery/chat-webhook-channel.d.ts +19 -0
  81. package/dist/delivery/chat-webhook-channel.js +48 -0
  82. package/dist/delivery/chat-webhook-sender.d.ts +21 -0
  83. package/dist/delivery/chat-webhook-sender.js +102 -0
  84. package/dist/delivery/delivery-service.js +3 -0
  85. package/dist/delivery/index.d.ts +2 -0
  86. package/dist/delivery/index.js +2 -0
  87. package/dist/delivery/jobs.js +1 -1
  88. package/dist/delivery/testing.d.ts +2 -0
  89. package/dist/delivery/types.d.ts +3 -1
  90. package/dist/delivery/types.js +1 -2
  91. package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
  92. package/dist/files-tenant-data/index.js +1 -1
  93. package/dist/form-draft/feature.js +1 -1
  94. package/dist/form-draft/handlers/discard.write.js +1 -1
  95. package/dist/form-draft/handlers/save.write.js +1 -1
  96. package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
  97. package/dist/inbound-mail-foundation/connect-routes.js +13 -8
  98. package/dist/inbound-mail-foundation/feature.js +1 -1
  99. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -1
  100. package/dist/inbound-mail-foundation/index.d.ts +1 -0
  101. package/dist/inbound-mail-foundation/index.js +1 -0
  102. package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
  103. package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
  104. package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
  105. package/dist/inbound-mail-foundation/types.d.ts +18 -1
  106. package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
  107. package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
  108. package/dist/managed-pages/handlers/set.write.js +1 -1
  109. package/dist/notes-history/handlers/add-note.write.js +1 -1
  110. package/dist/renderer-simple/feature.d.ts +8 -2
  111. package/dist/renderer-simple/feature.js +8 -5
  112. package/dist/renderer-simple/index.d.ts +2 -2
  113. package/dist/renderer-simple/index.js +1 -1
  114. package/dist/renderer-simple/simple-renderer.d.ts +15 -0
  115. package/dist/renderer-simple/simple-renderer.js +74 -27
  116. package/dist/secrets/feature.js +1 -1
  117. package/dist/sessions/feature.js +1 -1
  118. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
  119. package/dist/tenant-handover/handlers/claim.write.js +1 -2
  120. package/dist/tenant-lifecycle/feature.js +1 -1
  121. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +3 -1
  122. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -1
  123. package/dist/tier-engine/feature.js +1 -1
  124. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -1
  125. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -1
  126. package/dist/user-data-rights/feature.js +2 -2
  127. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -1
  128. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
  129. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
  130. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  131. package/dist/user-data-rights/handlers/export-status.query.js +1 -1
  132. package/dist/user-data-rights/handlers/lift-restriction.write.js +2 -4
  133. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
  134. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  135. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
  136. package/dist/user-data-rights/handlers/request-export.write.js +1 -1
  137. package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
  138. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -1
  139. package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
  140. package/dist/workflow-runner/event-subscriber.js +6 -2
  141. package/dist/workflow-runner/event-trigger.js +5 -0
  142. package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
  143. package/dist/workflow-runner/pending-projection.js +1 -0
  144. package/dist/workflow-runner/runner.d.ts +7 -1
  145. package/dist/workflow-runner/runner.js +1 -1
  146. package/dist/workflow-runner/tables.js +8 -1
  147. package/package.json +41 -9
  148. package/src/auth-email-password/changes.json +7 -0
  149. package/src/billing-foundation/changes.json +34 -0
  150. package/src/delivery/changes.json +7 -0
  151. package/src/inbound-mail-foundation/changes.json +7 -0
  152. package/src/renderer-simple/changes.json +9 -1
  153. package/src/workflow-runner/changes.json +7 -0
@@ -48,9 +48,15 @@ import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/ku
48
48
  import { Temporal } from "temporal-polyfill";
49
49
  import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.js";
50
50
  import { MAIL_ACCOUNT_PII_FIELDS, syncCursorTable } from "./entities.js";
51
+ import { createOAuthAccessTokenManager, usesFoundationManagedOAuth, } from "./oauth-access-token.js";
51
52
  import { mailAccountsProjectionTable } from "./projection.js";
52
53
  import { resolveInboundProviderForAccount } from "./provider-factory.js";
53
54
  import { isInboundAuthError, isInboundCursorInvalidError, isInboundRateLimitError, } from "./types.js";
55
+ // Provider errors can embed token or response data in `message`; logs carry
56
+ // only the error class.
57
+ function describeError(err) {
58
+ return err instanceof Error ? err.name : typeof err;
59
+ }
54
60
  const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
55
61
  const DEFAULT_BACKFILL_WINDOW_DAYS = 30;
56
62
  const DEFAULT_MAX_MESSAGES_PER_POLL = 200;
@@ -72,6 +78,25 @@ export function createInboundMailSupervisor(deps) {
72
78
  // configured TTL — real deployments (default 90s) never hit it.
73
79
  const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
74
80
  const log = deps.log ?? (() => { });
81
+ const oauthTokens = deps.oauthTokens ??
82
+ (deps.providerCtx.secrets
83
+ ? createOAuthAccessTokenManager({
84
+ db: deps.db,
85
+ secrets: deps.providerCtx.secrets,
86
+ providerCtx: deps.providerCtx,
87
+ })
88
+ : undefined);
89
+ // Undefined for non-OAuth accounts. A refresh failure propagates into the
90
+ // caller's handleSyncError: InboundAuthError → auth_error, anything else
91
+ // → transient retry.
92
+ async function resolveAccessToken(account, plugin) {
93
+ if (!usesFoundationManagedOAuth(account, plugin))
94
+ return undefined;
95
+ if (!oauthTokens) {
96
+ throw new Error("oauth account needs a secrets context for token refresh");
97
+ }
98
+ return oauthTokens.getAccessToken(account, plugin);
99
+ }
75
100
  let running = false;
76
101
  let pollTimer = null;
77
102
  let pollInFlight = null;
@@ -213,7 +238,7 @@ export function createInboundMailSupervisor(deps) {
213
238
  return true;
214
239
  }
215
240
  // Transient/unbekannt: loggen, nächster Tick retried.
216
- log(`inbound-mail: account ${account.id} sync error: ${err instanceof Error ? err.message : String(err)}`);
241
+ log(`inbound-mail: account ${account.id} sync error: ${describeError(err)}`);
217
242
  return true;
218
243
  }
219
244
  // ---------------------------------------------------------------
@@ -225,9 +250,11 @@ export function createInboundMailSupervisor(deps) {
225
250
  try {
226
251
  // hasMore-Schleife: Pagination innerhalb eines Ticks bis Budget.
227
252
  for (;;) {
253
+ const accessToken = await resolveAccessToken(account, plugin);
228
254
  const result = await plugin.fetch(deps.providerCtx, account, cursor, {
229
255
  backfillWindowDays,
230
256
  maxMessages: budget,
257
+ ...(accessToken !== undefined && { accessToken }),
231
258
  });
232
259
  await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
233
260
  await saveCursor(account, result.nextCursor);
@@ -249,7 +276,7 @@ export function createInboundMailSupervisor(deps) {
249
276
  plugin = resolveInboundProviderForAccount(deps.providerCtx, account);
250
277
  }
251
278
  catch (err) {
252
- log(`inbound-mail: ${err instanceof Error ? err.message : String(err)}`);
279
+ log(`inbound-mail: ${describeError(err)}`);
253
280
  continue;
254
281
  }
255
282
  await pollAccount(account, plugin);
@@ -290,7 +317,7 @@ export function createInboundMailSupervisor(deps) {
290
317
  renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
291
318
  }
292
319
  catch (err) {
293
- log(`inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
320
+ log(`inbound-mail: watch lease renew for account ${account.id} failed: ${describeError(err)}`);
294
321
  state.renewFailures += 1;
295
322
  // After failures spanning a full lease TTL, tear down — otherwise a
296
323
  // peer can acquire the expired key while we still hold an IDLE conn.
@@ -379,7 +406,7 @@ export function createInboundMailSupervisor(deps) {
379
406
  state.stop = null;
380
407
  const delay = state.backoffMs;
381
408
  state.backoffMs = Math.min(state.backoffMs * 2, backoffMaxMs);
382
- log(`inbound-mail: watch for account ${account.id} died (${err instanceof Error ? err.message : String(err)}) — restart in ${delay}ms`);
409
+ log(`inbound-mail: watch for account ${account.id} died (${describeError(err)}) — restart in ${delay}ms`);
383
410
  void markAccount(account, { watchState: `backoff:${delay}ms` }, "watch_supervisor");
384
411
  state.restartTimer = setTimeout(() => {
385
412
  state.restartTimer = null;
@@ -388,27 +415,28 @@ export function createInboundMailSupervisor(deps) {
388
415
  };
389
416
  state.starting = true;
390
417
  try {
418
+ const accessToken = await resolveAccessToken(account, plugin);
391
419
  const stop = await plugin.watch(deps.providerCtx, account, {
392
420
  onMessages: async (msgs) => {
393
421
  try {
394
422
  await ingestBatch(account, msgs, "watch");
395
423
  }
396
424
  catch (err) {
397
- // Ingest-Fehler killt den Watcher nicht — der Poll holt die
398
- // Messages beim nächsten Tick (Dedup macht's idempotent).
399
- log(`inbound-mail: watch-ingest for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
425
+ // An ingest failure must not kill the watcher: the next poll
426
+ // tick fetches the messages again and dedup keeps it idempotent.
427
+ log(`inbound-mail: watch-ingest for account ${account.id} failed: ${describeError(err)}`);
400
428
  }
401
429
  },
402
430
  onError: (err) => {
403
431
  void (async () => {
404
432
  const keepRunning = await handleSyncError(account, err);
405
- // auth_error → stopWatcher hat die generation gebumpt,
406
- // scheduleRestart no-op't; für alle anderen: Backoff-Restart.
433
+ // On auth_error stopWatcher already bumped the generation, so
434
+ // scheduleRestart is a no-op; every other error restarts with backoff.
407
435
  if (keepRunning)
408
436
  scheduleRestart(err);
409
437
  })();
410
438
  },
411
- });
439
+ }, accessToken !== undefined ? { accessToken } : undefined);
412
440
  if (state.generation !== generation || !running) {
413
441
  await stop();
414
442
  // skip: stop() kam während des Connects — Watcher wurde sofort wieder abgebaut.
@@ -425,7 +453,7 @@ export function createInboundMailSupervisor(deps) {
425
453
  await markAccount(account, { watchState: "watching" }, "watch_supervisor");
426
454
  }
427
455
  catch (err) {
428
- log(`inbound-mail: markAccount(watching) for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
456
+ log(`inbound-mail: markAccount(watching) for account ${account.id} failed: ${describeError(err)}`);
429
457
  }
430
458
  }
431
459
  catch (err) {
@@ -464,7 +492,7 @@ export function createInboundMailSupervisor(deps) {
464
492
  await stop();
465
493
  }
466
494
  catch (err) {
467
- log(`inbound-mail: stop watcher ${accountId} failed: ${err instanceof Error ? err.message : String(err)}`);
495
+ log(`inbound-mail: stop watcher ${accountId} failed: ${describeError(err)}`);
468
496
  }
469
497
  }
470
498
  if (deps.lock && lockToken) {
@@ -472,7 +500,7 @@ export function createInboundMailSupervisor(deps) {
472
500
  await deps.lock.release(accountId, lockToken);
473
501
  }
474
502
  catch (err) {
475
- log(`inbound-mail: watch lease release for account ${accountId} failed: ${err instanceof Error ? err.message : String(err)}`);
503
+ log(`inbound-mail: watch lease release for account ${accountId} failed: ${describeError(err)}`);
476
504
  }
477
505
  }
478
506
  }
@@ -486,7 +514,7 @@ export function createInboundMailSupervisor(deps) {
486
514
  pollTimer = setTimeout(() => {
487
515
  pollTimer = null;
488
516
  pollInFlight = pollOnce()
489
- .catch((err) => log(`inbound-mail: poll tick failed: ${err instanceof Error ? err.message : String(err)}`))
517
+ .catch((err) => log(`inbound-mail: poll tick failed: ${describeError(err)}`))
490
518
  .finally(() => {
491
519
  pollInFlight = null;
492
520
  scheduleNextPoll();
@@ -54,7 +54,7 @@ export const setWrite = defineWriteHandler({
54
54
  // on both the existing-check and the executor's stream reads; re-scope to the target.
55
55
  // Safe: the override branch is SystemAdmin-gated above.
56
56
  const scopedDb = override !== undefined
57
- ? createTenantDb(db.unsafeRaw(SET_PAGE_TENANT_OVERRIDE_REASON), override, // @cast-boundary engine-bridge
57
+ ? createTenantDb(db.unsafeRaw(), override, // @cast-boundary engine-bridge
58
58
  "tenant")
59
59
  : db;
60
60
  const existing = await fetchOne(scopedDb, pagesTable, {
@@ -54,7 +54,7 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS, entr
54
54
  // Bun.SQL poisons the whole tx after any error inside it, even one that's
55
55
  // caught — so the lookup runs in a savepoint where available, otherwise directly
56
56
  // (pool statements are their own units).
57
- authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON), async (sp) => {
57
+ authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(), async (sp) => {
58
58
  const userRow = await fetchOne(sp, userTable, {
59
59
  id: event.user.id,
60
60
  });
@@ -1,4 +1,10 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { NotificationRenderer } from "../delivery/index.js";
2
3
  import { type RendererContext, type RenderRequest, type RenderResponse } from "../renderer-foundation/index.js";
3
- export declare function adaptToFoundation(req: RenderRequest, ctx: RendererContext): Promise<RenderResponse>;
4
- export declare function createRendererSimpleFeature(): FeatureDefinition;
4
+ import { type MailBranding } from "./simple-renderer.js";
5
+ export declare function adaptToFoundation(req: RenderRequest, ctx: RendererContext, renderer?: NotificationRenderer): Promise<RenderResponse>;
6
+ export type RendererSimpleOptions = {
7
+ /** Branding applied to every notification mail, including the framework auth mails. */
8
+ readonly mailBranding?: MailBranding;
9
+ };
10
+ export declare function createRendererSimpleFeature(options?: RendererSimpleOptions): FeatureDefinition;
@@ -1,7 +1,7 @@
1
1
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { RENDERER_EXTENSION, RendererError, } from "../renderer-foundation/index.js";
3
3
  import { resolveNotificationVariables } from "./resolve-variables.js";
4
- import { simpleRenderer } from "./simple-renderer.js";
4
+ import { createSimpleRenderer, simpleRenderer } from "./simple-renderer.js";
5
5
  // Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
6
6
  // NotificationRenderer-Contract), renderer-foundation erwartet
7
7
  // `Promise<RenderResponse>` mit discriminated union. Mapper bewahrt
@@ -9,20 +9,23 @@ import { simpleRenderer } from "./simple-renderer.js";
9
9
  // Inline-CSS) und packt sie in den RendererPlugin-Contract.
10
10
  //
11
11
  // Exported damit der Adapter-Pfad direkt testbar ist (unit-test).
12
- export async function adaptToFoundation(req, ctx) {
12
+ export async function adaptToFoundation(req, ctx, renderer = simpleRenderer) {
13
13
  if (req.kind !== "notification") {
14
14
  // Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
15
15
  // dieser Pfad sollte unter normalen Umständen nie erreicht werden.
16
16
  throw new RendererError(`renderer-simple supports only kind="notification", got "${req.kind}"`, "invalid_payload");
17
17
  }
18
18
  const variables = await resolveNotificationVariables(req, ctx);
19
- const html = await simpleRenderer.render({
19
+ const html = await renderer.render({
20
20
  template: req.payload.template ?? "",
21
21
  variables,
22
22
  });
23
23
  return { kind: "notification", html };
24
24
  }
25
- export function createRendererSimpleFeature() {
25
+ export function createRendererSimpleFeature(options) {
26
+ const renderer = options?.mailBranding
27
+ ? createSimpleRenderer(options.mailBranding)
28
+ : simpleRenderer;
26
29
  return defineFeature("renderer-simple", (r) => {
27
30
  r.describe('Default renderer plugin for `kind="notification"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails \u2014 swap it for `renderer-mail-html` if you need MJML/Markdown layouts.');
28
31
  r.uiHints({
@@ -34,7 +37,7 @@ export function createRendererSimpleFeature() {
34
37
  r.optionalRequires("template-resolver");
35
38
  r.useExtension(RENDERER_EXTENSION, "simple", {
36
39
  kinds: ["notification"],
37
- render: adaptToFoundation,
40
+ render: (req, ctx) => adaptToFoundation(req, ctx, renderer),
38
41
  });
39
42
  });
40
43
  }
@@ -1,2 +1,2 @@
1
- export { createRendererSimpleFeature } from "./feature.js";
2
- export { simpleRenderer } from "./simple-renderer.js";
1
+ export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
+ export { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";
@@ -1,2 +1,2 @@
1
1
  export { createRendererSimpleFeature } from "./feature.js";
2
- export { simpleRenderer } from "./simple-renderer.js";
2
+ export { createSimpleRenderer, simpleRenderer } from "./simple-renderer.js";
@@ -1,2 +1,17 @@
1
1
  import type { NotificationRenderer } from "../delivery/index.js";
2
+ export type MailBranding = {
3
+ /** Shown as text when there is no logo, and as the logo alt text. */
4
+ readonly productName?: string;
5
+ /** Hex color (#rgb, #rrggbb, #rrggbbaa); used for buttons and the header accent. */
6
+ readonly primaryColor?: string;
7
+ /** Absolute http(s) URL; anything else is dropped. */
8
+ readonly logoUrl?: string;
9
+ readonly footerText?: string;
10
+ /** Absolute http(s) URLs only; other entries are dropped. */
11
+ readonly footerLinks?: readonly {
12
+ readonly label: string;
13
+ readonly url: string;
14
+ }[];
15
+ };
16
+ export declare function createSimpleRenderer(branding?: MailBranding): NotificationRenderer;
2
17
  export declare const simpleRenderer: NotificationRenderer;
@@ -1,37 +1,84 @@
1
- import { escapeHtml } from "@cosmicdrift/kumiko-headless";
2
- function renderSection(section) {
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ const DEFAULT_PRIMARY_COLOR = "#2563eb";
3
+ const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
4
+ function isHttpUrl(value) {
5
+ try {
6
+ const { protocol } = new URL(value);
7
+ return protocol === "http:" || protocol === "https:";
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function resolvePrimaryColor(branding) {
14
+ const color = branding?.primaryColor;
15
+ return color && HEX_COLOR_PATTERN.test(color) ? color : DEFAULT_PRIMARY_COLOR;
16
+ }
17
+ function renderBrandingHeader(branding, primaryColor) {
18
+ if (!branding)
19
+ return "";
20
+ const logoUrl = branding.logoUrl && isHttpUrl(branding.logoUrl) ? branding.logoUrl : undefined;
21
+ if (!logoUrl && !branding.productName)
22
+ return "";
23
+ const logo = logoUrl
24
+ ? `<img src="${escapeHtmlAttr(logoUrl)}" alt="${escapeHtmlAttr(branding.productName ?? "")}" style="display:block;max-height:40px;max-width:200px;border:0" />`
25
+ : `<span style="font-size:18px;font-weight:700;color:${escapeHtmlAttr(primaryColor)}">${escapeHtml(branding.productName ?? "")}</span>`;
26
+ return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
27
+ }
28
+ function renderBrandingFooter(branding) {
29
+ if (!branding)
30
+ return "";
31
+ const links = (branding.footerLinks ?? [])
32
+ .filter((link) => isHttpUrl(link.url))
33
+ .map((link) => `<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`);
34
+ const footerPartsHtml = [
35
+ branding.footerText ? escapeHtml(branding.footerText) : "",
36
+ links.join(" · "),
37
+ ].filter((part) => part !== "");
38
+ if (footerPartsHtml.length === 0)
39
+ return "";
40
+ const footerHtml = footerPartsHtml.join("<br />");
41
+ return `<p style="margin:16px 0 0;color:#999;font-size:12px">${footerHtml}</p>`;
42
+ }
43
+ function renderSection(section, primaryColor) {
3
44
  if ("text" in section) {
4
45
  return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
5
46
  }
6
47
  if ("button" in section) {
7
- return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
48
+ return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:${escapeHtmlAttr(primaryColor)};color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
8
49
  }
9
50
  return "";
10
51
  }
11
52
  // Simple Renderer: turns structured email template data into HTML with inline CSS.
12
53
  // No external dependencies, no template engine — just string concatenation.
13
- export const simpleRenderer = {
14
- name: "simple",
15
- async render(input) {
16
- const data = input.variables; // @cast-boundary render-helper
17
- // Fallback: if no structured fields, use title + body as header + single text section
18
- const header = data.header ?? data.title;
19
- const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
20
- const parts = [];
21
- parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
22
- parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
23
- if (header) {
24
- parts.push(`<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`);
25
- }
26
- if (sections) {
27
- for (const section of sections) {
28
- parts.push(renderSection(section));
54
+ export function createSimpleRenderer(branding) {
55
+ const primaryColor = resolvePrimaryColor(branding);
56
+ return {
57
+ name: "simple",
58
+ async render(input) {
59
+ const data = input.variables; // @cast-boundary render-helper
60
+ // Fallback: if no structured fields, use title + body as header + single text section
61
+ const header = data.header ?? data.title;
62
+ const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
63
+ const parts = [];
64
+ parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
65
+ parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
66
+ parts.push(renderBrandingHeader(branding, primaryColor));
67
+ if (header) {
68
+ parts.push(`<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`);
69
+ }
70
+ if (sections) {
71
+ for (const section of sections) {
72
+ parts.push(renderSection(section, primaryColor));
73
+ }
29
74
  }
30
- }
31
- if (data.footer) {
32
- parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(data.footer)}</p>`);
33
- }
34
- parts.push("</div></body></html>");
35
- return parts.join("");
36
- },
37
- };
75
+ if (data.footer) {
76
+ parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(data.footer)}</p>`);
77
+ }
78
+ parts.push(renderBrandingFooter(branding));
79
+ parts.push("</div></body></html>");
80
+ return parts.join("");
81
+ },
82
+ };
83
+ }
84
+ export const simpleRenderer = createSimpleRenderer();
@@ -135,7 +135,7 @@ export function createSecretsFeature(opts = {}) {
135
135
  escapeHatch: {
136
136
  reason: ROTATE_ESCAPE_HATCH_REASON,
137
137
  },
138
- handler: (payload, ctx) => rotateJob(payload, ctx, ctx.db.unsafeRaw(ROTATE_ESCAPE_HATCH_REASON)),
138
+ handler: (payload, ctx) => rotateJob(payload, ctx, ctx.db.unsafeRaw()),
139
139
  });
140
140
  // Pre-ES had a separate `retention-cleanup` job scrubbing the audit
141
141
  // table on a compliance-driven schedule (90d default). Post-ES the
@@ -121,7 +121,7 @@ export function createSessionsFeature(options) {
121
121
  name: "cleanup",
122
122
  trigger: { manual: true },
123
123
  escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
124
- handler: (payload, ctx) => cleanupJob(payload, ctx, ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON)),
124
+ handler: (payload, ctx) => cleanupJob(payload, ctx, ctx.db.unsafeRaw()),
125
125
  });
126
126
  // Cross-feature entity hook on "user". `r.entityHook` (NOT `r.hook`) is
127
127
  // the supported cross-feature path: entity-keyed, not prefixed by the
@@ -41,7 +41,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
41
41
  reason: REVOKE_ALL_SESSIONS_REASON,
42
42
  },
43
43
  handler: async (event, ctx) => {
44
- const runner = ctx.db.unsafeRaw(REVOKE_ALL_SESSIONS_REASON);
44
+ const runner = ctx.db.unsafeRaw();
45
45
  const updated = await updateMany(runner, userSessionTable, { revokedAt: Temporal.Now.instant() }, {
46
46
  userId: event.payload.userId,
47
47
  revokedAt: null,
@@ -36,8 +36,7 @@ export function createClaimTenantHandoverHandler(opts = {}) {
36
36
  agent: { expose: false },
37
37
  rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
38
38
  handler: async (event, ctx) => {
39
- const db = ctx.db.unsafeRaw("tenant-handover claim: cross-tenant read + ownership write, legitimized by the " +
40
- "row-bound grant redeemed below");
39
+ const db = ctx.db.unsafeRaw();
41
40
  const rootLocation = resolveRootAnchorLocation(ctx.registry, db, event.payload.entityType);
42
41
  if (!rootLocation)
43
42
  return notTransferable(event.payload.entityType);
@@ -74,7 +74,7 @@ export function createTenantLifecycleFeature() {
74
74
  }
75
75
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
76
76
  await runTenantDestructionSweep({
77
- db: ctx.db.unsafeRaw(RUN_TENANT_DESTRUCTION_REASON), // @cast-boundary db-operator — jobs never run inside a DbTx
77
+ db: ctx.db.unsafeRaw(), // @cast-boundary db-operator — jobs never run inside a DbTx
78
78
  registry: ctx.registry,
79
79
  now: T.Now.instant(),
80
80
  log: (message) => ctx.log?.warn(message),
@@ -20,7 +20,9 @@ export const cancelDestructionWrite = defineWriteHandler({
20
20
  },
21
21
  handler: async (event, ctx) => {
22
22
  const tenantId = event.user.tenantId;
23
- const row = await fetchOne(ctx.db.unsafeRaw(CANCEL_DESTRUCTION_TENANT_ROW_REASON), tenantTable, { id: tenantId });
23
+ const row = await fetchOne(ctx.db.unsafeRaw(), tenantTable, {
24
+ id: tenantId,
25
+ });
24
26
  if (!row) {
25
27
  return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
26
28
  }
@@ -24,7 +24,7 @@ export const requestDestructionWrite = defineWriteHandler({
24
24
  },
25
25
  handler: async (event, ctx) => {
26
26
  const tenantId = event.user.tenantId;
27
- const runner = ctx.db.unsafeRaw(REQUEST_DESTRUCTION_TENANT_ROW_REASON);
27
+ const runner = ctx.db.unsafeRaw();
28
28
  const row = await fetchOne(runner, tenantTable, { id: tenantId });
29
29
  if (!row) {
30
30
  return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
@@ -303,7 +303,7 @@ export function createTierEngineFeature(opts = {}) {
303
303
  const rawDb = ctx.systemDb
304
304
  ? ctx.systemDb.unsafeRaw(autoDefaultTierHookReason)
305
305
  : ctx.db && "unsafeRaw" in ctx.db
306
- ? ctx.db.unsafeRaw(autoDefaultTierHookReason)
306
+ ? ctx.db.unsafeRaw()
307
307
  : undefined;
308
308
  // skip: defensive — inTransaction phase always sets db, but AppContext's
309
309
  // type makes it optional. Throwing would be overreach (lifecycle
@@ -20,7 +20,7 @@ export const getTenantTierQuery = defineQueryHandler({
20
20
  },
21
21
  handler: async (query, ctx) => {
22
22
  const tenantId = query.payload.tenantId; // @cast-boundary engine-bridge
23
- const tdb = createTenantDb(ctx.db.unsafeRaw(GET_TENANT_TIER_REASON), tenantId, "system");
23
+ const tdb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
24
24
  const row = await fetchOne(tdb, tierAssignmentTable, { tenantId });
25
25
  return row ?? null;
26
26
  },
@@ -51,7 +51,7 @@ export function createSetTenantTierWrite(opts = {}) {
51
51
  },
52
52
  handler: async (event, ctx) => {
53
53
  const tenantId = event.payload.tenantId; // @cast-boundary engine-bridge
54
- const rawDb = ctx.db.unsafeRaw(SET_TENANT_TIER_REASON);
54
+ const rawDb = ctx.db.unsafeRaw();
55
55
  const tdb = createTenantDb(rawDb, tenantId, "system");
56
56
  const systemUser = { ...event.user, tenantId };
57
57
  const tier = event.payload.tier;
@@ -411,7 +411,7 @@ export function createUserDataRightsFeature(opts = {}) {
411
411
  // Discriminator wird via handlerName="user-data-rights:run-export-
412
412
  // jobs" im Secret-Read-Audit erfasst.
413
413
  const exportUserId = ctx._userId ?? SYSTEM_USER_ID;
414
- const exportDb = ctx.db.unsafeRaw(RUN_EXPORT_JOBS_REASON); // @cast-boundary db-operator — jobs never run inside a DbTx
414
+ const exportDb = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
415
415
  const exportRegistry = ctx.registry;
416
416
  // JobContext carries ctx.meter (raw), not ctx.metrics — that bound
417
417
  // handle only exists on HandlerContext (built per write/query call
@@ -496,7 +496,7 @@ export function createUserDataRightsFeature(opts = {}) {
496
496
  }
497
497
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
498
498
  const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
499
- const forgetDb = ctx.db.unsafeRaw(RUN_FORGET_CLEANUP_REASON); // @cast-boundary db-operator — jobs never run inside a DbTx
499
+ const forgetDb = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
500
500
  const forgetRegistry = ctx.registry;
501
501
  const tenantModel = await resolveAppTenantModel({
502
502
  registry: forgetRegistry,
@@ -45,7 +45,7 @@ export const cancelDeletionWrite = defineWriteHandler({
45
45
  if (!isWithinGracePeriod(row.gracePeriodEnd)) {
46
46
  return writeFailure(new UnprocessableError("grace_period_expired"));
47
47
  }
48
- await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), event.user.id, {
48
+ await updateUserLifecycle(ctx.db.unsafeRaw(), event.user.id, {
49
49
  status: USER_STATUS.Active,
50
50
  gracePeriodEnd: null,
51
51
  // #354/1: closes the replay-after-cancel window — a still-TTL-valid email
@@ -68,7 +68,7 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
68
68
  secret: opts.deletionTokenSecret,
69
69
  loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
70
70
  commitDeletion: async (userId, 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 });
71
+ const res = await startDeletionGracePeriod(ctx, userId, () => resolveGracePeriod(ctx, event.user.tenantId), ctx.db.unsafeRaw(), { pendingDeletionRequestId: requestId });
72
72
  if (!res.ok)
73
73
  return false;
74
74
  gracePeriodEndIso = res.gracePeriodEnd.toString();
@@ -61,7 +61,7 @@ export const downloadByJobQuery = defineQueryHandler({
61
61
  // Wert (603/2).
62
62
  const auditIp = requestContext.get()?.ip ?? null;
63
63
  const auditUa = requestContext.get()?.userAgent ?? null;
64
- const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_JOB_REASON);
64
+ const runner = ctx.db.unsafeRaw();
65
65
  // Step 1-2: job-lookup + cross-user-isolation
66
66
  const jobRow = await fetchOne(runner, exportJobsTable, { id: jobId });
67
67
  if (!jobRow || jobRow.userId !== userId) {
@@ -64,7 +64,7 @@ export const downloadByTokenQuery = defineQueryHandler({
64
64
  handler: async (query, ctx) => {
65
65
  const T = getTemporal();
66
66
  const now = T.Now.instant();
67
- const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_TOKEN_REASON);
67
+ const runner = ctx.db.unsafeRaw();
68
68
  // Step 1: hash + lookup
69
69
  const hash = await hashDownloadToken(query.payload.token);
70
70
  const tokenRow = await fetchOne(runner, exportDownloadTokensTable, {
@@ -30,7 +30,7 @@ export const exportStatusQuery = defineQueryHandler({
30
30
  reason: EXPORT_STATUS_REASON,
31
31
  },
32
32
  handler: async (query, ctx) => {
33
- const rows = await selectMany(ctx.db.unsafeRaw(EXPORT_STATUS_REASON), exportJobsTable, { userId: query.user.id }, { limit: 1, orderBy: { col: "requestedAt", direction: "desc" } });
33
+ const rows = await selectMany(ctx.db.unsafeRaw(), exportJobsTable, { userId: query.user.id }, { limit: 1, orderBy: { col: "requestedAt", direction: "desc" } });
34
34
  const latest = rows[0];
35
35
  if (!latest)
36
36
  return { hasJob: false };
@@ -5,8 +5,6 @@ import { USER_STATUS, userTable } from "../../user/index.js";
5
5
  import { denyIfTargetOutsideAdminTenant } from "../lib/deny-if-target-outside-admin-tenant.js";
6
6
  import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
7
7
  const LIFT_RESTRICTION_ESCAPE_HATCH_REASON = "checks the target user's membership in the admin's tenant and appends the user lifecycle status change on the SYSTEM_TENANT_ID user stream, both via DbRunner helpers outside the admin's own tenant scope.";
8
- const CHECK_TARGET_MEMBERSHIP_REASON = "checks the target user's membership in the admin's tenant via the DbRunner helper";
9
- const APPEND_LIFECYCLE_EVENT_REASON = "appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
10
8
  // POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
11
9
  //
12
10
  // Operator-only. A Restricted user's own session is unconditionally
@@ -38,7 +36,7 @@ export const liftRestrictionWrite = defineWriteHandler({
38
36
  },
39
37
  handler: async (event, ctx) => {
40
38
  const targetUserId = event.payload.userId;
41
- const outside = await denyIfTargetOutsideAdminTenant(ctx.db.unsafeRaw(CHECK_TARGET_MEMBERSHIP_REASON), event.user, targetUserId);
39
+ const outside = await denyIfTargetOutsideAdminTenant(ctx.db.unsafeRaw(), event.user, targetUserId);
42
40
  if (outside)
43
41
  return outside;
44
42
  const userRow = await ctx.db.global(userTable).fetchOne({
@@ -55,7 +53,7 @@ export const liftRestrictionWrite = defineWriteHandler({
55
53
  details: { currentStatus },
56
54
  }));
57
55
  }
58
- await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), targetUserId, {
56
+ await updateUserLifecycle(ctx.db.unsafeRaw(), targetUserId, {
59
57
  status: USER_STATUS.Active,
60
58
  });
61
59
  return {
@@ -53,7 +53,7 @@ export const myAuditLogQuery = defineQueryHandler({
53
53
  }
54
54
  if (p.before)
55
55
  where["id"] = { lt: BigInt(p.before) };
56
- const rows = await selectMany(ctx.db.unsafeRaw(MY_AUDIT_LOG_REASON), eventsTable, where, {
56
+ const rows = await selectMany(ctx.db.unsafeRaw(), eventsTable, where, {
57
57
  orderBy: { col: "id", direction: "desc" },
58
58
  limit: p.limit,
59
59
  });
@@ -57,7 +57,7 @@ export function createRequestDeletionByEmailHandler(opts = {}) {
57
57
  // user-Row landet und in die Token-HMAC-Purpose gefaltet wird. cancel
58
58
  // nullt sie → ein nach Cancel nachgespieltes Token verifiziert nicht mehr.
59
59
  const requestId = crypto.randomUUID();
60
- await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), userRow["id"], {
60
+ await updateUserLifecycle(ctx.db.unsafeRaw(), userRow["id"], {
61
61
  pendingDeletionRequestId: requestId,
62
62
  });
63
63
  const { token, expiresAt } = signDeletionToken(userRow["id"], requestId, DELETION_VERIFY_TTL_MINUTES, opts.deletionTokenSecret);
@@ -29,7 +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
- 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"));
32
+ const res = await startDeletionGracePeriod(ctx, event.user.id, () => resolveGracePeriod(ctx, event.user.tenantId), ctx.db.unsafeRaw());
33
33
  if (!res.ok)
34
34
  return writeFailure(res.error);
35
35
  const { gracePeriodEnd, userEmail, userLocale } = res;