@pithy-sh/support 0.1.2 → 0.1.4

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 (170) hide show
  1. package/dist/ai/classify.d.ts +92 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +153 -0
  4. package/dist/attachment/store.d.ts +55 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +69 -0
  7. package/dist/audit/actions.d.ts +70 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +70 -0
  10. package/dist/capability.d.ts +95 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +189 -0
  13. package/dist/client/projection.d.ts +58 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +2 -0
  16. package/dist/config/config.d.ts +217 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +171 -0
  19. package/dist/data/attachment.d.ts +32 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +34 -0
  22. package/dist/data/billingScope.d.ts +32 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +34 -0
  25. package/dist/data/categories.d.ts +56 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +99 -0
  28. package/dist/data/classification.d.ts +36 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +30 -0
  31. package/dist/data/enums.d.ts +84 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +72 -0
  34. package/dist/data/flag.d.ts +27 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +29 -0
  37. package/dist/data/message.d.ts +83 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +86 -0
  40. package/dist/data/tables.d.ts +162 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +46 -0
  43. package/dist/data/thread.d.ts +79 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +67 -0
  46. package/dist/error/errors.d.ts +66 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +84 -0
  49. package/dist/http/guards.d.ts +46 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +55 -0
  52. package/dist/http/handlers.d.ts +91 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +250 -0
  55. package/dist/http/resolve.d.ts +46 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +74 -0
  58. package/dist/http/responses.d.ts +831 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +305 -0
  61. package/dist/http/routes.d.ts +111 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +64 -0
  64. package/dist/http/schemas.d.ts +123 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +91 -0
  67. package/dist/http/scopes.d.ts +58 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +109 -0
  70. package/dist/http/views.d.ts +38 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +146 -0
  73. package/dist/inbound/authenticity.d.ts +83 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +63 -0
  76. package/dist/inbound/guard.d.ts +76 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +80 -0
  79. package/dist/inbound/handler.d.ts +30 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +84 -0
  82. package/dist/inbound/ingest.d.ts +106 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +328 -0
  85. package/dist/inbound/recipient.d.ts +18 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +48 -0
  88. package/dist/index.d.ts +22 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +16 -0
  91. package/dist/link/sender.d.ts +162 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +202 -0
  94. package/dist/migrations/0001_threads.d.ts +28 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +105 -0
  97. package/dist/mime/address.d.ts +24 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +30 -0
  100. package/dist/mime/parse.d.ts +98 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +148 -0
  103. package/dist/mime/sanitize.d.ts +27 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +214 -0
  106. package/dist/mime/threading.d.ts +81 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +113 -0
  109. package/dist/mime/truncate.d.ts +19 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +49 -0
  112. package/dist/provision/provisionSupport.d.ts +160 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +112 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +47 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +24 -0
  118. package/dist/reply/send.d.ts +125 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +155 -0
  121. package/dist/reply/snippets.d.ts +81 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +137 -0
  124. package/dist/secret/registry.d.ts +20 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +23 -0
  127. package/dist/seeds/example.d.ts +5 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +352 -0
  130. package/dist/store/paging.d.ts +20 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +22 -0
  133. package/dist/store/search.d.ts +69 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +162 -0
  136. package/dist/store/searchIndex.d.ts +55 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +68 -0
  139. package/dist/store/threads.d.ts +149 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +199 -0
  142. package/dist/submission/encoding.d.ts +23 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +57 -0
  145. package/dist/submission/guard.d.ts +73 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +48 -0
  148. package/dist/submission/submit.d.ts +154 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +321 -0
  151. package/dist/version.generated.d.ts +7 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +9 -0
  154. package/dist/workflows/classify.d.ts +65 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +70 -0
  157. package/dist/workflows/retryPolicy.d.ts +41 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +45 -0
  160. package/dist/workflows/specs.d.ts +45 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +48 -0
  163. package/dist/workflows/worker.d.ts +50 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +39 -0
  166. package/package.json +24 -15
  167. package/src/http/handlers.ts +1 -1
  168. package/src/submission/encoding.ts +1 -1
  169. package/src/submission/submit.ts +1 -1
  170. package/src/version.generated.ts +1 -1
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /**
5
+ * The tokens a dashboard substitutes before sending. Documented here because they are a contract
6
+ * between this catalog and whatever renders it, and deliberately few: a placeholder language is a
7
+ * template engine, and a template engine over untrusted context is a rendering vulnerability.
8
+ */
9
+ export declare const SNIPPET_TOKENS: {
10
+ /** The sender's display name, or their address when the app knows no name. */
11
+ readonly name: "{{name}}";
12
+ /** The thread's subject. */
13
+ readonly subject: "{{subject}}";
14
+ };
15
+ /** One canned reply. */
16
+ export declare const SupportReplySnippet: z.ZodObject<{
17
+ label: z.ZodString;
18
+ category: z.ZodOptional<z.ZodString>;
19
+ body: z.ZodString;
20
+ }, z.core.$strip>;
21
+ export type SupportReplySnippet = z.infer<typeof SupportReplySnippet>;
22
+ /** A snippet catalog: key → the snippet. */
23
+ export type SupportReplySnippets = Record<string, SupportReplySnippet>;
24
+ /**
25
+ * The set Pithy ships, keyed to the default categories.
26
+ *
27
+ * Six, not sixteen. A picker long enough to need scrolling is one nobody reads, and the ones below
28
+ * are the replies a solo developer actually writes over and over.
29
+ */
30
+ export declare const DEFAULT_SUPPORT_REPLIES: {
31
+ readonly refund_issued: {
32
+ readonly label: "Refund issued";
33
+ readonly category: "billing";
34
+ readonly body: "Hi {{name}},\n\nI've refunded this — you should see it back on your original payment method within ___ working days.\n\nSorry for the trouble.\n";
35
+ };
36
+ readonly billing_explained: {
37
+ readonly label: "Explain a charge";
38
+ readonly category: "billing";
39
+ readonly body: "Hi {{name}},\n\nThat charge is ___. It covers ___.\n\nIf that isn't what you expected, tell me and I'll sort it out.\n";
40
+ };
41
+ readonly sign_in_help: {
42
+ readonly label: "Sign-in help";
43
+ readonly category: "account_access";
44
+ readonly body: "Hi {{name}},\n\nI've had a look — ___.\n\nTry signing in again and let me know if it still doesn't work. If the email isn't arriving, check your spam folder, and tell me which address you're using so I can confirm it matches the account.\n";
45
+ };
46
+ readonly bug_acknowledged: {
47
+ readonly label: "Bug acknowledged";
48
+ readonly category: "bug_report";
49
+ readonly body: "Hi {{name}},\n\nThanks for reporting this — I've reproduced it and it's a real bug. ___.\n\nI'll follow up here once it's fixed.\n";
50
+ };
51
+ readonly feature_noted: {
52
+ readonly label: "Feature request noted";
53
+ readonly category: "feature_request";
54
+ readonly body: "Hi {{name}},\n\nThanks — that's a good idea and I've written it down. I can't promise a date, but ___.\n\nIf you can tell me a bit about how you'd use it, that genuinely helps me prioritize.\n";
55
+ };
56
+ readonly need_more_detail: {
57
+ readonly label: "Ask for more detail";
58
+ readonly body: "Hi {{name}},\n\nHappy to help with this. Could you tell me ___?\n\nA screenshot or the exact wording of any error would speed this up a lot.\n";
59
+ };
60
+ };
61
+ /**
62
+ * Declare an adopter's own canned replies, validated at author time.
63
+ *
64
+ * The same shape and the same reasoning as `defineSupportCategories`: a malformed key is a picker
65
+ * entry that silently never matches, and it should fail where the constant is written.
66
+ */
67
+ export declare function defineSupportReplies<const T extends SupportReplySnippets>(snippets: T): T;
68
+ /** The effective catalog: the shipped set, plus the adopter's, theirs winning on a key collision. */
69
+ export declare function resolveReplies(extra?: SupportReplySnippets): SupportReplySnippets;
70
+ /**
71
+ * The catalog as a dashboard consumes it: the snippets for this thread's category first, then the
72
+ * uncategorized general-purpose ones, then everything else.
73
+ *
74
+ * Ordering rather than filtering, deliberately. A classifier that put a refund request in
75
+ * `bug_report` would otherwise hide the refund snippets from the one person who can tell it was
76
+ * wrong — the model orders the list, it never restricts it.
77
+ */
78
+ export declare function repliesForCategory(snippets: SupportReplySnippets, category: string): Array<SupportReplySnippet & {
79
+ key: string;
80
+ }>;
81
+ //# sourceMappingURL=snippets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snippets.d.ts","sourceRoot":"","sources":["../../src/reply/snippets.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAqCxB;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,8EAA8E;aAC9E,IAAI,EAAE,UAAU;IAChB,4BAA4B;aAC5B,OAAO,EAAE,aAAa;CACd,CAAC;AAEX,wBAAwB;AACxB,eAAO,MAAM,mBAAmB;;;;iBAqBmD,CAAC;AACpF,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4CAA4C;AAC5C,MAAM,MAAM,oBAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;AAEvE;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB;;iBAEhC,KAAK,EAAE,eAAe;iBACtB,QAAQ,EAAE,SAAS;iBACnB,IAAI,EAAE,kJAAkJ;;;iBAGxJ,KAAK,EAAE,kBAAkB;iBACzB,QAAQ,EAAE,SAAS;iBACnB,IAAI,EAAE,wHAAwH;;;iBAG9H,KAAK,EAAE,cAAc;iBACrB,QAAQ,EAAE,gBAAgB;iBAC1B,IAAI,EAAE,iPAAiP;;;iBAGvP,KAAK,EAAE,kBAAkB;iBACzB,QAAQ,EAAE,YAAY;iBACtB,IAAI,EAAE,oIAAoI;;;iBAG1I,KAAK,EAAE,uBAAuB;iBAC9B,QAAQ,EAAE,iBAAiB;iBAC3B,IAAI,EAAE,kMAAkM;;;iBAGxM,KAAK,EAAE,qBAAqB;iBAC5B,IAAI,EAAE,gJAAgJ;;CAEjH,CAAC;AAE1C;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,CAAC,CAAC,SAAS,oBAAoB,EAAE,QAAQ,EAAE,CAAC,GAAG,CAAC,CAmBzF;AAED,qGAAqG;AACrG,wBAAgB,cAAc,CAAC,KAAK,GAAE,oBAAyB,GAAG,oBAAoB,CAErF;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,oBAAoB,EAC9B,QAAQ,EAAE,MAAM,GACf,KAAK,CAAC,mBAAmB,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAK9C"}
@@ -0,0 +1,137 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SupportInvalidCategoryError } from "../error/errors.js";
4
+ import { z } from "zod";
5
+ //#region src/reply/snippets.ts
6
+ /**
7
+ * Canned replies — starting points a human picks in the dashboard, edits, and sends.
8
+ *
9
+ * ## What these are, and what they are not
10
+ *
11
+ * A snippet is **body text**, not an email template. The rendering shell — the adopter's theme, the
12
+ * HTML and plain-text pair, the threading headers — is the single `supportReply` template in
13
+ * `@pithy-sh/email`. Adding a second email template per canned reply would put the adopter's
14
+ * wording in a precompiled Handlebars file that only a release can change, which is exactly backwards
15
+ * for text somebody wants to reword on a Tuesday.
16
+ *
17
+ * So the Worker serves a catalog and **never renders it**. The dashboard shows the list, the operator
18
+ * picks one, edits it, and posts the finished body to the reply route — which is why the placeholder
19
+ * tokens below are substituted client-side and this package owns no template engine. A support reply
20
+ * is a letter a person sent; the machine's only job is to hand them a better blank page.
21
+ *
22
+ * ## Federated, like the taxonomy
23
+ *
24
+ * Pithy ships a small set keyed to the default categories, and an adopter adds their own with
25
+ * {@link defineSupportReplies} — the same shape `defineSupportCategories` uses, for the same reason.
26
+ * A snippet tagged with a category is offered first on a thread the classifier put in that category,
27
+ * which is the one place the classification does work for a human rather than for a filter.
28
+ *
29
+ * The shipped wording is deliberately plain and slightly incomplete: every one of them has a blank
30
+ * an operator has to fill. A canned reply that could be sent without reading it is how a support
31
+ * inbox starts insulting people.
32
+ */
33
+ /** The shape a snippet key must take: lowercase `snake_case`, so it is a stable id in a dashboard. */
34
+ const SNIPPET_KEY = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
35
+ /** How long a snippet body may be. Long enough for a real answer; bounded because it ships in a manifest. */
36
+ const MAX_BODY = 2e3;
37
+ /**
38
+ * The tokens a dashboard substitutes before sending. Documented here because they are a contract
39
+ * between this catalog and whatever renders it, and deliberately few: a placeholder language is a
40
+ * template engine, and a template engine over untrusted context is a rendering vulnerability.
41
+ */
42
+ const SNIPPET_TOKENS = {
43
+ /** The sender's display name, or their address when the app knows no name. */
44
+ name: "{{name}}",
45
+ /** The thread's subject. */
46
+ subject: "{{subject}}"
47
+ };
48
+ /** One canned reply. */
49
+ const SupportReplySnippet = z.object({
50
+ label: z.string().min(1).max(80).describe("What the dashboard shows in the picker, e.g. `Refund issued`. A verb phrase, not a category name."),
51
+ category: z.string().optional().describe("The category this snippet is offered first for. Optional — a snippet with no category is always offered, which is right for the general-purpose ones."),
52
+ body: z.string().min(1).max(MAX_BODY).describe("The starting text, with `{{name}}` and `{{subject}}` substituted by the dashboard before sending. Written to be edited: every shipped one leaves a blank a human has to fill.")
53
+ }).describe("One canned reply — a starting point a human picks, edits, and sends.");
54
+ /**
55
+ * The set Pithy ships, keyed to the default categories.
56
+ *
57
+ * Six, not sixteen. A picker long enough to need scrolling is one nobody reads, and the ones below
58
+ * are the replies a solo developer actually writes over and over.
59
+ */
60
+ const DEFAULT_SUPPORT_REPLIES = {
61
+ refund_issued: {
62
+ label: "Refund issued",
63
+ category: "billing",
64
+ body: "Hi {{name}},\n\nI've refunded this — you should see it back on your original payment method within ___ working days.\n\nSorry for the trouble.\n"
65
+ },
66
+ billing_explained: {
67
+ label: "Explain a charge",
68
+ category: "billing",
69
+ body: "Hi {{name}},\n\nThat charge is ___. It covers ___.\n\nIf that isn't what you expected, tell me and I'll sort it out.\n"
70
+ },
71
+ sign_in_help: {
72
+ label: "Sign-in help",
73
+ category: "account_access",
74
+ body: "Hi {{name}},\n\nI've had a look — ___.\n\nTry signing in again and let me know if it still doesn't work. If the email isn't arriving, check your spam folder, and tell me which address you're using so I can confirm it matches the account.\n"
75
+ },
76
+ bug_acknowledged: {
77
+ label: "Bug acknowledged",
78
+ category: "bug_report",
79
+ body: "Hi {{name}},\n\nThanks for reporting this — I've reproduced it and it's a real bug. ___.\n\nI'll follow up here once it's fixed.\n"
80
+ },
81
+ feature_noted: {
82
+ label: "Feature request noted",
83
+ category: "feature_request",
84
+ body: "Hi {{name}},\n\nThanks — that's a good idea and I've written it down. I can't promise a date, but ___.\n\nIf you can tell me a bit about how you'd use it, that genuinely helps me prioritize.\n"
85
+ },
86
+ need_more_detail: {
87
+ label: "Ask for more detail",
88
+ body: "Hi {{name}},\n\nHappy to help with this. Could you tell me ___?\n\nA screenshot or the exact wording of any error would speed this up a lot.\n"
89
+ }
90
+ };
91
+ /**
92
+ * Declare an adopter's own canned replies, validated at author time.
93
+ *
94
+ * The same shape and the same reasoning as `defineSupportCategories`: a malformed key is a picker
95
+ * entry that silently never matches, and it should fail where the constant is written.
96
+ */
97
+ function defineSupportReplies(snippets) {
98
+ for (const [key, snippet] of Object.entries(snippets)) {
99
+ if (!SNIPPET_KEY.test(key)) throw new SupportInvalidCategoryError({
100
+ message: `Invalid support reply key: ${key}`,
101
+ action: "Use a lowercase snake_case key, e.g. `refund_issued`.",
102
+ detail: `key ${JSON.stringify(key)} does not match ${SNIPPET_KEY}`
103
+ });
104
+ const parsed = SupportReplySnippet.safeParse(snippet);
105
+ if (!parsed.success) throw new SupportInvalidCategoryError({
106
+ message: `Invalid support reply "${key}".`,
107
+ action: "Give it a label and a body, and keep the body under 2000 characters.",
108
+ detail: parsed.error.message
109
+ });
110
+ }
111
+ return snippets;
112
+ }
113
+ /** The effective catalog: the shipped set, plus the adopter's, theirs winning on a key collision. */
114
+ function resolveReplies(extra = {}) {
115
+ return {
116
+ ...DEFAULT_SUPPORT_REPLIES,
117
+ ...defineSupportReplies(extra)
118
+ };
119
+ }
120
+ /**
121
+ * The catalog as a dashboard consumes it: the snippets for this thread's category first, then the
122
+ * uncategorized general-purpose ones, then everything else.
123
+ *
124
+ * Ordering rather than filtering, deliberately. A classifier that put a refund request in
125
+ * `bug_report` would otherwise hide the refund snippets from the one person who can tell it was
126
+ * wrong — the model orders the list, it never restricts it.
127
+ */
128
+ function repliesForCategory(snippets, category) {
129
+ const entries = Object.entries(snippets).map(([key, snippet]) => ({
130
+ key,
131
+ ...snippet
132
+ }));
133
+ const rank = (snippet) => snippet.category === category ? 0 : snippet.category === void 0 ? 1 : 2;
134
+ return entries.sort((a, b) => rank(a) - rank(b) || a.key.localeCompare(b.key));
135
+ }
136
+ //#endregion
137
+ export { DEFAULT_SUPPORT_REPLIES, SNIPPET_TOKENS, SupportReplySnippet, defineSupportReplies, repliesForCategory, resolveReplies };
@@ -0,0 +1,20 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The secrets support reads — exactly one, and it is not support's own shape.
5
+ *
6
+ * Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
7
+ * signing needs an S3 key pair. Rather than declare another credential shape, support declares the
8
+ * *same* one `@pithy-sh/storage` owns, under its own name, through storage's factory. That is what
9
+ * lets support point the `ObjectStore` seam at `SUPPORT_BUCKET` while inheriting none of storage's
10
+ * tables, routes, or key policy — the same arrangement `@pithy-sh/media` already has.
11
+ *
12
+ * One factory rather than two hand-written declarations is also what keeps the name safe as a join
13
+ * key: `aggregateSecretRegistries` allows a name declared twice only when every axis agrees, and two
14
+ * hand-written copies drift where one factory cannot.
15
+ */
16
+ /** The name support's R2 credential bundle is stored and resolved under, per environment. */
17
+ export declare const SUPPORT_R2_SECRET = "support-r2-credentials";
18
+ /** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
19
+ export declare const supportSecretsRegistry: Record<"support-r2-credentials", import("@pithy-sh/storage/src/secret/registry").R2CredentialsEntry>;
20
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/secret/registry.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;GAYG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAE1D,uGAAuG;AACvG,eAAO,MAAM,sBAAsB,sGAA2C,CAAC"}
@@ -0,0 +1,23 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { r2CredentialsRegistry } from "@pithy-sh/storage/src/secret/registry";
4
+ //#region src/secret/registry.ts
5
+ /**
6
+ * The secrets support reads — exactly one, and it is not support's own shape.
7
+ *
8
+ * Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
9
+ * signing needs an S3 key pair. Rather than declare another credential shape, support declares the
10
+ * *same* one `@pithy-sh/storage` owns, under its own name, through storage's factory. That is what
11
+ * lets support point the `ObjectStore` seam at `SUPPORT_BUCKET` while inheriting none of storage's
12
+ * tables, routes, or key policy — the same arrangement `@pithy-sh/media` already has.
13
+ *
14
+ * One factory rather than two hand-written declarations is also what keeps the name safe as a join
15
+ * key: `aggregateSecretRegistries` allows a name declared twice only when every axis agrees, and two
16
+ * hand-written copies drift where one factory cannot.
17
+ */
18
+ /** The name support's R2 credential bundle is stored and resolved under, per environment. */
19
+ const SUPPORT_R2_SECRET = "support-r2-credentials";
20
+ /** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
21
+ const supportSecretsRegistry = r2CredentialsRegistry(SUPPORT_R2_SECRET);
22
+ //#endregion
23
+ export { SUPPORT_R2_SECRET, supportSecretsRegistry };
@@ -0,0 +1,5 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { type SeedSet } from "@pithy-sh/core/src/seed/seed";
4
+ export declare const supportExampleSeed: SeedSet;
5
+ //# sourceMappingURL=example.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"example.d.ts","sourceRoot":"","sources":["../../src/seeds/example.ts"],"names":[],"mappings":"AAIA,OAAO,EAA2B,KAAK,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAgFrF,eAAO,MAAM,kBAAkB,EAAE,OA4S/B,CAAC"}
@@ -0,0 +1,352 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SupportClassification } from "../data/classification.js";
4
+ import { SupportMessage } from "../data/message.js";
5
+ import { SupportThread } from "../data/thread.js";
6
+ import { SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
7
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
8
+ import { d1SeedGroup, defineSeed } from "@pithy-sh/core/src/seed/seed";
9
+ //#region src/seeds/example.ts
10
+ /**
11
+ * Four conversations for the canonical example cast, spread across the shapes a dashboard has to
12
+ * render.
13
+ *
14
+ * **Ada** is angry about a double charge — `billing`, `urgent`, and she is the one with a live Apple
15
+ * subscription in the payments seed, so a thread view opened on her shows the linkage working end to
16
+ * end rather than an empty panel. **Grace** cannot sign in — `account_access`, `normal`, two
17
+ * messages, so the thread view has an actual conversation in it and the reply path has something
18
+ * threaded to attach to. **Alan** sent a feature request that has been dealt with — `feature_request`,
19
+ * `low`, `positive`, and **archived**, so the done pile is not empty on a fresh backend and the
20
+ * archived filter is demonstrably doing something.
21
+ *
22
+ * **Grace again, from inside the app** — a `bug_report` filed through `POST /support/feedback`, so the
23
+ * fixture carries both channels and the same person on each. That pairing is the demonstration: her
24
+ * mail thread is linked to her account by an address in a header nobody proved, and her app thread is
25
+ * linked by a session, and a console that renders the two identically has a bug this seed will show
26
+ * you. It is also the only row carrying submission context, which is what a bug report knows and a
27
+ * piece of mail cannot — and the only row carrying a `declaredCategory`, because it is the only sender
28
+ * who was offered a chooser.
29
+ *
30
+ * **And it deliberately disagrees with itself.** Grace filed it under "something is missing" and the
31
+ * model read the text and called it a bug, so `declaredCategory` is `feature_request` while `category`
32
+ * is `bug_report`. That is the ordinary case rather than a contrived one — people file "X does not
33
+ * work for me" under a missing feature constantly — and it is the row that makes the rendering mistake
34
+ * visible before it ships: a console that shows one word, or shows the wrong one, has a bug this seed
35
+ * will hand you. Every mail thread's is null, because nobody asked its sender anything.
36
+ *
37
+ * That is one row per priority, all four sentiments, four categories, both channels, both link
38
+ * provenances, both a stated and an unstated category, and both sides of `archived` — the states
39
+ * anything reading these tables has to handle, without anybody authoring a fixture first.
40
+ *
41
+ * **Everything is fixed — the ids and the clock.** `pithy seed` is `INSERT OR IGNORE`, so a generated
42
+ * UUID would give Ada a second complaint on every run.
43
+ *
44
+ * **No attachments, and no raw messages.** Both would mean seeding R2 objects the D1 rows point at,
45
+ * and a fixture whose rows name bytes that are not there is worse than one that claims less. The
46
+ * classifications are seeded because they are the interesting half: a thread carries a model id, so
47
+ * the seed shows what "which rows came from which model" looks like before anybody has run one.
48
+ *
49
+ * Composed in only when the project turns on `seed.includeExamples`, and only for `dev` and
50
+ * `staging` — an example fixture never targets prod, whatever that setting says.
51
+ */
52
+ /** Where this set sorts. After auth's users (100) and payments' purchases (250), which it links to. */
53
+ const SUPPORT_EXAMPLE_SEED_ORDER = 260;
54
+ /** Fixed ids, so the fixture is idempotent and a classification cannot drift off its thread. */
55
+ const BILLING_THREAD_ID = "a1b2c3d4-0001-4a11-9c01-0e1f2a3b4c50";
56
+ const ACCESS_THREAD_ID = "a1b2c3d4-0002-4a11-9c01-0e1f2a3b4c51";
57
+ const FEATURE_THREAD_ID = "a1b2c3d4-0003-4a11-9c01-0e1f2a3b4c52";
58
+ const APP_THREAD_ID = "a1b2c3d4-0004-4a11-9c01-0e1f2a3b4c53";
59
+ const BILLING_MESSAGE_ID = "b1b2c3d4-0001-4a11-9c01-0e1f2a3b4c60";
60
+ const ACCESS_MESSAGE_ID = "b1b2c3d4-0002-4a11-9c01-0e1f2a3b4c61";
61
+ const ACCESS_REPLY_ID = "b1b2c3d4-0003-4a11-9c01-0e1f2a3b4c62";
62
+ const FEATURE_MESSAGE_ID = "b1b2c3d4-0004-4a11-9c01-0e1f2a3b4c63";
63
+ const APP_MESSAGE_ID = "b1b2c3d4-0005-4a11-9c01-0e1f2a3b4c64";
64
+ const BILLING_CLASSIFICATION_ID = "c1b2c3d4-0001-4a11-9c01-0e1f2a3b4c70";
65
+ const ACCESS_CLASSIFICATION_ID = "c1b2c3d4-0002-4a11-9c01-0e1f2a3b4c71";
66
+ const FEATURE_CLASSIFICATION_ID = "c1b2c3d4-0003-4a11-9c01-0e1f2a3b4c72";
67
+ const APP_CLASSIFICATION_ID = "c1b2c3d4-0004-4a11-9c01-0e1f2a3b4c73";
68
+ /** The inbox these threads arrived on — a subdomain, which is the only safe shape for a real one. */
69
+ const INBOX = "support@help.example.com";
70
+ /** The model the seeded classifications claim to have come from. */
71
+ const MODEL = "@cf/meta/llama-3.1-8b-instruct";
72
+ /** One fixed moment. The demo is about state, not about a calendar. */
73
+ const AT = /* @__PURE__ */ new Date("2026-01-10T09:00:00.000Z");
74
+ const LATER = /* @__PURE__ */ new Date("2026-01-10T11:30:00.000Z");
75
+ const ARCHIVED_AT = /* @__PURE__ */ new Date("2026-01-11T16:00:00.000Z");
76
+ const supportExampleSeed = defineSeed({
77
+ name: "example",
78
+ order: SUPPORT_EXAMPLE_SEED_ORDER,
79
+ environments: ["dev", "staging"],
80
+ example: true,
81
+ d1: [
82
+ d1SeedGroup("app", SUPPORT_THREADS_TABLE, SupportThread, [
83
+ {
84
+ id: BILLING_THREAD_ID,
85
+ channel: "email",
86
+ inboxAddress: INBOX,
87
+ subject: "I was charged twice this month",
88
+ fromAddress: EXAMPLE_ADA.email,
89
+ fromName: EXAMPLE_ADA.name,
90
+ senderAuthenticated: true,
91
+ userId: EXAMPLE_ADA.id,
92
+ accountLinkSource: "email_address",
93
+ category: "billing",
94
+ priority: "urgent",
95
+ sentiment: "angry",
96
+ confidence: .94,
97
+ model: MODEL,
98
+ classifiedAt: AT,
99
+ archived: false,
100
+ archivedAt: null,
101
+ archivedBy: null,
102
+ messageCount: 1,
103
+ firstMessageAt: AT,
104
+ lastMessageAt: AT,
105
+ createdAt: AT,
106
+ updatedAt: AT
107
+ },
108
+ {
109
+ id: ACCESS_THREAD_ID,
110
+ channel: "email",
111
+ inboxAddress: INBOX,
112
+ subject: "Magic link never arrives",
113
+ fromAddress: EXAMPLE_GRACE.email,
114
+ fromName: EXAMPLE_GRACE.name,
115
+ senderAuthenticated: true,
116
+ userId: EXAMPLE_GRACE.id,
117
+ accountLinkSource: "email_address",
118
+ category: "account_access",
119
+ priority: "normal",
120
+ sentiment: "frustrated",
121
+ confidence: .81,
122
+ model: MODEL,
123
+ classifiedAt: AT,
124
+ archived: false,
125
+ archivedAt: null,
126
+ archivedBy: null,
127
+ messageCount: 2,
128
+ firstMessageAt: AT,
129
+ lastMessageAt: LATER,
130
+ createdAt: AT,
131
+ updatedAt: LATER
132
+ },
133
+ {
134
+ id: FEATURE_THREAD_ID,
135
+ channel: "email",
136
+ inboxAddress: INBOX,
137
+ subject: "Could you add CSV export?",
138
+ fromAddress: EXAMPLE_ALAN.email,
139
+ fromName: EXAMPLE_ALAN.name,
140
+ senderAuthenticated: true,
141
+ userId: EXAMPLE_ALAN.id,
142
+ accountLinkSource: "email_address",
143
+ category: "feature_request",
144
+ priority: "low",
145
+ sentiment: "positive",
146
+ confidence: .88,
147
+ model: MODEL,
148
+ classifiedAt: AT,
149
+ archived: true,
150
+ archivedAt: ARCHIVED_AT,
151
+ archivedBy: "example-dashboard",
152
+ messageCount: 1,
153
+ firstMessageAt: AT,
154
+ lastMessageAt: AT,
155
+ createdAt: AT,
156
+ updatedAt: ARCHIVED_AT
157
+ },
158
+ {
159
+ id: APP_THREAD_ID,
160
+ channel: "app",
161
+ inboxAddress: INBOX,
162
+ subject: "Export button does nothing on the reports screen",
163
+ fromAddress: EXAMPLE_GRACE.email,
164
+ fromName: EXAMPLE_GRACE.name,
165
+ senderAuthenticated: true,
166
+ userId: EXAMPLE_GRACE.id,
167
+ accountLinkSource: "session",
168
+ declaredCategory: "feature_request",
169
+ category: "bug_report",
170
+ priority: "normal",
171
+ sentiment: "neutral",
172
+ confidence: .91,
173
+ model: MODEL,
174
+ classifiedAt: AT,
175
+ archived: false,
176
+ archivedAt: null,
177
+ archivedBy: null,
178
+ messageCount: 1,
179
+ firstMessageAt: LATER,
180
+ lastMessageAt: LATER,
181
+ createdAt: LATER,
182
+ updatedAt: LATER
183
+ }
184
+ ]),
185
+ d1SeedGroup("app", SUPPORT_MESSAGES_TABLE, SupportMessage, [
186
+ {
187
+ id: BILLING_MESSAGE_ID,
188
+ threadId: BILLING_THREAD_ID,
189
+ direction: "inbound",
190
+ channel: "email",
191
+ submittedByUserId: null,
192
+ context: null,
193
+ mimeMessageId: "seed-billing-1@example.com",
194
+ mimeInReplyTo: null,
195
+ mimeReferences: null,
196
+ fromAddress: EXAMPLE_ADA.email,
197
+ fromName: EXAMPLE_ADA.name,
198
+ toAddress: INBOX,
199
+ subject: "I was charged twice this month",
200
+ textBody: "My card shows two charges for the same subscription on the 3rd. I have only ever had one account. Please refund the duplicate.",
201
+ htmlBody: null,
202
+ emailJobId: null,
203
+ rawKey: null,
204
+ rawBytes: null,
205
+ receivedAt: AT,
206
+ createdAt: AT
207
+ },
208
+ {
209
+ id: ACCESS_MESSAGE_ID,
210
+ threadId: ACCESS_THREAD_ID,
211
+ direction: "inbound",
212
+ channel: "email",
213
+ submittedByUserId: null,
214
+ context: null,
215
+ mimeMessageId: "seed-access-1@example.com",
216
+ mimeInReplyTo: null,
217
+ mimeReferences: null,
218
+ fromAddress: EXAMPLE_GRACE.email,
219
+ fromName: EXAMPLE_GRACE.name,
220
+ toAddress: INBOX,
221
+ subject: "Magic link never arrives",
222
+ textBody: "I have asked for a sign-in link four times today and none of them have arrived. I checked spam. Is something broken?",
223
+ htmlBody: null,
224
+ emailJobId: null,
225
+ rawKey: null,
226
+ rawBytes: null,
227
+ receivedAt: AT,
228
+ createdAt: AT
229
+ },
230
+ {
231
+ id: ACCESS_REPLY_ID,
232
+ threadId: ACCESS_THREAD_ID,
233
+ direction: "outbound",
234
+ channel: "email",
235
+ submittedByUserId: null,
236
+ context: null,
237
+ mimeMessageId: null,
238
+ mimeInReplyTo: "seed-access-1@example.com",
239
+ mimeReferences: ["seed-access-1@example.com"],
240
+ fromAddress: INBOX,
241
+ fromName: null,
242
+ toAddress: EXAMPLE_GRACE.email,
243
+ subject: "Re: Magic link never arrives",
244
+ textBody: "Hi Grace,\n\nI found the problem — your address had bounced earlier in the week and was on our suppression list. I have cleared it. Try again and it should arrive within a minute.\n\nSorry for the trouble.\n",
245
+ htmlBody: null,
246
+ emailJobId: "seed-email-job-access-1",
247
+ rawKey: null,
248
+ rawBytes: null,
249
+ receivedAt: LATER,
250
+ createdAt: LATER
251
+ },
252
+ {
253
+ id: FEATURE_MESSAGE_ID,
254
+ threadId: FEATURE_THREAD_ID,
255
+ direction: "inbound",
256
+ channel: "email",
257
+ submittedByUserId: null,
258
+ context: null,
259
+ mimeMessageId: "seed-feature-1@example.com",
260
+ mimeInReplyTo: null,
261
+ mimeReferences: null,
262
+ fromAddress: EXAMPLE_ALAN.email,
263
+ fromName: EXAMPLE_ALAN.name,
264
+ toAddress: INBOX,
265
+ subject: "Could you add CSV export?",
266
+ textBody: "Loving the product. The one thing I miss is being able to export my data as CSV for a spreadsheet. No rush.",
267
+ htmlBody: null,
268
+ emailJobId: null,
269
+ rawKey: null,
270
+ rawBytes: null,
271
+ receivedAt: AT,
272
+ createdAt: AT
273
+ },
274
+ {
275
+ id: APP_MESSAGE_ID,
276
+ threadId: APP_THREAD_ID,
277
+ direction: "inbound",
278
+ channel: "app",
279
+ submittedByUserId: EXAMPLE_GRACE.id,
280
+ context: {
281
+ screen: "reports",
282
+ appVersion: "2.4.1",
283
+ platform: "web",
284
+ environment: "prod",
285
+ locale: "en-GB"
286
+ },
287
+ mimeMessageId: `${APP_MESSAGE_ID}@help.example.com`,
288
+ mimeInReplyTo: null,
289
+ mimeReferences: null,
290
+ fromAddress: EXAMPLE_GRACE.email,
291
+ fromName: EXAMPLE_GRACE.name,
292
+ toAddress: INBOX,
293
+ subject: "Export button does nothing on the reports screen",
294
+ textBody: "I press Export on the reports screen and nothing happens. No download, no error. It worked last week. Chrome, if that helps.",
295
+ htmlBody: null,
296
+ emailJobId: null,
297
+ rawKey: null,
298
+ rawBytes: null,
299
+ receivedAt: LATER,
300
+ createdAt: LATER
301
+ }
302
+ ]),
303
+ d1SeedGroup("app", SUPPORT_CLASSIFICATIONS_TABLE, SupportClassification, [
304
+ {
305
+ id: BILLING_CLASSIFICATION_ID,
306
+ threadId: BILLING_THREAD_ID,
307
+ messageId: BILLING_MESSAGE_ID,
308
+ category: "billing",
309
+ priority: "urgent",
310
+ sentiment: "angry",
311
+ confidence: .94,
312
+ model: MODEL,
313
+ createdAt: AT
314
+ },
315
+ {
316
+ id: ACCESS_CLASSIFICATION_ID,
317
+ threadId: ACCESS_THREAD_ID,
318
+ messageId: ACCESS_MESSAGE_ID,
319
+ category: "account_access",
320
+ priority: "normal",
321
+ sentiment: "frustrated",
322
+ confidence: .81,
323
+ model: MODEL,
324
+ createdAt: AT
325
+ },
326
+ {
327
+ id: APP_CLASSIFICATION_ID,
328
+ threadId: APP_THREAD_ID,
329
+ messageId: APP_MESSAGE_ID,
330
+ category: "bug_report",
331
+ priority: "normal",
332
+ sentiment: "neutral",
333
+ confidence: .91,
334
+ model: MODEL,
335
+ createdAt: LATER
336
+ },
337
+ {
338
+ id: FEATURE_CLASSIFICATION_ID,
339
+ threadId: FEATURE_THREAD_ID,
340
+ messageId: FEATURE_MESSAGE_ID,
341
+ category: "feature_request",
342
+ priority: "low",
343
+ sentiment: "positive",
344
+ confidence: .88,
345
+ model: MODEL,
346
+ createdAt: AT
347
+ }
348
+ ])
349
+ ]
350
+ });
351
+ //#endregion
352
+ export { supportExampleSeed };
@@ -0,0 +1,20 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The inbox page bounds, in the one module that has no reason to import anything (#430).
5
+ *
6
+ * `http/schemas.ts` bounds a caller's `limit` with the same number the reader clamps to, and it used to
7
+ * read it out of `store/threads.ts` — the Kysely reader, which brings `kysely`, `kysely-d1` and
8
+ * `@cloudflare/workers-types` with it. A request schema is a client's business: a management client
9
+ * building a call must be able to compile the shape it may send, in a browser, with no Worker types in
10
+ * reach. So the two numbers moved and the reader kept the query.
11
+ *
12
+ * **Both moved, not only the one the schema needs.** They are read together — a default clamped into a
13
+ * maximum, on one line, in two queries — and a pair split across two modules is how the next person
14
+ * picks the wrong one.
15
+ */
16
+ /** How many threads a page holds by default. */
17
+ export declare const DEFAULT_PAGE_SIZE = 25;
18
+ /** The most a caller may ask for in one page. */
19
+ export declare const MAX_PAGE_SIZE = 100;
20
+ //# sourceMappingURL=paging.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paging.d.ts","sourceRoot":"","sources":["../../src/store/paging.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AAEH,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,iDAAiD;AACjD,eAAO,MAAM,aAAa,MAAM,CAAC"}