@jini-ai/platform 0.5.9 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/asset-cache.js +26 -25
  3. package/dist/blob-storage.d.ts +7 -2
  4. package/dist/blob-storage.js +99 -59
  5. package/dist/child-environment.d.ts +36 -0
  6. package/dist/child-environment.js +68 -0
  7. package/dist/download.js +192 -148
  8. package/dist/fs/atomic-write.js +105 -93
  9. package/dist/fs/boot-identity.d.ts +16 -0
  10. package/dist/fs/boot-identity.js +38 -17
  11. package/dist/fs/directory-home.js +6 -0
  12. package/dist/fs/directory-registry.d.ts +1 -1
  13. package/dist/fs/directory-registry.js +23 -16
  14. package/dist/fs/directory-running-lock.d.ts +1 -1
  15. package/dist/fs/directory-running-lock.js +18 -7
  16. package/dist/fs/directory-sync.d.ts +1 -1
  17. package/dist/fs/directory-sync.js +64 -51
  18. package/dist/fs/directory-user-config.js +108 -67
  19. package/dist/fs/directory-workspace.d.ts +11 -0
  20. package/dist/fs/directory-workspace.js +16 -11
  21. package/dist/fs/durable-json.js +49 -37
  22. package/dist/fs/file-lock.js +214 -149
  23. package/dist/fs/guarded-reader.js +47 -35
  24. package/dist/fs/user-data-directories.d.ts +1 -1
  25. package/dist/fs/user-data-directories.js +25 -9
  26. package/dist/fs/verified-directory-move.d.ts +13 -11
  27. package/dist/fs/verified-directory-move.js +105 -68
  28. package/dist/fs/verified-file-relocation.d.ts +1 -1
  29. package/dist/fs/verified-file-relocation.js +111 -94
  30. package/dist/http/guarded/client.d.ts +3 -4
  31. package/dist/http/guarded/client.js +50 -60
  32. package/dist/http/guarded/fetch-adapter.d.ts +2 -3
  33. package/dist/http/guarded/fetch-adapter.js +26 -15
  34. package/dist/mail/builtin-providers.d.ts +39 -0
  35. package/dist/mail/builtin-providers.js +60 -0
  36. package/dist/mail/feedback.d.ts +84 -0
  37. package/dist/mail/feedback.js +90 -0
  38. package/dist/mail/index.d.ts +11 -0
  39. package/dist/mail/index.js +6 -0
  40. package/dist/mail/policy.d.ts +77 -0
  41. package/dist/mail/policy.js +155 -0
  42. package/dist/mail/ports.d.ts +56 -7
  43. package/dist/mail/provider.d.ts +117 -0
  44. package/dist/mail/provider.js +74 -0
  45. package/dist/mail/rate-limit.d.ts +33 -0
  46. package/dist/mail/rate-limit.js +35 -0
  47. package/dist/mail/sender-identity.d.ts +68 -0
  48. package/dist/mail/sender-identity.js +50 -0
  49. package/dist/mail/types.d.ts +9 -0
  50. package/dist/net/address.d.ts +9 -23
  51. package/dist/net/address.js +28 -133
  52. package/dist/net/endpoint-policy.d.ts +26 -2
  53. package/dist/net/endpoint-policy.js +159 -56
  54. package/dist/object-store/index.d.ts +2 -0
  55. package/dist/object-store/index.js +2 -0
  56. package/dist/object-store/local-fs.d.ts +17 -0
  57. package/dist/object-store/local-fs.js +146 -0
  58. package/dist/process.js +2 -0
  59. package/dist/proxy-env.js +131 -135
  60. package/dist/secrets/credential-sets/contracts.d.ts +1 -0
  61. package/dist/secrets/credential-sets/contracts.js +1 -0
  62. package/dist/secrets/credential-sets/index.d.ts +1 -0
  63. package/dist/secrets/credential-sets/index.js +1 -0
  64. package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
  65. package/dist/secrets/credential-sets/provider-matches.js +61 -0
  66. package/dist/secrets/credential-sets/types.d.ts +7 -0
  67. package/dist/secrets/credential-sets/vault.meta.js +58 -20
  68. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
  69. package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
  70. package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
  71. package/dist/secrets/credential-sets/vault.service.js +131 -61
  72. package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
  73. package/dist/secrets/credential-sets/vault.types.js +22 -8
  74. package/dist/secrets/keyring.env.js +14 -10
  75. package/dist/toolchain.js +119 -91
  76. package/dist/web/html-document.js +73 -69
  77. package/dist/web/html-to-markdown.js +127 -102
  78. package/dist/web/web-page.js +160 -80
  79. package/package.json +12 -4
@@ -1,43 +1,11 @@
1
1
  // Policy refusals use EgressRefusedError; DNS, connection and transport failures retain their
2
2
  // own errors because they are operational failures, not decisions that a different URL fixes.
3
- import { isPrivateAddress, expandIpv6 } from "../../net/address.js";
3
+ import { classifyIpAddress } from "../../net/address.js";
4
4
  import { FetchTimeoutError } from "../../fetch-with-timeout.js";
5
5
  import { isIP } from "node:net";
6
6
  import { EgressRefusedError } from "./errors.js";
7
7
  /** Uses the canonical net address validator; labels preserve the guarded refusal contract. */
8
- export function classifyAddress({ ip }) {
9
- if (!isPrivateAddress({ address: ip })) {
10
- if (ip.toLowerCase().startsWith("::0."))
11
- return "reserved";
12
- return "public";
13
- }
14
- if (isIP(ip) === 6) {
15
- const g = expandIpv6({ address: ip });
16
- if (g.slice(0, 5).every(n => n === 0) && g[5] === 0xffff) {
17
- return classifyAddress({ ip: [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255].join(".") });
18
- }
19
- if (g.slice(0, 7).every(n => n === 0) && g[7] === 1)
20
- return "loopback";
21
- if ((g[0] & 0xffc0) === 0xfe80)
22
- return "link-local";
23
- if ((g[0] & 0xfe00) === 0xfc00)
24
- return "private";
25
- return "reserved";
26
- }
27
- if (isIP(ip) !== 4)
28
- return "reserved";
29
- const [a, b] = ip.split(".").map(Number);
30
- if (a === 127)
31
- return "loopback";
32
- // This range includes the cloud metadata address 169.254.169.254.
33
- if (a === 169 && b === 254)
34
- return "link-local";
35
- if (a === 0 || a >= 224)
36
- return "reserved";
37
- // The remaining blocked IPv4 ranges include RFC 6598 shared carrier space (100.64.0.0/10):
38
- // it is not globally routable and belongs with RFC 1918 private ranges for egress decisions.
39
- return "private";
40
- }
8
+ export const classifyAddress = classifyIpAddress;
41
9
  const CROSS_ORIGIN_HEADERS = new Set(["accept", "accept-language", "user-agent"]);
42
10
  // HTTP header names are case-insensitive; preserve the caller's explicit User-Agent in any casing.
43
11
  function hasUserAgentHeader(headers) {
@@ -142,12 +110,16 @@ function capResponse(response, maxBytes) {
142
110
  };
143
111
  }
144
112
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
145
- async function sendWithPolicy(transport, policy, request, redirectsFollowed, dns, redirect) {
113
+ async function sendWithPolicy({ hop, request, redirectsFollowed }, _options = {}) {
146
114
  const url = new URL(request.url);
147
- request.signal?.throwIfAborted();
148
- const peer = await resolveBeforeDeadline(resolvePinnedPeer(url, policy, dns), request.signal);
149
- request.signal?.throwIfAborted();
150
- const boundedRequest = {
115
+ const response = await sendOneHop({ hop, request, url });
116
+ const nextRequest = followableRedirect({ hop, request, url, response, redirectsFollowed });
117
+ if (!nextRequest)
118
+ return response;
119
+ return sendWithPolicy({ hop, request: nextRequest, redirectsFollowed: redirectsFollowed + 1 });
120
+ }
121
+ function boundRequest({ request, policy }, _options = {}) {
122
+ return {
151
123
  ...request,
152
124
  // The policy timeout is a ceiling: callers may choose a shorter idle budget, never a longer one.
153
125
  // Keep the legacy alias synchronized for host transport adapters. Activity resets
@@ -156,34 +128,60 @@ async function sendWithPolicy(transport, policy, request, redirectsFollowed, dns
156
128
  timeoutMs: Math.min(request.idleTimeoutMs ?? request.timeoutMs, policy.connectTimeoutMs),
157
129
  maxResponseBytes: Math.min(request.maxResponseBytes ?? Infinity, policy.maxResponseBytes, policy.maxDecompressedBytes),
158
130
  };
159
- const response = await resolveBeforeDeadline(transport.requestPinned({ request: boundedRequest, peer }), request.signal);
160
- const effectiveCap = Math.min(request.maxResponseBytes ?? Infinity, policy.maxResponseBytes, policy.maxDecompressedBytes);
131
+ }
132
+ async function sendOneHop({ hop, request, url }, _options = {}) {
133
+ request.signal?.throwIfAborted();
134
+ const peer = await resolveBeforeDeadline(resolvePinnedPeer(url, hop.policy, hop.dns), request.signal);
135
+ request.signal?.throwIfAborted();
136
+ const boundedRequest = boundRequest({ request, policy: hop.policy });
137
+ const response = await resolveBeforeDeadline(hop.transport.requestPinned({ request: boundedRequest, peer }), request.signal);
138
+ const effectiveCap = Math.min(request.maxResponseBytes ?? Infinity, hop.policy.maxResponseBytes, hop.policy.maxDecompressedBytes);
161
139
  // Record the normalized URL actually resolved and pinned, not the caller's original spelling.
162
140
  // A followed redirect returns the deeper hop's response, including that hop's finalUrl.
163
141
  const cappedResponse = { ...capResponse(response, effectiveCap), finalUrl: url.href };
164
142
  // A refused redirect is a policy decision like a refused address, so it carries the same type.
165
143
  // The text names no Location target, which may be private, so both messages can share it.
166
- if (redirect === "error" && REDIRECT_STATUSES.has(cappedResponse.status)) {
144
+ if (hop.redirect === "error" && REDIRECT_STATUSES.has(cappedResponse.status)) {
167
145
  const message = "redirect response refused by the request's redirect policy";
168
146
  throw new EgressRefusedError({ message }, { callerSafeMessage: message });
169
147
  }
148
+ return cappedResponse;
149
+ }
150
+ /** The next hop's request when this response is a redirect the policy follows, else undefined. */
151
+ function followableRedirect({ hop, request, url, response, redirectsFollowed }, _options = {}) {
170
152
  // Follow GET only even when the policy allows redirects: 307/308 preserve method and body,
171
153
  // so following a mutating request would replay a write and leak its body to an unchosen target.
172
154
  // With follow policy, a non-GET redirect stays a raw response rather than a target refusal.
173
- const canFollowRedirect = redirect === "follow" && request.method === "GET" && REDIRECT_STATUSES.has(cappedResponse.status) && redirectsFollowed < policy.maxRedirects;
174
- if (!canFollowRedirect) {
175
- return cappedResponse;
176
- }
177
- const location = cappedResponse.headers.location ?? cappedResponse.headers.Location;
155
+ const canFollowRedirect = hop.redirect === "follow" && request.method === "GET" && REDIRECT_STATUSES.has(response.status) && redirectsFollowed < hop.policy.maxRedirects;
156
+ if (!canFollowRedirect)
157
+ return undefined;
158
+ const location = response.headers.location ?? response.headers.Location;
178
159
  if (!location)
179
- return cappedResponse;
160
+ return undefined;
180
161
  const nextUrl = new URL(location, url);
181
162
  // Cross-origin hops must not inherit authorization or cookies entrusted to the original origin.
182
163
  // Custom headers can carry credentials too; forward only the explicit noncredential allowlist.
183
164
  const nextHeaders = isCrossOrigin(url, nextUrl)
184
165
  ? withCrossOriginHeaders(request.headers)
185
166
  : request.headers;
186
- return sendWithPolicy(transport, policy, { ...request, url: nextUrl.toString(), headers: nextHeaders }, redirectsFollowed + 1, dns, redirect);
167
+ return { ...request, url: nextUrl.toString(), headers: nextHeaders };
168
+ }
169
+ function assertPositiveLimit({ value, name }, _options = {}) {
170
+ if (!Number.isSafeInteger(value) || value <= 0)
171
+ throw new TypeError(`${name} must be a positive safe integer`);
172
+ }
173
+ function assertRequestLimits({ request }, _options = {}) {
174
+ assertPositiveLimit({
175
+ value: request.idleTimeoutMs ?? request.timeoutMs,
176
+ name: request.idleTimeoutMs === undefined ? "timeoutMs" : "idleTimeoutMs",
177
+ });
178
+ if (request.totalDeadlineMs !== undefined)
179
+ assertPositiveLimit({ value: request.totalDeadlineMs, name: "totalDeadlineMs" });
180
+ if (request.maxResponseBytes !== undefined)
181
+ assertPositiveLimit({ value: request.maxResponseBytes, name: "maxResponseBytes" });
182
+ }
183
+ function composeRequestSignal({ caller, deadline }, _options = {}) {
184
+ return caller && deadline ? AbortSignal.any([caller, deadline]) : caller ?? deadline;
187
185
  }
188
186
  /** Builds an adapter-friendly outbound port. Every hop is resolved, checked, and pinned before I/O.
189
187
  * Socket idle time is bounded on every hop. An optional totalDeadlineMs includes DNS,
@@ -200,19 +198,11 @@ export function createHttpClient(required) {
200
198
  if (!Number.isSafeInteger(policy.maxRedirects) || policy.maxRedirects < 0)
201
199
  throw new TypeError("maxRedirects must be a nonnegative safe integer");
202
200
  return { async send({ request }, { redirect = "follow" } = {}) {
203
- const idleTimeoutMs = request.idleTimeoutMs ?? request.timeoutMs;
204
- if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs <= 0) {
205
- const timeoutName = request.idleTimeoutMs === undefined ? "timeoutMs" : "idleTimeoutMs";
206
- throw new TypeError(`${timeoutName} must be a positive safe integer`);
207
- }
208
- if (request.totalDeadlineMs !== undefined && (!Number.isSafeInteger(request.totalDeadlineMs) || request.totalDeadlineMs <= 0))
209
- throw new TypeError("totalDeadlineMs must be a positive safe integer");
210
- if (request.maxResponseBytes !== undefined && (!Number.isSafeInteger(request.maxResponseBytes) || request.maxResponseBytes <= 0))
211
- throw new TypeError("maxResponseBytes must be a positive safe integer");
201
+ assertRequestLimits({ request });
212
202
  const deadline = request.totalDeadlineMs === undefined ? undefined : clock.timeoutSignal({ timeoutMs: request.totalDeadlineMs });
213
- const signal = request.signal && deadline ? AbortSignal.any([request.signal, deadline]) : request.signal ?? deadline;
203
+ const signal = composeRequestSignal({ caller: request.signal, deadline });
214
204
  try {
215
- return await sendWithPolicy(transport, policy, { ...request, ...(signal === undefined ? {} : { signal }), headers: withDefaultUserAgent(request.headers, userAgent) }, 0, dns, redirect);
205
+ return await sendWithPolicy({ hop: { transport, policy, dns, redirect }, request: { ...request, ...(signal === undefined ? {} : { signal }), headers: withDefaultUserAgent(request.headers, userAgent) }, redirectsFollowed: 0 });
216
206
  }
217
207
  catch (error) {
218
208
  if (deadline?.aborted && !request.signal?.aborted)
@@ -1,5 +1,4 @@
1
- import type { HttpClientPort } from "@jini-ai/core/primitives";
2
- import type { HttpRequest, RequestRedirect } from "@jini-ai/core/primitives";
1
+ import type { HttpClientPort, HttpRequest, RequestRedirect } from "@jini-ai/core/primitives";
3
2
  export interface GuardedFetchOptions {
4
3
  method?: HttpRequest["method"];
5
4
  headers?: ConstructorParameters<typeof Headers>[0];
@@ -19,5 +18,5 @@ export declare function guardedFetch({ client, url, timeoutMs }: {
19
18
  client: HttpClientPort;
20
19
  url: string;
21
20
  timeoutMs: number;
22
- }, { method, headers, body, signal, redirect, idleTimeoutMs, totalDeadlineMs }?: GuardedFetchOptions): Promise<Response>;
21
+ }, { method, headers, redirect, ...rest }?: GuardedFetchOptions): Promise<Response>;
23
22
  //# sourceMappingURL=fetch-adapter.d.ts.map
@@ -2,18 +2,29 @@
2
2
  * Defaults to redirect refusal. Hosts adapt their SDK's positional fetch signature to this object API.
3
3
  * Strings and form bodies are supported; streaming uploads need a different transport contract.
4
4
  */
5
- export async function guardedFetch({ client, url, timeoutMs }, { method = "GET", headers = {}, body, signal, redirect = "error", idleTimeoutMs, totalDeadlineMs } = {}) {
6
- const response = await client.send({ request: {
7
- method, url, timeoutMs, headers: Object.fromEntries(new Headers(headers).entries()),
8
- ...(body === undefined ? {} : { body: String(body) }),
9
- ...(signal === undefined ? {} : { signal }),
10
- ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
11
- ...(totalDeadlineMs === undefined ? {} : { totalDeadlineMs }),
12
- } }, { redirect });
13
- const truncated = response.bodyBytes === undefined
14
- ? response.bodyTruncated : response.bodyBytesTruncated ?? response.bodyTruncated;
15
- if (truncated)
5
+ export async function guardedFetch({ client, url, timeoutMs }, { method = "GET", headers = {}, redirect = "error", ...rest } = {}) {
6
+ const response = await client.send({ request: toHttpRequest({ method, url, timeoutMs, headers }, rest) }, { redirect });
7
+ if (isTruncated(response))
16
8
  throw new Error("guarded response was truncated by the byte policy");
9
+ // Fetch prohibits bodies for HEAD and these statuses, even when a transport supplies an empty buffer.
10
+ const bodyless = method === "HEAD" || [204, 205, 304].includes(response.status);
11
+ return new Response(bodyless ? null : responseBody(response), { status: response.status, headers: responseHeaders(response) });
12
+ }
13
+ function toHttpRequest({ method, url, timeoutMs, headers }, { body, signal, idleTimeoutMs, totalDeadlineMs }) {
14
+ return {
15
+ method, url, timeoutMs, headers: Object.fromEntries(new Headers(headers).entries()),
16
+ ...(body === undefined ? {} : { body: String(body) }),
17
+ ...(signal === undefined ? {} : { signal }),
18
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
19
+ ...(totalDeadlineMs === undefined ? {} : { totalDeadlineMs }),
20
+ };
21
+ }
22
+ // With bytes present, the byte-specific flag decides; the coarse flag only fills in when it is absent.
23
+ function isTruncated(response) {
24
+ return response.bodyBytes === undefined
25
+ ? response.bodyTruncated : response.bodyBytesTruncated ?? response.bodyTruncated;
26
+ }
27
+ function responseHeaders(response) {
17
28
  const resultHeaders = new Headers(response.headers);
18
29
  resultHeaders.delete("content-encoding");
19
30
  resultHeaders.delete("content-length");
@@ -22,9 +33,9 @@ export async function guardedFetch({ client, url, timeoutMs }, { method = "GET",
22
33
  for (const cookie of response.setCookies)
23
34
  resultHeaders.append("set-cookie", cookie);
24
35
  }
25
- const bytes = response.bodyBytes === undefined ? response.bodyText : Buffer.from(response.bodyBytes);
26
- // Fetch prohibits bodies for HEAD and these statuses, even when a transport supplies an empty buffer.
27
- const bodyless = method === "HEAD" || [204, 205, 304].includes(response.status);
28
- return new Response(bodyless ? null : bytes, { status: response.status, headers: resultHeaders });
36
+ return resultHeaders;
37
+ }
38
+ function responseBody(response) {
39
+ return response.bodyBytes === undefined ? response.bodyText : Buffer.from(response.bodyBytes);
29
40
  }
30
41
  //# sourceMappingURL=fetch-adapter.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @file The two built-in mail providers: `smtp` (any mailbox/relay) and `console` (local only).
3
+ *
4
+ * SMTP gets its transport from the host (nodemailer is never loaded by this package); console logs
5
+ * each message with its body (the local preview of sign-in/confirmation links) and reports
6
+ * `hosted: false`, so it can never make a site look configured.
7
+ */
8
+ import { type Clock } from "@jini-ai/core/primitives";
9
+ import type { MailProviderModule } from "./provider.js";
10
+ import { type SmtpTransport } from "./smtp.js";
11
+ /**
12
+ * SMTP endpoint packed into a credential `baseUrl` (`https://host:port`); 443/465 = implicit TLS.
13
+ * @complexity O(1).
14
+ */
15
+ export declare function parseSmtpEndpoint({ baseUrl }: {
16
+ baseUrl: string;
17
+ }): {
18
+ host: string;
19
+ port: number;
20
+ secure: boolean;
21
+ };
22
+ export declare function createSmtpMailProvider(required: {
23
+ createTransport: (required: {
24
+ host: string;
25
+ port: number;
26
+ secure: boolean;
27
+ auth: {
28
+ user: string;
29
+ pass: string;
30
+ };
31
+ }) => SmtpTransport;
32
+ clock: Clock;
33
+ }): MailProviderModule;
34
+ export declare function createConsoleMailProvider(required: {
35
+ clock: Clock;
36
+ }, optional?: {
37
+ log?: (line: string) => void;
38
+ }): MailProviderModule;
39
+ //# sourceMappingURL=builtin-providers.d.ts.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @file The two built-in mail providers: `smtp` (any mailbox/relay) and `console` (local only).
3
+ *
4
+ * SMTP gets its transport from the host (nodemailer is never loaded by this package); console logs
5
+ * each message with its body (the local preview of sign-in/confirmation links) and reports
6
+ * `hosted: false`, so it can never make a site look configured.
7
+ */
8
+ import { nowIso } from "@jini-ai/core/primitives";
9
+ import { SmtpMailerAdapter } from "./smtp.js";
10
+ /**
11
+ * SMTP endpoint packed into a credential `baseUrl` (`https://host:port`); 443/465 = implicit TLS.
12
+ * @complexity O(1).
13
+ */
14
+ export function parseSmtpEndpoint({ baseUrl }) {
15
+ const url = new URL(baseUrl);
16
+ const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
17
+ return { host: url.hostname, port, secure: port === 443 || port === 465 };
18
+ }
19
+ export function createSmtpMailProvider(required) {
20
+ return {
21
+ id: "smtp",
22
+ label: "SMTP server",
23
+ credentialKeys: ["baseUrl", "username", "token"],
24
+ capabilities: { hosted: true, feedback: false, domainVerification: false },
25
+ create({ credential }) {
26
+ const endpoint = parseSmtpEndpoint({ baseUrl: credential.baseUrl ?? "" });
27
+ const transport = required.createTransport({ ...endpoint, auth: { user: credential.username ?? "", pass: credential.token } });
28
+ return new SmtpMailerAdapter({ transport, clock: required.clock });
29
+ },
30
+ };
31
+ }
32
+ export function createConsoleMailProvider(required, optional = {}) {
33
+ const log = optional.log ?? ((line) => console.log(line));
34
+ let counter = 0;
35
+ const mailer = {
36
+ capabilities: () => ({ driver: "console", supportsIdempotencyKey: false, supportsWebhookFeedback: false, maxBatchSize: 1, supportsAttachments: true }),
37
+ async send({ message }) {
38
+ counter += 1;
39
+ // The body is the point of a local mailer: a sign-in or confirmation link must be usable from the
40
+ // log, since nothing is delivered. Text is preferred (readable); an html-only message shows its html.
41
+ const body = message.text ?? message.html;
42
+ log(`[mail:console] to=${message.to.email} subject=${JSON.stringify(message.subject)}${body === undefined ? "" : `\n${body}`}`);
43
+ return { ok: true, providerMessageId: `console-${counter}`, acceptedAt: nowIso({ clock: required.clock }) };
44
+ },
45
+ async sendBatch({ messages, ...delivery }) {
46
+ const results = [];
47
+ for (const message of messages)
48
+ results.push(await mailer.send({ ...delivery, message }));
49
+ return results;
50
+ },
51
+ };
52
+ return {
53
+ id: "console",
54
+ label: "Console (local development, sends nothing)",
55
+ credentialKeys: [],
56
+ capabilities: { hosted: false, feedback: false, domainVerification: false },
57
+ create: () => mailer,
58
+ };
59
+ }
60
+ //# sourceMappingURL=builtin-providers.js.map
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @file Inbound delivery feedback (bounces, complaints) — one ingest, one suppression writer.
3
+ *
4
+ * The host mounts this behind `POST /hooks/mail/:providerId` with the RAW body. Hardening lives
5
+ * here so every host gets it: a body-size cap before parsing, a rate limit on unverified requests
6
+ * (signature guessing; a verified request is never throttled), vault-held secrets with rotation (any of several may verify), and an inbox
7
+ * keyed by (providerId, eventId) so provider replays are no-ops.
8
+ *
9
+ * The inbox row and the suppression write are separate statements with no enclosing transaction,
10
+ * so the row is only marked complete after suppression and the subscriber succeed: a crash or error
11
+ * in between leaves it incomplete, and the provider's replay re-applies it (suppression is an upsert).
12
+ */
13
+ import type { MailFeedbackParser } from "./provider.js";
14
+ import type { MailSuppressionRepoPort, MailSuppressionScope } from "./ports.js";
15
+ import type { MailerFeedbackEvent } from "./types.js";
16
+ export interface MailFeedbackInboxPort {
17
+ /**
18
+ * Insert-if-absent on (providerId, eventId). `duplicate` means this event was already applied
19
+ * (completed); `inserted` means it must be applied now: either new, or recorded earlier but never
20
+ * completed (the earlier attempt failed or stopped part-way).
21
+ */
22
+ record(required: {
23
+ providerId: string;
24
+ event: MailerFeedbackEvent & {
25
+ eventId: string;
26
+ recipient: string;
27
+ };
28
+ }): Promise<"inserted" | "duplicate">;
29
+ /** Marks (providerId, eventId) applied; later `record` calls for it return `duplicate`. */
30
+ complete(required: {
31
+ providerId: string;
32
+ eventId: string;
33
+ }): Promise<void>;
34
+ }
35
+ export interface MailFeedbackIngestDeps {
36
+ /** Only providers whose module ships a parser; others 404 so the route reveals nothing else. */
37
+ parserFor: (required: {
38
+ providerId: string;
39
+ }) => MailFeedbackParser | undefined;
40
+ /** Current + previous webhook secrets from the vault; empty = not configured. */
41
+ secretsFor: (required: {
42
+ providerId: string;
43
+ }) => Promise<readonly string[]>;
44
+ inbox: MailFeedbackInboxPort;
45
+ suppression: MailSuppressionRepoPort;
46
+ }
47
+ export interface MailFeedbackIngestOptions {
48
+ maxBodyBytes?: number;
49
+ /** Unverified requests allowed per provider per window before 429. */
50
+ maxUnverifiedPerWindow?: number;
51
+ windowMs?: number;
52
+ now?: () => number;
53
+ /** Extra subscriber after suppression (e.g. newsletter recipient status). Errors propagate. */
54
+ onEvent?: (required: {
55
+ providerId: string;
56
+ event: MailerFeedbackEvent;
57
+ }) => Promise<void>;
58
+ }
59
+ export interface MailFeedbackResponse {
60
+ status: 200 | 400 | 401 | 404 | 413 | 429 | 503;
61
+ body: {
62
+ ok: boolean;
63
+ applied?: number;
64
+ duplicates?: number;
65
+ error?: string;
66
+ };
67
+ }
68
+ /** Hard bounce → every lane; complaint → bulk only (never lock a member out of sign-in links). */
69
+ export declare function suppressionScopeFor({ event }: {
70
+ event: MailerFeedbackEvent;
71
+ }): MailSuppressionScope | null;
72
+ /**
73
+ * @complexity O(events) inbox writes per request.
74
+ * @example createMailFeedbackIngest({ parserFor, secretsFor, inbox, suppression }).handle({...})
75
+ */
76
+ export declare function createMailFeedbackIngest(required: MailFeedbackIngestDeps, optional?: MailFeedbackIngestOptions): {
77
+ handle(input: {
78
+ providerId: string;
79
+ workspaceId: string;
80
+ rawBody: Uint8Array;
81
+ headers: Readonly<Record<string, string>>;
82
+ }): Promise<MailFeedbackResponse>;
83
+ };
84
+ //# sourceMappingURL=feedback.d.ts.map
@@ -0,0 +1,90 @@
1
+ /** Hard bounce → every lane; complaint → bulk only (never lock a member out of sign-in links). */
2
+ export function suppressionScopeFor({ event }) {
3
+ if (event.kind === "bounced")
4
+ return event.hardBounce ? "all" : null;
5
+ if (event.kind === "complained")
6
+ return "bulk";
7
+ return null;
8
+ }
9
+ function isKeyed(event) {
10
+ return typeof event.eventId === "string" && event.eventId !== "" && typeof event.recipient === "string" && event.recipient !== "";
11
+ }
12
+ function createUnverifiedCounter(required) {
13
+ const windows = new Map();
14
+ return {
15
+ blocked({ providerId }) {
16
+ const entry = windows.get(providerId);
17
+ return entry !== undefined && required.now() - entry.start < required.windowMs && entry.count >= required.max;
18
+ },
19
+ note({ providerId }) {
20
+ const at = required.now();
21
+ const entry = windows.get(providerId);
22
+ if (entry === undefined || at - entry.start >= required.windowMs)
23
+ windows.set(providerId, { start: at, count: 1 });
24
+ else
25
+ entry.count += 1;
26
+ },
27
+ };
28
+ }
29
+ /**
30
+ * @complexity O(events) inbox writes per request.
31
+ * @example createMailFeedbackIngest({ parserFor, secretsFor, inbox, suppression }).handle({...})
32
+ */
33
+ export function createMailFeedbackIngest(required, optional = {}) {
34
+ const now = optional.now ?? Date.now;
35
+ const maxBodyBytes = optional.maxBodyBytes ?? 256 * 1024;
36
+ const unverified = createUnverifiedCounter({ max: optional.maxUnverifiedPerWindow ?? 30, windowMs: optional.windowMs ?? 60_000, now });
37
+ const apply = async (input) => {
38
+ const outcome = await required.inbox.record(input);
39
+ if (outcome === "duplicate")
40
+ return outcome;
41
+ const scope = suppressionScopeFor({ event: input.event });
42
+ if (scope !== null) {
43
+ const reason = input.event.kind === "complained" ? "complained" : "bounced";
44
+ await required.suppression.suppress({ workspaceId: input.event.workspaceId, normalizedAddress: input.event.recipient.trim().toLowerCase(), scope, reason });
45
+ }
46
+ await optional.onEvent?.(input);
47
+ await required.inbox.complete({ providerId: input.providerId, eventId: input.event.eventId });
48
+ return outcome;
49
+ };
50
+ const authenticate = async (input) => {
51
+ const secrets = await required.secretsFor({ providerId: input.providerId });
52
+ if (secrets.length === 0)
53
+ return { status: 503, body: { ok: false, error: "webhook secret not configured" } };
54
+ if (input.parser.verify({ rawBody: input.rawBody, headers: input.headers, secrets, now: now() }))
55
+ return null;
56
+ // The throttle answers failed verifications only. Checked before verifying, it let anyone who can
57
+ // reach the route block a provider's genuinely signed bounces/complaints with 30 junk requests.
58
+ const throttled = unverified.blocked({ providerId: input.providerId });
59
+ unverified.note({ providerId: input.providerId });
60
+ if (throttled)
61
+ return { status: 429, body: { ok: false, error: "too many unverified requests" } };
62
+ return { status: 401, body: { ok: false, error: "signature rejected" } };
63
+ };
64
+ return {
65
+ async handle(input) {
66
+ const parser = required.parserFor({ providerId: input.providerId });
67
+ if (!parser)
68
+ return { status: 404, body: { ok: false, error: "unknown provider" } };
69
+ if (input.rawBody.byteLength > maxBodyBytes)
70
+ return { status: 413, body: { ok: false, error: "body too large" } };
71
+ const denied = await authenticate({ ...input, parser });
72
+ if (denied)
73
+ return denied;
74
+ const parsed = parser.parse({ rawBody: input.rawBody, workspaceId: input.workspaceId, headers: input.headers });
75
+ if ("error" in parsed)
76
+ return { status: 400, body: { ok: false, error: parsed.error } };
77
+ let applied = 0;
78
+ let duplicates = 0;
79
+ // Events without an id or recipient cannot be deduped or suppressed; they are dropped, not guessed.
80
+ for (const event of parsed.filter(isKeyed)) {
81
+ if ((await apply({ providerId: input.providerId, event })) === "inserted")
82
+ applied += 1;
83
+ else
84
+ duplicates += 1;
85
+ }
86
+ return { status: 200, body: { ok: true, applied, duplicates } };
87
+ },
88
+ };
89
+ }
90
+ //# sourceMappingURL=feedback.js.map
@@ -4,4 +4,15 @@ export type * from "./adapter-module.js";
4
4
  export { wrapMailerWithPurposeGate } from "./purpose-scoped-mailer.js";
5
5
  export type { WrapMailerWithPurposeGateOptions } from "./purpose-scoped-mailer.js";
6
6
  export { isMailDeliveryAvailable } from "./delivery-availability.js";
7
+ export { createMailSenderIdentityResolver, NON_DELIVERING_MAIL_DRIVERS, SenderIdentityNotConfiguredError } from "./sender-identity.js";
8
+ export type { ConfiguredMailSender, CreateMailSenderIdentityResolverOptional, MailSenderIdentityPort } from "./sender-identity.js";
9
+ export { createMailProviderRegistry, validateMailProvider, initialMailProviderSetting, computeMailStatus, MAIL_PROVIDER_SETTING_KEY } from "./provider.js";
10
+ export type * from "./provider.js";
11
+ export { createMailLaneLimiter } from "./rate-limit.js";
12
+ export type { MailLaneLimiter } from "./rate-limit.js";
13
+ export { createMailDeliveryPolicy, createPolicyMailer, laneForPurpose, hasOneClickUnsubscribe, mailPayloadHash, MAIL_RATE_LIMITED, isMailRateLimited } from "./policy.js";
14
+ export type { MailDeliveryPolicy, MailDeliveryPolicyDeps, MailDeliveryPolicyOptions, MailResendPolicy, MailTestMode } from "./policy.js";
15
+ export { createMailFeedbackIngest, suppressionScopeFor } from "./feedback.js";
16
+ export type { MailFeedbackInboxPort, MailFeedbackIngestDeps, MailFeedbackIngestOptions, MailFeedbackResponse } from "./feedback.js";
17
+ export { createSmtpMailProvider, createConsoleMailProvider, parseSmtpEndpoint } from "./builtin-providers.js";
7
18
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,9 @@
1
1
  export { wrapMailerWithPurposeGate } from "./purpose-scoped-mailer.js";
2
2
  export { isMailDeliveryAvailable } from "./delivery-availability.js";
3
+ export { createMailSenderIdentityResolver, NON_DELIVERING_MAIL_DRIVERS, SenderIdentityNotConfiguredError } from "./sender-identity.js";
4
+ export { createMailProviderRegistry, validateMailProvider, initialMailProviderSetting, computeMailStatus, MAIL_PROVIDER_SETTING_KEY } from "./provider.js";
5
+ export { createMailLaneLimiter } from "./rate-limit.js";
6
+ export { createMailDeliveryPolicy, createPolicyMailer, laneForPurpose, hasOneClickUnsubscribe, mailPayloadHash, MAIL_RATE_LIMITED, isMailRateLimited } from "./policy.js";
7
+ export { createMailFeedbackIngest, suppressionScopeFor } from "./feedback.js";
8
+ export { createSmtpMailProvider, createConsoleMailProvider, parseSmtpEndpoint } from "./builtin-providers.js";
3
9
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,77 @@
1
+ import type { MailCapturePort, MailDeliveryLane, MailerPort, MailSendLedgerPort, MailSuppressionRepoPort } from "./ports.js";
2
+ import type { MailLaneLimiter } from "./rate-limit.js";
3
+ import type { EmailAddress, MailerSendOptions, MailerSendResult, OutboundEmail } from "./types.js";
4
+ /**
5
+ * The one throttling code of the mail contract: the local token bucket's refusal and a provider's own
6
+ * throttling (e.g. an HTTP 429, which adapters normalize to it) are the same retryable back-pressure,
7
+ * with `retryAfterMs` as the hint. Callers defer such a recipient instead of recording a failure.
8
+ */
9
+ export declare const MAIL_RATE_LIMITED = "MAIL_RATE_LIMITED";
10
+ /** Whether a send result is the typed rate-limited refusal. @complexity O(1). */
11
+ export declare function isMailRateLimited({ result }: {
12
+ result: MailerSendResult;
13
+ }): boolean;
14
+ /** `never`: login links — the user asks again; `on_unknown`: confirmations may be re-sent once unknown. */
15
+ export type MailResendPolicy = "never" | "on_unknown";
16
+ export type MailTestMode = {
17
+ kind: "redirect";
18
+ to: EmailAddress;
19
+ } | {
20
+ kind: "capture";
21
+ sink: MailCapturePort;
22
+ };
23
+ export interface MailDeliveryPolicyDeps {
24
+ providerId: string;
25
+ ledger: MailSendLedgerPort;
26
+ suppression: MailSuppressionRepoPort;
27
+ supportsAttachments: boolean;
28
+ }
29
+ export interface MailDeliveryPolicyOptions {
30
+ limiter?: MailLaneLimiter;
31
+ testMode?: MailTestMode;
32
+ /** Per-purpose resend policy; unlisted purposes use `on_unknown`. */
33
+ resendPolicy?: (required: {
34
+ purpose: MailerSendOptions["purpose"];
35
+ }) => MailResendPolicy;
36
+ /** ISO clock for synthesized results (dedup replays, captures). */
37
+ nowIso?: () => string;
38
+ }
39
+ export interface MailDeliveryPolicy {
40
+ /** Applies the policy to one message; `send` is the provider call (any host call shape). */
41
+ deliver(required: {
42
+ message: OutboundEmail;
43
+ options: MailerSendOptions;
44
+ send: (required: {
45
+ message: OutboundEmail;
46
+ }) => Promise<MailerSendResult>;
47
+ }): Promise<MailerSendResult>;
48
+ }
49
+ /** Bulk lane only for `purpose: "bulk"`; everything else (incl. `feature:*`) is transactional. */
50
+ export declare function laneForPurpose({ purpose }: {
51
+ purpose: MailerSendOptions["purpose"];
52
+ }): MailDeliveryLane;
53
+ /** RFC 8058 one-click needs both headers; header names are case-insensitive. */
54
+ export declare function hasOneClickUnsubscribe({ headers }: {
55
+ headers: OutboundEmail["headers"];
56
+ }): boolean;
57
+ /**
58
+ * Stable per-recipient payload hash: a reused key with different content is a caller bug. It covers
59
+ * everything the provider delivers (addresses with display names, reply-to, subject, bodies, headers,
60
+ * attachment names, types and bytes), so a changed message under a sent key is a conflict rather than
61
+ * the earlier message's success. Attachment bytes enter as their own sha256 to keep the input small.
62
+ * @complexity O(message size).
63
+ */
64
+ export declare function mailPayloadHash({ message }: {
65
+ message: OutboundEmail;
66
+ }): string;
67
+ /**
68
+ * Builds the policy. @complexity O(1) ledger round-trips per recipient.
69
+ * @example createMailDeliveryPolicy({ providerId: "smtp", ledger, suppression, supportsAttachments: true })
70
+ */
71
+ export declare function createMailDeliveryPolicy(required: MailDeliveryPolicyDeps, optional?: MailDeliveryPolicyOptions): MailDeliveryPolicy;
72
+ /** Jini-shaped `MailerPort` decorator over the policy; batches go recipient by recipient. */
73
+ export declare function createPolicyMailer(required: {
74
+ inner: MailerPort;
75
+ policy: MailDeliveryPolicy;
76
+ }): MailerPort;
77
+ //# sourceMappingURL=policy.d.ts.map