@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,400 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { normalizeAddress, parseAddress } from "@pithy-sh/core/src/address/address";
5
+ import { z } from "zod";
6
+ import { SupportReplySnippet } from "../reply/snippets";
7
+
8
+ /**
9
+ * The support capability's configuration — the thin, user-owned surface in `pithy.config.ts`.
10
+ *
11
+ * The one field with no sensible default is `inboundAddresses`, and the reason is worth stating
12
+ * where an adopter will read it: **Cloudflare Email Routing takes over a zone's MX.** An address on
13
+ * the apex would move the adopter's real mail off their existing provider, so the address is theirs
14
+ * to choose deliberately — almost always on a subdomain (`support@help.theirdomain.com`) — and this
15
+ * capability will not guess one. Until it is set the inbox is inert: every message is ignored, and
16
+ * the handler says so once in the log rather than quietly storing mail from an address nobody
17
+ * configured.
18
+ */
19
+
20
+ /**
21
+ * The default Workers AI model for classification.
22
+ *
23
+ * An instruct model rather than a classifier: the taxonomy is federated, so the valid label set is
24
+ * not known until an adopter composes the capability, and a fine-tuned classifier cannot be given new
25
+ * classes in a config file. Small on purpose — this runs once per inbound message on the adopter's
26
+ * own bill, and the judgment is "which of these eight sentences fits", not a reasoning task.
27
+ */
28
+ export const DEFAULT_CLASSIFY_MODEL = "@cf/meta/llama-3.1-8b-instruct";
29
+
30
+ /** How the inbound message is classified, and by what. */
31
+ export const SupportAiConfig = z
32
+ .object({
33
+ enabled: z
34
+ .boolean()
35
+ .default(true)
36
+ .describe(
37
+ "Classify each inbound message with Workers AI. Off means every thread stays `uncategorized` and the inbox is chronological — still useful, and still free.",
38
+ ),
39
+ model: z
40
+ .string()
41
+ .default(DEFAULT_CLASSIFY_MODEL)
42
+ .describe(
43
+ "The Workers AI model classification runs on, over your own `AI` binding. Recorded on every classification, so a model change is visible in the data rather than inferred. Override to swap models with no code edits.",
44
+ ),
45
+ maxChars: z
46
+ .number()
47
+ .int()
48
+ .positive()
49
+ .default(4000)
50
+ .describe(
51
+ "How much of a message body the model sees. A support request states its point in the first paragraph; the rest is usually a quoted thread, and paying to embed somebody's own signature block twice a day is the kind of cost that creeps.",
52
+ ),
53
+ temperature: z
54
+ .number()
55
+ .min(0)
56
+ .max(2)
57
+ .default(0)
58
+ .describe(
59
+ "Sampling temperature. Zero by default: classification wants the same answer for the same message, and a reclassification pass that disagreed with itself would be unreadable.",
60
+ ),
61
+ })
62
+ .describe("Workers AI classification settings — the model, how much it reads, and how deterministic it is.");
63
+ export type SupportAiConfig = z.output<typeof SupportAiConfig>;
64
+
65
+ /** What happens to files that arrive attached to a message. */
66
+ export const SupportAttachmentsConfig = z
67
+ .object({
68
+ enabled: z
69
+ .boolean()
70
+ .default(true)
71
+ .describe(
72
+ "Store attachments in your own R2. Off drops them at ingest — the message is still stored and the attachment metadata is not, which is the right setting for an inbox that never needs a screenshot.",
73
+ ),
74
+ maxBytes: z
75
+ .number()
76
+ .int()
77
+ .positive()
78
+ .default(10 * 1024 * 1024)
79
+ .describe("The largest single attachment stored. A larger one is skipped; the message it arrived on is kept."),
80
+ maxCount: z
81
+ .number()
82
+ .int()
83
+ .positive()
84
+ .default(10)
85
+ .describe(
86
+ "How many attachments one message may contribute. Beyond this the extras are skipped — a bound on a number an attacker chooses.",
87
+ ),
88
+ retainRaw: z
89
+ .boolean()
90
+ .default(true)
91
+ .describe(
92
+ "Keep each message's raw MIME in R2, unchanged. Separate from `enabled` on purpose: the raw form is what makes the parse and the sanitize re-runnable, so a project that drops attachments still wants it, and folding the two together would silently turn off re-parsing for anyone who only meant to stop storing screenshots. Off means `rawKey` is null on every message and a sanitizer improvement can never be applied retroactively.",
93
+ ),
94
+ })
95
+ .describe("Attachment and raw-message handling — what bytes are kept, and the bounds on what one message may store.");
96
+ export type SupportAttachmentsConfig = z.output<typeof SupportAttachmentsConfig>;
97
+
98
+ /**
99
+ * The spam and volume guard.
100
+ *
101
+ * A public address is a public write endpoint into the adopter's D1, and that is the honest way to
102
+ * think about it. These bounds are what stop a mail flood from being a storage bill.
103
+ */
104
+ export const SupportGuardConfig = z
105
+ .object({
106
+ maxRawBytes: z
107
+ .number()
108
+ .int()
109
+ .positive()
110
+ .default(2 * 1024 * 1024)
111
+ .describe(
112
+ "The largest raw message accepted. Anything bigger is refused before it is parsed, because parsing is the expensive step and the size is known first.",
113
+ ),
114
+ maxPerSenderPerHour: z
115
+ .number()
116
+ .int()
117
+ .positive()
118
+ .default(20)
119
+ .describe(
120
+ "How many messages one address may land in an hour. The per-sender bound catches the common case: one broken auto-responder in a loop with your inbox.",
121
+ ),
122
+ maxPerHour: z
123
+ .number()
124
+ .int()
125
+ .positive()
126
+ .default(500)
127
+ .describe(
128
+ "How many messages the whole inbox may accept in an hour, across every sender. The bound that matters under a distributed flood, where no single address trips the per-sender limit.",
129
+ ),
130
+ trustAuthenticationResults: z
131
+ .boolean()
132
+ .default(false)
133
+ .describe(
134
+ "Treat the `Authentication-Results` header in the received message as a verdict you can rely on. **Off by default, and the reason is that under Cloudflare Email Routing there is no trust anchor for it.** Cloudflare evaluates DMARC — its `reply()` API requires a valid result — but does not reliably hand the verdict to a Worker (`Authentication-Results`, `Received`, and `DKIM-Signature` are all reported missing from `message.headers`), and a header a sender wrote is indistinguishable from one an MTA wrote once the MTA's copy is absent. Turn this on only if your receiving MTA both **stamps** the header and **strips** any inbound copy of it, and set `authservId` alongside. With it off the inbox still matches a sender to an account — it just never claims that match was verified, and withholds their billing history.",
135
+ ),
136
+ authservId: z
137
+ .string()
138
+ .min(1)
139
+ .optional()
140
+ .describe(
141
+ "The `authserv-id` your receiving MTA stamps its `Authentication-Results` header with — the field before the first `;`. Read only when `trustAuthenticationResults` is on. **This is a public hostname, not a credential**: an attacker who knows it can put it in a header they write, so it narrows a mistake rather than stopping an attacker. It is worth setting anyway, because with the wrong MTA in front of you it is the difference between reading somebody else's verdict and reading none.",
142
+ ),
143
+ archiveSpam: z
144
+ .boolean()
145
+ .default(true)
146
+ .describe(
147
+ "Archive a thread the moment the classifier calls it `spam`, so it never appears in the open inbox. **Archived, never deleted** — a classifier that silently destroyed mail would be untrustworthy the first time it was wrong, and it is wrong sometimes. The thread stays readable under the archived filter and unarchiving is one click, which is what makes this a filter rather than a bin. Off leaves spam in the open inbox for you to sort by hand.",
148
+ ),
149
+ })
150
+ .describe("Inbound bounds — size, per-sender rate, global rate, and what happens to spam.");
151
+ export type SupportGuardConfig = z.output<typeof SupportGuardConfig>;
152
+
153
+ /**
154
+ * What a signed-in user may attach to an in-app submission.
155
+ *
156
+ * **Stated here rather than inherited from `attachments`, and that is the point.** The mail path's
157
+ * bounds were written for parts of a MIME message somebody sent to a public address: they cap size and
158
+ * count, and they say nothing at all about type, because there is no useful type restriction on mail —
159
+ * refusing a `.docx` a customer attached to a bug report would lose the report. A direct upload from a
160
+ * browser is a different surface with a different answer: the client is authenticated but untrusted,
161
+ * the useful payload is a screenshot, and an allowlist is both possible and worth having. Inheriting
162
+ * the email numbers would have meant a 10 MB any-type upload endpoint arriving as a side effect of a
163
+ * setting an adopter tuned for their inbox.
164
+ *
165
+ * Bytes are stored exactly as the mail path stores them — server-derived opaque key,
166
+ * `application/octet-stream` on the object whatever was declared — so nothing here re-opens the
167
+ * stored-XSS question `attachment/store.ts` already closed.
168
+ */
169
+ /**
170
+ * The hard ceiling on how many attachments one submission may declare, whatever
171
+ * `submission.attachments.maxCount` is set to.
172
+ *
173
+ * On the schema as well as in the handler because the two refuse at different moments and the earlier
174
+ * one is free: the validator rejects a thousand-element array before a handler runs, without reading
175
+ * the resolved config. The configured bound is what an adopter tunes; this is what is true regardless.
176
+ */
177
+ export const MAX_SUBMISSION_ATTACHMENTS = 10;
178
+
179
+ export const SupportSubmissionAttachmentsConfig = z
180
+ .object({
181
+ enabled: z
182
+ .boolean()
183
+ .default(true)
184
+ .describe(
185
+ "Accept attachments on an in-app submission. Off refuses any, which is the right setting for a project that wants feedback but no upload surface at all — the report is still stored, without the file.",
186
+ ),
187
+ maxBytes: z
188
+ .number()
189
+ .int()
190
+ .positive()
191
+ .default(5 * 1024 * 1024)
192
+ .describe(
193
+ "The largest single attachment accepted, measured on the **decoded** bytes. Deliberately smaller than the mail path's bound: a phone screenshot is under a megabyte, and this is a limit on what a signed-in client may push into your R2 in one request rather than on what a stranger's mail client happened to send.",
194
+ ),
195
+ maxCount: z
196
+ .number()
197
+ .int()
198
+ .positive()
199
+ .max(MAX_SUBMISSION_ATTACHMENTS)
200
+ .default(3)
201
+ .describe(
202
+ "How many attachments one submission may carry. Low on purpose — a bug report is a screenshot or two, and every extra slot is another `maxBytes` a client may spend per request. Checked before any payload is decoded, so the cheapest refusal in the path does not sit behind its most expensive step.",
203
+ ),
204
+ allowedContentTypes: z
205
+ .array(z.string().min(3).describe("One exact MIME type, lowercased, e.g. `image/png`. No wildcards."))
206
+ .default(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"])
207
+ .describe(
208
+ "The exact types a submission may carry — **an allowlist, not a denylist**, so a type nobody considered is refused rather than accepted. The default is what a bug report is actually made of: screenshots, a PDF, a log paste. The declared type is recorded and never honored when the bytes are served, so this bounds what lands in your bucket rather than standing in for that protection.",
209
+ ),
210
+ })
211
+ .describe(
212
+ "Bounds on a direct upload from a signed-in client — size, count, and an allowlist of types. Stated explicitly rather than inherited from the mail path, which answers a different question.",
213
+ );
214
+ export type SupportSubmissionAttachmentsConfig = z.output<typeof SupportSubmissionAttachmentsConfig>;
215
+
216
+ /**
217
+ * The ceilings a configured submission bound may not exceed — the route's own schema is written to
218
+ * these, so a payload beyond one is refused by the validator before a handler runs.
219
+ *
220
+ * Two layers, and both earn their place. The **config** bound is the adopter's number and produces the
221
+ * message their user reads; the **schema** ceiling is what stops a megabyte of text reaching a handler
222
+ * at all, and it cannot be config-derived because a request schema is built once at module load while
223
+ * config is resolved per project. Bounding the config field by the same constant is what keeps them
224
+ * from disagreeing: a setting above the ceiling would be a limit the route silently refused to honor.
225
+ */
226
+ export const MAX_SUBMISSION_SUBJECT_CHARS = 500;
227
+ /** The hard ceiling on a submission body, whatever `submission.maxBodyChars` is set to. */
228
+ export const MAX_SUBMISSION_BODY_CHARS = 100_000;
229
+
230
+ /**
231
+ * The in-app submission channel: a signed-in user of the adopter's own app opening a support thread
232
+ * without leaving it.
233
+ *
234
+ * **The hardest problem in the mail path does not exist here.** `inbound/authenticity.ts` spends two
235
+ * hundred lines earning a customer link from a `From:` header that anybody can write; a submission
236
+ * arrives on a request `requireAuth()` already proved, so the account is the identity rather than a
237
+ * guess about it. Everything below is therefore a bound on *volume and shape*, never on identity.
238
+ *
239
+ * Bounded separately from `guard` for the same reason it is counted separately: that block exists
240
+ * because a public address is a public write endpoint, and this surface is neither public nor
241
+ * anonymous. Abuse here is attributable to an account and revocable with it, which is a materially
242
+ * different threat model and deserves its own numbers rather than a share of somebody else's.
243
+ */
244
+
245
+ export const SupportSubmissionConfig = z
246
+ .object({
247
+ enabled: z
248
+ .boolean()
249
+ .default(true)
250
+ .describe(
251
+ "Serve the in-app submission routes. On by default because the capability is inert without an address either way, and a project that composes support almost always wants its signed-in users able to write in. Off removes the routes entirely — they answer 404, not 403, because a route that is not served has nothing to say about who was asking.",
252
+ ),
253
+ maxSubjectChars: z
254
+ .number()
255
+ .int()
256
+ .positive()
257
+ .max(MAX_SUBMISSION_SUBJECT_CHARS)
258
+ .default(200)
259
+ .describe(
260
+ "The longest subject a submission may carry. It becomes the thread's name in the inbox and the subject line of every reply, so it is bounded to something that renders in a list.",
261
+ ),
262
+ maxBodyChars: z
263
+ .number()
264
+ .int()
265
+ .positive()
266
+ .max(MAX_SUBMISSION_BODY_CHARS)
267
+ .default(10_000)
268
+ .describe(
269
+ "The longest report body accepted. Generous — somebody describing a bug properly is exactly who this channel is for — but bounded, because it is a string a client chose and it lands in a D1 row and a model prompt.",
270
+ ),
271
+ maxPerAccountPerHour: z
272
+ .number()
273
+ .int()
274
+ .positive()
275
+ .default(10)
276
+ .describe(
277
+ "How many submissions one account may land in an hour. **Session-bound abuse is attributable and revocable in a way mail is not**, which is why this number is smaller than the mail path's and still safe: a real person filing a genuine report does not file eleven, and an account that does is one an adopter can act on. Counted from the messages table over the same sliding hour the mail guard uses, and counted **only against app submissions** — a busy inbox must never stop a user reporting the outage.",
278
+ ),
279
+ attachments: SupportSubmissionAttachmentsConfig.prefault({}).describe(
280
+ "What a signed-in client may upload with a report.",
281
+ ),
282
+ })
283
+ .describe(
284
+ "The in-app submission channel — whether it is served, what one submission may contain, and how many one account may send.",
285
+ );
286
+ export type SupportSubmissionConfig = z.output<typeof SupportSubmissionConfig>;
287
+
288
+ /** How a reply leaves the building. */
289
+ export const SupportReplyConfig = z
290
+ .object({
291
+ enabled: z
292
+ .boolean()
293
+ .default(true)
294
+ .describe(
295
+ "Allow replies from the control-plane route. Off makes the inbox read-only, which is a reasonable setting for a project that answers support from its own mail client.",
296
+ ),
297
+ replyToAddress: z
298
+ .string()
299
+ .optional()
300
+ // Normalized for the same reason, and it matters more here: this address is what a customer's
301
+ // answer comes back to, so a casing mismatch against `inboundAddresses` ends the conversation
302
+ // silently rather than loudly.
303
+ .transform((address) =>
304
+ address === undefined ? undefined : (parseAddress(address) ?? normalizeAddress(address)),
305
+ )
306
+ .describe(
307
+ "The `Reply-To` a reply carries — the address the customer's answer comes back to, which must be one of `inboundAddresses` or the conversation ends there. Defaults to the inbox address the thread arrived on, which is almost always what you want.",
308
+ ),
309
+ deliverInApp: z
310
+ .boolean()
311
+ .default(false)
312
+ .describe(
313
+ "Answer an `app` thread by storing the reply for the submitter to read in the app, instead of mailing it. **A choice, not a fallback.** Turning on Email Routing takes over the zone's MX, so a project already running mail on that domain cannot receive support replies without consequences everywhere else on it — and for a submitter who is a signed-in user sitting on the screen they wrote from, the answer is better placed there anyway. In-app delivery also happens automatically when there is no address to reply from and no email capability to send with, whatever this says; the setting is what makes it reachable for a project whose mail works fine. It never applies to an `email` thread: a mail thread's sender has no read-back, so a stored answer there is one nobody would ever see.",
314
+ ),
315
+ snippets: z
316
+ .record(z.string(), SupportReplySnippet)
317
+ .default({})
318
+ .describe(
319
+ "Your own canned replies, merged over the ones Pithy ships and winning on a key collision. These are *starting points a human picks and edits* in the dashboard, not automatic replies — nothing here is ever sent without somebody pressing send. Author them with `defineSupportReplies` so a malformed one fails where it is written.",
320
+ ),
321
+ })
322
+ .describe(
323
+ "Reply settings — whether replies are allowed, where the answer goes, and what to offer as a starting point.",
324
+ );
325
+ export type SupportReplyConfig = z.output<typeof SupportReplyConfig>;
326
+
327
+ /**
328
+ * How text search is answered.
329
+ *
330
+ * The one setting here has a cost an adopter must opt into knowingly, which is why it is a setting
331
+ * at all rather than a decision this package made for them.
332
+ */
333
+ export const SupportSearchConfig = z
334
+ .object({
335
+ fts: z
336
+ .boolean()
337
+ .default(false)
338
+ .describe(
339
+ "Build a SQLite FTS5 index over message subjects and bodies. **Off by default, and the reason is not performance.** `wrangler d1 export` refuses to dump any database containing an FTS5 virtual table — it fails outright rather than skipping the table — and the check runs server-side across the whole database before `--table` filtering, so turning this on takes your **entire app database's** export with it. A failed attempt has also been reported to leave the database inaccessible until it clears. If you do not use `wrangler d1 export`, none of that reaches you and FTS5 is the better search. Safe to toggle either way: the index is derived rather than migrated, so `pithy support provision` creates or drops it to match — and until you re-provision, search falls back to the `LIKE` scan rather than failing.",
340
+ ),
341
+ })
342
+ .describe("Text search settings — the `LIKE` scan by default, FTS5 as a deliberate opt-in.");
343
+ export type SupportSearchConfig = z.output<typeof SupportSearchConfig>;
344
+
345
+ /** The full support configuration. */
346
+ export const SupportConfig = z
347
+ .object({
348
+ inboundAddresses: z
349
+ .array(
350
+ z
351
+ .string()
352
+ .min(3)
353
+ .describe("One address this inbox accepts mail for, e.g. `support@help.example.com`.")
354
+ // Normalized at parse time, because every comparison downstream is against a normalized
355
+ // envelope recipient. Without this, `Support@Help.Example.com` in an adopter's config
356
+ // matches nothing, every message returns `not_addressed`, and the inbox is *silently*
357
+ // inert — no error, no warning, just no mail ever appearing.
358
+ .transform((address) => parseAddress(address) ?? normalizeAddress(address)),
359
+ )
360
+ .default([])
361
+ .describe(
362
+ "The addresses this capability claims. Every capability's `email()` handler sees every message the Worker receives, so this list is how support tells its mail apart from the bounce handler's — and it is a claim, not a route: the routing rule that delivers to this Worker is created by `pithy support provision`. **Use a subdomain, never your apex**: Email Routing takes over the zone's MX, and enabling it on the apex moves your real mail off your provider. Empty means the inbox is inert.",
363
+ ),
364
+ categories: z
365
+ .record(z.string(), z.string())
366
+ .default({})
367
+ .describe(
368
+ "Your own categories, merged over the eight Pithy ships and winning on a key collision. A key is `snake_case`; the value is the instruction a model reads when deciding whether this is the one, and it lands in the prompt verbatim. Author it with `defineSupportCategories` so a typo fails where it is written.",
369
+ ),
370
+ ai: SupportAiConfig.prefault({}).describe("Classification settings."),
371
+ attachments: SupportAttachmentsConfig.prefault({}).describe("Attachment handling."),
372
+ guard: SupportGuardConfig.prefault({}).describe("Inbound mail size and rate bounds."),
373
+ submission: SupportSubmissionConfig.prefault({}).describe("The in-app submission channel."),
374
+ reply: SupportReplyConfig.prefault({}).describe("Reply settings."),
375
+ search: SupportSearchConfig.prefault({}).describe("How text search is answered."),
376
+ })
377
+ .describe(
378
+ "Configuration for the support capability — which addresses it claims, and how it classifies and bounds them.",
379
+ );
380
+ export type SupportConfig = z.output<typeof SupportConfig>;
381
+ export type SupportConfigInput = z.input<typeof SupportConfig>;
382
+
383
+ /**
384
+ * Would this configuration ever put a byte in `SUPPORT_BUCKET`?
385
+ *
386
+ * **The one place the question is answered, because three independent settings answer it and each has
387
+ * its own writer.** `attachments.enabled` gates stored mail attachments (`inbound/ingest.ts:200`),
388
+ * `attachments.retainRaw` gates the raw MIME copy (`inbound/ingest.ts:346`), and
389
+ * `submission.attachments.enabled` gates an in-app submission's files (`submission/submit.ts:268`).
390
+ * All three default `true`, so the ordinary project is unaffected by this predicate existing.
391
+ *
392
+ * Two callers, and they must agree: `capability.ts` declares the binding only when this is true, and
393
+ * `pithy support provision` creates the bucket only when this is true. They disagreed before #440 —
394
+ * the provisioner asked about `enabled` and `retainRaw` and never about submissions, so a project that
395
+ * wanted uploads but no mail attachments got a binding pointing at a bucket nothing had created, and
396
+ * every submitted file was dropped with a warning. One predicate is what makes that unrepresentable.
397
+ */
398
+ export function supportNeedsBucket(config: SupportConfig): boolean {
399
+ return config.attachments.enabled || config.attachments.retainRaw || config.submission.attachments.enabled;
400
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * One attachment in `pithy_support_attachments` — the metadata row for bytes that live in R2.
9
+ *
10
+ * **The bytes are never proxied.** A dashboard gets a short-lived signed URL and fetches from R2
11
+ * directly, because proxying would put a Pithy-operated surface in the data path, which is the exact
12
+ * thing principle 1 exists to prevent. The key is server-derived and opaque, so holding a thread id
13
+ * never lets a caller name or guess an object.
14
+ *
15
+ * `contentType` and `filename` are recorded **as the sender declared them** and are never trusted on
16
+ * the way out. This is the most attacker-controlled field pair in the capability: a message can claim
17
+ * `text/html` for a script, or a filename with a path separator or a right-to-left override in it.
18
+ * Whatever serves these bytes re-derives its own `Content-Type` and `Content-Disposition`, the same
19
+ * rule `@pithy-sh/storage` applies to uploads.
20
+ */
21
+ export const SupportAttachment = z
22
+ .object({
23
+ id: z.string().describe("UUID primary key."),
24
+ messageId: z.string().describe("The `pithy_support_messages.id` this attachment arrived on."),
25
+ threadId: z
26
+ .string()
27
+ .describe(
28
+ "The `pithy_support_threads.id`, denormalized from the message. Carried so the thread view lists every attachment in one indexed read instead of a join across the thread's messages.",
29
+ ),
30
+ filename: z
31
+ .string()
32
+ .describe(
33
+ "The filename as the sender declared it, after stripping path separators and control characters. Display only — it never becomes part of a storage key.",
34
+ ),
35
+ contentType: z
36
+ .string()
37
+ .describe("The MIME type as the sender declared it. Recorded, never honored — a serve path derives its own."),
38
+ size: z
39
+ .number()
40
+ .int()
41
+ .nonnegative()
42
+ .describe("The attachment's size in bytes, measured from the decoded bytes rather than claimed."),
43
+ sha256: z
44
+ .string()
45
+ .describe(
46
+ "Lowercase hex SHA-256 of the decoded bytes. Makes a re-delivered attachment recognizable and gives an operator something to check a download against.",
47
+ ),
48
+ storageKey: z
49
+ .string()
50
+ .describe(
51
+ "The R2 object key, server-derived and opaque (`support/<thread>/<uuid>`). Never sent to a client — a signed URL is.",
52
+ ),
53
+ contentId: z
54
+ .string()
55
+ .nullish()
56
+ .describe(
57
+ "The `Content-ID`, when the part carried one. What an inline image in the HTML body refers to by `cid:`.",
58
+ ),
59
+ inline: SQLiteBoolean.describe(
60
+ "Whether the part was `Content-Disposition: inline` — a signature image rather than the document somebody meant to send. A dashboard hides these by default.",
61
+ ),
62
+ createdAt: SQLiteDate.describe("When the attachment row was written."),
63
+ })
64
+ .describe("One attachment in `pithy_support_attachments` — metadata for bytes stored in the adopter's own R2.");
65
+ export type SupportAttachment = z.output<typeof SupportAttachment>;
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The one payments subject kind support reads.
6
+ *
7
+ * `@pithy-sh/payments` keys a purchase on a **subject pair** — a user or an organization, plus an id —
8
+ * and which of the two a project uses is its `billingSubject` config. Support cannot honor that choice.
9
+ * It starts from a `From:` header, resolves it to a *person*, and does its billing lookup at thread-read
10
+ * time, where there is no Hono `Context` to hand the adopter's subject resolver: the seam's whole job is
11
+ * to answer "which organization is *this caller* acting for", and a support thread has no caller. So the
12
+ * lookups in `link/sender.ts` filter on `user`, and `http/responses.ts` declares that on every response.
13
+ *
14
+ * ## Why this is its own module and not a constant in either of them
15
+ *
16
+ * It was `link/sender.ts`'s, and #418 imported it from `http/responses.ts` to build `SenderBillingScope`
17
+ * off it — one line, one string, and the correct instinct: the value the `WHERE` clause filters on and
18
+ * the value a console is told must be the same value. What came with it was `sender.ts`'s import list,
19
+ * which is the server data layer: `@pithy-sh/auth`'s Kysely builder, `@pithy-sh/payments`' table map,
20
+ * D1. A response schema is a module a browser imports — that is the whole reason §HTTP makes a response
21
+ * a Zod object rather than an interface — and the adopter's client program stopped compiling on
22
+ * `Cannot find name 'D1Database'` in a file it had never heard of (#419).
23
+ *
24
+ * So the constant lives where neither half owns it, and imports nothing. Both halves name it, neither
25
+ * reaches the other, and widening it is still the single edit it was: one literal, breaking every
26
+ * consumer that has not decided what an organization's panel should render, which is the correct amount
27
+ * of friction for that change.
28
+ *
29
+ * `tooling/browser-scopes` holds the rule this module is an instance of: a module a browser may import
30
+ * reaches no module that needs the Workers runtime.
31
+ */
32
+ export const SUPPORT_BILLING_SCOPE = "user" as const;
@@ -0,0 +1,117 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { SupportInvalidCategoryError } from "../error/errors";
6
+ import { UNCATEGORIZED } from "./enums";
7
+
8
+ /**
9
+ * The support taxonomy — federated, the way audit actions and migration namespaces already are.
10
+ *
11
+ * Pithy ships eight categories chosen to map to **action rather than topic**, because an inbox sorted
12
+ * by topic is a filing cabinet and an inbox sorted by action is a work queue. An adopter adds their
13
+ * own with {@link defineSupportCategories} — a game adds `tournament_dispute`, a marketplace adds
14
+ * `seller_payout` — and core never learns about it.
15
+ *
16
+ * A category is a `snake_case` key plus the sentence that tells a model when to pick it. The
17
+ * description is not documentation for humans: it is **prompt input**, interpolated into the
18
+ * classification prompt verbatim, which is why a bad one shows up as bad classifications rather than
19
+ * as a bad doc. Write it as an instruction.
20
+ */
21
+
22
+ /** The shape a category key must take: lowercase `snake_case`, so it is safe in a prompt and a filter. */
23
+ const CATEGORY_KEY = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
24
+
25
+ /** How long a category description may be. Bounded because every one of them lands in every prompt. */
26
+ const MAX_DESCRIPTION = 200;
27
+
28
+ /** A category map: key → the instruction a model reads when deciding whether this is the one. */
29
+ export type SupportCategories = Record<string, string>;
30
+
31
+ /**
32
+ * The eight Pithy ships. Each earns its slot by naming something a developer would *do differently*,
33
+ * not something a message is *about*.
34
+ */
35
+ export const DEFAULT_SUPPORT_CATEGORIES = {
36
+ billing:
37
+ "A question or complaint about money: a refund, a double charge, a failed renewal, or 'I paid and did not get it'.",
38
+ account_access:
39
+ "The sender cannot get in: a magic link that never arrived, a sign-in that fails, a lost or changed email address.",
40
+ bug_report: "The sender is reporting that something is broken, wrong, or behaving differently than it should.",
41
+ feature_request: "The sender is asking for something that does not exist yet, or for an existing thing to change.",
42
+ abuse_report: "The sender is reporting another user's behavior — harassment, cheating, spam, or impersonation.",
43
+ privacy_request:
44
+ "The sender is exercising a data right: deletion, export, access, or correction of their personal data.",
45
+ spam: "Unsolicited bulk mail, marketing, or an obvious phishing or scam attempt. Not a real support request.",
46
+ [UNCATEGORIZED]:
47
+ "Use this when no other category clearly applies, or when the message is too ambiguous to place with confidence.",
48
+ } as const satisfies SupportCategories;
49
+
50
+ /**
51
+ * Declare an adopter's own categories, validated at author time.
52
+ *
53
+ * The same shape `defineAuditActions` uses, for the same reason: a typo in a key is a filter that
54
+ * silently matches nothing, and it should fail where the constant is written rather than the first
55
+ * time a model happens to return it. Returns the same object, typed, so `MyCategories.tournament_dispute`
56
+ * narrows to a literal.
57
+ *
58
+ * ```ts
59
+ * export const GameCategories = defineSupportCategories({
60
+ * tournament_dispute: "The sender is contesting a tournament result, a disqualification, or a prize.",
61
+ * });
62
+ * ```
63
+ */
64
+ export function defineSupportCategories<const T extends SupportCategories>(categories: T): T {
65
+ for (const [key, description] of Object.entries(categories)) {
66
+ if (!CATEGORY_KEY.test(key)) {
67
+ throw new SupportInvalidCategoryError({
68
+ message: `Invalid support category key: ${key}`,
69
+ action: "Use a lowercase snake_case key, e.g. `tournament_dispute`.",
70
+ detail: `key ${JSON.stringify(key)} does not match ${CATEGORY_KEY}`,
71
+ });
72
+ }
73
+ if (description.trim().length === 0 || description.length > MAX_DESCRIPTION) {
74
+ throw new SupportInvalidCategoryError({
75
+ message: `Invalid description for support category "${key}".`,
76
+ action: `Write one instructional sentence, at most ${MAX_DESCRIPTION} characters, telling a model when to pick it.`,
77
+ detail: `description length ${description.length} is outside 1..${MAX_DESCRIPTION}`,
78
+ });
79
+ }
80
+ }
81
+ return categories;
82
+ }
83
+
84
+ /**
85
+ * The taxonomy a capability actually classifies against: the defaults, plus the adopter's, with the
86
+ * adopter's winning on a key collision so a shipped description can be reworded for their product.
87
+ */
88
+ export function resolveCategories(extra: SupportCategories = {}): SupportCategories {
89
+ return { ...DEFAULT_SUPPORT_CATEGORIES, ...defineSupportCategories(extra) };
90
+ }
91
+
92
+ /**
93
+ * The Zod enum a model's answer is checked against.
94
+ *
95
+ * Built from the *effective* taxonomy at capability-construction time rather than declared as a
96
+ * literal, because the whole point of federation is that the valid set is not known until an adopter
97
+ * composes the capability. This is the schema `classifyMessage` parses against before anything is
98
+ * written — the single place an invented label is caught.
99
+ */
100
+ export function categoryEnum(categories: SupportCategories): z.ZodEnum<Record<string, string>> {
101
+ const keys = Object.keys(categories);
102
+ return z
103
+ .enum(Object.fromEntries(keys.map((key) => [key, key])))
104
+ .describe("One of this project's effective support categories — the shipped defaults plus any the adopter added.");
105
+ }
106
+
107
+ /**
108
+ * The taxonomy rendered for a prompt: one `key — instruction` line per category, in declaration order.
109
+ *
110
+ * Deliberately not JSON. A bulleted list is what these models are trained to follow, and the format
111
+ * is stable enough that a category added by an adopter reads exactly like a shipped one.
112
+ */
113
+ export function describeCategories(categories: SupportCategories): string {
114
+ return Object.entries(categories)
115
+ .map(([key, description]) => `- ${key}: ${description}`)
116
+ .join("\n");
117
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { SupportPriority, SupportSentiment } from "./enums";
7
+
8
+ /**
9
+ * One classification in `pithy_support_classifications` — append-only, one row per model run.
10
+ *
11
+ * The thread carries the *current* answer because that is what the inbox query needs; this carries
12
+ * *every* answer, because a reclassification pass after a model upgrade has to be able to say which
13
+ * rows came from which model and what changed. Append-only is what makes that a fact rather than a
14
+ * reconstruction: nothing here is ever updated, and a wrong classification is superseded, not fixed.
15
+ *
16
+ * Confidence is the model's own self-report, which is worth exactly what a language model's
17
+ * self-report is worth — useful for sorting a review queue, never for a gate.
18
+ */
19
+ export const SupportClassification = z
20
+ .object({
21
+ id: z.string().describe("UUID primary key."),
22
+ threadId: z.string().describe("The `pithy_support_threads.id` this classification was written against."),
23
+ messageId: z
24
+ .string()
25
+ .describe(
26
+ "The `pithy_support_messages.id` the model actually read. Recorded because a thread's classification is a judgment about one message, and knowing which one is what makes a disagreement between two runs legible.",
27
+ ),
28
+ category: z
29
+ .string()
30
+ .describe(
31
+ "The category key the model chose, already validated against the effective taxonomy — an out-of-taxonomy answer never reaches this table, it becomes `uncategorized` first.",
32
+ ),
33
+ priority: SupportPriority.describe("The priority the model assigned."),
34
+ sentiment: SupportSentiment.describe("The sentiment the model assigned."),
35
+ confidence: z
36
+ .number()
37
+ .min(0)
38
+ .max(1)
39
+ .describe(
40
+ "The model's self-reported confidence, 0..1. Bounded here rather than by a CHECK constraint: the schema is the table definition, and a model's answer is the one value in this table that originates outside our own code, so the bound belongs on the way in.",
41
+ ),
42
+ model: z
43
+ .string()
44
+ .describe(
45
+ "The Workers AI model id that produced this row. The column the whole table exists for — without it a model upgrade is a silent rewrite of history.",
46
+ ),
47
+ createdAt: SQLiteDate.describe("When this classification was written."),
48
+ })
49
+ .describe("One append-only classification in `pithy_support_classifications` — what a model said, and which model.");
50
+ export type SupportClassification = z.output<typeof SupportClassification>;