@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,293 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
+ import { noopEmit } from "@pithy-sh/core/src/audit/recorder";
6
+ import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
7
+ import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
8
+ import type { DatabaseSpecMap } from "@pithy-sh/core/src/data/databases";
9
+ import type { KvNamespaceSpecMap } from "@pithy-sh/core/src/kv/namespaces";
10
+ import { workflowBindings } from "@pithy-sh/core/src/workflow/bindings";
11
+ import type { EmailCapability } from "@pithy-sh/email/src/capability";
12
+ import { isEmailCapability } from "@pithy-sh/email/src/capability";
13
+ import type { Migration } from "kysely/migration";
14
+ import type { SupportClientProjection } from "./client/projection";
15
+ import {
16
+ type SupportConfig,
17
+ type SupportConfigInput,
18
+ SupportConfig as SupportConfigSchema,
19
+ supportNeedsBucket,
20
+ } from "./config/config";
21
+ import { resolveCategories, type SupportCategories } from "./data/categories";
22
+ import { supportTables } from "./data/tables";
23
+ import { makeResolveDeps } from "./http/resolve";
24
+ import { registerSupportRoutes } from "./http/routes";
25
+ import { supportAdminRoutes } from "./http/scopes";
26
+ import { createSupportEmailHandler } from "./inbound/handler";
27
+ import { support_0001_threads } from "./migrations/0001_threads";
28
+ import { resolveReplies, type SupportReplySnippets } from "./reply/snippets";
29
+ import { supportSecretsRegistry } from "./secret/registry";
30
+ import { supportExampleSeed } from "./seeds/example";
31
+ import { PACKAGE_VERSION } from "./version.generated";
32
+ import { supportWorkflows } from "./workflows/specs";
33
+
34
+ /**
35
+ * Sort order of the support migrations within the app database, relative to other capabilities.
36
+ * Unique per database; the registry composes keys like `1200_support_0001_threads`.
37
+ *
38
+ * Taken from `NEXT_FREE_ORDER` in `packages/cli/src/migrations/orders.test.ts` at implementation
39
+ * time, as the procedure in that file requires — never picked by grepping, which is how two pairs of
40
+ * capabilities once collided in a range that was 99% empty. Stable forever: renumbering would rename
41
+ * the composed keys, and Kysely would then read applied migrations as unapplied and re-run them.
42
+ */
43
+ export const SUPPORT_MIGRATION_ORDER = 1200;
44
+
45
+ /** The options `support()` accepts: the config, plus the path its admin routes mount under. */
46
+ export type SupportOptions = SupportConfigInput & {
47
+ /** The path the control-plane routes mount under. Defaults to `/support`. */
48
+ basePath?: string;
49
+ };
50
+
51
+ /**
52
+ * What the capability resolves at construction and at compose time, shared with the routes and the
53
+ * inbound handler. Mutable in exactly one direction: `compose` fills the optional seams once, at
54
+ * startup, and nothing writes to it afterwards.
55
+ */
56
+ export interface SupportWiring {
57
+ /** The resolved config. */
58
+ config: SupportConfig;
59
+ /** The effective taxonomy. */
60
+ categories: SupportCategories;
61
+ /** The effective canned-reply catalog. */
62
+ snippets: SupportReplySnippets;
63
+ /**
64
+ * The email capability's bound enqueue seam — how a reply is delivered. Set by `compose` when
65
+ * `email()` is composed, and left undefined otherwise, which is a supported deployment: an inbox
66
+ * that receives and classifies but cannot answer is still worth having, and the reply route says
67
+ * so with `support/reply_failed` rather than the capability refusing to start.
68
+ */
69
+ enqueueEmail: EmailCapability["enqueue"] | undefined;
70
+ /**
71
+ * The audit seam the inbound handler emits through.
72
+ *
73
+ * Held here rather than read from a request because an `email()` handler has no request — there is
74
+ * no `c.var.emit` to reach for. `compose` cannot supply the real recorder either (that is
75
+ * per-request state), so this stays the no-op and inbound rejections are recorded in the log. The
76
+ * decisions that most need auditing — archive, reply, reclassify — all happen on a request and go
77
+ * through the real seam.
78
+ */
79
+ emit: AuditEmit;
80
+ }
81
+
82
+ /**
83
+ * What a browser may know about this project's support — the `virtual:pithy/support` module.
84
+ *
85
+ * **One route on this capability is called by a browser**, and that is the whole reason this exists:
86
+ * `POST {basePath}/feedback`, with the two reads beside it. Everything else answers to a control-plane
87
+ * credential a management client holds, and a management client reads the manifest. So the list is
88
+ * `basePath` and the bounds the compose form must hold somebody to before it lets them press Send —
89
+ * a form that stops at 200 characters because the handler refuses at 200 characters is a better form
90
+ * than one that finds out afterwards.
91
+ *
92
+ * **`{ enabled: false }` when the submission channel is off**, and that is not the same statement as
93
+ * "support is not composed" only in a sentence nobody writes. `registerSupportRoutes` does not mount
94
+ * the feedback routes when `submission.enabled` is false — they answer 404, not 403 — so a browser
95
+ * then has nothing on this capability to call and no use for a path. A screen branches on `enabled`
96
+ * rather than guarding, exactly as it does for a payments catalog with nothing in it.
97
+ *
98
+ * **What deliberately does not cross, and why each one was considered.**
99
+ * - **The taxonomy.** A category's value is the instruction a model reads and it lands in the prompt
100
+ * verbatim — prompt input written for a classifier, not copy for a chooser. An adopter's UI wants
101
+ * its own words either way, and the descriptions are the closest thing here to authored internals.
102
+ * - **`inboundAddresses` and `reply.replyToAddress`.** The inbox's own mail addresses. A browser never
103
+ * writes to them, and publishing an address into every bundle is a favor to nobody but a spammer.
104
+ * - **`reply.snippets`.** Canned copy an operator picks and edits in a dashboard. Staff-side, and the
105
+ * dashboard reads it through the control-plane route that already serves it.
106
+ * - **`submission.maxPerAccountPerHour`.** A rate a client cannot pre-enforce honestly — the count
107
+ * lives in D1 and the server's refusal is the only truth about it — so projecting it would publish
108
+ * an abuse budget in exchange for a number no form can act on.
109
+ * - **`ai`, `guard`, `attachments`, `search`.** The model and its prompt budget, the mail path's size
110
+ * and rate bounds, what happens to mail attachments, and how search is indexed. Each describes the
111
+ * deployment or a surface a browser never touches. The mail path's `attachments` block in
112
+ * particular is *not* the submission's: `config.ts` says at length why the two are stated
113
+ * separately, and inheriting one for the other here would undo that in the one place it reaches a
114
+ * client.
115
+ *
116
+ * There is no secret in reach of this closure to begin with — the R2 credential bundle lives in the
117
+ * secrets store behind `supportSecretsRegistry` and is never in config — so what discipline covers is
118
+ * the list above. `capability.test.ts` locks the key set by hand and sweeps the serialized result.
119
+ *
120
+ * `null` for attachments that are off, never `undefined`: the projection is inlined into a bundle with
121
+ * `JSON.stringify`, which drops an undefined value and leaves the screen reading a key that is simply
122
+ * absent. Null is also what makes "render no file picker" one check rather than three absences.
123
+ *
124
+ * The return type is {@link SupportClientProjection} — **declared, not inferred**. `ClientProjection` is
125
+ * `{ enabled: boolean }` plus a JSON catchall, which accepts anything this function could return. The
126
+ * declared type is what makes a dropped field a compile error here rather than a browser's problem, and
127
+ * what stops the list above widening by accident when `SupportConfig` grows.
128
+ */
129
+ function clientProjection(config: SupportConfig, basePath: string): SupportClientProjection {
130
+ const submission = config.submission;
131
+ if (!submission.enabled) return { enabled: false };
132
+ const attachments = submission.attachments;
133
+ return {
134
+ enabled: true,
135
+ basePath,
136
+ // Nested under the channel these bounds belong to. `maxBytes` alone would be ambiguous between the
137
+ // mail path and this one, and the mail path's numbers are a different answer to a different
138
+ // question — see `SupportSubmissionAttachmentsConfig`.
139
+ submission: {
140
+ maxSubjectChars: submission.maxSubjectChars,
141
+ maxBodyChars: submission.maxBodyChars,
142
+ attachments: attachments.enabled
143
+ ? {
144
+ maxCount: attachments.maxCount,
145
+ maxBytes: attachments.maxBytes,
146
+ // The `accept` attribute of a file input, and the check a client makes before it spends a
147
+ // request on bytes the handler will refuse. An allowlist is not a secret: it is the
148
+ // contract the route already publishes by refusing everything else.
149
+ allowedContentTypes: [...attachments.allowedContentTypes],
150
+ }
151
+ : null,
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * The support capability, with its resolved config, taxonomy, and reply catalog attached. The
158
+ * workflow slice is kept literal so a composed project types
159
+ * `c.var.workflows.trigger("support/classify", …)` precisely — an unregistered key or a mistyped
160
+ * payload is a compile error, not a 500.
161
+ */
162
+ export interface SupportCapability
163
+ extends Capability<DatabaseSpecMap, KvNamespaceSpecMap, "support", typeof supportWorkflows> {
164
+ /** The resolved support config. */
165
+ supportConfig: SupportConfig;
166
+ /** The effective taxonomy: the eight Pithy ships, plus the adopter's, theirs winning on a collision. */
167
+ categories: SupportCategories;
168
+ /** The effective canned-reply catalog. */
169
+ snippets: SupportReplySnippets;
170
+ /** The path the admin routes mounted under — the one the manifest advertises. */
171
+ basePath: string;
172
+ }
173
+
174
+ /**
175
+ * The support capability — an inbound support inbox in the adopter's own D1, classified on their own
176
+ * Workers AI binding.
177
+ *
178
+ * It contributes five tables (and optionally a full-text index) to the app `DB`, an `email()` handler
179
+ * that claims the configured addresses out of the Worker's single inbound entry, the classification
180
+ * Workflow that handler dispatches to, and a control-plane admin surface for a dashboard to read and
181
+ * act on.
182
+ *
183
+ * **Nothing here is gated by tier.** Every route is reachable with a control-plane credential the
184
+ * adopter issued, whatever anyone pays; a hosted dashboard gates its own UI and nothing else.
185
+ * Crippling MIT code would be both wrong and futile.
186
+ *
187
+ * Attachments presign through `@pithy-sh/storage`'s `ObjectStore` against `SUPPORT_BUCKET` under a
188
+ * credential name support declares, so `secrets` must be composed. The admin routes need the
189
+ * `control-plane` seam; without it every one denies, which is the correct failure for an inbox
190
+ * holding other people's correspondence. `@pithy-sh/auth` and `@pithy-sh/payments` are reached by
191
+ * guarded dynamic import for the sender link, so both stay genuinely optional.
192
+ */
193
+ export function support(options: SupportOptions = {}): SupportCapability {
194
+ const { basePath, ...configInput } = options;
195
+ const resolved = SupportConfigSchema.parse(configInput);
196
+ const categories = resolveCategories(resolved.categories);
197
+ const snippets = resolveReplies(resolved.reply.snippets);
198
+ const mountPath = basePath ?? "/support";
199
+
200
+ const wiring: SupportWiring = {
201
+ config: resolved,
202
+ categories,
203
+ snippets,
204
+ enqueueEmail: undefined,
205
+ emit: noopEmit,
206
+ };
207
+
208
+ // One migration, unconditionally. The full-text index is **not** here: it is derived from the
209
+ // messages table and rebuildable at any time, so it is a provisioned resource that
210
+ // `pithy support provision` creates and drops (see `store/searchIndex.ts`), not schema whose loss
211
+ // loses data. Keeping it out of the ledger is what makes `search.fts` safe to toggle in either
212
+ // direction — a config flag must never be able to corrupt a migration set shared with every other
213
+ // capability in the database.
214
+ const migrations: Record<string, Migration> = { "0001_threads": support_0001_threads };
215
+
216
+ const requiredBindings: BindingSpecInput[] = [
217
+ // The app database every support table lives in.
218
+ { type: "d1", name: "DB" },
219
+ // Attachment and raw-message bytes. Optional: a project that has not provisioned yet must still
220
+ // boot and still receive mail, which is why the runtime has a path for the bucket's absence
221
+ // (`http/resolve.ts:77`).
222
+ //
223
+ // **Declared only when this configuration would ever write to it.** A manifest is one static file
224
+ // and cannot vary with config, so the manifest says `optional` and the composed instance decides
225
+ // — which is exactly what the CLI's `effectiveBindings` reads, and what `controlplane` has always
226
+ // done for its KV. Declaring it unconditionally made `pithy upgrade` write six R2 stanzas across
227
+ // three environments for an inbox with attachments off, and `pithy doctor` call them missing
228
+ // forever once they were deleted by hand (#440). The three settings behind `supportNeedsBucket`
229
+ // all default `true`, so nothing changes for a project that has not turned one off.
230
+ ...(supportNeedsBucket(resolved) ? [{ type: "r2" as const, name: "SUPPORT_BUCKET", optional: true }] : []),
231
+ // The classification Workflow the inbound handler dispatches to, derived from the spec rather
232
+ // than listed again — one declaration, so a binding rename cannot leave the two disagreeing.
233
+ ...workflowBindings(supportWorkflows),
234
+ ];
235
+
236
+ const capability = defineCapability({
237
+ name: "support",
238
+ // The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
239
+ // cannot read its own package.json. Reported per capability by the control-plane manifest.
240
+ version: PACKAGE_VERSION,
241
+ // Attachment presigning reads an R2 credential bundle through @pithy-sh/secrets.
242
+ dependsOn: ["secrets"],
243
+ secretRegistry: supportSecretsRegistry,
244
+ workflows: supportWorkflows,
245
+ requiredBindings,
246
+ // Provisioning creates the routing rule that delivers mail to this Worker.
247
+ ciPermissions: ["email:routing"],
248
+ databases: {
249
+ app: {
250
+ binding: "DB",
251
+ tables: supportTables,
252
+ migrationOrder: SUPPORT_MIGRATION_ORDER,
253
+ migrations,
254
+ },
255
+ },
256
+ /**
257
+ * Bind the optional email seam once, at startup.
258
+ *
259
+ * Deliberately not a hard `dependsOn`. Auth *requires* email — a magic link that cannot be sent
260
+ * is a broken sign-in — but a support inbox that receives, threads, and classifies without being
261
+ * able to answer is a coherent and useful thing, so a missing email capability degrades one
262
+ * route instead of refusing to boot.
263
+ */
264
+ compose: ({ capabilities }) => {
265
+ const email = capabilities.find(isEmailCapability);
266
+ wiring.enqueueEmail = email?.enqueue;
267
+ },
268
+ // What a browser may know. Built from the resolved `mountPath`, never the default — the whole
269
+ // point is that moving the mount moves the address the client posts to. See `clientProjection`
270
+ // for why the list is as short as it is.
271
+ client: (): SupportClientProjection => clientProjection(resolved, mountPath),
272
+ adminRoutes: supportAdminRoutes(mountPath),
273
+ routes: registerSupportRoutes({
274
+ basePath: mountPath,
275
+ submission: resolved.submission.enabled,
276
+ resolveDeps: makeResolveDeps(wiring),
277
+ }),
278
+ email: createSupportEmailHandler(wiring),
279
+ seeds: [supportExampleSeed],
280
+ });
281
+
282
+ return Object.assign(capability, {
283
+ supportConfig: resolved,
284
+ categories,
285
+ snippets,
286
+ basePath: mountPath,
287
+ });
288
+ }
289
+
290
+ /** Whether a capability is the support capability — carries its resolved config and effective taxonomy. */
291
+ export function isSupportCapability(capability: Capability): capability is SupportCapability {
292
+ return capability.name === "support" && "supportConfig" in capability;
293
+ }
@@ -0,0 +1,60 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * What a browser may know about this project's support inbox — the shape of `virtual:pithy/support`.
6
+ *
7
+ * **This declaration is the contract, and the projection is checked against it.** It is written here
8
+ * rather than inferred from the closure that builds it, and that is the whole point: an inferred type
9
+ * follows whatever the producer last happened to say, so a projection that dropped `maxBodyChars`, or
10
+ * that started passing the mail path's attachment bounds through because `SupportConfig` moved a field,
11
+ * would take the type with it and nothing would go red. Declared, the arrow is the thing that has to
12
+ * change — and every widening of what a browser sees is a decision made here, on purpose.
13
+ *
14
+ * The list is short on purpose and `clientProjection`'s own comment says at length what stays behind:
15
+ * the taxonomy, the inbound addresses, the reply snippets, the per-account rate, and the whole `ai`,
16
+ * `guard`, mail-`attachments` and `search` blocks.
17
+ *
18
+ * **This is the only statement of the shape.** `@pithy-sh/ui-react`'s `templates/client-env.d.ts` — the
19
+ * ambient declaration `pithy ui add react` copies into an adopter's Worker — is generated from this type
20
+ * by `@pithy-sh/vite`'s `clientEnvDeclaration.ts` (#398). The unions and the per-field doc comments below
21
+ * are emitted verbatim, so what is written here is what a screen author reads.
22
+ */
23
+ export type SupportClientProjection =
24
+ | {
25
+ /**
26
+ * Support is not composed, or is not serving the in-app submission routes — the only ones a
27
+ * browser calls. A screen branches rather than rendering a compose form nothing will accept.
28
+ */
29
+ enabled: false;
30
+ }
31
+ | {
32
+ /** Support is composed AND serving the in-app submission routes. */
33
+ enabled: true;
34
+ /** Where the support routes mount, e.g. `/support`. `POST {basePath}/feedback` writes in. */
35
+ basePath: string;
36
+ /**
37
+ * What one submission may carry. Hold a compose form to these so the handler does not have to
38
+ * refuse after somebody pressed Send. The taxonomy is not here: a category's text is the
39
+ * instruction a classifier reads, not copy for a chooser.
40
+ */
41
+ submission: {
42
+ /** The longest subject accepted. It becomes the thread's name in the inbox. */
43
+ maxSubjectChars: number;
44
+ /** The longest report body accepted. */
45
+ maxBodyChars: number;
46
+ /**
47
+ * What an upload control may offer, or null when attachments are off and it renders none.
48
+ * Null rather than absent: the projection is inlined with `JSON.stringify`, which drops an
49
+ * undefined value and leaves a screen reading a key that is simply gone.
50
+ */
51
+ attachments: {
52
+ /** How many files one submission may carry. */
53
+ maxCount: number;
54
+ /** The largest single file, measured on the decoded bytes. */
55
+ maxBytes: number;
56
+ /** The exact MIME types accepted — an allowlist, and the `accept` a file input wants. */
57
+ allowedContentTypes: string[];
58
+ } | null;
59
+ };
60
+ };
@@ -0,0 +1,15 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
7
+ // config in `vitest.workers.config.ts`: the app `DB` database the support tables live in, and the
8
+ // `SUPPORT_BUCKET` R2 bucket attachment bytes are written to. `cloudflare:test` types its `env` as
9
+ // `Cloudflare.Env`, so test bindings are declared by augmenting that interface.
10
+ declare namespace Cloudflare {
11
+ interface Env {
12
+ DB: D1Database;
13
+ SUPPORT_BUCKET: R2Bucket;
14
+ }
15
+ }