@pithy-sh/support 0.2.9 → 0.3.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.
@@ -7,6 +7,7 @@ import type { KvNamespaceSpecMap } from "@pithy-sh/core/src/kv/namespaces";
7
7
  import type { EmailCapability } from "@pithy-sh/email/src/capability";
8
8
  import { type SupportConfig, type SupportConfigInput } from "./config/config";
9
9
  import { type SupportCategories } from "./data/categories";
10
+ import type { SenderPeers } from "./link/sender";
10
11
  import { type SupportReplySnippets } from "./reply/snippets";
11
12
  import { supportWorkflows } from "./workflows/specs";
12
13
  /**
@@ -43,6 +44,11 @@ export interface SupportWiring {
43
44
  * so with `support/reply_failed` rather than the capability refusing to start.
44
45
  */
45
46
  enqueueEmail: EmailCapability["enqueue"] | undefined;
47
+ /**
48
+ * The optional peers the sender link reads through — `auth()`'s and `payments()`'s surfaces, set by
49
+ * `compose` when each is composed. Found, never imported (#645): see `link/sender.ts`.
50
+ */
51
+ peers: SenderPeers;
46
52
  /**
47
53
  * The audit seam the inbound handler emits through.
48
54
  *
@@ -1 +1 @@
1
- {"version":3,"file":"capability.d.ts","sourceRoot":"","sources":["../src/capability.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAGnE,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,0CAA0C,CAAC;AAC7F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAE3E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAItE,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,kBAAkB,EAGxB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAO9E,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAI7E,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C,+FAA+F;AAC/F,MAAM,MAAM,cAAc,GAAG,kBAAkB,GAAG;IAChD,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB,8BAA8B;IAC9B,UAAU,EAAE,iBAAiB,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,EAAE,oBAAoB,CAAC;IAC/B;;;;;OAKG;IACH,YAAY,EAAE,eAAe,CAAC,SAAS,CAAC,GAAG,SAAS,CAAC;IACrD;;;;;;;;OAQG;IACH,IAAI,EAAE,SAAS,CAAC;CACjB;AA4ED;;;;;GAKG;AACH,MAAM,WAAW,iBACf,SAAQ,UAAU,CAAC,eAAe,EAAE,kBAAkB,EAAE,SAAS,EAAE,OAAO,gBAAgB,CAAC;IAC3F,mCAAmC;IACnC,aAAa,EAAE,aAAa,CAAC;IAC7B,wGAAwG;IACxG,UAAU,EAAE,iBAAiB,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CAsGvE;AAED,2GAA2G;AAC3G,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,IAAI,iBAAiB,CAE3F"}
1
+ {"version":3,"file":"capability.d.ts","sourceRoot":"","sources":["../src/capability.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAGnE,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,0CAA0C,CAAC;AAC7F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAE3E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAItE,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,kBAAkB,EAGxB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAO9E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAI7E,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C,+FAA+F;AAC/F,MAAM,MAAM,cAAc,GAAG,kBAAkB,GAAG;IAChD,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB,8BAA8B;IAC9B,UAAU,EAAE,iBAAiB,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,EAAE,oBAAoB,CAAC;IAC/B;;;;;OAKG;IACH,YAAY,EAAE,eAAe,CAAC,SAAS,CAAC,GAAG,SAAS,CAAC;IACrD;;;OAGG;IACH,KAAK,EAAE,WAAW,CAAC;IACnB;;;;;;;;OAQG;IACH,IAAI,EAAE,SAAS,CAAC;CACjB;AA4ED;;;;;GAKG;AACH,MAAM,WAAW,iBACf,SAAQ,UAAU,CAAC,eAAe,EAAE,kBAAkB,EAAE,SAAS,EAAE,OAAO,gBAAgB,CAAC;IAC3F,mCAAmC;IACnC,aAAa,EAAE,aAAa,CAAC;IAC7B,wGAAwG;IACxG,UAAU,EAAE,iBAAiB,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CA0GvE;AAED,2GAA2G;AAC3G,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,IAAI,iBAAiB,CAE3F"}
@@ -10,6 +10,7 @@ import { makeResolveDeps } from "./http/resolve.js";
10
10
  import { supportAdminRoutes } from "./http/scopes.js";
11
11
  import { registerSupportRoutes } from "./http/routes.js";
12
12
  import { createSupportEmailHandler } from "./inbound/handler.js";
13
+ import { senderPeers } from "./link/peers.js";
13
14
  import { support_0001_threads } from "./migrations/0001_threads.js";
14
15
  import { supportExampleSeed } from "./seeds/example.js";
15
16
  import { PACKAGE_NAME, PACKAGE_VERSION } from "./version.generated.js";
@@ -123,6 +124,7 @@ function support(options = {}) {
123
124
  categories,
124
125
  snippets,
125
126
  enqueueEmail: void 0,
127
+ peers: {},
126
128
  emit: noopEmit
127
129
  };
128
130
  const migrations = { "0001_threads": support_0001_threads };
@@ -165,6 +167,7 @@ function support(options = {}) {
165
167
  compose: ({ capabilities }) => {
166
168
  const email = capabilities.find(isEmailCapability);
167
169
  wiring.enqueueEmail = email?.enqueue;
170
+ wiring.peers = senderPeers(capabilities, resolved);
168
171
  },
169
172
  client: () => clientProjection(resolved, mountPath),
170
173
  adminRoutes: supportAdminRoutes(mountPath),
@@ -7,6 +7,7 @@ import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
7
7
  import type { SupportConfig } from "../config/config";
8
8
  import type { SupportCategories } from "../data/categories";
9
9
  import type { SupportDatabase } from "../data/tables";
10
+ import { type SenderPeers } from "../link/sender";
10
11
  import { sendReply } from "../reply/send";
11
12
  import type { SupportReplySnippets } from "../reply/snippets";
12
13
  import type { SupportFlagsResponse, SupportMyThreadResponse, SupportMyThreadsResponse, SupportReclassifiedResponse, SupportReplySentResponse, SupportReplyView, SupportSubmissionResponse, SupportThreadResponse, SupportThreadsResponse, SupportThreadView } from "./responses";
@@ -40,6 +41,8 @@ export interface HandlerDeps {
40
41
  enqueue?: Parameters<typeof sendReply>[0]["enqueue"];
41
42
  /** Start a classification for a message. Resolves to whether an instance actually started. */
42
43
  dispatchClassify: (messageId: string) => Promise<boolean>;
44
+ /** The optional peers the sender link reads through, as `compose` found them. Empty when none is composed. */
45
+ peers: SenderPeers;
43
46
  /** The audit seam. */
44
47
  emit: AuditEmit;
45
48
  /** The request logger. */
@@ -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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"}
@@ -69,7 +69,7 @@ async function presentAttachments(deps, attachments) {
69
69
  /** Read one conversation in full. */
70
70
  async function readConversation(deps, threadId) {
71
71
  const detail = await readThread(deps.db, threadId);
72
- 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()) : resolveSenderContext(deps.d1, detail.thread.fromAddress, deps.now(), { authenticated: detail.thread.senderAuthenticated })]);
72
+ 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)]);
73
73
  return {
74
74
  thread: threadView(detail.thread),
75
75
  messages: detail.messages.map(messageView),
@@ -185,7 +185,7 @@ async function submitFeedbackRequest(deps, input, userId) {
185
185
  categories: deps.categories,
186
186
  bucket: deps.bucket,
187
187
  fts: deps.fts,
188
- resolveAccount: (id) => resolveSubmitterAccount(deps.d1, id),
188
+ resolveAccount: (id) => resolveSubmitterAccount(deps.d1, id, deps.peers),
189
189
  dispatchClassify: deps.dispatchClassify,
190
190
  emit: deps.emit,
191
191
  log: deps.log,
@@ -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,CA2BzG"}
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"}
@@ -63,6 +63,7 @@ function makeResolveDeps(wiring) {
63
63
  store: resolveStore(env),
64
64
  enqueue: wiring.enqueueEmail ? (input) => wiring.enqueueEmail?.(env, input) : void 0,
65
65
  dispatchClassify: makeClassifyDispatcher(env, log),
66
+ peers: wiring.peers,
66
67
  emit: c.var.emit,
67
68
  log,
68
69
  newId: () => crypto.randomUUID(),
@@ -59,7 +59,7 @@ function createSupportEmailHandler(wiring) {
59
59
  bucket: bindings.SUPPORT_BUCKET,
60
60
  fts: wiring.config.search.fts,
61
61
  dispatchClassify: makeClassifyDispatcher(env, log),
62
- linkSender: (address) => resolveSenderUserId(d1, address),
62
+ linkSender: (address) => resolveSenderUserId(d1, address, wiring.peers),
63
63
  emit: wiring.emit,
64
64
  log,
65
65
  newId: () => crypto.randomUUID(),
@@ -0,0 +1,14 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
4
+ import type { SupportConfig } from "../config/config";
5
+ import type { SenderPeers } from "./sender";
6
+ /**
7
+ * The peers a composition holds — what `support()`'s `compose` hook records — refusing one this Worker's config
8
+ * needs and cannot reach.
9
+ *
10
+ * With the in-app channel off, auth and payments may both be absent. A sender is then never linked to an account
11
+ * and a thread shows no purchases, which is exactly what a project with no accounts and no store has to show.
12
+ */
13
+ export declare function senderPeers(capabilities: readonly Capability[], config: SupportConfig): SenderPeers;
14
+ //# sourceMappingURL=peers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"peers.d.ts","sourceRoot":"","sources":["../../src/link/peers.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAG3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AA4C5C;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,EAAE,MAAM,EAAE,aAAa,GAAG,WAAW,CA2BnG"}
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
4
+ //#region src/link/peers.ts
5
+ /**
6
+ * The named kit capability's peer surface: `undefined` when it is not composed, or a refusal when it is composed
7
+ * from a release too old to carry one. Recognized by the config field it has always carried, not by the surface,
8
+ * because a pre-#645 release has the one and not the other.
9
+ */
10
+ function surface(capabilities, wanted) {
11
+ const found = capabilities.find((capability) => capability.name === wanted.name && wanted.config in capability);
12
+ if (found === void 0) return void 0;
13
+ const value = found[wanted.key];
14
+ if (typeof value?.[wanted.probe] !== "function") throw new ValidationError({
15
+ message: `The composed ${wanted.name} is too old for support to ${wanted.wants}.`,
16
+ action: `Upgrade ${wanted.pkg} to the version this @pithy-sh/support peers.`,
17
+ detail: `The composed ${wanted.name} capability carries no \`${wanted.key}\`. It was released before optional peers arrived through the composition (#645).`
18
+ });
19
+ return value;
20
+ }
21
+ /**
22
+ * The peers a composition holds — what `support()`'s `compose` hook records — refusing one this Worker's config
23
+ * needs and cannot reach.
24
+ *
25
+ * With the in-app channel off, auth and payments may both be absent. A sender is then never linked to an account
26
+ * and a thread shows no purchases, which is exactly what a project with no accounts and no store has to show.
27
+ */
28
+ function senderPeers(capabilities, config) {
29
+ const auth = surface(capabilities, {
30
+ name: "auth",
31
+ pkg: "@pithy-sh/auth",
32
+ config: "authConfig",
33
+ key: "authPeer",
34
+ probe: "authDatabase",
35
+ wants: "link a sender to an account"
36
+ });
37
+ const payments = surface(capabilities, {
38
+ name: "payments",
39
+ pkg: "@pithy-sh/payments",
40
+ config: "paymentsConfig",
41
+ key: "paymentsPeer",
42
+ probe: "resolveEntitlements",
43
+ wants: "show what a sender bought"
44
+ });
45
+ if (config.submission.enabled && auth === void 0) throw new ValidationError({
46
+ message: "No auth is composed in this Worker, and the in-app support channel needs it to know who is writing.",
47
+ action: "Compose `auth(...)` in this Worker, or turn the in-app channel off with `submission: { enabled: false }` for a mail-only inbox.",
48
+ detail: "`submission.enabled` defaults to true. A submission is keyed to the account its session proves, read through @pithy-sh/auth, so without auth in this Worker the channel refuses every report, and no sender on the mail path is ever linked to an account."
49
+ });
50
+ return {
51
+ ...auth ? { auth } : {},
52
+ ...payments ? { payments } : {}
53
+ };
54
+ }
55
+ //#endregion
56
+ export { senderPeers };
@@ -1,7 +1,9 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import type { D1Database } from "@cloudflare/workers-types";
4
+ import type { AuthPeer } from "@pithy-sh/auth/src/peer";
4
5
  import type { Entitlement } from "@pithy-sh/core/src/entitlement/entitlement";
6
+ import type { PaymentsPeer } from "@pithy-sh/payments/src/peer";
5
7
  /**
6
8
  * The link from an address in a `From` header to the customer the app already knows about.
7
9
  *
@@ -10,13 +12,18 @@ import type { Entitlement } from "@pithy-sh/core/src/entitlement/entitlement";
10
12
  * different object from a message in a mailbox — and every one of those facts is already in the
11
13
  * adopter's own D1, one table over, unused.
12
14
  *
13
- * ## Guarded dynamic imports, not dependencies
15
+ * ## Handed over by the composition, never imported
14
16
  *
15
17
  * `@pithy-sh/auth` and `@pithy-sh/payments` are **optional** here (principle 4: depend on core seams,
16
- * never on a sibling's internals), so both are reached the way `@pithy-sh/matchmaking` already
17
- * reaches auth — a dynamic import inside a `try`, degrading to "no link" when the package is not
18
- * installed. A support inbox in a project with no accounts and no payments is a perfectly reasonable
19
- * thing to run, and it must not fail to start, or to store mail, because of what it cannot see.
18
+ * never on a sibling's internals), so both reach this module from the composition: `support()`'s `compose`
19
+ * hook finds each one's peer surface among the composed capabilities and every function below is handed
20
+ * {@link SenderPeers}, degrading to "no link" when one is absent. They were reached by `import()` inside a
21
+ * `try` until #645, which is optional at runtime and required at bundle time — wrangler's esbuild resolves a
22
+ * literal specifier whether or not the branch holding it ever runs, so a project without either package
23
+ * could not deploy support at all. Only types name the two packages now, and a bundler never sees a type.
24
+ *
25
+ * A support inbox in a project with no accounts and no payments is a perfectly reasonable thing to run, and
26
+ * it must not fail to start, or to store mail, because of what it cannot see.
20
27
  *
21
28
  * Every function here is **best-effort by contract**: it returns nothing rather than throwing, and
22
29
  * the caller stores the message either way. Linkage is context, and context is never worth losing a
@@ -89,6 +96,16 @@ export interface SenderPurchase {
89
96
  /** When it was refunded or revoked; null while it stands. */
90
97
  revokedAt: Date | null;
91
98
  }
99
+ /**
100
+ * The optional peers the link reads through — what `support()`'s `compose` hook found. Either may be absent,
101
+ * and an absent one is "no link", never a failure.
102
+ */
103
+ export interface SenderPeers {
104
+ /** `auth()`'s surface, when auth is composed: who an address or a session id belongs to. */
105
+ readonly auth?: AuthPeer;
106
+ /** `payments()`'s surface, when payments is composed: what that account bought and holds. */
107
+ readonly payments?: PaymentsPeer;
108
+ }
92
109
  /** How many purchases a thread view carries. Enough to see the pattern, bounded so a whale is not a slow page. */
93
110
  export declare const MAX_LINKED_PURCHASES = 25;
94
111
  /**
@@ -102,14 +119,14 @@ export declare const MAX_LINKED_PURCHASES = 25;
102
119
  * capitals by some other route simply does not link, which costs a line of context rather than a
103
120
  * customer's request.
104
121
  */
105
- export declare function resolveSenderUserId(d1: D1Database, address: string): Promise<string | null>;
122
+ export declare function resolveSenderUserId(d1: D1Database, address: string, peers: SenderPeers): Promise<string | null>;
106
123
  /**
107
124
  * The address an operator's reply to an app thread will be sent to, or null when the account carries
108
125
  * nothing deliverable.
109
126
  *
110
127
  * Extracted rather than inlined because it is the one decision in {@link resolveSubmitterAccount} with
111
- * a wrong answer available, and inlining it puts that decision behind a guarded dynamic import where
112
- * no test can reach it.
128
+ * a wrong answer available, and inlining it puts that decision behind a peer read where no test can
129
+ * reach it.
113
130
  *
114
131
  * **`parseAddress` only, with no `normalizeAddress` fallback.** The fallback merely trims and
115
132
  * lowercases, so it would hand back an address this capability's own parser had just refused — and
@@ -127,11 +144,11 @@ export declare function submitterAddress(email: string | null | undefined): stri
127
144
  * back to. Deriving that from anything the client sent would hand a signed-in caller the ability to
128
145
  * point a support conversation — and every operator reply on it — at somebody else's mailbox.
129
146
  *
130
- * Returns null when `@pithy-sh/auth` is absent or the account is gone. A submission whose account
147
+ * Returns null when `@pithy-sh/auth` is not composed or the account is gone. A submission whose account
131
148
  * cannot be read is refused by the caller rather than stored with a guessed address: an app thread with
132
149
  * no working reply address is a report nobody can answer.
133
150
  */
134
- export declare function resolveSubmitterAccount(d1: D1Database, userId: string): Promise<{
151
+ export declare function resolveSubmitterAccount(d1: D1Database, userId: string, peers: SenderPeers): Promise<{
135
152
  email: string;
136
153
  name?: string;
137
154
  emailVerified?: boolean;
@@ -146,7 +163,7 @@ export declare function resolveSubmitterAccount(d1: D1Database, userId: string):
146
163
  */
147
164
  export declare function resolveSenderContext(d1: D1Database, address: string, now: Date, options: {
148
165
  authenticated: boolean;
149
- }): Promise<SenderContext>;
166
+ }, peers: SenderPeers): Promise<SenderContext>;
150
167
  /**
151
168
  * The customer context behind a **session-proven** link, resolved from the user id itself.
152
169
  *
@@ -156,7 +173,7 @@ export declare function resolveSenderContext(d1: D1Database, address: string, no
156
173
  * nobody — turning the one link that *is* certain into the one the console shows as unknown.
157
174
  *
158
175
  * Everything below the link degrades exactly as it does on the mail path: purchases and entitlements
159
- * are guarded dynamic imports and come back empty when `@pithy-sh/payments` is absent.
176
+ * come back empty when `@pithy-sh/payments` is not composed.
160
177
  */
161
- export declare function resolveSubmitterContext(d1: D1Database, userId: string, now: Date): Promise<SenderContext>;
178
+ export declare function resolveSubmitterContext(d1: D1Database, userId: string, now: Date, peers: SenderPeers): Promise<SenderContext>;
162
179
  //# sourceMappingURL=sender.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/link/sender.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4CAA4C,CAAC;AAG9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,aAAa,EAAE,OAAO,CAAC;IACvB,qGAAqG;IACrG,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6GAA6G;IAC7G,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,SAAS,EAAE,SAAS,cAAc,EAAE,CAAC;IACrC;;;OAGG;IACH,YAAY,EAAE,SAAS,WAAW,EAAE,CAAC;CACtC;AAED,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,6BAA6B;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,WAAW,EAAE,MAAM,CAAC;IACpB,0BAA0B;IAC1B,WAAW,EAAE,IAAI,CAAC;IAClB,0DAA0D;IAC1D,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,6DAA6D;IAC7D,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;CACxB;AAED,kHAAkH;AAClH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiBjG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAEhF;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAAC,CAiB3E;AA4ED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,UAAU,EACd,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,IAAI,EACT,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAA;CAAE,GAClC,OAAO,CAAC,aAAa,CAAC,CAwBxB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,CAI/G"}
1
+ {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/link/sender.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAExD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4CAA4C,CAAC;AAC9E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAGhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,aAAa,EAAE,OAAO,CAAC;IACvB,qGAAqG;IACrG,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6GAA6G;IAC7G,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,SAAS,EAAE,SAAS,cAAc,EAAE,CAAC;IACrC;;;OAGG;IACH,YAAY,EAAE,SAAS,WAAW,EAAE,CAAC;CACtC;AAED,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,6BAA6B;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,WAAW,EAAE,MAAM,CAAC;IACpB,0BAA0B;IAC1B,WAAW,EAAE,IAAI,CAAC;IAClB,0DAA0D;IAC1D,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,6DAA6D;IAC7D,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,4FAA4F;IAC5F,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC;IACzB,6FAA6F;IAC7F,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;CAClC;AAED,kHAAkH;AAClH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiBrH;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAEhF;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAAC,CAiB3E;AAsFD;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,UAAU,EACd,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,IAAI,EACT,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAA;CAAE,EACnC,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,aAAa,CAAC,CAwBxB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,IAAI,EACT,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,aAAa,CAAC,CAIxB"}
@@ -16,12 +16,11 @@ const MAX_LINKED_PURCHASES = 25;
16
16
  * capitals by some other route simply does not link, which costs a line of context rather than a
17
17
  * customer's request.
18
18
  */
19
- async function resolveSenderUserId(d1, address) {
19
+ async function resolveSenderUserId(d1, address, peers) {
20
20
  const normalized = parseAddress(address);
21
- if (!normalized) return null;
21
+ if (!normalized || !peers.auth) return null;
22
22
  try {
23
- const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
24
- return (await authDatabase(d1).selectFrom("pithyAuthUsers").select(["id"]).where("email", "=", normalized).executeTakeFirst())?.id ?? null;
23
+ return (await peers.auth.authDatabase(d1).selectFrom("pithyAuthUsers").select(["id"]).where("email", "=", normalized).executeTakeFirst())?.id ?? null;
25
24
  } catch {
26
25
  return null;
27
26
  }
@@ -31,8 +30,8 @@ async function resolveSenderUserId(d1, address) {
31
30
  * nothing deliverable.
32
31
  *
33
32
  * Extracted rather than inlined because it is the one decision in {@link resolveSubmitterAccount} with
34
- * a wrong answer available, and inlining it puts that decision behind a guarded dynamic import where
35
- * no test can reach it.
33
+ * a wrong answer available, and inlining it puts that decision behind a peer read where no test can
34
+ * reach it.
36
35
  *
37
36
  * **`parseAddress` only, with no `normalizeAddress` fallback.** The fallback merely trims and
38
37
  * lowercases, so it would hand back an address this capability's own parser had just refused — and
@@ -52,14 +51,14 @@ function submitterAddress(email) {
52
51
  * back to. Deriving that from anything the client sent would hand a signed-in caller the ability to
53
52
  * point a support conversation — and every operator reply on it — at somebody else's mailbox.
54
53
  *
55
- * Returns null when `@pithy-sh/auth` is absent or the account is gone. A submission whose account
54
+ * Returns null when `@pithy-sh/auth` is not composed or the account is gone. A submission whose account
56
55
  * cannot be read is refused by the caller rather than stored with a guessed address: an app thread with
57
56
  * no working reply address is a report nobody can answer.
58
57
  */
59
- async function resolveSubmitterAccount(d1, userId) {
58
+ async function resolveSubmitterAccount(d1, userId, peers) {
59
+ if (!peers.auth) return null;
60
+ const { authDatabase, User } = peers.auth;
60
61
  try {
61
- const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
62
- const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
63
62
  const row = await authDatabase(d1).selectFrom("pithyAuthUsers").selectAll().where("id", "=", userId).executeTakeFirst();
64
63
  if (!row) return null;
65
64
  const user = User.parse(row);
@@ -75,10 +74,10 @@ async function resolveSubmitterAccount(d1, userId) {
75
74
  }
76
75
  }
77
76
  /** Read the account's own fields, when auth is composed. */
78
- async function resolveAccount(d1, address) {
77
+ async function resolveAccount(d1, address, peers) {
78
+ if (!peers.auth) return null;
79
+ const { authDatabase, User } = peers.auth;
79
80
  try {
80
- const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
81
- const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
82
81
  const row = await authDatabase(d1).selectFrom("pithyAuthUsers").selectAll().where("email", "=", address).executeTakeFirst();
83
82
  if (!row) return null;
84
83
  const user = User.parse(row);
@@ -99,10 +98,10 @@ async function resolveAccount(d1, address) {
99
98
  * is the whole reason payments made the holder a pair. See {@link SUPPORT_BILLING_SCOPE} for why the
100
99
  * type half is pinned to `user` rather than resolved.
101
100
  */
102
- async function resolvePurchases(d1, userId) {
101
+ async function resolvePurchases(d1, userId, peers) {
102
+ if (!peers.payments) return [];
103
+ const { PAYMENTS_PURCHASES_TABLE, paymentsDatabase, PaymentsPurchase } = peers.payments;
103
104
  try {
104
- const { PAYMENTS_PURCHASES_TABLE, paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
105
- const { PaymentsPurchase } = await import("@pithy-sh/payments/src/data/purchase");
106
105
  return (await paymentsDatabase(d1).selectFrom(PAYMENTS_PURCHASES_TABLE).selectAll().where("subjectType", "=", SUPPORT_BILLING_SCOPE).where("subjectId", "=", userId).orderBy("purchasedAt", "desc").limit(25).execute()).map((row) => {
107
106
  const purchase = PaymentsPurchase.parse(row);
108
107
  return {
@@ -121,10 +120,10 @@ async function resolvePurchases(d1, userId) {
121
120
  }
122
121
  }
123
122
  /** Read what the account is entitled to, when payments is composed — **`user`-subject rows only**. */
124
- async function resolveLinkedEntitlements(d1, userId, now) {
123
+ async function resolveLinkedEntitlements(d1, userId, now, peers) {
124
+ if (!peers.payments) return [];
125
+ const { paymentsDatabase, resolveEntitlements } = peers.payments;
125
126
  try {
126
- const { paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
127
- const { resolveEntitlements } = await import("@pithy-sh/payments/src/projection/resolve");
128
127
  return await resolveEntitlements(paymentsDatabase(d1), {
129
128
  subjectType: SUPPORT_BILLING_SCOPE,
130
129
  subjectId: userId
@@ -141,7 +140,7 @@ async function resolveLinkedEntitlements(d1, userId, now) {
141
140
  * That is the same rule the classification follows, applied to the half of the model that lives in
142
141
  * somebody else's tables.
143
142
  */
144
- async function resolveSenderContext(d1, address, now, options) {
143
+ async function resolveSenderContext(d1, address, now, options, peers) {
145
144
  const empty = {
146
145
  authenticated: options.authenticated,
147
146
  userId: null,
@@ -150,7 +149,7 @@ async function resolveSenderContext(d1, address, now, options) {
150
149
  };
151
150
  const normalized = parseAddress(address);
152
151
  if (!normalized) return empty;
153
- const account = await resolveAccount(d1, normalized);
152
+ const account = await resolveAccount(d1, normalized, peers);
154
153
  if (!account) return empty;
155
154
  if (!options.authenticated) return {
156
155
  authenticated: false,
@@ -159,7 +158,7 @@ async function resolveSenderContext(d1, address, now, options) {
159
158
  purchases: [],
160
159
  entitlements: []
161
160
  };
162
- return provenContext(d1, account, now);
161
+ return provenContext(d1, account, now, peers);
163
162
  }
164
163
  /**
165
164
  * The customer context behind a **session-proven** link, resolved from the user id itself.
@@ -170,10 +169,10 @@ async function resolveSenderContext(d1, address, now, options) {
170
169
  * nobody — turning the one link that *is* certain into the one the console shows as unknown.
171
170
  *
172
171
  * Everything below the link degrades exactly as it does on the mail path: purchases and entitlements
173
- * are guarded dynamic imports and come back empty when `@pithy-sh/payments` is absent.
172
+ * come back empty when `@pithy-sh/payments` is not composed.
174
173
  */
175
- async function resolveSubmitterContext(d1, userId, now) {
176
- const account = await resolveSubmitterAccount(d1, userId);
174
+ async function resolveSubmitterContext(d1, userId, now, peers) {
175
+ const account = await resolveSubmitterAccount(d1, userId, peers);
177
176
  if (!account) return {
178
177
  authenticated: true,
179
178
  userId,
@@ -184,11 +183,11 @@ async function resolveSubmitterContext(d1, userId, now) {
184
183
  userId,
185
184
  name: account.name,
186
185
  emailVerified: account.emailVerified
187
- }, now);
186
+ }, now, peers);
188
187
  }
189
188
  /** The proven half, shared by both entry points: the account, plus what it bought and what it holds. */
190
- async function provenContext(d1, account, now) {
191
- const [purchases, entitlements] = await Promise.all([resolvePurchases(d1, account.userId), resolveLinkedEntitlements(d1, account.userId, now)]);
189
+ async function provenContext(d1, account, now, peers) {
190
+ const [purchases, entitlements] = await Promise.all([resolvePurchases(d1, account.userId, peers), resolveLinkedEntitlements(d1, account.userId, now, peers)]);
192
191
  return {
193
192
  authenticated: true,
194
193
  userId: account.userId,
@@ -3,5 +3,5 @@
3
3
  /** This package's npm name — the join key against a release feed. */
4
4
  export declare const PACKAGE_NAME = "@pithy-sh/support";
5
5
  /** This package's version, stamped from its own package.json at generation time. */
6
- export declare const PACKAGE_VERSION = "0.2.9";
6
+ export declare const PACKAGE_VERSION = "0.3.0";
7
7
  //# sourceMappingURL=version.generated.d.ts.map
@@ -4,6 +4,6 @@
4
4
  /** This package's npm name — the join key against a release feed. */
5
5
  const PACKAGE_NAME = "@pithy-sh/support";
6
6
  /** This package's version, stamped from its own package.json at generation time. */
7
- const PACKAGE_VERSION = "0.2.9";
7
+ const PACKAGE_VERSION = "0.3.0";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/support",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,10 +40,10 @@
40
40
  "tldts": "^7.4.10"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/auth": "^0.6.3",
43
+ "@pithy-sh/auth": "^0.6.4",
44
44
  "@pithy-sh/core": "^0.7.4",
45
45
  "@pithy-sh/email": "^0.3.8",
46
- "@pithy-sh/payments": "^0.3.6",
46
+ "@pithy-sh/payments": "^0.3.7",
47
47
  "@pithy-sh/secrets": "^0.2.7",
48
48
  "@pithy-sh/storage": "^0.2.7",
49
49
  "hono": "^4.13.2",
package/src/capability.ts CHANGED
@@ -24,6 +24,8 @@ import { makeResolveDeps } from "./http/resolve";
24
24
  import { registerSupportRoutes } from "./http/routes";
25
25
  import { supportAdminRoutes } from "./http/scopes";
26
26
  import { createSupportEmailHandler } from "./inbound/handler";
27
+ import { senderPeers } from "./link/peers";
28
+ import type { SenderPeers } from "./link/sender";
27
29
  import { support_0001_threads } from "./migrations/0001_threads";
28
30
  import { resolveReplies, type SupportReplySnippets } from "./reply/snippets";
29
31
  import { inapplicableAttachmentSecrets, SUPPORT_BUCKET_BINDING, supportSecretsRegistry } from "./secret/registry";
@@ -67,6 +69,11 @@ export interface SupportWiring {
67
69
  * so with `support/reply_failed` rather than the capability refusing to start.
68
70
  */
69
71
  enqueueEmail: EmailCapability["enqueue"] | undefined;
72
+ /**
73
+ * The optional peers the sender link reads through — `auth()`'s and `payments()`'s surfaces, set by
74
+ * `compose` when each is composed. Found, never imported (#645): see `link/sender.ts`.
75
+ */
76
+ peers: SenderPeers;
70
77
  /**
71
78
  * The audit seam the inbound handler emits through.
72
79
  *
@@ -202,6 +209,7 @@ export function support(options: SupportOptions = {}): SupportCapability {
202
209
  categories,
203
210
  snippets,
204
211
  enqueueEmail: undefined,
212
+ peers: {},
205
213
  emit: noopEmit,
206
214
  };
207
215
 
@@ -271,6 +279,9 @@ export function support(options: SupportOptions = {}): SupportCapability {
271
279
  compose: ({ capabilities }) => {
272
280
  const email = capabilities.find(isEmailCapability);
273
281
  wiring.enqueueEmail = email?.enqueue;
282
+ // Auth and payments, found rather than imported (#645), and refused here when this Worker's config needs
283
+ // one it cannot reach. See `link/peers.ts`.
284
+ wiring.peers = senderPeers(capabilities, resolved);
274
285
  },
275
286
  // What a browser may know. Built from the resolved `mountPath`, never the default — the whole
276
287
  // point is that moving the mount moves the address the client posts to. See `clientProjection`
@@ -13,7 +13,12 @@ import type { SupportAttachment } from "../data/attachment";
13
13
  import type { SupportCategories } from "../data/categories";
14
14
  import type { SupportDatabase } from "../data/tables";
15
15
  import { SupportClassificationError, SupportNotFoundError } from "../error/errors";
16
- import { resolveSenderContext, resolveSubmitterAccount, resolveSubmitterContext } from "../link/sender";
16
+ import {
17
+ resolveSenderContext,
18
+ resolveSubmitterAccount,
19
+ resolveSubmitterContext,
20
+ type SenderPeers,
21
+ } from "../link/sender";
17
22
  import { sendReply } from "../reply/send";
18
23
  import type { SupportReplySnippets } from "../reply/snippets";
19
24
  import { repliesForCategory } from "../reply/snippets";
@@ -75,6 +80,8 @@ export interface HandlerDeps {
75
80
  enqueue?: Parameters<typeof sendReply>[0]["enqueue"];
76
81
  /** Start a classification for a message. Resolves to whether an instance actually started. */
77
82
  dispatchClassify: (messageId: string) => Promise<boolean>;
83
+ /** The optional peers the sender link reads through, as `compose` found them. Empty when none is composed. */
84
+ peers: SenderPeers;
78
85
  /** The audit seam. */
79
86
  emit: AuditEmit;
80
87
  /** The request logger. */
@@ -155,10 +162,14 @@ export async function readConversation(deps: HandlerDeps, threadId: string): Pro
155
162
  // the `email` column exactly, so an account stored with capitals by some other route would resolve
156
163
  // to nobody — turning the one link that *is* certain into the one the console shows as unknown.
157
164
  detail.thread.accountLinkSource === "session" && detail.thread.userId
158
- ? resolveSubmitterContext(deps.d1, detail.thread.userId, deps.now())
159
- : resolveSenderContext(deps.d1, detail.thread.fromAddress, deps.now(), {
160
- authenticated: detail.thread.senderAuthenticated,
161
- }),
165
+ ? resolveSubmitterContext(deps.d1, detail.thread.userId, deps.now(), deps.peers)
166
+ : resolveSenderContext(
167
+ deps.d1,
168
+ detail.thread.fromAddress,
169
+ deps.now(),
170
+ { authenticated: detail.thread.senderAuthenticated },
171
+ deps.peers,
172
+ ),
162
173
  ]);
163
174
 
164
175
  return {
@@ -343,7 +354,7 @@ export async function submitFeedbackRequest(
343
354
  categories: deps.categories,
344
355
  bucket: deps.bucket,
345
356
  fts: deps.fts,
346
- resolveAccount: (id) => resolveSubmitterAccount(deps.d1, id),
357
+ resolveAccount: (id) => resolveSubmitterAccount(deps.d1, id, deps.peers),
347
358
  dispatchClassify: deps.dispatchClassify,
348
359
  emit: deps.emit,
349
360
  log: deps.log,
@@ -100,6 +100,9 @@ export function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv
100
100
  ? (input) => wiring.enqueueEmail?.(env as never, input) as Promise<{ jobId: string }>
101
101
  : undefined,
102
102
  dispatchClassify: makeClassifyDispatcher(env as unknown as Record<string, unknown>, log),
103
+ // What `compose` found — auth and payments, each only when composed. Read per request, because
104
+ // `compose` runs after the factory built this closure.
105
+ peers: wiring.peers,
103
106
  emit: c.var.emit,
104
107
  log,
105
108
  newId: () => crypto.randomUUID(),
@@ -76,7 +76,7 @@ export function createSupportEmailHandler(wiring: SupportWiring): CapabilityEmai
76
76
  bucket: bindings.SUPPORT_BUCKET,
77
77
  fts: wiring.config.search.fts,
78
78
  dispatchClassify: makeClassifyDispatcher(env, log),
79
- linkSender: (address) => resolveSenderUserId(d1, address),
79
+ linkSender: (address) => resolveSenderUserId(d1, address, wiring.peers),
80
80
  emit: wiring.emit,
81
81
  log,
82
82
  newId: () => crypto.randomUUID(),
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuthPeer } from "@pithy-sh/auth/src/peer";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { PaymentsPeer } from "@pithy-sh/payments/src/peer";
8
+ import type { SupportConfig } from "../config/config";
9
+ import type { SenderPeers } from "./sender";
10
+
11
+ /**
12
+ * How `support()`'s `compose` hook finds the sender link's optional peers (#645). Its own module so
13
+ * `sender.ts` stays free of the capability contract, which a browser program has no use for.
14
+ *
15
+ * **Nothing degrades silently (#645 review).** A composed auth or payments released before its surface is
16
+ * refused, whatever the config: read as absent, a project that plainly has accounts stopped linking every sender,
17
+ * and one that plainly sells stopped showing an operator what anybody bought. The in-app channel, on by default,
18
+ * is refused without auth in this Worker: every submission is keyed to the account a session proves, and the
19
+ * account is read through auth, so the channel could not accept one report. Turning it off is the adopter's
20
+ * statement that this Worker is a mail inbox, and then an absent auth or payments proceeds — see
21
+ * {@link senderPeers}.
22
+ */
23
+
24
+ /** One optional peer, as `senderPeers` looks for it. */
25
+ interface Wanted {
26
+ readonly name: string;
27
+ readonly pkg: string;
28
+ readonly config: string;
29
+ readonly key: string;
30
+ readonly probe: string;
31
+ readonly wants: string;
32
+ }
33
+
34
+ /**
35
+ * The named kit capability's peer surface: `undefined` when it is not composed, or a refusal when it is composed
36
+ * from a release too old to carry one. Recognized by the config field it has always carried, not by the surface,
37
+ * because a pre-#645 release has the one and not the other.
38
+ */
39
+ function surface<T>(capabilities: readonly Capability[], wanted: Wanted): T | undefined {
40
+ const found = capabilities.find((capability) => capability.name === wanted.name && wanted.config in capability);
41
+ if (found === undefined) return undefined;
42
+ const value = (found as unknown as Record<string, Record<string, unknown> | undefined>)[wanted.key];
43
+ if (typeof value?.[wanted.probe] !== "function") {
44
+ throw new ValidationError({
45
+ message: `The composed ${wanted.name} is too old for support to ${wanted.wants}.`,
46
+ action: `Upgrade ${wanted.pkg} to the version this @pithy-sh/support peers.`,
47
+ detail: `The composed ${wanted.name} capability carries no \`${wanted.key}\`. It was released before optional peers arrived through the composition (#645).`,
48
+ });
49
+ }
50
+ return value as T;
51
+ }
52
+
53
+ /**
54
+ * The peers a composition holds — what `support()`'s `compose` hook records — refusing one this Worker's config
55
+ * needs and cannot reach.
56
+ *
57
+ * With the in-app channel off, auth and payments may both be absent. A sender is then never linked to an account
58
+ * and a thread shows no purchases, which is exactly what a project with no accounts and no store has to show.
59
+ */
60
+ export function senderPeers(capabilities: readonly Capability[], config: SupportConfig): SenderPeers {
61
+ const auth = surface<AuthPeer>(capabilities, {
62
+ name: "auth",
63
+ pkg: "@pithy-sh/auth",
64
+ config: "authConfig",
65
+ key: "authPeer",
66
+ probe: "authDatabase",
67
+ wants: "link a sender to an account",
68
+ });
69
+ const payments = surface<PaymentsPeer>(capabilities, {
70
+ name: "payments",
71
+ pkg: "@pithy-sh/payments",
72
+ config: "paymentsConfig",
73
+ key: "paymentsPeer",
74
+ probe: "resolveEntitlements",
75
+ wants: "show what a sender bought",
76
+ });
77
+ if (config.submission.enabled && auth === undefined) {
78
+ throw new ValidationError({
79
+ message: "No auth is composed in this Worker, and the in-app support channel needs it to know who is writing.",
80
+ action:
81
+ "Compose `auth(...)` in this Worker, or turn the in-app channel off with `submission: { enabled: false }` for a mail-only inbox.",
82
+ detail:
83
+ "`submission.enabled` defaults to true. A submission is keyed to the account its session proves, read through @pithy-sh/auth, so without auth in this Worker the channel refuses every report, and no sender on the mail path is ever linked to an account.",
84
+ });
85
+ }
86
+ return { ...(auth ? { auth } : {}), ...(payments ? { payments } : {}) };
87
+ }
@@ -2,8 +2,10 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { AuthPeer } from "@pithy-sh/auth/src/peer";
5
6
  import { parseAddress } from "@pithy-sh/core/src/address/address";
6
7
  import type { Entitlement } from "@pithy-sh/core/src/entitlement/entitlement";
8
+ import type { PaymentsPeer } from "@pithy-sh/payments/src/peer";
7
9
  import { SUPPORT_BILLING_SCOPE } from "../data/billingScope";
8
10
 
9
11
  /**
@@ -14,13 +16,18 @@ import { SUPPORT_BILLING_SCOPE } from "../data/billingScope";
14
16
  * different object from a message in a mailbox — and every one of those facts is already in the
15
17
  * adopter's own D1, one table over, unused.
16
18
  *
17
- * ## Guarded dynamic imports, not dependencies
19
+ * ## Handed over by the composition, never imported
18
20
  *
19
21
  * `@pithy-sh/auth` and `@pithy-sh/payments` are **optional** here (principle 4: depend on core seams,
20
- * never on a sibling's internals), so both are reached the way `@pithy-sh/matchmaking` already
21
- * reaches auth — a dynamic import inside a `try`, degrading to "no link" when the package is not
22
- * installed. A support inbox in a project with no accounts and no payments is a perfectly reasonable
23
- * thing to run, and it must not fail to start, or to store mail, because of what it cannot see.
22
+ * never on a sibling's internals), so both reach this module from the composition: `support()`'s `compose`
23
+ * hook finds each one's peer surface among the composed capabilities and every function below is handed
24
+ * {@link SenderPeers}, degrading to "no link" when one is absent. They were reached by `import()` inside a
25
+ * `try` until #645, which is optional at runtime and required at bundle time — wrangler's esbuild resolves a
26
+ * literal specifier whether or not the branch holding it ever runs, so a project without either package
27
+ * could not deploy support at all. Only types name the two packages now, and a bundler never sees a type.
28
+ *
29
+ * A support inbox in a project with no accounts and no payments is a perfectly reasonable thing to run, and
30
+ * it must not fail to start, or to store mail, because of what it cannot see.
24
31
  *
25
32
  * Every function here is **best-effort by contract**: it returns nothing rather than throwing, and
26
33
  * the caller stores the message either way. Linkage is context, and context is never worth losing a
@@ -96,6 +103,17 @@ export interface SenderPurchase {
96
103
  revokedAt: Date | null;
97
104
  }
98
105
 
106
+ /**
107
+ * The optional peers the link reads through — what `support()`'s `compose` hook found. Either may be absent,
108
+ * and an absent one is "no link", never a failure.
109
+ */
110
+ export interface SenderPeers {
111
+ /** `auth()`'s surface, when auth is composed: who an address or a session id belongs to. */
112
+ readonly auth?: AuthPeer;
113
+ /** `payments()`'s surface, when payments is composed: what that account bought and holds. */
114
+ readonly payments?: PaymentsPeer;
115
+ }
116
+
99
117
  /** How many purchases a thread view carries. Enough to see the pattern, bounded so a whale is not a slow page. */
100
118
  export const MAX_LINKED_PURCHASES = 25;
101
119
 
@@ -110,20 +128,20 @@ export const MAX_LINKED_PURCHASES = 25;
110
128
  * capitals by some other route simply does not link, which costs a line of context rather than a
111
129
  * customer's request.
112
130
  */
113
- export async function resolveSenderUserId(d1: D1Database, address: string): Promise<string | null> {
131
+ export async function resolveSenderUserId(d1: D1Database, address: string, peers: SenderPeers): Promise<string | null> {
114
132
  const normalized = parseAddress(address);
115
- if (!normalized) return null;
133
+ if (!normalized || !peers.auth) return null;
116
134
 
117
135
  try {
118
- const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
119
- const row = await authDatabase(d1)
136
+ const row = await peers.auth
137
+ .authDatabase(d1)
120
138
  .selectFrom("pithyAuthUsers")
121
139
  .select(["id"])
122
140
  .where("email", "=", normalized)
123
141
  .executeTakeFirst();
124
142
  return row?.id ?? null;
125
143
  } catch {
126
- // `@pithy-sh/auth` is not installed, or the table does not exist yet. Both mean the same thing
144
+ // The auth table does not exist yet — composed but not migrated. It means what an absent auth means
127
145
  // here: nobody to link to.
128
146
  return null;
129
147
  }
@@ -134,8 +152,8 @@ export async function resolveSenderUserId(d1: D1Database, address: string): Prom
134
152
  * nothing deliverable.
135
153
  *
136
154
  * Extracted rather than inlined because it is the one decision in {@link resolveSubmitterAccount} with
137
- * a wrong answer available, and inlining it puts that decision behind a guarded dynamic import where
138
- * no test can reach it.
155
+ * a wrong answer available, and inlining it puts that decision behind a peer read where no test can
156
+ * reach it.
139
157
  *
140
158
  * **`parseAddress` only, with no `normalizeAddress` fallback.** The fallback merely trims and
141
159
  * lowercases, so it would hand back an address this capability's own parser had just refused — and
@@ -156,17 +174,18 @@ export function submitterAddress(email: string | null | undefined): string | nul
156
174
  * back to. Deriving that from anything the client sent would hand a signed-in caller the ability to
157
175
  * point a support conversation — and every operator reply on it — at somebody else's mailbox.
158
176
  *
159
- * Returns null when `@pithy-sh/auth` is absent or the account is gone. A submission whose account
177
+ * Returns null when `@pithy-sh/auth` is not composed or the account is gone. A submission whose account
160
178
  * cannot be read is refused by the caller rather than stored with a guessed address: an app thread with
161
179
  * no working reply address is a report nobody can answer.
162
180
  */
163
181
  export async function resolveSubmitterAccount(
164
182
  d1: D1Database,
165
183
  userId: string,
184
+ peers: SenderPeers,
166
185
  ): Promise<{ email: string; name?: string; emailVerified?: boolean } | null> {
186
+ if (!peers.auth) return null;
187
+ const { authDatabase, User } = peers.auth;
167
188
  try {
168
- const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
169
- const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
170
189
  const row = await authDatabase(d1)
171
190
  .selectFrom("pithyAuthUsers")
172
191
  .selectAll()
@@ -186,10 +205,11 @@ export async function resolveSubmitterAccount(
186
205
  async function resolveAccount(
187
206
  d1: D1Database,
188
207
  address: string,
208
+ peers: SenderPeers,
189
209
  ): Promise<{ userId: string; name?: string; emailVerified?: boolean } | null> {
210
+ if (!peers.auth) return null;
211
+ const { authDatabase, User } = peers.auth;
190
212
  try {
191
- const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
192
- const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
193
213
  const row = await authDatabase(d1)
194
214
  .selectFrom("pithyAuthUsers")
195
215
  .selectAll()
@@ -211,10 +231,14 @@ async function resolveAccount(
211
231
  * is the whole reason payments made the holder a pair. See {@link SUPPORT_BILLING_SCOPE} for why the
212
232
  * type half is pinned to `user` rather than resolved.
213
233
  */
214
- async function resolvePurchases(d1: D1Database, userId: string): Promise<readonly SenderPurchase[]> {
234
+ async function resolvePurchases(
235
+ d1: D1Database,
236
+ userId: string,
237
+ peers: SenderPeers,
238
+ ): Promise<readonly SenderPurchase[]> {
239
+ if (!peers.payments) return [];
240
+ const { PAYMENTS_PURCHASES_TABLE, paymentsDatabase, PaymentsPurchase } = peers.payments;
215
241
  try {
216
- const { PAYMENTS_PURCHASES_TABLE, paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
217
- const { PaymentsPurchase } = await import("@pithy-sh/payments/src/data/purchase");
218
242
  const rows = await paymentsDatabase(d1)
219
243
  .selectFrom(PAYMENTS_PURCHASES_TABLE)
220
244
  .selectAll()
@@ -242,10 +266,15 @@ async function resolvePurchases(d1: D1Database, userId: string): Promise<readonl
242
266
  }
243
267
 
244
268
  /** Read what the account is entitled to, when payments is composed — **`user`-subject rows only**. */
245
- async function resolveLinkedEntitlements(d1: D1Database, userId: string, now: Date): Promise<readonly Entitlement[]> {
269
+ async function resolveLinkedEntitlements(
270
+ d1: D1Database,
271
+ userId: string,
272
+ now: Date,
273
+ peers: SenderPeers,
274
+ ): Promise<readonly Entitlement[]> {
275
+ if (!peers.payments) return [];
276
+ const { paymentsDatabase, resolveEntitlements } = peers.payments;
246
277
  try {
247
- const { paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
248
- const { resolveEntitlements } = await import("@pithy-sh/payments/src/projection/resolve");
249
278
  return await resolveEntitlements(
250
279
  paymentsDatabase(d1),
251
280
  { subjectType: SUPPORT_BILLING_SCOPE, subjectId: userId },
@@ -269,13 +298,14 @@ export async function resolveSenderContext(
269
298
  address: string,
270
299
  now: Date,
271
300
  options: { authenticated: boolean },
301
+ peers: SenderPeers,
272
302
  ): Promise<SenderContext> {
273
303
  const empty: SenderContext = { authenticated: options.authenticated, userId: null, purchases: [], entitlements: [] };
274
304
 
275
305
  const normalized = parseAddress(address);
276
306
  if (!normalized) return empty;
277
307
 
278
- const account = await resolveAccount(d1, normalized);
308
+ const account = await resolveAccount(d1, normalized, peers);
279
309
  if (!account) return empty;
280
310
 
281
311
  // **An unverified match is reported; its billing history is not.**
@@ -292,7 +322,7 @@ export async function resolveSenderContext(
292
322
  return { authenticated: false, userId: account.userId, name: account.name, purchases: [], entitlements: [] };
293
323
  }
294
324
 
295
- return provenContext(d1, account, now);
325
+ return provenContext(d1, account, now, peers);
296
326
  }
297
327
 
298
328
  /**
@@ -304,12 +334,17 @@ export async function resolveSenderContext(
304
334
  * nobody — turning the one link that *is* certain into the one the console shows as unknown.
305
335
  *
306
336
  * Everything below the link degrades exactly as it does on the mail path: purchases and entitlements
307
- * are guarded dynamic imports and come back empty when `@pithy-sh/payments` is absent.
337
+ * come back empty when `@pithy-sh/payments` is not composed.
308
338
  */
309
- export async function resolveSubmitterContext(d1: D1Database, userId: string, now: Date): Promise<SenderContext> {
310
- const account = await resolveSubmitterAccount(d1, userId);
339
+ export async function resolveSubmitterContext(
340
+ d1: D1Database,
341
+ userId: string,
342
+ now: Date,
343
+ peers: SenderPeers,
344
+ ): Promise<SenderContext> {
345
+ const account = await resolveSubmitterAccount(d1, userId, peers);
311
346
  if (!account) return { authenticated: true, userId, purchases: [], entitlements: [] };
312
- return provenContext(d1, { userId, name: account.name, emailVerified: account.emailVerified }, now);
347
+ return provenContext(d1, { userId, name: account.name, emailVerified: account.emailVerified }, now, peers);
313
348
  }
314
349
 
315
350
  /** The proven half, shared by both entry points: the account, plus what it bought and what it holds. */
@@ -317,10 +352,11 @@ async function provenContext(
317
352
  d1: D1Database,
318
353
  account: { userId: string; name?: string; emailVerified?: boolean },
319
354
  now: Date,
355
+ peers: SenderPeers,
320
356
  ): Promise<SenderContext> {
321
357
  const [purchases, entitlements] = await Promise.all([
322
- resolvePurchases(d1, account.userId),
323
- resolveLinkedEntitlements(d1, account.userId, now),
358
+ resolvePurchases(d1, account.userId, peers),
359
+ resolveLinkedEntitlements(d1, account.userId, now, peers),
324
360
  ]);
325
361
 
326
362
  return {
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/support";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.2.9";
16
+ export const PACKAGE_VERSION = "0.3.0";