@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
@@ -6,10 +6,11 @@ import { z } from "zod";
6
6
  *
7
7
  * **Everything on it is derived from immutable inbound mail.** The classification is recomputed, never
8
8
  * repaired; the user link is a lookup, not a decision; the counters are facts about the messages
9
- * below. There is no assignee and no status, because a status field is a state machine and a state
10
- * machine attracts SLAs, escalation, and reporting — which is a ticketing product and explicitly not
11
- * this. The two exceptions are deliberate and named: `archived` (one shared boolean meaning done) and
12
- * the per-viewer flags, which live in their own table because nobody coordinates around them.
9
+ * below. There is no status, because a status field is a state machine and a state machine attracts
10
+ * SLAs, escalation, and reporting — which is a ticketing product and explicitly not this. The
11
+ * exceptions are deliberate and named: `archived` (one shared boolean meaning done), the assignee (one
12
+ * fact, who has this, with its history in `pithy_support_assignments` — #816), and the per-viewer
13
+ * flags, which live in their own table because nobody coordinates around them.
13
14
  *
14
15
  * The classification axes are **denormalized onto the thread** rather than joined from
15
16
  * `pithy_support_classifications`. The whole dashboard interaction is "newest first, filtered by
@@ -52,6 +53,10 @@ export declare const SupportThread: z.ZodObject<{
52
53
  archived: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
53
54
  archivedAt: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
54
55
  archivedBy: z.ZodOptional<z.ZodNullable<z.ZodString>>;
56
+ assigneeRef: z.ZodOptional<z.ZodNullable<z.ZodString>>;
57
+ assigneeName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
58
+ assignedAt: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
59
+ assignedBy: z.ZodOptional<z.ZodNullable<z.ZodString>>;
55
60
  messageCount: z.ZodNumber;
56
61
  firstMessageAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
57
62
  lastMessageAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
@@ -1 +1 @@
1
- {"version":3,"file":"thread.d.ts","sourceRoot":"","sources":["../../src/data/thread.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA4FmF,CAAC;AAC9G,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY;;uBAEb,QAAQ;wBACP,SAAS;;;;CAOrB,CAAC"}
1
+ {"version":3,"file":"thread.d.ts","sourceRoot":"","sources":["../../src/data/thread.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiHmF,CAAC;AAC9G,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY;;uBAEb,QAAQ;wBACP,SAAS;;;;CAOrB,CAAC"}
@@ -9,10 +9,11 @@ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
9
9
  *
10
10
  * **Everything on it is derived from immutable inbound mail.** The classification is recomputed, never
11
11
  * repaired; the user link is a lookup, not a decision; the counters are facts about the messages
12
- * below. There is no assignee and no status, because a status field is a state machine and a state
13
- * machine attracts SLAs, escalation, and reporting — which is a ticketing product and explicitly not
14
- * this. The two exceptions are deliberate and named: `archived` (one shared boolean meaning done) and
15
- * the per-viewer flags, which live in their own table because nobody coordinates around them.
12
+ * below. There is no status, because a status field is a state machine and a state machine attracts
13
+ * SLAs, escalation, and reporting — which is a ticketing product and explicitly not this. The
14
+ * exceptions are deliberate and named: `archived` (one shared boolean meaning done), the assignee (one
15
+ * fact, who has this, with its history in `pithy_support_assignments` — #816), and the per-viewer
16
+ * flags, which live in their own table because nobody coordinates around them.
16
17
  *
17
18
  * The classification axes are **denormalized onto the thread** rather than joined from
18
19
  * `pithy_support_classifications`. The whole dashboard interaction is "newest first, filtered by
@@ -40,6 +41,10 @@ const SupportThread = z.object({
40
41
  archived: SQLiteBoolean.describe("Done. One shared boolean, not per viewer, because if one person resolves a thread another should not still see it open. The mildest possible coordination state: no transitions to get wrong, nothing can be stuck, and unarchiving undoes a mistake instantly."),
41
42
  archivedAt: SQLiteDate.nullish().describe("When the thread was last archived; null while it is open."),
42
43
  archivedBy: z.string().nullish().describe("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."),
44
+ assigneeRef: z.string().nullish().describe("**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."),
45
+ assigneeName: z.string().nullish().describe("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."),
46
+ assignedAt: SQLiteDate.nullish().describe("When the current assignment was made; null while the thread has no assignee."),
47
+ assignedBy: z.string().nullish().describe("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."),
43
48
  messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
44
49
  firstMessageAt: SQLiteDate.describe("When the thread opened — the receive time of its first message."),
45
50
  lastMessageAt: SQLiteDate.describe("When the thread last moved. The inbox sorts on this, and it is the `receivedAt` half of every composite index and of the pagination cursor."),
@@ -2,35 +2,43 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
4
4
  import type { MiddlewareHandler } from "hono";
5
+ import type { SupportStaffConfig } from "../config/config";
5
6
  /**
6
- * Support's identity gates: the copied `requireAuth()` its in-app submission routes wear, and the
7
- * control-plane gate its management routes take from core's seam.
7
+ * Support's identity gates: the copied `requireAuth()` its in-app routes wear, the control-plane gate its
8
+ * management routes take from core's seam, and `requireStaff()`, which lets the adopter's own staff onto a
9
+ * slice of the management side through their session (#816).
8
10
  *
9
11
  * **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
10
12
  * connection may do, and it reads them in a browser — so they cannot live in a module that imports
11
13
  * Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
12
14
  * the routes import both.
13
15
  *
14
- * ## Two surfaces, and the split is the whole shape of this file
16
+ * ## Three doors, and the split is the whole shape of this file
15
17
  *
16
18
  * **The management surface is `control-plane` and default-denied.** It reads and acts on every
17
- * customer's private correspondence, so it answers to a credential the adopter issued and to nothing
18
- * else. The seam's gate is imported from core rather than copied — core is a hard dependency of every
19
- * capability, so importing its gate cannot leave a deployment without one, and with the seam
20
- * uncomposed `requireControlPlane` raises `controlplane/not_connected` rather than passing.
19
+ * customer's private correspondence, so it answers to a credential the adopter issued. The seam's gate is
20
+ * imported from core rather than copied — core is a hard dependency of every capability, so importing its
21
+ * gate cannot leave a deployment without one, and with the seam uncomposed `requireControlPlane` raises
22
+ * `controlplane/not_connected` rather than passing.
21
23
  *
22
24
  * **The submission surface is `bearer`/`session` and is the adopter's own signed-in user.** It exists
23
25
  * because a product with a logged-in user, a session, and a support console should not have to ask
24
26
  * that user to open their mail client — and because the hardest problem on the mail path, proving a
25
27
  * `From:` header, does not exist on a request whose session was already proved.
26
28
  *
27
- * **`requireAuth()` never appears on a management route, and `requireControlPlane` never appears on a
28
- * submission route.** They are not two strengths of the same gate. A management client holds no
29
- * session and owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking
30
- * them would deny every legitimate call on both surfaces, permanently, with no credential able to fix
31
- * it. What each route may *see* follows from which gate it wears: an operator reads a thread with its
32
- * classification and its sender's purchases, and a submitter reads their own words back and nothing
33
- * else.
29
+ * **The staff door is a session, and it reaches management work.** `requireAuth()` then `requireStaff()`
30
+ * admit a signed-in user whose role, in the organization the request acts for, holds one of the powers
31
+ * `support({ staff })` names — and nobody at all when `staff` is absent. It opens two things and no more:
32
+ * assigning a thread, at its own `/staff/` path, and joining a thread's presence room. Everything else on
33
+ * the management side still answers to the control plane alone.
34
+ *
35
+ * **`requireAuth()` never appears on a control-plane route, and `requireControlPlane` never appears on a
36
+ * session route.** They are not two strengths of the same gate. A management client holds no session and
37
+ * owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking them would deny
38
+ * every legitimate call on both, permanently, with no credential able to fix it. That is why staff
39
+ * assignment is a second route beside the control-plane one rather than a second gate on it. What each
40
+ * route may *see* follows from which gate it wears: an operator reads a thread with its classification and
41
+ * its sender's purchases, and a submitter reads their own words back and nothing else.
34
42
  */
35
43
  /**
36
44
  * Require an authenticated caller — core's `AuthContext` seam, and the gate on every in-app route.
@@ -43,4 +51,14 @@ import type { MiddlewareHandler } from "hono";
43
51
  * gate is somebody's support history.
44
52
  */
45
53
  export declare function requireAuth(): MiddlewareHandler<PithyHonoEnv>;
54
+ /**
55
+ * Require one of support's staff powers in the organization this request acts for (#816) — the gate on
56
+ * the staff session routes, after `requireAuth()`.
57
+ *
58
+ * With no `staff` configured, or no organization composed, or no organization in force, every caller is
59
+ * refused: the inbox is every customer's correspondence, and the absence of a policy is never an open one.
60
+ * The refusal names no power and no organization, because which power reaches the inbox is the adopter's
61
+ * internal arrangement and a caller who does not hold it has no use for the name.
62
+ */
63
+ export declare function requireStaff(staff: SupportStaffConfig | undefined, power: keyof SupportStaffConfig): MiddlewareHandler<PithyHonoEnv>;
46
64
  //# sourceMappingURL=guards.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../src/http/guards.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAE7E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH;;;;;;;;;GASG;AACH,wBAAgB,WAAW,IAAI,iBAAiB,CAAC,YAAY,CAAC,CAU7D"}
1
+ {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../src/http/guards.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAE7E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAG3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH;;;;;;;;;GASG;AACH,wBAAgB,WAAW,IAAI,iBAAiB,CAAC,YAAY,CAAC,CAU7D;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,kBAAkB,GAAG,SAAS,EACrC,KAAK,EAAE,MAAM,kBAAkB,GAC9B,iBAAiB,CAAC,YAAY,CAAC,CAcjC"}
@@ -1,36 +1,44 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
3
+ import { staffMay } from "../staff/staff.js";
4
+ import { ForbiddenError, UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
4
5
  //#region src/http/guards.ts
5
6
  /**
6
- * Support's identity gates: the copied `requireAuth()` its in-app submission routes wear, and the
7
- * control-plane gate its management routes take from core's seam.
7
+ * Support's identity gates: the copied `requireAuth()` its in-app routes wear, the control-plane gate its
8
+ * management routes take from core's seam, and `requireStaff()`, which lets the adopter's own staff onto a
9
+ * slice of the management side through their session (#816).
8
10
  *
9
11
  * **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
10
12
  * connection may do, and it reads them in a browser — so they cannot live in a module that imports
11
13
  * Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
12
14
  * the routes import both.
13
15
  *
14
- * ## Two surfaces, and the split is the whole shape of this file
16
+ * ## Three doors, and the split is the whole shape of this file
15
17
  *
16
18
  * **The management surface is `control-plane` and default-denied.** It reads and acts on every
17
- * customer's private correspondence, so it answers to a credential the adopter issued and to nothing
18
- * else. The seam's gate is imported from core rather than copied — core is a hard dependency of every
19
- * capability, so importing its gate cannot leave a deployment without one, and with the seam
20
- * uncomposed `requireControlPlane` raises `controlplane/not_connected` rather than passing.
19
+ * customer's private correspondence, so it answers to a credential the adopter issued. The seam's gate is
20
+ * imported from core rather than copied — core is a hard dependency of every capability, so importing its
21
+ * gate cannot leave a deployment without one, and with the seam uncomposed `requireControlPlane` raises
22
+ * `controlplane/not_connected` rather than passing.
21
23
  *
22
24
  * **The submission surface is `bearer`/`session` and is the adopter's own signed-in user.** It exists
23
25
  * because a product with a logged-in user, a session, and a support console should not have to ask
24
26
  * that user to open their mail client — and because the hardest problem on the mail path, proving a
25
27
  * `From:` header, does not exist on a request whose session was already proved.
26
28
  *
27
- * **`requireAuth()` never appears on a management route, and `requireControlPlane` never appears on a
28
- * submission route.** They are not two strengths of the same gate. A management client holds no
29
- * session and owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking
30
- * them would deny every legitimate call on both surfaces, permanently, with no credential able to fix
31
- * it. What each route may *see* follows from which gate it wears: an operator reads a thread with its
32
- * classification and its sender's purchases, and a submitter reads their own words back and nothing
33
- * else.
29
+ * **The staff door is a session, and it reaches management work.** `requireAuth()` then `requireStaff()`
30
+ * admit a signed-in user whose role, in the organization the request acts for, holds one of the powers
31
+ * `support({ staff })` names — and nobody at all when `staff` is absent. It opens two things and no more:
32
+ * assigning a thread, at its own `/staff/` path, and joining a thread's presence room. Everything else on
33
+ * the management side still answers to the control plane alone.
34
+ *
35
+ * **`requireAuth()` never appears on a control-plane route, and `requireControlPlane` never appears on a
36
+ * session route.** They are not two strengths of the same gate. A management client holds no session and
37
+ * owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking them would deny
38
+ * every legitimate call on both, permanently, with no credential able to fix it. That is why staff
39
+ * assignment is a second route beside the control-plane one rather than a second gate on it. What each
40
+ * route may *see* follows from which gate it wears: an operator reads a thread with its classification and
41
+ * its sender's purchases, and a submitter reads their own words back and nothing else.
34
42
  */
35
43
  /**
36
44
  * Require an authenticated caller — core's `AuthContext` seam, and the gate on every in-app route.
@@ -51,5 +59,25 @@ function requireAuth() {
51
59
  await next();
52
60
  };
53
61
  }
62
+ /**
63
+ * Require one of support's staff powers in the organization this request acts for (#816) — the gate on
64
+ * the staff session routes, after `requireAuth()`.
65
+ *
66
+ * With no `staff` configured, or no organization composed, or no organization in force, every caller is
67
+ * refused: the inbox is every customer's correspondence, and the absence of a policy is never an open one.
68
+ * The refusal names no power and no organization, because which power reaches the inbox is the adopter's
69
+ * internal arrangement and a caller who does not hold it has no use for the name.
70
+ */
71
+ function requireStaff(staff, power) {
72
+ return async (c, next) => {
73
+ const acting = await c.var.actingOrganization();
74
+ if (!staffMay(acting, staff, power)) throw new ForbiddenError({
75
+ message: "You do not work this support inbox.",
76
+ action: staff ? `Give the user an unassignable role holding \`${staff[power]}\`, through the control plane.` : "Name staff powers in support({ staff }) to let signed-in users work the inbox.",
77
+ detail: `user ${c.var.auth?.userId ?? "unknown"} holds no ${power} for support in ${acting?.organizationId ?? "no organization"}`
78
+ });
79
+ await next();
80
+ };
81
+ }
54
82
  //#endregion
55
- export { requireAuth };
83
+ export { requireAuth, requireStaff };
@@ -4,14 +4,18 @@ import type { D1Database, 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
6
  import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
7
+ import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
8
+ import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
9
+ import type { VectorStore } from "@pithy-sh/vector/src/index/index";
7
10
  import type { SupportConfig } from "../config/config";
11
+ import type { SupportAssignmentActorType } from "../data/assignment";
8
12
  import type { SupportCategories } from "../data/categories";
9
13
  import type { SupportDatabase } from "../data/tables";
10
14
  import { type SenderPeers } from "../link/sender";
11
15
  import { sendReply } from "../reply/send";
12
16
  import type { SupportReplySnippets } from "../reply/snippets";
13
- import type { SupportFlagsResponse, SupportMyThreadResponse, SupportMyThreadsResponse, SupportReclassifiedResponse, SupportReplySentResponse, SupportReplyView, SupportSubmissionResponse, SupportThreadResponse, SupportThreadsResponse, SupportThreadView } from "./responses";
14
- import type { ArchiveThreadInput, FlagsInput, ListThreadsQuery, MyThreadsQuery, RepliesQuery, ReplyInput, SubmitFeedbackInput } from "./schemas";
17
+ import type { SupportAssignResponse, SupportFlagsResponse, SupportMyThreadResponse, SupportMyThreadsResponse, SupportReclassifiedResponse, SupportReplySentResponse, SupportReplyView, SupportSearchResponse, SupportSubmissionResponse, SupportThreadResponse, SupportThreadsResponse, SupportThreadView } from "./responses";
18
+ import type { ArchiveThreadInput, AssignThreadInput, FlagsInput, ListThreadsQuery, MyThreadsQuery, RepliesQuery, ReplyInput, SearchThreadsQuery, SubmitFeedbackInput } from "./schemas";
15
19
  /**
16
20
  * The support handlers — every one of them behind the `control-plane` gate, and every one of them
17
21
  * taking already-validated values.
@@ -33,6 +37,15 @@ export interface HandlerDeps {
33
37
  snippets: SupportReplySnippets;
34
38
  /** Whether the FTS5 index is composed. */
35
39
  fts: boolean;
40
+ /**
41
+ * The semantic message index and the bindings it runs on. `index` is absent when `search.semantic` is
42
+ * off; `ai` and `store` are absent when this Worker was not given them — until `pithy support provision`.
43
+ */
44
+ semantic: {
45
+ index?: VectorIndexConfig;
46
+ ai?: VectorAi;
47
+ store?: VectorStore;
48
+ };
36
49
  /** The attachment bucket, when one is bound. */
37
50
  bucket?: R2Bucket;
38
51
  /** The object store attachment URLs are signed through, when credentials are available. */
@@ -54,10 +67,30 @@ export interface HandlerDeps {
54
67
  }
55
68
  /** List the inbox. */
56
69
  export declare function listInbox(deps: HandlerDeps, query: ListThreadsQuery, viewer: string): Promise<SupportThreadsResponse>;
70
+ /**
71
+ * Search the inbox: keyword and semantic, ranked as one. The semantic seams are whatever this Worker
72
+ * was given — absent bindings skip that backend and say so, rather than failing the search.
73
+ */
74
+ export declare function searchInbox(deps: HandlerDeps, query: SearchThreadsQuery, viewer: string): Promise<SupportSearchResponse>;
57
75
  /** Read one conversation in full. */
58
76
  export declare function readConversation(deps: HandlerDeps, threadId: string): Promise<SupportThreadResponse>;
59
77
  /** Mark a conversation done, or reopen it. */
60
78
  export declare function archiveConversation(deps: HandlerDeps, threadId: string, input: ArchiveThreadInput, viewer: string): Promise<SupportThreadView>;
79
+ /** Who is assigning: how they were verified, and their id under it. */
80
+ export interface Assigner {
81
+ /** `control-plane` for a management client, `user` for a signed-in user holding support's staff power. */
82
+ type: SupportAssignmentActorType;
83
+ /** The control-plane subject, or the user's id. */
84
+ id: string;
85
+ }
86
+ /**
87
+ * Assign a conversation to somebody, or to nobody — the one handler behind both doors.
88
+ *
89
+ * The audit event is emitted **only when something changed**. The current assignee again, or unassigning
90
+ * a thread nobody has, wrote nothing to the thread or its history, so an event would record a handover
91
+ * that did not happen.
92
+ */
93
+ export declare function assignConversation(deps: HandlerDeps, threadId: string, input: AssignThreadInput, assigner: Assigner): Promise<SupportAssignResponse>;
61
94
  /** Answer the customer. */
62
95
  export declare function replyToConversation(deps: HandlerDeps, threadId: string, input: ReplyInput, viewer: string): Promise<SupportReplySentResponse>;
63
96
  /** Re-run the classifier over a conversation's latest inbound message. */
@@ -1 +1 @@
1
- {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/http/handlers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/http/handlers.ts"],"names":[],"mappings":"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"}
@@ -6,11 +6,13 @@ import { attachmentUrl } from "../attachment/store.js";
6
6
  import { SupportAuditActions } from "../audit/actions.js";
7
7
  import { resolveSenderContext, resolveSubmitterAccount, resolveSubmitterContext } from "../link/sender.js";
8
8
  import { sendReply } from "../reply/send.js";
9
+ import { assignThread, listAssignments } from "../store/assignment.js";
9
10
  import { listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags } from "../store/threads.js";
11
+ import { searchThreads } from "../store/hybrid.js";
10
12
  import { decodeBase64 } from "../submission/encoding.js";
11
13
  import { submitFeedback } from "../submission/submit.js";
12
14
  import { latestInboundMessageId } from "../workflows/classify.js";
13
- import { listedThreadView, messageView, myMessageView, myThreadView, senderView, threadView } from "./views.js";
15
+ import { assignmentView, listedThreadView, messageView, myMessageView, myThreadView, searchView, senderView, threadView } from "./views.js";
14
16
  import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
15
17
  import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
16
18
  //#region src/http/handlers.ts
@@ -24,14 +26,12 @@ async function listInbox(deps, query, viewer) {
24
26
  sentiment: query.sentiment,
25
27
  channel: query.channel,
26
28
  inbox: query.inbox,
27
- q: query.q,
29
+ assignee: query.assignee,
28
30
  cursor: query.cursor,
29
31
  limit: query.limit
30
32
  }, {
31
- fts: deps.fts,
32
33
  viewer,
33
- now: deps.now(),
34
- log: deps.log
34
+ now: deps.now()
35
35
  });
36
36
  return {
37
37
  threads: page.threads.map(listedThreadView),
@@ -39,6 +39,34 @@ async function listInbox(deps, query, viewer) {
39
39
  };
40
40
  }
41
41
  /**
42
+ * Search the inbox: keyword and semantic, ranked as one. The semantic seams are whatever this Worker
43
+ * was given — absent bindings skip that backend and say so, rather than failing the search.
44
+ */
45
+ async function searchInbox(deps, query, viewer) {
46
+ const outcome = await searchThreads({
47
+ db: deps.db,
48
+ fts: deps.fts,
49
+ ...deps.semantic.index ? { index: deps.semantic.index } : {},
50
+ ...deps.semantic.ai ? { ai: deps.semantic.ai } : {},
51
+ ...deps.semantic.store ? { store: deps.semantic.store } : {},
52
+ viewer,
53
+ log: deps.log
54
+ }, {
55
+ q: query.q,
56
+ ...query.mode !== void 0 ? { mode: query.mode } : {},
57
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
58
+ archived: query.archived === "true",
59
+ category: query.category,
60
+ declaredCategory: query.declaredCategory,
61
+ priority: query.priority,
62
+ sentiment: query.sentiment,
63
+ channel: query.channel,
64
+ inbox: query.inbox,
65
+ assignee: query.assignee
66
+ });
67
+ return searchView(outcome);
68
+ }
69
+ /**
42
70
  * Present an attachment.
43
71
  *
44
72
  * The `storageKey` is **never** in the response. It is server-derived and opaque precisely so a
@@ -70,13 +98,18 @@ async function presentAttachments(deps, attachments) {
70
98
  /** Read one conversation in full. */
71
99
  async function readConversation(deps, threadId) {
72
100
  const detail = await readThread(deps.db, threadId);
73
- const [attachments, sender] = await Promise.all([presentAttachments(deps, detail.attachments), detail.thread.accountLinkSource === "session" && detail.thread.userId ? resolveSubmitterContext(deps.d1, detail.thread.userId, deps.now(), deps.peers) : resolveSenderContext(deps.d1, detail.thread.fromAddress, deps.now(), { authenticated: detail.thread.senderAuthenticated }, deps.peers)]);
101
+ const [attachments, sender, assignments] = await Promise.all([
102
+ presentAttachments(deps, detail.attachments),
103
+ detail.thread.accountLinkSource === "session" && detail.thread.userId ? resolveSubmitterContext(deps.d1, detail.thread.userId, deps.now(), deps.peers) : resolveSenderContext(deps.d1, detail.thread.fromAddress, deps.now(), { authenticated: detail.thread.senderAuthenticated }, deps.peers),
104
+ listAssignments(deps.db, threadId)
105
+ ]);
74
106
  return {
75
107
  thread: threadView(detail.thread),
76
108
  messages: detail.messages.map(messageView),
77
109
  attachments,
78
110
  sender: senderView(sender),
79
- replies: repliesForCategory(deps.snippets, detail.thread.category)
111
+ replies: repliesForCategory(deps.snippets, detail.thread.category),
112
+ assignments: assignments.map(assignmentView)
80
113
  };
81
114
  }
82
115
  /** Mark a conversation done, or reopen it. */
@@ -93,6 +126,41 @@ async function archiveConversation(deps, threadId, input, viewer) {
93
126
  });
94
127
  return threadView(thread);
95
128
  }
129
+ /**
130
+ * Assign a conversation to somebody, or to nobody — the one handler behind both doors.
131
+ *
132
+ * The audit event is emitted **only when something changed**. The current assignee again, or unassigning
133
+ * a thread nobody has, wrote nothing to the thread or its history, so an event would record a handover
134
+ * that did not happen.
135
+ */
136
+ async function assignConversation(deps, threadId, input, assigner) {
137
+ const result = await assignThread(deps.d1, {
138
+ threadId,
139
+ assignee: input.assignee,
140
+ actorType: assigner.type,
141
+ assignedBy: assigner.id,
142
+ note: input.note,
143
+ newId: deps.newId,
144
+ now: deps.now()
145
+ });
146
+ if (result.changed) await deps.emit({
147
+ action: input.assignee ? SupportAuditActions.threadAssigned : SupportAuditActions.threadUnassigned,
148
+ outcome: "success",
149
+ actorType: assigner.type,
150
+ actorId: assigner.id,
151
+ resourceType: "support_thread",
152
+ resourceId: threadId,
153
+ metadata: {
154
+ previousAssignee: result.previous,
155
+ assignee: input.assignee,
156
+ note: input.note ?? null
157
+ }
158
+ });
159
+ return {
160
+ thread: threadView(result.thread),
161
+ changed: result.changed
162
+ };
163
+ }
96
164
  /** Answer the customer. */
97
165
  async function replyToConversation(deps, threadId, input, viewer) {
98
166
  return sendReply({
@@ -112,6 +180,11 @@ async function replyToConversation(deps, threadId, input, viewer) {
112
180
  }
113
181
  /** Re-run the classifier over a conversation's latest inbound message. */
114
182
  async function reclassifyConversation(deps, threadId, viewer) {
183
+ if (!deps.config.ai.enabled) throw new SupportClassificationError({
184
+ message: "Classification is off for this project.",
185
+ action: `Turn on \`ai.enabled\` in the support config, run \`${pithyCommandOf("support provision", {}, { runtime: true })}\`, then retry.`,
186
+ detail: `ai.enabled is false, so no instance was started for thread ${threadId}`
187
+ });
115
188
  const messageId = await latestInboundMessageId(deps.db, threadId);
116
189
  if (!messageId) throw new SupportNotFoundError({
117
190
  message: "That conversation has nothing to classify.",
@@ -246,4 +319,4 @@ async function readMyThread(deps, threadId, userId) {
246
319
  };
247
320
  }
248
321
  //#endregion
249
- export { archiveConversation, listInbox, listMyThreads, listReplies, readConversation, readMyThread, reclassifyConversation, replyToConversation, submitFeedbackRequest, updateFlags };
322
+ export { archiveConversation, assignConversation, listInbox, listMyThreads, listReplies, readConversation, readMyThread, reclassifyConversation, replyToConversation, searchInbox, submitFeedbackRequest, updateFlags };
@@ -7,6 +7,7 @@ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
7
7
  import { objectStore } from "@pithy-sh/storage/src/object/store";
8
8
  import type { Context } from "hono";
9
9
  import type { SupportWiring } from "../capability";
10
+ import type { SupportConfig } from "../config/config";
10
11
  import type { HandlerDeps } from "./handlers";
11
12
  /**
12
13
  * Turning a Worker env into the dependencies the handlers take.
@@ -41,6 +42,13 @@ export declare function makeClassifyDispatcher(env: Record<string, unknown>, log
41
42
  * inside the seam, so this costs nothing until something is actually signed.
42
43
  */
43
44
  export declare function resolveStore(env: SupportEnv): ReturnType<typeof objectStore> | undefined;
45
+ /**
46
+ * The semantic index and the two bindings it runs on, as this Worker has them.
47
+ *
48
+ * Absent rather than refused: a project that turned `search.semantic` on and has not provisioned yet still
49
+ * serves every route, and the search route names the backend it skipped.
50
+ */
51
+ export declare function resolveSemantic(env: Record<string, unknown>, config: SupportConfig): HandlerDeps["semantic"];
44
52
  /** Build the per-request handler dependencies. */
45
53
  export declare function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
46
54
  //# sourceMappingURL=resolve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/http/resolve.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAE7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AACjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAInD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;GAMG;AAEH,kCAAkC;AAClC,MAAM,WAAW,UAAW,SAAQ,eAAe;IACjD,qDAAqD;IACrD,EAAE,EAAE,UAAU,CAAC;IACf,mGAAmG;IACnG,cAAc,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAED,iGAAiG;AACjG,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,CAUlE;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,GAAG,EAAE,MAAM,GACV,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAUzC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,GAAG,SAAS,CAGxF;AAED,kDAAkD;AAClD,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,CA8BzG"}
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/http/resolve.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAE7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AAGjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAKtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;GAMG;AAEH,kCAAkC;AAClC,MAAM,WAAW,UAAW,SAAQ,eAAe;IACjD,qDAAqD;IACrD,EAAE,EAAE,UAAU,CAAC;IACf,mGAAmG;IACnG,cAAc,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAED,iGAAiG;AACjG,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,CAUlE;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,GAAG,EAAE,MAAM,GACV,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAUzC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,GAAG,SAAS,CAGxF;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,aAAa,GAAG,WAAW,CAAC,UAAU,CAAC,CAM5G;AAED,kDAAkD;AAClD,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,CA+BzG"}
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { supportDatabase } from "../data/tables.js";
4
4
  import { SUPPORT_R2_SECRET } from "../secret/registry.js";
5
+ import { SUPPORT_VECTORIZE_BINDING, supportMessageIndex } from "../semantic/messageIndex.js";
5
6
  import { supportWorkflowRegistry, supportWorkflows } from "../workflows/specs.js";
6
7
  import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
8
  import { objectStore } from "@pithy-sh/storage/src/object/store";
@@ -46,6 +47,23 @@ function resolveStore(env) {
46
47
  secretName: SUPPORT_R2_SECRET
47
48
  });
48
49
  }
50
+ /**
51
+ * The semantic index and the two bindings it runs on, as this Worker has them.
52
+ *
53
+ * Absent rather than refused: a project that turned `search.semantic` on and has not provisioned yet still
54
+ * serves every route, and the search route names the backend it skipped.
55
+ */
56
+ function resolveSemantic(env, config) {
57
+ const index = supportMessageIndex(config.search);
58
+ if (!index) return {};
59
+ const ai = env["AI"];
60
+ const store = env[SUPPORT_VECTORIZE_BINDING];
61
+ return {
62
+ index,
63
+ ...ai ? { ai } : {},
64
+ ...store ? { store } : {}
65
+ };
66
+ }
49
67
  /** Build the per-request handler dependencies. */
50
68
  function makeResolveDeps(wiring) {
51
69
  return async (c) => {
@@ -59,6 +77,7 @@ function makeResolveDeps(wiring) {
59
77
  categories: wiring.categories,
60
78
  snippets: wiring.snippets,
61
79
  fts: wiring.config.search.fts,
80
+ semantic: resolveSemantic(env, wiring.config),
62
81
  bucket: env.SUPPORT_BUCKET,
63
82
  store: resolveStore(env),
64
83
  enqueue: wiring.enqueueEmail ? (input) => wiring.enqueueEmail?.(env, input) : void 0,
@@ -72,4 +91,4 @@ function makeResolveDeps(wiring) {
72
91
  };
73
92
  }
74
93
  //#endregion
75
- export { makeClassifyDispatcher, makeResolveDeps, resolveDb, resolveStore };
94
+ export { makeClassifyDispatcher, makeResolveDeps, resolveDb, resolveSemantic, resolveStore };