@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.
- package/dist/capability.d.ts +6 -0
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +3 -0
- package/dist/http/handlers.d.ts +3 -0
- package/dist/http/handlers.d.ts.map +1 -1
- package/dist/http/handlers.js +2 -2
- package/dist/http/resolve.d.ts.map +1 -1
- package/dist/http/resolve.js +1 -0
- package/dist/inbound/handler.js +1 -1
- package/dist/link/peers.d.ts +14 -0
- package/dist/link/peers.d.ts.map +1 -0
- package/dist/link/peers.js +56 -0
- package/dist/link/sender.d.ts +30 -13
- package/dist/link/sender.d.ts.map +1 -1
- package/dist/link/sender.js +27 -28
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +3 -3
- package/src/capability.ts +11 -0
- package/src/http/handlers.ts +17 -6
- package/src/http/resolve.ts +3 -0
- package/src/inbound/handler.ts +1 -1
- package/src/link/peers.ts +87 -0
- package/src/link/sender.ts +67 -31
- package/src/version.generated.ts +1 -1
package/dist/capability.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/capability.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/capability.js
CHANGED
|
@@ -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),
|
package/dist/http/handlers.d.ts
CHANGED
|
@@ -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":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAEnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AAGtE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/http/handlers.ts"],"names":[],"mappings":"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"}
|
package/dist/http/handlers.js
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/http/resolve.js
CHANGED
|
@@ -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(),
|
package/dist/inbound/handler.js
CHANGED
|
@@ -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 };
|
package/dist/link/sender.d.ts
CHANGED
|
@@ -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
|
-
* ##
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
112
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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;
|
|
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"}
|
package/dist/link/sender.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
35
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`
|
package/src/http/handlers.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
160
|
-
|
|
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,
|
package/src/http/resolve.ts
CHANGED
|
@@ -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(),
|
package/src/inbound/handler.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/link/sender.ts
CHANGED
|
@@ -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
|
-
* ##
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
119
|
-
|
|
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
|
-
//
|
|
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
|
|
138
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
337
|
+
* come back empty when `@pithy-sh/payments` is not composed.
|
|
308
338
|
*/
|
|
309
|
-
export async function resolveSubmitterContext(
|
|
310
|
-
|
|
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 {
|
package/src/version.generated.ts
CHANGED