@jini-ai/platform 0.6.15-rc.1 → 0.6.15
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 +26 -0
- package/README.md +5 -1
- package/dist/blob-storage.js +10 -9
- package/dist/child-environment.js +1 -1
- package/dist/command.js +4 -1
- package/dist/core/effective-port.d.ts +6 -0
- package/dist/core/effective-port.js +8 -0
- package/dist/download.js +25 -24
- package/dist/fetch-with-timeout.d.ts +44 -1
- package/dist/fetch-with-timeout.js +60 -1
- package/dist/fs/atomic-write.js +5 -2
- package/dist/fs/boot-identity.d.ts +0 -4
- package/dist/fs/boot-identity.js +9 -3
- package/dist/fs/directory-registry.js +2 -2
- package/dist/fs/directory-running-lock.js +9 -4
- package/dist/fs/directory-sync.js +2 -3
- package/dist/fs/directory-workspace.js +11 -14
- package/dist/fs/durable-json.js +22 -16
- package/dist/fs/errno.d.ts +9 -0
- package/dist/fs/errno.js +21 -0
- package/dist/fs/file-lock.js +17 -19
- package/dist/fs/guarded-reader.js +15 -9
- package/dist/fs/user-data-directories.js +5 -7
- package/dist/fs.js +2 -1
- package/dist/http/guarded/client.js +2 -1
- package/dist/http.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mail/adapter-module.d.ts +18 -0
- package/dist/mail/adapter-module.js +13 -1
- package/dist/mail/builtin-providers.js +4 -2
- package/dist/mail/feedback.js +1 -1
- package/dist/mail/index.d.ts +1 -0
- package/dist/mail/index.js +1 -0
- package/dist/mail/prepare-sender-email.js +7 -1
- package/dist/mail/provider.js +18 -9
- package/dist/mail/rate-limit.js +3 -1
- package/dist/net/endpoint-policy.js +15 -7
- package/dist/object-store/local-fs.js +3 -1
- package/dist/process.js +10 -9
- package/dist/proxy-env.js +22 -14
- package/dist/secrets/credential-sets/provider-matches.js +14 -3
- package/dist/secrets/credential-sets/vault.meta.js +6 -2
- package/dist/secrets/credential-sets/vault.repo.memory.js +3 -5
- package/dist/secrets/credential-sets/vault.service.js +22 -17
- package/dist/secrets/credential-sets/vault.types.d.ts +6 -0
- package/dist/secrets/credential-sets/vault.types.js +7 -0
- package/dist/secrets/credential-token.js +9 -2
- package/dist/secrets/keyring.env.js +2 -4
- package/dist/shell.js +2 -1
- package/dist/terminal.js +4 -7
- package/dist/toolchain.js +4 -9
- package/dist/web/first-distinct.d.ts +11 -0
- package/dist/web/first-distinct.js +16 -0
- package/dist/web/html-document.js +35 -39
- package/dist/web/html-to-markdown.js +20 -9
- package/dist/web/web-page.js +28 -25
- package/dist/windows-system-tools.d.ts +15 -0
- package/dist/windows-system-tools.js +16 -0
- package/package.json +2 -2
package/dist/fs/durable-json.js
CHANGED
|
@@ -43,8 +43,9 @@ export function createNodeDurableJsonPorts(_required) {
|
|
|
43
43
|
export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }, { pollMs = 10, staleMs = 30000 } = {}) {
|
|
44
44
|
if (!filePath)
|
|
45
45
|
throw new TypeError("filePath is required");
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const validPoll = Number.isSafeInteger(pollMs) && pollMs > 0;
|
|
47
|
+
const validStale = (Number.isSafeInteger(staleMs) || staleMs === Infinity) && staleMs > 0;
|
|
48
|
+
if (!validPoll || !validStale)
|
|
48
49
|
throw new TypeError("lock intervals must be positive safe integers");
|
|
49
50
|
// A synchronous read-modify-write normally takes milliseconds; five seconds allows contention
|
|
50
51
|
// while refusing a wedged change. Short ten-millisecond polling fits those short critical sections.
|
|
@@ -139,31 +140,36 @@ export function salvageJsonPrefix({ text }, { maxAttempts = 1000 } = {}) {
|
|
|
139
140
|
}
|
|
140
141
|
// Cut only at completed strings/objects/arrays, sufficient for string-valued lists. Damage inside a
|
|
141
142
|
// bare number/boolean falls back to the previous cut and loses that element rather than guessing it.
|
|
143
|
+
const CLOSER_FOR = new Map([["{", "}"], ["[", "]"]]);
|
|
144
|
+
const CLOSERS = new Set(["}", "]"]);
|
|
142
145
|
function elementEnds(text) {
|
|
143
146
|
const open = [];
|
|
144
147
|
const cuts = [];
|
|
145
|
-
|
|
148
|
+
let index = 0;
|
|
149
|
+
while (index < text.length) {
|
|
146
150
|
const char = text[index];
|
|
151
|
+
const closer = CLOSER_FOR.get(char);
|
|
152
|
+
// An opener only records its closer; a completed string or container is a cut point.
|
|
153
|
+
if (closer !== undefined)
|
|
154
|
+
open.push(closer);
|
|
155
|
+
if (CLOSERS.has(char))
|
|
156
|
+
open.pop();
|
|
147
157
|
if (char === '"')
|
|
148
158
|
index = endOfString(text, index);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
else if (char === "}" || char === "]")
|
|
154
|
-
open.pop();
|
|
155
|
-
else
|
|
156
|
-
continue;
|
|
157
|
-
cuts.push({ end: index + 1, closers: [...open].reverse().join("") });
|
|
159
|
+
if (char === '"' || CLOSERS.has(char))
|
|
160
|
+
cuts.push({ end: index + 1, closers: [...open].reverse().join("") });
|
|
161
|
+
index += 1;
|
|
158
162
|
}
|
|
159
163
|
return cuts;
|
|
160
164
|
}
|
|
161
165
|
function endOfString(text, start) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
let index = start + 1;
|
|
167
|
+
while (index < text.length) {
|
|
168
|
+
const char = text[index];
|
|
169
|
+
if (char === '"')
|
|
166
170
|
return index;
|
|
171
|
+
// A backslash escapes the next character, whatever it is.
|
|
172
|
+
index += char === "\\" ? 2 : 1;
|
|
167
173
|
}
|
|
168
174
|
return text.length;
|
|
169
175
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The string `code` of a Node system error (`ENOENT`, `EEXIST`, ...), or undefined for anything else.
|
|
2
|
+
* @complexity O(1). */
|
|
3
|
+
export declare function errorCodeOf(error: unknown): string | undefined;
|
|
4
|
+
/** Returns `fallback` when `matches(error)` and rethrows anything else, so an expected failure reads
|
|
5
|
+
* `catch (error) { return fallbackIf(error, isExpected, undefined); }`. @complexity O(matches). */
|
|
6
|
+
export declare function fallbackIf<T>(error: unknown, matches: (error: unknown) => boolean, fallback: T): T;
|
|
7
|
+
/** {@link fallbackIf} for a missing entry (ENOENT). @complexity O(1). */
|
|
8
|
+
export declare function fallbackIfMissing<T>(error: unknown, fallback: T): T;
|
|
9
|
+
//# sourceMappingURL=errno.d.ts.map
|
package/dist/fs/errno.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The string `code` of a Node system error (`ENOENT`, `EEXIST`, ...), or undefined for anything else.
|
|
2
|
+
* @complexity O(1). */
|
|
3
|
+
export function errorCodeOf(error) {
|
|
4
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
5
|
+
return undefined;
|
|
6
|
+
return typeof error.code === "string" ? error.code : undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Returns `fallback` when `matches(error)` and rethrows anything else, so an expected failure reads
|
|
9
|
+
* `catch (error) { return fallbackIf(error, isExpected, undefined); }`. @complexity O(matches). */
|
|
10
|
+
export function fallbackIf(error, matches, fallback) {
|
|
11
|
+
if (matches(error))
|
|
12
|
+
return fallback;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
/** {@link fallbackIf} for a missing entry (ENOENT). @complexity O(1). */
|
|
16
|
+
export function fallbackIfMissing(error, fallback) {
|
|
17
|
+
if (error.code === "ENOENT")
|
|
18
|
+
return fallback;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=errno.js.map
|
package/dist/fs/file-lock.js
CHANGED
|
@@ -18,6 +18,7 @@ import { hostname as nodeHostname } from "node:os";
|
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { createSystemClock } from "@jini-ai/core/primitives";
|
|
20
20
|
import { defaultPlatformMessages } from "../messages.js";
|
|
21
|
+
import { errorCodeOf, fallbackIf } from "./errno.js";
|
|
21
22
|
export const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
|
|
22
23
|
export const DEFAULT_LOCK_STALE_AFTER_MS = 10_000;
|
|
23
24
|
export class FileLockTimeoutError extends Error {
|
|
@@ -44,14 +45,14 @@ export class FileLockLostError extends Error {
|
|
|
44
45
|
export function isContendedLockError({ code, platform }) {
|
|
45
46
|
return code === "EEXIST" || (code === "EPERM" && platform === "win32");
|
|
46
47
|
}
|
|
47
|
-
function
|
|
48
|
-
return
|
|
48
|
+
function isContended(error) {
|
|
49
|
+
return isContendedLockError({ code: errorCodeOf(error), platform: process.platform });
|
|
49
50
|
}
|
|
50
51
|
function isAbsentOrContended(error) {
|
|
51
|
-
const code =
|
|
52
|
+
const code = errorCodeOf(error);
|
|
52
53
|
return code === "ENOENT" || isContendedLockError({ code, platform: process.platform });
|
|
53
54
|
}
|
|
54
|
-
function rethrowUnlessMissing(error) { if (
|
|
55
|
+
function rethrowUnlessMissing(error) { if (errorCodeOf(error) !== "ENOENT")
|
|
55
56
|
throw error; }
|
|
56
57
|
function validPid(pid) { return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; }
|
|
57
58
|
function isHolderRecord(value) {
|
|
@@ -77,7 +78,10 @@ export function isLockStale(required) {
|
|
|
77
78
|
return (required.ownerAlive === false || required.nowMs - required.mtimeMs > required.staleMs) && required.observedInode === required.currentInode && required.observedDevice === required.currentDevice;
|
|
78
79
|
}
|
|
79
80
|
function intervalsValid({ timeoutMs, staleMs, pollMs }) {
|
|
80
|
-
|
|
81
|
+
const validTimeout = Number.isSafeInteger(timeoutMs) && timeoutMs >= 0;
|
|
82
|
+
const validStale = (Number.isSafeInteger(staleMs) || staleMs === Infinity) && staleMs > 0;
|
|
83
|
+
const validPoll = Number.isSafeInteger(pollMs) && pollMs > 0;
|
|
84
|
+
return validTimeout && validStale && validPoll;
|
|
81
85
|
}
|
|
82
86
|
function intervals(options) {
|
|
83
87
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_AFTER_MS, pollMs = options.pollMs ?? 10;
|
|
@@ -91,7 +95,7 @@ function defaultProcessPort() {
|
|
|
91
95
|
return true;
|
|
92
96
|
}
|
|
93
97
|
catch (error) {
|
|
94
|
-
return
|
|
98
|
+
return errorCodeOf(error) !== "ESRCH";
|
|
95
99
|
} } };
|
|
96
100
|
}
|
|
97
101
|
function settings(options) {
|
|
@@ -102,7 +106,9 @@ function settings(options) {
|
|
|
102
106
|
return { ...checked, processPort, clock, monotonicMs, boot: processPort.bootIdentity?.(), hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
|
|
103
107
|
}
|
|
104
108
|
function ownership(s, options) {
|
|
105
|
-
|
|
109
|
+
if (options.format === "token")
|
|
110
|
+
return `${s.processPort.pid}:${s.token}${s.boot ? ":" + s.boot.id : ""}`;
|
|
111
|
+
return JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString(), ...(s.boot ? { bootId: s.boot.id } : {}) });
|
|
106
112
|
}
|
|
107
113
|
function holderAlive(holder, s) {
|
|
108
114
|
if (holder.hostname !== s.hostname)
|
|
@@ -155,9 +161,7 @@ function inspectSync(lockPath, filesystem) {
|
|
|
155
161
|
descriptor = filesystem.openSync(lockPath, "r");
|
|
156
162
|
}
|
|
157
163
|
catch (error) {
|
|
158
|
-
|
|
159
|
-
return undefined;
|
|
160
|
-
throw error;
|
|
164
|
+
return fallbackIf(error, isAbsentOrContended, undefined);
|
|
161
165
|
}
|
|
162
166
|
try {
|
|
163
167
|
const info = filesystem.fstatSync(descriptor), raw = filesystem.readFileSync(descriptor, "utf8");
|
|
@@ -173,9 +177,7 @@ async function inspectAsync(lockPath, filesystem) {
|
|
|
173
177
|
handle = await filesystem.open(lockPath, "r");
|
|
174
178
|
}
|
|
175
179
|
catch (error) {
|
|
176
|
-
|
|
177
|
-
return undefined;
|
|
178
|
-
throw error;
|
|
180
|
+
return fallbackIf(error, isAbsentOrContended, undefined);
|
|
179
181
|
}
|
|
180
182
|
try {
|
|
181
183
|
const info = await handle.stat(), raw = await handle.readFile("utf8");
|
|
@@ -203,9 +205,7 @@ function tryCreateSync(ctx) {
|
|
|
203
205
|
descriptor = filesystem.openSync(lockPath, "wx", 0o600);
|
|
204
206
|
}
|
|
205
207
|
catch (error) {
|
|
206
|
-
|
|
207
|
-
throw error;
|
|
208
|
-
return undefined;
|
|
208
|
+
return fallbackIf(error, isContended, undefined);
|
|
209
209
|
}
|
|
210
210
|
let identity;
|
|
211
211
|
try {
|
|
@@ -300,9 +300,7 @@ async function tryCreateAsync(ctx) {
|
|
|
300
300
|
handle = await ctx.filesystem.open(ctx.lockPath, "wx", 0o600);
|
|
301
301
|
}
|
|
302
302
|
catch (error) {
|
|
303
|
-
|
|
304
|
-
throw error;
|
|
305
|
-
return undefined;
|
|
303
|
+
return fallbackIf(error, isContended, undefined);
|
|
306
304
|
}
|
|
307
305
|
let identity;
|
|
308
306
|
try {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import nodeFs from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { trimCharsEnd } from "@jini-ai/core/primitives";
|
|
3
4
|
import { FsFilePathError } from '@jini-ai/core/filesystem';
|
|
5
|
+
/** Path segments that never add a component: empty (doubled separator), ".", and "..". */
|
|
6
|
+
const NON_DESCENDING_SEGMENTS = new Set(["", ".", ".."]);
|
|
4
7
|
export { FsFilePathError } from '@jini-ai/core/filesystem';
|
|
5
8
|
/** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
|
|
6
9
|
export function createNodeGuardedReaderFilesystem(_required) { return nodeFs; }
|
|
@@ -34,8 +37,9 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
|
|
|
34
37
|
// name::$DATA as name. Compare raw AND folded names: folding may add denials, never remove them,
|
|
35
38
|
// and must not change the path actually opened.
|
|
36
39
|
function foldFsNameForDenylist(name) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
40
|
+
const streamAt = name.indexOf(":");
|
|
41
|
+
const withoutStream = streamAt === -1 ? name : name.slice(0, streamAt);
|
|
42
|
+
const withoutTrailing = trimCharsEnd({ text: withoutStream, chars: ". " });
|
|
39
43
|
return withoutTrailing.toLowerCase().toUpperCase().toLowerCase();
|
|
40
44
|
}
|
|
41
45
|
// Split both separator spellings even on a host whose native separator is '/', so deny checks
|
|
@@ -43,13 +47,11 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
|
|
|
43
47
|
function normalizeFsRelativePath(relativePath) {
|
|
44
48
|
const segments = [];
|
|
45
49
|
for (const segment of relativePath.split(/[\\/]/)) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (segment === "..") {
|
|
50
|
+
// Empty and "." segments are no-ops; ".." climbs one level; anything else descends.
|
|
51
|
+
if (segment === "..")
|
|
49
52
|
segments.pop();
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
segments.push(segment);
|
|
53
|
+
if (!NON_DESCENDING_SEGMENTS.has(segment))
|
|
54
|
+
segments.push(segment);
|
|
53
55
|
}
|
|
54
56
|
return segments.join("/");
|
|
55
57
|
}
|
|
@@ -362,11 +364,15 @@ function descriptorOpenFlags({ filesystem, policy }) {
|
|
|
362
364
|
throw new TypeError("exactMode must contain only permission bits (0o0000 through 0o7777)");
|
|
363
365
|
return O_RDONLY | O_NOFOLLOW | O_NONBLOCK;
|
|
364
366
|
}
|
|
367
|
+
/** A safe, non-negative integer no larger than `max`; guards the size reported by an injected stat. */
|
|
368
|
+
function isByteCountWithin(value, max) {
|
|
369
|
+
return Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
370
|
+
}
|
|
365
371
|
/** Both read paths validate the opened object, independent of earlier pathname metadata. */
|
|
366
372
|
function assertDescriptorStat({ stat, relativePath, maxBytes }, { exactMode } = {}) {
|
|
367
373
|
if (!stat.isFile())
|
|
368
374
|
throw new FsFilePathError({ message: `path '${relativePath}' is not a regular file` });
|
|
369
|
-
if (!
|
|
375
|
+
if (!isByteCountWithin(stat.size, maxBytes))
|
|
370
376
|
throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${maxBytes}-byte descriptor limit` });
|
|
371
377
|
// Include setuid/setgid/sticky: "exact" 0o600 must refuse a 0o4600 file.
|
|
372
378
|
if (exactMode !== undefined && (stat.mode & 0o7777) !== exactMode)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { windowsSystemTool } from "../windows-system-tools.js";
|
|
5
|
+
import { fallbackIfMissing } from "./errno.js";
|
|
4
6
|
/** Electron-free OS directory rules. Windows uses roaming app-data and the redirected Personal
|
|
5
7
|
* known folder; Linux honors XDG user-dirs. No directories are created by this resolver.
|
|
6
8
|
* env-paths is intentionally not used: its Windows data directory is LocalAppData/<app>/Data.
|
|
@@ -43,21 +45,17 @@ export function readOptionalText({ file }, _options = {}) {
|
|
|
43
45
|
return fs.readFileSync(file, "utf8");
|
|
44
46
|
}
|
|
45
47
|
catch (error) {
|
|
46
|
-
|
|
47
|
-
return undefined;
|
|
48
|
-
throw error;
|
|
48
|
+
return fallbackIfMissing(error, undefined);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
function readWindowsDocuments() {
|
|
52
|
-
|
|
53
|
-
const output = execFileSync("reg.exe", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders", "/v", "Personal"], { encoding: "utf8", timeout: 3000, windowsHide: true });
|
|
52
|
+
const output = execFileSync(windowsSystemTool({ relativePath: ["reg.exe"] }), ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders", "/v", "Personal"], { encoding: "utf8", timeout: 3000, windowsHide: true });
|
|
54
53
|
return /Personal\s+REG_(?:EXPAND_)?SZ\s+(.+)/.exec(output)?.[1]?.trim();
|
|
55
54
|
}
|
|
56
55
|
/** OneDrive publishes account mount paths in HKCU; absent accounts are a normal empty result. */
|
|
57
56
|
export function readWindowsOneDriveRoots(_required, _options = {}) {
|
|
58
57
|
try {
|
|
59
|
-
|
|
60
|
-
const output = execFileSync("reg.exe", ["query", "HKCU\\Software\\Microsoft\\OneDrive\\Accounts", "/s", "/v", "UserFolder"], { encoding: "utf8", timeout: 3000, windowsHide: true });
|
|
58
|
+
const output = execFileSync(windowsSystemTool({ relativePath: ["reg.exe"] }), ["query", "HKCU\\Software\\Microsoft\\OneDrive\\Accounts", "/s", "/v", "UserFolder"], { encoding: "utf8", timeout: 3000, windowsHide: true });
|
|
61
59
|
return [...output.matchAll(/UserFolder\s+REG_(?:EXPAND_)?SZ\s+([^\r\n]+)/g)].map((match) => match[1].trim());
|
|
62
60
|
}
|
|
63
61
|
catch (error) {
|
package/dist/fs.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Keeps private `errorCode` / `errorMessage` copies so it owns no cross-module
|
|
9
9
|
* runtime surface.
|
|
10
10
|
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
11
12
|
import { copyFile, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
12
13
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
14
|
/** @internal Extract a Node `error.code` as a string, or `null` when the value carries no code. */
|
|
@@ -72,7 +73,7 @@ export async function atomicCopyFile(sourcePath, destinationPath, options = {})
|
|
|
72
73
|
if (existing != null && options.overwrite !== true) {
|
|
73
74
|
throw destinationExistsError(destination);
|
|
74
75
|
}
|
|
75
|
-
const tempPath = join(destinationDir, `.${basename(destination)}.${process.pid}.${Date.now().toString(36)}.${
|
|
76
|
+
const tempPath = join(destinationDir, `.${basename(destination)}.${process.pid}.${Date.now().toString(36)}.${randomUUID()}.tmp`);
|
|
76
77
|
try {
|
|
77
78
|
await copyFile(source, tempPath);
|
|
78
79
|
// Rename replaces the old file atomically. Removing it first opens a
|
|
@@ -1,6 +1,7 @@
|
|
|
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
3
|
import { classifyIpAddress } from "../../net/address.js";
|
|
4
|
+
import { effectivePort } from "../../core/effective-port.js";
|
|
4
5
|
import { FetchTimeoutError } from "../../fetch-with-timeout.js";
|
|
5
6
|
import { isIP } from "node:net";
|
|
6
7
|
import { EgressRefusedError } from "./errors.js";
|
|
@@ -55,7 +56,7 @@ function assertNoPrivateAddress(hostname, addresses) {
|
|
|
55
56
|
}
|
|
56
57
|
async function resolvePinnedPeer(url, policy, dns) {
|
|
57
58
|
assertAllowedTarget(url, policy);
|
|
58
|
-
const port =
|
|
59
|
+
const port = effectivePort({ url });
|
|
59
60
|
const hostname = stripIpv6Brackets(url.hostname);
|
|
60
61
|
const isDevAllowlisted = policy.devHostAllowlist.includes(hostname);
|
|
61
62
|
const addresses = await resolveHostAddresses(url.hostname, dns);
|
package/dist/http.js
CHANGED
|
@@ -43,6 +43,7 @@ export async function waitForHttpOk(url, { timeoutMs = 20000 } = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
await sleep(150);
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
const detail = lastError ? ` (${lastError.message})` : "";
|
|
47
|
+
throw new Error(`timed out waiting for ${url}${detail}`);
|
|
47
48
|
}
|
|
48
49
|
//# sourceMappingURL=http.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -42,8 +42,8 @@ export type { AtomicCopyFileOptions, AtomicCopyFileResult, RemovePathBestEffortO
|
|
|
42
42
|
export { atomicCopyFile, pathContains, readLogTail, removePathBestEffort } from "./fs.js";
|
|
43
43
|
export type { HttpWaitOptions } from "./http.js";
|
|
44
44
|
export { waitForHttpOk } from "./http.js";
|
|
45
|
-
export type { FetchWithTimeoutOptions } from "./fetch-with-timeout.js";
|
|
46
|
-
export { FETCH_TIMEOUT_MS, FetchTimeoutError, fetchWithTimeout } from "./fetch-with-timeout.js";
|
|
45
|
+
export type { DeadlineSignalOptions, FetchWithTimeoutOptions } from "./fetch-with-timeout.js";
|
|
46
|
+
export { FETCH_TIMEOUT_MS, FetchTimeoutError, ResponseLimitExceededError, createDeadlineSignal, fetchWithTimeout } from "./fetch-with-timeout.js";
|
|
47
47
|
export type { WellKnownUserToolchainOptions } from "./toolchain.js";
|
|
48
48
|
export { wellKnownUserToolchainBins } from "./toolchain.js";
|
|
49
49
|
export type { BufferedCommandResult } from "./shell.js";
|
package/dist/index.js
CHANGED
|
@@ -37,7 +37,7 @@ export { mergeProxyAwareEnv, parseMacosScutilProxyOutput, parseWindowsInternetSe
|
|
|
37
37
|
export { collectProcessTreePids, createProcessStampArgs, isProcessAlive, listProcessSnapshots, listProcessUsage, matchesProcessStamp, matchesStampedProcess, readFlagValue, readProcessStamp, readProcessStampFromCommand, spawnBackgroundProcess, spawnLoggedProcess, stopProcesses, waitForProcessExit, } from "./process.js";
|
|
38
38
|
export { atomicCopyFile, pathContains, readLogTail, removePathBestEffort } from "./fs.js";
|
|
39
39
|
export { waitForHttpOk } from "./http.js";
|
|
40
|
-
export { FETCH_TIMEOUT_MS, FetchTimeoutError, fetchWithTimeout } from "./fetch-with-timeout.js";
|
|
40
|
+
export { FETCH_TIMEOUT_MS, FetchTimeoutError, ResponseLimitExceededError, createDeadlineSignal, fetchWithTimeout } from "./fetch-with-timeout.js";
|
|
41
41
|
export { wellKnownUserToolchainBins } from "./toolchain.js";
|
|
42
42
|
export { execCommandViaLoginShell, execFileBuffered } from "./shell.js";
|
|
43
43
|
export { AssetCacheError, assertSafePublicUrl, assetCacheKey, assetCacheRewriteUrl, createAssetCache, createAssetCacheDispatcher, createValidatingLookup, isCacheableExternalUrl, } from "./asset-cache.js";
|
|
@@ -12,7 +12,25 @@ export interface MailAdapterKit {
|
|
|
12
12
|
export interface MailAdapterCreateContext {
|
|
13
13
|
readonly credential: MailAdapterCredential;
|
|
14
14
|
readonly kit: MailAdapterKit;
|
|
15
|
+
/** Sample at the point of use. Hosts normalize this to the real clock when omitted. */
|
|
16
|
+
readonly now?: () => Date;
|
|
17
|
+
/** Per-install configuration; only the provider module owns its schema and validation. */
|
|
18
|
+
readonly settings?: unknown;
|
|
15
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a host's module context without inspecting settings or replacing its guarded ports.
|
|
22
|
+
* The optional fields on {@link MailAdapterCreateContext} preserve legacy direct create calls;
|
|
23
|
+
* hosts use this boundary to supply both defaults before invoking a provider module.
|
|
24
|
+
* @param required Sending credential and guarded adapter kit, passed through unchanged.
|
|
25
|
+
* @param optional Clock and opaque per-install settings. Only undefined selects a default.
|
|
26
|
+
* @returns A context with a live clock and settings (a fresh empty object when omitted).
|
|
27
|
+
* @complexity O(1) time and space; does not sample the clock or perform I/O.
|
|
28
|
+
* @example provider.create(createMailAdapterContext({ credential, kit }, { now, settings }));
|
|
29
|
+
*/
|
|
30
|
+
export declare function createMailAdapterContext(required: {
|
|
31
|
+
credential: MailAdapterCredential;
|
|
32
|
+
kit: MailAdapterKit;
|
|
33
|
+
}, { now, settings }?: Pick<MailAdapterCreateContext, "now" | "settings">): Required<MailAdapterCreateContext>;
|
|
16
34
|
/** Provider modules return the ordinary mail port; the shared contract names no vendor. */
|
|
17
35
|
export interface MailAdapterModule {
|
|
18
36
|
/** Optional host-resolved account connection for domain reads only. The host validates the
|
|
@@ -1,2 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a host's module context without inspecting settings or replacing its guarded ports.
|
|
3
|
+
* The optional fields on {@link MailAdapterCreateContext} preserve legacy direct create calls;
|
|
4
|
+
* hosts use this boundary to supply both defaults before invoking a provider module.
|
|
5
|
+
* @param required Sending credential and guarded adapter kit, passed through unchanged.
|
|
6
|
+
* @param optional Clock and opaque per-install settings. Only undefined selects a default.
|
|
7
|
+
* @returns A context with a live clock and settings (a fresh empty object when omitted).
|
|
8
|
+
* @complexity O(1) time and space; does not sample the clock or perform I/O.
|
|
9
|
+
* @example provider.create(createMailAdapterContext({ credential, kit }, { now, settings }));
|
|
10
|
+
*/
|
|
11
|
+
export function createMailAdapterContext(required, { now = () => new Date(), settings = {} } = {}) {
|
|
12
|
+
return { credential: required.credential, kit: required.kit, now, settings };
|
|
13
|
+
}
|
|
2
14
|
//# sourceMappingURL=adapter-module.js.map
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `hosted: false`, so it can never make a site look configured.
|
|
7
7
|
*/
|
|
8
8
|
import { nowIso } from "@jini-ai/core/primitives";
|
|
9
|
+
import { effectivePort } from "../core/effective-port.js";
|
|
9
10
|
import { SmtpMailerAdapter } from "./smtp.js";
|
|
10
11
|
/**
|
|
11
12
|
* SMTP endpoint packed into a credential `baseUrl` (`https://host:port`); 443/465 = implicit TLS.
|
|
@@ -13,7 +14,7 @@ import { SmtpMailerAdapter } from "./smtp.js";
|
|
|
13
14
|
*/
|
|
14
15
|
export function parseSmtpEndpoint({ baseUrl }) {
|
|
15
16
|
const url = new URL(baseUrl);
|
|
16
|
-
const port =
|
|
17
|
+
const port = effectivePort({ url });
|
|
17
18
|
return { host: url.hostname, port, secure: port === 443 || port === 465 };
|
|
18
19
|
}
|
|
19
20
|
export function createSmtpMailProvider(required) {
|
|
@@ -39,7 +40,8 @@ export function createConsoleMailProvider(required, optional = {}) {
|
|
|
39
40
|
// The body is the point of a local mailer: a sign-in or confirmation link must be usable from the
|
|
40
41
|
// log, since nothing is delivered. Text is preferred (readable); an html-only message shows its html.
|
|
41
42
|
const body = message.text ?? message.html;
|
|
42
|
-
|
|
43
|
+
const bodySuffix = body === undefined ? "" : `\n${body}`;
|
|
44
|
+
log(`[mail:console] to=${message.to.email} subject=${JSON.stringify(message.subject)}${bodySuffix}`);
|
|
43
45
|
return { ok: true, providerMessageId: `console-${counter}`, acceptedAt: nowIso({ clock: required.clock }) };
|
|
44
46
|
},
|
|
45
47
|
async sendBatch({ messages, ...delivery }) {
|
package/dist/mail/feedback.js
CHANGED
|
@@ -37,7 +37,7 @@ export function createMailFeedbackIngest(required, optional = {}) {
|
|
|
37
37
|
const apply = async (input) => {
|
|
38
38
|
const outcome = await required.inbox.record(input);
|
|
39
39
|
if (outcome === "duplicate")
|
|
40
|
-
return
|
|
40
|
+
return "duplicate";
|
|
41
41
|
const scope = suppressionScopeFor({ event: input.event });
|
|
42
42
|
if (scope !== null) {
|
|
43
43
|
const reason = input.event.kind === "complained" ? "complained" : "bounced";
|
package/dist/mail/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type * from "./types.js";
|
|
2
2
|
export type * from "./ports.js";
|
|
3
3
|
export type * from "./adapter-module.js";
|
|
4
|
+
export { createMailAdapterContext } from "./adapter-module.js";
|
|
4
5
|
export type * from "./engine-contracts.js";
|
|
5
6
|
export { wrapMailerWithPurposeGate, isPurposeGateOpen } from "./purpose-scoped-mailer.js";
|
|
6
7
|
export type { WrapMailerWithPurposeGateOptions, PurposeGateRequired } from "./purpose-scoped-mailer.js";
|
package/dist/mail/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { createMailAdapterContext } from "./adapter-module.js";
|
|
1
2
|
export { wrapMailerWithPurposeGate, isPurposeGateOpen } from "./purpose-scoped-mailer.js";
|
|
2
3
|
export { isMailDeliveryAvailable } from "./delivery-availability.js";
|
|
3
4
|
export { createMailSenderIdentityResolver, NON_DELIVERING_MAIL_DRIVERS, SenderIdentityNotConfiguredError } from "./sender-identity.js";
|
|
@@ -54,7 +54,7 @@ export async function prepareSenderEmail(required, _optional = {}) {
|
|
|
54
54
|
validateEmailSourceTemplate({ fields: source.fields, template });
|
|
55
55
|
const resolved = await sender.resolveEvent({ ...context, eventId });
|
|
56
56
|
// MOVE: Compare against the latest activation so re-enabling cannot revive a backlog.
|
|
57
|
-
if (!
|
|
57
|
+
if (!occurredSinceActivation({ occurredAt: resolved.occurredAt, activatedAt: binding.activatedAt }))
|
|
58
58
|
throw new EmailSenderError({ code: "configuration" });
|
|
59
59
|
const to = recipientAddress({ source, resolved, recipientId: binding.recipientId });
|
|
60
60
|
const { values, warnings } = templateValues({ fields: source.fields, tokens: template.fields, values: resolved.values });
|
|
@@ -65,4 +65,10 @@ export async function prepareSenderEmail(required, _optional = {}) {
|
|
|
65
65
|
replyTo: { email: binding.replyTo }, subject: rendered.subject, html: rendered.html, text: rendered.text,
|
|
66
66
|
purpose: source.purpose, lane: source.lane, warnings };
|
|
67
67
|
}
|
|
68
|
+
/** True only for an event at or after the binding's activation; an unparsable time is never current. */
|
|
69
|
+
function occurredSinceActivation({ occurredAt, activatedAt }) {
|
|
70
|
+
if (!activatedAt)
|
|
71
|
+
return false;
|
|
72
|
+
return Date.parse(occurredAt) >= Date.parse(activatedAt);
|
|
73
|
+
}
|
|
68
74
|
//# sourceMappingURL=prepare-sender-email.js.map
|
package/dist/mail/provider.js
CHANGED
|
@@ -21,11 +21,14 @@ export function validateMailProvider({ provider }) {
|
|
|
21
21
|
// A capability claim without the code behind it is how Resend once advertised feedback with no route.
|
|
22
22
|
if (caps.feedback !== (provider.feedback !== undefined))
|
|
23
23
|
return "capabilities.feedback must match the presence of a feedback parser";
|
|
24
|
-
const hasDomain =
|
|
24
|
+
const hasDomain = hasDomainVerification(provider);
|
|
25
25
|
if (caps.domainVerification !== hasDomain)
|
|
26
26
|
return "capabilities.domainVerification must match domainRequirements + domainStatus";
|
|
27
27
|
return undefined;
|
|
28
28
|
}
|
|
29
|
+
function hasDomainVerification(provider) {
|
|
30
|
+
return typeof provider.domainRequirements === "function" && typeof provider.domainStatus === "function";
|
|
31
|
+
}
|
|
29
32
|
/** Mail registry over the shared keyed registry; built-ins throw on a bad registration, plugins are refused. */
|
|
30
33
|
export function createMailProviderRegistry(_required = {}, optional = {}) {
|
|
31
34
|
const seed = (optional.builtins ?? []).map((provider) => ({ id: provider.id, provider, label: provider.label }));
|
|
@@ -61,14 +64,20 @@ function laneStatus(required) {
|
|
|
61
64
|
export function computeMailStatus(required, optional = {}) {
|
|
62
65
|
const { setting, registry, hasCredential } = required;
|
|
63
66
|
const lanes = ["transactional", "bulk"].map((lane) => laneStatus({ lane, providerId: setting[lane], registry, hasCredential }));
|
|
64
|
-
const
|
|
65
|
-
const
|
|
67
|
+
const senderWarning = senderDomainWarning(optional.senderDomainState);
|
|
68
|
+
const warnings = senderWarning === undefined ? [] : [senderWarning];
|
|
69
|
+
return { state: overallMailState(lanes), lanes, warnings };
|
|
70
|
+
}
|
|
71
|
+
function senderDomainWarning(domain) {
|
|
66
72
|
if (domain === null)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
return "sender_unknown";
|
|
74
|
+
if (domain === undefined || domain === "verified")
|
|
75
|
+
return undefined;
|
|
76
|
+
return "sender_unverified";
|
|
77
|
+
}
|
|
78
|
+
function overallMailState(lanes) {
|
|
79
|
+
if (lanes.every((status) => status.state === "ready"))
|
|
80
|
+
return "ready";
|
|
81
|
+
return lanes[0].state === "unconfigured" ? "unconfigured" : "degraded";
|
|
73
82
|
}
|
|
74
83
|
//# sourceMappingURL=provider.js.map
|
package/dist/mail/rate-limit.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export function createMailLaneLimiter(required, optional = {}) {
|
|
9
9
|
const { perSecond, burst } = required.limit;
|
|
10
|
-
|
|
10
|
+
// Positive comparisons on purpose: NaN fails both and is rejected.
|
|
11
|
+
const validLimit = perSecond > 0 && burst >= 1;
|
|
12
|
+
if (!validLimit)
|
|
11
13
|
throw new RangeError("mail rate limit needs perSecond > 0 and burst >= 1");
|
|
12
14
|
const now = optional.now ?? Date.now;
|
|
13
15
|
// At least one token whenever burst > 1: 20% of a small burst (Resend's 2) floors to 0, which let
|
|
@@ -13,7 +13,13 @@ function normalizeBracketedIpv6(hostname) {
|
|
|
13
13
|
// form, so `localhost.` must normalize to `localhost` before the equality
|
|
14
14
|
// check below — and `0.0.0.0.`, `10.0.0.1.`, etc. must normalize before
|
|
15
15
|
// the IPv4 classifier parses them. Strips one or more trailing dots.
|
|
16
|
-
|
|
16
|
+
// A scan rather than /\.+$/, which backtracks quadratically on long interior dot runs. This module
|
|
17
|
+
// stays import-free (see its contract test), so it does not use core's trimCharsEnd.
|
|
18
|
+
const lowered = stripped.toLowerCase();
|
|
19
|
+
let end = lowered.length;
|
|
20
|
+
while (end > 0 && lowered[end - 1] === '.')
|
|
21
|
+
end -= 1;
|
|
22
|
+
return lowered.slice(0, end);
|
|
17
23
|
}
|
|
18
24
|
function parseIpv4(hostname) {
|
|
19
25
|
const parts = hostname.split('.');
|
|
@@ -71,7 +77,7 @@ export function expandIpv6({ address: addr }) {
|
|
|
71
77
|
const fill = 8 - left.length - right.length;
|
|
72
78
|
if (fill < 1)
|
|
73
79
|
return null;
|
|
74
|
-
groups = [...left, ...
|
|
80
|
+
groups = [...left, ...Array.from({ length: fill }, () => 0), ...right];
|
|
75
81
|
}
|
|
76
82
|
if (!validIpv6Groups(groups))
|
|
77
83
|
return null;
|
|
@@ -88,8 +94,8 @@ function foldIpv4Tail(s) {
|
|
|
88
94
|
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
89
95
|
return null;
|
|
90
96
|
// `o` is now a validated 4-element array — indices 0-3 always defined.
|
|
91
|
-
const hi = ((
|
|
92
|
-
const lo = ((
|
|
97
|
+
const hi = ((o[0] << 8) | o[1]).toString(16);
|
|
98
|
+
const lo = ((o[2] << 8) | o[3]).toString(16);
|
|
93
99
|
s = `${v4[1]}${hi}:${lo}`;
|
|
94
100
|
}
|
|
95
101
|
return s;
|
|
@@ -116,9 +122,11 @@ function classifyIpv4Bytes(bytes) {
|
|
|
116
122
|
function isPrivateIpv4Bytes([a, b]) {
|
|
117
123
|
if (a === 100)
|
|
118
124
|
return b >= 64 && b <= 127;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
if (a === 172)
|
|
126
|
+
return b >= 16 && b <= 31;
|
|
127
|
+
if (a === 192)
|
|
128
|
+
return b === 168;
|
|
129
|
+
return a === 10;
|
|
122
130
|
}
|
|
123
131
|
// Each /96 prefix carries IPv4 in the final four bytes. Matching canonical
|
|
124
132
|
// bytes also handles expanded IPv6 and WHATWG URL's dotted-to-hex normalization.
|
|
@@ -29,7 +29,9 @@ function contained(required) {
|
|
|
29
29
|
const { rootDir, key } = required;
|
|
30
30
|
const path = join(rootDir, key);
|
|
31
31
|
const rel = relative(resolve(rootDir), resolve(path));
|
|
32
|
-
|
|
32
|
+
const invalidKey = key === '' || isAbsolute(key);
|
|
33
|
+
const escapesRoot = rel === '' || rel.startsWith('..') || isAbsolute(rel);
|
|
34
|
+
if (invalidKey || escapesRoot) {
|
|
33
35
|
throw new Error(`object key '${key}' is outside the store root`);
|
|
34
36
|
}
|
|
35
37
|
return path;
|