@pithy-sh/support 0.1.2 → 0.1.3

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 (167) hide show
  1. package/dist/ai/classify.d.ts +90 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +151 -0
  4. package/dist/attachment/store.d.ts +53 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +67 -0
  7. package/dist/audit/actions.d.ts +68 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +68 -0
  10. package/dist/capability.d.ts +93 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +187 -0
  13. package/dist/client/projection.d.ts +56 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +0 -0
  16. package/dist/config/config.d.ts +215 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +169 -0
  19. package/dist/data/attachment.d.ts +30 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +32 -0
  22. package/dist/data/billingScope.d.ts +30 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +32 -0
  25. package/dist/data/categories.d.ts +54 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +97 -0
  28. package/dist/data/classification.d.ts +34 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +28 -0
  31. package/dist/data/enums.d.ts +82 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +70 -0
  34. package/dist/data/flag.d.ts +25 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +27 -0
  37. package/dist/data/message.d.ts +81 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +84 -0
  40. package/dist/data/tables.d.ts +160 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +44 -0
  43. package/dist/data/thread.d.ts +77 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +65 -0
  46. package/dist/error/errors.d.ts +64 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +82 -0
  49. package/dist/http/guards.d.ts +44 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +53 -0
  52. package/dist/http/handlers.d.ts +89 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +248 -0
  55. package/dist/http/resolve.d.ts +44 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +72 -0
  58. package/dist/http/responses.d.ts +829 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +303 -0
  61. package/dist/http/routes.d.ts +109 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +62 -0
  64. package/dist/http/schemas.d.ts +121 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +89 -0
  67. package/dist/http/scopes.d.ts +56 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +107 -0
  70. package/dist/http/views.d.ts +36 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +144 -0
  73. package/dist/inbound/authenticity.d.ts +81 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +61 -0
  76. package/dist/inbound/guard.d.ts +74 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +78 -0
  79. package/dist/inbound/handler.d.ts +28 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +82 -0
  82. package/dist/inbound/ingest.d.ts +104 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +326 -0
  85. package/dist/inbound/recipient.d.ts +16 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +46 -0
  88. package/dist/index.d.ts +20 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +14 -0
  91. package/dist/link/sender.d.ts +160 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +200 -0
  94. package/dist/migrations/0001_threads.d.ts +26 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +103 -0
  97. package/dist/mime/address.d.ts +22 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +28 -0
  100. package/dist/mime/parse.d.ts +96 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +146 -0
  103. package/dist/mime/sanitize.d.ts +25 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +212 -0
  106. package/dist/mime/threading.d.ts +79 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +111 -0
  109. package/dist/mime/truncate.d.ts +17 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +47 -0
  112. package/dist/provision/provisionSupport.d.ts +158 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +110 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +45 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +22 -0
  118. package/dist/reply/send.d.ts +123 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +153 -0
  121. package/dist/reply/snippets.d.ts +79 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +135 -0
  124. package/dist/secret/registry.d.ts +18 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +21 -0
  127. package/dist/seeds/example.d.ts +3 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +350 -0
  130. package/dist/store/paging.d.ts +18 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +20 -0
  133. package/dist/store/search.d.ts +67 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +160 -0
  136. package/dist/store/searchIndex.d.ts +53 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +66 -0
  139. package/dist/store/threads.d.ts +147 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +197 -0
  142. package/dist/submission/encoding.d.ts +21 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +55 -0
  145. package/dist/submission/guard.d.ts +71 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +46 -0
  148. package/dist/submission/submit.d.ts +152 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +319 -0
  151. package/dist/version.generated.d.ts +5 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +7 -0
  154. package/dist/workflows/classify.d.ts +63 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +68 -0
  157. package/dist/workflows/retryPolicy.d.ts +39 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +43 -0
  160. package/dist/workflows/specs.d.ts +43 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +46 -0
  163. package/dist/workflows/worker.d.ts +48 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +37 -0
  166. package/package.json +24 -15
  167. package/src/version.generated.ts +1 -1
@@ -0,0 +1,326 @@
1
+ import { SupportAttachment } from "../data/attachment.js";
2
+ import { SupportMessage } from "../data/message.js";
3
+ import { SupportThread, UNCLASSIFIED } from "../data/thread.js";
4
+ import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
5
+ import { attachmentKey, putAttachment, rawMessageKey, sha256Hex } from "../attachment/store.js";
6
+ import { SupportAuditActions } from "../audit/actions.js";
7
+ import { parentCandidates } from "../mime/threading.js";
8
+ import { indexMessage } from "../store/search.js";
9
+ import { truncateToBytes } from "../mime/truncate.js";
10
+ import { MAX_TEXT_BODY, parseInbound } from "../mime/parse.js";
11
+ import { checkRates, checkSize } from "./guard.js";
12
+ import { htmlToText, sanitizeHtml } from "../mime/sanitize.js";
13
+ import { senderAuthenticity } from "./authenticity.js";
14
+ import { resolveInbox } from "./recipient.js";
15
+ import { chunkRowsByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
16
+ //#region src/inbound/ingest.ts
17
+ /** Record a refusal. Never throws — a guard decision must not become a 500. */
18
+ async function auditRejection(deps, reason, detail, fromAddress) {
19
+ try {
20
+ await deps.emit({
21
+ action: SupportAuditActions.inboundRejected,
22
+ outcome: "denied",
23
+ severity: "warning",
24
+ actorType: "anonymous",
25
+ actorId: null,
26
+ resourceType: "support_inbox",
27
+ metadata: {
28
+ reason,
29
+ fromAddress: fromAddress ?? null
30
+ }
31
+ });
32
+ deps.log.warn("support inbound message refused", {
33
+ reason,
34
+ detail,
35
+ fromAddress: fromAddress ?? null
36
+ });
37
+ } catch (error) {
38
+ deps.log.warn("support inbound rejection audit dropped", {
39
+ reason,
40
+ detail,
41
+ error
42
+ });
43
+ }
44
+ }
45
+ /**
46
+ * Find the thread a message continues, by walking its threading chain most-precise-first.
47
+ *
48
+ * The lookup goes through `pithy_support_messages.mime_message_id`, which is uniquely indexed — so
49
+ * this is an index seek per candidate and the chain is bounded at 50 entries. It stops at the first
50
+ * hit, which is why `parentCandidates` puts `In-Reply-To` ahead of a reversed `References`.
51
+ */
52
+ async function findParentThread(db, parsed, inbox) {
53
+ const candidates = parentCandidates(parsed.inReplyTo, parsed.references);
54
+ if (candidates.length === 0) return void 0;
55
+ const parent = await db.selectFrom(SUPPORT_MESSAGES_TABLE).innerJoin(SUPPORT_THREADS_TABLE, `${SUPPORT_THREADS_TABLE}.id`, `${SUPPORT_MESSAGES_TABLE}.threadId`).select([`${SUPPORT_MESSAGES_TABLE}.threadId`, `${SUPPORT_MESSAGES_TABLE}.mimeMessageId`]).where(`${SUPPORT_MESSAGES_TABLE}.mimeMessageId`, "in", candidates).where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", inbox).execute();
56
+ if (parent.length === 0) return void 0;
57
+ const byId = new Map(parent.map((row) => [row.mimeMessageId, row.threadId]));
58
+ for (const candidate of candidates) {
59
+ const threadId = byId.get(candidate);
60
+ if (threadId !== void 0) return threadId;
61
+ }
62
+ }
63
+ /**
64
+ * How many columns one `pithy_support_attachments` row binds. Derived from the schema rather than
65
+ * written as a literal, so adding a column re-chunks instead of silently re-breaking the cap.
66
+ */
67
+ const SUPPORT_ATTACHMENT_COLUMNS = Object.keys(SupportAttachment.shape).length;
68
+ /** Store the attachments this config allows, and return the rows written. */
69
+ async function storeAttachments(deps, parsed, threadId, messageId, now) {
70
+ const { attachments: bounds } = deps.config;
71
+ const bucket = deps.bucket;
72
+ if (!bounds.enabled || !bucket || parsed.attachments.length === 0) return 0;
73
+ const accepted = parsed.attachments.slice(0, bounds.maxCount);
74
+ if (parsed.attachments.length > accepted.length) deps.log.warn("support attachments truncated", {
75
+ threadId,
76
+ declared: parsed.attachments.length,
77
+ stored: accepted.length
78
+ });
79
+ const rows = [];
80
+ for (const attachment of accepted) {
81
+ if (attachment.bytes.byteLength > bounds.maxBytes) {
82
+ deps.log.warn("support attachment skipped, over the size bound", {
83
+ threadId,
84
+ bytes: attachment.bytes.byteLength,
85
+ maxBytes: bounds.maxBytes
86
+ });
87
+ continue;
88
+ }
89
+ const id = deps.newId();
90
+ const key = attachmentKey(threadId, id);
91
+ await putAttachment(bucket, key, attachment.bytes);
92
+ rows.push({
93
+ id,
94
+ messageId,
95
+ threadId,
96
+ filename: attachment.filename,
97
+ contentType: attachment.contentType,
98
+ size: attachment.bytes.byteLength,
99
+ sha256: await sha256Hex(attachment.bytes),
100
+ storageKey: key,
101
+ contentId: attachment.contentId ?? null,
102
+ inline: attachment.inline,
103
+ createdAt: now
104
+ });
105
+ }
106
+ const encoded = rows.map((row) => SupportAttachment.encode(row));
107
+ for (const chunk of chunkRowsByBoundParameters(encoded, SUPPORT_ATTACHMENT_COLUMNS)) await deps.db.insertInto(SUPPORT_ATTACHMENTS_TABLE).values(chunk).execute();
108
+ return rows.length;
109
+ }
110
+ /** Ingest one inbound message. */
111
+ async function ingestInbound(deps, input) {
112
+ const now = deps.now();
113
+ const rawBytes = input.raw.byteLength;
114
+ const size = checkSize(deps.config.guard, rawBytes);
115
+ if (!size.accepted) {
116
+ await auditRejection(deps, size.reason, size.detail, void 0);
117
+ return {
118
+ handled: false,
119
+ reason: "rejected",
120
+ rejection: size.reason
121
+ };
122
+ }
123
+ const parsed = await parseInbound(input.raw, { expectedAuthservId: deps.config.guard.authservId });
124
+ const inbox = resolveInbox({
125
+ inboundAddresses: deps.config.inboundAddresses,
126
+ envelopeTo: input.envelopeTo,
127
+ headerRecipients: parsed.headerRecipients
128
+ });
129
+ if (inbox === void 0) return {
130
+ handled: false,
131
+ reason: "not_addressed"
132
+ };
133
+ const rates = await checkRates(deps.db, deps.config.guard, {
134
+ rawBytes,
135
+ fromAddress: parsed.fromAddress,
136
+ now
137
+ });
138
+ if (!rates.accepted) {
139
+ await auditRejection(deps, rates.reason, rates.detail, parsed.fromAddress);
140
+ return {
141
+ handled: false,
142
+ reason: "rejected",
143
+ rejection: rates.reason
144
+ };
145
+ }
146
+ if (parsed.messageId !== void 0) {
147
+ const existing = await deps.db.selectFrom(SUPPORT_MESSAGES_TABLE).select(["id", "threadId"]).where("mimeMessageId", "=", parsed.messageId).where("toAddress", "=", inbox).executeTakeFirst();
148
+ if (existing) {
149
+ deps.log.info("support message already stored, ignoring redelivery", { messageId: existing.id });
150
+ return {
151
+ handled: true,
152
+ duplicate: true,
153
+ threadId: existing.threadId,
154
+ messageId: existing.id
155
+ };
156
+ }
157
+ }
158
+ const html = parsed.html !== void 0 ? await sanitizeHtml(parsed.html) : void 0;
159
+ const derived = parsed.text.trim().length > 0 ? parsed.text : parsed.html !== void 0 ? await htmlToText(parsed.html) : "";
160
+ const text = truncateToBytes(derived, MAX_TEXT_BODY);
161
+ const authenticity = senderAuthenticity({
162
+ authResults: parsed.authResults,
163
+ fromAddress: parsed.fromAddress,
164
+ envelopeFrom: input.envelopeFrom,
165
+ trusted: deps.config.guard.trustAuthenticationResults
166
+ });
167
+ deps.log.info("support inbound authentication headers observed", {
168
+ seen: parsed.authHeadersSeen,
169
+ trusted: deps.config.guard.trustAuthenticationResults,
170
+ authenticated: authenticity.authenticated
171
+ });
172
+ const parentThreadId = await findParentThread(deps.db, parsed, inbox);
173
+ const threadId = parentThreadId ?? deps.newId();
174
+ const newThread = parentThreadId === void 0;
175
+ const messageId = deps.newId();
176
+ const subject = parsed.subject.length > 0 ? parsed.subject : "(no subject)";
177
+ let rawKey = null;
178
+ if (deps.bucket && deps.config.attachments.retainRaw) {
179
+ rawKey = rawMessageKey(threadId, messageId);
180
+ await deps.bucket.put(rawKey, input.raw, { httpMetadata: {
181
+ contentType: "application/octet-stream",
182
+ contentDisposition: "attachment"
183
+ } });
184
+ }
185
+ const message = {
186
+ id: messageId,
187
+ threadId,
188
+ direction: "inbound",
189
+ channel: "email",
190
+ submittedByUserId: null,
191
+ context: null,
192
+ mimeMessageId: parsed.messageId ?? null,
193
+ mimeInReplyTo: parsed.inReplyTo ?? null,
194
+ mimeReferences: parsed.references.length > 0 ? parsed.references : null,
195
+ fromAddress: parsed.fromAddress,
196
+ fromName: parsed.fromName ?? null,
197
+ toAddress: inbox,
198
+ subject,
199
+ textBody: text,
200
+ htmlBody: html ?? null,
201
+ emailJobId: null,
202
+ rawKey,
203
+ rawBytes,
204
+ receivedAt: now,
205
+ createdAt: now
206
+ };
207
+ try {
208
+ await deps.db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode(message)).execute();
209
+ } catch (error) {
210
+ const existing = parsed.messageId ? await deps.db.selectFrom(SUPPORT_MESSAGES_TABLE).select(["id", "threadId"]).where("mimeMessageId", "=", parsed.messageId).where("toAddress", "=", inbox).executeTakeFirst() : void 0;
211
+ if (existing) {
212
+ deps.log.info("support message stored concurrently, ignoring redelivery", { messageId: existing.id });
213
+ return {
214
+ handled: true,
215
+ duplicate: true,
216
+ threadId: existing.threadId,
217
+ messageId: existing.id
218
+ };
219
+ }
220
+ throw error;
221
+ }
222
+ try {
223
+ if (newThread) {
224
+ let userId = null;
225
+ try {
226
+ userId = await deps.linkSender(parsed.fromAddress);
227
+ } catch (error) {
228
+ deps.log.warn("support sender link failed", { error });
229
+ }
230
+ if (userId && !authenticity.authenticated) deps.log.info("support sender matched on an unverified From address", { method: authenticity.method });
231
+ const thread = {
232
+ id: threadId,
233
+ channel: "email",
234
+ inboxAddress: inbox,
235
+ subject,
236
+ fromAddress: parsed.fromAddress,
237
+ fromName: parsed.fromName ?? null,
238
+ senderAuthenticated: authenticity.authenticated,
239
+ userId,
240
+ accountLinkSource: userId ? "email_address" : null,
241
+ ...UNCLASSIFIED,
242
+ archived: false,
243
+ archivedAt: null,
244
+ archivedBy: null,
245
+ messageCount: 1,
246
+ firstMessageAt: now,
247
+ lastMessageAt: now,
248
+ createdAt: now,
249
+ updatedAt: now
250
+ };
251
+ await deps.db.insertInto(SUPPORT_THREADS_TABLE).values(SupportThread.encode(thread)).execute();
252
+ } else {
253
+ let linkedNow = null;
254
+ if (authenticity.authenticated) {
255
+ if ((await deps.db.selectFrom("pithySupportThreads").select(["userId"]).where("id", "=", threadId).executeTakeFirst())?.userId == null) try {
256
+ linkedNow = await deps.linkSender(parsed.fromAddress);
257
+ } catch (error) {
258
+ deps.log.warn("support sender re-link failed", { error });
259
+ }
260
+ }
261
+ await deps.db.updateTable(SUPPORT_THREADS_TABLE).set((eb) => ({
262
+ ...linkedNow ? {
263
+ userId: linkedNow,
264
+ senderAuthenticated: 1,
265
+ accountLinkSource: "email_address"
266
+ } : {},
267
+ messageCount: eb("messageCount", "+", 1),
268
+ lastMessageAt: now.getTime(),
269
+ updatedAt: now.getTime(),
270
+ archived: 0,
271
+ archivedAt: null,
272
+ archivedBy: null
273
+ })).where("id", "=", threadId).execute();
274
+ }
275
+ } catch (error) {
276
+ await deps.db.deleteFrom(SUPPORT_MESSAGES_TABLE).where("id", "=", messageId).execute().catch(() => {
277
+ deps.log.error("support message orphaned — thread write failed and the message could not be removed", {
278
+ messageId,
279
+ threadId
280
+ });
281
+ });
282
+ throw error;
283
+ }
284
+ if (deps.fts) try {
285
+ await indexMessage(deps.db, {
286
+ threadId,
287
+ messageId,
288
+ subject,
289
+ body: text
290
+ });
291
+ } catch (error) {
292
+ deps.log.warn("support message not indexed", {
293
+ messageId,
294
+ error
295
+ });
296
+ }
297
+ let attachments = 0;
298
+ try {
299
+ attachments = await storeAttachments(deps, parsed, threadId, messageId, now);
300
+ } catch (error) {
301
+ deps.log.warn("support attachments not stored", {
302
+ messageId,
303
+ error
304
+ });
305
+ }
306
+ const classifying = deps.config.ai.enabled && !parsed.autoSubmitted;
307
+ if (classifying) try {
308
+ await deps.dispatchClassify(messageId);
309
+ } catch (error) {
310
+ deps.log.warn("support classification dispatch failed", {
311
+ messageId,
312
+ error
313
+ });
314
+ }
315
+ return {
316
+ handled: true,
317
+ duplicate: false,
318
+ threadId,
319
+ messageId,
320
+ newThread,
321
+ attachments,
322
+ classifying
323
+ };
324
+ }
325
+ //#endregion
326
+ export { ingestInbound };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Resolve which configured inbox a message landed on, or `undefined` when none of them did.
3
+ *
4
+ * The returned address is stored on the thread, so a Worker serving `support@` and `security@` can
5
+ * keep the two apart in one table — and the value is the *configured* one rather than the envelope
6
+ * string, so a thread's inbox is always something that appears in `pithy.config.ts`.
7
+ */
8
+ export declare function resolveInbox(options: {
9
+ /** The configured addresses, already normalized and lowercased. */
10
+ inboundAddresses: readonly string[];
11
+ /** The SMTP envelope recipient — `ForwardableEmailMessage.to`. The authority. */
12
+ envelopeTo: string | undefined;
13
+ /** `To`/`Cc`/`Delivered-To` from the parsed headers. Corroborating only. */
14
+ headerRecipients: readonly string[];
15
+ }): string | undefined;
16
+ //# sourceMappingURL=recipient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipient.d.ts","sourceRoot":"","sources":["../../src/inbound/recipient.ts"],"names":[],"mappings":"AAgCA;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE;IACpC,mEAAmE;IACnE,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,iFAAiF;IACjF,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,4EAA4E;IAC5E,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC,GAAG,MAAM,GAAG,SAAS,CAoBrB"}
@@ -0,0 +1,46 @@
1
+ import { parseAddress } from "@pithy-sh/core/src/address/address";
2
+ //#region src/inbound/recipient.ts
3
+ /**
4
+ * Which inbound messages belong to this capability.
5
+ *
6
+ * A Worker has exactly one `email()` entry, and `createEntrypoint` fans every message to **every**
7
+ * capability that declares a handler. So a Worker running both `@pithy-sh/email` and this one sees
8
+ * each bounce twice and each support request twice, and the discrimination is each handler's own
9
+ * job. Email's handler filters by content — it acts only on DSNs and ARF reports. This one filters
10
+ * by **address**, because a support request looks like ordinary mail and there is nothing in its
11
+ * body to key on.
12
+ *
13
+ * ## The envelope recipient is the authority; the headers are not
14
+ *
15
+ * `ForwardableEmailMessage.to` is the SMTP envelope recipient — the address Cloudflare's own routing
16
+ * rule matched to deliver here. It is the only recipient that was proved rather than asserted.
17
+ *
18
+ * The `To:` and `Cc:` headers are neither. Anyone can put `To: support@yourdomain.com` on a message
19
+ * addressed elsewhere, and BCC means a legitimate message frequently does not name its real
20
+ * recipient at all. Trusting them would let a stranger inject threads into an inbox they were never
21
+ * routed to — so they are used only as a fallback for the one case where the envelope is unusable,
22
+ * and never to override it.
23
+ */
24
+ /** Whether an address is one of the configured inbox addresses. Both sides are already normalized. */
25
+ function claims(addresses, candidate) {
26
+ return candidate !== void 0 && addresses.includes(candidate);
27
+ }
28
+ /**
29
+ * Resolve which configured inbox a message landed on, or `undefined` when none of them did.
30
+ *
31
+ * The returned address is stored on the thread, so a Worker serving `support@` and `security@` can
32
+ * keep the two apart in one table — and the value is the *configured* one rather than the envelope
33
+ * string, so a thread's inbox is always something that appears in `pithy.config.ts`.
34
+ */
35
+ function resolveInbox(options) {
36
+ const { inboundAddresses } = options;
37
+ if (inboundAddresses.length === 0) return void 0;
38
+ const envelope = parseAddress(options.envelopeTo);
39
+ if (envelope !== void 0) return claims(inboundAddresses, envelope) ? envelope : void 0;
40
+ for (const candidate of options.headerRecipients) {
41
+ const normalized = parseAddress(candidate);
42
+ if (claims(inboundAddresses, normalized)) return normalized;
43
+ }
44
+ }
45
+ //#endregion
46
+ export { resolveInbox };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The package entrypoint — the surface `pithy add support` wires into `pithy.config.ts`. Deliberately
3
+ * narrow: the capability factory, its config and options types, the two federation helpers an adopter
4
+ * calls, the control-plane scopes, and the table schemas. Every other module is imported by deep path
5
+ * (`@pithy-sh/support/src/...`); this is the documented contract, not a barrel over the package.
6
+ */
7
+ export { type SupportAuditAction, SupportAuditActions } from "./audit/actions";
8
+ export { isSupportCapability, SUPPORT_MIGRATION_ORDER, type SupportCapability, type SupportOptions, support, } from "./capability";
9
+ export { DEFAULT_CLASSIFY_MODEL, SupportAiConfig, SupportAttachmentsConfig, SupportConfig, type SupportConfigInput, SupportGuardConfig, SupportReplyConfig, SupportSearchConfig, } from "./config/config";
10
+ export { SupportAttachment } from "./data/attachment";
11
+ export { DEFAULT_SUPPORT_CATEGORIES, defineSupportCategories, type SupportCategories, } from "./data/categories";
12
+ export { SupportClassification } from "./data/classification";
13
+ export { SupportMessageDirection, SupportPriority, SupportSentiment, UNCATEGORIZED } from "./data/enums";
14
+ export { SupportThreadFlag } from "./data/flag";
15
+ export { SupportMessage } from "./data/message";
16
+ export { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_SEARCH_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase, supportDatabase, } from "./data/tables";
17
+ export { SupportThread } from "./data/thread";
18
+ export { SUPPORT_CONTROL_PLANE_SCOPES, SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE, } from "./http/scopes";
19
+ export { DEFAULT_SUPPORT_REPLIES, defineSupportReplies, SupportReplySnippet, type SupportReplySnippets, } from "./reply/snippets";
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AAEH,OAAO,EAAE,KAAK,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EACvB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,OAAO,GACR,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,sBAAsB,EACtB,eAAe,EACf,wBAAwB,EACxB,aAAa,EACb,KAAK,kBAAkB,EACvB,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EACL,0BAA0B,EAC1B,uBAAuB,EACvB,KAAK,iBAAiB,GACvB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,uBAAuB,EAAE,eAAe,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzG,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EACL,yBAAyB,EACzB,6BAA6B,EAC7B,mBAAmB,EACnB,sBAAsB,EACtB,oBAAoB,EACpB,qBAAqB,EACrB,KAAK,eAAe,EACpB,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EACL,4BAA4B,EAC5B,6BAA6B,EAC7B,0BAA0B,EAC1B,0BAA0B,EAC1B,gCAAgC,EAChC,2BAA2B,GAC5B,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,oBAAoB,GAC1B,MAAM,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ import { DEFAULT_SUPPORT_REPLIES, SupportReplySnippet, defineSupportReplies } from "./reply/snippets.js";
2
+ import { DEFAULT_CLASSIFY_MODEL, SupportAiConfig, SupportAttachmentsConfig, SupportConfig, SupportGuardConfig, SupportReplyConfig, SupportSearchConfig } from "./config/config.js";
3
+ import { SupportMessageDirection, SupportPriority, SupportSentiment, UNCATEGORIZED } from "./data/enums.js";
4
+ import { DEFAULT_SUPPORT_CATEGORIES, defineSupportCategories } from "./data/categories.js";
5
+ import { SupportAttachment } from "./data/attachment.js";
6
+ import { SupportClassification } from "./data/classification.js";
7
+ import { SupportThreadFlag } from "./data/flag.js";
8
+ import { SupportMessage } from "./data/message.js";
9
+ import { SupportThread } from "./data/thread.js";
10
+ import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_SEARCH_TABLE, SUPPORT_THREADS_TABLE, supportDatabase } from "./data/tables.js";
11
+ import { SupportAuditActions } from "./audit/actions.js";
12
+ import { SUPPORT_CONTROL_PLANE_SCOPES, SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE } from "./http/scopes.js";
13
+ import { SUPPORT_MIGRATION_ORDER, isSupportCapability, support } from "./capability.js";
14
+ export { DEFAULT_CLASSIFY_MODEL, DEFAULT_SUPPORT_CATEGORIES, DEFAULT_SUPPORT_REPLIES, SUPPORT_ATTACHMENTS_TABLE, SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_CONTROL_PLANE_SCOPES, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_MIGRATION_ORDER, SUPPORT_SEARCH_TABLE, SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE, SUPPORT_THREADS_TABLE, SupportAiConfig, SupportAttachment, SupportAttachmentsConfig, SupportAuditActions, SupportClassification, SupportConfig, SupportGuardConfig, SupportMessage, SupportMessageDirection, SupportPriority, SupportReplyConfig, SupportReplySnippet, SupportSearchConfig, SupportSentiment, SupportThread, SupportThreadFlag, UNCATEGORIZED, defineSupportCategories, defineSupportReplies, isSupportCapability, support, supportDatabase };
@@ -0,0 +1,160 @@
1
+ import type { D1Database } from "@cloudflare/workers-types";
2
+ import type { Entitlement } from "@pithy-sh/core/src/entitlement/entitlement";
3
+ /**
4
+ * The link from an address in a `From` header to the customer the app already knows about.
5
+ *
6
+ * This is the feature the whole capability exists for. A support thread that arrives already knowing
7
+ * who the sender is, what they bought, on which rail, and whether their last renewal failed is a
8
+ * different object from a message in a mailbox — and every one of those facts is already in the
9
+ * adopter's own D1, one table over, unused.
10
+ *
11
+ * ## Guarded dynamic imports, not dependencies
12
+ *
13
+ * `@pithy-sh/auth` and `@pithy-sh/payments` are **optional** here (principle 4: depend on core seams,
14
+ * never on a sibling's internals), so both are reached the way `@pithy-sh/matchmaking` already
15
+ * reaches auth — a dynamic import inside a `try`, degrading to "no link" when the package is not
16
+ * installed. A support inbox in a project with no accounts and no payments is a perfectly reasonable
17
+ * thing to run, and it must not fail to start, or to store mail, because of what it cannot see.
18
+ *
19
+ * Every function here is **best-effort by contract**: it returns nothing rather than throwing, and
20
+ * the caller stores the message either way. Linkage is context, and context is never worth losing a
21
+ * customer's support request over.
22
+ *
23
+ * ## The billing half links people, and only people
24
+ *
25
+ * `@pithy-sh/payments` keys a purchase on a **subject pair** — a user or an organization, plus an id —
26
+ * and which of the two a project uses is its `billingSubject` config. Support cannot honor that choice.
27
+ * It starts from a `From:` header, resolves it to a *person*, and does its billing lookup at thread-read
28
+ * time, where there is no Hono `Context` to hand the adopter's subject resolver: the seam's whole job is
29
+ * to answer "which organization is *this caller* acting for", and a support thread has no caller.
30
+ *
31
+ * So this file reads `user`-subject rows, and {@link SUPPORT_BILLING_SCOPE} says so on every response.
32
+ * Under organization billing the panel is empty, and it must not be *silently* empty — the lookups below
33
+ * are guarded dynamic imports whose `catch` already returns `[]`, so an empty panel is indistinguishable
34
+ * between "bought nothing", "`@pithy-sh/payments` is not installed" and "billed to an organization".
35
+ * An operator reads the first of those and decides a refund on it. A declared scope is what turns the
36
+ * third case from a silence into a limitation somebody can see.
37
+ *
38
+ * **The scope constant used to be declared here, and is now `data/billingScope.ts` (Jim, 2026-08-21).**
39
+ * The old argument was that the query and the wire must read one value, which is right and unchanged —
40
+ * but `http/responses.ts` honoring it by importing from *this* file pulled the whole server data layer
41
+ * into a browser program, because that is what this file's imports are (#419). One value, in a module
42
+ * neither half owns.
43
+ */
44
+ /** The account a sender resolves to, and what the app knows about them. */
45
+ export interface SenderContext {
46
+ /**
47
+ * Whether the `From:` header was proved to belong to the sender. **Everything below is empty when
48
+ * this is false**, and a dashboard must render the sender as an unverified claim rather than as a
49
+ * customer — the whole value of this panel is that an operator trusts it, so it must not be
50
+ * populated from an address anybody could have written.
51
+ */
52
+ authenticated: boolean;
53
+ /** The linked `pithy_auth_users.id`, or null when this address belongs to nobody with an account. */
54
+ userId: string | null;
55
+ /** The account's display name, when auth is composed and the sender is known. */
56
+ name?: string;
57
+ /** Whether the account has verified this address. A useful signal beside an unverified claim in a header. */
58
+ emailVerified?: boolean;
59
+ /**
60
+ * Their purchase history, newest first — **individually billed purchases only**. Empty when payments
61
+ * is absent, when they have bought nothing, and when their billing is held by an organization this
62
+ * seam cannot resolve. `SenderContextView.billingScope` is what tells a console those apart.
63
+ */
64
+ purchases: readonly SenderPurchase[];
65
+ /**
66
+ * Their entitlements, lapsed ones included and marked inactive — a paywall wants to say when Pro
67
+ * ended. Same scope as {@link SenderContext.purchases}: `user` subjects, nothing organization-held.
68
+ */
69
+ entitlements: readonly Entitlement[];
70
+ }
71
+ /** One purchase, flattened to what a support console renders. */
72
+ export interface SenderPurchase {
73
+ /** The purchase row id. */
74
+ id: string;
75
+ /** Which rail it went through — `apple`, `google`, or `stripe`. */
76
+ rail: string;
77
+ /** The Pithy product key. */
78
+ productId: string;
79
+ /** Its lifecycle state — `active`, `refunded`, `expired`, and so on. */
80
+ status: string;
81
+ /** Whether the purchase happened in the store's sandbox rather than production. */
82
+ environment: string;
83
+ /** When it was bought. */
84
+ purchasedAt: Date;
85
+ /** When it runs out; null for something owned forever. */
86
+ expiresAt: Date | null;
87
+ /** When it was refunded or revoked; null while it stands. */
88
+ revokedAt: Date | null;
89
+ }
90
+ /** How many purchases a thread view carries. Enough to see the pattern, bounded so a whale is not a slow page. */
91
+ export declare const MAX_LINKED_PURCHASES = 25;
92
+ /**
93
+ * Resolve a sender address to a user id.
94
+ *
95
+ * **Exact match on the normalized address, and deliberately no case-insensitive fallback.** The
96
+ * `email` column is indexed and unique, so this is one index seek; a `lower(email) = ?` fallback
97
+ * would be a full table scan, and it would run for *every unknown sender* — which on a public
98
+ * address means every piece of spam scanning the entire user table. Better Auth normalizes on its
99
+ * own signup path, so the exact match is the case that actually occurs; an address stored with
100
+ * capitals by some other route simply does not link, which costs a line of context rather than a
101
+ * customer's request.
102
+ */
103
+ export declare function resolveSenderUserId(d1: D1Database, address: string): Promise<string | null>;
104
+ /**
105
+ * The address an operator's reply to an app thread will be sent to, or null when the account carries
106
+ * nothing deliverable.
107
+ *
108
+ * Extracted rather than inlined because it is the one decision in {@link resolveSubmitterAccount} with
109
+ * a wrong answer available, and inlining it puts that decision behind a guarded dynamic import where
110
+ * no test can reach it.
111
+ *
112
+ * **`parseAddress` only, with no `normalizeAddress` fallback.** The fallback merely trims and
113
+ * lowercases, so it would hand back an address this capability's own parser had just refused — and
114
+ * this value becomes the thread's `fromAddress`, which `sendReply` enqueues an answer to. Returning
115
+ * null makes an unparseable account the same hard fault as a missing one, which is what the caller
116
+ * already does with it: refusing the submission beats accepting a report whose only reply address
117
+ * cannot be delivered to.
118
+ */
119
+ export declare function submitterAddress(email: string | null | undefined): string | null;
120
+ /**
121
+ * The account behind an authenticated submitter — resolved by id, never by an address.
122
+ *
123
+ * The inverse of {@link resolveSenderUserId}, and it exists because the in-app channel starts from the
124
+ * opposite end: a session names a user id, and what the thread needs is the address a reply will go
125
+ * back to. Deriving that from anything the client sent would hand a signed-in caller the ability to
126
+ * point a support conversation — and every operator reply on it — at somebody else's mailbox.
127
+ *
128
+ * Returns null when `@pithy-sh/auth` is absent or the account is gone. A submission whose account
129
+ * cannot be read is refused by the caller rather than stored with a guessed address: an app thread with
130
+ * no working reply address is a report nobody can answer.
131
+ */
132
+ export declare function resolveSubmitterAccount(d1: D1Database, userId: string): Promise<{
133
+ email: string;
134
+ name?: string;
135
+ emailVerified?: boolean;
136
+ } | null>;
137
+ /**
138
+ * Everything the app already knows about a sender — read at thread-read time rather than stored.
139
+ *
140
+ * Derived, so it is always current: a customer who buys Pro an hour after writing in shows as
141
+ * entitled the next time the thread is opened, with nothing to backfill and nothing to invalidate.
142
+ * That is the same rule the classification follows, applied to the half of the model that lives in
143
+ * somebody else's tables.
144
+ */
145
+ export declare function resolveSenderContext(d1: D1Database, address: string, now: Date, options: {
146
+ authenticated: boolean;
147
+ }): Promise<SenderContext>;
148
+ /**
149
+ * The customer context behind a **session-proven** link, resolved from the user id itself.
150
+ *
151
+ * An app thread already holds the id its session proved, so re-deriving it from the thread's address
152
+ * would be both a step backwards and a correctness bug: `resolveSenderUserId` matches the `email`
153
+ * column exactly, and an account stored with capitals by some other route would silently resolve to
154
+ * nobody — turning the one link that *is* certain into the one the console shows as unknown.
155
+ *
156
+ * Everything below the link degrades exactly as it does on the mail path: purchases and entitlements
157
+ * are guarded dynamic imports and come back empty when `@pithy-sh/payments` is absent.
158
+ */
159
+ export declare function resolveSubmitterContext(d1: D1Database, userId: string, now: Date): Promise<SenderContext>;
160
+ //# sourceMappingURL=sender.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/link/sender.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4CAA4C,CAAC;AAG9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,aAAa,EAAE,OAAO,CAAC;IACvB,qGAAqG;IACrG,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6GAA6G;IAC7G,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,SAAS,EAAE,SAAS,cAAc,EAAE,CAAC;IACrC;;;OAGG;IACH,YAAY,EAAE,SAAS,WAAW,EAAE,CAAC;CACtC;AAED,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,6BAA6B;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,WAAW,EAAE,MAAM,CAAC;IACpB,0BAA0B;IAC1B,WAAW,EAAE,IAAI,CAAC;IAClB,0DAA0D;IAC1D,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,6DAA6D;IAC7D,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;CACxB;AAED,kHAAkH;AAClH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiBjG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAEhF;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAAC,CAiB3E;AA4ED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,UAAU,EACd,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,IAAI,EACT,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAA;CAAE,GAClC,OAAO,CAAC,aAAa,CAAC,CAwBxB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,CAI/G"}