@pithy-sh/support 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +8 -0
  2. package/dist/audit/actions.d.ts +9 -0
  3. package/dist/audit/actions.d.ts.map +1 -1
  4. package/dist/audit/actions.js +9 -0
  5. package/dist/capability.d.ts +2 -1
  6. package/dist/capability.d.ts.map +1 -1
  7. package/dist/capability.js +30 -6
  8. package/dist/config/config.d.ts +63 -2
  9. package/dist/config/config.d.ts.map +1 -1
  10. package/dist/config/config.js +63 -5
  11. package/dist/data/assignment.d.ts +43 -0
  12. package/dist/data/assignment.d.ts.map +1 -0
  13. package/dist/data/assignment.js +38 -0
  14. package/dist/data/embedding.d.ts +21 -0
  15. package/dist/data/embedding.d.ts.map +1 -0
  16. package/dist/data/embedding.js +23 -0
  17. package/dist/data/tables.d.ts +28 -0
  18. package/dist/data/tables.d.ts.map +1 -1
  19. package/dist/data/tables.js +10 -2
  20. package/dist/data/thread.d.ts +9 -4
  21. package/dist/data/thread.d.ts.map +1 -1
  22. package/dist/data/thread.js +9 -4
  23. package/dist/http/guards.d.ts +32 -14
  24. package/dist/http/guards.d.ts.map +1 -1
  25. package/dist/http/guards.js +44 -16
  26. package/dist/http/handlers.d.ts +35 -2
  27. package/dist/http/handlers.d.ts.map +1 -1
  28. package/dist/http/handlers.js +81 -8
  29. package/dist/http/resolve.d.ts +8 -0
  30. package/dist/http/resolve.d.ts.map +1 -1
  31. package/dist/http/resolve.js +20 -1
  32. package/dist/http/responses.d.ts +355 -0
  33. package/dist/http/responses.d.ts.map +1 -1
  34. package/dist/http/responses.js +64 -2
  35. package/dist/http/routes.d.ts +25 -11
  36. package/dist/http/routes.d.ts.map +1 -1
  37. package/dist/http/routes.js +34 -5
  38. package/dist/http/schemas.d.ts +67 -3
  39. package/dist/http/schemas.d.ts.map +1 -1
  40. package/dist/http/schemas.js +44 -5
  41. package/dist/http/scopes.d.ts +10 -5
  42. package/dist/http/scopes.d.ts.map +1 -1
  43. package/dist/http/scopes.js +42 -6
  44. package/dist/http/views.d.ts +9 -1
  45. package/dist/http/views.d.ts.map +1 -1
  46. package/dist/http/views.js +40 -1
  47. package/dist/inbound/ingest.d.ts +1 -1
  48. package/dist/inbound/ingest.d.ts.map +1 -1
  49. package/dist/inbound/ingest.js +3 -1
  50. package/dist/index.d.ts +5 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +6 -4
  53. package/dist/migrations/0002_assignment.d.ts +20 -0
  54. package/dist/migrations/0002_assignment.d.ts.map +1 -0
  55. package/dist/migrations/0002_assignment.js +45 -0
  56. package/dist/migrations/0003_embeddings.d.ts +15 -0
  57. package/dist/migrations/0003_embeddings.d.ts.map +1 -0
  58. package/dist/migrations/0003_embeddings.js +23 -0
  59. package/dist/migrations/set.d.ts +13 -0
  60. package/dist/migrations/set.d.ts.map +1 -0
  61. package/dist/migrations/set.js +21 -0
  62. package/dist/presence/room.d.ts +35 -0
  63. package/dist/presence/room.d.ts.map +1 -0
  64. package/dist/presence/room.js +66 -0
  65. package/dist/provision/provisionSupport.d.ts +32 -0
  66. package/dist/provision/provisionSupport.d.ts.map +1 -1
  67. package/dist/provision/provisionSupport.js +10 -2
  68. package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
  69. package/dist/provision/resolveSupportConfig.js +13 -2
  70. package/dist/purge/contribution.d.ts +49 -0
  71. package/dist/purge/contribution.d.ts.map +1 -0
  72. package/dist/purge/contribution.js +164 -0
  73. package/dist/secret/registry.d.ts +26 -2
  74. package/dist/secret/registry.d.ts.map +1 -1
  75. package/dist/secret/registry.js +15 -3
  76. package/dist/semantic/documents.d.ts +21 -0
  77. package/dist/semantic/documents.d.ts.map +1 -0
  78. package/dist/semantic/documents.js +128 -0
  79. package/dist/semantic/embed.d.ts +59 -0
  80. package/dist/semantic/embed.d.ts.map +1 -0
  81. package/dist/semantic/embed.js +59 -0
  82. package/dist/semantic/messageIndex.d.ts +46 -0
  83. package/dist/semantic/messageIndex.d.ts.map +1 -0
  84. package/dist/semantic/messageIndex.js +67 -0
  85. package/dist/semantic/query.d.ts +37 -0
  86. package/dist/semantic/query.d.ts.map +1 -0
  87. package/dist/semantic/query.js +46 -0
  88. package/dist/staff/staff.d.ts +22 -0
  89. package/dist/staff/staff.d.ts.map +1 -0
  90. package/dist/staff/staff.js +64 -0
  91. package/dist/store/assignment.d.ts +63 -0
  92. package/dist/store/assignment.d.ts.map +1 -0
  93. package/dist/store/assignment.js +86 -0
  94. package/dist/store/hybrid.d.ts +134 -0
  95. package/dist/store/hybrid.d.ts.map +1 -0
  96. package/dist/store/hybrid.js +185 -0
  97. package/dist/store/paging.d.ts +10 -0
  98. package/dist/store/paging.d.ts.map +1 -1
  99. package/dist/store/paging.js +11 -1
  100. package/dist/store/provisionIndex.d.ts +21 -0
  101. package/dist/store/provisionIndex.d.ts.map +1 -0
  102. package/dist/store/provisionIndex.js +36 -0
  103. package/dist/store/search.d.ts +18 -9
  104. package/dist/store/search.d.ts.map +1 -1
  105. package/dist/store/search.js +36 -34
  106. package/dist/store/searchIndex.d.ts +4 -4
  107. package/dist/store/searchIndex.js +4 -4
  108. package/dist/store/threads.d.ts +174 -8
  109. package/dist/store/threads.d.ts.map +1 -1
  110. package/dist/store/threads.js +33 -40
  111. package/dist/submission/submit.d.ts +1 -1
  112. package/dist/submission/submit.d.ts.map +1 -1
  113. package/dist/submission/submit.js +2 -1
  114. package/dist/test-utils/seed.d.ts +33 -0
  115. package/dist/test-utils/seed.d.ts.map +1 -0
  116. package/dist/test-utils/seed.js +102 -0
  117. package/dist/test-utils/semantic.d.ts +21 -0
  118. package/dist/test-utils/semantic.d.ts.map +1 -0
  119. package/dist/test-utils/semantic.js +107 -0
  120. package/dist/version.generated.d.ts +1 -1
  121. package/dist/version.generated.js +1 -1
  122. package/dist/workflows/specs.d.ts +23 -0
  123. package/dist/workflows/specs.d.ts.map +1 -1
  124. package/dist/workflows/specs.js +27 -7
  125. package/dist/workflows/worker.d.ts +15 -2
  126. package/dist/workflows/worker.d.ts.map +1 -1
  127. package/dist/workflows/worker.js +48 -4
  128. package/package.json +9 -7
  129. package/pithy.manifest.json +21 -3
  130. package/src/audit/actions.ts +9 -0
  131. package/src/capability.ts +43 -4
  132. package/src/config/config.ts +104 -5
  133. package/src/data/assignment.ts +55 -0
  134. package/src/data/embedding.ts +27 -0
  135. package/src/data/tables.ts +8 -0
  136. package/src/data/thread.ts +26 -4
  137. package/src/http/guards.ts +52 -15
  138. package/src/http/handlers.ts +125 -4
  139. package/src/http/resolve.ts +19 -0
  140. package/src/http/responses.ts +154 -0
  141. package/src/http/routes.ts +98 -12
  142. package/src/http/schemas.ts +117 -28
  143. package/src/http/scopes.ts +43 -6
  144. package/src/http/views.ts +44 -0
  145. package/src/inbound/ingest.ts +5 -2
  146. package/src/index.ts +5 -0
  147. package/src/migrations/0002_assignment.ts +65 -0
  148. package/src/migrations/0003_embeddings.ts +29 -0
  149. package/src/migrations/set.ts +21 -0
  150. package/src/presence/room.ts +94 -0
  151. package/src/provision/provisionSupport.ts +33 -1
  152. package/src/provision/resolveSupportConfig.ts +24 -13
  153. package/src/purge/contribution.ts +294 -0
  154. package/src/secret/registry.ts +17 -2
  155. package/src/semantic/documents.ts +178 -0
  156. package/src/semantic/embed.ts +99 -0
  157. package/src/semantic/messageIndex.ts +74 -0
  158. package/src/semantic/query.ts +66 -0
  159. package/src/staff/staff.ts +128 -0
  160. package/src/store/assignment.ts +179 -0
  161. package/src/store/hybrid.ts +314 -0
  162. package/src/store/paging.ts +14 -0
  163. package/src/store/provisionIndex.ts +46 -0
  164. package/src/store/search.ts +58 -37
  165. package/src/store/searchIndex.ts +4 -4
  166. package/src/store/threads.ts +91 -91
  167. package/src/submission/submit.ts +3 -2
  168. package/src/test-utils/seed.ts +141 -0
  169. package/src/test-utils/semantic.ts +110 -0
  170. package/src/version.generated.ts +1 -1
  171. package/src/workflows/specs.ts +34 -0
  172. package/src/workflows/worker.ts +84 -8
  173. package/src/workflows/wrangler.jsonc +8 -3
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { asRead } from "@pithy-sh/core/src/projection/asRead";
5
5
  import { z } from "zod";
6
+ import { SupportAssignmentActorType } from "../data/assignment";
6
7
  import { SUPPORT_BILLING_SCOPE } from "../data/billingScope";
7
8
  import {
8
9
  SupportAccountLinkSource,
@@ -109,6 +110,34 @@ export const SupportThreadView = z
109
110
  .describe(
110
111
  "The control-plane subject that archived it, for convenience only — the answer to 'who marked this done' comes from the audit trail.",
111
112
  ),
113
+ // Optional as well as nullable (#450, #816): absent says *this Worker predates assignment*, which a client
114
+ // must not read as *nobody has it*.
115
+ assigneeRef: z
116
+ .string()
117
+ .nullable()
118
+ .optional()
119
+ .describe(
120
+ "Who has this conversation, as the opaque reference it was assigned to, or null when nobody has it. Absent from a Worker that predates assignment. The listing's `assignee` filter matches it.",
121
+ ),
122
+ assigneeName: z
123
+ .string()
124
+ .nullable()
125
+ .optional()
126
+ .describe(
127
+ "How the assignee is shown, as the assigner named them; null when nobody has it. A label, never an identity — `assigneeRef` is that. Untrusted text; render it escaped.",
128
+ ),
129
+ assignedAt: z.iso
130
+ .datetime()
131
+ .nullable()
132
+ .optional()
133
+ .describe("When the current assignment was made, ISO-8601; null when nobody has it."),
134
+ assignedBy: z
135
+ .string()
136
+ .nullable()
137
+ .optional()
138
+ .describe(
139
+ "Who made the current assignment — a control-plane subject, or a user of the app's id; null when nobody has it. Every earlier change is in the thread's `assignments`.",
140
+ ),
112
141
  messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
113
142
  firstMessageAt: z.iso.datetime().describe("When the thread opened, ISO-8601."),
114
143
  lastMessageAt: z.iso.datetime().describe("When the thread last moved, ISO-8601. The inbox sorts on this."),
@@ -281,6 +310,98 @@ export const SupportThreadsResponse = z
281
310
  .describe("A page of the inbox, as one viewer sees it.");
282
311
  export type SupportThreadsResponse = z.output<typeof SupportThreadsResponse>;
283
312
 
313
+ /** A search backend, by name. */
314
+ const SupportSearchBackend = z
315
+ .enum(["fts", "like", "semantic"])
316
+ .describe("A search backend: `fts` (the FTS5 index), `like` (the keyword scan), or `semantic` (the message index).");
317
+
318
+ /** One search result. */
319
+ export const SupportSearchResultView = z
320
+ .object({
321
+ thread: SupportListedThreadView.describe("The thread, carrying the calling viewer's own read and snooze state."),
322
+ messageId: z
323
+ .string()
324
+ .describe(
325
+ "The message in the thread that matched best — the one the scores below belong to. Open the thread at it.",
326
+ ),
327
+ bm25: z
328
+ .number()
329
+ .optional()
330
+ .describe(
331
+ "That message's FTS5 `bm25`, raw: lower is better, and comparable only within one search. Absent when FTS5 did not run or did not match it — including on the `LIKE` scan, which has no score.",
332
+ ),
333
+ similarity: z
334
+ .number()
335
+ .optional()
336
+ .describe(
337
+ "That message's similarity to the query in the semantic index, raw: higher is closer. Absent when semantic search did not run or did not match it. Never normalized against `bm25` — the two are reported as they are.",
338
+ ),
339
+ })
340
+ .describe("One thread a search found, named by its best-matching message, with that message's raw scores.");
341
+ export type SupportSearchResultView = z.output<typeof SupportSearchResultView>;
342
+
343
+ /** `GET {base}/threads/search`. */
344
+ export const SupportSearchResponse = z
345
+ .object({
346
+ mode: z
347
+ .enum(["keyword", "semantic", "hybrid"])
348
+ .describe("The mode that ran: the one asked for, or `hybrid` when none was."),
349
+ backends: z
350
+ .object({
351
+ ran: z.array(SupportSearchBackend).describe("The backends that answered."),
352
+ skipped: z
353
+ .array(
354
+ z
355
+ .object({
356
+ backend: SupportSearchBackend,
357
+ reason: z
358
+ .enum(["unprovisioned", "failed"])
359
+ .describe(
360
+ "`unprovisioned` — on in config, and its index or binding is not there until `pithy support provision` runs. `failed` — it was there and did not answer.",
361
+ ),
362
+ })
363
+ .describe("A backend that was on and did not answer."),
364
+ )
365
+ .describe(
366
+ "The backends that were on and did not answer. Empty when every one did. A skip degrades the search, never fails it.",
367
+ ),
368
+ })
369
+ .describe("Which backends ran, and which were skipped."),
370
+ count: z
371
+ .number()
372
+ .int()
373
+ .min(0)
374
+ .describe(
375
+ "How many results came back. Under the limit when the filters left fewer — that is the true count, not a page.",
376
+ ),
377
+ results: z
378
+ .array(SupportSearchResultView)
379
+ .describe("The threads, best first: fused by rank, newest first on a tie."),
380
+ })
381
+ .describe("A ranked search over the inbox. No cursor: a ranked list has a top, not pages.");
382
+ export type SupportSearchResponse = z.output<typeof SupportSearchResponse>;
383
+
384
+ /** One change of a thread's assignee, as its history shows it. */
385
+ export const SupportAssignmentView = z
386
+ .object({
387
+ id: z.string().describe("The history row's id."),
388
+ assigneeRef: z
389
+ .string()
390
+ .nullable()
391
+ .describe("Who had the thread after this change, or null when it was unassigned."),
392
+ assigneeName: z.string().nullable().describe("How that assignee was shown at the time, or null."),
393
+ previousRef: z.string().nullable().describe("Who had it before, or null when nobody did."),
394
+ previousName: z.string().nullable().describe("How the previous assignee was shown, or null."),
395
+ actorType: SupportAssignmentActorType.describe(
396
+ "How whoever made the change was verified: `control-plane` for a management client, `user` for a signed-in user of the app.",
397
+ ),
398
+ assignedBy: z.string().describe("Who made the change — a control-plane subject, or a user of the app's id."),
399
+ note: z.string().nullable().describe("Why, in the assigner's words, or null. Untrusted text; render it escaped."),
400
+ createdAt: z.iso.datetime().describe("When the change was made, ISO-8601."),
401
+ })
402
+ .describe("One append-only change of a conversation's assignee.");
403
+ export type SupportAssignmentView = z.output<typeof SupportAssignmentView>;
404
+
284
405
  /** `GET {base}/threads/:id`. */
285
406
  export const SupportThreadResponse = z
286
407
  .object({
@@ -289,6 +410,13 @@ export const SupportThreadResponse = z
289
410
  attachments: z.array(SupportAttachmentView).describe("Its attachments, across every message."),
290
411
  sender: SenderContextView.describe("What the app already knows about the sender."),
291
412
  replies: z.array(SupportReplyView).describe("The canned replies, ordered for this thread's category."),
413
+ // Optional (#450): absent from a Worker that predates assignment.
414
+ assignments: z
415
+ .array(SupportAssignmentView)
416
+ .optional()
417
+ .describe(
418
+ "Every change of the assignee, oldest first. Empty when nobody was ever assigned; absent from a Worker that predates assignment.",
419
+ ),
292
420
  })
293
421
  .describe("A whole conversation, with the customer context and the replies worth offering on it.");
294
422
  export type SupportThreadResponse = z.output<typeof SupportThreadResponse>;
@@ -333,6 +461,24 @@ export const SupportReplySentResponse = z
333
461
  .describe("The reply that was filed, and which of the two ways it reaches the customer.");
334
462
  export type SupportReplySentResponse = z.output<typeof SupportReplySentResponse>;
335
463
 
464
+ /**
465
+ * `POST {base}/threads/:id/assign` and `POST {base}/staff/threads/:id/assign`.
466
+ *
467
+ * The thread as the assignment left it, and whether anything changed: assigning the current assignee again
468
+ * is a success that wrote nothing, and a pane that re-renders from it should know not to say *reassigned*.
469
+ */
470
+ export const SupportAssignResponse = z
471
+ .object({
472
+ thread: SupportThreadView.describe("The conversation, as the assignment left it."),
473
+ changed: z
474
+ .boolean()
475
+ .describe(
476
+ "Whether the assignee changed. False when the thread already had that assignee, or had none and was unassigned — nothing was recorded and nothing was audited.",
477
+ ),
478
+ })
479
+ .describe("The conversation after an assignment, and whether it changed anything.");
480
+ export type SupportAssignResponse = z.output<typeof SupportAssignResponse>;
481
+
336
482
  /** `POST {base}/threads/:id/reclassify`. */
337
483
  export const SupportReclassifiedResponse = z
338
484
  .object({
@@ -399,6 +545,10 @@ export type SupportMessageViewAsRead = z.output<typeof SupportMessageViewAsRead>
399
545
  export const SupportThreadsResponseAsRead = asRead(SupportThreadsResponse);
400
546
  export type SupportThreadsResponseAsRead = z.output<typeof SupportThreadsResponseAsRead>;
401
547
 
548
+ /** `GET {base}/threads/search`, as a client that does not control the Worker must read it. */
549
+ export const SupportSearchResponseAsRead = asRead(SupportSearchResponse);
550
+ export type SupportSearchResponseAsRead = z.output<typeof SupportSearchResponseAsRead>;
551
+
402
552
  /** `GET {base}/threads/:id`, as a client that does not control the Worker must read it. */
403
553
  export const SupportThreadResponseAsRead = asRead(SupportThreadResponse);
404
554
  export type SupportThreadResponseAsRead = z.output<typeof SupportThreadResponseAsRead>;
@@ -407,6 +557,10 @@ export type SupportThreadResponseAsRead = z.output<typeof SupportThreadResponseA
407
557
  export const SupportArchiveResponseAsRead = asRead(SupportArchiveResponse);
408
558
  export type SupportArchiveResponseAsRead = z.output<typeof SupportArchiveResponseAsRead>;
409
559
 
560
+ /** `POST {base}/threads/:id/assign`, as a client that does not control the Worker must read it. */
561
+ export const SupportAssignResponseAsRead = asRead(SupportAssignResponse);
562
+ export type SupportAssignResponseAsRead = z.output<typeof SupportAssignResponseAsRead>;
563
+
410
564
  /**
411
565
  * ## The submitter's own view, and what it deliberately omits
412
566
  *
@@ -8,9 +8,11 @@ import { describeRoute } from "@pithy-sh/core/src/http/routeSpec";
8
8
  import { requireSameOrigin } from "@pithy-sh/core/src/http/sameOrigin";
9
9
  import { validate } from "@pithy-sh/core/src/http/validation";
10
10
  import type { Context, Hono } from "hono";
11
- import { requireAuth } from "./guards";
11
+ import type { SupportStaffConfig } from "../config/config";
12
+ import { requireAuth, requireStaff } from "./guards";
12
13
  import {
13
14
  archiveConversation,
15
+ assignConversation,
14
16
  type HandlerDeps,
15
17
  listInbox,
16
18
  listMyThreads,
@@ -19,33 +21,39 @@ import {
19
21
  readMyThread,
20
22
  reclassifyConversation,
21
23
  replyToConversation,
24
+ searchInbox,
22
25
  submitFeedbackRequest,
23
26
  updateFlags,
24
27
  } from "./handlers";
25
28
  import {
26
29
  SupportArchiveResponse,
30
+ SupportAssignResponse,
27
31
  SupportFlagsResponse,
28
32
  SupportMyThreadResponse,
29
33
  SupportMyThreadsResponse,
30
34
  SupportReclassifiedResponse,
31
35
  SupportRepliesResponse,
32
36
  SupportReplySentResponse,
37
+ SupportSearchResponse,
33
38
  SupportSubmissionResponse,
34
39
  SupportThreadResponse,
35
40
  SupportThreadsResponse,
36
41
  } from "./responses";
37
42
  import {
38
43
  ArchiveThreadInput,
44
+ AssignThreadInput,
39
45
  FlagsInput,
40
46
  ListThreadsQuery,
41
47
  MyThreadsQuery,
42
48
  RepliesQuery,
43
49
  ReplyInput,
50
+ SearchThreadsQuery,
44
51
  SubmitFeedbackInput,
45
52
  ThreadIdParam,
46
53
  } from "./schemas";
47
54
  import {
48
55
  SUPPORT_THREADS_ARCHIVE_SCOPE,
56
+ SUPPORT_THREADS_ASSIGN_SCOPE,
49
57
  SUPPORT_THREADS_FLAG_SCOPE,
50
58
  SUPPORT_THREADS_READ_SCOPE,
51
59
  SUPPORT_THREADS_RECLASSIFY_SCOPE,
@@ -56,8 +64,10 @@ import {
56
64
  * The support routes, their verification strategies, and what each accepts:
57
65
  *
58
66
  * GET /support/threads → the inbox (control-plane: support:threads:read) query: ListThreadsQuery
67
+ * GET /support/threads/search → search (control-plane: support:threads:read) query: SearchThreadsQuery
59
68
  * GET /support/threads/:id → one thread (control-plane: support:threads:read) param: ThreadIdParam
60
69
  * POST /support/threads/:id/archive → done/reopen (control-plane: support:threads:archive) param + json
70
+ * POST /support/threads/:id/assign → who has it (control-plane: support:threads:assign) param + json
61
71
  * POST /support/threads/:id/reply → answer (control-plane: support:threads:reply) param + json
62
72
  * POST /support/threads/:id/reclassify→ re-run model (control-plane: support:threads:reclassify) param
63
73
  * POST /support/threads/:id/flags → read/snooze (control-plane: support:threads:flag) param + json
@@ -67,20 +77,26 @@ import {
67
77
  * GET /support/feedback → my requests (bearer/session) query: MyThreadsQuery
68
78
  * GET /support/feedback/:id → one of mine (bearer/session) param: ThreadIdParam
69
79
  *
70
- * **Two surfaces, two gates, and they never stack.** The management routes answer to a control-plane
71
- * credential the adopter issued, and with the seam uncomposed every one denies with
72
- * `controlplane/not_connected` — the correct failure for an inbox holding other people's private
80
+ * POST /support/staff/threads/:id/assign → who has it (bearer/session + same-origin + staff assignPower) param + json
81
+ *
82
+ * **Three doors, three gates, and the control plane never stacks with a session.** The management routes
83
+ * answer to a control-plane credential the adopter issued, and with the seam uncomposed every one denies
84
+ * with `controlplane/not_connected` — the correct failure for an inbox holding other people's private
73
85
  * correspondence. The `feedback` routes answer to the adopter's own signed-in user, and with no auth
74
- * capability composed `c.var.auth` is null and every one denies. A control-plane caller holds no
75
- * session by design and can never satisfy `requireAuth()`; a user's session confers no scope. See
86
+ * capability composed `c.var.auth` is null and every one denies. The `staff` route answers to a signed-in
87
+ * user too, and then to `requireStaff()`: a staff power from `support({ staff })`, held in the organization
88
+ * the request acts for, and with no `staff` configured every caller is refused. A control-plane caller
89
+ * holds no session by design and can never satisfy `requireAuth()`; a user's session confers no scope —
90
+ * which is why staff assignment is its own route rather than a second gate on the control-plane one. See
76
91
  * `guards.ts`.
77
92
  *
78
- * **`requireSameOrigin()` is on the submission route and on neither read.** Cookie-mode sessions make a
79
- * mutating route CSRF-reachable, and this one writes into a support inbox under a real customer's name
80
- * — a forged submission is somebody else's words attributed to them, in the one place an operator
81
- * treats attribution as proven. The reads are GETs and carry no such risk. A bearer caller is
82
- * CSRF-exempt, and that exemption belongs to the gate `@pithy-sh/auth` publishes rather than to
83
- * anything decided here.
93
+ * **`requireSameOrigin()` is on the two session routes that write, and on no read.** Cookie-mode sessions
94
+ * make a mutating route CSRF-reachable. The submission route writes into a support inbox under a real
95
+ * customer's name — a forged submission is somebody else's words attributed to them, in the one place an
96
+ * operator treats attribution as proven — and the staff assign route hands a customer's conversation to
97
+ * somebody on a staff member's say-so. The reads are GETs and carry no such risk. A bearer caller is
98
+ * CSRF-exempt, and that exemption belongs to the gate `@pithy-sh/auth` publishes rather than to anything
99
+ * decided here.
84
100
  *
85
101
  * **Every response has an exported schema**, in `responses.ts`. Each handler's return type is
86
102
  * `z.output` of its envelope, so `c.json(await handler(...))` carries the contract without a cast — and
@@ -158,6 +174,11 @@ export interface SupportRoutesOptions {
158
174
  submission?: boolean;
159
175
  /** Resolve handler deps from the request context. */
160
176
  resolveDeps: (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
177
+ /**
178
+ * Who of the app's signed-in users works the inbox. Absent, the staff route is still mounted and refuses
179
+ * every caller with 403 — it is not a feature this deployment lacks, it is a door nobody holds a key to.
180
+ */
181
+ staff?: SupportStaffConfig;
161
182
  }
162
183
 
163
184
  /**
@@ -217,6 +238,20 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
217
238
  async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))),
218
239
  );
219
240
 
241
+ // Before `/threads/:id`, so `search` is never read as a thread id.
242
+ app.get(
243
+ `${base}/threads/search`,
244
+ describeRoute({
245
+ summary: "Search the inbox, by words and by meaning, ranked as one.",
246
+ verification: "control-plane",
247
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
248
+ responses: { 200: SupportSearchResponse },
249
+ }),
250
+ requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
251
+ validate("query", SearchThreadsQuery),
252
+ async (c) => c.json(await searchInbox(await resolve(c), c.req.valid("query"), viewer(c))),
253
+ );
254
+
220
255
  app.get(
221
256
  `${base}/replies`,
222
257
  describeRoute({
@@ -259,6 +294,51 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
259
294
  c.json(await archiveConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
260
295
  );
261
296
 
297
+ app.post(
298
+ `${base}/threads/:id/assign`,
299
+ describeRoute({
300
+ summary: "Assign a conversation to somebody, or to nobody.",
301
+ verification: "control-plane",
302
+ scopes: [SUPPORT_THREADS_ASSIGN_SCOPE],
303
+ responses: { 200: SupportAssignResponse },
304
+ }),
305
+ requireControlPlane(SUPPORT_THREADS_ASSIGN_SCOPE),
306
+ validate("param", ThreadIdParam),
307
+ validate("json", AssignThreadInput),
308
+ async (c) =>
309
+ c.json(
310
+ await assignConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), {
311
+ type: "control-plane",
312
+ id: viewer(c),
313
+ }),
314
+ ),
315
+ );
316
+
317
+ // The same act for a signed-in user of the app who works the inbox: their session, the CSRF gate a
318
+ // cookie-mode session needs on any write, and support's staff power in the organization they act for
319
+ // (`staff.ts`). A control-plane caller holds no session and can never pass `requireAuth()`, so the two
320
+ // doors never stack.
321
+ app.post(
322
+ `${base}/staff/threads/:id/assign`,
323
+ describeRoute({
324
+ summary: "Assign a conversation as a signed-in member of the support staff.",
325
+ verification: ["bearer", "session"],
326
+ responses: { 200: SupportAssignResponse },
327
+ }),
328
+ requireAuth(),
329
+ requireSameOrigin(),
330
+ requireStaff(options.staff, "assignPower"),
331
+ validate("param", ThreadIdParam),
332
+ validate("json", AssignThreadInput),
333
+ async (c) =>
334
+ c.json(
335
+ await assignConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), {
336
+ type: "user",
337
+ id: submitter(c),
338
+ }),
339
+ ),
340
+ );
341
+
262
342
  app.post(
263
343
  `${base}/threads/:id/reply`,
264
344
  describeRoute({
@@ -313,6 +393,8 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
313
393
  `${base}/feedback`,
314
394
  describeRoute({
315
395
  summary: "Write in to support from the app.",
396
+ operation: "feedback.write",
397
+ tool: { effect: "sends" },
316
398
  verification: ["bearer", "session"],
317
399
  responses: { 201: SupportSubmissionResponse },
318
400
  }),
@@ -326,6 +408,8 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
326
408
  `${base}/feedback`,
327
409
  describeRoute({
328
410
  summary: "List the caller's own support requests.",
411
+ operation: "feedback.list",
412
+ tool: true,
329
413
  verification: ["bearer", "session"],
330
414
  responses: { 200: SupportMyThreadsResponse },
331
415
  }),
@@ -338,6 +422,8 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
338
422
  `${base}/feedback/:id`,
339
423
  describeRoute({
340
424
  summary: "Read one of the caller's own support requests.",
425
+ operation: "feedback.read",
426
+ tool: true,
341
427
  verification: ["bearer", "session"],
342
428
  responses: { 200: SupportMyThreadResponse },
343
429
  }),
@@ -1,11 +1,20 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { PresenceParticipantName, PresenceParticipantRef } from "@pithy-sh/core/src/presence/frames";
4
5
  import { z } from "zod";
5
6
  import { MAX_SUBMISSION_ATTACHMENTS, MAX_SUBMISSION_BODY_CHARS, MAX_SUBMISSION_SUBJECT_CHARS } from "../config/config";
7
+ import { UNASSIGNED } from "../data/assignment";
6
8
  import { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
7
9
  import { SupportSubmissionContext } from "../data/message";
8
- import { MAX_PAGE_SIZE } from "../store/paging";
10
+ import { DEFAULT_SEARCH_LIMIT, MAX_PAGE_SIZE, MAX_SEARCH_LIMIT, MAX_SEARCH_TERM } from "../store/paging";
11
+
12
+ /** An assignee reference, as the listing filters on it and an assignment names it. */
13
+ const AssigneeRef = PresenceParticipantRef.refine((ref) => ref !== UNASSIGNED, {
14
+ message: `"${UNASSIGNED}" is reserved: it is the filter for threads nobody has`,
15
+ }).describe(
16
+ "Who the thread is assigned to: an opaque reference, the participant shape presence rooms use. A management client's own reference, which it chose; or a user of the app's id. Never `none`, which is the listing's filter for threads nobody has.",
17
+ );
9
18
 
10
19
  /**
11
20
  * The request contracts every support route declares (CLAUDE.md §HTTP).
@@ -41,34 +50,45 @@ const CategoryFilter = z
41
50
  "One category key from this project's effective taxonomy. A key an adopter removed simply matches nothing.",
42
51
  );
43
52
 
44
- /** The inbox listing query. */
53
+ /** The filters the inbox listing and the search route share, applied in D1 either way. */
54
+ const threadFilterFields = {
55
+ archived: z
56
+ .enum(["true", "false"])
57
+ .optional()
58
+ .describe("`true` for the done pile, `false` or absent for the open inbox — which is the default on purpose."),
59
+ category: CategoryFilter.optional().describe("Filter to one category — what the classifier made of it."),
60
+ declaredCategory: CategoryFilter.optional().describe(
61
+ "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.",
62
+ ),
63
+ priority: SupportPriority.optional().describe("Filter to one priority."),
64
+ sentiment: SupportSentiment.optional().describe("Filter to one sentiment."),
65
+ channel: SupportChannel.optional().describe(
66
+ "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.",
67
+ ),
68
+ inbox: z
69
+ .string()
70
+ .min(3)
71
+ .max(256)
72
+ .optional()
73
+ .describe("Filter to one configured inbox address, for a Worker serving several."),
74
+ assignee: z
75
+ .union([z.literal(UNASSIGNED), AssigneeRef])
76
+ .optional()
77
+ .describe(
78
+ "Filter to the threads one assignee has, by their `assigneeRef`, or `none` for the threads nobody has. Absent shows every thread, assigned or not.",
79
+ ),
80
+ };
81
+
82
+ /**
83
+ * The inbox listing query.
84
+ *
85
+ * **Strict, and there is no `q`.** Listing lists; `GET {basePath}/threads/search` searches. An unknown
86
+ * key is refused as `validation/invalid_input` rather than ignored, so a client sending `q` is told so
87
+ * instead of getting the unfiltered inbox back as though it were the answer.
88
+ */
45
89
  export const ListThreadsQuery = z
46
90
  .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."),
91
+ ...threadFilterFields,
72
92
  cursor: z
73
93
  .string()
74
94
  .max(512)
@@ -82,9 +102,45 @@ export const ListThreadsQuery = z
82
102
  .optional()
83
103
  .describe("How many threads to return. Bounded, because a verified client can still have a bug."),
84
104
  })
85
- .describe("The inbox query: what to filter by, what to search for, and where to resume.");
105
+ .strict()
106
+ .describe("The inbox query: what to filter by, and where to resume. Search is its own route.");
86
107
  export type ListThreadsQuery = z.infer<typeof ListThreadsQuery>;
87
108
 
109
+ /** How a search runs. */
110
+ export const SupportSearchMode = z
111
+ .enum(["keyword", "semantic", "hybrid"])
112
+ .describe(
113
+ "`keyword` finds the exact words — FTS5 when `search.fts` is on, the `LIKE` scan otherwise. `semantic` finds the meaning, through the message index. `hybrid` runs both and ranks them as one.",
114
+ );
115
+ export type SupportSearchMode = z.infer<typeof SupportSearchMode>;
116
+
117
+ /** The search query. */
118
+ export const SearchThreadsQuery = z
119
+ .object({
120
+ q: z
121
+ .string()
122
+ .trim()
123
+ .min(1)
124
+ .max(MAX_SEARCH_TERM)
125
+ .describe("What to search for, in the customer's words or yours. An order number, an error, or a description."),
126
+ mode: SupportSearchMode.optional().describe(
127
+ "Which backends to run. Absent runs every one that is on, as `hybrid`. `semantic` or `hybrid` while `search.semantic` is off is refused, naming the flag.",
128
+ ),
129
+ limit: z.coerce
130
+ .number()
131
+ .int()
132
+ .min(1)
133
+ .max(MAX_SEARCH_LIMIT)
134
+ .optional()
135
+ .describe(
136
+ `How many threads to return, best first. Default ${DEFAULT_SEARCH_LIMIT}, at most ${MAX_SEARCH_LIMIT}. No cursor: a ranked list has a top, not pages.`,
137
+ ),
138
+ ...threadFilterFields,
139
+ })
140
+ .strict()
141
+ .describe("A search over the inbox: the words, the mode, how many, and the same filters the inbox listing takes.");
142
+ export type SearchThreadsQuery = z.infer<typeof SearchThreadsQuery>;
143
+
88
144
  /** The archive/unarchive body. */
89
145
  export const ArchiveThreadInput = z
90
146
  .object({
@@ -97,6 +153,39 @@ export const ArchiveThreadInput = z
97
153
  .describe("Mark a conversation done, or reopen it.");
98
154
  export type ArchiveThreadInput = z.infer<typeof ArchiveThreadInput>;
99
155
 
156
+ /** Who a thread is assigned to. */
157
+ export const AssigneeInput = z
158
+ .object({
159
+ ref: AssigneeRef,
160
+ name: PresenceParticipantName.describe(
161
+ "How the assignee is shown in the inbox and in the history. A label, never an identity — `ref` is that.",
162
+ ),
163
+ })
164
+ .describe("Somebody a thread may be assigned to: an opaque reference and the name to show.");
165
+ export type AssigneeInput = z.infer<typeof AssigneeInput>;
166
+
167
+ /** The longest assignment note, in UTF-16 code units. */
168
+ export const MAX_ASSIGNMENT_NOTE_CHARS = 500;
169
+
170
+ /** The assign/unassign body, the same on the control-plane route and the staff session route. */
171
+ export const AssignThreadInput = z
172
+ .object({
173
+ assignee: AssigneeInput.nullable().describe(
174
+ "Who should have this conversation, or `null` to unassign it. Assigning the current assignee again changes nothing and records nothing.",
175
+ ),
176
+ note: z
177
+ .string()
178
+ .trim()
179
+ .min(1)
180
+ .max(MAX_ASSIGNMENT_NOTE_CHARS)
181
+ .optional()
182
+ .describe(
183
+ "Why, in your own words. Kept in the thread's assignment history and on the audit event, and shown to anybody who can read the thread — so it is a note to colleagues, never a place for the customer's details.",
184
+ ),
185
+ })
186
+ .describe("Assign a conversation to somebody, or to nobody.");
187
+ export type AssignThreadInput = z.infer<typeof AssignThreadInput>;
188
+
100
189
  /** The reply body. */
101
190
  export const ReplyInput = z
102
191
  .object({