@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.
- package/CHANGELOG.md +22 -1
- package/dist/asset-cache.js +26 -25
- package/dist/blob-storage.d.ts +7 -2
- package/dist/blob-storage.js +99 -59
- package/dist/child-environment.d.ts +36 -0
- package/dist/child-environment.js +68 -0
- package/dist/download.js +192 -148
- package/dist/fs/atomic-write.js +105 -93
- package/dist/fs/boot-identity.d.ts +16 -0
- package/dist/fs/boot-identity.js +38 -17
- package/dist/fs/directory-home.js +6 -0
- package/dist/fs/directory-registry.d.ts +1 -1
- package/dist/fs/directory-registry.js +23 -16
- package/dist/fs/directory-running-lock.d.ts +1 -1
- package/dist/fs/directory-running-lock.js +18 -7
- package/dist/fs/directory-sync.d.ts +1 -1
- package/dist/fs/directory-sync.js +64 -51
- package/dist/fs/directory-user-config.js +108 -67
- package/dist/fs/directory-workspace.d.ts +11 -0
- package/dist/fs/directory-workspace.js +16 -11
- package/dist/fs/durable-json.js +49 -37
- package/dist/fs/file-lock.js +214 -149
- package/dist/fs/guarded-reader.js +47 -35
- package/dist/fs/user-data-directories.d.ts +1 -1
- package/dist/fs/user-data-directories.js +25 -9
- package/dist/fs/verified-directory-move.d.ts +13 -11
- package/dist/fs/verified-directory-move.js +105 -68
- package/dist/fs/verified-file-relocation.d.ts +1 -1
- package/dist/fs/verified-file-relocation.js +111 -94
- package/dist/http/guarded/client.d.ts +3 -4
- package/dist/http/guarded/client.js +50 -60
- package/dist/http/guarded/fetch-adapter.d.ts +2 -3
- package/dist/http/guarded/fetch-adapter.js +26 -15
- package/dist/mail/builtin-providers.d.ts +39 -0
- package/dist/mail/builtin-providers.js +60 -0
- package/dist/mail/feedback.d.ts +84 -0
- package/dist/mail/feedback.js +90 -0
- package/dist/mail/index.d.ts +11 -0
- package/dist/mail/index.js +6 -0
- package/dist/mail/policy.d.ts +77 -0
- package/dist/mail/policy.js +155 -0
- package/dist/mail/ports.d.ts +56 -7
- package/dist/mail/provider.d.ts +117 -0
- package/dist/mail/provider.js +74 -0
- package/dist/mail/rate-limit.d.ts +33 -0
- package/dist/mail/rate-limit.js +35 -0
- package/dist/mail/sender-identity.d.ts +68 -0
- package/dist/mail/sender-identity.js +50 -0
- package/dist/mail/types.d.ts +9 -0
- package/dist/net/address.d.ts +9 -23
- package/dist/net/address.js +28 -133
- package/dist/net/endpoint-policy.d.ts +26 -2
- package/dist/net/endpoint-policy.js +159 -56
- package/dist/object-store/index.d.ts +2 -0
- package/dist/object-store/index.js +2 -0
- package/dist/object-store/local-fs.d.ts +17 -0
- package/dist/object-store/local-fs.js +146 -0
- package/dist/process.js +2 -0
- package/dist/proxy-env.js +131 -135
- package/dist/secrets/credential-sets/contracts.d.ts +1 -0
- package/dist/secrets/credential-sets/contracts.js +1 -0
- package/dist/secrets/credential-sets/index.d.ts +1 -0
- package/dist/secrets/credential-sets/index.js +1 -0
- package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
- package/dist/secrets/credential-sets/provider-matches.js +61 -0
- package/dist/secrets/credential-sets/types.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.meta.js +58 -20
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
- package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
- package/dist/secrets/credential-sets/vault.service.js +131 -61
- package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
- package/dist/secrets/credential-sets/vault.types.js +22 -8
- package/dist/secrets/keyring.env.js +14 -10
- package/dist/toolchain.js +119 -91
- package/dist/web/html-document.js +73 -69
- package/dist/web/html-to-markdown.js +127 -102
- package/dist/web/web-page.js +160 -80
- 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 {
|
|
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
|
|
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(
|
|
113
|
+
async function sendWithPolicy({ hop, request, redirectsFollowed }, _options = {}) {
|
|
146
114
|
const url = new URL(request.url);
|
|
147
|
-
request
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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(
|
|
174
|
-
if (!canFollowRedirect)
|
|
175
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
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 = {},
|
|
6
|
-
const response = await client.send({ request: {
|
|
7
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return
|
|
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
|
package/dist/mail/index.d.ts
CHANGED
|
@@ -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
|
package/dist/mail/index.js
CHANGED
|
@@ -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
|