@cosmicdrift/kumiko-samples 0.393.0 → 0.395.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 (226) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +143 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -2
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +238 -89
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +36 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +61 -136
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +4 -6
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
  133. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  134. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  135. package/packages/bundled-features/src/jobs/changes.json +18 -0
  136. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  137. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  138. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  139. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  140. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  141. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  142. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  143. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  144. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  145. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  146. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  147. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  148. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  149. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  150. package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
  151. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  152. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  153. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  154. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  155. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  156. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  157. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  158. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  159. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  160. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  161. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  162. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  163. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  164. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  165. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  166. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  167. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  168. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  169. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  170. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  171. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  172. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  173. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  174. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  175. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  176. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
  177. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  178. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  179. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
  180. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  181. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  182. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  183. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  184. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  186. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
  188. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  189. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  190. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  191. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  193. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  194. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  195. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  196. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  197. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  198. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  199. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  200. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  201. package/samples/apps/use-all-bundled/feature-manifest.json +89 -40
  202. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  203. package/samples/apps/use-all-bundled/package.json +1 -0
  204. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  205. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  206. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  207. package/samples/apps/use-all-bundled/src/app/client.tsx +8 -1
  208. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  209. package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
  210. package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
  211. package/samples/recipes/cross-feature-events/README.md +10 -0
  212. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  213. package/samples/recipes/dashboard-stats/README.md +8 -0
  214. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  215. package/samples/recipes/env-composition/README.md +2 -2
  216. package/samples/recipes/env-composition/src/env.ts +2 -2
  217. package/samples/recipes/inbound-mail-triage/README.md +8 -0
  218. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  219. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  220. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  221. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  222. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  223. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  224. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  225. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  226. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -0,0 +1,184 @@
1
+ // attach-message-content — stores the content of a message that was imported as snippet only, after
2
+ // the supervisor re-downloaded it. Appends one content-fetched event on the existing stream; no
3
+ // thread rollup, no new message row. A header-only import from quarantine takes its content the
4
+ // same way. Nested dispatches skip the pre-transaction upload and store in their own transaction.
5
+
6
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { ConflictError, failNotFound, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
+ import type {
9
+ BeforeTransactionContext,
10
+ BeforeTransactionResult,
11
+ WriteResult,
12
+ } from "@cosmicdrift/kumiko-types/handlers";
13
+ import * as z from "zod";
14
+ import { inboundMessageAggregateId } from "../aggregate-id.ts";
15
+ import {
16
+ INBOUND_MESSAGE_AGGREGATE_TYPE,
17
+ INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_QN,
18
+ INBOUND_MESSAGE_RECEIVED_EVENT_QN,
19
+ type InboundContentState,
20
+ type InboundMessageContentFetchedEventPayload,
21
+ } from "../events.ts";
22
+ import { inboundMessagesProjectionTable } from "../projection.ts";
23
+ import {
24
+ finalizePreparedContent,
25
+ type PreparedInboundContent,
26
+ prepareInboundContent,
27
+ } from "./ingest-message-prepare.ts";
28
+ import {
29
+ contentStateOf,
30
+ encryptMessageBodyText,
31
+ inboundContentPayloadShape,
32
+ MESSAGE_LOCK_NAMESPACE,
33
+ storeInboundMessageContent,
34
+ } from "./message-content.ts";
35
+
36
+ export const attachMessageContentSchema = z.object({
37
+ accountId: z.uuid(),
38
+ providerMessageId: z.string().min(1).max(500),
39
+ /** "unavailable" is terminal: the mail is gone from the server or cannot be parsed. */
40
+ outcome: z.enum(["fetched", "unavailable"]),
41
+ ...inboundContentPayloadShape,
42
+ });
43
+
44
+ type AttachMessageContentPayload = z.infer<typeof attachMessageContentSchema>;
45
+
46
+ const COMPLETE: InboundContentState = "complete";
47
+
48
+ function hasContentAlready(events: readonly { type: string; payload: unknown }[]): boolean {
49
+ return events.some((e) => {
50
+ if (e.type === INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_QN) return true;
51
+ const state = z.object({ contentState: z.string() }).safeParse(e.payload);
52
+ return state.success && state.data.contentState === COMPLETE;
53
+ });
54
+ }
55
+
56
+ const receivedOwnerSchema = z.object({ ownerUserId: z.string().nullable() });
57
+
58
+ function ownerUserIdOfStream(events: readonly { type: string; payload: unknown }[]): string | null {
59
+ const received = events.find((e) => e.type === INBOUND_MESSAGE_RECEIVED_EVENT_QN);
60
+ const parsed = receivedOwnerSchema.safeParse(received?.payload);
61
+ return parsed.success ? parsed.data.ownerUserId : null;
62
+ }
63
+
64
+ // Read before the transaction, so it can only skip work: a row that already shows attached content
65
+ // spares parallel refetches the upload. The event check under the message lock stays authoritative.
66
+ async function showsAttachedContent(
67
+ db: BeforeTransactionContext["db"],
68
+ messageAggId: string,
69
+ ): Promise<boolean> {
70
+ const [row] = await db.selectMany(
71
+ inboundMessagesProjectionTable,
72
+ { id: messageAggId },
73
+ { fields: ["contentState", "contentUnavailableReason"] },
74
+ );
75
+ if (row === undefined) return false;
76
+ if (row["contentState"] === COMPLETE) return true;
77
+ // "quarantined" is the header-only import still waiting for its content.
78
+ return row["contentState"] === "unavailable" && row["contentUnavailableReason"] !== "quarantined";
79
+ }
80
+
81
+ function contentInputOf(payload: AttachMessageContentPayload, messageAggId: string) {
82
+ return {
83
+ accountId: payload.accountId,
84
+ messageAggId,
85
+ bodyRef: "",
86
+ rawMime: payload.rawMime,
87
+ bodyHtml: payload.bodyHtml,
88
+ attachments: payload.attachments,
89
+ skippedAttachments: payload.skippedAttachments,
90
+ };
91
+ }
92
+
93
+ async function contentEventPayloadOf(
94
+ payload: AttachMessageContentPayload,
95
+ content: Awaited<ReturnType<typeof storeInboundMessageContent>> | undefined,
96
+ tenantId: Parameters<typeof encryptMessageBodyText>[0],
97
+ messageAggId: string,
98
+ ownerUserId: string | null,
99
+ ): Promise<InboundMessageContentFetchedEventPayload> {
100
+ const fetched = payload.outcome === "fetched";
101
+ return {
102
+ accountId: payload.accountId,
103
+ ownerUserId,
104
+ bodyRef: content?.bodyRef ?? "",
105
+ bodyText: await encryptMessageBodyText(tenantId, messageAggId, fetched ? payload.bodyText : ""),
106
+ bodyTextTruncated: fetched && payload.bodyTextTruncated,
107
+ attachmentFileIds: content?.attachmentFileIds ?? [],
108
+ skippedAttachments: content?.skippedAttachments ?? [],
109
+ contentState: content
110
+ ? contentStateOf({
111
+ bodyText: payload.bodyText,
112
+ hasOriginal: payload.rawMime !== undefined || payload.bodyHtml !== undefined,
113
+ contentStorage: content.contentStorage,
114
+ })
115
+ : "unavailable",
116
+ contentUnavailableReason: !content
117
+ ? "gone_from_server"
118
+ : content.contentStorage === "unavailable"
119
+ ? "no_file_storage"
120
+ : null,
121
+ };
122
+ }
123
+
124
+ export const attachMessageContentHandler = defineWriteHandler({
125
+ name: "attach-message-content",
126
+ schema: attachMessageContentSchema,
127
+ access: { roles: ["SystemAdmin"] },
128
+ agent: { expose: false },
129
+ description:
130
+ "Stores the full text, original and attachments of an already imported snippet-only or quarantined mail, or marks its content as unavailable when the provider no longer has it; called by the sync supervisor, a second call for the same mail is a no-op.",
131
+ beforeTransactionWhenNested: "skip",
132
+ beforeTransaction: async (
133
+ event,
134
+ ctx,
135
+ ): Promise<BeforeTransactionResult<PreparedInboundContent | undefined>> => {
136
+ const { payload } = event;
137
+ if (payload.outcome !== "fetched") return { isSuccess: true, data: undefined };
138
+ const messageAggId = inboundMessageAggregateId(payload.accountId, payload.providerMessageId);
139
+ if (await showsAttachedContent(ctx.db, messageAggId))
140
+ return { isSuccess: true, data: undefined };
141
+ return prepareInboundContent(ctx, contentInputOf(payload, messageAggId));
142
+ },
143
+ handler: async (event, ctx): Promise<WriteResult<{ readonly duplicate: boolean }>> => {
144
+ const { payload } = event;
145
+ const prepared: PreparedInboundContent | undefined = event.prepared;
146
+ const tenantId = event.user.tenantId;
147
+ const messageAggId = inboundMessageAggregateId(payload.accountId, payload.providerMessageId);
148
+
149
+ await ctx.db.advisoryXactLock(MESSAGE_LOCK_NAMESPACE, messageAggId);
150
+ const events = await ctx.events.load(messageAggId);
151
+ if (events.length === 0) return failNotFound("inbound-message", messageAggId);
152
+ if (hasContentAlready(events)) {
153
+ return { isSuccess: true as const, data: { duplicate: true as const } };
154
+ }
155
+
156
+ const content =
157
+ payload.outcome !== "fetched"
158
+ ? undefined
159
+ : prepared
160
+ ? await finalizePreparedContent(ctx, prepared)
161
+ : await storeInboundMessageContent(ctx, contentInputOf(payload, messageAggId));
162
+ const eventPayload = await contentEventPayloadOf(
163
+ payload,
164
+ content,
165
+ tenantId,
166
+ messageAggId,
167
+ ownerUserIdOfStream(events),
168
+ );
169
+ const appended = await ctx.events.tryAppend({
170
+ aggregateId: messageAggId,
171
+ aggregateType: INBOUND_MESSAGE_AGGREGATE_TYPE,
172
+ type: INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_QN,
173
+ payload: eventPayload,
174
+ headers: { providerMessageId: payload.providerMessageId, outcome: payload.outcome },
175
+ });
176
+ // Losing the version race must roll back the stored attachments instead of reporting success.
177
+ if (!appended.ok) {
178
+ return writeFailure(
179
+ new ConflictError({ message: "inbound message content changed concurrently" }),
180
+ );
181
+ }
182
+ return { isSuccess: true as const, data: { duplicate: false as const } };
183
+ },
184
+ });
@@ -1,4 +1,4 @@
1
- // connect-account — A tenant admin connects a mailbox. Creates the
1
+ // connect-account — A tenant admin connects a mailbox (a member's personal one goes through the provider form, which dispatches here as the system user). Creates the
2
2
  // mail-account stream (aggregateId = random uuid = accountId =
3
3
  // secret-slot key for the IMAP password/OAuth refresh token).
4
4
  //
@@ -11,15 +11,28 @@
11
11
  // and switches to auth_error via update-account on failure—the connect
12
12
  // stays fast and offline-capable (the test result arrives asynchronously
13
13
  // as a status transition).
14
+ // - No second account for one address: a live or disconnected address is
15
+ // refused (409 reasons in InboundMailConnectErrors); only a disconnected or
16
+ // sign-in-failed OAuth mailbox is reopened in place, keeping its account id and cursor.
17
+ // The reopen returns the existing id and changes nothing yet; the callback re-arms it.
14
18
 
15
19
  import {
16
20
  configuredPiiSubjectKms,
17
21
  encryptPiiFieldValues,
18
22
  } from "@cosmicdrift/kumiko-framework/crypto";
19
23
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
20
- import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
24
+ import {
25
+ failUnprocessable,
26
+ UnprocessableError,
27
+ writeFailure,
28
+ } from "@cosmicdrift/kumiko-framework/errors";
21
29
  import * as z from "zod";
22
- import { InboundMailAccountStatuses, InboundMailAuthMethods } from "../constants.ts";
30
+ import {
31
+ InboundMailAccountStatuses,
32
+ InboundMailAuthMethods,
33
+ InboundMailConnectErrors,
34
+ InboundMailFoundationHandlers,
35
+ } from "../constants.ts";
23
36
  import { MAIL_ACCOUNT_PII_FIELDS, mailAccountEntity } from "../entities.ts";
24
37
  import {
25
38
  MAIL_ACCOUNT_AGGREGATE_TYPE,
@@ -27,91 +40,131 @@ import {
27
40
  type MailAccountEventHeaders,
28
41
  type MailAccountEventPayload,
29
42
  } from "../events.ts";
43
+ import { accessRolesOf, type InboundMailRoles } from "../mail-roles.ts";
44
+ import { connectConflictOf, findMailboxByAddress } from "./account-address.ts";
30
45
 
31
- export const connectAccountSchema = z.object({
32
- /** Provider key registered with the extension ("imap",
33
- * "m365-graph", "gmail-rest"). */
34
- provider: z.string().min(1).max(50),
35
- authMethod: z.enum([
36
- InboundMailAuthMethods.password,
37
- InboundMailAuthMethods.xoauth2,
38
- InboundMailAuthMethods.oauth,
39
- ]),
40
- displayName: z.string().trim().max(200),
41
- /** Mailbox address in plaintext; it is encrypted here. */
42
- address: z.string().min(1).max(320),
43
- /** "shared" = shared across the tenant (info@) · "user" = the caller's
44
- * personal mailbox (ownerUserId is taken from the session, never from
45
- * the payload—preventing claims on another user's mailbox). */
46
- scope: z.enum(["shared", "user"]),
47
- /** Only for the programmatic OAuth callback path (SystemAdmin): there,
48
- * the dispatcher user is a SystemUser and the actual owner is in the
49
- * HMAC-verified state. Tenant callers must not set this field (the
50
- * handler prevents claims on another user's mailbox). */
51
- ownerUserIdOverride: z.string().max(36).nullable().optional(),
52
- });
46
+ export const connectAccountSchema = z
47
+ .object({
48
+ /** Provider key registered with the extension ("imap",
49
+ * "m365-graph", "gmail-rest"). */
50
+ provider: z.string().min(1).max(50),
51
+ authMethod: z.enum([
52
+ InboundMailAuthMethods.password,
53
+ InboundMailAuthMethods.xoauth2,
54
+ InboundMailAuthMethods.oauth,
55
+ ]),
56
+ displayName: z.string().trim().max(200),
57
+ /** Mailbox address in plaintext; it is encrypted here. */
58
+ address: z.string().min(1).max(320),
59
+ /** "shared" = shared across the tenant (info@) · "user" = the caller's
60
+ * personal mailbox (ownerUserId is taken from the session, never from
61
+ * the payload—preventing claims on another user's mailbox). */
62
+ scope: z.enum(["shared", "user"]),
63
+ /** Only for the programmatic OAuth callback path (SystemAdmin): there,
64
+ * the dispatcher user is a SystemUser and the actual owner is in the
65
+ * HMAC-verified state. Tenant callers must not set this field (the
66
+ * handler prevents claims on another user's mailbox). */
67
+ ownerUserIdOverride: z.string().max(36).nullable().optional(),
68
+ })
69
+ // trustedAuthservId is deliberately not accepted here: it decides which Authentication-Results header
70
+ // is believed, so it only changes through set-trusted-authserv-id.
71
+ .strict();
53
72
  type ConnectAccountPayload = z.infer<typeof connectAccountSchema>;
54
73
 
55
- export const connectAccountHandler: WriteHandlerDef = {
56
- name: "connect-account",
57
- schema: connectAccountSchema,
58
- access: { roles: ["SystemAdmin", "TenantAdmin"] },
59
- description:
60
- "Registers a mailbox for the tenant (provider, auth method, display name, encrypted address, shared or personal) and returns the new accountId that also keys its credential slot; use it to start a mail connection, credentials and the first connection test happen outside this call.",
61
- handler: async (event, ctx) => {
62
- // @cast-boundary engine-payload — dispatcher-zod-validated payload
63
- const payload = event.payload as ConnectAccountPayload;
64
- const tenantId = event.user.tenantId;
65
- const accountId = crypto.randomUUID();
74
+ export function createConnectAccountHandler({ adminRoles }: InboundMailRoles): WriteHandlerDef {
75
+ return {
76
+ name: "connect-account",
77
+ schema: connectAccountSchema,
78
+ access: { roles: accessRolesOf(InboundMailFoundationHandlers.connectAccount, adminRoles) },
79
+ // Connects a mailbox, like disconnect-account (risk high), which the model must not trigger on its own.
80
+ agent: { expose: false },
81
+ description:
82
+ "Registers a mailbox for the tenant (provider, auth method, display name, encrypted address, shared or personal) and returns the new accountId that also keys its credential slot; use it to start a mail connection, credentials and the first connection test happen outside this call.",
83
+ handler: async (event, ctx) => {
84
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
85
+ const payload = event.payload as ConnectAccountPayload;
86
+ const tenantId = event.user.tenantId;
87
+ const accountId = crypto.randomUUID();
88
+
89
+ if (payload.ownerUserIdOverride !== undefined && !event.user.roles.includes("SystemAdmin")) {
90
+ return failUnprocessable("owner_override_requires_system_admin");
91
+ }
92
+ const ownerUserId =
93
+ payload.ownerUserIdOverride !== undefined
94
+ ? payload.ownerUserIdOverride
95
+ : payload.scope === "user"
96
+ ? event.user.id
97
+ : null;
66
98
 
67
- if (payload.ownerUserIdOverride !== undefined && !event.user.roles.includes("SystemAdmin")) {
68
- return failUnprocessable("owner_override_requires_system_admin");
69
- }
70
- const ownerUserId =
71
- payload.ownerUserIdOverride !== undefined
72
- ? payload.ownerUserIdOverride
73
- : payload.scope === "user"
74
- ? event.user.id
75
- : null;
99
+ const existing = await findMailboxByAddress(ctx, tenantId, payload.address);
100
+ if (existing) {
101
+ const matched = connectConflictOf(existing, {
102
+ authMethod: payload.authMethod,
103
+ provider: payload.provider,
104
+ ownerUserId,
105
+ });
106
+ // Only the OAuth callback (it passes ownerUserIdOverride) reopens: it stores the fresh token and
107
+ // only then switches the account on via reactivate-account. Everyone else gets the plain conflict.
108
+ const conflict =
109
+ matched === "reopen_in_place" && payload.ownerUserIdOverride === undefined
110
+ ? existing.status === InboundMailAccountStatuses.disconnected
111
+ ? "address_disconnected"
112
+ : "already_connected"
113
+ : matched;
114
+ if (conflict === "reopen_in_place") {
115
+ return {
116
+ isSuccess: true as const,
117
+ data: { accountId: existing.id, reconnected: true as const },
118
+ };
119
+ }
120
+ const { reason, i18nKey } =
121
+ conflict === "address_disconnected"
122
+ ? InboundMailConnectErrors.addressDisconnected
123
+ : InboundMailConnectErrors.addressAlreadyConnected;
124
+ return writeFailure(new UnprocessableError(reason, { i18nKey }));
125
+ }
76
126
 
77
- // PII-encrypt the address before the append. This is the only write path to
78
- // the stream, so it covers both the event log and the projection.
79
- const piiKms = configuredPiiSubjectKms();
80
- const plainPii = { tenantId, address: payload.address };
81
- const encryptedFields = piiKms
82
- ? await encryptPiiFieldValues(
83
- plainPii,
84
- mailAccountEntity,
85
- MAIL_ACCOUNT_PII_FIELDS,
86
- piiKms,
87
- { requestId: `inbound-mail-foundation:connect-account:${accountId}`, tenantId },
88
- { tenantId, entityName: MAIL_ACCOUNT_AGGREGATE_TYPE },
89
- )
90
- : plainPii;
127
+ // PII-encrypt the address before the append. This is the only write path to
128
+ // the stream, so it covers both the event log and the projection.
129
+ const piiKms = configuredPiiSubjectKms();
130
+ const plainPii = { tenantId, address: payload.address };
131
+ const encryptedFields = piiKms
132
+ ? await encryptPiiFieldValues(
133
+ plainPii,
134
+ mailAccountEntity,
135
+ MAIL_ACCOUNT_PII_FIELDS,
136
+ piiKms,
137
+ { requestId: `inbound-mail-foundation:connect-account:${accountId}`, tenantId },
138
+ { tenantId, entityName: MAIL_ACCOUNT_AGGREGATE_TYPE },
139
+ )
140
+ : plainPii;
91
141
 
92
- const eventPayload: MailAccountEventPayload = {
93
- provider: payload.provider,
94
- authMethod: payload.authMethod,
95
- ownerUserId,
96
- displayName: payload.displayName,
97
- address: encryptedFields.address,
98
- // The account is active upon connection; the supervisor picks it up on
99
- // the next tick and downgrades it when credentials fail.
100
- status: InboundMailAccountStatuses.active,
101
- watchState: "idle",
102
- };
103
- const headers: MailAccountEventHeaders = {
104
- providerName: payload.provider,
105
- reason: "connect_flow",
106
- };
107
- await ctx.events.unsafeAppend({
108
- aggregateId: accountId,
109
- aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
110
- type: MAIL_ACCOUNT_CONNECTED_EVENT_QN,
111
- payload: eventPayload,
112
- headers,
113
- });
142
+ const eventPayload: MailAccountEventPayload = {
143
+ provider: payload.provider,
144
+ authMethod: payload.authMethod,
145
+ ownerUserId,
146
+ displayName: payload.displayName,
147
+ address: encryptedFields.address,
148
+ // The account is active upon connection; the supervisor picks it up on
149
+ // the next tick and downgrades it when credentials fail.
150
+ status: InboundMailAccountStatuses.active,
151
+ watchState: "idle",
152
+ lastError: null,
153
+ trustedAuthservId: null,
154
+ };
155
+ const headers: MailAccountEventHeaders = {
156
+ providerName: payload.provider,
157
+ reason: "connect_flow",
158
+ };
159
+ await ctx.events.unsafeAppend({
160
+ aggregateId: accountId,
161
+ aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
162
+ type: MAIL_ACCOUNT_CONNECTED_EVENT_QN,
163
+ payload: eventPayload,
164
+ headers,
165
+ });
114
166
 
115
- return { isSuccess: true as const, data: { accountId } };
116
- },
117
- };
167
+ return { isSuccess: true as const, data: { accountId, reconnected: false as const } };
168
+ },
169
+ };
170
+ }
@@ -1,72 +1,92 @@
1
1
  // disconnect-account — A TenantAdmin disconnects a mailbox. The stream is retained
2
2
  // for audit history, the status becomes disconnected, and the watch supervisor
3
- // skips the account starting with the next tick. Secret cleanup (IMAP password/
4
- // refresh token in the accountId slot) is handled by the caller flow; this handler
5
- // does not touch the secrets path.
3
+ // skips the account starting with the next tick. The credential secret (IMAP password/
4
+ // refresh token in the accountId slot) is deleted in the same transaction, so a
5
+ // disconnected mailbox cannot be re-armed with a stale credential.
6
6
  //
7
7
  // Idempotent: disconnecting an already disconnected account is a success no-op
8
8
  // with no second event, making double-clicks and retries safe.
9
9
 
10
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
10
11
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
11
12
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
12
13
  import * as z from "zod";
13
- import { InboundMailAccountStatuses } from "../constants.ts";
14
+ import { InboundMailAccountStatuses, inboundCredentialSecretKey } from "../constants.ts";
14
15
  import {
15
16
  MAIL_ACCOUNT_AGGREGATE_TYPE,
16
17
  MAIL_ACCOUNT_DISCONNECTED_EVENT_QN,
17
18
  type MailAccountEventHeaders,
18
19
  type MailAccountEventPayload,
19
20
  } from "../events.ts";
21
+ import type { InboundMailRoles } from "../mail-roles.ts";
20
22
  import { loadCurrentMailAccountPayload } from "./account-state.ts";
21
23
 
22
24
  export const disconnectAccountSchema = z.object({
23
25
  accountId: z.uuid(),
24
26
  reason: z.string().min(1).max(100).default("tenant_admin"),
25
27
  });
28
+ const CREDENTIAL_DELETE_REASON =
29
+ "the credential secret is deleted on the request transaction so a failed disconnect leaves the secret in place and the account status stays consistent with it";
30
+
26
31
  type DisconnectAccountPayload = z.infer<typeof disconnectAccountSchema>;
27
32
 
28
- export const disconnectAccountHandler: WriteHandlerDef = {
29
- name: "disconnect-account",
30
- schema: disconnectAccountSchema,
31
- access: { roles: ["SystemAdmin", "TenantAdmin"] },
32
- description:
33
- "Puts a connected mailbox into the final disconnected state so the watch supervisor stops fetching it, keeping the stream for audit and requiring a fresh connect to resume; use it to stop mail ingestion for one mailbox.",
34
- agent: { risk: "high" },
35
- handler: async (event, ctx) => {
36
- // @cast-boundary engine-payload — dispatcher-zod-validated payload
37
- const payload = event.payload as DisconnectAccountPayload;
33
+ export function createDisconnectAccountHandler({ adminRoles }: InboundMailRoles): WriteHandlerDef {
34
+ return {
35
+ name: "disconnect-account",
36
+ schema: disconnectAccountSchema,
37
+ access: { roles: [...adminRoles] },
38
+ description:
39
+ "Puts a connected mailbox into the final disconnected state so the watch supervisor stops fetching it, keeping the stream for audit and requiring a fresh connect to resume; use it to stop mail ingestion for one mailbox.",
40
+ agent: { risk: "high" },
41
+ escapeHatch: { grants: ["unsafeRaw"], reason: CREDENTIAL_DELETE_REASON },
42
+ handler: async (event, ctx) => {
43
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
44
+ const payload = event.payload as DisconnectAccountPayload;
38
45
 
39
- const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
40
- if (!current) {
41
- return failNotFound("mail-account", payload.accountId);
42
- }
43
- if (current.status === InboundMailAccountStatuses.disconnected) {
44
- return {
45
- isSuccess: true as const,
46
- data: { accountId: payload.accountId, alreadyDisconnected: true as const },
46
+ const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
47
+ if (!current) {
48
+ return failNotFound("mail-account", payload.accountId);
49
+ }
50
+ if (current.status === InboundMailAccountStatuses.disconnected) {
51
+ return {
52
+ isSuccess: true as const,
53
+ data: { accountId: payload.accountId, alreadyDisconnected: true as const },
54
+ };
55
+ }
56
+
57
+ const eventPayload: MailAccountEventPayload = {
58
+ ...current,
59
+ status: InboundMailAccountStatuses.disconnected,
60
+ watchState: "idle",
47
61
  };
48
- }
62
+ const headers: MailAccountEventHeaders = {
63
+ providerName: current.provider,
64
+ reason: payload.reason,
65
+ };
66
+ await ctx.events.unsafeAppend({
67
+ aggregateId: payload.accountId,
68
+ aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
69
+ type: MAIL_ACCOUNT_DISCONNECTED_EVENT_QN,
70
+ payload: eventPayload,
71
+ headers,
72
+ });
49
73
 
50
- const eventPayload: MailAccountEventPayload = {
51
- ...current,
52
- status: InboundMailAccountStatuses.disconnected,
53
- watchState: "idle",
54
- };
55
- const headers: MailAccountEventHeaders = {
56
- providerName: current.provider,
57
- reason: payload.reason,
58
- };
59
- await ctx.events.unsafeAppend({
60
- aggregateId: payload.accountId,
61
- aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
62
- type: MAIL_ACCOUNT_DISCONNECTED_EVENT_QN,
63
- payload: eventPayload,
64
- headers,
65
- });
74
+ // Tests and apps without a secrets store have no credential to remove.
75
+ if (ctx.services.secrets) {
76
+ await requireSecretsContext(
77
+ { secrets: ctx.services.secrets, user: ctx.user },
78
+ "disconnect-account",
79
+ )
80
+ .withRunner(ctx.db.unsafeRaw())
81
+ .delete(event.user.tenantId, inboundCredentialSecretKey(payload.accountId), {
82
+ deletedBy: event.user.id,
83
+ });
84
+ }
66
85
 
67
- return {
68
- isSuccess: true as const,
69
- data: { accountId: payload.accountId, alreadyDisconnected: false as const },
70
- };
71
- },
72
- };
86
+ return {
87
+ isSuccess: true as const,
88
+ data: { accountId: payload.accountId, alreadyDisconnected: false as const },
89
+ };
90
+ },
91
+ };
92
+ }