@pithy-sh/support 0.5.1 → 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
@@ -2,13 +2,16 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
4
4
  import type { Context, Hono } from "hono";
5
+ import type { SupportStaffConfig } from "../config/config";
5
6
  import { type HandlerDeps } from "./handlers";
6
7
  /**
7
8
  * The support routes, their verification strategies, and what each accepts:
8
9
  *
9
10
  * GET /support/threads → the inbox (control-plane: support:threads:read) query: ListThreadsQuery
11
+ * GET /support/threads/search → search (control-plane: support:threads:read) query: SearchThreadsQuery
10
12
  * GET /support/threads/:id → one thread (control-plane: support:threads:read) param: ThreadIdParam
11
13
  * POST /support/threads/:id/archive → done/reopen (control-plane: support:threads:archive) param + json
14
+ * POST /support/threads/:id/assign → who has it (control-plane: support:threads:assign) param + json
12
15
  * POST /support/threads/:id/reply → answer (control-plane: support:threads:reply) param + json
13
16
  * POST /support/threads/:id/reclassify→ re-run model (control-plane: support:threads:reclassify) param
14
17
  * POST /support/threads/:id/flags → read/snooze (control-plane: support:threads:flag) param + json
@@ -18,20 +21,26 @@ import { type HandlerDeps } from "./handlers";
18
21
  * GET /support/feedback → my requests (bearer/session) query: MyThreadsQuery
19
22
  * GET /support/feedback/:id → one of mine (bearer/session) param: ThreadIdParam
20
23
  *
21
- * **Two surfaces, two gates, and they never stack.** The management routes answer to a control-plane
22
- * credential the adopter issued, and with the seam uncomposed every one denies with
23
- * `controlplane/not_connected` — the correct failure for an inbox holding other people's private
24
+ * POST /support/staff/threads/:id/assign → who has it (bearer/session + same-origin + staff assignPower) param + json
25
+ *
26
+ * **Three doors, three gates, and the control plane never stacks with a session.** The management routes
27
+ * answer to a control-plane credential the adopter issued, and with the seam uncomposed every one denies
28
+ * with `controlplane/not_connected` — the correct failure for an inbox holding other people's private
24
29
  * correspondence. The `feedback` routes answer to the adopter's own signed-in user, and with no auth
25
- * capability composed `c.var.auth` is null and every one denies. A control-plane caller holds no
26
- * session by design and can never satisfy `requireAuth()`; a user's session confers no scope. See
30
+ * capability composed `c.var.auth` is null and every one denies. The `staff` route answers to a signed-in
31
+ * user too, and then to `requireStaff()`: a staff power from `support({ staff })`, held in the organization
32
+ * the request acts for, and with no `staff` configured every caller is refused. A control-plane caller
33
+ * holds no session by design and can never satisfy `requireAuth()`; a user's session confers no scope —
34
+ * which is why staff assignment is its own route rather than a second gate on the control-plane one. See
27
35
  * `guards.ts`.
28
36
  *
29
- * **`requireSameOrigin()` is on the submission route and on neither read.** Cookie-mode sessions make a
30
- * mutating route CSRF-reachable, and this one writes into a support inbox under a real customer's name
31
- * — a forged submission is somebody else's words attributed to them, in the one place an operator
32
- * treats attribution as proven. The reads are GETs and carry no such risk. A bearer caller is
33
- * CSRF-exempt, and that exemption belongs to the gate `@pithy-sh/auth` publishes rather than to
34
- * anything decided here.
37
+ * **`requireSameOrigin()` is on the two session routes that write, and on no read.** Cookie-mode sessions
38
+ * make a mutating route CSRF-reachable. The submission route writes into a support inbox under a real
39
+ * customer's name — a forged submission is somebody else's words attributed to them, in the one place an
40
+ * operator treats attribution as proven — and the staff assign route hands a customer's conversation to
41
+ * somebody on a staff member's say-so. The reads are GETs and carry no such risk. A bearer caller is
42
+ * CSRF-exempt, and that exemption belongs to the gate `@pithy-sh/auth` publishes rather than to anything
43
+ * decided here.
35
44
  *
36
45
  * **Every response has an exported schema**, in `responses.ts`. Each handler's return type is
37
46
  * `z.output` of its envelope, so `c.json(await handler(...))` carries the contract without a cast — and
@@ -108,6 +117,11 @@ export interface SupportRoutesOptions {
108
117
  submission?: boolean;
109
118
  /** Resolve handler deps from the request context. */
110
119
  resolveDeps: (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
120
+ /**
121
+ * Who of the app's signed-in users works the inbox. Absent, the staff route is still mounted and refuses
122
+ * every caller with 403 — it is not a feature this deployment lacks, it is a door nobody holds a key to.
123
+ */
124
+ staff?: SupportStaffConfig;
111
125
  }
112
126
  /** Register the support sub-router. Returned as the capability's `routes` hook. */
113
127
  export declare function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono<PithyHonoEnv>) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/http/routes.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAM7E,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE1C,OAAO,EAEL,KAAK,WAAW,EAUjB,MAAM,YAAY,CAAC;AA+BpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0FG;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,CAoJtG"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/http/routes.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAM7E,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC1C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE3D,OAAO,EAGL,KAAK,WAAW,EAWjB,MAAM,YAAY,CAAC;AAoCpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkGG;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;IAChE;;;OAGG;IACH,KAAK,CAAC,EAAE,kBAAkB,CAAC;CAC5B;AAuCD,mFAAmF;AACnF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,oBAAoB,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,CAAC,KAAK,IAAI,CAqNtG"}
@@ -1,10 +1,10 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { requireAuth } from "./guards.js";
4
- import { archiveConversation, listInbox, listMyThreads, listReplies, readConversation, readMyThread, reclassifyConversation, replyToConversation, submitFeedbackRequest, updateFlags } from "./handlers.js";
5
- import { SupportArchiveResponse, SupportFlagsResponse, SupportMyThreadResponse, SupportMyThreadsResponse, SupportReclassifiedResponse, SupportRepliesResponse, SupportReplySentResponse, SupportSubmissionResponse, SupportThreadResponse, SupportThreadsResponse } from "./responses.js";
6
- import { ArchiveThreadInput, FlagsInput, ListThreadsQuery, MyThreadsQuery, RepliesQuery, ReplyInput, SubmitFeedbackInput, ThreadIdParam } from "./schemas.js";
7
- import { SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE } from "./scopes.js";
3
+ import { requireAuth, requireStaff } from "./guards.js";
4
+ import { archiveConversation, assignConversation, listInbox, listMyThreads, listReplies, readConversation, readMyThread, reclassifyConversation, replyToConversation, searchInbox, submitFeedbackRequest, updateFlags } from "./handlers.js";
5
+ import { SupportArchiveResponse, SupportAssignResponse, SupportFlagsResponse, SupportMyThreadResponse, SupportMyThreadsResponse, SupportReclassifiedResponse, SupportRepliesResponse, SupportReplySentResponse, SupportSearchResponse, SupportSubmissionResponse, SupportThreadResponse, SupportThreadsResponse } from "./responses.js";
6
+ import { ArchiveThreadInput, AssignThreadInput, FlagsInput, ListThreadsQuery, MyThreadsQuery, RepliesQuery, ReplyInput, SearchThreadsQuery, SubmitFeedbackInput, ThreadIdParam } from "./schemas.js";
7
+ import { SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_ASSIGN_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE } from "./scopes.js";
8
8
  import { InternalError } from "@pithy-sh/core/src/error/pithyError";
9
9
  import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
10
10
  import { describeRoute } from "@pithy-sh/core/src/http/routeSpec";
@@ -54,6 +54,12 @@ function registerSupportRoutes(options) {
54
54
  scopes: [SUPPORT_THREADS_READ_SCOPE],
55
55
  responses: { 200: SupportThreadsResponse }
56
56
  }), requireControlPlane(SUPPORT_THREADS_READ_SCOPE), validate("query", ListThreadsQuery), async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))));
57
+ app.get(`${base}/threads/search`, describeRoute({
58
+ summary: "Search the inbox, by words and by meaning, ranked as one.",
59
+ verification: "control-plane",
60
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
61
+ responses: { 200: SupportSearchResponse }
62
+ }), requireControlPlane(SUPPORT_THREADS_READ_SCOPE), validate("query", SearchThreadsQuery), async (c) => c.json(await searchInbox(await resolve(c), c.req.valid("query"), viewer(c))));
57
63
  app.get(`${base}/replies`, describeRoute({
58
64
  summary: "List the canned replies.",
59
65
  verification: "control-plane",
@@ -72,6 +78,23 @@ function registerSupportRoutes(options) {
72
78
  scopes: [SUPPORT_THREADS_ARCHIVE_SCOPE],
73
79
  responses: { 200: SupportArchiveResponse }
74
80
  }), requireControlPlane(SUPPORT_THREADS_ARCHIVE_SCOPE), validate("param", ThreadIdParam), validate("json", ArchiveThreadInput), async (c) => c.json(await archiveConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))));
81
+ app.post(`${base}/threads/:id/assign`, describeRoute({
82
+ summary: "Assign a conversation to somebody, or to nobody.",
83
+ verification: "control-plane",
84
+ scopes: [SUPPORT_THREADS_ASSIGN_SCOPE],
85
+ responses: { 200: SupportAssignResponse }
86
+ }), requireControlPlane(SUPPORT_THREADS_ASSIGN_SCOPE), validate("param", ThreadIdParam), validate("json", AssignThreadInput), async (c) => c.json(await assignConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), {
87
+ type: "control-plane",
88
+ id: viewer(c)
89
+ })));
90
+ app.post(`${base}/staff/threads/:id/assign`, describeRoute({
91
+ summary: "Assign a conversation as a signed-in member of the support staff.",
92
+ verification: ["bearer", "session"],
93
+ responses: { 200: SupportAssignResponse }
94
+ }), requireAuth(), requireSameOrigin(), requireStaff(options.staff, "assignPower"), validate("param", ThreadIdParam), validate("json", AssignThreadInput), async (c) => c.json(await assignConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), {
95
+ type: "user",
96
+ id: submitter(c)
97
+ })));
75
98
  app.post(`${base}/threads/:id/reply`, describeRoute({
76
99
  summary: "Answer the customer.",
77
100
  verification: "control-plane",
@@ -93,16 +116,22 @@ function registerSupportRoutes(options) {
93
116
  if (!submission) return;
94
117
  app.post(`${base}/feedback`, describeRoute({
95
118
  summary: "Write in to support from the app.",
119
+ operation: "feedback.write",
120
+ tool: { effect: "sends" },
96
121
  verification: ["bearer", "session"],
97
122
  responses: { 201: SupportSubmissionResponse }
98
123
  }), requireAuth(), requireSameOrigin(), validate("json", SubmitFeedbackInput), async (c) => c.json(await submitFeedbackRequest(await resolve(c), c.req.valid("json"), submitter(c)), 201));
99
124
  app.get(`${base}/feedback`, describeRoute({
100
125
  summary: "List the caller's own support requests.",
126
+ operation: "feedback.list",
127
+ tool: true,
101
128
  verification: ["bearer", "session"],
102
129
  responses: { 200: SupportMyThreadsResponse }
103
130
  }), requireAuth(), validate("query", MyThreadsQuery), async (c) => c.json(await listMyThreads(await resolve(c), c.req.valid("query"), submitter(c))));
104
131
  app.get(`${base}/feedback/:id`, describeRoute({
105
132
  summary: "Read one of the caller's own support requests.",
133
+ operation: "feedback.read",
134
+ tool: true,
106
135
  verification: ["bearer", "session"],
107
136
  responses: { 200: SupportMyThreadResponse }
108
137
  }), requireAuth(), validate("param", ThreadIdParam), async (c) => c.json(await readMyThread(await resolve(c), c.req.valid("param").id, submitter(c))));
@@ -18,7 +18,13 @@ export declare const ThreadIdParam: z.ZodObject<{
18
18
  id: z.ZodString;
19
19
  }, z.core.$strip>;
20
20
  export type ThreadIdParam = z.infer<typeof ThreadIdParam>;
21
- /** The inbox listing query. */
21
+ /**
22
+ * The inbox listing query.
23
+ *
24
+ * **Strict, and there is no `q`.** Listing lists; `GET {basePath}/threads/search` searches. An unknown
25
+ * key is refused as `validation/invalid_input` rather than ignored, so a client sending `q` is told so
26
+ * instead of getting the unfiltered inbox back as though it were the answer.
27
+ */
22
28
  export declare const ListThreadsQuery: z.ZodObject<{
23
29
  archived: z.ZodOptional<z.ZodEnum<{
24
30
  false: "false";
@@ -42,16 +48,74 @@ export declare const ListThreadsQuery: z.ZodObject<{
42
48
  email: "email";
43
49
  }>>;
44
50
  inbox: z.ZodOptional<z.ZodString>;
45
- q: z.ZodOptional<z.ZodString>;
51
+ assignee: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"none">, z.ZodString]>>;
46
52
  cursor: z.ZodOptional<z.ZodString>;
47
53
  limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
48
- }, z.core.$strip>;
54
+ }, z.core.$strict>;
49
55
  export type ListThreadsQuery = z.infer<typeof ListThreadsQuery>;
56
+ /** How a search runs. */
57
+ export declare const SupportSearchMode: z.ZodEnum<{
58
+ hybrid: "hybrid";
59
+ keyword: "keyword";
60
+ semantic: "semantic";
61
+ }>;
62
+ export type SupportSearchMode = z.infer<typeof SupportSearchMode>;
63
+ /** The search query. */
64
+ export declare const SearchThreadsQuery: z.ZodObject<{
65
+ archived: z.ZodOptional<z.ZodEnum<{
66
+ false: "false";
67
+ true: "true";
68
+ }>>;
69
+ category: z.ZodOptional<z.ZodString>;
70
+ declaredCategory: z.ZodOptional<z.ZodString>;
71
+ priority: z.ZodOptional<z.ZodEnum<{
72
+ low: "low";
73
+ normal: "normal";
74
+ urgent: "urgent";
75
+ }>>;
76
+ sentiment: z.ZodOptional<z.ZodEnum<{
77
+ angry: "angry";
78
+ frustrated: "frustrated";
79
+ neutral: "neutral";
80
+ positive: "positive";
81
+ }>>;
82
+ channel: z.ZodOptional<z.ZodEnum<{
83
+ app: "app";
84
+ email: "email";
85
+ }>>;
86
+ inbox: z.ZodOptional<z.ZodString>;
87
+ assignee: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"none">, z.ZodString]>>;
88
+ q: z.ZodString;
89
+ mode: z.ZodOptional<z.ZodEnum<{
90
+ hybrid: "hybrid";
91
+ keyword: "keyword";
92
+ semantic: "semantic";
93
+ }>>;
94
+ limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
95
+ }, z.core.$strict>;
96
+ export type SearchThreadsQuery = z.infer<typeof SearchThreadsQuery>;
50
97
  /** The archive/unarchive body. */
51
98
  export declare const ArchiveThreadInput: z.ZodObject<{
52
99
  archived: z.ZodBoolean;
53
100
  }, z.core.$strip>;
54
101
  export type ArchiveThreadInput = z.infer<typeof ArchiveThreadInput>;
102
+ /** Who a thread is assigned to. */
103
+ export declare const AssigneeInput: z.ZodObject<{
104
+ ref: z.ZodString;
105
+ name: z.ZodString;
106
+ }, z.core.$strip>;
107
+ export type AssigneeInput = z.infer<typeof AssigneeInput>;
108
+ /** The longest assignment note, in UTF-16 code units. */
109
+ export declare const MAX_ASSIGNMENT_NOTE_CHARS = 500;
110
+ /** The assign/unassign body, the same on the control-plane route and the staff session route. */
111
+ export declare const AssignThreadInput: z.ZodObject<{
112
+ assignee: z.ZodNullable<z.ZodObject<{
113
+ ref: z.ZodString;
114
+ name: z.ZodString;
115
+ }, z.core.$strip>>;
116
+ note: z.ZodOptional<z.ZodString>;
117
+ }, z.core.$strip>;
118
+ export type AssignThreadInput = z.infer<typeof AssignThreadInput>;
55
119
  /** The reply body. */
56
120
  export declare const ReplyInput: z.ZodObject<{
57
121
  body: z.ZodString;
@@ -1 +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"}
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../src/http/schemas.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAcxB;;;;;;;;;;;GAWG;AAEH,+BAA+B;AAC/B,eAAO,MAAM,aAAa;;iBAOsC,CAAC;AACjE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAyC1D;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;kBAiBmE,CAAC;AACjG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,yBAAyB;AACzB,eAAO,MAAM,iBAAiB;;;;EAI3B,CAAC;AACJ,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE,wBAAwB;AACxB,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAuBqF,CAAC;AACrH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,kCAAkC;AAClC,eAAO,MAAM,kBAAkB;;iBAQuB,CAAC;AACvD,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,mCAAmC;AACnC,eAAO,MAAM,aAAa;;;iBAOoE,CAAC;AAC/F,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D,yDAAyD;AACzD,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAE7C,iGAAiG;AACjG,eAAO,MAAM,iBAAiB;;;;;;iBAeiC,CAAC;AAChE,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE,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"}
@@ -2,10 +2,14 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { MAX_SUBMISSION_BODY_CHARS } from "../config/config.js";
4
4
  import { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums.js";
5
+ import { UNASSIGNED } from "../data/assignment.js";
5
6
  import { SupportSubmissionContext } from "../data/message.js";
6
7
  import "../store/paging.js";
7
8
  import { z } from "zod";
9
+ import { PresenceParticipantName, PresenceParticipantRef } from "@pithy-sh/core/src/presence/frames";
8
10
  //#region src/http/schemas.ts
11
+ /** An assignee reference, as the listing filters on it and an assignment names it. */
12
+ const AssigneeRef = PresenceParticipantRef.refine((ref) => ref !== UNASSIGNED, { message: `"${UNASSIGNED}" is reserved: it is the filter for threads nobody has` }).describe("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.");
9
13
  /**
10
14
  * The request contracts every support route declares (CLAUDE.md §HTTP).
11
15
  *
@@ -22,8 +26,8 @@ import { z } from "zod";
22
26
  const ThreadIdParam = z.object({ id: z.string().uuid().describe("The thread's UUID. A param schema constrains the string; the handler still does the lookup.") }).describe("The path parameters of every single-thread route.");
23
27
  /** A category key as a filter — bounded and shaped, never resolved against the configured set. */
24
28
  const CategoryFilter = z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/).describe("One category key from this project's effective taxonomy. A key an adopter removed simply matches nothing.");
25
- /** The inbox listing query. */
26
- const ListThreadsQuery = z.object({
29
+ /** The filters the inbox listing and the search route share, applied in D1 either way. */
30
+ const threadFilterFields = {
27
31
  archived: z.enum(["true", "false"]).optional().describe("`true` for the done pile, `false` or absent for the open inbox — which is the default on purpose."),
28
32
  category: CategoryFilter.optional().describe("Filter to one category — what the classifier made of it."),
29
33
  declaredCategory: CategoryFilter.optional().describe("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."),
@@ -31,12 +35,47 @@ const ListThreadsQuery = z.object({
31
35
  sentiment: SupportSentiment.optional().describe("Filter to one sentiment."),
32
36
  channel: SupportChannel.optional().describe("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."),
33
37
  inbox: z.string().min(3).max(256).optional().describe("Filter to one configured inbox address, for a Worker serving several."),
34
- q: z.string().min(1).max(200).optional().describe("Free text over subjects and bodies. Keyword search — the words people actually remember."),
38
+ assignee: z.union([z.literal(UNASSIGNED), AssigneeRef]).optional().describe("Filter to the threads one assignee has, by their `assigneeRef`, or `none` for the threads nobody has. Absent shows every thread, assigned or not.")
39
+ };
40
+ /**
41
+ * The inbox listing query.
42
+ *
43
+ * **Strict, and there is no `q`.** Listing lists; `GET {basePath}/threads/search` searches. An unknown
44
+ * key is refused as `validation/invalid_input` rather than ignored, so a client sending `q` is told so
45
+ * instead of getting the unfiltered inbox back as though it were the answer.
46
+ */
47
+ const ListThreadsQuery = z.object({
48
+ ...threadFilterFields,
35
49
  cursor: z.string().max(512).optional().describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page."),
36
50
  limit: z.coerce.number().int().min(1).max(100).optional().describe("How many threads to return. Bounded, because a verified client can still have a bug.")
37
- }).describe("The inbox query: what to filter by, what to search for, and where to resume.");
51
+ }).strict().describe("The inbox query: what to filter by, and where to resume. Search is its own route.");
52
+ /** How a search runs. */
53
+ const SupportSearchMode = z.enum([
54
+ "keyword",
55
+ "semantic",
56
+ "hybrid"
57
+ ]).describe("`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.");
58
+ /** The search query. */
59
+ const SearchThreadsQuery = z.object({
60
+ q: z.string().trim().min(1).max(200).describe("What to search for, in the customer's words or yours. An order number, an error, or a description."),
61
+ mode: SupportSearchMode.optional().describe("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."),
62
+ limit: z.coerce.number().int().min(1).max(50).optional().describe(`How many threads to return, best first. Default 20, at most 50. No cursor: a ranked list has a top, not pages.`),
63
+ ...threadFilterFields
64
+ }).strict().describe("A search over the inbox: the words, the mode, how many, and the same filters the inbox listing takes.");
38
65
  /** The archive/unarchive body. */
39
66
  const ArchiveThreadInput = z.object({ archived: z.boolean().describe("`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.") }).describe("Mark a conversation done, or reopen it.");
67
+ /** Who a thread is assigned to. */
68
+ const AssigneeInput = z.object({
69
+ ref: AssigneeRef,
70
+ name: PresenceParticipantName.describe("How the assignee is shown in the inbox and in the history. A label, never an identity — `ref` is that.")
71
+ }).describe("Somebody a thread may be assigned to: an opaque reference and the name to show.");
72
+ /** The longest assignment note, in UTF-16 code units. */
73
+ const MAX_ASSIGNMENT_NOTE_CHARS = 500;
74
+ /** The assign/unassign body, the same on the control-plane route and the staff session route. */
75
+ const AssignThreadInput = z.object({
76
+ assignee: AssigneeInput.nullable().describe("Who should have this conversation, or `null` to unassign it. Assigning the current assignee again changes nothing and records nothing."),
77
+ note: z.string().trim().min(1).max(500).optional().describe("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.")
78
+ }).describe("Assign a conversation to somebody, or to nobody.");
40
79
  /** The reply body. */
41
80
  const ReplyInput = z.object({
42
81
  body: z.string().min(1).max(5e4).describe("The reply text, as a human wrote and edited it. Rendered HTML-escaped into the adopter's email shell — it is prose, never markup."),
@@ -88,4 +127,4 @@ const MyThreadsQuery = z.object({
88
127
  /** The canned-reply catalog query. */
89
128
  const RepliesQuery = z.object({ category: CategoryFilter.optional().describe("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.") }).describe("How to order the canned reply catalog.");
90
129
  //#endregion
91
- export { ArchiveThreadInput, FlagsInput, ListThreadsQuery, MyThreadsQuery, RepliesQuery, ReplyInput, SubmitFeedbackInput, SubmittedAttachmentInput, ThreadIdParam };
130
+ export { ArchiveThreadInput, AssignThreadInput, AssigneeInput, FlagsInput, ListThreadsQuery, MAX_ASSIGNMENT_NOTE_CHARS, MyThreadsQuery, RepliesQuery, ReplyInput, SearchThreadsQuery, SubmitFeedbackInput, SubmittedAttachmentInput, SupportSearchMode, ThreadIdParam };
@@ -1,11 +1,11 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
3
+ import type { AdminRouteDeclaration } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
4
4
  import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
5
5
  /**
6
6
  * Support's control-plane scopes, and the admin surface a manifest advertises.
7
7
  *
8
- * **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
8
+ * **Separate from `guards.ts` because a scope name is a client's business**. A management
9
9
  * client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
10
10
  * writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
11
11
  * program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
@@ -13,7 +13,7 @@ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/sc
13
13
  * program has none of. **This module imports types and nothing else, and a gate holds it there**:
14
14
  * `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
15
15
  *
16
- * ## Five scopes, because these are five different blast radii
16
+ * ## Six scopes, because these are six different blast radii
17
17
  *
18
18
  * The temptation is one `support:admin` flag. It is wrong on the merits, the same way payments'
19
19
  * single flag was: **reading** an inbox exposes every customer's private correspondence, while
@@ -30,7 +30,7 @@ export declare const SUPPORT_THREADS_READ_SCOPE: ControlPlaneScope;
30
30
  /** Mark a thread done or reopen it — the one shared piece of state in the model. */
31
31
  export declare const SUPPORT_THREADS_ARCHIVE_SCOPE: ControlPlaneScope;
32
32
  /**
33
- * Send mail to a customer under the adopter's domain. The most dangerous of the five and the reason
33
+ * Send mail to a customer under the adopter's domain. The most dangerous of the six and the reason
34
34
  * a single admin flag was never an option.
35
35
  */
36
36
  export declare const SUPPORT_THREADS_REPLY_SCOPE: ControlPlaneScope;
@@ -38,6 +38,11 @@ export declare const SUPPORT_THREADS_REPLY_SCOPE: ControlPlaneScope;
38
38
  export declare const SUPPORT_THREADS_RECLASSIFY_SCOPE: ControlPlaneScope;
39
39
  /** Set a viewer's own read/snooze flags. Private, uncoordinated, and the least dangerous thing here. */
40
40
  export declare const SUPPORT_THREADS_FLAG_SCOPE: ControlPlaneScope;
41
+ /**
42
+ * Say who has a thread, or that nobody does. Shared state like archive, and its own scope like
43
+ * archive, because deciding who answers a customer is not the same permission as marking them done.
44
+ */
45
+ export declare const SUPPORT_THREADS_ASSIGN_SCOPE: ControlPlaneScope;
41
46
  /**
42
47
  * Every control-plane scope support defines — what `pithy dashboard connect` offers for this
43
48
  * capability, and the list a manifest or a doc quotes rather than re-typing.
@@ -54,5 +59,5 @@ export declare const SUPPORT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[];
54
59
  * The summaries say what the operation is *for*. A client renders these next to a button somebody is
55
60
  * about to press on a real customer's conversation.
56
61
  */
57
- export declare function supportAdminRoutes(basePath: string): AdminRoute[];
62
+ export declare function supportAdminRoutes(basePath: string): AdminRouteDeclaration[];
58
63
  //# sourceMappingURL=scopes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/http/scopes.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sDAAsD,CAAC;AACvF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6CAA6C,CAAC;AAErF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,iGAAiG;AACjG,eAAO,MAAM,0BAA0B,EAAE,iBAA0C,CAAC;AAEpF,oFAAoF;AACpF,eAAO,MAAM,6BAA6B,EAAE,iBAA6C,CAAC;AAE1F;;;GAGG;AACH,eAAO,MAAM,2BAA2B,EAAE,iBAA2C,CAAC;AAEtF,8EAA8E;AAC9E,eAAO,MAAM,gCAAgC,EAAE,iBAAgD,CAAC;AAEhG,wGAAwG;AACxG,eAAO,MAAM,0BAA0B,EAAE,iBAA0C,CAAC;AAEpF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,EAAE,SAAS,iBAAiB,EAMpE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,EAAE,CA6CjE"}
1
+ {"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/http/scopes.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sDAAsD,CAAC;AAClG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6CAA6C,CAAC;AAErF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,iGAAiG;AACjG,eAAO,MAAM,0BAA0B,EAAE,iBAA0C,CAAC;AAEpF,oFAAoF;AACpF,eAAO,MAAM,6BAA6B,EAAE,iBAA6C,CAAC;AAE1F;;;GAGG;AACH,eAAO,MAAM,2BAA2B,EAAE,iBAA2C,CAAC;AAEtF,8EAA8E;AAC9E,eAAO,MAAM,gCAAgC,EAAE,iBAAgD,CAAC;AAEhG,wGAAwG;AACxG,eAAO,MAAM,0BAA0B,EAAE,iBAA0C,CAAC;AAEpF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,EAAE,iBAA4C,CAAC;AAExF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,EAAE,SAAS,iBAAiB,EAOpE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,qBAAqB,EAAE,CA2E5E"}
@@ -4,7 +4,7 @@
4
4
  /**
5
5
  * Support's control-plane scopes, and the admin surface a manifest advertises.
6
6
  *
7
- * **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
7
+ * **Separate from `guards.ts` because a scope name is a client's business**. A management
8
8
  * client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
9
9
  * writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
10
10
  * program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
@@ -12,7 +12,7 @@
12
12
  * program has none of. **This module imports types and nothing else, and a gate holds it there**:
13
13
  * `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
14
14
  *
15
- * ## Five scopes, because these are five different blast radii
15
+ * ## Six scopes, because these are six different blast radii
16
16
  *
17
17
  * The temptation is one `support:admin` flag. It is wrong on the merits, the same way payments'
18
18
  * single flag was: **reading** an inbox exposes every customer's private correspondence, while
@@ -29,7 +29,7 @@ const SUPPORT_THREADS_READ_SCOPE = "support:threads:read";
29
29
  /** Mark a thread done or reopen it — the one shared piece of state in the model. */
30
30
  const SUPPORT_THREADS_ARCHIVE_SCOPE = "support:threads:archive";
31
31
  /**
32
- * Send mail to a customer under the adopter's domain. The most dangerous of the five and the reason
32
+ * Send mail to a customer under the adopter's domain. The most dangerous of the six and the reason
33
33
  * a single admin flag was never an option.
34
34
  */
35
35
  const SUPPORT_THREADS_REPLY_SCOPE = "support:threads:reply";
@@ -38,6 +38,11 @@ const SUPPORT_THREADS_RECLASSIFY_SCOPE = "support:threads:reclassify";
38
38
  /** Set a viewer's own read/snooze flags. Private, uncoordinated, and the least dangerous thing here. */
39
39
  const SUPPORT_THREADS_FLAG_SCOPE = "support:threads:flag";
40
40
  /**
41
+ * Say who has a thread, or that nobody does. Shared state like archive, and its own scope like
42
+ * archive, because deciding who answers a customer is not the same permission as marking them done.
43
+ */
44
+ const SUPPORT_THREADS_ASSIGN_SCOPE = "support:threads:assign";
45
+ /**
41
46
  * Every control-plane scope support defines — what `pithy dashboard connect` offers for this
42
47
  * capability, and the list a manifest or a doc quotes rather than re-typing.
43
48
  */
@@ -46,7 +51,8 @@ const SUPPORT_CONTROL_PLANE_SCOPES = [
46
51
  SUPPORT_THREADS_ARCHIVE_SCOPE,
47
52
  SUPPORT_THREADS_REPLY_SCOPE,
48
53
  SUPPORT_THREADS_RECLASSIFY_SCOPE,
49
- SUPPORT_THREADS_FLAG_SCOPE
54
+ SUPPORT_THREADS_FLAG_SCOPE,
55
+ SUPPORT_THREADS_ASSIGN_SCOPE
50
56
  ];
51
57
  /**
52
58
  * Support's management surface, as `GET /control-plane/manifest` reports it.
@@ -63,47 +69,77 @@ function supportAdminRoutes(basePath) {
63
69
  return [
64
70
  {
65
71
  method: "GET",
72
+ operation: "threads.list",
66
73
  path: `${basePath}/threads`,
67
74
  scope: SUPPORT_THREADS_READ_SCOPE,
68
- summary: "List the inbox, newest first, filtered by category, priority, and archived, searched by text."
75
+ kind: "reads",
76
+ summary: "List the inbox, newest first, filtered by category, priority, assignee, and archived."
69
77
  },
70
78
  {
71
79
  method: "GET",
80
+ operation: "threads.search",
81
+ path: `${basePath}/threads/search`,
82
+ scope: SUPPORT_THREADS_READ_SCOPE,
83
+ kind: "reads",
84
+ summary: "Search the inbox by words and by meaning, ranked as one, with the same filters as the list."
85
+ },
86
+ {
87
+ method: "GET",
88
+ operation: "threads.read",
72
89
  path: `${basePath}/threads/:id`,
73
90
  scope: SUPPORT_THREADS_READ_SCOPE,
91
+ kind: "reads",
74
92
  summary: "Read one conversation, with the sender's linked account and what they bought."
75
93
  },
76
94
  {
77
95
  method: "POST",
96
+ operation: "threads.archive",
78
97
  path: `${basePath}/threads/:id/archive`,
79
98
  scope: SUPPORT_THREADS_ARCHIVE_SCOPE,
99
+ kind: "writes",
80
100
  summary: "Mark a conversation done, or reopen one."
81
101
  },
82
102
  {
83
103
  method: "POST",
104
+ operation: "threads.assign",
105
+ path: `${basePath}/threads/:id/assign`,
106
+ scope: SUPPORT_THREADS_ASSIGN_SCOPE,
107
+ kind: "writes",
108
+ summary: "Give a conversation to somebody, hand it on, or give it up."
109
+ },
110
+ {
111
+ method: "POST",
112
+ operation: "threads.reply",
84
113
  path: `${basePath}/threads/:id/reply`,
85
114
  scope: SUPPORT_THREADS_REPLY_SCOPE,
115
+ kind: "sends",
86
116
  summary: "Answer the customer, threaded so their mail client keeps one conversation."
87
117
  },
88
118
  {
89
119
  method: "POST",
120
+ operation: "threads.reclassify",
90
121
  path: `${basePath}/threads/:id/reclassify`,
91
122
  scope: SUPPORT_THREADS_RECLASSIFY_SCOPE,
123
+ kind: "writes",
92
124
  summary: "Re-run the classifier over this conversation's latest inbound message."
93
125
  },
94
126
  {
95
127
  method: "POST",
128
+ operation: "threads.flags",
96
129
  path: `${basePath}/threads/:id/flags`,
97
130
  scope: SUPPORT_THREADS_FLAG_SCOPE,
131
+ kind: "writes",
98
132
  summary: "Set your own read and snooze state for a conversation. Private to you."
99
133
  },
100
134
  {
101
135
  method: "GET",
136
+ operation: "replies.list",
102
137
  path: `${basePath}/replies`,
103
138
  scope: SUPPORT_THREADS_READ_SCOPE,
139
+ kind: "reads",
104
140
  summary: "The canned reply catalog — starting points to pick from, ordered for a thread's category."
105
141
  }
106
142
  ];
107
143
  }
108
144
  //#endregion
109
- export { SUPPORT_CONTROL_PLANE_SCOPES, SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE, supportAdminRoutes };
145
+ export { SUPPORT_CONTROL_PLANE_SCOPES, SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_ASSIGN_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE, supportAdminRoutes };
@@ -1,14 +1,22 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
+ import type { SupportAssignment } from "../data/assignment";
3
4
  import type { SupportMessage } from "../data/message";
4
5
  import type { SupportThread } from "../data/thread";
5
6
  import type { SenderContext } from "../link/sender";
7
+ import type { SearchOutcome, SearchResult } from "../store/hybrid";
6
8
  import type { ListedThread } from "../store/threads";
7
- import type { SenderContextView, SupportAttachmentView, SupportListedThreadView, SupportMessageView, SupportMyMessageView, SupportMyThreadView, SupportThreadView } from "./responses";
9
+ import type { SenderContextView, SupportAssignmentView, SupportAttachmentView, SupportListedThreadView, SupportMessageView, SupportMyMessageView, SupportMyThreadView, SupportSearchResponse, SupportSearchResultView, SupportThreadView } from "./responses";
8
10
  /** Project one thread row. */
9
11
  export declare function threadView(thread: SupportThread): SupportThreadView;
12
+ /** Project one change of a thread's assignee. The thread id is the route's, so it is not repeated. */
13
+ export declare function assignmentView(assignment: SupportAssignment): SupportAssignmentView;
10
14
  /** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
11
15
  export declare function listedThreadView(thread: ListedThread): SupportListedThreadView;
16
+ /** Project one search result: the thread as this viewer sees it, and its best message's raw scores. */
17
+ export declare function searchResultView(result: SearchResult): SupportSearchResultView;
18
+ /** Project a whole search. */
19
+ export declare function searchView(outcome: SearchOutcome): SupportSearchResponse;
12
20
  /**
13
21
  * Project one message.
14
22
  *
@@ -1 +1 @@
1
- {"version":3,"file":"views.d.ts","sourceRoot":"","sources":["../../src/http/views.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EACV,iBAAiB,EACjB,qBAAqB,EACrB,uBAAuB,EACvB,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAyBrB,8BAA8B;AAC9B,wBAAgB,UAAU,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CA2BnE;AAED,0FAA0F;AAC1F,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,GAAG,uBAAuB,CAE9E;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,kBAAkB,CAgBvE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,aAAa,GAAG,mBAAmB,CASvE;AAED,gGAAgG;AAChG,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,qBAAqB,EAAE,GAAG,oBAAoB,CAYjH;AAED,0DAA0D;AAC1D,wBAAgB,UAAU,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CA4BnE"}
1
+ {"version":3,"file":"views.d.ts","sourceRoot":"","sources":["../../src/http/views.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EACV,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,uBAAuB,EACvB,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,qBAAqB,EACrB,uBAAuB,EACvB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAyBrB,8BAA8B;AAC9B,wBAAgB,UAAU,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CA+BnE;AAED,sGAAsG;AACtG,wBAAgB,cAAc,CAAC,UAAU,EAAE,iBAAiB,GAAG,qBAAqB,CAYnF;AAED,0FAA0F;AAC1F,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,GAAG,uBAAuB,CAE9E;AAED,uGAAuG;AACvG,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,GAAG,uBAAuB,CAO9E;AAED,8BAA8B;AAC9B,wBAAgB,UAAU,CAAC,OAAO,EAAE,aAAa,GAAG,qBAAqB,CAOxE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,kBAAkB,CAgBvE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,aAAa,GAAG,mBAAmB,CASvE;AAED,gGAAgG;AAChG,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,qBAAqB,EAAE,GAAG,oBAAoB,CAYjH;AAED,0DAA0D;AAC1D,wBAAgB,UAAU,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CA4BnE"}
@@ -44,6 +44,10 @@ function threadView(thread) {
44
44
  archived: thread.archived,
45
45
  archivedAt: iso(thread.archivedAt),
46
46
  archivedBy: thread.archivedBy ?? null,
47
+ assigneeRef: thread.assigneeRef ?? null,
48
+ assigneeName: thread.assigneeName ?? null,
49
+ assignedAt: iso(thread.assignedAt),
50
+ assignedBy: thread.assignedBy ?? null,
47
51
  messageCount: thread.messageCount,
48
52
  firstMessageAt: thread.firstMessageAt.toISOString(),
49
53
  lastMessageAt: thread.lastMessageAt.toISOString(),
@@ -51,6 +55,20 @@ function threadView(thread) {
51
55
  updatedAt: thread.updatedAt.toISOString()
52
56
  };
53
57
  }
58
+ /** Project one change of a thread's assignee. The thread id is the route's, so it is not repeated. */
59
+ function assignmentView(assignment) {
60
+ return {
61
+ id: assignment.id,
62
+ assigneeRef: assignment.assigneeRef ?? null,
63
+ assigneeName: assignment.assigneeName ?? null,
64
+ previousRef: assignment.previousRef ?? null,
65
+ previousName: assignment.previousName ?? null,
66
+ actorType: assignment.actorType,
67
+ assignedBy: assignment.assignedBy,
68
+ note: assignment.note ?? null,
69
+ createdAt: assignment.createdAt.toISOString()
70
+ };
71
+ }
54
72
  /** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
55
73
  function listedThreadView(thread) {
56
74
  return {
@@ -59,6 +77,27 @@ function listedThreadView(thread) {
59
77
  snoozedUntil: iso(thread.snoozedUntil)
60
78
  };
61
79
  }
80
+ /** Project one search result: the thread as this viewer sees it, and its best message's raw scores. */
81
+ function searchResultView(result) {
82
+ return {
83
+ thread: listedThreadView(result.thread),
84
+ messageId: result.messageId,
85
+ ...result.bm25 !== void 0 ? { bm25: result.bm25 } : {},
86
+ ...result.similarity !== void 0 ? { similarity: result.similarity } : {}
87
+ };
88
+ }
89
+ /** Project a whole search. */
90
+ function searchView(outcome) {
91
+ return {
92
+ mode: outcome.mode,
93
+ backends: {
94
+ ran: [...outcome.ran],
95
+ skipped: outcome.skipped.map((skip) => ({ ...skip }))
96
+ },
97
+ count: outcome.results.length,
98
+ results: outcome.results.map(searchResultView)
99
+ };
100
+ }
62
101
  /**
63
102
  * Project one message.
64
103
  *
@@ -143,4 +182,4 @@ function senderView(sender) {
143
182
  };
144
183
  }
145
184
  //#endregion
146
- export { listedThreadView, messageView, myMessageView, myThreadView, senderView, threadView };
185
+ export { assignmentView, listedThreadView, messageView, myMessageView, myThreadView, searchResultView, searchView, senderView, threadView };
@@ -3,7 +3,7 @@
3
3
  import type { R2Bucket } from "@cloudflare/workers-types";
4
4
  import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
5
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
6
- import type { SupportConfig } from "../config/config";
6
+ import { type SupportConfig } from "../config/config";
7
7
  import { type SupportDatabase } from "../data/tables";
8
8
  /**
9
9
  * Ingest — everything the `email()` handler does, with every dependency injected.