@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
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * The `assignee` filter value meaning *nobody has it*. Reserved: an assignment to a ref spelled this way is
9
+ * refused at the boundary (`http/schemas.ts`), so the filter can never mean two things.
10
+ */
11
+ export const UNASSIGNED = "none";
12
+
13
+ /**
14
+ * Who assigned a thread: a management client through the control plane, or a signed-in user of the app
15
+ * through support's staff power. Recorded per change, because the two are different evidence about who
16
+ * acted, exactly as the audit trail's `actorType` is.
17
+ */
18
+ export const SupportAssignmentActorType = z
19
+ .enum(["control-plane", "user"])
20
+ .describe(
21
+ "How the assigner was verified: `control-plane` for a management client's scoped credential, `user` for a signed-in user of the app holding support's staff power.",
22
+ );
23
+ export type SupportAssignmentActorType = z.output<typeof SupportAssignmentActorType>;
24
+
25
+ /**
26
+ * One change of a thread's assignee in `pithy_support_assignments` — append-only, one row per change.
27
+ *
28
+ * The thread carries the **current** assignee because that is what the inbox filters on; this carries
29
+ * **every** change, the same split the classification table has with the thread. Nothing here is updated
30
+ * and nothing is deleted while the thread stands. A change that changed nothing — the current assignee
31
+ * assigned again — writes no row, so every row here is a real handover.
32
+ */
33
+ export const SupportAssignment = z
34
+ .object({
35
+ id: z.string().describe("UUID primary key."),
36
+ threadId: z.string().describe("The `pithy_support_threads.id` whose assignee changed."),
37
+ assigneeRef: z
38
+ .string()
39
+ .nullish()
40
+ .describe("Who has the thread after this change, as an opaque reference, or null when it was unassigned."),
41
+ assigneeName: z.string().nullish().describe("How that assignee was shown at the time, or null when unassigned."),
42
+ previousRef: z.string().nullish().describe("Who had the thread before this change, or null when nobody did."),
43
+ previousName: z.string().nullish().describe("How the previous assignee was shown, or null when nobody had it."),
44
+ actorType: SupportAssignmentActorType.describe("How the person or client making the change was verified."),
45
+ assignedBy: z.string().describe("Who made the change — the control-plane subject, or the signed-in user's id."),
46
+ note: z
47
+ .string()
48
+ .nullish()
49
+ .describe(
50
+ "Why, in the assigner's own words, when they gave a reason. Staff-written, never customer text, and shown to anybody who can read the thread.",
51
+ ),
52
+ createdAt: SQLiteDate.describe("When the change was made."),
53
+ })
54
+ .describe("One append-only change of a support thread's assignee in `pithy_support_assignments`.");
55
+ export type SupportAssignment = z.output<typeof SupportAssignment>;
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * Which model embedded a message into the semantic index, in `pithy_support_embeddings`.
9
+ *
10
+ * **Its own table, not a column on the message**, for two reasons. A released migration is never edited, and
11
+ * a stamp belongs to a derived index rather than to the mail: drop the index and every stamp is a lie, so they
12
+ * go together, and nothing about a message changes. It is what `pithy vector reprocess --index
13
+ * support-messages` reads to re-embed exactly the rows whose model differs from the configured one — the same
14
+ * job `pithy_vector_documents.model` does for an index `vector()` owns.
15
+ *
16
+ * A message with no row was never embedded, which is the set a backfill and a repair both pick up.
17
+ */
18
+ export const SupportEmbedding = z
19
+ .object({
20
+ messageId: z
21
+ .string()
22
+ .describe("The `pithy_support_messages.id` this stamp is for — also the vector's id in the index."),
23
+ model: z.string().describe("The Workers AI embedding model that produced the vector now in the index."),
24
+ embeddedAt: SQLiteDate.describe("When the vector was written."),
25
+ })
26
+ .describe("The model stamp for one message's vector in the semantic index.");
27
+ export type SupportEmbedding = z.output<typeof SupportEmbedding>;
@@ -5,8 +5,10 @@ import type { D1Database } from "@cloudflare/workers-types";
5
5
  import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
6
  import type { Kysely } from "kysely";
7
7
  import type { z } from "zod";
8
+ import { SupportAssignment } from "./assignment";
8
9
  import { SupportAttachment } from "./attachment";
9
10
  import { SupportClassification } from "./classification";
11
+ import { SupportEmbedding } from "./embedding";
10
12
  import { SupportThreadFlag } from "./flag";
11
13
  import { SupportMessage } from "./message";
12
14
  import { SupportThread } from "./thread";
@@ -26,6 +28,10 @@ export const SUPPORT_ATTACHMENTS_TABLE = "pithySupportAttachments";
26
28
  export const SUPPORT_CLASSIFICATIONS_TABLE = "pithySupportClassifications";
27
29
  /** Per-viewer read/snooze state. `pithy_support_thread_flags`. */
28
30
  export const SUPPORT_FLAGS_TABLE = "pithySupportThreadFlags";
31
+ /** Append-only assignee history. `pithy_support_assignments`. */
32
+ export const SUPPORT_ASSIGNMENTS_TABLE = "pithySupportAssignments";
33
+ /** The semantic index's per-message model stamps. `pithy_support_embeddings`. */
34
+ export const SUPPORT_EMBEDDINGS_TABLE = "pithySupportEmbeddings";
29
35
 
30
36
  /**
31
37
  * The FTS5 full-text index. Not a Kysely table and deliberately not in {@link supportTables}: it is a
@@ -44,6 +50,8 @@ export const supportTables = {
44
50
  [SUPPORT_ATTACHMENTS_TABLE]: SupportAttachment,
45
51
  [SUPPORT_CLASSIFICATIONS_TABLE]: SupportClassification,
46
52
  [SUPPORT_FLAGS_TABLE]: SupportThreadFlag,
53
+ [SUPPORT_ASSIGNMENTS_TABLE]: SupportAssignment,
54
+ [SUPPORT_EMBEDDINGS_TABLE]: SupportEmbedding,
47
55
  } satisfies Record<string, z.ZodObject>;
48
56
 
49
57
  /** The support tables, as a type. */
@@ -10,10 +10,11 @@ import { SupportAccountLinkSource, SupportChannel, SupportPriority, SupportSenti
10
10
  *
11
11
  * **Everything on it is derived from immutable inbound mail.** The classification is recomputed, never
12
12
  * repaired; the user link is a lookup, not a decision; the counters are facts about the messages
13
- * below. There is no assignee and no status, because a status field is a state machine and a state
14
- * machine attracts SLAs, escalation, and reporting — which is a ticketing product and explicitly not
15
- * this. The two exceptions are deliberate and named: `archived` (one shared boolean meaning done) and
16
- * the per-viewer flags, which live in their own table because nobody coordinates around them.
13
+ * below. There is no status, because a status field is a state machine and a state machine attracts
14
+ * SLAs, escalation, and reporting — which is a ticketing product and explicitly not this. The
15
+ * exceptions are deliberate and named: `archived` (one shared boolean meaning done), the assignee (one
16
+ * fact, who has this, with its history in `pithy_support_assignments` — #816), and the per-viewer
17
+ * flags, which live in their own table because nobody coordinates around them.
17
18
  *
18
19
  * The classification axes are **denormalized onto the thread** rather than joined from
19
20
  * `pithy_support_classifications`. The whole dashboard interaction is "newest first, filtered by
@@ -105,6 +106,27 @@ export const SupportThread = z
105
106
  .describe(
106
107
  "The control-plane subject that archived it. Recorded for the dashboard's convenience only — the answer to 'who marked this done' comes from the audit trail, which is why this is not an ownership column.",
107
108
  ),
109
+ assigneeRef: z
110
+ .string()
111
+ .nullish()
112
+ .describe(
113
+ "**Who has this thread** — an opaque reference to the person or agent it is assigned to, or null when nobody has it. Opaque on purpose: a dashboard member, a user of the app and an agent are all assignable, and support never needs to know which. Not a status: there are no transitions to get wrong, and unassigning is one write. Every change is kept in `pithy_support_assignments`. Added by `0002_assignment`, so a row written before it reads null.",
114
+ ),
115
+ assigneeName: z
116
+ .string()
117
+ .nullish()
118
+ .describe(
119
+ "How the assignee is shown, as the assigner named them. Null exactly when `assigneeRef` is. Display text, never an identity: two refs may share a name, and the ref is what a filter matches.",
120
+ ),
121
+ assignedAt: SQLiteDate.nullish().describe(
122
+ "When the current assignment was made; null while the thread has no assignee.",
123
+ ),
124
+ assignedBy: z
125
+ .string()
126
+ .nullish()
127
+ .describe(
128
+ "Who made the current assignment — the control-plane subject, or the signed-in user's id. Null while the thread has no assignee. The full answer, with every earlier change, is the history table and the audit trail.",
129
+ ),
108
130
  messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
109
131
  firstMessageAt: SQLiteDate.describe("When the thread opened — the receive time of its first message."),
110
132
  lastMessageAt: SQLiteDate.describe(
@@ -2,38 +2,47 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
- import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { ForbiddenError, UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
6
6
  import type { MiddlewareHandler } from "hono";
7
+ import type { SupportStaffConfig } from "../config/config";
8
+ import { staffMay } from "../staff/staff";
7
9
 
8
10
  /**
9
- * Support's identity gates: the copied `requireAuth()` its in-app submission routes wear, and the
10
- * control-plane gate its management routes take from core's seam.
11
+ * Support's identity gates: the copied `requireAuth()` its in-app routes wear, the control-plane gate its
12
+ * management routes take from core's seam, and `requireStaff()`, which lets the adopter's own staff onto a
13
+ * slice of the management side through their session (#816).
11
14
  *
12
15
  * **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
13
16
  * connection may do, and it reads them in a browser — so they cannot live in a module that imports
14
17
  * Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
15
18
  * the routes import both.
16
19
  *
17
- * ## Two surfaces, and the split is the whole shape of this file
20
+ * ## Three doors, and the split is the whole shape of this file
18
21
  *
19
22
  * **The management surface is `control-plane` and default-denied.** It reads and acts on every
20
- * customer's private correspondence, so it answers to a credential the adopter issued and to nothing
21
- * else. The seam's gate is imported from core rather than copied — core is a hard dependency of every
22
- * capability, so importing its gate cannot leave a deployment without one, and with the seam
23
- * uncomposed `requireControlPlane` raises `controlplane/not_connected` rather than passing.
23
+ * customer's private correspondence, so it answers to a credential the adopter issued. The seam's gate is
24
+ * imported from core rather than copied — core is a hard dependency of every capability, so importing its
25
+ * gate cannot leave a deployment without one, and with the seam uncomposed `requireControlPlane` raises
26
+ * `controlplane/not_connected` rather than passing.
24
27
  *
25
28
  * **The submission surface is `bearer`/`session` and is the adopter's own signed-in user.** It exists
26
29
  * because a product with a logged-in user, a session, and a support console should not have to ask
27
30
  * that user to open their mail client — and because the hardest problem on the mail path, proving a
28
31
  * `From:` header, does not exist on a request whose session was already proved.
29
32
  *
30
- * **`requireAuth()` never appears on a management route, and `requireControlPlane` never appears on a
31
- * submission route.** They are not two strengths of the same gate. A management client holds no
32
- * session and owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking
33
- * them would deny every legitimate call on both surfaces, permanently, with no credential able to fix
34
- * it. What each route may *see* follows from which gate it wears: an operator reads a thread with its
35
- * classification and its sender's purchases, and a submitter reads their own words back and nothing
36
- * else.
33
+ * **The staff door is a session, and it reaches management work.** `requireAuth()` then `requireStaff()`
34
+ * admit a signed-in user whose role, in the organization the request acts for, holds one of the powers
35
+ * `support({ staff })` names — and nobody at all when `staff` is absent. It opens two things and no more:
36
+ * assigning a thread, at its own `/staff/` path, and joining a thread's presence room. Everything else on
37
+ * the management side still answers to the control plane alone.
38
+ *
39
+ * **`requireAuth()` never appears on a control-plane route, and `requireControlPlane` never appears on a
40
+ * session route.** They are not two strengths of the same gate. A management client holds no session and
41
+ * owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking them would deny
42
+ * every legitimate call on both, permanently, with no credential able to fix it. That is why staff
43
+ * assignment is a second route beside the control-plane one rather than a second gate on it. What each
44
+ * route may *see* follows from which gate it wears: an operator reads a thread with its classification and
45
+ * its sender's purchases, and a submitter reads their own words back and nothing else.
37
46
  */
38
47
 
39
48
  /**
@@ -57,3 +66,31 @@ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
57
66
  await next();
58
67
  };
59
68
  }
69
+
70
+ /**
71
+ * Require one of support's staff powers in the organization this request acts for (#816) — the gate on
72
+ * the staff session routes, after `requireAuth()`.
73
+ *
74
+ * With no `staff` configured, or no organization composed, or no organization in force, every caller is
75
+ * refused: the inbox is every customer's correspondence, and the absence of a policy is never an open one.
76
+ * The refusal names no power and no organization, because which power reaches the inbox is the adopter's
77
+ * internal arrangement and a caller who does not hold it has no use for the name.
78
+ */
79
+ export function requireStaff(
80
+ staff: SupportStaffConfig | undefined,
81
+ power: keyof SupportStaffConfig,
82
+ ): MiddlewareHandler<PithyHonoEnv> {
83
+ return async (c, next) => {
84
+ const acting = await c.var.actingOrganization();
85
+ if (!staffMay(acting, staff, power)) {
86
+ throw new ForbiddenError({
87
+ message: "You do not work this support inbox.",
88
+ action: staff
89
+ ? `Give the user an unassignable role holding \`${staff[power]}\`, through the control plane.`
90
+ : "Name staff powers in support({ staff }) to let signed-in users work the inbox.",
91
+ detail: `user ${c.var.auth?.userId ?? "unknown"} holds no ${power} for support in ${acting?.organizationId ?? "no organization"}`,
92
+ });
93
+ }
94
+ await next();
95
+ };
96
+ }
@@ -7,9 +7,13 @@ import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
7
7
  import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
8
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
9
9
  import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
10
+ import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
11
+ import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
12
+ import type { VectorStore } from "@pithy-sh/vector/src/index/index";
10
13
  import { attachmentUrl } from "../attachment/store";
11
14
  import { SupportAuditActions } from "../audit/actions";
12
15
  import type { SupportConfig } from "../config/config";
16
+ import type { SupportAssignmentActorType } from "../data/assignment";
13
17
  import type { SupportAttachment } from "../data/attachment";
14
18
  import type { SupportCategories } from "../data/categories";
15
19
  import type { SupportDatabase } from "../data/tables";
@@ -23,11 +27,14 @@ import {
23
27
  import { sendReply } from "../reply/send";
24
28
  import type { SupportReplySnippets } from "../reply/snippets";
25
29
  import { repliesForCategory } from "../reply/snippets";
30
+ import { assignThread, listAssignments } from "../store/assignment";
31
+ import { searchThreads } from "../store/hybrid";
26
32
  import { listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags } from "../store/threads";
27
33
  import { decodeBase64 } from "../submission/encoding";
28
34
  import { submitFeedback } from "../submission/submit";
29
35
  import { latestInboundMessageId } from "../workflows/classify";
30
36
  import type {
37
+ SupportAssignResponse,
31
38
  SupportAttachmentView,
32
39
  SupportFlagsResponse,
33
40
  SupportMyThreadResponse,
@@ -35,6 +42,7 @@ import type {
35
42
  SupportReclassifiedResponse,
36
43
  SupportReplySentResponse,
37
44
  SupportReplyView,
45
+ SupportSearchResponse,
38
46
  SupportSubmissionResponse,
39
47
  SupportThreadResponse,
40
48
  SupportThreadsResponse,
@@ -42,14 +50,25 @@ import type {
42
50
  } from "./responses";
43
51
  import type {
44
52
  ArchiveThreadInput,
53
+ AssignThreadInput,
45
54
  FlagsInput,
46
55
  ListThreadsQuery,
47
56
  MyThreadsQuery,
48
57
  RepliesQuery,
49
58
  ReplyInput,
59
+ SearchThreadsQuery,
50
60
  SubmitFeedbackInput,
51
61
  } from "./schemas";
52
- import { listedThreadView, messageView, myMessageView, myThreadView, senderView, threadView } from "./views";
62
+ import {
63
+ assignmentView,
64
+ listedThreadView,
65
+ messageView,
66
+ myMessageView,
67
+ myThreadView,
68
+ searchView,
69
+ senderView,
70
+ threadView,
71
+ } from "./views";
53
72
 
54
73
  /**
55
74
  * The support handlers — every one of them behind the `control-plane` gate, and every one of them
@@ -73,6 +92,11 @@ export interface HandlerDeps {
73
92
  snippets: SupportReplySnippets;
74
93
  /** Whether the FTS5 index is composed. */
75
94
  fts: boolean;
95
+ /**
96
+ * The semantic message index and the bindings it runs on. `index` is absent when `search.semantic` is
97
+ * off; `ai` and `store` are absent when this Worker was not given them — until `pithy support provision`.
98
+ */
99
+ semantic: { index?: VectorIndexConfig; ai?: VectorAi; store?: VectorStore };
76
100
  /** The attachment bucket, when one is bound. */
77
101
  bucket?: R2Bucket;
78
102
  /** The object store attachment URLs are signed through, when credentials are available. */
@@ -109,15 +133,51 @@ export async function listInbox(
109
133
  sentiment: query.sentiment,
110
134
  channel: query.channel,
111
135
  inbox: query.inbox,
112
- q: query.q,
136
+ assignee: query.assignee,
113
137
  cursor: query.cursor,
114
138
  limit: query.limit,
115
139
  },
116
- { fts: deps.fts, viewer, now: deps.now(), log: deps.log },
140
+ { viewer, now: deps.now() },
117
141
  );
118
142
  return { threads: page.threads.map(listedThreadView), nextCursor: page.nextCursor };
119
143
  }
120
144
 
145
+ /**
146
+ * Search the inbox: keyword and semantic, ranked as one. The semantic seams are whatever this Worker
147
+ * was given — absent bindings skip that backend and say so, rather than failing the search.
148
+ */
149
+ export async function searchInbox(
150
+ deps: HandlerDeps,
151
+ query: SearchThreadsQuery,
152
+ viewer: string,
153
+ ): Promise<SupportSearchResponse> {
154
+ const outcome = await searchThreads(
155
+ {
156
+ db: deps.db,
157
+ fts: deps.fts,
158
+ ...(deps.semantic.index ? { index: deps.semantic.index } : {}),
159
+ ...(deps.semantic.ai ? { ai: deps.semantic.ai } : {}),
160
+ ...(deps.semantic.store ? { store: deps.semantic.store } : {}),
161
+ viewer,
162
+ log: deps.log,
163
+ },
164
+ {
165
+ q: query.q,
166
+ ...(query.mode !== undefined ? { mode: query.mode } : {}),
167
+ ...(query.limit !== undefined ? { limit: query.limit } : {}),
168
+ archived: query.archived === "true",
169
+ category: query.category,
170
+ declaredCategory: query.declaredCategory,
171
+ priority: query.priority,
172
+ sentiment: query.sentiment,
173
+ channel: query.channel,
174
+ inbox: query.inbox,
175
+ assignee: query.assignee,
176
+ },
177
+ );
178
+ return searchView(outcome);
179
+ }
180
+
121
181
  /**
122
182
  * Present an attachment.
123
183
  *
@@ -156,7 +216,7 @@ async function presentAttachments(
156
216
  /** Read one conversation in full. */
157
217
  export async function readConversation(deps: HandlerDeps, threadId: string): Promise<SupportThreadResponse> {
158
218
  const detail = await readThread(deps.db, threadId);
159
- const [attachments, sender] = await Promise.all([
219
+ const [attachments, sender, assignments] = await Promise.all([
160
220
  presentAttachments(deps, detail.attachments),
161
221
  // A session-proven thread already holds the id its session established, so the context is resolved
162
222
  // from that rather than re-derived from an address. Not a shortcut: `resolveSenderUserId` matches
@@ -171,6 +231,7 @@ export async function readConversation(deps: HandlerDeps, threadId: string): Pro
171
231
  { authenticated: detail.thread.senderAuthenticated },
172
232
  deps.peers,
173
233
  ),
234
+ listAssignments(deps.db, threadId),
174
235
  ]);
175
236
 
176
237
  return {
@@ -179,6 +240,7 @@ export async function readConversation(deps: HandlerDeps, threadId: string): Pro
179
240
  attachments,
180
241
  sender: senderView(sender),
181
242
  replies: repliesForCategory(deps.snippets, detail.thread.category),
243
+ assignments: assignments.map(assignmentView),
182
244
  };
183
245
  }
184
246
 
@@ -206,6 +268,56 @@ export async function archiveConversation(
206
268
  return threadView(thread);
207
269
  }
208
270
 
271
+ /** Who is assigning: how they were verified, and their id under it. */
272
+ export interface Assigner {
273
+ /** `control-plane` for a management client, `user` for a signed-in user holding support's staff power. */
274
+ type: SupportAssignmentActorType;
275
+ /** The control-plane subject, or the user's id. */
276
+ id: string;
277
+ }
278
+
279
+ /**
280
+ * Assign a conversation to somebody, or to nobody — the one handler behind both doors.
281
+ *
282
+ * The audit event is emitted **only when something changed**. The current assignee again, or unassigning
283
+ * a thread nobody has, wrote nothing to the thread or its history, so an event would record a handover
284
+ * that did not happen.
285
+ */
286
+ export async function assignConversation(
287
+ deps: HandlerDeps,
288
+ threadId: string,
289
+ input: AssignThreadInput,
290
+ assigner: Assigner,
291
+ ): Promise<SupportAssignResponse> {
292
+ const result = await assignThread(deps.d1, {
293
+ threadId,
294
+ assignee: input.assignee,
295
+ actorType: assigner.type,
296
+ assignedBy: assigner.id,
297
+ note: input.note,
298
+ newId: deps.newId,
299
+ now: deps.now(),
300
+ });
301
+
302
+ if (result.changed) {
303
+ await deps.emit({
304
+ action: input.assignee ? SupportAuditActions.threadAssigned : SupportAuditActions.threadUnassigned,
305
+ outcome: "success",
306
+ actorType: assigner.type,
307
+ actorId: assigner.id,
308
+ resourceType: "support_thread",
309
+ resourceId: threadId,
310
+ metadata: {
311
+ previousAssignee: result.previous,
312
+ assignee: input.assignee,
313
+ note: input.note ?? null,
314
+ },
315
+ });
316
+ }
317
+
318
+ return { thread: threadView(result.thread), changed: result.changed };
319
+ }
320
+
209
321
  /** Answer the customer. */
210
322
  export async function replyToConversation(
211
323
  deps: HandlerDeps,
@@ -237,6 +349,15 @@ export async function reclassifyConversation(
237
349
  threadId: string,
238
350
  viewer: string,
239
351
  ): Promise<SupportReclassifiedResponse> {
352
+ // The worker classifies only with `ai.enabled` on. Dispatching anyway would start a run that classifies
353
+ // nothing, and the 200 and the audit event below would both say it had.
354
+ if (!deps.config.ai.enabled) {
355
+ throw new SupportClassificationError({
356
+ message: "Classification is off for this project.",
357
+ action: `Turn on \`ai.enabled\` in the support config, run \`${pithyCommandOf("support provision", {}, { runtime: true })}\`, then retry.`,
358
+ detail: `ai.enabled is false, so no instance was started for thread ${threadId}`,
359
+ });
360
+ }
240
361
  const messageId = await latestInboundMessageId(deps.db, threadId);
241
362
  if (!messageId) {
242
363
  throw new SupportNotFoundError({
@@ -7,10 +7,14 @@ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
7
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
8
8
  import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
9
9
  import { objectStore } from "@pithy-sh/storage/src/object/store";
10
+ import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
11
+ import type { VectorStore } from "@pithy-sh/vector/src/index/index";
10
12
  import type { Context } from "hono";
11
13
  import type { SupportWiring } from "../capability";
14
+ import type { SupportConfig } from "../config/config";
12
15
  import { supportDatabase } from "../data/tables";
13
16
  import { SUPPORT_R2_SECRET } from "../secret/registry";
17
+ import { SUPPORT_AI_BINDING, SUPPORT_VECTORIZE_BINDING, supportMessageIndex } from "../semantic/messageIndex";
14
18
  import { supportWorkflowRegistry, supportWorkflows } from "../workflows/specs";
15
19
  import type { HandlerDeps } from "./handlers";
16
20
 
@@ -78,6 +82,20 @@ export function resolveStore(env: SupportEnv): ReturnType<typeof objectStore> |
78
82
  return objectStore({ bucket: env.SUPPORT_BUCKET, env, secretName: SUPPORT_R2_SECRET });
79
83
  }
80
84
 
85
+ /**
86
+ * The semantic index and the two bindings it runs on, as this Worker has them.
87
+ *
88
+ * Absent rather than refused: a project that turned `search.semantic` on and has not provisioned yet still
89
+ * serves every route, and the search route names the backend it skipped.
90
+ */
91
+ export function resolveSemantic(env: Record<string, unknown>, config: SupportConfig): HandlerDeps["semantic"] {
92
+ const index = supportMessageIndex(config.search);
93
+ if (!index) return {};
94
+ const ai = env[SUPPORT_AI_BINDING] as VectorAi | undefined;
95
+ const store = env[SUPPORT_VECTORIZE_BINDING] as VectorStore | undefined;
96
+ return { index, ...(ai ? { ai } : {}), ...(store ? { store } : {}) };
97
+ }
98
+
81
99
  /** Build the per-request handler dependencies. */
82
100
  export function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv>) => Promise<HandlerDeps> {
83
101
  return async (c) => {
@@ -92,6 +110,7 @@ export function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv
92
110
  categories: wiring.categories,
93
111
  snippets: wiring.snippets,
94
112
  fts: wiring.config.search.fts,
113
+ semantic: resolveSemantic(env as unknown as Record<string, unknown>, wiring.config),
95
114
  bucket: env.SUPPORT_BUCKET,
96
115
  store: resolveStore(env),
97
116
  // Bound at compose time when `email()` is composed; undefined otherwise, which the reply