@pithy-sh/support 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +68 -0
  4. package/pithy.manifest.json +40 -0
  5. package/src/ai/classify.ts +239 -0
  6. package/src/attachment/store.ts +78 -0
  7. package/src/audit/actions.ts +71 -0
  8. package/src/capability.ts +293 -0
  9. package/src/client/projection.ts +60 -0
  10. package/src/cloudflare-test.d.ts +15 -0
  11. package/src/config/config.ts +400 -0
  12. package/src/data/attachment.ts +65 -0
  13. package/src/data/billingScope.ts +32 -0
  14. package/src/data/categories.ts +117 -0
  15. package/src/data/classification.ts +50 -0
  16. package/src/data/enums.ts +90 -0
  17. package/src/data/flag.ts +37 -0
  18. package/src/data/message.ts +224 -0
  19. package/src/data/tables.ts +58 -0
  20. package/src/data/thread.ts +138 -0
  21. package/src/error/errors.ts +133 -0
  22. package/src/http/guards.ts +59 -0
  23. package/src/http/handlers.ts +418 -0
  24. package/src/http/resolve.ts +109 -0
  25. package/src/http/responses.ts +506 -0
  26. package/src/http/routes.ts +272 -0
  27. package/src/http/schemas.ts +251 -0
  28. package/src/http/scopes.ts +117 -0
  29. package/src/http/views.ts +169 -0
  30. package/src/inbound/authenticity.ts +114 -0
  31. package/src/inbound/guard.ts +127 -0
  32. package/src/inbound/handler.ts +102 -0
  33. package/src/inbound/ingest.ts +548 -0
  34. package/src/inbound/recipient.ts +67 -0
  35. package/src/index.ts +63 -0
  36. package/src/link/sender.ts +334 -0
  37. package/src/migrations/0001_threads.ts +296 -0
  38. package/src/mime/address.ts +37 -0
  39. package/src/mime/parse.ts +299 -0
  40. package/src/mime/sanitize.ts +253 -0
  41. package/src/mime/threading.ts +127 -0
  42. package/src/mime/truncate.ts +55 -0
  43. package/src/provision/provisionSupport.ts +179 -0
  44. package/src/provision/resolveSupportConfig.ts +67 -0
  45. package/src/reply/send.ts +322 -0
  46. package/src/reply/snippets.ts +167 -0
  47. package/src/secret/registry.ts +24 -0
  48. package/src/seeds/example.ts +385 -0
  49. package/src/store/paging.ts +22 -0
  50. package/src/store/search.ts +197 -0
  51. package/src/store/searchIndex.ts +71 -0
  52. package/src/store/threads.ts +452 -0
  53. package/src/submission/encoding.ts +66 -0
  54. package/src/submission/guard.ts +120 -0
  55. package/src/submission/submit.ts +539 -0
  56. package/src/version.generated.ts +16 -0
  57. package/src/workflows/classify.ts +164 -0
  58. package/src/workflows/retryPolicy.ts +48 -0
  59. package/src/workflows/specs.ts +61 -0
  60. package/src/workflows/worker.ts +82 -0
  61. package/src/workflows/wrangler.jsonc +46 -0
@@ -0,0 +1,127 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * RFC 5322 threading — turning `Message-ID`, `In-Reply-To`, and `References` into a conversation.
6
+ *
7
+ * **Threading is done on ids, never on the subject.** Subject matching is what makes two unrelated
8
+ * people who both wrote "Refund" land in one thread, and it is what splits a real conversation the
9
+ * moment somebody's client localizes `Re:` to `Aw:` or `Antw:`. The headers exist precisely so this
10
+ * does not have to be guessed, and every mail client sets them.
11
+ *
12
+ * The chain is tried in the order that goes from most precise to least: `In-Reply-To` names exactly
13
+ * one message, so it is asked first; `References` is the whole ancestry, so it is the fallback for a
14
+ * client that dropped `In-Reply-To` or a reply to a message that reached us out of order. Reading
15
+ * `References` **newest-first** matters — the last entry is the nearest ancestor, and starting at the
16
+ * root would attach a reply to the top of a long thread rather than to the message it answers.
17
+ */
18
+
19
+ /** The longest `References` chain kept. Bounded because the header is attacker-controlled and unbounded by spec. */
20
+ export const MAX_REFERENCES = 50;
21
+
22
+ /** The longest single message id kept, after unwrapping. Anything longer is not an id a client produced. */
23
+ const MAX_ID_LENGTH = 512;
24
+
25
+ /**
26
+ * Strip the angle brackets from a message id and bound it.
27
+ *
28
+ * Stored without brackets so a lookup is plain equality: the brackets are transport syntax, and one
29
+ * path storing `<a@b>` while another queries `a@b` is a threading bug that only shows up on replies.
30
+ */
31
+ export function normalizeMessageId(value: string | null | undefined): string | undefined {
32
+ if (typeof value !== "string") return undefined;
33
+ const trimmed = value.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
34
+ if (trimmed.length === 0 || trimmed.length > MAX_ID_LENGTH) return undefined;
35
+ // Whitespace inside means this was a list, not an id — the caller wanted `parseReferences`.
36
+ return /\s/.test(trimmed) ? undefined : trimmed;
37
+ }
38
+
39
+ /**
40
+ * Parse a `References` (or `In-Reply-To`) header into ids, oldest first, deduplicated and bounded.
41
+ *
42
+ * `postal-mime` hands `references` back as the raw header string, so the splitting is ours. Angle
43
+ * brackets are the delimiter when they are present and whitespace is the fallback when they are not,
44
+ * because a malformed header from a hand-rolled sender is a normal input here.
45
+ *
46
+ * **When the chain is over the cap, the OLDEST entries are dropped, not the newest.** That direction
47
+ * is the whole point: `parentCandidates` reads this list from the newest end backwards, because the
48
+ * nearest ancestor is the message a reply actually answers. Capping from the other end would discard
49
+ * exactly the ids the lookup wants first and keep the ones it would reach last — so a long thread
50
+ * would silently stop threading on `References` and fall back to whatever `In-Reply-To` happened to
51
+ * survive. Same rule `buildReferencesHeader` follows on the way out, and RFC 5322 §3.6.4 says to trim
52
+ * this end for the same reason.
53
+ */
54
+ export function parseReferences(value: string | null | undefined): string[] {
55
+ if (typeof value !== "string") return [];
56
+ const bracketed = [...value.matchAll(/<([^<>]+)>/g)].map((match) => match[1] ?? "");
57
+ const candidates = bracketed.length > 0 ? bracketed : value.split(/\s+/);
58
+ const seen = new Set<string>();
59
+ for (const candidate of candidates) {
60
+ const id = normalizeMessageId(candidate);
61
+ if (id) seen.add(id);
62
+ }
63
+ const ids = [...seen];
64
+ return ids.length > MAX_REFERENCES ? ids.slice(ids.length - MAX_REFERENCES) : ids;
65
+ }
66
+
67
+ /**
68
+ * The ids to look a parent up by, most precise first.
69
+ *
70
+ * The order is the whole content of this function: `In-Reply-To`, then `References` from the newest
71
+ * entry backwards. A caller resolves the first one it recognizes and stops.
72
+ */
73
+ export function parentCandidates(inReplyTo: string | undefined, references: readonly string[]): string[] {
74
+ const ordered = [...(inReplyTo ? [inReplyTo] : []), ...[...references].reverse()];
75
+ return [...new Set(ordered)];
76
+ }
77
+
78
+ /**
79
+ * Build the `References` header value for an outgoing reply.
80
+ *
81
+ * RFC 5322 §3.6.4: a reply's `References` is the parent's `References` followed by the parent's
82
+ * `Message-ID`. Getting this wrong is not cosmetic — it is the difference between the customer
83
+ * seeing one conversation and seeing a new mail every time somebody answers them, which is the
84
+ * single most visible way a support inbox looks broken from the outside. It is also the reason
85
+ * replying belongs in the Worker rather than in a dashboard: only the Worker holds the chain.
86
+ *
87
+ * The result is bracketed and space-separated, ready to be a header value, and bounded from the
88
+ * *front* — RFC 5322 says to drop the oldest entries when a chain has to be trimmed, because the
89
+ * recent ones are what a client threads on.
90
+ */
91
+ export function buildReferencesHeader(parentReferences: readonly string[], parentMessageId?: string): string {
92
+ // The parent's id is removed from the inherited chain before being re-appended, so it lands *last*
93
+ // even when it already appeared mid-chain. A plain `Set` would keep its first position instead, and
94
+ // §3.6.4's "parent's id last" property would quietly not hold for the one input where it is easiest
95
+ // to get wrong: a reply to a message that was itself already referenced.
96
+ const inherited = parentReferences.filter((id) => id !== parentMessageId);
97
+ const chain = [...inherited, ...(parentMessageId ? [parentMessageId] : [])];
98
+ const deduped = [...new Set(chain)];
99
+ const trimmed = deduped.length > MAX_REFERENCES ? deduped.slice(deduped.length - MAX_REFERENCES) : deduped;
100
+ return trimmed.map((id) => `<${id}>`).join(" ");
101
+ }
102
+
103
+ /**
104
+ * Mint a `Message-ID` for an outgoing reply.
105
+ *
106
+ * Ours to generate, because a message we send has to be nameable by the customer's own reply — their
107
+ * client will put this value in its `In-Reply-To`, and that is how the answer comes back to the same
108
+ * thread. The domain half is taken from the address it is sent as, so the id is plausible to a
109
+ * receiving spam filter rather than pointing at a domain we do not control.
110
+ */
111
+ export function mintMessageId(fromAddress: string, uuid: string): string {
112
+ const domain = fromAddress.slice(fromAddress.lastIndexOf("@") + 1) || "localhost";
113
+ return `${uuid}@${domain}`;
114
+ }
115
+
116
+ /**
117
+ * The subject line for a reply — `Re: ` prefixed exactly once.
118
+ *
119
+ * Match `Re:` case-insensitively and allow the bracketed count some clients add (`Re[2]:`), so
120
+ * answering a long thread does not build `Re: Re: Re: Re:`. Non-English prefixes are deliberately
121
+ * left alone: they are unbounded in practice, and a stray `Aw: Re:` is cosmetic, while stripping a
122
+ * word that happened to look like a prefix would change what the customer wrote.
123
+ */
124
+ export function replySubject(subject: string): string {
125
+ const trimmed = subject.trim();
126
+ return /^re(\[\d+\])?:/i.test(trimmed) ? trimmed : `Re: ${trimmed}`;
127
+ }
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Truncation that counts **bytes**, because the limits it serves are byte limits.
6
+ *
7
+ * `String.prototype.slice` counts UTF-16 code units, so a bound written as "256 KB" admits 256 K
8
+ * *characters* — three times that in bytes for CJK, four for most emoji. The bounds in this package
9
+ * exist to keep a row under D1's 2,000,000-byte ceiling, and a character-counted bound does not do
10
+ * that: a large CJK message sails through every check and then fails the insert, at which point the
11
+ * inbound handler swallows the error and the customer's mail is gone. Counting the thing the limit is
12
+ * about is the whole fix.
13
+ *
14
+ * Truncation lands on a code-point boundary, so the result is never a lone surrogate — which would be
15
+ * invalid text in the database and could break a JSON encode downstream.
16
+ */
17
+
18
+ const encoder = new TextEncoder();
19
+
20
+ /** How many bytes this string occupies as UTF-8. */
21
+ export function byteLength(value: string): number {
22
+ return encoder.encode(value).byteLength;
23
+ }
24
+
25
+ /**
26
+ * `value`, cut to at most `maxBytes` UTF-8 bytes, never mid-code-point.
27
+ *
28
+ * **Binary search, because the obvious loop is quadratic.** Walking down one code unit at a time and
29
+ * re-encoding the prefix each step is O(n) encodes of O(n) bytes, and the starting point makes it
30
+ * worse: `maxBytes` *code units* can be four times `maxBytes` *bytes*, so multi-byte text starts far
31
+ * above the answer and decrements toward it. Measured on the real constants, a 400k-character CJK
32
+ * body took 174,763 iterations and about three minutes of CPU — inside an `email()` handler, which
33
+ * the runtime kills long before that, so the message was never stored and every redelivery hit the
34
+ * same wall. A size bound that loses the message is worse than no bound.
35
+ *
36
+ * The search runs over `[0, min(length, maxBytes)]`: `maxBytes` code units is an upper bound because
37
+ * every code unit is at least one byte, and the predicate is monotone, so O(log n) encodes settle it.
38
+ */ export function truncateToBytes(value: string, maxBytes: number): string {
39
+ // The common case by far, and it costs one encode rather than a search.
40
+ if (maxBytes <= 0) return "";
41
+ if (byteLength(value) <= maxBytes) return value;
42
+
43
+ // Largest code-unit count whose UTF-8 encoding still fits. Monotone in `end`, so bisect it.
44
+ let low = 0;
45
+ let high = Math.min(value.length, maxBytes);
46
+ while (low < high) {
47
+ const mid = Math.ceil((low + high) / 2);
48
+ if (byteLength(value.slice(0, mid)) <= maxBytes) low = mid;
49
+ else high = mid - 1;
50
+ }
51
+
52
+ // `slice` on a code-unit index can split a surrogate pair; stepping back one unit repairs it.
53
+ const cut = value.slice(0, low);
54
+ return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
55
+ }
@@ -0,0 +1,179 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
5
+ import { GLOBAL_SCOPE } from "@pithy-sh/core/src/naming/environment";
6
+ import { resourceName } from "@pithy-sh/core/src/naming/resource";
7
+ import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
8
+ import { type ManagedEnvironment, managedEnvironments } from "@pithy-sh/secrets/src/scope";
9
+ import { SUPPORT_CAPABILITY } from "../workflows/specs";
10
+
11
+ /**
12
+ * The provisioning orchestration for the support capability — the live counterpart to
13
+ * `pithy add support`'s config wiring.
14
+ *
15
+ * Three things have to exist before a support inbox works, and none of them can be a binding an
16
+ * adopter hand-writes: the R2 bucket attachments and raw messages land in, the prebuilt classification
17
+ * worker per environment, and — the one that actually delivers the mail — an Email Routing rule
18
+ * pointing the support address at the app worker.
19
+ *
20
+ * The live Cloudflare/wrangler steps sit behind the {@link SupportProvisioner} seam, so the
21
+ * orchestration (order, idempotency, per-env fan-out) is unit-tested without touching Cloudflare.
22
+ * Every step is idempotent; re-running is a no-op.
23
+ *
24
+ * ## The routing rule is opt-in, and that is a safety decision
25
+ *
26
+ * `ensureRoutingRule` returns `skipped: true` when no routing config is supplied, exactly as
27
+ * `@pithy-sh/email`'s does — because **enabling Email Routing on a zone points its MX at Cloudflare.**
28
+ * Creating a rule on the wrong zone would move an adopter's real inbound mail off their existing
29
+ * provider, which is not a mistake a provisioning command gets to make on their behalf. So the zone,
30
+ * the address, and the target worker are all explicit flags, and a project that has not decided yet
31
+ * provisions everything else and adds the rule later.
32
+ *
33
+ * The rule name is `<project>-global-support-inbound`, deliberately distinct from `@pithy-sh/email`'s
34
+ * `<project>-global-email-bounce`. Idempotency in `ensureWorkerRoute` keys on the rule *name*, so
35
+ * sharing one would make whichever capability provisioned second silently believe its rule already
36
+ * existed — and the project segment is what stops two Pithy projects on one zone doing the same to
37
+ * each other, with the loser's customer mail delivered to the winner's Worker.
38
+ */
39
+
40
+ /**
41
+ * The name of the Email Routing rule this capability creates, for one project. Distinct from the
42
+ * bounce handler's, and `global` in the environment slot because there is one rule per zone: the
43
+ * environments are separated by which app Worker the rule is pointed at, not by the rule.
44
+ *
45
+ * **Still on the generic composer**, and deliberately: an Email Routing rule is not a namespace
46
+ * `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for, and the facade exists so a
47
+ * kind of thing brings its own number rather than borrowing one. It takes the conservative default
48
+ * until that namespace lands — the same call `@pithy-sh/email`'s bounce rule makes.
49
+ */
50
+ export function supportRoutingRuleName(project: string): string {
51
+ return resourceName({ project, env: GLOBAL_SCOPE, thing: `${SUPPORT_CAPABILITY}-inbound` });
52
+ }
53
+
54
+ /**
55
+ * The deployed Worker name for a project's environment — also its resolved config basename. Composed
56
+ * through core's naming facade under the **`worker`** namespace, so it is measured against a Worker
57
+ * script's 63 and the environment is validated on the way through. One source, so the name the CLI
58
+ * audits and deletes under cannot drift from the name it deploys under.
59
+ */
60
+ export function supportWorkerName(project: string, env: ManagedEnvironment): string {
61
+ return resourceNames(project).env(env).worker(SUPPORT_CAPABILITY);
62
+ }
63
+
64
+ /** The live Cloudflare/wrangler seam. Each step must be idempotent. */
65
+ export interface SupportProvisioner {
66
+ /**
67
+ * Verify account prerequisites before any resource is created — most importantly a registered
68
+ * `workers.dev` subdomain, which Cloudflare requires to deploy the Workflow-hosting worker.
69
+ */
70
+ preflight(): Promise<void>;
71
+ /**
72
+ * Create (or reuse) the R2 bucket attachments and raw messages live in. Idempotent.
73
+ *
74
+ * Returns `skipped: true` when the capability is configured with attachments off — a bucket nobody
75
+ * writes to is a resource an adopter did not ask for.
76
+ */
77
+ ensureBucket(): Promise<{ bucket: string; created: boolean; skipped: boolean }>;
78
+ /** Deploy the prebuilt classification worker for this environment. */
79
+ deployWorker(env: ManagedEnvironment): Promise<void>;
80
+ /**
81
+ * Create or drop the full-text index in this environment's app database, to match `search.fts`.
82
+ *
83
+ * A provisioning step rather than a migration, because the index is **derived** — every row in it
84
+ * comes from `pithy_support_messages` and `reindexThread` rebuilds it on demand. That is the line a
85
+ * migration is for: schema whose loss loses data. Keeping it here is also what makes the flag safe
86
+ * to toggle at all, since a config-conditional migration removed from the set is corruption to
87
+ * Kysely and blocks `pithy migrate` for every capability sharing the database.
88
+ */
89
+ ensureSearchIndex(env: ManagedEnvironment): Promise<{ created: boolean; dropped: boolean }>;
90
+ /**
91
+ * Ensure the inbound Email Routing rule that delivers the support address to the app worker.
92
+ * Idempotent, keyed on the rule name. Returns `skipped: true` when no routing config was supplied.
93
+ */
94
+ ensureRoutingRule(): Promise<{ created: boolean; skipped: boolean }>;
95
+ }
96
+
97
+ /** What provisioning produced. */
98
+ export interface SupportProvisionResult {
99
+ /** The R2 bucket, and whether it had to be created. */
100
+ bucket: { bucket: string; created: boolean; skipped: boolean };
101
+ /** The environments a classification worker was deployed for. */
102
+ environments: ManagedEnvironment[];
103
+ /** What the full-text index did, per environment — created, dropped, or already correct. */
104
+ search: Array<{ env: ManagedEnvironment; created: boolean; dropped: boolean }>;
105
+ /** Whether the inbound routing rule was created, already present, or skipped. */
106
+ routing: { created: boolean; skipped: boolean };
107
+ }
108
+
109
+ /**
110
+ * Provision the support infrastructure.
111
+ *
112
+ * The order matters and is the inverse of how it fails: the bucket exists before a worker that could
113
+ * write to it, and **the routing rule is last** — creating it first would start delivering mail to a
114
+ * Worker whose classification host is not deployed yet, which is a window where real customer
115
+ * messages arrive and stay `uncategorized` with nothing to say why.
116
+ *
117
+ * `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
118
+ * environment is provisioned; an environment this skipped would be one the project deploys to with no
119
+ * resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
120
+ */
121
+ export async function provisionSupport(
122
+ provisioner: SupportProvisioner,
123
+ environments: DeclaredEnvironments | readonly string[],
124
+ ): Promise<SupportProvisionResult> {
125
+ await provisioner.preflight();
126
+ const bucket = await provisioner.ensureBucket();
127
+ const search: SupportProvisionResult["search"] = [];
128
+ for (const env of managedEnvironments(environments)) {
129
+ await provisioner.deployWorker(env);
130
+ // Per environment, because each has its own app database — and after the worker, so a database
131
+ // that gains the index always has something able to write to it.
132
+ search.push({ env, ...(await provisioner.ensureSearchIndex(env)) });
133
+ }
134
+ const routing = await provisioner.ensureRoutingRule();
135
+ return { bucket, environments: managedEnvironments(environments), routing, search };
136
+ }
137
+
138
+ /** The teardown seam — the inverse of {@link SupportProvisioner}. Every step idempotent. */
139
+ export interface SupportDeprovisioner {
140
+ /** Delete the env's classification worker. Idempotent. */
141
+ deleteWorker(env: ManagedEnvironment): Promise<void>;
142
+ /** Remove the inbound routing rule, so mail stops being delivered here. Idempotent. */
143
+ removeRoutingRule(): Promise<{ removed: boolean }>;
144
+ /**
145
+ * Delete the R2 bucket **and everything in it** — every attachment and every raw message an adopter's
146
+ * customers ever sent. Destructive, so the orchestration only calls it when explicitly asked.
147
+ */
148
+ deleteBucket(): Promise<void>;
149
+ }
150
+
151
+ /** Teardown options. By default the bucket is **kept**: it holds correspondence, not cache. */
152
+ export interface SupportDeprovisionOptions {
153
+ /** Also delete the R2 bucket and its contents. Off by default. */
154
+ deleteStorage?: boolean;
155
+ }
156
+
157
+ /**
158
+ * Tear down the support infrastructure, reversing {@link provisionSupport}.
159
+ *
160
+ * The routing rule goes **first**, and that ordering is the whole point: stop new mail arriving before
161
+ * removing the workers that would have handled it, or messages land in a Worker with no classification
162
+ * host during the teardown. Stored correspondence is preserved unless explicitly requested — losing an
163
+ * adopter's support history to a teardown flag would be unrecoverable.
164
+ *
165
+ * `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
166
+ * environment is provisioned; an environment this skipped would be one the project deploys to with no
167
+ * resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
168
+ */
169
+ export async function deprovisionSupport(
170
+ deprovisioner: SupportDeprovisioner,
171
+ environments: DeclaredEnvironments | readonly string[],
172
+ options: SupportDeprovisionOptions = {},
173
+ ): Promise<void> {
174
+ await deprovisioner.removeRoutingRule();
175
+ for (const env of managedEnvironments(environments)) {
176
+ await deprovisioner.deleteWorker(env);
177
+ }
178
+ if (options.deleteStorage) await deprovisioner.deleteBucket();
179
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { hostWorkflowsFor, resolveWorkflowHost, type WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
5
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
6
+ import type { SupportConfig } from "../config/config";
7
+ import { SUPPORT_CAPABILITY, supportWorkflowRegistry } from "../workflows/specs";
8
+
9
+ /**
10
+ * Resolve the support worker's committed `wrangler.jsonc` template into one environment's standalone
11
+ * config. Every per-environment decision lives here; everything static — the compatibility date, the
12
+ * AI binding, the Workflow class name — stays as the template committed it.
13
+ *
14
+ * Thin over core's {@link resolveWorkflowHost}, which owns the mechanics (clone, fill by binding name,
15
+ * stamp `ENVIRONMENT`). This file owns only what is support's: which binding maps to which provisioned
16
+ * resource, the Workflow name derived from support's own specs, and the serialized config the worker
17
+ * parses.
18
+ *
19
+ * **`AI` is marked remote.** Workflows cannot use remote bindings in general, so a host always runs
20
+ * locally under `wrangler dev` — and Workers AI has no local emulation, so without this flag the
21
+ * classification step has nothing to call in development. Same treatment `@pithy-sh/vector` gives
22
+ * Vectorize, for the same reason.
23
+ *
24
+ * There is no secrets binding and no secrets database, and that absence is deliberate: this worker
25
+ * reads a message and writes a classification. The AI binding is its entire dependency, so it holds no
26
+ * credential at all — which is the smallest blast radius a deployed worker can have.
27
+ */
28
+
29
+ /** The resolved resource ids + per-env values for one environment's support-worker deploy. */
30
+ export interface SupportConfigParams {
31
+ /**
32
+ * The project name — the `<project>` segment the deployed worker and its classification Workflow
33
+ * lead with. The root `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed.
34
+ */
35
+ project: string;
36
+ /** The target environment. */
37
+ env: ManagedEnvironment;
38
+ /** The app database id for this environment — where the support tables live. */
39
+ appDatabaseId: string;
40
+ /**
41
+ * The app's resolved support config, serialized into the worker's `SUPPORT_CONFIG` var.
42
+ *
43
+ * This is how an adopter's federated categories reach the prompt. The worker is deployed from this
44
+ * package and cannot import their code, so the taxonomy has to travel as data — which it does,
45
+ * because it always was data.
46
+ */
47
+ supportConfig: SupportConfig;
48
+ }
49
+
50
+ /** Fill the template for one environment. */
51
+ export function resolveSupportConfig(
52
+ template: WorkflowHostTemplate,
53
+ params: SupportConfigParams,
54
+ ): WorkflowHostTemplate {
55
+ const { project, env } = params;
56
+ return resolveWorkflowHost(template, {
57
+ project,
58
+ capability: SUPPORT_CAPABILITY,
59
+ env,
60
+ databaseIds: { DB: params.appDatabaseId },
61
+ remoteBindings: ["AI"],
62
+ vars: { SUPPORT_CONFIG: JSON.stringify(params.supportConfig) },
63
+ // The classification Workflow, derived from support's own specs. A Workflow name is
64
+ // account-scoped, so it has to carry the project, and only the registry knows the job.
65
+ workflows: hostWorkflowsFor(supportWorkflowRegistry, { project, capability: SUPPORT_CAPABILITY, env }).workflows,
66
+ });
67
+ }