@pithy-sh/support 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/dist/ai/classify.d.ts +90 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +151 -0
  4. package/dist/attachment/store.d.ts +53 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +67 -0
  7. package/dist/audit/actions.d.ts +68 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +68 -0
  10. package/dist/capability.d.ts +93 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +187 -0
  13. package/dist/client/projection.d.ts +56 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +0 -0
  16. package/dist/config/config.d.ts +215 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +169 -0
  19. package/dist/data/attachment.d.ts +30 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +32 -0
  22. package/dist/data/billingScope.d.ts +30 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +32 -0
  25. package/dist/data/categories.d.ts +54 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +97 -0
  28. package/dist/data/classification.d.ts +34 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +28 -0
  31. package/dist/data/enums.d.ts +82 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +70 -0
  34. package/dist/data/flag.d.ts +25 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +27 -0
  37. package/dist/data/message.d.ts +81 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +84 -0
  40. package/dist/data/tables.d.ts +160 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +44 -0
  43. package/dist/data/thread.d.ts +77 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +65 -0
  46. package/dist/error/errors.d.ts +64 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +82 -0
  49. package/dist/http/guards.d.ts +44 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +53 -0
  52. package/dist/http/handlers.d.ts +89 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +248 -0
  55. package/dist/http/resolve.d.ts +44 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +72 -0
  58. package/dist/http/responses.d.ts +829 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +303 -0
  61. package/dist/http/routes.d.ts +109 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +62 -0
  64. package/dist/http/schemas.d.ts +121 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +89 -0
  67. package/dist/http/scopes.d.ts +56 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +107 -0
  70. package/dist/http/views.d.ts +36 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +144 -0
  73. package/dist/inbound/authenticity.d.ts +81 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +61 -0
  76. package/dist/inbound/guard.d.ts +74 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +78 -0
  79. package/dist/inbound/handler.d.ts +28 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +82 -0
  82. package/dist/inbound/ingest.d.ts +104 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +326 -0
  85. package/dist/inbound/recipient.d.ts +16 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +46 -0
  88. package/dist/index.d.ts +20 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +14 -0
  91. package/dist/link/sender.d.ts +160 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +200 -0
  94. package/dist/migrations/0001_threads.d.ts +26 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +103 -0
  97. package/dist/mime/address.d.ts +22 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +28 -0
  100. package/dist/mime/parse.d.ts +96 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +146 -0
  103. package/dist/mime/sanitize.d.ts +25 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +212 -0
  106. package/dist/mime/threading.d.ts +79 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +111 -0
  109. package/dist/mime/truncate.d.ts +17 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +47 -0
  112. package/dist/provision/provisionSupport.d.ts +158 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +110 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +45 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +22 -0
  118. package/dist/reply/send.d.ts +123 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +153 -0
  121. package/dist/reply/snippets.d.ts +79 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +135 -0
  124. package/dist/secret/registry.d.ts +18 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +21 -0
  127. package/dist/seeds/example.d.ts +3 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +350 -0
  130. package/dist/store/paging.d.ts +18 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +20 -0
  133. package/dist/store/search.d.ts +67 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +160 -0
  136. package/dist/store/searchIndex.d.ts +53 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +66 -0
  139. package/dist/store/threads.d.ts +147 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +197 -0
  142. package/dist/submission/encoding.d.ts +21 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +55 -0
  145. package/dist/submission/guard.d.ts +71 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +46 -0
  148. package/dist/submission/submit.d.ts +152 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +319 -0
  151. package/dist/version.generated.d.ts +5 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +7 -0
  154. package/dist/workflows/classify.d.ts +63 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +68 -0
  157. package/dist/workflows/retryPolicy.d.ts +39 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +43 -0
  160. package/dist/workflows/specs.d.ts +43 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +46 -0
  163. package/dist/workflows/worker.d.ts +48 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +37 -0
  166. package/package.json +24 -15
  167. package/src/version.generated.ts +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"responses.d.ts","sourceRoot":"","sources":["../../src/http/responses.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,303 @@
1
+ import { SupportAccountLinkSource, SupportChannel, SupportMessageDirection, SupportPriority, SupportSentiment } from "../data/enums.js";
2
+ import { SupportSubmissionContext } from "../data/message.js";
3
+ import { SUPPORT_BILLING_SCOPE } from "../data/billingScope.js";
4
+ import { z } from "zod";
5
+ import { asRead } from "@pithy-sh/core/src/projection/asRead";
6
+ //#region src/http/responses.ts
7
+ /**
8
+ * What the support routes return, as Zod objects a management client can validate against.
9
+ *
10
+ * `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
11
+ * runtime values for the same reason: a management client reading a customer's Worker is crossing a
12
+ * trust boundary and must validate what comes back, and a TypeScript interface is erased before it
13
+ * can help — so every client that had only an interface hand-wrote a mirror, and the mirror drifted
14
+ * the first time a field landed here.
15
+ *
16
+ * **Dates are ISO-8601 strings, and that is not a change.** These handlers used to hand `Date` objects
17
+ * to `c.json`, which serializes them as ISO strings anyway — so the bytes on the wire were always
18
+ * this, while the type said otherwise and no schema could describe it. `views.ts` now converts
19
+ * explicitly, which is what makes the shape statable at all.
20
+ *
21
+ * **No codecs, and no transform anywhere in this file.** These describe JSON on the wire, so parsing
22
+ * one hands back exactly what went in — which is what lets `responses.test.ts` compare a parsed value
23
+ * with the projection's output and fail on a field either side forgot.
24
+ *
25
+ * **A browser imports this file, so it reaches no module that needs the Workers runtime.** That is not
26
+ * a preference about bundle size; it is what "a client validates what comes back" means once the client
27
+ * is a Vite program with the DOM lib and no Workers types. This file imported `SUPPORT_BILLING_SCOPE`
28
+ * from `link/sender.ts` for one release and brought D1, Kysely and two capabilities' table maps with it,
29
+ * which took `pithy-sh/dashboard`'s client build down on an error in `@pithy-sh/auth` (#419). The rule is
30
+ * held by `tooling/browser-scopes`, which compiles this module with `types: []` and asks the compiler
31
+ * which files that program included.
32
+ *
33
+ * **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
34
+ * version boundary — a management client validates a response with this schema against a customer's
35
+ * Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
36
+ * below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
37
+ * say*, which is a different fact from `null`.
38
+ */
39
+ /** Where a page resumes, or the end of the list. */
40
+ const NextCursor = z.string().nullable().describe("Where the next page resumes. Null at the end of the list. Opaque — pass it back verbatim.");
41
+ /** One conversation, as the inbox and the reading pane show it. */
42
+ const SupportThreadView = z.object({
43
+ id: z.string().describe("The thread's UUID — what every other route on this surface takes."),
44
+ channel: SupportChannel.describe("How this conversation started — `email` at an inbound address, or `app` from a signed-in user of the adopter's own app."),
45
+ inboxAddress: z.string().nullable().describe("The support address this thread arrived on, lowercased, and the address a reply comes back to. Null only on an `app` thread in a project with no inbound address configured."),
46
+ subject: z.string().describe("The subject of the message that opened the thread. Later replies never rewrite it."),
47
+ fromAddress: z.string().describe("The sender's address, lowercased."),
48
+ fromName: z.string().nullable().describe("The sender's display name, or null. Untrusted text; render it escaped."),
49
+ senderAuthenticated: z.boolean().describe("Whether the sender was proved to be who they claim. On `email` that means the `From:` header was proved, and false means unproven rather than forged — it is what withholds the customer link. Always true on `app`, where a session proved it before the request arrived."),
50
+ userId: z.string().nullable().describe("The user this sender resolves to, or null when nobody or unproven."),
51
+ accountLinkSource: SupportAccountLinkSource.nullable().describe("How `userId` was established, or null when there is no link. **Render these differently.** `session` means an authenticated request proved it and the account *is* the caller; `email_address` means it was matched against an address in a header nobody proved. The same operator action — a refund, a reset — follows from very different evidence."),
52
+ declaredCategory: z.string().nullable().describe("**What the submitter said this is about**, or null when nobody said — every mail thread, and every app thread filed without a chooser. **Render this and `category` differently, and never fold one into the other.** They are two facts with two authors: this is a claim by the person writing, `category` is a machine's judgment about them, and an operator deciding what to do needs to know which is on screen. Where they disagree, that disagreement is the most useful thing on the row. Where `classifiedAt` is null this is the only category anybody stated."),
53
+ category: z.string().describe("**What the classifier made of it** — the current category key, from this project's own federated taxonomy. `uncategorized` until a classification lands, and forever on a project with `ai.enabled: false`."),
54
+ priority: SupportPriority.describe("How fast this thread needs a human."),
55
+ sentiment: SupportSentiment.describe("How the sender sounds — the churn signal."),
56
+ confidence: z.number().min(0).max(1).nullable().describe("The model's confidence in the current classification, or null until one lands."),
57
+ model: z.string().nullable().describe("The Workers AI model that produced the classification, or null."),
58
+ classifiedAt: z.iso.datetime().nullable().describe("When the classification was written, ISO-8601; null until it is."),
59
+ archived: z.boolean().describe("Done. One shared boolean, not per viewer."),
60
+ archivedAt: z.iso.datetime().nullable().describe("When it was last archived, ISO-8601; null while open."),
61
+ archivedBy: z.string().nullable().describe("The control-plane subject that archived it, for convenience only — the answer to 'who marked this done' comes from the audit trail."),
62
+ messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
63
+ firstMessageAt: z.iso.datetime().describe("When the thread opened, ISO-8601."),
64
+ lastMessageAt: z.iso.datetime().describe("When the thread last moved, ISO-8601. The inbox sorts on this."),
65
+ createdAt: z.iso.datetime().describe("When the thread row was created, ISO-8601."),
66
+ updatedAt: z.iso.datetime().describe("When the thread row was last written, ISO-8601.")
67
+ }).describe("One support conversation, as a management client sees it.");
68
+ /**
69
+ * A thread in the inbox listing — the thread plus this viewer's own private state.
70
+ *
71
+ * Re-described after `.extend()`: extending builds a new schema, and a description does not follow it.
72
+ */
73
+ const SupportListedThreadView = SupportThreadView.extend({
74
+ read: z.boolean().describe("Whether this viewer has read it. False when they have no flag row, the common case."),
75
+ snoozedUntil: z.iso.datetime().nullable().describe("When this viewer's snooze expires, ISO-8601, or null.")
76
+ }).describe("One conversation in the inbox, carrying the calling viewer's own read and snooze state.");
77
+ /** One message in a conversation. */
78
+ const SupportMessageView = z.object({
79
+ id: z.string().describe("The message's UUID — Pithy's id, not the sender's."),
80
+ direction: SupportMessageDirection.describe("`inbound` from the customer, `outbound` from this Worker."),
81
+ channel: SupportChannel.describe("How this message traveled, which on an outbound row says how the answer was delivered: `email` means it went out, `app` means it is waiting for the submitter to read it and no mail was sent."),
82
+ context: SupportSubmissionContext.nullable().describe("The bounded context an app submission carried — screen, build, platform, environment, locale. Null on every mail-path message."),
83
+ fromAddress: z.string().nullable().describe("The address this message was sent from, lowercased. Null on an answer delivered in the app, which left no envelope."),
84
+ fromName: z.string().nullable().describe("The sender's display name, or null. Untrusted text."),
85
+ toAddress: z.string().nullable().describe("The address this message was addressed to, lowercased. Null on an app submission with no envelope."),
86
+ subject: z.string().describe("This message's own subject, which may differ from the thread's."),
87
+ textBody: z.string().describe("The plain-text body."),
88
+ htmlBody: z.string().nullable().describe("The sanitized HTML body, or null. Sanitized at ingest; the raw original stays in R2 and is never served."),
89
+ emailJobId: z.string().nullable().describe("The email job this message was enqueued as. Present exactly when `direction` is `outbound` and `channel` is `email`; read `channel` to ask whether an answer went out, never the absence of this."),
90
+ receivedAt: z.iso.datetime().describe("When the message arrived or was sent, ISO-8601.")
91
+ }).describe("One message in a conversation, with the threading internals dropped.");
92
+ /** One attachment — metadata plus a short-lived URL, never the storage key. */
93
+ const SupportAttachmentView = z.object({
94
+ id: z.string().describe("The attachment's UUID."),
95
+ filename: z.string().describe("The filename as it arrived, sanitized."),
96
+ contentType: z.string().describe("The declared content type."),
97
+ size: z.number().int().describe("The size in bytes."),
98
+ sha256: z.string().describe("The content digest, so a client can tell two identical attachments apart from one."),
99
+ inline: z.boolean().describe("Whether it was referenced inline in the HTML body rather than attached."),
100
+ url: z.string().nullable().describe("A signed URL valid for a few minutes, or null when no credentials were available to sign one. The `storageKey` is never here: it is server-derived precisely so a client cannot name an object or guess the one beside it.")
101
+ }).describe("One attachment as a client receives it — metadata and a signed URL, never the storage key.");
102
+ /** One of the sender's purchases, flattened to what a support console renders. */
103
+ const SenderPurchaseView = z.object({
104
+ id: z.string().describe("The purchase row id."),
105
+ rail: z.string().describe("Which rail it went through — `apple`, `google`, or `stripe`."),
106
+ productId: z.string().describe("The Pithy product key."),
107
+ status: z.string().describe("Its lifecycle state — `active`, `refunded`, `expired`, and so on."),
108
+ environment: z.string().describe("Whether it happened in the store's sandbox rather than production."),
109
+ purchasedAt: z.iso.datetime().describe("When it was bought, ISO-8601."),
110
+ expiresAt: z.iso.datetime().nullable().describe("When it runs out, ISO-8601; null for something owned forever."),
111
+ revokedAt: z.iso.datetime().nullable().describe("When it was refunded or revoked, ISO-8601; null while it stands.")
112
+ }).describe("One purchase beside the sender, so an operator answering a billing question need not go and look.");
113
+ /** One of the sender's entitlements, lapsed ones included and marked inactive. */
114
+ const SenderEntitlementView = z.object({
115
+ key: z.string().describe("The entitlement key."),
116
+ active: z.boolean().describe("Whether it granted when the projection last wrote it."),
117
+ expiresAt: z.iso.datetime().nullable().describe("When the grant lapses, ISO-8601; null when it never does."),
118
+ source: z.string().nullable().describe("Opaque provenance — a purchase id, or a support grant.")
119
+ }).describe("One entitlement beside the sender. Lapsed ones are here too — a paywall wants to say when Pro ended.");
120
+ /**
121
+ * Which billing subject the purchase and entitlement lists cover — always `user`, and said out loud.
122
+ *
123
+ * `@pithy-sh/payments` keys a purchase on a **subject pair**, a user or an organization. Support starts
124
+ * from a `From:` header and resolves a *person*; at thread-read time it holds no Hono `Context`, so it
125
+ * cannot ask the adopter's subject seam which organization this caller is acting for. It therefore reads
126
+ * `user`-subject rows and nothing else.
127
+ *
128
+ * **A field rather than a footnote, because empty already means too many things.** The purchase and
129
+ * entitlement lookups are guarded dynamic imports whose `catch` returns `[]`, so an empty panel is
130
+ * indistinguishable between "bought nothing", "`@pithy-sh/payments` is not installed" and "billed to an
131
+ * organization this seam cannot see" — and an operator looking at a customer with a live subscription
132
+ * reads it as the first. That is the actual hazard, and it is a wrong refund decision away from costing
133
+ * something. Declaring the scope on the wire lets a console say *why* the panel is thin.
134
+ *
135
+ * **A closed literal, not a string.** Widening it is then a compile error at every consumer, which is
136
+ * correct: the day support can resolve an organization, every panel has to decide what to render.
137
+ */
138
+ const SenderBillingScope = z.literal(SUPPORT_BILLING_SCOPE).describe("Which billing subject the purchase and entitlement lists cover. Always `user`: support resolves a person from an address and cannot reach the adopter's subject seam, so an organization-billed customer's purchases are not shown here.");
139
+ /**
140
+ * What the app already knows about the sender. Derived at read time, so it is always current.
141
+ *
142
+ * **Everything but `authenticated` and `billingScope` is empty when the sender is unproven.** The whole
143
+ * value of the panel is that an operator trusts it, so it must not be populated from an address anybody
144
+ * could have written — that is what turns a spoofed message into support-driven account takeover. The
145
+ * scope is declared either way: it describes what this seam *can* see, not what it found.
146
+ */
147
+ const SenderContextView = z.object({
148
+ authenticated: z.boolean().describe("Whether the `From:` header was proved to belong to the sender."),
149
+ userId: z.string().nullable().describe("The linked user id, or null when this address belongs to nobody."),
150
+ name: z.string().optional().describe("The account's display name, when auth is composed and the sender is known."),
151
+ emailVerified: z.boolean().optional().describe("Whether the account has verified this address."),
152
+ billingScope: SenderBillingScope,
153
+ purchases: z.array(SenderPurchaseView).describe("Their purchase history, newest first, bounded. Empty when none."),
154
+ entitlements: z.array(SenderEntitlementView).describe("Their entitlements, lapsed ones marked inactive.")
155
+ }).describe("The customer context beside a conversation, withheld entirely when the sender is unproven.");
156
+ /** One canned reply, ordered for this thread's category. */
157
+ const SupportReplyView = z.object({
158
+ key: z.string().describe("The snippet's key in the catalog."),
159
+ label: z.string().describe("What the picker shows, e.g. `Refund issued`."),
160
+ category: z.string().optional().describe("The category it is offered first for. Absent means always offered."),
161
+ body: z.string().describe("The reply text, ready to edit.")
162
+ }).describe("One canned reply from the effective catalog.");
163
+ /** `GET {base}/threads`. */
164
+ const SupportThreadsResponse = z.object({
165
+ threads: z.array(SupportListedThreadView).describe("The page, newest first."),
166
+ nextCursor: NextCursor
167
+ }).describe("A page of the inbox, as one viewer sees it.");
168
+ /** `GET {base}/threads/:id`. */
169
+ const SupportThreadResponse = z.object({
170
+ thread: SupportThreadView.describe("The conversation."),
171
+ messages: z.array(SupportMessageView).describe("Its messages, oldest first — a conversation reads downward."),
172
+ attachments: z.array(SupportAttachmentView).describe("Its attachments, across every message."),
173
+ sender: SenderContextView.describe("What the app already knows about the sender."),
174
+ replies: z.array(SupportReplyView).describe("The canned replies, ordered for this thread's category.")
175
+ }).describe("A whole conversation, with the customer context and the replies worth offering on it.");
176
+ /**
177
+ * `POST {base}/threads/:id/archive`.
178
+ *
179
+ * The thread itself rather than an envelope around it: archiving is a write whose entire result is the
180
+ * new state of the row, and a pane re-renders from it.
181
+ */
182
+ const SupportArchiveResponse = SupportThreadView.describe("The conversation, as the archive left it.");
183
+ /**
184
+ * `POST {base}/threads/:id/reply`.
185
+ *
186
+ * **A union on `channel`, not an object with an optional `jobId`.** A reply either left by mail or was
187
+ * stored for the submitter to read in the app, and those are different promises about when somebody
188
+ * sees the answer. An optional field is exactly what lets a console render "sent" over both of them,
189
+ * so the two arms are structurally different and a client has to branch to read either one.
190
+ */
191
+ const SupportReplySentResponse = z.discriminatedUnion("channel", [z.object({
192
+ channel: z.literal("email").describe("The reply was handed to the durable send path. It is on its way."),
193
+ messageId: z.string().describe("The outbound message row this reply created."),
194
+ jobId: z.string().describe("The email job it was enqueued as. The send itself is a Workflow's job.")
195
+ }).describe("A reply that was queued for sending, by message and by mail job."), z.object({
196
+ channel: z.literal("app").describe("The reply was stored, and no mail was sent. The submitter reads it next time they open the conversation — which means nobody has told them yet, and telling them is the application's call."),
197
+ messageId: z.string().describe("The outbound message row this reply created.")
198
+ }).describe("A reply that is waiting in the app. There is no mail job, because there was no send.")]).describe("The reply that was filed, and which of the two ways it reaches the customer.");
199
+ /** `POST {base}/threads/:id/reclassify`. */
200
+ const SupportReclassifiedResponse = z.object({ messageId: z.string().describe("The latest inbound message the classifier was started against.") }).describe("Which message a reclassification was started for.");
201
+ /**
202
+ * `POST {base}/threads/:id/flags`.
203
+ *
204
+ * `ok` and nothing else. A private read flag is not audited and carries no state a client did not
205
+ * just send, so echoing the thread back would only invite somebody to render it.
206
+ */
207
+ const SupportFlagsResponse = z.object({ ok: z.literal(true).describe("Always true. The write either happened or the request failed.") }).describe("The acknowledgement of one viewer's private flags.");
208
+ /**
209
+ * ## The reader's contracts, beside the producers above
210
+ *
211
+ * Everything above this line is stated for a Worker checking its own projection, and it is strict on
212
+ * purpose: `SupportChannel` and `SupportAccountLinkSource` are what `inbound/` branches on and what D1
213
+ * holds, and a tolerated-unknown member there would license this capability to *store* a channel it
214
+ * cannot handle. Nothing below changes any of that. The enums are untouched, and so is every schema
215
+ * above.
216
+ *
217
+ * Below is the same projection for the other consumer: a management client reading a Worker **it does
218
+ * not control**. That client is crossing a trust boundary — a fork, a bug, a half-finished deploy, or a
219
+ * hostile Worker each put a member here that no enum of ours declares — and under the strict schema one
220
+ * such token costs it the entire response. It cost `pithy-sh/dashboard#15` exactly that: one
221
+ * unrecognized `channel` and the support pane rendered zero of twenty-five conversations. The client
222
+ * then had to widen our shape locally, which is the hand-held mirror `#113` exists to forbid.
223
+ *
224
+ * So the tolerance is published rather than reimplemented, by the one pattern
225
+ * `@pithy-sh/core/src/projection/asRead` states for the whole kit: same objects, every enum read as a
226
+ * string, every other field the identical schema instance.
227
+ *
228
+ * **What a client does with a widened value is ask the enum.** `SupportChannel.safeParse(value).success`
229
+ * answers *does this capability declare it*, and a value it does not declare is **marked, never mapped**
230
+ * — rendering an unknown provenance as the nearest one you know is a lie about evidence on the screen
231
+ * where somebody decides whether to act on a stranger's request.
232
+ *
233
+ * **Only the management surface.** The submitter's own views stay strict: an app reading its own
234
+ * Worker's `/feedback` is not reading a stranger, and `SupportReplySentResponse` is a union discriminated
235
+ * on a literal the client itself sent, where tolerating an unknown arm would mean tolerating a response
236
+ * with no shape at all.
237
+ */
238
+ /** {@link SupportThreadView}, as a client that does not control the Worker must read it. */
239
+ const SupportThreadViewAsRead = asRead(SupportThreadView);
240
+ /** {@link SupportListedThreadView}, as a client that does not control the Worker must read it. */
241
+ const SupportListedThreadViewAsRead = asRead(SupportListedThreadView);
242
+ /** {@link SupportMessageView}, as a client that does not control the Worker must read it. */
243
+ const SupportMessageViewAsRead = asRead(SupportMessageView);
244
+ /** `GET {base}/threads`, as a client that does not control the Worker must read it. */
245
+ const SupportThreadsResponseAsRead = asRead(SupportThreadsResponse);
246
+ /** `GET {base}/threads/:id`, as a client that does not control the Worker must read it. */
247
+ const SupportThreadResponseAsRead = asRead(SupportThreadResponse);
248
+ /** `POST {base}/threads/:id/archive`, as a client that does not control the Worker must read it. */
249
+ const SupportArchiveResponseAsRead = asRead(SupportArchiveResponse);
250
+ /**
251
+ * ## The submitter's own view, and what it deliberately omits
252
+ *
253
+ * Everything above this line is written for a **management client**: an operator triaging an inbox,
254
+ * who needs the classification to sort by and the sender's purchase history to act on. Everything
255
+ * below is written for the **person who wrote the message**, and it is a different object rather than
256
+ * the same one with fields nulled — because a projection that starts from the operator's shape leaks
257
+ * the first time somebody adds a column and forgets which of the two callers is reading.
258
+ *
259
+ * A submitter is shown their own words, the answers to them, and whether the thread is done. They are
260
+ * never shown the classification (a machine's judgment about them, and a filter on it is the inbox's
261
+ * business), the priority or sentiment (the same, and `angry` rendered back to the person it describes
262
+ * is its own kind of disaster), the per-viewer flags (private to an operator), the account link or its
263
+ * provenance (they know who they are), or anything at all about another account.
264
+ */
265
+ /** One of the submitter's own conversations. */
266
+ const SupportMyThreadView = z.object({
267
+ id: z.string().describe("The conversation's id — what the read-back and a follow-up submission take."),
268
+ subject: z.string().describe("What they called it when they opened it. Later messages never rewrite it."),
269
+ resolved: z.boolean().describe("Whether support has marked this done. Named for the reader rather than mirroring the column: `archived` is an inbox's word for a thread it has finished with, and to the person waiting on an answer the fact is that it was resolved. Writing again reopens it."),
270
+ messageCount: z.number().int().describe("How many messages the conversation holds, theirs and the answers."),
271
+ lastMessageAt: z.iso.datetime().describe("When the conversation last moved, ISO-8601. The list sorts on this."),
272
+ createdAt: z.iso.datetime().describe("When they opened it, ISO-8601.")
273
+ }).describe("One of the caller's own in-app support conversations, as the person who opened it sees it.");
274
+ /** One message in the submitter's own conversation. */
275
+ const SupportMyMessageView = z.object({
276
+ id: z.string().describe("The message's id."),
277
+ direction: SupportMessageDirection.describe("`inbound` is theirs, `outbound` is the answer. The only identity on this view: who answered is a person's name in the body if they signed it, and never an operator's account."),
278
+ body: z.string().describe("The message text. Plain text on both sides — what they sent, and what was sent back. Never HTML, so a client renders it escaped and there is nothing here to sanitize."),
279
+ context: SupportSubmissionContext.nullable().describe("The context their app attached to this message, so a client can show what was sent on their behalf. Null on an answer."),
280
+ attachments: z.array(SupportAttachmentView).describe("What they attached to this message. Empty on an answer."),
281
+ sentAt: z.iso.datetime().describe("When it was sent, ISO-8601.")
282
+ }).describe("One message in the caller's own conversation — their words, or the answer to them.");
283
+ /** `POST {base}/feedback`. */
284
+ const SupportSubmissionResponse = z.object({
285
+ threadId: z.string().describe("The conversation it landed in — a new one, or the one it continued."),
286
+ messageId: z.string().describe("The message that was stored."),
287
+ opened: z.boolean().describe("True when this opened a conversation, false when it continued one. A client that sent no `threadId` and gets false has hit nothing surprising — it cannot happen — so this is here for the client that sent one and wants to confirm which."),
288
+ attachments: z.number().int().describe("How many attachments were stored. Reported rather than assumed: a deployment with no bucket bound stores the report and none of its files, and a client that showed a paperclip should be able to say so.")
289
+ }).describe("What an in-app support request produced.");
290
+ /** `GET {base}/feedback`. */
291
+ const SupportMyThreadsResponse = z.object({
292
+ threads: z.array(SupportMyThreadView).describe("The caller's own conversations, newest first."),
293
+ nextCursor: NextCursor
294
+ }).describe("A page of the caller's own support conversations.");
295
+ /** `GET {base}/feedback/:id`. */
296
+ const SupportMyThreadResponse = z.object({
297
+ thread: SupportMyThreadView.describe("The conversation."),
298
+ messages: z.array(SupportMyMessageView).describe("Its messages, oldest first — a conversation reads downward.")
299
+ }).describe("One of the caller's own conversations, in full.");
300
+ /** `GET {base}/replies`. */
301
+ const SupportRepliesResponse = z.object({ replies: z.array(SupportReplyView).describe("The catalog, ordered for the requested category.") }).describe("The canned reply catalog.");
302
+ //#endregion
303
+ export { SenderBillingScope, SenderContextView, SenderEntitlementView, SenderPurchaseView, SupportArchiveResponse, SupportArchiveResponseAsRead, SupportAttachmentView, SupportFlagsResponse, SupportListedThreadView, SupportListedThreadViewAsRead, SupportMessageView, SupportMessageViewAsRead, SupportMyMessageView, SupportMyThreadResponse, SupportMyThreadView, SupportMyThreadsResponse, SupportReclassifiedResponse, SupportRepliesResponse, SupportReplySentResponse, SupportReplyView, SupportSubmissionResponse, SupportThreadResponse, SupportThreadResponseAsRead, SupportThreadView, SupportThreadViewAsRead, SupportThreadsResponse, SupportThreadsResponseAsRead };
@@ -0,0 +1,109 @@
1
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
2
+ import type { Context, Hono } from "hono";
3
+ import { type HandlerDeps } from "./handlers";
4
+ /**
5
+ * The support routes, their verification strategies, and what each accepts:
6
+ *
7
+ * GET /support/threads → the inbox (control-plane: support:threads:read) query: ListThreadsQuery
8
+ * GET /support/threads/:id → one thread (control-plane: support:threads:read) param: ThreadIdParam
9
+ * POST /support/threads/:id/archive → done/reopen (control-plane: support:threads:archive) param + json
10
+ * POST /support/threads/:id/reply → answer (control-plane: support:threads:reply) param + json
11
+ * POST /support/threads/:id/reclassify→ re-run model (control-plane: support:threads:reclassify) param
12
+ * POST /support/threads/:id/flags → read/snooze (control-plane: support:threads:flag) param + json
13
+ * GET /support/replies → canned copy (control-plane: support:threads:read) query: RepliesQuery
14
+ *
15
+ * POST /support/feedback → write in (bearer/session + same-origin) json: SubmitFeedbackInput
16
+ * GET /support/feedback → my requests (bearer/session) query: MyThreadsQuery
17
+ * GET /support/feedback/:id → one of mine (bearer/session) param: ThreadIdParam
18
+ *
19
+ * **Two surfaces, two gates, and they never stack.** The management routes answer to a control-plane
20
+ * credential the adopter issued, and with the seam uncomposed every one denies with
21
+ * `controlplane/not_connected` — the correct failure for an inbox holding other people's private
22
+ * correspondence. The `feedback` routes answer to the adopter's own signed-in user, and with no auth
23
+ * capability composed `c.var.auth` is null and every one denies. A control-plane caller holds no
24
+ * session by design and can never satisfy `requireAuth()`; a user's session confers no scope. See
25
+ * `guards.ts`.
26
+ *
27
+ * **`requireSameOrigin()` is on the submission route and on neither read.** Cookie-mode sessions make a
28
+ * mutating route CSRF-reachable, and this one writes into a support inbox under a real customer's name
29
+ * — a forged submission is somebody else's words attributed to them, in the one place an operator
30
+ * treats attribution as proven. The reads are GETs and carry no such risk. A bearer caller is
31
+ * CSRF-exempt, and that exemption belongs to the gate `@pithy-sh/auth` publishes rather than to
32
+ * anything decided here.
33
+ *
34
+ * **Every response has an exported schema**, in `responses.ts`. Each handler's return type is
35
+ * `z.output` of its envelope, so `c.json(await handler(...))` carries the contract without a cast — and
36
+ * a management client imports the same object and validates with it rather than hand-writing a mirror
37
+ * that drifts.
38
+ *
39
+ * The validators sit **after** the gate on every line. A validator ahead of it turns a 401 into a
40
+ * 400 and tells an unverified caller which requests were well-formed — and on this surface that is a
41
+ * live oracle for the shape of an adopter's support tooling.
42
+ *
43
+ * ## An adopter's own authorization on the submission route
44
+ *
45
+ * **This capability gates `POST {base}/feedback` on a session and same-origin, and on nothing else,
46
+ * permanently.** Writing to support must not be role-gated or it stops being a general intake: the
47
+ * person who most needs to reach support is often the one whose access is broken, and a role the kit
48
+ * invented would make one adopter's account model a condition on everybody's ability to report a bug.
49
+ *
50
+ * But an adopter whose *own* model makes some submissions act-on-behalf-of — `pithy-sh/dashboard#10`'s
51
+ * discount application is made for an organization, and a member may not make one — needs somewhere to
52
+ * put that check, and it must not be the client. **The seam for it already exists, in the composition
53
+ * contract rather than in this capability's config**, and it is documented here because it was not
54
+ * discoverable rather than because it was missing:
55
+ *
56
+ * ```ts
57
+ * // the adopter's own `app` capability
58
+ * defineCapability({
59
+ * name: "app",
60
+ * middleware: [
61
+ * (app) => {
62
+ * app.use("/support/feedback", async (c, next) => {
63
+ * // c.var.auth is already populated — @pithy-sh/auth's session middleware is a library's, and
64
+ * // every capability's middleware mounts before any capability's routes.
65
+ * if (c.var.auth && !(await mayWriteOnBehalfOfTheOrganization(c))) throw new ForbiddenError({ … });
66
+ * await next();
67
+ * });
68
+ * },
69
+ * ],
70
+ * …
71
+ * });
72
+ * ```
73
+ *
74
+ * `createBackend` mounts **every** capability's middleware before **any** capability's routes, and the
75
+ * adopter's `app` capability composes last — so their middleware runs after auth has resolved the
76
+ * session and before this file's `requireAuth()`. `createBackend.workers.test.ts` pins that ordering,
77
+ * because a paragraph asserting it is not the same as a test failing when it changes.
78
+ *
79
+ * Two consequences worth stating rather than discovering. Their middleware sees `c.var.auth` as **null**
80
+ * on an unauthenticated request, since it runs ahead of the route's own gate — so it should pass those
81
+ * through and let `requireAuth()` answer 401, rather than 403 a caller who was never signed in. And the
82
+ * path is theirs to write, from the `basePath` they configured; a mount point they changed and a
83
+ * middleware path they did not is a gate that silently stops covering anything.
84
+ *
85
+ * **A `beforeSubmit` callback in `SupportConfig` was the tempting alternative and is the wrong shape.**
86
+ * The need is not support's — every capability with a write route has it — so solving it once per
87
+ * capability would give an adopter a different mechanism per package, each with its own signature and
88
+ * its own answer to "what is in scope here". A config file is also the wrong home for an authorization
89
+ * decision that wants `c.var`, and a second place to look for the gates on a route is how a Worker ends
90
+ * up with two of them, free to disagree.
91
+ */
92
+ /** How the support sub-router is built. */
93
+ export interface SupportRoutesOptions {
94
+ /** The path the routes mount under. Defaults to `/support`. */
95
+ basePath?: string;
96
+ /**
97
+ * Whether to mount the in-app submission routes. Defaults to true, matching `submission.enabled`.
98
+ *
99
+ * Not mounting is deliberate rather than a guard inside the handlers: a route that is not served
100
+ * answers 404, and 404 is the honest answer. A 403 would say "this exists and you may not use it" to
101
+ * a caller asking about a feature this deployment does not have.
102
+ */
103
+ submission?: boolean;
104
+ /** Resolve handler deps from the request context. */
105
+ resolveDeps: (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
106
+ }
107
+ /** Register the support sub-router. Returned as the capability's `routes` hook. */
108
+ export declare function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono<PithyHonoEnv>) => void;
109
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/http/routes.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAK7E,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE1C,OAAO,EAEL,KAAK,WAAW,EAUjB,MAAM,YAAY,CAAC;AAoBpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuFG;AAEH,2CAA2C;AAC3C,MAAM,WAAW,oBAAoB;IACnC,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,qDAAqD;IACrD,WAAW,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;CACjE;AAuCD,mFAAmF;AACnF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,oBAAoB,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,CAAC,KAAK,IAAI,CAqFtG"}
@@ -0,0 +1,62 @@
1
+ import { requireAuth } from "./guards.js";
2
+ import { archiveConversation, listInbox, listMyThreads, listReplies, readConversation, readMyThread, reclassifyConversation, replyToConversation, submitFeedbackRequest, updateFlags } from "./handlers.js";
3
+ import { ArchiveThreadInput, FlagsInput, ListThreadsQuery, MyThreadsQuery, RepliesQuery, ReplyInput, SubmitFeedbackInput, ThreadIdParam } from "./schemas.js";
4
+ import { SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE } from "./scopes.js";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { zValidator } from "@hono/zod-validator";
7
+ import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
8
+ import { requireSameOrigin } from "@pithy-sh/core/src/http/sameOrigin";
9
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
10
+ //#region src/http/routes.ts
11
+ /**
12
+ * The verified management client behind a control-plane call.
13
+ *
14
+ * `requireControlPlane()` has run on every route, so `c.var.controlPlane` is populated by the time a
15
+ * handler reads it. The throw is a programming-error guard, not a runtime path: reaching it would
16
+ * mean a route was mounted without its gate, which is the one mistake this whole file is arranged to
17
+ * make impossible.
18
+ */
19
+ function viewer(c) {
20
+ const subject = c.var.controlPlane?.subject;
21
+ if (!subject) throw new InternalError({
22
+ message: "Support could not identify the management caller.",
23
+ detail: "requireControlPlane() must run before a support handler reads the caller."
24
+ });
25
+ return subject;
26
+ }
27
+ /**
28
+ * The signed-in user behind an in-app submission.
29
+ *
30
+ * `requireAuth()` has run on every route that calls this, so `c.var.auth` is populated by the time a
31
+ * handler reads it. The throw is a programming-error guard rather than a runtime path — the mirror of
32
+ * {@link viewer}, and the reason neither handler ever has to think about an absent caller.
33
+ */
34
+ function submitter(c) {
35
+ const auth = c.var.auth;
36
+ if (!auth) throw new InternalError({
37
+ message: "Support could not identify the signed-in caller.",
38
+ detail: "requireAuth() must run before a support submission handler reads the submitter."
39
+ });
40
+ return auth.userId;
41
+ }
42
+ /** Register the support sub-router. Returned as the capability's `routes` hook. */
43
+ function registerSupportRoutes(options) {
44
+ const base = options.basePath ?? "/support";
45
+ const resolve = options.resolveDeps;
46
+ const submission = options.submission ?? true;
47
+ return (app) => {
48
+ app.get(`${base}/threads`, requireControlPlane(SUPPORT_THREADS_READ_SCOPE), zValidator("query", ListThreadsQuery, validationHook), async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))));
49
+ app.get(`${base}/replies`, requireControlPlane(SUPPORT_THREADS_READ_SCOPE), zValidator("query", RepliesQuery, validationHook), async (c) => c.json({ replies: listReplies(await resolve(c), c.req.valid("query")) }));
50
+ app.get(`${base}/threads/:id`, requireControlPlane(SUPPORT_THREADS_READ_SCOPE), zValidator("param", ThreadIdParam, validationHook), async (c) => c.json(await readConversation(await resolve(c), c.req.valid("param").id)));
51
+ app.post(`${base}/threads/:id/archive`, requireControlPlane(SUPPORT_THREADS_ARCHIVE_SCOPE), zValidator("param", ThreadIdParam, validationHook), zValidator("json", ArchiveThreadInput, validationHook), async (c) => c.json(await archiveConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))));
52
+ app.post(`${base}/threads/:id/reply`, requireControlPlane(SUPPORT_THREADS_REPLY_SCOPE), zValidator("param", ThreadIdParam, validationHook), zValidator("json", ReplyInput, validationHook), async (c) => c.json(await replyToConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c)), 201));
53
+ app.post(`${base}/threads/:id/reclassify`, requireControlPlane(SUPPORT_THREADS_RECLASSIFY_SCOPE), zValidator("param", ThreadIdParam, validationHook), async (c) => c.json(await reclassifyConversation(await resolve(c), c.req.valid("param").id, viewer(c))));
54
+ app.post(`${base}/threads/:id/flags`, requireControlPlane(SUPPORT_THREADS_FLAG_SCOPE), zValidator("param", ThreadIdParam, validationHook), zValidator("json", FlagsInput, validationHook), async (c) => c.json(await updateFlags(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))));
55
+ if (!submission) return;
56
+ app.post(`${base}/feedback`, requireAuth(), requireSameOrigin(), zValidator("json", SubmitFeedbackInput, validationHook), async (c) => c.json(await submitFeedbackRequest(await resolve(c), c.req.valid("json"), submitter(c)), 201));
57
+ app.get(`${base}/feedback`, requireAuth(), zValidator("query", MyThreadsQuery, validationHook), async (c) => c.json(await listMyThreads(await resolve(c), c.req.valid("query"), submitter(c))));
58
+ app.get(`${base}/feedback/:id`, requireAuth(), zValidator("param", ThreadIdParam, validationHook), async (c) => c.json(await readMyThread(await resolve(c), c.req.valid("param").id, submitter(c))));
59
+ };
60
+ }
61
+ //#endregion
62
+ export { registerSupportRoutes };
@@ -0,0 +1,121 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The request contracts every support route declares (CLAUDE.md §HTTP).
4
+ *
5
+ * Every one of these is a **bound on something a caller chose**, which is the whole job: a control-
6
+ * plane credential is verified, but verified is not the same as trusted, and a management client with
7
+ * a bug can ask for a million rows as easily as a hostile one can.
8
+ *
9
+ * The category filter is a bounded string rather than an enum, and that is deliberate: the taxonomy
10
+ * is federated, so the valid set is not known here — and building the schema from the configured set
11
+ * would make a filter for a category an adopter just removed a 400 instead of an empty list, which is
12
+ * a worse answer to the same question. The value is only ever compared, never interpreted.
13
+ */
14
+ /** A thread id in the path. */
15
+ export declare const ThreadIdParam: z.ZodObject<{
16
+ id: z.ZodString;
17
+ }, z.core.$strip>;
18
+ export type ThreadIdParam = z.infer<typeof ThreadIdParam>;
19
+ /** The inbox listing query. */
20
+ export declare const ListThreadsQuery: z.ZodObject<{
21
+ archived: z.ZodOptional<z.ZodEnum<{
22
+ false: "false";
23
+ true: "true";
24
+ }>>;
25
+ category: z.ZodOptional<z.ZodString>;
26
+ declaredCategory: z.ZodOptional<z.ZodString>;
27
+ priority: z.ZodOptional<z.ZodEnum<{
28
+ low: "low";
29
+ normal: "normal";
30
+ urgent: "urgent";
31
+ }>>;
32
+ sentiment: z.ZodOptional<z.ZodEnum<{
33
+ angry: "angry";
34
+ frustrated: "frustrated";
35
+ neutral: "neutral";
36
+ positive: "positive";
37
+ }>>;
38
+ channel: z.ZodOptional<z.ZodEnum<{
39
+ app: "app";
40
+ email: "email";
41
+ }>>;
42
+ inbox: z.ZodOptional<z.ZodString>;
43
+ q: z.ZodOptional<z.ZodString>;
44
+ cursor: z.ZodOptional<z.ZodString>;
45
+ limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
46
+ }, z.core.$strip>;
47
+ export type ListThreadsQuery = z.infer<typeof ListThreadsQuery>;
48
+ /** The archive/unarchive body. */
49
+ export declare const ArchiveThreadInput: z.ZodObject<{
50
+ archived: z.ZodBoolean;
51
+ }, z.core.$strip>;
52
+ export type ArchiveThreadInput = z.infer<typeof ArchiveThreadInput>;
53
+ /** The reply body. */
54
+ export declare const ReplyInput: z.ZodObject<{
55
+ body: z.ZodString;
56
+ agentName: z.ZodOptional<z.ZodString>;
57
+ }, z.core.$strip>;
58
+ export type ReplyInput = z.infer<typeof ReplyInput>;
59
+ /** The per-viewer flags body. */
60
+ export declare const FlagsInput: z.ZodObject<{
61
+ read: z.ZodOptional<z.ZodBoolean>;
62
+ snoozedUntil: z.ZodOptional<z.ZodNullable<z.ZodString>>;
63
+ }, z.core.$strip>;
64
+ export type FlagsInput = z.infer<typeof FlagsInput>;
65
+ /**
66
+ * One file on an in-app submission.
67
+ *
68
+ * The bytes arrive base64-encoded in JSON so the whole request stays one Zod object on the route line,
69
+ * like every other route in this capability. **The size and type bounds are not here**: they are the
70
+ * adopter's `submission.attachments` config, resolved in the handler, because a request schema is built
71
+ * once at module load and cannot know them. What this bounds is the shape — a filename that renders, a
72
+ * MIME type that looks like one, and a payload that is base64 at all.
73
+ */
74
+ export declare const SubmittedAttachmentInput: z.ZodObject<{
75
+ filename: z.ZodString;
76
+ contentType: z.ZodString;
77
+ data: z.ZodUnion<readonly [z.ZodBase64, z.ZodBase64URL]>;
78
+ }, z.core.$strip>;
79
+ export type SubmittedAttachmentInput = z.infer<typeof SubmittedAttachmentInput>;
80
+ /**
81
+ * The in-app submission body.
82
+ *
83
+ * **Nothing here names an account, and nothing here could.** The submitter is `c.var.auth.userId`,
84
+ * proved before this schema ran — a `userId` field would be a client-supplied identity on the one
85
+ * surface whose whole argument is that it does not need one.
86
+ *
87
+ * The lengths are the hard ceilings from `config/config.ts`, not the adopter's configured bounds: this
88
+ * schema is built once at module load, and the configured numbers are applied in the handler where the
89
+ * resolved config lives. A body over the ceiling never reaches a handler at all.
90
+ */
91
+ export declare const SubmitFeedbackInput: z.ZodObject<{
92
+ subject: z.ZodString;
93
+ body: z.ZodString;
94
+ declaredCategory: z.ZodOptional<z.ZodString>;
95
+ threadId: z.ZodOptional<z.ZodString>;
96
+ context: z.ZodOptional<z.ZodObject<{
97
+ screen: z.ZodOptional<z.ZodString>;
98
+ appVersion: z.ZodOptional<z.ZodString>;
99
+ platform: z.ZodOptional<z.ZodString>;
100
+ environment: z.ZodOptional<z.ZodString>;
101
+ locale: z.ZodOptional<z.ZodString>;
102
+ }, z.core.$strict>>;
103
+ attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
104
+ filename: z.ZodString;
105
+ contentType: z.ZodString;
106
+ data: z.ZodUnion<readonly [z.ZodBase64, z.ZodBase64URL]>;
107
+ }, z.core.$strip>>>;
108
+ }, z.core.$strip>;
109
+ export type SubmitFeedbackInput = z.infer<typeof SubmitFeedbackInput>;
110
+ /** The submitter's own thread list. */
111
+ export declare const MyThreadsQuery: z.ZodObject<{
112
+ cursor: z.ZodOptional<z.ZodString>;
113
+ limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
114
+ }, z.core.$strip>;
115
+ export type MyThreadsQuery = z.infer<typeof MyThreadsQuery>;
116
+ /** The canned-reply catalog query. */
117
+ export declare const RepliesQuery: z.ZodObject<{
118
+ category: z.ZodOptional<z.ZodString>;
119
+ }, z.core.$strip>;
120
+ export type RepliesQuery = z.infer<typeof RepliesQuery>;
121
+ //# sourceMappingURL=schemas.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../src/http/schemas.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB;;;;;;;;;;;GAWG;AAEH,+BAA+B;AAC/B,eAAO,MAAM,aAAa;;iBAOsC,CAAC;AACjE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAY1D,+BAA+B;AAC/B,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwC8D,CAAC;AAC5F,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,kCAAkC;AAClC,eAAO,MAAM,kBAAkB;;iBAQuB,CAAC;AACvD,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,sBAAsB;AACtB,eAAO,MAAM,UAAU;;;iBAgB0B,CAAC;AAClD,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,iCAAiC;AACjC,eAAO,MAAM,UAAU;;;iBAWmC,CAAC;AAC3D,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB;;;;iBA4B8D,CAAC;AACpG,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAEhF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;iBAiCqF,CAAC;AACtH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uCAAuC;AACvC,eAAO,MAAM,cAAc;;;iBAiBxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,sCAAsC;AACtC,eAAO,MAAM,YAAY;;iBAM4B,CAAC;AACtD,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC"}