@pithy-sh/support 0.1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +68 -0
  4. package/pithy.manifest.json +40 -0
  5. package/src/ai/classify.ts +239 -0
  6. package/src/attachment/store.ts +78 -0
  7. package/src/audit/actions.ts +71 -0
  8. package/src/capability.ts +293 -0
  9. package/src/client/projection.ts +60 -0
  10. package/src/cloudflare-test.d.ts +15 -0
  11. package/src/config/config.ts +400 -0
  12. package/src/data/attachment.ts +65 -0
  13. package/src/data/billingScope.ts +32 -0
  14. package/src/data/categories.ts +117 -0
  15. package/src/data/classification.ts +50 -0
  16. package/src/data/enums.ts +90 -0
  17. package/src/data/flag.ts +37 -0
  18. package/src/data/message.ts +224 -0
  19. package/src/data/tables.ts +58 -0
  20. package/src/data/thread.ts +138 -0
  21. package/src/error/errors.ts +133 -0
  22. package/src/http/guards.ts +59 -0
  23. package/src/http/handlers.ts +418 -0
  24. package/src/http/resolve.ts +109 -0
  25. package/src/http/responses.ts +506 -0
  26. package/src/http/routes.ts +272 -0
  27. package/src/http/schemas.ts +251 -0
  28. package/src/http/scopes.ts +117 -0
  29. package/src/http/views.ts +169 -0
  30. package/src/inbound/authenticity.ts +114 -0
  31. package/src/inbound/guard.ts +127 -0
  32. package/src/inbound/handler.ts +102 -0
  33. package/src/inbound/ingest.ts +548 -0
  34. package/src/inbound/recipient.ts +67 -0
  35. package/src/index.ts +63 -0
  36. package/src/link/sender.ts +334 -0
  37. package/src/migrations/0001_threads.ts +296 -0
  38. package/src/mime/address.ts +37 -0
  39. package/src/mime/parse.ts +299 -0
  40. package/src/mime/sanitize.ts +253 -0
  41. package/src/mime/threading.ts +127 -0
  42. package/src/mime/truncate.ts +55 -0
  43. package/src/provision/provisionSupport.ts +179 -0
  44. package/src/provision/resolveSupportConfig.ts +67 -0
  45. package/src/reply/send.ts +322 -0
  46. package/src/reply/snippets.ts +167 -0
  47. package/src/secret/registry.ts +24 -0
  48. package/src/seeds/example.ts +385 -0
  49. package/src/store/paging.ts +22 -0
  50. package/src/store/search.ts +197 -0
  51. package/src/store/searchIndex.ts +71 -0
  52. package/src/store/threads.ts +452 -0
  53. package/src/submission/encoding.ts +66 -0
  54. package/src/submission/guard.ts +120 -0
  55. package/src/submission/submit.ts +539 -0
  56. package/src/version.generated.ts +16 -0
  57. package/src/workflows/classify.ts +164 -0
  58. package/src/workflows/retryPolicy.ts +48 -0
  59. package/src/workflows/specs.ts +61 -0
  60. package/src/workflows/worker.ts +82 -0
  61. package/src/workflows/wrangler.jsonc +46 -0
@@ -0,0 +1,133 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * `@pithy-sh/support` throw sugar. The `support/*` codes live in core's closed `KitErrorPayload` union
9
+ * (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
10
+ * package-local vehicles that set one of those members — the same pattern as `@pithy-sh/media` and
11
+ * `@pithy-sh/payments`. Runtime code in this package throws one of these, never a plain `new Error`.
12
+ *
13
+ * **Keep the public `message` free of anything the sender wrote.** This capability's entire input is
14
+ * attacker-controlled mail, so an error that echoed a subject line back would turn the error channel
15
+ * into a reflection surface. Sender text goes in `detail`, which the HTTP codec strips.
16
+ */
17
+
18
+ /** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
19
+ interface SupportErrorArgs {
20
+ /** Override the public, safe-to-expose message. */
21
+ message?: string;
22
+ /** A remediation hint (CLI action line). */
23
+ action?: string;
24
+ /** Internal context for logs + audit. Never serialized to clients. */
25
+ detail?: string;
26
+ /**
27
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
28
+ * unlike `action` and `detail` — these cross the boundary with `message`.
29
+ */
30
+ params?: MessageParams;
31
+ }
32
+
33
+ /** A requested thread, message, or attachment does not exist. */
34
+ export class SupportNotFoundError extends PithyError {
35
+ constructor(args: SupportErrorArgs = {}, options?: { cause?: unknown }) {
36
+ super(
37
+ {
38
+ code: "support/not_found",
39
+ status: 404,
40
+ message: args.message ?? "That support thread does not exist.",
41
+ action: args.action,
42
+ detail: args.detail,
43
+ params: args.params,
44
+ },
45
+ options,
46
+ );
47
+ }
48
+ }
49
+
50
+ /** A category key or its description failed validation at author time. */
51
+ export class SupportInvalidCategoryError extends PithyError {
52
+ constructor(args: SupportErrorArgs = {}, options?: { cause?: unknown }) {
53
+ super(
54
+ {
55
+ code: "support/invalid_category",
56
+ status: 400,
57
+ message: args.message ?? "That support category is not valid.",
58
+ action: args.action ?? "Use a lowercase snake_case key with one instructional sentence describing it.",
59
+ detail: args.detail,
60
+ params: args.params,
61
+ },
62
+ options,
63
+ );
64
+ }
65
+ }
66
+
67
+ /** An inbound message could not be parsed, or carried nothing this capability can store. */
68
+ export class SupportUnparseableMessageError extends PithyError {
69
+ constructor(args: SupportErrorArgs = {}, options?: { cause?: unknown }) {
70
+ super(
71
+ {
72
+ code: "support/unparseable_message",
73
+ status: 400,
74
+ message: args.message ?? "That message could not be read as email.",
75
+ action: args.action,
76
+ detail: args.detail,
77
+ params: args.params,
78
+ },
79
+ options,
80
+ );
81
+ }
82
+ }
83
+
84
+ /** An inbound message was refused by the volume or size guard before anything was persisted. */
85
+ export class SupportRejectedError extends PithyError {
86
+ constructor(args: SupportErrorArgs = {}, options?: { cause?: unknown }) {
87
+ super(
88
+ {
89
+ code: "support/rejected",
90
+ status: 429,
91
+ message: args.message ?? "That message was refused by the inbound guard.",
92
+ action: args.action,
93
+ detail: args.detail,
94
+ params: args.params,
95
+ },
96
+ options,
97
+ );
98
+ }
99
+ }
100
+
101
+ /** The AI classification step failed, or the binding it needs is absent. */
102
+ export class SupportClassificationError extends PithyError {
103
+ constructor(args: SupportErrorArgs = {}, options?: { cause?: unknown }) {
104
+ super(
105
+ {
106
+ code: "support/classification_failed",
107
+ status: 500,
108
+ message: args.message ?? "Support classification could not complete.",
109
+ action: args.action,
110
+ detail: args.detail,
111
+ params: args.params,
112
+ },
113
+ options,
114
+ );
115
+ }
116
+ }
117
+
118
+ /** A reply could not be enqueued — the email capability is absent, or it refused the job. */
119
+ export class SupportReplyFailedError extends PithyError {
120
+ constructor(args: SupportErrorArgs = {}, options?: { cause?: unknown }) {
121
+ super(
122
+ {
123
+ code: "support/reply_failed",
124
+ status: 502,
125
+ message: args.message ?? "That reply could not be sent.",
126
+ action: args.action ?? "Add the email capability and provision it, then retry.",
127
+ detail: args.detail,
128
+ params: args.params,
129
+ },
130
+ options,
131
+ );
132
+ }
133
+ }
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { MiddlewareHandler } from "hono";
7
+
8
+ /**
9
+ * Support's identity gates: the copied `requireAuth()` its in-app submission routes wear, and the
10
+ * control-plane gate its management routes take from core's seam.
11
+ *
12
+ * **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
13
+ * connection may do, and it reads them in a browser — so they cannot live in a module that imports
14
+ * Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
15
+ * the routes import both.
16
+ *
17
+ * ## Two surfaces, and the split is the whole shape of this file
18
+ *
19
+ * **The management surface is `control-plane` and default-denied.** It reads and acts on every
20
+ * customer's private correspondence, so it answers to a credential the adopter issued and to nothing
21
+ * else. The seam's gate is imported from core rather than copied — core is a hard dependency of every
22
+ * capability, so importing its gate cannot leave a deployment without one, and with the seam
23
+ * uncomposed `requireControlPlane` raises `controlplane/not_connected` rather than passing.
24
+ *
25
+ * **The submission surface is `bearer`/`session` and is the adopter's own signed-in user.** It exists
26
+ * because a product with a logged-in user, a session, and a support console should not have to ask
27
+ * that user to open their mail client — and because the hardest problem on the mail path, proving a
28
+ * `From:` header, does not exist on a request whose session was already proved.
29
+ *
30
+ * **`requireAuth()` never appears on a management route, and `requireControlPlane` never appears on a
31
+ * submission route.** They are not two strengths of the same gate. A management client holds no
32
+ * session and owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking
33
+ * them would deny every legitimate call on both surfaces, permanently, with no credential able to fix
34
+ * it. What each route may *see* follows from which gate it wears: an operator reads a thread with its
35
+ * classification and its sender's purchases, and a submitter reads their own words back and nothing
36
+ * else.
37
+ */
38
+
39
+ /**
40
+ * Require an authenticated caller — core's `AuthContext` seam, and the gate on every in-app route.
41
+ *
42
+ * **Copied from `@pithy-sh/auth`, not imported, and the duplication is deliberate.** Importing it would
43
+ * make auth a hard dependency, and *a package that imports its authorization from another package
44
+ * fails open when that package is absent*. Depending on the core seam instead means `c.var.auth` is
45
+ * simply null with no auth capability composed, and every submission route denies. Failing closed is
46
+ * not a side effect of the copy; it is the reason for it — and on this capability the thing behind the
47
+ * gate is somebody's support history.
48
+ */
49
+ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
50
+ return async (c, next) => {
51
+ if (!c.var.auth) {
52
+ throw new UnauthorizedError({
53
+ message: "Authentication required.",
54
+ action: "Sign in and retry with a valid session or bearer token.",
55
+ });
56
+ }
57
+ await next();
58
+ };
59
+ }
@@ -0,0 +1,418 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
5
+ import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
6
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
8
+ import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
9
+ import { attachmentUrl } from "../attachment/store";
10
+ import { SupportAuditActions } from "../audit/actions";
11
+ import type { SupportConfig } from "../config/config";
12
+ import type { SupportAttachment } from "../data/attachment";
13
+ import type { SupportCategories } from "../data/categories";
14
+ import type { SupportDatabase } from "../data/tables";
15
+ import { SupportClassificationError, SupportNotFoundError } from "../error/errors";
16
+ import { resolveSenderContext, resolveSubmitterAccount, resolveSubmitterContext } from "../link/sender";
17
+ import { sendReply } from "../reply/send";
18
+ import type { SupportReplySnippets } from "../reply/snippets";
19
+ import { repliesForCategory } from "../reply/snippets";
20
+ import { listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags } from "../store/threads";
21
+ import { decodeBase64 } from "../submission/encoding";
22
+ import { submitFeedback } from "../submission/submit";
23
+ import { latestInboundMessageId } from "../workflows/classify";
24
+ import type {
25
+ SupportAttachmentView,
26
+ SupportFlagsResponse,
27
+ SupportMyThreadResponse,
28
+ SupportMyThreadsResponse,
29
+ SupportReclassifiedResponse,
30
+ SupportReplySentResponse,
31
+ SupportReplyView,
32
+ SupportSubmissionResponse,
33
+ SupportThreadResponse,
34
+ SupportThreadsResponse,
35
+ SupportThreadView,
36
+ } from "./responses";
37
+ import type {
38
+ ArchiveThreadInput,
39
+ FlagsInput,
40
+ ListThreadsQuery,
41
+ MyThreadsQuery,
42
+ RepliesQuery,
43
+ ReplyInput,
44
+ SubmitFeedbackInput,
45
+ } from "./schemas";
46
+ import { listedThreadView, messageView, myMessageView, myThreadView, senderView, threadView } from "./views";
47
+
48
+ /**
49
+ * The support handlers — every one of them behind the `control-plane` gate, and every one of them
50
+ * taking already-validated values.
51
+ *
52
+ * Nothing here reads raw request input: the route line carries the contract and the handler takes
53
+ * typed arguments, which is what lets these be tested without a request at all.
54
+ */
55
+
56
+ /** Everything the handlers need, resolved per request. */
57
+ export interface HandlerDeps {
58
+ /** The support tables. */
59
+ db: SupportDatabase;
60
+ /** The raw `DB` binding — the linkage reads sibling capabilities' tables through it. */
61
+ d1: D1Database;
62
+ /** The resolved config. */
63
+ config: SupportConfig;
64
+ /** The effective taxonomy. */
65
+ categories: SupportCategories;
66
+ /** The effective canned-reply catalog. */
67
+ snippets: SupportReplySnippets;
68
+ /** Whether the FTS5 index is composed. */
69
+ fts: boolean;
70
+ /** The attachment bucket, when one is bound. */
71
+ bucket?: R2Bucket;
72
+ /** The object store attachment URLs are signed through, when credentials are available. */
73
+ store?: ObjectStore;
74
+ /** The email capability's env-bound `enqueue`, when email is composed. */
75
+ enqueue?: Parameters<typeof sendReply>[0]["enqueue"];
76
+ /** Start a classification for a message. Resolves to whether an instance actually started. */
77
+ dispatchClassify: (messageId: string) => Promise<boolean>;
78
+ /** The audit seam. */
79
+ emit: AuditEmit;
80
+ /** The request logger. */
81
+ log: Logger;
82
+ /** Generate a row id. */
83
+ newId: () => string;
84
+ /** Now. */
85
+ now: () => Date;
86
+ }
87
+
88
+ /** List the inbox. */
89
+ export async function listInbox(
90
+ deps: HandlerDeps,
91
+ query: ListThreadsQuery,
92
+ viewer: string,
93
+ ): Promise<SupportThreadsResponse> {
94
+ const page = await listThreads(
95
+ deps.db,
96
+ {
97
+ archived: query.archived === "true",
98
+ category: query.category,
99
+ declaredCategory: query.declaredCategory,
100
+ priority: query.priority,
101
+ sentiment: query.sentiment,
102
+ channel: query.channel,
103
+ inbox: query.inbox,
104
+ q: query.q,
105
+ cursor: query.cursor,
106
+ limit: query.limit,
107
+ },
108
+ { fts: deps.fts, viewer, now: deps.now(), log: deps.log },
109
+ );
110
+ return { threads: page.threads.map(listedThreadView), nextCursor: page.nextCursor };
111
+ }
112
+
113
+ /**
114
+ * Present an attachment.
115
+ *
116
+ * The `storageKey` is **never** in the response. It is server-derived and opaque precisely so a
117
+ * client cannot name an object or guess the one beside it, and echoing it back would hand that
118
+ * capability away for no benefit — a client cannot use a key for anything except guessing.
119
+ */
120
+ async function presentAttachments(
121
+ deps: HandlerDeps,
122
+ attachments: readonly SupportAttachment[],
123
+ ): Promise<SupportAttachmentView[]> {
124
+ return Promise.all(
125
+ attachments.map(async (attachment) => {
126
+ let url: string | null = null;
127
+ if (deps.store) {
128
+ try {
129
+ url = await attachmentUrl(deps.store, attachment.storageKey);
130
+ } catch (error) {
131
+ // A thread whose attachment cannot be signed is still a thread worth reading.
132
+ deps.log.warn("support attachment URL not signed", { attachmentId: attachment.id, error });
133
+ }
134
+ }
135
+ return {
136
+ id: attachment.id,
137
+ filename: attachment.filename,
138
+ contentType: attachment.contentType,
139
+ size: attachment.size,
140
+ sha256: attachment.sha256,
141
+ inline: attachment.inline,
142
+ url,
143
+ };
144
+ }),
145
+ );
146
+ }
147
+
148
+ /** Read one conversation in full. */
149
+ export async function readConversation(deps: HandlerDeps, threadId: string): Promise<SupportThreadResponse> {
150
+ const detail = await readThread(deps.db, threadId);
151
+ const [attachments, sender] = await Promise.all([
152
+ presentAttachments(deps, detail.attachments),
153
+ // A session-proven thread already holds the id its session established, so the context is resolved
154
+ // from that rather than re-derived from an address. Not a shortcut: `resolveSenderUserId` matches
155
+ // the `email` column exactly, so an account stored with capitals by some other route would resolve
156
+ // to nobody — turning the one link that *is* certain into the one the console shows as unknown.
157
+ detail.thread.accountLinkSource === "session" && detail.thread.userId
158
+ ? resolveSubmitterContext(deps.d1, detail.thread.userId, deps.now())
159
+ : resolveSenderContext(deps.d1, detail.thread.fromAddress, deps.now(), {
160
+ authenticated: detail.thread.senderAuthenticated,
161
+ }),
162
+ ]);
163
+
164
+ return {
165
+ thread: threadView(detail.thread),
166
+ messages: detail.messages.map(messageView),
167
+ attachments,
168
+ sender: senderView(sender),
169
+ replies: repliesForCategory(deps.snippets, detail.thread.category),
170
+ };
171
+ }
172
+
173
+ /** Mark a conversation done, or reopen it. */
174
+ export async function archiveConversation(
175
+ deps: HandlerDeps,
176
+ threadId: string,
177
+ input: ArchiveThreadInput,
178
+ viewer: string,
179
+ ): Promise<SupportThreadView> {
180
+ const thread = await setArchived(deps.db, threadId, input.archived, viewer, deps.now());
181
+
182
+ // The audit event is what makes "who marked this done" answerable, and it is exactly why the model
183
+ // gets away with having no ownership column.
184
+ await deps.emit({
185
+ action: input.archived ? SupportAuditActions.threadArchived : SupportAuditActions.threadUnarchived,
186
+ outcome: "success",
187
+ actorType: "control-plane",
188
+ actorId: viewer,
189
+ resourceType: "support_thread",
190
+ resourceId: threadId,
191
+ metadata: { archived: input.archived },
192
+ });
193
+
194
+ return threadView(thread);
195
+ }
196
+
197
+ /** Answer the customer. */
198
+ export async function replyToConversation(
199
+ deps: HandlerDeps,
200
+ threadId: string,
201
+ input: ReplyInput,
202
+ viewer: string,
203
+ ): Promise<SupportReplySentResponse> {
204
+ // `enqueue` is passed through absent rather than refused here. Whether a reply needs mail at all is
205
+ // `sendReply`'s decision — an `app` thread is answered in the app when there is nothing to send
206
+ // with — and a check at this call site would refuse the request before the one function that knows
207
+ // ever got to look.
208
+ return sendReply(
209
+ {
210
+ db: deps.db,
211
+ config: deps.config,
212
+ enqueue: deps.enqueue,
213
+ fts: deps.fts,
214
+ emit: deps.emit,
215
+ log: deps.log,
216
+ newId: deps.newId,
217
+ now: deps.now,
218
+ },
219
+ { threadId, body: input.body, agentName: input.agentName, viewer },
220
+ );
221
+ }
222
+
223
+ /** Re-run the classifier over a conversation's latest inbound message. */
224
+ export async function reclassifyConversation(
225
+ deps: HandlerDeps,
226
+ threadId: string,
227
+ viewer: string,
228
+ ): Promise<SupportReclassifiedResponse> {
229
+ const messageId = await latestInboundMessageId(deps.db, threadId);
230
+ if (!messageId) {
231
+ throw new SupportNotFoundError({
232
+ message: "That conversation has nothing to classify.",
233
+ detail: `thread ${threadId} has no inbound message`,
234
+ });
235
+ }
236
+
237
+ const dispatched = await deps.dispatchClassify(messageId);
238
+ if (!dispatched) {
239
+ // The Workflow binding is optional, so an unprovisioned project reaches here and `triggerWorkflow`
240
+ // degrades to a logged skip. Returning 200 anyway would tell a human "reclassified" while nothing
241
+ // ran — forever, and with a success event in the audit trail to corroborate it.
242
+ throw new SupportClassificationError({
243
+ message: "Classification is not available on this deployment.",
244
+ action: "Run `pithy support provision` to deploy the classification worker, then retry.",
245
+ detail: `the ${"SUPPORT_CLASSIFY"} workflow binding is absent, so no instance was started`,
246
+ });
247
+ }
248
+
249
+ // Audited because it rewrites a judgment about a customer's message, and because a run of them is
250
+ // what somebody fishing for a different answer looks like.
251
+ await deps.emit({
252
+ action: SupportAuditActions.threadReclassified,
253
+ outcome: "success",
254
+ actorType: "control-plane",
255
+ actorId: viewer,
256
+ resourceType: "support_thread",
257
+ resourceId: threadId,
258
+ metadata: { messageId },
259
+ });
260
+
261
+ return { messageId };
262
+ }
263
+
264
+ /** Set one viewer's private flags. */
265
+ export async function updateFlags(
266
+ deps: HandlerDeps,
267
+ threadId: string,
268
+ input: FlagsInput,
269
+ viewer: string,
270
+ ): Promise<SupportFlagsResponse> {
271
+ await setFlags(deps.db, {
272
+ threadId,
273
+ viewer,
274
+ read: input.read,
275
+ snoozedUntil:
276
+ input.snoozedUntil === undefined ? undefined : input.snoozedUntil ? new Date(input.snoozedUntil) : null,
277
+ newId: deps.newId,
278
+ now: deps.now(),
279
+ });
280
+ // Not audited, deliberately: a private read flag is not a security-relevant action, and writing one
281
+ // event per thread somebody scrolled past would bury the events that are.
282
+ return { ok: true };
283
+ }
284
+
285
+ /** The canned reply catalog. */
286
+ export function listReplies(deps: HandlerDeps, query: RepliesQuery): SupportReplyView[] {
287
+ return repliesForCategory(deps.snippets, query.category ?? "");
288
+ }
289
+
290
+ /**
291
+ * Take an in-app support request from a signed-in user.
292
+ *
293
+ * `userId` is a parameter rather than something read out of `input`, and that is the contract this
294
+ * whole channel rests on: it comes from `c.var.auth`, which `requireAuth()` filled before the route's
295
+ * validator ran. Nothing in the submitted body names an account, and the schema has no field that
296
+ * could.
297
+ *
298
+ * The configured length bounds are applied here rather than in the schema, for the reason
299
+ * `schemas.ts` states: a request schema is built once at module load and the adopter's numbers are
300
+ * resolved per project. This is config-backed resolution, which belongs in a handler — the same rule
301
+ * that keeps `board()` and `currency()` lookups out of a param schema.
302
+ */
303
+ export async function submitFeedbackRequest(
304
+ deps: HandlerDeps,
305
+ input: SubmitFeedbackInput,
306
+ userId: string,
307
+ ): Promise<SupportSubmissionResponse> {
308
+ const bounds = deps.config.submission;
309
+ if (input.subject.length > bounds.maxSubjectChars) {
310
+ throw new ValidationError({
311
+ message: `A subject may be at most ${bounds.maxSubjectChars} characters.`,
312
+ action: "Shorten the subject and try again.",
313
+ detail: `submitted subject is ${input.subject.length} characters, over the ${bounds.maxSubjectChars} bound`,
314
+ });
315
+ }
316
+ if (input.body.length > bounds.maxBodyChars) {
317
+ throw new ValidationError({
318
+ message: `A support request may be at most ${bounds.maxBodyChars} characters.`,
319
+ action: "Shorten the message and try again, or attach the detail as a file.",
320
+ detail: `submitted body is ${input.body.length} characters, over the ${bounds.maxBodyChars} bound`,
321
+ });
322
+ }
323
+ // **The count bound belongs here, ahead of the decode, and not only inside `submitFeedback`.**
324
+ // `decodeBase64` bounds each payload before it allocates, but nothing bounded *how many* of them a
325
+ // request could carry until the array had already been mapped — so the cheapest refusal in the whole
326
+ // path sat behind the most expensive step in it. `submitFeedback` checks the same bound again for
327
+ // any caller that reaches it directly.
328
+ if (input.attachments.length > bounds.attachments.maxCount) {
329
+ throw new ValidationError({
330
+ message: `A support request may carry at most ${bounds.attachments.maxCount} attachments.`,
331
+ action: `Send at most ${bounds.attachments.maxCount} files.`,
332
+ detail: `submission declared ${input.attachments.length} attachments, over the ${bounds.attachments.maxCount} bound`,
333
+ });
334
+ }
335
+
336
+ const outcome = await submitFeedback(
337
+ {
338
+ db: deps.db,
339
+ config: deps.config,
340
+ // The capability's own merged taxonomy, handed straight through. The declared-category check is
341
+ // `submitFeedback`'s rather than this handler's on purpose: a bound that decides what may be
342
+ // *stored* belongs with the write, so it holds for every caller and not only for HTTP.
343
+ categories: deps.categories,
344
+ bucket: deps.bucket,
345
+ fts: deps.fts,
346
+ resolveAccount: (id) => resolveSubmitterAccount(deps.d1, id),
347
+ dispatchClassify: deps.dispatchClassify,
348
+ emit: deps.emit,
349
+ log: deps.log,
350
+ newId: deps.newId,
351
+ now: deps.now,
352
+ },
353
+ {
354
+ userId,
355
+ subject: input.subject,
356
+ body: input.body,
357
+ declaredCategory: input.declaredCategory,
358
+ threadId: input.threadId,
359
+ context: input.context,
360
+ // Decoded here, at the transport boundary, so `submitFeedback` takes bytes and stays testable
361
+ // without a request — the same split `ingest.ts` has from the `email()` handler. The decode is
362
+ // bounded before it allocates: `atob` materialises the whole result, so a size check afterwards
363
+ // has already paid for the attack it was meant to refuse.
364
+ attachments: input.attachments.map((attachment) => ({
365
+ filename: attachment.filename,
366
+ contentType: attachment.contentType,
367
+ bytes: decodeBase64(attachment.data, { maxBytes: bounds.attachments.maxBytes }),
368
+ })),
369
+ },
370
+ );
371
+
372
+ return {
373
+ threadId: outcome.threadId,
374
+ messageId: outcome.messageId,
375
+ opened: outcome.newThread,
376
+ attachments: outcome.attachments,
377
+ };
378
+ }
379
+
380
+ /** List the caller's own in-app conversations. */
381
+ export async function listMyThreads(
382
+ deps: HandlerDeps,
383
+ query: MyThreadsQuery,
384
+ userId: string,
385
+ ): Promise<SupportMyThreadsResponse> {
386
+ const page = await listOwnThreads(deps.db, userId, { cursor: query.cursor, limit: query.limit });
387
+ return { threads: page.threads.map(myThreadView), nextCursor: page.nextCursor };
388
+ }
389
+
390
+ /**
391
+ * Read one of the caller's own conversations.
392
+ *
393
+ * The scoping is `readOwnThread`'s `where`, so a thread belonging to somebody else raises the same
394
+ * `support/not_found` as one that never existed. The projection is `myThreadView`/`myMessageView`,
395
+ * built from scratch rather than by omitting fields from the operator's — see `views.ts` for why that
396
+ * distinction is the security boundary rather than a style preference.
397
+ */
398
+ export async function readMyThread(
399
+ deps: HandlerDeps,
400
+ threadId: string,
401
+ userId: string,
402
+ ): Promise<SupportMyThreadResponse> {
403
+ const detail = await readOwnThread(deps.db, threadId, userId);
404
+ const attachments = await presentAttachments(deps, detail.attachments);
405
+ const byMessage = new Map<string, SupportAttachmentView[]>();
406
+ for (const [index, attachment] of detail.attachments.entries()) {
407
+ const view = attachments[index];
408
+ if (!view) continue;
409
+ const bucket = byMessage.get(attachment.messageId);
410
+ if (bucket) bucket.push(view);
411
+ else byMessage.set(attachment.messageId, [view]);
412
+ }
413
+
414
+ return {
415
+ thread: myThreadView(detail.thread),
416
+ messages: detail.messages.map((message) => myMessageView(message, byMessage.get(message.id) ?? [])),
417
+ };
418
+ }