@pithy-sh/support 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +68 -0
- package/pithy.manifest.json +40 -0
- package/src/ai/classify.ts +239 -0
- package/src/attachment/store.ts +78 -0
- package/src/audit/actions.ts +71 -0
- package/src/capability.ts +293 -0
- package/src/client/projection.ts +60 -0
- package/src/cloudflare-test.d.ts +15 -0
- package/src/config/config.ts +400 -0
- package/src/data/attachment.ts +65 -0
- package/src/data/billingScope.ts +32 -0
- package/src/data/categories.ts +117 -0
- package/src/data/classification.ts +50 -0
- package/src/data/enums.ts +90 -0
- package/src/data/flag.ts +37 -0
- package/src/data/message.ts +224 -0
- package/src/data/tables.ts +58 -0
- package/src/data/thread.ts +138 -0
- package/src/error/errors.ts +133 -0
- package/src/http/guards.ts +59 -0
- package/src/http/handlers.ts +418 -0
- package/src/http/resolve.ts +109 -0
- package/src/http/responses.ts +506 -0
- package/src/http/routes.ts +272 -0
- package/src/http/schemas.ts +251 -0
- package/src/http/scopes.ts +117 -0
- package/src/http/views.ts +169 -0
- package/src/inbound/authenticity.ts +114 -0
- package/src/inbound/guard.ts +127 -0
- package/src/inbound/handler.ts +102 -0
- package/src/inbound/ingest.ts +548 -0
- package/src/inbound/recipient.ts +67 -0
- package/src/index.ts +63 -0
- package/src/link/sender.ts +334 -0
- package/src/migrations/0001_threads.ts +296 -0
- package/src/mime/address.ts +37 -0
- package/src/mime/parse.ts +299 -0
- package/src/mime/sanitize.ts +253 -0
- package/src/mime/threading.ts +127 -0
- package/src/mime/truncate.ts +55 -0
- package/src/provision/provisionSupport.ts +179 -0
- package/src/provision/resolveSupportConfig.ts +67 -0
- package/src/reply/send.ts +322 -0
- package/src/reply/snippets.ts +167 -0
- package/src/secret/registry.ts +24 -0
- package/src/seeds/example.ts +385 -0
- package/src/store/paging.ts +22 -0
- package/src/store/search.ts +197 -0
- package/src/store/searchIndex.ts +71 -0
- package/src/store/threads.ts +452 -0
- package/src/submission/encoding.ts +66 -0
- package/src/submission/guard.ts +120 -0
- package/src/submission/submit.ts +539 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/classify.ts +164 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +61 -0
- package/src/workflows/worker.ts +82 -0
- package/src/workflows/wrangler.jsonc +46 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { zValidator } from "@hono/zod-validator";
|
|
5
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
|
|
7
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import { requireSameOrigin } from "@pithy-sh/core/src/http/sameOrigin";
|
|
9
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
10
|
+
import type { Context, Hono } from "hono";
|
|
11
|
+
import { requireAuth } from "./guards";
|
|
12
|
+
import {
|
|
13
|
+
archiveConversation,
|
|
14
|
+
type HandlerDeps,
|
|
15
|
+
listInbox,
|
|
16
|
+
listMyThreads,
|
|
17
|
+
listReplies,
|
|
18
|
+
readConversation,
|
|
19
|
+
readMyThread,
|
|
20
|
+
reclassifyConversation,
|
|
21
|
+
replyToConversation,
|
|
22
|
+
submitFeedbackRequest,
|
|
23
|
+
updateFlags,
|
|
24
|
+
} from "./handlers";
|
|
25
|
+
import type { SupportRepliesResponse } from "./responses";
|
|
26
|
+
import {
|
|
27
|
+
ArchiveThreadInput,
|
|
28
|
+
FlagsInput,
|
|
29
|
+
ListThreadsQuery,
|
|
30
|
+
MyThreadsQuery,
|
|
31
|
+
RepliesQuery,
|
|
32
|
+
ReplyInput,
|
|
33
|
+
SubmitFeedbackInput,
|
|
34
|
+
ThreadIdParam,
|
|
35
|
+
} from "./schemas";
|
|
36
|
+
import {
|
|
37
|
+
SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
38
|
+
SUPPORT_THREADS_FLAG_SCOPE,
|
|
39
|
+
SUPPORT_THREADS_READ_SCOPE,
|
|
40
|
+
SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
41
|
+
SUPPORT_THREADS_REPLY_SCOPE,
|
|
42
|
+
} from "./scopes";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The support routes, their verification strategies, and what each accepts:
|
|
46
|
+
*
|
|
47
|
+
* GET /support/threads → the inbox (control-plane: support:threads:read) query: ListThreadsQuery
|
|
48
|
+
* GET /support/threads/:id → one thread (control-plane: support:threads:read) param: ThreadIdParam
|
|
49
|
+
* POST /support/threads/:id/archive → done/reopen (control-plane: support:threads:archive) param + json
|
|
50
|
+
* POST /support/threads/:id/reply → answer (control-plane: support:threads:reply) param + json
|
|
51
|
+
* POST /support/threads/:id/reclassify→ re-run model (control-plane: support:threads:reclassify) param
|
|
52
|
+
* POST /support/threads/:id/flags → read/snooze (control-plane: support:threads:flag) param + json
|
|
53
|
+
* GET /support/replies → canned copy (control-plane: support:threads:read) query: RepliesQuery
|
|
54
|
+
*
|
|
55
|
+
* POST /support/feedback → write in (bearer/session + same-origin) json: SubmitFeedbackInput
|
|
56
|
+
* GET /support/feedback → my requests (bearer/session) query: MyThreadsQuery
|
|
57
|
+
* GET /support/feedback/:id → one of mine (bearer/session) param: ThreadIdParam
|
|
58
|
+
*
|
|
59
|
+
* **Two surfaces, two gates, and they never stack.** The management routes answer to a control-plane
|
|
60
|
+
* credential the adopter issued, and with the seam uncomposed every one denies with
|
|
61
|
+
* `controlplane/not_connected` — the correct failure for an inbox holding other people's private
|
|
62
|
+
* correspondence. The `feedback` routes answer to the adopter's own signed-in user, and with no auth
|
|
63
|
+
* capability composed `c.var.auth` is null and every one denies. A control-plane caller holds no
|
|
64
|
+
* session by design and can never satisfy `requireAuth()`; a user's session confers no scope. See
|
|
65
|
+
* `guards.ts`.
|
|
66
|
+
*
|
|
67
|
+
* **`requireSameOrigin()` is on the submission route and on neither read.** Cookie-mode sessions make a
|
|
68
|
+
* mutating route CSRF-reachable, and this one writes into a support inbox under a real customer's name
|
|
69
|
+
* — a forged submission is somebody else's words attributed to them, in the one place an operator
|
|
70
|
+
* treats attribution as proven. The reads are GETs and carry no such risk. A bearer caller is
|
|
71
|
+
* CSRF-exempt, and that exemption belongs to the gate `@pithy-sh/auth` publishes rather than to
|
|
72
|
+
* anything decided here.
|
|
73
|
+
*
|
|
74
|
+
* **Every response has an exported schema**, in `responses.ts`. Each handler's return type is
|
|
75
|
+
* `z.output` of its envelope, so `c.json(await handler(...))` carries the contract without a cast — and
|
|
76
|
+
* a management client imports the same object and validates with it rather than hand-writing a mirror
|
|
77
|
+
* that drifts.
|
|
78
|
+
*
|
|
79
|
+
* The validators sit **after** the gate on every line. A validator ahead of it turns a 401 into a
|
|
80
|
+
* 400 and tells an unverified caller which requests were well-formed — and on this surface that is a
|
|
81
|
+
* live oracle for the shape of an adopter's support tooling.
|
|
82
|
+
*
|
|
83
|
+
* ## An adopter's own authorization on the submission route
|
|
84
|
+
*
|
|
85
|
+
* **This capability gates `POST {base}/feedback` on a session and same-origin, and on nothing else,
|
|
86
|
+
* permanently.** Writing to support must not be role-gated or it stops being a general intake: the
|
|
87
|
+
* person who most needs to reach support is often the one whose access is broken, and a role the kit
|
|
88
|
+
* invented would make one adopter's account model a condition on everybody's ability to report a bug.
|
|
89
|
+
*
|
|
90
|
+
* But an adopter whose *own* model makes some submissions act-on-behalf-of — `pithy-sh/dashboard#10`'s
|
|
91
|
+
* discount application is made for an organization, and a member may not make one — needs somewhere to
|
|
92
|
+
* put that check, and it must not be the client. **The seam for it already exists, in the composition
|
|
93
|
+
* contract rather than in this capability's config**, and it is documented here because it was not
|
|
94
|
+
* discoverable rather than because it was missing:
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* // the adopter's own `app` capability
|
|
98
|
+
* defineCapability({
|
|
99
|
+
* name: "app",
|
|
100
|
+
* middleware: [
|
|
101
|
+
* (app) => {
|
|
102
|
+
* app.use("/support/feedback", async (c, next) => {
|
|
103
|
+
* // c.var.auth is already populated — @pithy-sh/auth's session middleware is a library's, and
|
|
104
|
+
* // every capability's middleware mounts before any capability's routes.
|
|
105
|
+
* if (c.var.auth && !(await mayWriteOnBehalfOfTheOrganization(c))) throw new ForbiddenError({ … });
|
|
106
|
+
* await next();
|
|
107
|
+
* });
|
|
108
|
+
* },
|
|
109
|
+
* ],
|
|
110
|
+
* …
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* `createBackend` mounts **every** capability's middleware before **any** capability's routes, and the
|
|
115
|
+
* adopter's `app` capability composes last — so their middleware runs after auth has resolved the
|
|
116
|
+
* session and before this file's `requireAuth()`. `createBackend.workers.test.ts` pins that ordering,
|
|
117
|
+
* because a paragraph asserting it is not the same as a test failing when it changes.
|
|
118
|
+
*
|
|
119
|
+
* Two consequences worth stating rather than discovering. Their middleware sees `c.var.auth` as **null**
|
|
120
|
+
* on an unauthenticated request, since it runs ahead of the route's own gate — so it should pass those
|
|
121
|
+
* through and let `requireAuth()` answer 401, rather than 403 a caller who was never signed in. And the
|
|
122
|
+
* path is theirs to write, from the `basePath` they configured; a mount point they changed and a
|
|
123
|
+
* middleware path they did not is a gate that silently stops covering anything.
|
|
124
|
+
*
|
|
125
|
+
* **A `beforeSubmit` callback in `SupportConfig` was the tempting alternative and is the wrong shape.**
|
|
126
|
+
* The need is not support's — every capability with a write route has it — so solving it once per
|
|
127
|
+
* capability would give an adopter a different mechanism per package, each with its own signature and
|
|
128
|
+
* its own answer to "what is in scope here". A config file is also the wrong home for an authorization
|
|
129
|
+
* decision that wants `c.var`, and a second place to look for the gates on a route is how a Worker ends
|
|
130
|
+
* up with two of them, free to disagree.
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/** How the support sub-router is built. */
|
|
134
|
+
export interface SupportRoutesOptions {
|
|
135
|
+
/** The path the routes mount under. Defaults to `/support`. */
|
|
136
|
+
basePath?: string;
|
|
137
|
+
/**
|
|
138
|
+
* Whether to mount the in-app submission routes. Defaults to true, matching `submission.enabled`.
|
|
139
|
+
*
|
|
140
|
+
* Not mounting is deliberate rather than a guard inside the handlers: a route that is not served
|
|
141
|
+
* answers 404, and 404 is the honest answer. A 403 would say "this exists and you may not use it" to
|
|
142
|
+
* a caller asking about a feature this deployment does not have.
|
|
143
|
+
*/
|
|
144
|
+
submission?: boolean;
|
|
145
|
+
/** Resolve handler deps from the request context. */
|
|
146
|
+
resolveDeps: (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The verified management client behind a control-plane call.
|
|
151
|
+
*
|
|
152
|
+
* `requireControlPlane()` has run on every route, so `c.var.controlPlane` is populated by the time a
|
|
153
|
+
* handler reads it. The throw is a programming-error guard, not a runtime path: reaching it would
|
|
154
|
+
* mean a route was mounted without its gate, which is the one mistake this whole file is arranged to
|
|
155
|
+
* make impossible.
|
|
156
|
+
*/
|
|
157
|
+
function viewer(c: Context<PithyHonoEnv>): string {
|
|
158
|
+
const subject = c.var.controlPlane?.subject;
|
|
159
|
+
if (!subject) {
|
|
160
|
+
throw new InternalError({
|
|
161
|
+
message: "Support could not identify the management caller.",
|
|
162
|
+
detail: "requireControlPlane() must run before a support handler reads the caller.",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return subject;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The signed-in user behind an in-app submission.
|
|
170
|
+
*
|
|
171
|
+
* `requireAuth()` has run on every route that calls this, so `c.var.auth` is populated by the time a
|
|
172
|
+
* handler reads it. The throw is a programming-error guard rather than a runtime path — the mirror of
|
|
173
|
+
* {@link viewer}, and the reason neither handler ever has to think about an absent caller.
|
|
174
|
+
*/
|
|
175
|
+
function submitter(c: Context<PithyHonoEnv>): string {
|
|
176
|
+
const auth = c.var.auth;
|
|
177
|
+
if (!auth) {
|
|
178
|
+
throw new InternalError({
|
|
179
|
+
message: "Support could not identify the signed-in caller.",
|
|
180
|
+
detail: "requireAuth() must run before a support submission handler reads the submitter.",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return auth.userId;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Register the support sub-router. Returned as the capability's `routes` hook. */
|
|
187
|
+
export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
188
|
+
const base = options.basePath ?? "/support";
|
|
189
|
+
const resolve = options.resolveDeps;
|
|
190
|
+
const submission = options.submission ?? true;
|
|
191
|
+
|
|
192
|
+
return (app) => {
|
|
193
|
+
app.get(
|
|
194
|
+
`${base}/threads`,
|
|
195
|
+
requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
|
|
196
|
+
zValidator("query", ListThreadsQuery, validationHook),
|
|
197
|
+
async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
app.get(
|
|
201
|
+
`${base}/replies`,
|
|
202
|
+
requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
|
|
203
|
+
zValidator("query", RepliesQuery, validationHook),
|
|
204
|
+
async (c) =>
|
|
205
|
+
c.json({ replies: listReplies(await resolve(c), c.req.valid("query")) } satisfies SupportRepliesResponse),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
app.get(
|
|
209
|
+
`${base}/threads/:id`,
|
|
210
|
+
requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
|
|
211
|
+
zValidator("param", ThreadIdParam, validationHook),
|
|
212
|
+
async (c) => c.json(await readConversation(await resolve(c), c.req.valid("param").id)),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
app.post(
|
|
216
|
+
`${base}/threads/:id/archive`,
|
|
217
|
+
requireControlPlane(SUPPORT_THREADS_ARCHIVE_SCOPE),
|
|
218
|
+
zValidator("param", ThreadIdParam, validationHook),
|
|
219
|
+
zValidator("json", ArchiveThreadInput, validationHook),
|
|
220
|
+
async (c) =>
|
|
221
|
+
c.json(await archiveConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
app.post(
|
|
225
|
+
`${base}/threads/:id/reply`,
|
|
226
|
+
requireControlPlane(SUPPORT_THREADS_REPLY_SCOPE),
|
|
227
|
+
zValidator("param", ThreadIdParam, validationHook),
|
|
228
|
+
zValidator("json", ReplyInput, validationHook),
|
|
229
|
+
async (c) =>
|
|
230
|
+
c.json(
|
|
231
|
+
await replyToConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c)),
|
|
232
|
+
201,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
app.post(
|
|
237
|
+
`${base}/threads/:id/reclassify`,
|
|
238
|
+
requireControlPlane(SUPPORT_THREADS_RECLASSIFY_SCOPE),
|
|
239
|
+
zValidator("param", ThreadIdParam, validationHook),
|
|
240
|
+
async (c) => c.json(await reclassifyConversation(await resolve(c), c.req.valid("param").id, viewer(c))),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
app.post(
|
|
244
|
+
`${base}/threads/:id/flags`,
|
|
245
|
+
requireControlPlane(SUPPORT_THREADS_FLAG_SCOPE),
|
|
246
|
+
zValidator("param", ThreadIdParam, validationHook),
|
|
247
|
+
zValidator("json", FlagsInput, validationHook),
|
|
248
|
+
async (c) => c.json(await updateFlags(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (!submission) return;
|
|
252
|
+
|
|
253
|
+
// The one route on this capability a customer calls directly. `requireAuth()` first, then the CSRF
|
|
254
|
+
// gate, then the contract — a validator ahead of either would turn a 401 into a 400 and tell an
|
|
255
|
+
// unauthenticated caller which submissions were well-formed.
|
|
256
|
+
app.post(
|
|
257
|
+
`${base}/feedback`,
|
|
258
|
+
requireAuth(),
|
|
259
|
+
requireSameOrigin(),
|
|
260
|
+
zValidator("json", SubmitFeedbackInput, validationHook),
|
|
261
|
+
async (c) => c.json(await submitFeedbackRequest(await resolve(c), c.req.valid("json"), submitter(c)), 201),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
app.get(`${base}/feedback`, requireAuth(), zValidator("query", MyThreadsQuery, validationHook), async (c) =>
|
|
265
|
+
c.json(await listMyThreads(await resolve(c), c.req.valid("query"), submitter(c))),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
app.get(`${base}/feedback/:id`, requireAuth(), zValidator("param", ThreadIdParam, validationHook), async (c) =>
|
|
269
|
+
c.json(await readMyThread(await resolve(c), c.req.valid("param").id, submitter(c))),
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { MAX_SUBMISSION_ATTACHMENTS, MAX_SUBMISSION_BODY_CHARS, MAX_SUBMISSION_SUBJECT_CHARS } from "../config/config";
|
|
6
|
+
import { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
|
|
7
|
+
import { SupportSubmissionContext } from "../data/message";
|
|
8
|
+
import { MAX_PAGE_SIZE } from "../store/paging";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The request contracts every support route declares (CLAUDE.md §HTTP).
|
|
12
|
+
*
|
|
13
|
+
* Every one of these is a **bound on something a caller chose**, which is the whole job: a control-
|
|
14
|
+
* plane credential is verified, but verified is not the same as trusted, and a management client with
|
|
15
|
+
* a bug can ask for a million rows as easily as a hostile one can.
|
|
16
|
+
*
|
|
17
|
+
* The category filter is a bounded string rather than an enum, and that is deliberate: the taxonomy
|
|
18
|
+
* is federated, so the valid set is not known here — and building the schema from the configured set
|
|
19
|
+
* would make a filter for a category an adopter just removed a 400 instead of an empty list, which is
|
|
20
|
+
* a worse answer to the same question. The value is only ever compared, never interpreted.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A thread id in the path. */
|
|
24
|
+
export const ThreadIdParam = z
|
|
25
|
+
.object({
|
|
26
|
+
id: z
|
|
27
|
+
.string()
|
|
28
|
+
.uuid()
|
|
29
|
+
.describe("The thread's UUID. A param schema constrains the string; the handler still does the lookup."),
|
|
30
|
+
})
|
|
31
|
+
.describe("The path parameters of every single-thread route.");
|
|
32
|
+
export type ThreadIdParam = z.infer<typeof ThreadIdParam>;
|
|
33
|
+
|
|
34
|
+
/** A category key as a filter — bounded and shaped, never resolved against the configured set. */
|
|
35
|
+
const CategoryFilter = z
|
|
36
|
+
.string()
|
|
37
|
+
.min(1)
|
|
38
|
+
.max(64)
|
|
39
|
+
.regex(/^[a-z][a-z0-9_]*$/)
|
|
40
|
+
.describe(
|
|
41
|
+
"One category key from this project's effective taxonomy. A key an adopter removed simply matches nothing.",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** The inbox listing query. */
|
|
45
|
+
export const ListThreadsQuery = z
|
|
46
|
+
.object({
|
|
47
|
+
archived: z
|
|
48
|
+
.enum(["true", "false"])
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("`true` for the done pile, `false` or absent for the open inbox — which is the default on purpose."),
|
|
51
|
+
category: CategoryFilter.optional().describe("Filter to one category — what the classifier made of it."),
|
|
52
|
+
declaredCategory: CategoryFilter.optional().describe(
|
|
53
|
+
"Filter to one category **the submitter chose**, which is a different question from `category` and answers it about different threads. Combining the two is how an operator finds the disagreements; asking for this one alone is how they triage a project with `ai.enabled: false`, where nothing ever writes the other.",
|
|
54
|
+
),
|
|
55
|
+
priority: SupportPriority.optional().describe("Filter to one priority."),
|
|
56
|
+
sentiment: SupportSentiment.optional().describe("Filter to one sentiment."),
|
|
57
|
+
channel: SupportChannel.optional().describe(
|
|
58
|
+
"Filter to one channel — `email` for mail, `app` for what signed-in users filed from inside the app. Absent shows both, which is the right default for an inbox that is one queue however things arrived.",
|
|
59
|
+
),
|
|
60
|
+
inbox: z
|
|
61
|
+
.string()
|
|
62
|
+
.min(3)
|
|
63
|
+
.max(256)
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Filter to one configured inbox address, for a Worker serving several."),
|
|
66
|
+
q: z
|
|
67
|
+
.string()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(200)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Free text over subjects and bodies. Keyword search — the words people actually remember."),
|
|
72
|
+
cursor: z
|
|
73
|
+
.string()
|
|
74
|
+
.max(512)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page."),
|
|
77
|
+
limit: z.coerce
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.min(1)
|
|
81
|
+
.max(MAX_PAGE_SIZE)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("How many threads to return. Bounded, because a verified client can still have a bug."),
|
|
84
|
+
})
|
|
85
|
+
.describe("The inbox query: what to filter by, what to search for, and where to resume.");
|
|
86
|
+
export type ListThreadsQuery = z.infer<typeof ListThreadsQuery>;
|
|
87
|
+
|
|
88
|
+
/** The archive/unarchive body. */
|
|
89
|
+
export const ArchiveThreadInput = z
|
|
90
|
+
.object({
|
|
91
|
+
archived: z
|
|
92
|
+
.boolean()
|
|
93
|
+
.describe(
|
|
94
|
+
"`true` marks the conversation done; `false` reopens it. Explicit rather than a toggle, so a retried request lands on the state the caller meant rather than flipping it back.",
|
|
95
|
+
),
|
|
96
|
+
})
|
|
97
|
+
.describe("Mark a conversation done, or reopen it.");
|
|
98
|
+
export type ArchiveThreadInput = z.infer<typeof ArchiveThreadInput>;
|
|
99
|
+
|
|
100
|
+
/** The reply body. */
|
|
101
|
+
export const ReplyInput = z
|
|
102
|
+
.object({
|
|
103
|
+
body: z
|
|
104
|
+
.string()
|
|
105
|
+
.min(1)
|
|
106
|
+
.max(50_000)
|
|
107
|
+
.describe(
|
|
108
|
+
"The reply text, as a human wrote and edited it. Rendered HTML-escaped into the adopter's email shell — it is prose, never markup.",
|
|
109
|
+
),
|
|
110
|
+
agentName: z
|
|
111
|
+
.string()
|
|
112
|
+
.min(1)
|
|
113
|
+
.max(80)
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("Who is answering, signed at the bottom. Omitted rather than guessed."),
|
|
116
|
+
})
|
|
117
|
+
.describe("An answer to send to the customer.");
|
|
118
|
+
export type ReplyInput = z.infer<typeof ReplyInput>;
|
|
119
|
+
|
|
120
|
+
/** The per-viewer flags body. */
|
|
121
|
+
export const FlagsInput = z
|
|
122
|
+
.object({
|
|
123
|
+
read: z.boolean().optional().describe("Mark read or unread. Absent leaves it alone."),
|
|
124
|
+
snoozedUntil: z
|
|
125
|
+
.string()
|
|
126
|
+
.datetime()
|
|
127
|
+
.nullish()
|
|
128
|
+
.describe(
|
|
129
|
+
"Hide this thread from your inbox until this ISO-8601 moment. `null` clears a snooze; absent leaves it alone. A time rather than a flag, so it expires on its own and nothing has to sweep.",
|
|
130
|
+
),
|
|
131
|
+
})
|
|
132
|
+
.describe("One viewer's private read and snooze state.");
|
|
133
|
+
export type FlagsInput = z.infer<typeof FlagsInput>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One file on an in-app submission.
|
|
137
|
+
*
|
|
138
|
+
* The bytes arrive base64-encoded in JSON so the whole request stays one Zod object on the route line,
|
|
139
|
+
* like every other route in this capability. **The size and type bounds are not here**: they are the
|
|
140
|
+
* adopter's `submission.attachments` config, resolved in the handler, because a request schema is built
|
|
141
|
+
* once at module load and cannot know them. What this bounds is the shape — a filename that renders, a
|
|
142
|
+
* MIME type that looks like one, and a payload that is base64 at all.
|
|
143
|
+
*/
|
|
144
|
+
export const SubmittedAttachmentInput = z
|
|
145
|
+
.object({
|
|
146
|
+
filename: z
|
|
147
|
+
.string()
|
|
148
|
+
.min(1)
|
|
149
|
+
.max(255)
|
|
150
|
+
.describe(
|
|
151
|
+
"The filename as the client declares it. **Recorded and never honored** — the R2 key is server-derived, so this is metadata a console renders escaped, not a path anything resolves.",
|
|
152
|
+
),
|
|
153
|
+
contentType: z
|
|
154
|
+
.string()
|
|
155
|
+
.min(3)
|
|
156
|
+
.max(128)
|
|
157
|
+
.regex(/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/)
|
|
158
|
+
.describe(
|
|
159
|
+
"The MIME type as the client declares it, lowercased, checked against the configured allowlist in the handler. Never used to serve the bytes: every object is stored as `application/octet-stream`, because R2 echoes a stored type back on a presigned GET and a browser renders `text/html`.",
|
|
160
|
+
),
|
|
161
|
+
data: z
|
|
162
|
+
// **Both alphabets, and the union is not belt-and-braces.** `z.base64()` rejects any string
|
|
163
|
+
// containing `-` or `_`, so it alone would 400 every client whose platform encoder emits
|
|
164
|
+
// `base64url` — Swift's `base64EncodedString(options:)`, Node's `toString("base64url")`, and
|
|
165
|
+
// most JWT-adjacent helpers — while `decodeBase64` sits behind it happily normalizing the two.
|
|
166
|
+
// A validator that refuses what the decoder documents as fine is the validator that is wrong.
|
|
167
|
+
.union([z.base64(), z.base64url()])
|
|
168
|
+
.describe(
|
|
169
|
+
"The file's bytes, base64 — the standard alphabet or the URL-safe one, because a client whose encoder emits the latter has not made a mistake worth a 400. The configured `maxBytes` bound is measured on the **decoded** length, which is a third smaller than this string.",
|
|
170
|
+
),
|
|
171
|
+
})
|
|
172
|
+
.describe("One file attached to an in-app support request, as bytes a signed-in client encoded.");
|
|
173
|
+
export type SubmittedAttachmentInput = z.infer<typeof SubmittedAttachmentInput>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The in-app submission body.
|
|
177
|
+
*
|
|
178
|
+
* **Nothing here names an account, and nothing here could.** The submitter is `c.var.auth.userId`,
|
|
179
|
+
* proved before this schema ran — a `userId` field would be a client-supplied identity on the one
|
|
180
|
+
* surface whose whole argument is that it does not need one.
|
|
181
|
+
*
|
|
182
|
+
* The lengths are the hard ceilings from `config/config.ts`, not the adopter's configured bounds: this
|
|
183
|
+
* schema is built once at module load, and the configured numbers are applied in the handler where the
|
|
184
|
+
* resolved config lives. A body over the ceiling never reaches a handler at all.
|
|
185
|
+
*/
|
|
186
|
+
export const SubmitFeedbackInput = z
|
|
187
|
+
.object({
|
|
188
|
+
subject: z
|
|
189
|
+
.string()
|
|
190
|
+
.min(1)
|
|
191
|
+
.max(MAX_SUBMISSION_SUBJECT_CHARS)
|
|
192
|
+
.describe("What the request is about. Becomes the thread's name in the inbox and the subject of every reply."),
|
|
193
|
+
body: z
|
|
194
|
+
.string()
|
|
195
|
+
.min(1)
|
|
196
|
+
.max(MAX_SUBMISSION_BODY_CHARS)
|
|
197
|
+
.describe("The report itself, as the person wrote it. Plain text — it is prose, never markup."),
|
|
198
|
+
declaredCategory: CategoryFilter.optional().describe(
|
|
199
|
+
"What the person writing says this is about, from your app's own chooser. **Named for what it is, on the wire as well as in the row**: a field called `category` here would land in a column of that name that this never touches, which is the exact confusion the two columns exist to prevent. Bounded to a key's shape here and checked against your effective taxonomy in the handler — a key you do not declare is **refused**, because a chooser was built from that taxonomy and a value outside it is the client's bug. Accepted only when opening a request: sent alongside `threadId` it is refused rather than ignored.",
|
|
200
|
+
),
|
|
201
|
+
threadId: z
|
|
202
|
+
.string()
|
|
203
|
+
.uuid()
|
|
204
|
+
.optional()
|
|
205
|
+
.describe(
|
|
206
|
+
"Continue this conversation instead of opening one. Accepted only for the caller's **own** app thread; anybody else's answers 404, which is the same answer a thread that does not exist gets — a 403 would confirm the id names a real conversation.",
|
|
207
|
+
),
|
|
208
|
+
context: SupportSubmissionContext.optional().describe(
|
|
209
|
+
"What the app knows and the user did not type — screen, build, platform, environment, locale. A closed set: an undeclared key is refused rather than stored, so this cannot quietly become a telemetry pipe.",
|
|
210
|
+
),
|
|
211
|
+
attachments: z
|
|
212
|
+
.array(SubmittedAttachmentInput)
|
|
213
|
+
.max(MAX_SUBMISSION_ATTACHMENTS)
|
|
214
|
+
.default([])
|
|
215
|
+
.describe(
|
|
216
|
+
"Files attached to the request. Bounded by `submission.attachments` in the adopter's config — count, decoded size, and an allowlist of types — and refused as a whole rather than silently trimmed. The ceiling here is the one that holds whatever that config says, and it is checked before any payload is decoded.",
|
|
217
|
+
),
|
|
218
|
+
})
|
|
219
|
+
.describe("An in-app support request from a signed-in user: what it is about, what happened, and what it carries.");
|
|
220
|
+
export type SubmitFeedbackInput = z.infer<typeof SubmitFeedbackInput>;
|
|
221
|
+
|
|
222
|
+
/** The submitter's own thread list. */
|
|
223
|
+
export const MyThreadsQuery = z
|
|
224
|
+
.object({
|
|
225
|
+
cursor: z
|
|
226
|
+
.string()
|
|
227
|
+
.max(512)
|
|
228
|
+
.optional()
|
|
229
|
+
.describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page."),
|
|
230
|
+
limit: z.coerce
|
|
231
|
+
.number()
|
|
232
|
+
.int()
|
|
233
|
+
.min(1)
|
|
234
|
+
.max(MAX_PAGE_SIZE)
|
|
235
|
+
.optional()
|
|
236
|
+
.describe("How many conversations to return. Bounded, because a signed-in caller can still have a bug."),
|
|
237
|
+
})
|
|
238
|
+
.describe(
|
|
239
|
+
"The submitter's own conversation list. No filters: this is one person's handful of requests, not an inbox to triage.",
|
|
240
|
+
);
|
|
241
|
+
export type MyThreadsQuery = z.infer<typeof MyThreadsQuery>;
|
|
242
|
+
|
|
243
|
+
/** The canned-reply catalog query. */
|
|
244
|
+
export const RepliesQuery = z
|
|
245
|
+
.object({
|
|
246
|
+
category: CategoryFilter.optional().describe(
|
|
247
|
+
"Order the catalog for this category — its snippets first, then the general-purpose ones. Ordering, never filtering: a misclassified thread must not hide the snippet its operator actually needs.",
|
|
248
|
+
),
|
|
249
|
+
})
|
|
250
|
+
.describe("How to order the canned reply catalog.");
|
|
251
|
+
export type RepliesQuery = z.infer<typeof RepliesQuery>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
|
|
5
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Support's control-plane scopes, and the admin surface a manifest advertises.
|
|
9
|
+
*
|
|
10
|
+
* **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
|
|
11
|
+
* client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
|
|
12
|
+
* writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
|
|
13
|
+
* program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
|
|
14
|
+
* one compiled `PithyHonoEnv`, which reached core's `capability.ts`, which named Worker globals that
|
|
15
|
+
* program has none of. **This module imports types and nothing else, and a gate holds it there**:
|
|
16
|
+
* `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
|
|
17
|
+
*
|
|
18
|
+
* ## Five scopes, because these are five different blast radii
|
|
19
|
+
*
|
|
20
|
+
* The temptation is one `support:admin` flag. It is wrong on the merits, the same way payments'
|
|
21
|
+
* single flag was: **reading** an inbox exposes every customer's private correspondence, while
|
|
22
|
+
* **replying** sends mail to a real person under the adopter's domain and DKIM. Those are not the
|
|
23
|
+
* same permission, and a tool that needed one should never silently hold the other. `scopeCovers`
|
|
24
|
+
* matches exactly, with no prefix or wildcard rule, so holding one confers nothing about the rest.
|
|
25
|
+
*
|
|
26
|
+
* The split that matters most is `reply`. A compromised read credential is a privacy incident; a
|
|
27
|
+
* compromised reply credential is somebody sending mail *as the adopter* to their own customers,
|
|
28
|
+
* which is a phishing platform with a verified sending domain attached.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Read the inbox: list threads, read one, and see the sender's linked account and purchases. */
|
|
32
|
+
export const SUPPORT_THREADS_READ_SCOPE: ControlPlaneScope = "support:threads:read";
|
|
33
|
+
|
|
34
|
+
/** Mark a thread done or reopen it — the one shared piece of state in the model. */
|
|
35
|
+
export const SUPPORT_THREADS_ARCHIVE_SCOPE: ControlPlaneScope = "support:threads:archive";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Send mail to a customer under the adopter's domain. The most dangerous of the five and the reason
|
|
39
|
+
* a single admin flag was never an option.
|
|
40
|
+
*/
|
|
41
|
+
export const SUPPORT_THREADS_REPLY_SCOPE: ControlPlaneScope = "support:threads:reply";
|
|
42
|
+
|
|
43
|
+
/** Re-run the model over a thread, overwriting its current classification. */
|
|
44
|
+
export const SUPPORT_THREADS_RECLASSIFY_SCOPE: ControlPlaneScope = "support:threads:reclassify";
|
|
45
|
+
|
|
46
|
+
/** Set a viewer's own read/snooze flags. Private, uncoordinated, and the least dangerous thing here. */
|
|
47
|
+
export const SUPPORT_THREADS_FLAG_SCOPE: ControlPlaneScope = "support:threads:flag";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every control-plane scope support defines — what `pithy dashboard connect` offers for this
|
|
51
|
+
* capability, and the list a manifest or a doc quotes rather than re-typing.
|
|
52
|
+
*/
|
|
53
|
+
export const SUPPORT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
|
|
54
|
+
SUPPORT_THREADS_READ_SCOPE,
|
|
55
|
+
SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
56
|
+
SUPPORT_THREADS_REPLY_SCOPE,
|
|
57
|
+
SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
58
|
+
SUPPORT_THREADS_FLAG_SCOPE,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Support's management surface, as `GET /control-plane/manifest` reports it.
|
|
63
|
+
*
|
|
64
|
+
* Declared beside the scopes so the scope a route demands and the scope a manifest advertises are
|
|
65
|
+
* the same constant. `basePath` is a parameter and never a default: an adopter who mounted support
|
|
66
|
+
* at `/inbox` must get a manifest naming `/inbox/threads`, or a client composing its calls from the
|
|
67
|
+
* manifest would 404 against exactly the adopters who customized anything.
|
|
68
|
+
*
|
|
69
|
+
* The summaries say what the operation is *for*. A client renders these next to a button somebody is
|
|
70
|
+
* about to press on a real customer's conversation.
|
|
71
|
+
*/
|
|
72
|
+
export function supportAdminRoutes(basePath: string): AdminRoute[] {
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
method: "GET",
|
|
76
|
+
path: `${basePath}/threads`,
|
|
77
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
78
|
+
summary: "List the inbox, newest first, filtered by category, priority, and archived, searched by text.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
method: "GET",
|
|
82
|
+
path: `${basePath}/threads/:id`,
|
|
83
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
84
|
+
summary: "Read one conversation, with the sender's linked account and what they bought.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
method: "POST",
|
|
88
|
+
path: `${basePath}/threads/:id/archive`,
|
|
89
|
+
scope: SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
90
|
+
summary: "Mark a conversation done, or reopen one.",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: `${basePath}/threads/:id/reply`,
|
|
95
|
+
scope: SUPPORT_THREADS_REPLY_SCOPE,
|
|
96
|
+
summary: "Answer the customer, threaded so their mail client keeps one conversation.",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
method: "POST",
|
|
100
|
+
path: `${basePath}/threads/:id/reclassify`,
|
|
101
|
+
scope: SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
102
|
+
summary: "Re-run the classifier over this conversation's latest inbound message.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
method: "POST",
|
|
106
|
+
path: `${basePath}/threads/:id/flags`,
|
|
107
|
+
scope: SUPPORT_THREADS_FLAG_SCOPE,
|
|
108
|
+
summary: "Set your own read and snooze state for a conversation. Private to you.",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
method: "GET",
|
|
112
|
+
path: `${basePath}/replies`,
|
|
113
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
114
|
+
summary: "The canned reply catalog — starting points to pick from, ordered for a thread's category.",
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|