@pithy-sh/support 0.1.2 → 0.1.3
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/ai/classify.d.ts +90 -0
- package/dist/ai/classify.d.ts.map +1 -0
- package/dist/ai/classify.js +151 -0
- package/dist/attachment/store.d.ts +53 -0
- package/dist/attachment/store.d.ts.map +1 -0
- package/dist/attachment/store.js +67 -0
- package/dist/audit/actions.d.ts +68 -0
- package/dist/audit/actions.d.ts.map +1 -0
- package/dist/audit/actions.js +68 -0
- package/dist/capability.d.ts +93 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +187 -0
- package/dist/client/projection.d.ts +56 -0
- package/dist/client/projection.d.ts.map +1 -0
- package/dist/client/projection.js +0 -0
- package/dist/config/config.d.ts +215 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +169 -0
- package/dist/data/attachment.d.ts +30 -0
- package/dist/data/attachment.d.ts.map +1 -0
- package/dist/data/attachment.js +32 -0
- package/dist/data/billingScope.d.ts +30 -0
- package/dist/data/billingScope.d.ts.map +1 -0
- package/dist/data/billingScope.js +32 -0
- package/dist/data/categories.d.ts +54 -0
- package/dist/data/categories.d.ts.map +1 -0
- package/dist/data/categories.js +97 -0
- package/dist/data/classification.d.ts +34 -0
- package/dist/data/classification.d.ts.map +1 -0
- package/dist/data/classification.js +28 -0
- package/dist/data/enums.d.ts +82 -0
- package/dist/data/enums.d.ts.map +1 -0
- package/dist/data/enums.js +70 -0
- package/dist/data/flag.d.ts +25 -0
- package/dist/data/flag.d.ts.map +1 -0
- package/dist/data/flag.js +27 -0
- package/dist/data/message.d.ts +81 -0
- package/dist/data/message.d.ts.map +1 -0
- package/dist/data/message.js +84 -0
- package/dist/data/tables.d.ts +160 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +44 -0
- package/dist/data/thread.d.ts +77 -0
- package/dist/data/thread.d.ts.map +1 -0
- package/dist/data/thread.js +65 -0
- package/dist/error/errors.d.ts +64 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +82 -0
- package/dist/http/guards.d.ts +44 -0
- package/dist/http/guards.d.ts.map +1 -0
- package/dist/http/guards.js +53 -0
- package/dist/http/handlers.d.ts +89 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +248 -0
- package/dist/http/resolve.d.ts +44 -0
- package/dist/http/resolve.d.ts.map +1 -0
- package/dist/http/resolve.js +72 -0
- package/dist/http/responses.d.ts +829 -0
- package/dist/http/responses.d.ts.map +1 -0
- package/dist/http/responses.js +303 -0
- package/dist/http/routes.d.ts +109 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +62 -0
- package/dist/http/schemas.d.ts +121 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +89 -0
- package/dist/http/scopes.d.ts +56 -0
- package/dist/http/scopes.d.ts.map +1 -0
- package/dist/http/scopes.js +107 -0
- package/dist/http/views.d.ts +36 -0
- package/dist/http/views.d.ts.map +1 -0
- package/dist/http/views.js +144 -0
- package/dist/inbound/authenticity.d.ts +81 -0
- package/dist/inbound/authenticity.d.ts.map +1 -0
- package/dist/inbound/authenticity.js +61 -0
- package/dist/inbound/guard.d.ts +74 -0
- package/dist/inbound/guard.d.ts.map +1 -0
- package/dist/inbound/guard.js +78 -0
- package/dist/inbound/handler.d.ts +28 -0
- package/dist/inbound/handler.d.ts.map +1 -0
- package/dist/inbound/handler.js +82 -0
- package/dist/inbound/ingest.d.ts +104 -0
- package/dist/inbound/ingest.d.ts.map +1 -0
- package/dist/inbound/ingest.js +326 -0
- package/dist/inbound/recipient.d.ts +16 -0
- package/dist/inbound/recipient.d.ts.map +1 -0
- package/dist/inbound/recipient.js +46 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/link/sender.d.ts +160 -0
- package/dist/link/sender.d.ts.map +1 -0
- package/dist/link/sender.js +200 -0
- package/dist/migrations/0001_threads.d.ts +26 -0
- package/dist/migrations/0001_threads.d.ts.map +1 -0
- package/dist/migrations/0001_threads.js +103 -0
- package/dist/mime/address.d.ts +22 -0
- package/dist/mime/address.d.ts.map +1 -0
- package/dist/mime/address.js +28 -0
- package/dist/mime/parse.d.ts +96 -0
- package/dist/mime/parse.d.ts.map +1 -0
- package/dist/mime/parse.js +146 -0
- package/dist/mime/sanitize.d.ts +25 -0
- package/dist/mime/sanitize.d.ts.map +1 -0
- package/dist/mime/sanitize.js +212 -0
- package/dist/mime/threading.d.ts +79 -0
- package/dist/mime/threading.d.ts.map +1 -0
- package/dist/mime/threading.js +111 -0
- package/dist/mime/truncate.d.ts +17 -0
- package/dist/mime/truncate.d.ts.map +1 -0
- package/dist/mime/truncate.js +47 -0
- package/dist/provision/provisionSupport.d.ts +158 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -0
- package/dist/provision/provisionSupport.js +110 -0
- package/dist/provision/resolveSupportConfig.d.ts +45 -0
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
- package/dist/provision/resolveSupportConfig.js +22 -0
- package/dist/reply/send.d.ts +123 -0
- package/dist/reply/send.d.ts.map +1 -0
- package/dist/reply/send.js +153 -0
- package/dist/reply/snippets.d.ts +79 -0
- package/dist/reply/snippets.d.ts.map +1 -0
- package/dist/reply/snippets.js +135 -0
- package/dist/secret/registry.d.ts +18 -0
- package/dist/secret/registry.d.ts.map +1 -0
- package/dist/secret/registry.js +21 -0
- package/dist/seeds/example.d.ts +3 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +350 -0
- package/dist/store/paging.d.ts +18 -0
- package/dist/store/paging.d.ts.map +1 -0
- package/dist/store/paging.js +20 -0
- package/dist/store/search.d.ts +67 -0
- package/dist/store/search.d.ts.map +1 -0
- package/dist/store/search.js +160 -0
- package/dist/store/searchIndex.d.ts +53 -0
- package/dist/store/searchIndex.d.ts.map +1 -0
- package/dist/store/searchIndex.js +66 -0
- package/dist/store/threads.d.ts +147 -0
- package/dist/store/threads.d.ts.map +1 -0
- package/dist/store/threads.js +197 -0
- package/dist/submission/encoding.d.ts +21 -0
- package/dist/submission/encoding.d.ts.map +1 -0
- package/dist/submission/encoding.js +55 -0
- package/dist/submission/guard.d.ts +71 -0
- package/dist/submission/guard.d.ts.map +1 -0
- package/dist/submission/guard.js +46 -0
- package/dist/submission/submit.d.ts +152 -0
- package/dist/submission/submit.d.ts.map +1 -0
- package/dist/submission/submit.js +319 -0
- package/dist/version.generated.d.ts +5 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +7 -0
- package/dist/workflows/classify.d.ts +63 -0
- package/dist/workflows/classify.d.ts.map +1 -0
- package/dist/workflows/classify.js +68 -0
- package/dist/workflows/retryPolicy.d.ts +39 -0
- package/dist/workflows/retryPolicy.d.ts.map +1 -0
- package/dist/workflows/retryPolicy.js +43 -0
- package/dist/workflows/specs.d.ts +43 -0
- package/dist/workflows/specs.d.ts.map +1 -0
- package/dist/workflows/specs.js +46 -0
- package/dist/workflows/worker.d.ts +48 -0
- package/dist/workflows/worker.d.ts.map +1 -0
- package/dist/workflows/worker.js +37 -0
- package/package.json +24 -15
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { MAX_SUBMISSION_BODY_CHARS } from "../config/config.js";
|
|
2
|
+
import { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums.js";
|
|
3
|
+
import { SupportSubmissionContext } from "../data/message.js";
|
|
4
|
+
import "../store/paging.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/http/schemas.ts
|
|
7
|
+
/**
|
|
8
|
+
* The request contracts every support route declares (CLAUDE.md §HTTP).
|
|
9
|
+
*
|
|
10
|
+
* Every one of these is a **bound on something a caller chose**, which is the whole job: a control-
|
|
11
|
+
* plane credential is verified, but verified is not the same as trusted, and a management client with
|
|
12
|
+
* a bug can ask for a million rows as easily as a hostile one can.
|
|
13
|
+
*
|
|
14
|
+
* The category filter is a bounded string rather than an enum, and that is deliberate: the taxonomy
|
|
15
|
+
* is federated, so the valid set is not known here — and building the schema from the configured set
|
|
16
|
+
* would make a filter for a category an adopter just removed a 400 instead of an empty list, which is
|
|
17
|
+
* a worse answer to the same question. The value is only ever compared, never interpreted.
|
|
18
|
+
*/
|
|
19
|
+
/** A thread id in the path. */
|
|
20
|
+
const ThreadIdParam = z.object({ id: z.string().uuid().describe("The thread's UUID. A param schema constrains the string; the handler still does the lookup.") }).describe("The path parameters of every single-thread route.");
|
|
21
|
+
/** A category key as a filter — bounded and shaped, never resolved against the configured set. */
|
|
22
|
+
const CategoryFilter = z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/).describe("One category key from this project's effective taxonomy. A key an adopter removed simply matches nothing.");
|
|
23
|
+
/** The inbox listing query. */
|
|
24
|
+
const ListThreadsQuery = z.object({
|
|
25
|
+
archived: z.enum(["true", "false"]).optional().describe("`true` for the done pile, `false` or absent for the open inbox — which is the default on purpose."),
|
|
26
|
+
category: CategoryFilter.optional().describe("Filter to one category — what the classifier made of it."),
|
|
27
|
+
declaredCategory: CategoryFilter.optional().describe("Filter to one category **the submitter chose**, which is a different question from `category` and answers it about different threads. Combining the two is how an operator finds the disagreements; asking for this one alone is how they triage a project with `ai.enabled: false`, where nothing ever writes the other."),
|
|
28
|
+
priority: SupportPriority.optional().describe("Filter to one priority."),
|
|
29
|
+
sentiment: SupportSentiment.optional().describe("Filter to one sentiment."),
|
|
30
|
+
channel: SupportChannel.optional().describe("Filter to one channel — `email` for mail, `app` for what signed-in users filed from inside the app. Absent shows both, which is the right default for an inbox that is one queue however things arrived."),
|
|
31
|
+
inbox: z.string().min(3).max(256).optional().describe("Filter to one configured inbox address, for a Worker serving several."),
|
|
32
|
+
q: z.string().min(1).max(200).optional().describe("Free text over subjects and bodies. Keyword search — the words people actually remember."),
|
|
33
|
+
cursor: z.string().max(512).optional().describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page."),
|
|
34
|
+
limit: z.coerce.number().int().min(1).max(100).optional().describe("How many threads to return. Bounded, because a verified client can still have a bug.")
|
|
35
|
+
}).describe("The inbox query: what to filter by, what to search for, and where to resume.");
|
|
36
|
+
/** The archive/unarchive body. */
|
|
37
|
+
const ArchiveThreadInput = z.object({ archived: z.boolean().describe("`true` marks the conversation done; `false` reopens it. Explicit rather than a toggle, so a retried request lands on the state the caller meant rather than flipping it back.") }).describe("Mark a conversation done, or reopen it.");
|
|
38
|
+
/** The reply body. */
|
|
39
|
+
const ReplyInput = z.object({
|
|
40
|
+
body: z.string().min(1).max(5e4).describe("The reply text, as a human wrote and edited it. Rendered HTML-escaped into the adopter's email shell — it is prose, never markup."),
|
|
41
|
+
agentName: z.string().min(1).max(80).optional().describe("Who is answering, signed at the bottom. Omitted rather than guessed.")
|
|
42
|
+
}).describe("An answer to send to the customer.");
|
|
43
|
+
/** The per-viewer flags body. */
|
|
44
|
+
const FlagsInput = z.object({
|
|
45
|
+
read: z.boolean().optional().describe("Mark read or unread. Absent leaves it alone."),
|
|
46
|
+
snoozedUntil: z.string().datetime().nullish().describe("Hide this thread from your inbox until this ISO-8601 moment. `null` clears a snooze; absent leaves it alone. A time rather than a flag, so it expires on its own and nothing has to sweep.")
|
|
47
|
+
}).describe("One viewer's private read and snooze state.");
|
|
48
|
+
/**
|
|
49
|
+
* One file on an in-app submission.
|
|
50
|
+
*
|
|
51
|
+
* The bytes arrive base64-encoded in JSON so the whole request stays one Zod object on the route line,
|
|
52
|
+
* like every other route in this capability. **The size and type bounds are not here**: they are the
|
|
53
|
+
* adopter's `submission.attachments` config, resolved in the handler, because a request schema is built
|
|
54
|
+
* once at module load and cannot know them. What this bounds is the shape — a filename that renders, a
|
|
55
|
+
* MIME type that looks like one, and a payload that is base64 at all.
|
|
56
|
+
*/
|
|
57
|
+
const SubmittedAttachmentInput = z.object({
|
|
58
|
+
filename: z.string().min(1).max(255).describe("The filename as the client declares it. **Recorded and never honored** — the R2 key is server-derived, so this is metadata a console renders escaped, not a path anything resolves."),
|
|
59
|
+
contentType: z.string().min(3).max(128).regex(/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/).describe("The MIME type as the client declares it, lowercased, checked against the configured allowlist in the handler. Never used to serve the bytes: every object is stored as `application/octet-stream`, because R2 echoes a stored type back on a presigned GET and a browser renders `text/html`."),
|
|
60
|
+
data: z.union([z.base64(), z.base64url()]).describe("The file's bytes, base64 — the standard alphabet or the URL-safe one, because a client whose encoder emits the latter has not made a mistake worth a 400. The configured `maxBytes` bound is measured on the **decoded** length, which is a third smaller than this string.")
|
|
61
|
+
}).describe("One file attached to an in-app support request, as bytes a signed-in client encoded.");
|
|
62
|
+
/**
|
|
63
|
+
* The in-app submission body.
|
|
64
|
+
*
|
|
65
|
+
* **Nothing here names an account, and nothing here could.** The submitter is `c.var.auth.userId`,
|
|
66
|
+
* proved before this schema ran — a `userId` field would be a client-supplied identity on the one
|
|
67
|
+
* surface whose whole argument is that it does not need one.
|
|
68
|
+
*
|
|
69
|
+
* The lengths are the hard ceilings from `config/config.ts`, not the adopter's configured bounds: this
|
|
70
|
+
* schema is built once at module load, and the configured numbers are applied in the handler where the
|
|
71
|
+
* resolved config lives. A body over the ceiling never reaches a handler at all.
|
|
72
|
+
*/
|
|
73
|
+
const SubmitFeedbackInput = z.object({
|
|
74
|
+
subject: z.string().min(1).max(500).describe("What the request is about. Becomes the thread's name in the inbox and the subject of every reply."),
|
|
75
|
+
body: z.string().min(1).max(MAX_SUBMISSION_BODY_CHARS).describe("The report itself, as the person wrote it. Plain text — it is prose, never markup."),
|
|
76
|
+
declaredCategory: CategoryFilter.optional().describe("What the person writing says this is about, from your app's own chooser. **Named for what it is, on the wire as well as in the row**: a field called `category` here would land in a column of that name that this never touches, which is the exact confusion the two columns exist to prevent. Bounded to a key's shape here and checked against your effective taxonomy in the handler — a key you do not declare is **refused**, because a chooser was built from that taxonomy and a value outside it is the client's bug. Accepted only when opening a request: sent alongside `threadId` it is refused rather than ignored."),
|
|
77
|
+
threadId: z.string().uuid().optional().describe("Continue this conversation instead of opening one. Accepted only for the caller's **own** app thread; anybody else's answers 404, which is the same answer a thread that does not exist gets — a 403 would confirm the id names a real conversation."),
|
|
78
|
+
context: SupportSubmissionContext.optional().describe("What the app knows and the user did not type — screen, build, platform, environment, locale. A closed set: an undeclared key is refused rather than stored, so this cannot quietly become a telemetry pipe."),
|
|
79
|
+
attachments: z.array(SubmittedAttachmentInput).max(10).default([]).describe("Files attached to the request. Bounded by `submission.attachments` in the adopter's config — count, decoded size, and an allowlist of types — and refused as a whole rather than silently trimmed. The ceiling here is the one that holds whatever that config says, and it is checked before any payload is decoded.")
|
|
80
|
+
}).describe("An in-app support request from a signed-in user: what it is about, what happened, and what it carries.");
|
|
81
|
+
/** The submitter's own thread list. */
|
|
82
|
+
const MyThreadsQuery = z.object({
|
|
83
|
+
cursor: z.string().max(512).optional().describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page."),
|
|
84
|
+
limit: z.coerce.number().int().min(1).max(100).optional().describe("How many conversations to return. Bounded, because a signed-in caller can still have a bug.")
|
|
85
|
+
}).describe("The submitter's own conversation list. No filters: this is one person's handful of requests, not an inbox to triage.");
|
|
86
|
+
/** The canned-reply catalog query. */
|
|
87
|
+
const RepliesQuery = z.object({ category: CategoryFilter.optional().describe("Order the catalog for this category — its snippets first, then the general-purpose ones. Ordering, never filtering: a misclassified thread must not hide the snippet its operator actually needs.") }).describe("How to order the canned reply catalog.");
|
|
88
|
+
//#endregion
|
|
89
|
+
export { ArchiveThreadInput, FlagsInput, ListThreadsQuery, MyThreadsQuery, RepliesQuery, ReplyInput, SubmitFeedbackInput, SubmittedAttachmentInput, ThreadIdParam };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
|
|
2
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
3
|
+
/**
|
|
4
|
+
* Support's control-plane scopes, and the admin surface a manifest advertises.
|
|
5
|
+
*
|
|
6
|
+
* **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
|
|
7
|
+
* client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
|
|
8
|
+
* writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
|
|
9
|
+
* program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
|
|
10
|
+
* one compiled `PithyHonoEnv`, which reached core's `capability.ts`, which named Worker globals that
|
|
11
|
+
* program has none of. **This module imports types and nothing else, and a gate holds it there**:
|
|
12
|
+
* `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
|
|
13
|
+
*
|
|
14
|
+
* ## Five scopes, because these are five different blast radii
|
|
15
|
+
*
|
|
16
|
+
* The temptation is one `support:admin` flag. It is wrong on the merits, the same way payments'
|
|
17
|
+
* single flag was: **reading** an inbox exposes every customer's private correspondence, while
|
|
18
|
+
* **replying** sends mail to a real person under the adopter's domain and DKIM. Those are not the
|
|
19
|
+
* same permission, and a tool that needed one should never silently hold the other. `scopeCovers`
|
|
20
|
+
* matches exactly, with no prefix or wildcard rule, so holding one confers nothing about the rest.
|
|
21
|
+
*
|
|
22
|
+
* The split that matters most is `reply`. A compromised read credential is a privacy incident; a
|
|
23
|
+
* compromised reply credential is somebody sending mail *as the adopter* to their own customers,
|
|
24
|
+
* which is a phishing platform with a verified sending domain attached.
|
|
25
|
+
*/
|
|
26
|
+
/** Read the inbox: list threads, read one, and see the sender's linked account and purchases. */
|
|
27
|
+
export declare const SUPPORT_THREADS_READ_SCOPE: ControlPlaneScope;
|
|
28
|
+
/** Mark a thread done or reopen it — the one shared piece of state in the model. */
|
|
29
|
+
export declare const SUPPORT_THREADS_ARCHIVE_SCOPE: ControlPlaneScope;
|
|
30
|
+
/**
|
|
31
|
+
* Send mail to a customer under the adopter's domain. The most dangerous of the five and the reason
|
|
32
|
+
* a single admin flag was never an option.
|
|
33
|
+
*/
|
|
34
|
+
export declare const SUPPORT_THREADS_REPLY_SCOPE: ControlPlaneScope;
|
|
35
|
+
/** Re-run the model over a thread, overwriting its current classification. */
|
|
36
|
+
export declare const SUPPORT_THREADS_RECLASSIFY_SCOPE: ControlPlaneScope;
|
|
37
|
+
/** Set a viewer's own read/snooze flags. Private, uncoordinated, and the least dangerous thing here. */
|
|
38
|
+
export declare const SUPPORT_THREADS_FLAG_SCOPE: ControlPlaneScope;
|
|
39
|
+
/**
|
|
40
|
+
* Every control-plane scope support defines — what `pithy dashboard connect` offers for this
|
|
41
|
+
* capability, and the list a manifest or a doc quotes rather than re-typing.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SUPPORT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[];
|
|
44
|
+
/**
|
|
45
|
+
* Support's management surface, as `GET /control-plane/manifest` reports it.
|
|
46
|
+
*
|
|
47
|
+
* Declared beside the scopes so the scope a route demands and the scope a manifest advertises are
|
|
48
|
+
* the same constant. `basePath` is a parameter and never a default: an adopter who mounted support
|
|
49
|
+
* at `/inbox` must get a manifest naming `/inbox/threads`, or a client composing its calls from the
|
|
50
|
+
* manifest would 404 against exactly the adopters who customized anything.
|
|
51
|
+
*
|
|
52
|
+
* The summaries say what the operation is *for*. A client renders these next to a button somebody is
|
|
53
|
+
* about to press on a real customer's conversation.
|
|
54
|
+
*/
|
|
55
|
+
export declare function supportAdminRoutes(basePath: string): AdminRoute[];
|
|
56
|
+
//# sourceMappingURL=scopes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/http/scopes.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sDAAsD,CAAC;AACvF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6CAA6C,CAAC;AAErF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,iGAAiG;AACjG,eAAO,MAAM,0BAA0B,EAAE,iBAA0C,CAAC;AAEpF,oFAAoF;AACpF,eAAO,MAAM,6BAA6B,EAAE,iBAA6C,CAAC;AAE1F;;;GAGG;AACH,eAAO,MAAM,2BAA2B,EAAE,iBAA2C,CAAC;AAEtF,8EAA8E;AAC9E,eAAO,MAAM,gCAAgC,EAAE,iBAAgD,CAAC;AAEhG,wGAAwG;AACxG,eAAO,MAAM,0BAA0B,EAAE,iBAA0C,CAAC;AAEpF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,EAAE,SAAS,iBAAiB,EAMpE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,EAAE,CA6CjE"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//#region src/http/scopes.ts
|
|
2
|
+
/**
|
|
3
|
+
* Support's control-plane scopes, and the admin surface a manifest advertises.
|
|
4
|
+
*
|
|
5
|
+
* **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
|
|
6
|
+
* client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
|
|
7
|
+
* writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
|
|
8
|
+
* program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
|
|
9
|
+
* one compiled `PithyHonoEnv`, which reached core's `capability.ts`, which named Worker globals that
|
|
10
|
+
* program has none of. **This module imports types and nothing else, and a gate holds it there**:
|
|
11
|
+
* `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
|
|
12
|
+
*
|
|
13
|
+
* ## Five scopes, because these are five different blast radii
|
|
14
|
+
*
|
|
15
|
+
* The temptation is one `support:admin` flag. It is wrong on the merits, the same way payments'
|
|
16
|
+
* single flag was: **reading** an inbox exposes every customer's private correspondence, while
|
|
17
|
+
* **replying** sends mail to a real person under the adopter's domain and DKIM. Those are not the
|
|
18
|
+
* same permission, and a tool that needed one should never silently hold the other. `scopeCovers`
|
|
19
|
+
* matches exactly, with no prefix or wildcard rule, so holding one confers nothing about the rest.
|
|
20
|
+
*
|
|
21
|
+
* The split that matters most is `reply`. A compromised read credential is a privacy incident; a
|
|
22
|
+
* compromised reply credential is somebody sending mail *as the adopter* to their own customers,
|
|
23
|
+
* which is a phishing platform with a verified sending domain attached.
|
|
24
|
+
*/
|
|
25
|
+
/** Read the inbox: list threads, read one, and see the sender's linked account and purchases. */
|
|
26
|
+
const SUPPORT_THREADS_READ_SCOPE = "support:threads:read";
|
|
27
|
+
/** Mark a thread done or reopen it — the one shared piece of state in the model. */
|
|
28
|
+
const SUPPORT_THREADS_ARCHIVE_SCOPE = "support:threads:archive";
|
|
29
|
+
/**
|
|
30
|
+
* Send mail to a customer under the adopter's domain. The most dangerous of the five and the reason
|
|
31
|
+
* a single admin flag was never an option.
|
|
32
|
+
*/
|
|
33
|
+
const SUPPORT_THREADS_REPLY_SCOPE = "support:threads:reply";
|
|
34
|
+
/** Re-run the model over a thread, overwriting its current classification. */
|
|
35
|
+
const SUPPORT_THREADS_RECLASSIFY_SCOPE = "support:threads:reclassify";
|
|
36
|
+
/** Set a viewer's own read/snooze flags. Private, uncoordinated, and the least dangerous thing here. */
|
|
37
|
+
const SUPPORT_THREADS_FLAG_SCOPE = "support:threads:flag";
|
|
38
|
+
/**
|
|
39
|
+
* Every control-plane scope support defines — what `pithy dashboard connect` offers for this
|
|
40
|
+
* capability, and the list a manifest or a doc quotes rather than re-typing.
|
|
41
|
+
*/
|
|
42
|
+
const SUPPORT_CONTROL_PLANE_SCOPES = [
|
|
43
|
+
SUPPORT_THREADS_READ_SCOPE,
|
|
44
|
+
SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
45
|
+
SUPPORT_THREADS_REPLY_SCOPE,
|
|
46
|
+
SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
47
|
+
SUPPORT_THREADS_FLAG_SCOPE
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* Support's management surface, as `GET /control-plane/manifest` reports it.
|
|
51
|
+
*
|
|
52
|
+
* Declared beside the scopes so the scope a route demands and the scope a manifest advertises are
|
|
53
|
+
* the same constant. `basePath` is a parameter and never a default: an adopter who mounted support
|
|
54
|
+
* at `/inbox` must get a manifest naming `/inbox/threads`, or a client composing its calls from the
|
|
55
|
+
* manifest would 404 against exactly the adopters who customized anything.
|
|
56
|
+
*
|
|
57
|
+
* The summaries say what the operation is *for*. A client renders these next to a button somebody is
|
|
58
|
+
* about to press on a real customer's conversation.
|
|
59
|
+
*/
|
|
60
|
+
function supportAdminRoutes(basePath) {
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
method: "GET",
|
|
64
|
+
path: `${basePath}/threads`,
|
|
65
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
66
|
+
summary: "List the inbox, newest first, filtered by category, priority, and archived, searched by text."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
method: "GET",
|
|
70
|
+
path: `${basePath}/threads/:id`,
|
|
71
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
72
|
+
summary: "Read one conversation, with the sender's linked account and what they bought."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
method: "POST",
|
|
76
|
+
path: `${basePath}/threads/:id/archive`,
|
|
77
|
+
scope: SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
78
|
+
summary: "Mark a conversation done, or reopen one."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
method: "POST",
|
|
82
|
+
path: `${basePath}/threads/:id/reply`,
|
|
83
|
+
scope: SUPPORT_THREADS_REPLY_SCOPE,
|
|
84
|
+
summary: "Answer the customer, threaded so their mail client keeps one conversation."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
method: "POST",
|
|
88
|
+
path: `${basePath}/threads/:id/reclassify`,
|
|
89
|
+
scope: SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
90
|
+
summary: "Re-run the classifier over this conversation's latest inbound message."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: `${basePath}/threads/:id/flags`,
|
|
95
|
+
scope: SUPPORT_THREADS_FLAG_SCOPE,
|
|
96
|
+
summary: "Set your own read and snooze state for a conversation. Private to you."
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
method: "GET",
|
|
100
|
+
path: `${basePath}/replies`,
|
|
101
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
102
|
+
summary: "The canned reply catalog — starting points to pick from, ordered for a thread's category."
|
|
103
|
+
}
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
export { SUPPORT_CONTROL_PLANE_SCOPES, SUPPORT_THREADS_ARCHIVE_SCOPE, SUPPORT_THREADS_FLAG_SCOPE, SUPPORT_THREADS_READ_SCOPE, SUPPORT_THREADS_RECLASSIFY_SCOPE, SUPPORT_THREADS_REPLY_SCOPE, supportAdminRoutes };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SupportMessage } from "../data/message";
|
|
2
|
+
import type { SupportThread } from "../data/thread";
|
|
3
|
+
import type { SenderContext } from "../link/sender";
|
|
4
|
+
import type { ListedThread } from "../store/threads";
|
|
5
|
+
import type { SenderContextView, SupportAttachmentView, SupportListedThreadView, SupportMessageView, SupportMyMessageView, SupportMyThreadView, SupportThreadView } from "./responses";
|
|
6
|
+
/** Project one thread row. */
|
|
7
|
+
export declare function threadView(thread: SupportThread): SupportThreadView;
|
|
8
|
+
/** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
|
|
9
|
+
export declare function listedThreadView(thread: ListedThread): SupportListedThreadView;
|
|
10
|
+
/**
|
|
11
|
+
* Project one message.
|
|
12
|
+
*
|
|
13
|
+
* The threading internals — `mimeMessageId`, `mimeInReplyTo`, `mimeReferences`, `rawKey`, `rawBytes` —
|
|
14
|
+
* are dropped. They are how a reply is stitched to its parent, and an operator would never act on one.
|
|
15
|
+
* `rawKey` in particular is the R2 object holding the message exactly as it arrived, which is the one
|
|
16
|
+
* copy of this data that has had nothing done to it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function messageView(message: SupportMessage): SupportMessageView;
|
|
19
|
+
/**
|
|
20
|
+
* Project one thread for the person who opened it.
|
|
21
|
+
*
|
|
22
|
+
* **Built from scratch rather than from {@link threadView}, and that is the security boundary.** A
|
|
23
|
+
* submitter's view derived by omitting fields from an operator's would disclose the next column
|
|
24
|
+
* somebody adds, silently, on the day they add it — the failure would be a default rather than a
|
|
25
|
+
* decision. Starting from nothing means a field reaches a customer only because somebody wrote it
|
|
26
|
+
* here.
|
|
27
|
+
*
|
|
28
|
+
* So: no classification, no priority, no sentiment, no confidence, no model, no viewer flags, no
|
|
29
|
+
* account link, no `archivedBy`, no addresses. What is left is their conversation.
|
|
30
|
+
*/
|
|
31
|
+
export declare function myThreadView(thread: SupportThread): SupportMyThreadView;
|
|
32
|
+
/** Project one message for the person in the conversation. Their own attachments ride along. */
|
|
33
|
+
export declare function myMessageView(message: SupportMessage, attachments: SupportAttachmentView[]): SupportMyMessageView;
|
|
34
|
+
/** Project the customer context beside a conversation. */
|
|
35
|
+
export declare function senderView(sender: SenderContext): SenderContextView;
|
|
36
|
+
//# sourceMappingURL=views.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"views.d.ts","sourceRoot":"","sources":["../../src/http/views.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EACV,iBAAiB,EACjB,qBAAqB,EACrB,uBAAuB,EACvB,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAyBrB,8BAA8B;AAC9B,wBAAgB,UAAU,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CA2BnE;AAED,0FAA0F;AAC1F,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,GAAG,uBAAuB,CAE9E;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,kBAAkB,CAgBvE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,aAAa,GAAG,mBAAmB,CASvE;AAED,gGAAgG;AAChG,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,qBAAqB,EAAE,GAAG,oBAAoB,CAYjH;AAED,0DAA0D;AAC1D,wBAAgB,UAAU,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CA4BnE"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//#region src/http/views.ts
|
|
2
|
+
/**
|
|
3
|
+
* The projections between a support row and the wire.
|
|
4
|
+
*
|
|
5
|
+
* Every return type is `z.output` of the matching object in `responses.ts`, so what this Worker sends
|
|
6
|
+
* and what a management client validates against are one declaration. A field added to one and not
|
|
7
|
+
* the other does not compile.
|
|
8
|
+
*
|
|
9
|
+
* **These convert dates to ISO-8601 strings, and that changes nothing on the wire.** A `Date` handed
|
|
10
|
+
* to `c.json` serializes as an ISO string already — so the bytes were always this, while the type
|
|
11
|
+
* claimed a `Date` no client ever received and no schema could describe. Converting explicitly is
|
|
12
|
+
* what makes the response statable, and it is the whole reason the fields could be typed at all.
|
|
13
|
+
*
|
|
14
|
+
* **A row is never spread.** Each projection names its fields, so a column added to
|
|
15
|
+
* `pithy_support_threads` is disclosed by a decision rather than by default — which is the same rule
|
|
16
|
+
* every other capability's admin surface follows, and the one a `SELECT *` handed to `c.json` breaks
|
|
17
|
+
* silently.
|
|
18
|
+
*/
|
|
19
|
+
/** An ISO string, or null. */
|
|
20
|
+
function iso(date) {
|
|
21
|
+
return date ? date.toISOString() : null;
|
|
22
|
+
}
|
|
23
|
+
/** Project one thread row. */
|
|
24
|
+
function threadView(thread) {
|
|
25
|
+
return {
|
|
26
|
+
id: thread.id,
|
|
27
|
+
channel: thread.channel,
|
|
28
|
+
inboxAddress: thread.inboxAddress ?? null,
|
|
29
|
+
subject: thread.subject,
|
|
30
|
+
fromAddress: thread.fromAddress,
|
|
31
|
+
fromName: thread.fromName ?? null,
|
|
32
|
+
senderAuthenticated: thread.senderAuthenticated,
|
|
33
|
+
userId: thread.userId ?? null,
|
|
34
|
+
accountLinkSource: thread.accountLinkSource ?? null,
|
|
35
|
+
declaredCategory: thread.declaredCategory ?? null,
|
|
36
|
+
category: thread.category,
|
|
37
|
+
priority: thread.priority,
|
|
38
|
+
sentiment: thread.sentiment,
|
|
39
|
+
confidence: thread.confidence ?? null,
|
|
40
|
+
model: thread.model ?? null,
|
|
41
|
+
classifiedAt: iso(thread.classifiedAt),
|
|
42
|
+
archived: thread.archived,
|
|
43
|
+
archivedAt: iso(thread.archivedAt),
|
|
44
|
+
archivedBy: thread.archivedBy ?? null,
|
|
45
|
+
messageCount: thread.messageCount,
|
|
46
|
+
firstMessageAt: thread.firstMessageAt.toISOString(),
|
|
47
|
+
lastMessageAt: thread.lastMessageAt.toISOString(),
|
|
48
|
+
createdAt: thread.createdAt.toISOString(),
|
|
49
|
+
updatedAt: thread.updatedAt.toISOString()
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
|
|
53
|
+
function listedThreadView(thread) {
|
|
54
|
+
return {
|
|
55
|
+
...threadView(thread),
|
|
56
|
+
read: thread.read,
|
|
57
|
+
snoozedUntil: iso(thread.snoozedUntil)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Project one message.
|
|
62
|
+
*
|
|
63
|
+
* The threading internals — `mimeMessageId`, `mimeInReplyTo`, `mimeReferences`, `rawKey`, `rawBytes` —
|
|
64
|
+
* are dropped. They are how a reply is stitched to its parent, and an operator would never act on one.
|
|
65
|
+
* `rawKey` in particular is the R2 object holding the message exactly as it arrived, which is the one
|
|
66
|
+
* copy of this data that has had nothing done to it.
|
|
67
|
+
*/
|
|
68
|
+
function messageView(message) {
|
|
69
|
+
return {
|
|
70
|
+
id: message.id,
|
|
71
|
+
direction: message.direction,
|
|
72
|
+
channel: message.channel,
|
|
73
|
+
context: message.context ?? null,
|
|
74
|
+
fromAddress: message.fromAddress ?? null,
|
|
75
|
+
fromName: message.fromName ?? null,
|
|
76
|
+
toAddress: message.toAddress ?? null,
|
|
77
|
+
subject: message.subject,
|
|
78
|
+
htmlBody: message.htmlBody ?? null,
|
|
79
|
+
textBody: message.textBody,
|
|
80
|
+
emailJobId: message.emailJobId ?? null,
|
|
81
|
+
receivedAt: message.receivedAt.toISOString()
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Project one thread for the person who opened it.
|
|
86
|
+
*
|
|
87
|
+
* **Built from scratch rather than from {@link threadView}, and that is the security boundary.** A
|
|
88
|
+
* submitter's view derived by omitting fields from an operator's would disclose the next column
|
|
89
|
+
* somebody adds, silently, on the day they add it — the failure would be a default rather than a
|
|
90
|
+
* decision. Starting from nothing means a field reaches a customer only because somebody wrote it
|
|
91
|
+
* here.
|
|
92
|
+
*
|
|
93
|
+
* So: no classification, no priority, no sentiment, no confidence, no model, no viewer flags, no
|
|
94
|
+
* account link, no `archivedBy`, no addresses. What is left is their conversation.
|
|
95
|
+
*/
|
|
96
|
+
function myThreadView(thread) {
|
|
97
|
+
return {
|
|
98
|
+
id: thread.id,
|
|
99
|
+
subject: thread.subject,
|
|
100
|
+
resolved: thread.archived,
|
|
101
|
+
messageCount: thread.messageCount,
|
|
102
|
+
lastMessageAt: thread.lastMessageAt.toISOString(),
|
|
103
|
+
createdAt: thread.createdAt.toISOString()
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Project one message for the person in the conversation. Their own attachments ride along. */
|
|
107
|
+
function myMessageView(message, attachments) {
|
|
108
|
+
return {
|
|
109
|
+
id: message.id,
|
|
110
|
+
direction: message.direction,
|
|
111
|
+
body: message.textBody,
|
|
112
|
+
context: message.context ?? null,
|
|
113
|
+
attachments,
|
|
114
|
+
sentAt: message.receivedAt.toISOString()
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Project the customer context beside a conversation. */
|
|
118
|
+
function senderView(sender) {
|
|
119
|
+
return {
|
|
120
|
+
authenticated: sender.authenticated,
|
|
121
|
+
userId: sender.userId,
|
|
122
|
+
billingScope: "user",
|
|
123
|
+
...sender.name === void 0 ? {} : { name: sender.name },
|
|
124
|
+
...sender.emailVerified === void 0 ? {} : { emailVerified: sender.emailVerified },
|
|
125
|
+
purchases: sender.purchases.map((purchase) => ({
|
|
126
|
+
id: purchase.id,
|
|
127
|
+
rail: purchase.rail,
|
|
128
|
+
productId: purchase.productId,
|
|
129
|
+
status: purchase.status,
|
|
130
|
+
environment: purchase.environment,
|
|
131
|
+
purchasedAt: purchase.purchasedAt.toISOString(),
|
|
132
|
+
expiresAt: iso(purchase.expiresAt),
|
|
133
|
+
revokedAt: iso(purchase.revokedAt)
|
|
134
|
+
})),
|
|
135
|
+
entitlements: sender.entitlements.map((entitlement) => ({
|
|
136
|
+
key: entitlement.key,
|
|
137
|
+
active: entitlement.active,
|
|
138
|
+
expiresAt: iso(entitlement.expiresAt),
|
|
139
|
+
source: entitlement.source
|
|
140
|
+
}))
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
export { listedThreadView, messageView, myMessageView, myThreadView, senderView, threadView };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a message's `From:` header is something we are entitled to believe.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `From:` is an unauthenticated claim. Anyone can send mail saying `From: ada@example.com`, and for
|
|
7
|
+
* every domain that publishes DMARC `p=none` — which is most small domains and plenty of large ones
|
|
8
|
+
* — a receiving MTA will deliver it rather than reject it.
|
|
9
|
+
*
|
|
10
|
+
* That claim is the join key for the customer link: it resolves to a `pithy_auth_users` row and
|
|
11
|
+
* pulls that account's name, entitlements, and itemized purchase history into a support console,
|
|
12
|
+
* where a human reads it and acts. Rendering an attacker's thread decorated with a real customer's
|
|
13
|
+
* billing history is the standard opening move of support-driven account takeover, and this
|
|
14
|
+
* capability's own `account_access` and `privacy_request` categories route exactly those messages to
|
|
15
|
+
* an operator already primed to act on them.
|
|
16
|
+
*
|
|
17
|
+
* The recipient side of this package gets the equivalent question right and says so at length: the
|
|
18
|
+
* SMTP envelope is authority, headers are not. This is that same rule applied to the sender.
|
|
19
|
+
*
|
|
20
|
+
* ## What counts as authenticated
|
|
21
|
+
*
|
|
22
|
+
* DMARC is the only verdict that asserts what we actually need — that the **`From:` domain** is
|
|
23
|
+
* aligned with something that passed. `spf=pass` alone says the *envelope* sender's domain passed,
|
|
24
|
+
* which is a different domain and is exactly the gap DMARC exists to close, so it counts only when
|
|
25
|
+
* the envelope sender aligns with the header From. `dkim=pass` alone has the same alignment gap.
|
|
26
|
+
*
|
|
27
|
+
* Absence is not a pass. A message with no `Authentication-Results` at all is unauthenticated, which
|
|
28
|
+
* is the right answer for mail that reached us through something that never checked.
|
|
29
|
+
*/
|
|
30
|
+
/** What is known about a sender's claimed identity. */
|
|
31
|
+
export interface SenderAuthenticity {
|
|
32
|
+
/** Whether the `From:` header may be treated as the real sender. */
|
|
33
|
+
authenticated: boolean;
|
|
34
|
+
/** How it was established, for the log and for a dashboard to explain itself. `none` when it was not. */
|
|
35
|
+
method: "dmarc" | "spf-aligned" | "none";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether two domains are aligned in the relaxed sense DMARC uses — the same Organizational Domain.
|
|
39
|
+
*
|
|
40
|
+
* **This is a public-suffix lookup, not label arithmetic, because RFC 7489 §3.2 defines relaxed
|
|
41
|
+
* alignment in terms of the Organizational Domain and that term is defined by the public suffix
|
|
42
|
+
* list.** An earlier version counted labels and required a shared parent of at least two, which is a
|
|
43
|
+
* decent approximation and wrong in exactly the way approximations of security boundaries are wrong:
|
|
44
|
+
* `co.uk` has two labels, so it "aligned" with `bbc.co.uk`. Implementing the actual algorithm removes
|
|
45
|
+
* the whole class rather than the instance somebody happened to notice.
|
|
46
|
+
*
|
|
47
|
+
* `allowPrivateDomains` is on. Without it `a.github.io` and `b.github.io` both reduce to `github.io`
|
|
48
|
+
* and two unrelated people's sites align with each other; the private section of the list is what
|
|
49
|
+
* knows that `github.io` is a boundary. Stricter is the right direction for a check that gates
|
|
50
|
+
* whether a stranger's mail gets decorated with a real customer's purchase history.
|
|
51
|
+
*
|
|
52
|
+
* A domain that has no Organizational Domain at all — a bare public suffix like `co.uk`, an IP
|
|
53
|
+
* literal, `localhost` — returns `null` from `getDomain` and therefore aligns with nothing, which is
|
|
54
|
+
* the correct answer rather than a special case.
|
|
55
|
+
*/
|
|
56
|
+
export declare function domainsAlign(a: string | undefined, b: string | undefined): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether the `From:` header is believable.
|
|
59
|
+
*
|
|
60
|
+
* Takes the parsed verdicts and both addresses rather than the whole message, so the decision is a
|
|
61
|
+
* pure function of four values and is testable without a MIME fixture.
|
|
62
|
+
*/
|
|
63
|
+
export declare function senderAuthenticity(options: {
|
|
64
|
+
/** The `Authentication-Results` verdicts, keyed by method. */
|
|
65
|
+
authResults: Record<string, string>;
|
|
66
|
+
/** The address in the `From:` header, normalized. */
|
|
67
|
+
fromAddress: string;
|
|
68
|
+
/** The SMTP envelope sender — what SPF was actually evaluated against. */
|
|
69
|
+
envelopeFrom: string | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the adopter has attested that the header is worth reading at all.
|
|
72
|
+
*
|
|
73
|
+
* **Default off, and this is the load-bearing gate.** Under Cloudflare Email Routing a Worker is
|
|
74
|
+
* not reliably given the MTA's own `Authentication-Results`, and a header the *sender* wrote is
|
|
75
|
+
* indistinguishable from one an MTA wrote once the MTA's copy is absent — so reading it by default
|
|
76
|
+
* would mean believing whatever the attacker typed. Everything below is a verdict about a value
|
|
77
|
+
* that only means something once somebody has said their pipeline produces it honestly.
|
|
78
|
+
*/
|
|
79
|
+
trusted: boolean;
|
|
80
|
+
}): SenderAuthenticity;
|
|
81
|
+
//# sourceMappingURL=authenticity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authenticity.d.ts","sourceRoot":"","sources":["../../src/inbound/authenticity.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,uDAAuD;AACvD,MAAM,WAAW,kBAAkB;IACjC,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAC;IACvB,yGAAyG;IACzG,MAAM,EAAE,OAAO,GAAG,aAAa,GAAG,MAAM,CAAC;CAC1C;AASD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAIlF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC1C,8DAA8D;IAC9D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC;IACpB,0EAA0E;IAC1E,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;;;;;OAQG;IACH,OAAO,EAAE,OAAO,CAAC;CAClB,GAAG,kBAAkB,CAcrB"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { parseAddress } from "@pithy-sh/core/src/address/address";
|
|
2
|
+
import { getDomain } from "tldts";
|
|
3
|
+
//#region src/inbound/authenticity.ts
|
|
4
|
+
/** The domain half of an address, lowercased. */
|
|
5
|
+
function domainOf(address) {
|
|
6
|
+
const normalized = parseAddress(address);
|
|
7
|
+
const at = normalized?.lastIndexOf("@") ?? -1;
|
|
8
|
+
return normalized && at > 0 ? normalized.slice(at + 1) : void 0;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Whether two domains are aligned in the relaxed sense DMARC uses — the same Organizational Domain.
|
|
12
|
+
*
|
|
13
|
+
* **This is a public-suffix lookup, not label arithmetic, because RFC 7489 §3.2 defines relaxed
|
|
14
|
+
* alignment in terms of the Organizational Domain and that term is defined by the public suffix
|
|
15
|
+
* list.** An earlier version counted labels and required a shared parent of at least two, which is a
|
|
16
|
+
* decent approximation and wrong in exactly the way approximations of security boundaries are wrong:
|
|
17
|
+
* `co.uk` has two labels, so it "aligned" with `bbc.co.uk`. Implementing the actual algorithm removes
|
|
18
|
+
* the whole class rather than the instance somebody happened to notice.
|
|
19
|
+
*
|
|
20
|
+
* `allowPrivateDomains` is on. Without it `a.github.io` and `b.github.io` both reduce to `github.io`
|
|
21
|
+
* and two unrelated people's sites align with each other; the private section of the list is what
|
|
22
|
+
* knows that `github.io` is a boundary. Stricter is the right direction for a check that gates
|
|
23
|
+
* whether a stranger's mail gets decorated with a real customer's purchase history.
|
|
24
|
+
*
|
|
25
|
+
* A domain that has no Organizational Domain at all — a bare public suffix like `co.uk`, an IP
|
|
26
|
+
* literal, `localhost` — returns `null` from `getDomain` and therefore aligns with nothing, which is
|
|
27
|
+
* the correct answer rather than a special case.
|
|
28
|
+
*/
|
|
29
|
+
function domainsAlign(a, b) {
|
|
30
|
+
if (!a || !b) return false;
|
|
31
|
+
const organizational = getDomain(a, { allowPrivateDomains: true });
|
|
32
|
+
return organizational !== null && organizational === getDomain(b, { allowPrivateDomains: true });
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Decide whether the `From:` header is believable.
|
|
36
|
+
*
|
|
37
|
+
* Takes the parsed verdicts and both addresses rather than the whole message, so the decision is a
|
|
38
|
+
* pure function of four values and is testable without a MIME fixture.
|
|
39
|
+
*/
|
|
40
|
+
function senderAuthenticity(options) {
|
|
41
|
+
if (!options.trusted) return {
|
|
42
|
+
authenticated: false,
|
|
43
|
+
method: "none"
|
|
44
|
+
};
|
|
45
|
+
if (options.authResults.dmarc === "pass") return {
|
|
46
|
+
authenticated: true,
|
|
47
|
+
method: "dmarc"
|
|
48
|
+
};
|
|
49
|
+
if (options.authResults.spf === "pass") {
|
|
50
|
+
if (domainsAlign(domainOf(options.envelopeFrom), domainOf(options.fromAddress))) return {
|
|
51
|
+
authenticated: true,
|
|
52
|
+
method: "spf-aligned"
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
authenticated: false,
|
|
57
|
+
method: "none"
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { domainsAlign, senderAuthenticity };
|