@jini-ai/platform 0.6.12 → 0.6.15-rc.2
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 +33 -0
- package/README.md +54 -0
- package/dist/asset-cache.js +27 -13
- package/dist/command.d.ts +1 -1
- package/dist/command.js +11 -8
- package/dist/download.js +26 -32
- package/dist/fetch-with-timeout.d.ts +77 -0
- package/dist/fetch-with-timeout.js +148 -2
- package/dist/fs/directory-user-config.js +2 -1
- package/dist/fs/directory-workspace.js +3 -1
- package/dist/fs/guarded-reader.d.ts +19 -47
- package/dist/fs/guarded-reader.js +82 -13
- 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/email-sender-registry.d.ts +15 -0
- package/dist/mail/email-sender-registry.js +22 -0
- package/dist/mail/email-sender.d.ts +109 -0
- package/dist/mail/email-sender.js +10 -0
- package/dist/mail/index.d.ts +16 -2
- package/dist/mail/index.js +9 -1
- package/dist/mail/mail-status-cache.d.ts +33 -0
- package/dist/mail/mail-status-cache.js +56 -0
- package/dist/mail/prepare-sender-email.d.ts +35 -0
- package/dist/mail/prepare-sender-email.js +68 -0
- package/dist/mail/provider.js +4 -1
- package/dist/mail/resolve-mailer.d.ts +95 -0
- package/dist/mail/resolve-mailer.js +348 -0
- package/dist/mail/senders.d.ts +5 -0
- package/dist/mail/senders.js +5 -0
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
- package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
- package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
- package/dist/secrets/credential-sets/vault.service.js +18 -9
- package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
- package/package.json +8 -2
|
@@ -87,6 +87,126 @@ export const FETCH_TIMEOUT_MS = {
|
|
|
87
87
|
* legitimately still-generating request early. */
|
|
88
88
|
GENERATE: 10 * 60_000,
|
|
89
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* Creates one signal that aborts when the remaining wait budget expires or the caller cancels.
|
|
92
|
+
* Spent time is subtracted once; future start times cannot increase the original budget.
|
|
93
|
+
* @param input - Wait budget in milliseconds. Negative remaining budgets become zero.
|
|
94
|
+
* @param options - Optional caller signal, start time and clock port.
|
|
95
|
+
* @returns A native deadline signal preserving the first abort reason. Zero expires asynchronously,
|
|
96
|
+
* following AbortSignal.timeout(0); an already-aborted caller is reflected immediately.
|
|
97
|
+
* Creates a native timeout without mutating inputs or the caller's signal.
|
|
98
|
+
* @throws {RangeError} If the remaining delay is invalid for AbortSignal.timeout.
|
|
99
|
+
* @complexity O(1) time and space.
|
|
100
|
+
* @example createDeadlineSignal({ waitMs: 5_000 }, { signal, startedAt, now: Date.now })
|
|
101
|
+
*/
|
|
102
|
+
export function createDeadlineSignal({ waitMs }, { signal, startedAt, now = Date.now } = {}) {
|
|
103
|
+
const elapsedMs = startedAt === undefined ? 0 : Math.max(0, now() - startedAt);
|
|
104
|
+
const deadline = AbortSignal.timeout(Math.max(0, waitMs - elapsedMs));
|
|
105
|
+
if (signal === undefined)
|
|
106
|
+
return deadline;
|
|
107
|
+
return AbortSignal.any([deadline, signal]);
|
|
108
|
+
}
|
|
109
|
+
export async function readFetchResponseText(input, { maxResponseBytes, strictUtf8 = false } = {}) {
|
|
110
|
+
const details = "result" in input && input.result === "details";
|
|
111
|
+
const options = details ? input : { ...(maxResponseBytes === undefined ? {} : { maxResponseBytes }), strictUtf8 };
|
|
112
|
+
validateTextReadOptions({ maxResponseBytes: options.maxResponseBytes, requireCap: details });
|
|
113
|
+
if (!details && options.maxResponseBytes === undefined && !options.strictUtf8)
|
|
114
|
+
return input.response.text();
|
|
115
|
+
const result = await consumeResponseText({ response: input.response, awaitCancel: !details }, options);
|
|
116
|
+
return details ? result : result.text;
|
|
117
|
+
}
|
|
118
|
+
/** Owns reader cleanup for every caller; legacy failure cleanup keeps its awaited behavior.
|
|
119
|
+
* Signal-driven callers skip that wait: a body that ignores its signal may never settle cancel. */
|
|
120
|
+
async function consumeResponseText({ response, awaitCancel }, options = {}) {
|
|
121
|
+
options.signal?.throwIfAborted();
|
|
122
|
+
if (response.body === null)
|
|
123
|
+
return { text: "", byteLength: 0, truncated: false };
|
|
124
|
+
const reader = response.body.getReader();
|
|
125
|
+
let failed = true;
|
|
126
|
+
try {
|
|
127
|
+
const result = await decodeResponseStream({ reader }, options);
|
|
128
|
+
if (result.truncated)
|
|
129
|
+
void cancelResponseReader({ reader });
|
|
130
|
+
failed = false;
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const cancellation = cancelResponseReader({ reader, reason: error });
|
|
135
|
+
if (awaitCancel)
|
|
136
|
+
await cancellation;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
try {
|
|
141
|
+
reader.releaseLock();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (!failed)
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Observe synchronous/asynchronous cancellation failure without masking the consumption outcome. */
|
|
150
|
+
function cancelResponseReader({ reader, reason }) {
|
|
151
|
+
try {
|
|
152
|
+
return reader.cancel(reason).catch(() => { });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return Promise.resolve();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Reject invalid caps before consumption, including for empty responses. */
|
|
159
|
+
function validateTextReadOptions({ maxResponseBytes, requireCap = false }) {
|
|
160
|
+
if (maxResponseBytes === undefined && !requireCap)
|
|
161
|
+
return;
|
|
162
|
+
if (typeof maxResponseBytes !== "number" || !Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 0) {
|
|
163
|
+
throw new RangeError("maxResponseBytes must be a nonnegative safe integer");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Byte counting precedes decoding; final flush detects truncated UTF-8 in strict mode.
|
|
167
|
+
* Retains only the allowed prefix, independently of upstream chunk size or headers. */
|
|
168
|
+
async function decodeResponseStream({ reader }, { maxResponseBytes = Infinity, strictUtf8 = false, onLimit = "reject", signal } = {}) {
|
|
169
|
+
const decoder = new TextDecoder("utf-8", { fatal: strictUtf8 });
|
|
170
|
+
let total = 0;
|
|
171
|
+
let text = "";
|
|
172
|
+
for (;;) {
|
|
173
|
+
const { done, value } = await readResponseChunk({ reader, signal });
|
|
174
|
+
if (done)
|
|
175
|
+
break;
|
|
176
|
+
const remaining = maxResponseBytes - total;
|
|
177
|
+
if (value.byteLength > remaining) {
|
|
178
|
+
if (onLimit === "reject")
|
|
179
|
+
throw new RangeError(`response exceeds the ${maxResponseBytes}-byte limit`);
|
|
180
|
+
text += decoder.decode(value.subarray(0, remaining), { stream: true });
|
|
181
|
+
return { text: text + decoder.decode(), byteLength: maxResponseBytes, truncated: true };
|
|
182
|
+
}
|
|
183
|
+
total += value.byteLength;
|
|
184
|
+
text += decoder.decode(value, { stream: true });
|
|
185
|
+
}
|
|
186
|
+
return { text: text + decoder.decode(), byteLength: total, truncated: false };
|
|
187
|
+
}
|
|
188
|
+
/** Race one read against cancellation, removing the listener on every outcome.
|
|
189
|
+
* Per-read listeners avoid accumulating pending race handlers over a long response. */
|
|
190
|
+
async function readResponseChunk({ reader, signal }) {
|
|
191
|
+
if (!signal)
|
|
192
|
+
return reader.read();
|
|
193
|
+
signal.throwIfAborted();
|
|
194
|
+
let abort = () => { };
|
|
195
|
+
try {
|
|
196
|
+
return await Promise.race([
|
|
197
|
+
new Promise((_resolve, reject) => {
|
|
198
|
+
abort = () => reject(signal.reason);
|
|
199
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
200
|
+
if (signal.aborted)
|
|
201
|
+
abort();
|
|
202
|
+
}),
|
|
203
|
+
Promise.resolve().then(() => { signal.throwIfAborted(); return reader.read(); }),
|
|
204
|
+
]);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
signal.removeEventListener("abort", abort);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
90
210
|
/**
|
|
91
211
|
* `fetch`, but aborted after `options.timeoutMs` if it hasn't settled by then. If `init.signal` is
|
|
92
212
|
* already set, the call aborts on EITHER that signal or the timeout, via `AbortSignal.any` — a
|
|
@@ -103,11 +223,37 @@ export const FETCH_TIMEOUT_MS = {
|
|
|
103
223
|
* genuine network error) propagates unchanged.
|
|
104
224
|
*/
|
|
105
225
|
export async function fetchWithTimeout(url, init = {}, options) {
|
|
106
|
-
|
|
226
|
+
return executeFetchWithTimeout({ url, init, timeoutMs: options.timeoutMs, consume: async ({ response }) => response }, options);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Fetches and consumes text inside one timeout scope. The returned response keeps its metadata
|
|
230
|
+
* and has a consumed body. No retries; other fetch/read errors propagate unchanged.
|
|
231
|
+
* @throws {FetchTimeoutError} The timeout ends fetch or body consumption before caller abort.
|
|
232
|
+
* @throws {RangeError | TypeError} See {@link readFetchResponseText} for opt-in guards.
|
|
233
|
+
* @complexity O(n) time and text storage for n response bytes, optionally capped.
|
|
234
|
+
*/
|
|
235
|
+
export async function fetchTextWithTimeout(required, optional = {}) {
|
|
236
|
+
return executeFetchWithTimeout({
|
|
237
|
+
url: required.url,
|
|
238
|
+
timeoutMs: required.timeoutMs,
|
|
239
|
+
init: required.init ?? {},
|
|
240
|
+
consume: async ({ response, signal }) => {
|
|
241
|
+
const { maxResponseBytes, strictUtf8 = false } = optional;
|
|
242
|
+
validateTextReadOptions({ maxResponseBytes });
|
|
243
|
+
// Always stream under the scope's signal: an injected fetch's body may ignore that signal,
|
|
244
|
+
// and only racing each read lets the timeout end consumption.
|
|
245
|
+
const read = await consumeResponseText({ response, awaitCancel: false }, { ...(maxResponseBytes === undefined ? {} : { maxResponseBytes }), strictUtf8, signal });
|
|
246
|
+
return { response, text: read.text };
|
|
247
|
+
},
|
|
248
|
+
}, optional);
|
|
249
|
+
}
|
|
250
|
+
/** Shared signal composition/error mapping; consumption decides the timeout scope's endpoint. */
|
|
251
|
+
async function executeFetchWithTimeout({ url, init, timeoutMs, consume }, options = {}) {
|
|
107
252
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
108
253
|
const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
|
|
109
254
|
try {
|
|
110
|
-
|
|
255
|
+
const response = await (options.fetch ?? fetch)(url, { ...init, signal });
|
|
256
|
+
return await consume({ response, signal });
|
|
111
257
|
}
|
|
112
258
|
catch (error) {
|
|
113
259
|
// `timeoutSignal` is the original object this function created, not the (possibly composed)
|
|
@@ -68,7 +68,8 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
|
|
|
68
68
|
}
|
|
69
69
|
function configuredRoots(saved) {
|
|
70
70
|
const fromEnv = env[policy.rootsEnvName];
|
|
71
|
-
|
|
71
|
+
// Saved roots are extras; policy roots remain implicit unless the environment overrides them.
|
|
72
|
+
return fromEnv !== undefined ? fromEnv.split(",").map((root) => root.trim()).filter(Boolean) : [...(policy.sourceRoots ?? []), ...(saved.siteRoots ?? [])];
|
|
72
73
|
}
|
|
73
74
|
// Prefer explicit settings, never coerce an existing regular file into a directory.
|
|
74
75
|
function assertDirectories(checked) {
|
|
@@ -85,7 +85,9 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
|
|
|
85
85
|
const oldHome = legacy.state === "ok" ? legacy.value?.home : path.join(homeDir, userConfig.legacyDefaultFolder ?? defaultFolder);
|
|
86
86
|
if (typeof oldHome !== "string")
|
|
87
87
|
throw new Error("Legacy directory setting has an invalid home.");
|
|
88
|
-
|
|
88
|
+
// Legacy settings use the same home shorthand contract; current overrides must not redirect the import.
|
|
89
|
+
const expandedHome = resolveDirectoryHome({ homeDir, defaultFolder, envName, env: {}, setting: oldHome }, { cwd, ...directoryOptions });
|
|
90
|
+
return [...new Set([expandedHome, ...config.legacyHomes({}), home()])].map((root) => createDirectoryRegistry({ file: fileAt(path.join(root, registryName)), canonicalPath }));
|
|
89
91
|
};
|
|
90
92
|
const importOldCatalog = () => {
|
|
91
93
|
const current = atHome();
|
|
@@ -1,60 +1,32 @@
|
|
|
1
1
|
import nodeFs from "node:fs";
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
maxWalkEntries: number;
|
|
18
|
-
}
|
|
19
|
-
export type GuardedReaderFilesystem = Pick<typeof nodeFs, "existsSync" | "lstatSync" | "readdirSync" | "realpathSync" | "statSync" | "openSync" | "readSync" | "closeSync" | "createReadStream">;
|
|
2
|
+
import { type DenyRules, type GuardedFileReader, type GuardedReaderLimits } from '@jini-ai/core/filesystem';
|
|
3
|
+
export { FsFilePathError, type DenyRules, type GuardedFileReader, type GuardedReaderLimits } from '@jini-ai/core/filesystem';
|
|
4
|
+
/** Optional capabilities needed only when descriptor reads are requested. */
|
|
5
|
+
export type DescriptorCapabilities = Partial<Pick<typeof nodeFs, "fstatSync" | "constants">>;
|
|
6
|
+
export type DescriptorReadPolicy = {
|
|
7
|
+
/** Require exact permission bits, e.g. 0o600; no subset/superset matching. */
|
|
8
|
+
exactMode?: number;
|
|
9
|
+
/** Reject growth beyond the descriptor's initial size even below the byte cap. */
|
|
10
|
+
rejectGrowth?: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** Additive descriptor policy in the guarded reader's existing options object. */
|
|
13
|
+
export type AddedReaderOptions = {
|
|
14
|
+
descriptorRead?: DescriptorReadPolicy;
|
|
15
|
+
};
|
|
16
|
+
export type GuardedReaderFilesystem = Pick<typeof nodeFs, "existsSync" | "lstatSync" | "readdirSync" | "realpathSync" | "statSync" | "openSync" | "readSync" | "closeSync" | "createReadStream"> & DescriptorCapabilities;
|
|
20
17
|
/** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
|
|
21
18
|
export declare function createNodeGuardedReaderFilesystem(_required: Record<string, never>): GuardedReaderFilesystem;
|
|
22
|
-
export interface GuardedFileReader {
|
|
23
|
-
resolve(required: {
|
|
24
|
-
relativePath: string;
|
|
25
|
-
}): string;
|
|
26
|
-
read(required: {
|
|
27
|
-
relativePath: string;
|
|
28
|
-
}): {
|
|
29
|
-
content: string;
|
|
30
|
-
bytes: number;
|
|
31
|
-
};
|
|
32
|
-
readBytes(required: {
|
|
33
|
-
relativePath: string;
|
|
34
|
-
maxBytes: number;
|
|
35
|
-
}): Promise<Uint8Array>;
|
|
36
|
-
list(required: Record<string, never>, optional?: {
|
|
37
|
-
relativePath?: string;
|
|
38
|
-
}): {
|
|
39
|
-
files: string[];
|
|
40
|
-
truncated: boolean;
|
|
41
|
-
};
|
|
42
|
-
isDeniedFileName(required: {
|
|
43
|
-
fileName: string;
|
|
44
|
-
}): boolean;
|
|
45
|
-
isDeniedPathSegment(required: {
|
|
46
|
-
segmentName: string;
|
|
47
|
-
}): boolean;
|
|
48
|
-
}
|
|
49
19
|
/** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
|
|
50
20
|
* Deny rules and traversal/read limits are host policy and have no implicit defaults.
|
|
21
|
+
* Opt-in descriptor reads fail closed without fstat/no-follow/nonblocking capabilities. No-follow
|
|
22
|
+
* protects the final component, not concurrent ancestor-directory replacement.
|
|
51
23
|
*/
|
|
52
24
|
export declare function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }: {
|
|
53
25
|
rootPath: string;
|
|
54
26
|
denyRules: DenyRules;
|
|
55
27
|
limits: GuardedReaderLimits;
|
|
56
28
|
filesystem: GuardedReaderFilesystem;
|
|
57
|
-
}, { excludedListingDirs }?: {
|
|
29
|
+
}, { excludedListingDirs, descriptorRead }?: {
|
|
58
30
|
excludedListingDirs?: ReadonlySet<string>;
|
|
59
|
-
}): GuardedFileReader;
|
|
31
|
+
} & AddedReaderOptions): GuardedFileReader;
|
|
60
32
|
//# sourceMappingURL=guarded-reader.d.ts.map
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import nodeFs from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
/** Refusal of a malformed, denied, escaped, or over-limit file operation. */
|
|
6
|
-
export class FsFilePathError extends Error {
|
|
7
|
-
constructor({ message }) { super(message); this.name = "FsFilePathError"; }
|
|
8
|
-
}
|
|
3
|
+
import { FsFilePathError } from '@jini-ai/core/filesystem';
|
|
4
|
+
export { FsFilePathError } from '@jini-ai/core/filesystem';
|
|
9
5
|
/** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
|
|
10
6
|
export function createNodeGuardedReaderFilesystem(_required) { return nodeFs; }
|
|
11
7
|
/** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
|
|
12
8
|
* Deny rules and traversal/read limits are host policy and have no implicit defaults.
|
|
9
|
+
* Opt-in descriptor reads fail closed without fstat/no-follow/nonblocking capabilities. No-follow
|
|
10
|
+
* protects the final component, not concurrent ancestor-directory replacement.
|
|
13
11
|
*/
|
|
14
|
-
export function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }, { excludedListingDirs = new Set(["node_modules", ".git", "dist"]) } = {}) {
|
|
12
|
+
export function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }, { excludedListingDirs = new Set(["node_modules", ".git", "dist"]), descriptorRead } = {}) {
|
|
15
13
|
if (!rootPath)
|
|
16
14
|
throw new TypeError("rootPath is required");
|
|
17
15
|
for (const value of Object.values(limits))
|
|
18
16
|
if (!Number.isSafeInteger(value) || value <= 0)
|
|
19
17
|
throw new TypeError("reader limits must be positive safe integers");
|
|
18
|
+
const descriptorFlags = descriptorRead === undefined ? undefined : descriptorOpenFlags({ filesystem: fs, policy: descriptorRead });
|
|
20
19
|
const { existsSync, lstatSync, readdirSync, realpathSync, statSync } = fs;
|
|
21
20
|
const MAX_FS_FILE_BYTES = limits.maxFileBytes, BINARY_SNIFF_BYTES = limits.binarySniffBytes;
|
|
22
21
|
const MAX_LISTED_FILES = limits.maxListedFiles, MAX_WALK_DEPTH = limits.maxWalkDepth, MAX_WALK_ENTRIES = limits.maxWalkEntries;
|
|
@@ -250,9 +249,11 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
|
|
|
250
249
|
}
|
|
251
250
|
// Read one byte past the cap so a file that grew after stat is refused rather than clipped.
|
|
252
251
|
function readBoundedSync(target, relativePath) {
|
|
253
|
-
const fd =
|
|
252
|
+
const { fd, maxBytes } = descriptorFlags === undefined
|
|
253
|
+
? { fd: fs.openSync(target, "r"), maxBytes: MAX_FS_FILE_BYTES }
|
|
254
|
+
: openValidatedDescriptor({ target, relativePath, maxBytes: MAX_FS_FILE_BYTES });
|
|
254
255
|
try {
|
|
255
|
-
const bounded = Buffer.alloc(
|
|
256
|
+
const bounded = Buffer.alloc(maxBytes + 1);
|
|
256
257
|
let count = 0;
|
|
257
258
|
while (count < bounded.length) {
|
|
258
259
|
const read = fs.readSync(fd, bounded, count, bounded.length - count, null);
|
|
@@ -260,14 +261,30 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
|
|
|
260
261
|
break;
|
|
261
262
|
count += read;
|
|
262
263
|
}
|
|
263
|
-
if (count >
|
|
264
|
-
throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${
|
|
264
|
+
if (count > maxBytes)
|
|
265
|
+
throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${maxBytes}-byte readable limit` });
|
|
265
266
|
return bounded.subarray(0, count);
|
|
266
267
|
}
|
|
267
268
|
finally {
|
|
268
269
|
fs.closeSync(fd);
|
|
269
270
|
}
|
|
270
271
|
}
|
|
272
|
+
/** Owns the descriptor until validated; every refusal closes it before propagating. */
|
|
273
|
+
function openValidatedDescriptor({ target, relativePath, maxBytes }) {
|
|
274
|
+
const fd = openNoFollow({ target, relativePath });
|
|
275
|
+
try {
|
|
276
|
+
const stat = fs.fstatSync(fd);
|
|
277
|
+
assertDescriptorStat({ stat, relativePath, maxBytes }, descriptorRead);
|
|
278
|
+
return { fd, maxBytes: descriptorRead?.rejectGrowth ? Math.min(maxBytes, stat.size) : maxBytes };
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
try {
|
|
282
|
+
fs.closeSync(fd);
|
|
283
|
+
}
|
|
284
|
+
catch { /* Preserve the validation error. */ }
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
271
288
|
// A stat-only size check can be bypassed by a growing file: bound the stream independently and
|
|
272
289
|
// reject rather than return a clipped image/video. Destroy the stream on success or failure.
|
|
273
290
|
async function openFsFileForRead(required) {
|
|
@@ -283,10 +300,11 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
|
|
|
283
300
|
return readBoundedStream(target, { relativePath, maxBytes });
|
|
284
301
|
}
|
|
285
302
|
async function readBoundedStream(target, { relativePath, maxBytes }) {
|
|
286
|
-
const
|
|
303
|
+
const opened = createBoundedStream({ target, relativePath, maxBytes });
|
|
304
|
+
const { stream } = opened;
|
|
287
305
|
try {
|
|
288
306
|
// Own refusals pass through; any other stream failure becomes this reader's refusal class.
|
|
289
|
-
return await collectBoundedChunks(stream, { relativePath, maxBytes }).catch((error) => Promise.reject(error instanceof FsFilePathError
|
|
307
|
+
return await collectBoundedChunks(stream, { relativePath, maxBytes: opened.maxBytes }).catch((error) => Promise.reject(error instanceof FsFilePathError
|
|
290
308
|
? error
|
|
291
309
|
: new FsFilePathError({ message: `path '${relativePath}' could not be read: ${errorText(error)}` })));
|
|
292
310
|
}
|
|
@@ -294,6 +312,36 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
|
|
|
294
312
|
stream.destroy();
|
|
295
313
|
}
|
|
296
314
|
}
|
|
315
|
+
/** O_NOFOLLOW refusals (ELOOP) and other open failures carry absolute host paths; surface only
|
|
316
|
+
* the caller's relative path, like every other refusal from this reader. */
|
|
317
|
+
function openNoFollow({ target, relativePath }) {
|
|
318
|
+
try {
|
|
319
|
+
return fs.openSync(target, descriptorFlags);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
throw new FsFilePathError({ message: `path '${relativePath}' is a symbolic link or could not be opened` });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Transfer ownership to the stream only after construction; it alone closes on end/error. */
|
|
326
|
+
function createBoundedStream({ target, relativePath, maxBytes }) {
|
|
327
|
+
const highWaterMark = Math.min(64 * 1024, maxBytes + 1);
|
|
328
|
+
if (descriptorFlags === undefined)
|
|
329
|
+
return { stream: fs.createReadStream(target, { highWaterMark }), maxBytes };
|
|
330
|
+
const opened = openValidatedDescriptor({ target, relativePath, maxBytes });
|
|
331
|
+
try {
|
|
332
|
+
return {
|
|
333
|
+
stream: fs.createReadStream(target, { fd: opened.fd, autoClose: true, highWaterMark: Math.min(highWaterMark, opened.maxBytes + 1) }),
|
|
334
|
+
maxBytes: opened.maxBytes,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
try {
|
|
339
|
+
fs.closeSync(opened.fd);
|
|
340
|
+
}
|
|
341
|
+
catch { /* Preserve the construction error. */ }
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
297
345
|
return {
|
|
298
346
|
resolve: ({ relativePath }) => resolveFsFilePath({ rootPath, relativePath }),
|
|
299
347
|
read: ({ relativePath }) => readFsFile({ rootPath, relativePath }),
|
|
@@ -303,6 +351,27 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
|
|
|
303
351
|
isDeniedPathSegment: ({ segmentName }) => isDeniedFsPathSegment(segmentName),
|
|
304
352
|
};
|
|
305
353
|
}
|
|
354
|
+
/** Validate opt-in capability support without weakening legacy filesystem adapters. */
|
|
355
|
+
function descriptorOpenFlags({ filesystem, policy }) {
|
|
356
|
+
if (!filesystem.fstatSync || !filesystem.constants)
|
|
357
|
+
throw new TypeError("descriptorRead requires fstatSync and filesystem constants");
|
|
358
|
+
const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = filesystem.constants;
|
|
359
|
+
if (!O_NOFOLLOW || !O_NONBLOCK || !Number.isInteger(O_RDONLY))
|
|
360
|
+
throw new TypeError("descriptorRead requires supported O_RDONLY, O_NOFOLLOW and O_NONBLOCK flags");
|
|
361
|
+
if (policy.exactMode !== undefined && (!Number.isInteger(policy.exactMode) || policy.exactMode < 0 || policy.exactMode > 0o7777))
|
|
362
|
+
throw new TypeError("exactMode must contain only permission bits (0o0000 through 0o7777)");
|
|
363
|
+
return O_RDONLY | O_NOFOLLOW | O_NONBLOCK;
|
|
364
|
+
}
|
|
365
|
+
/** Both read paths validate the opened object, independent of earlier pathname metadata. */
|
|
366
|
+
function assertDescriptorStat({ stat, relativePath, maxBytes }, { exactMode } = {}) {
|
|
367
|
+
if (!stat.isFile())
|
|
368
|
+
throw new FsFilePathError({ message: `path '${relativePath}' is not a regular file` });
|
|
369
|
+
if (!Number.isSafeInteger(stat.size) || stat.size < 0 || stat.size > maxBytes)
|
|
370
|
+
throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${maxBytes}-byte descriptor limit` });
|
|
371
|
+
// Include setuid/setgid/sticky: "exact" 0o600 must refuse a 0o4600 file.
|
|
372
|
+
if (exactMode !== undefined && (stat.mode & 0o7777) !== exactMode)
|
|
373
|
+
throw new FsFilePathError({ message: `file '${relativePath}' does not have exact mode ${exactMode.toString(8)}` });
|
|
374
|
+
}
|
|
306
375
|
function errorText(error) {
|
|
307
376
|
return error instanceof Error ? error.message : String(error);
|
|
308
377
|
}
|
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, 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, 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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ProviderRegistry } from "@jini-ai/core/provider-registry";
|
|
2
|
+
import { type EmailSender, type EmailSenderContext, type EmailSourceSummary } from "./email-sender.js";
|
|
3
|
+
/** Shares the existing keyed-registry admission rules; no plugin contribution category is added. */
|
|
4
|
+
export type EmailSenderRegistry = ProviderRegistry<EmailSender>;
|
|
5
|
+
/** Creates an isolated registry, throwing on invalid ids or duplicate built-in senders.
|
|
6
|
+
* @complexity O(s) registrations for s senders; keyed lookup remains O(1). */
|
|
7
|
+
export declare function createEmailSenderRegistry(required: {
|
|
8
|
+
readonly senders: readonly EmailSender[];
|
|
9
|
+
}, _optional?: Record<string, never>): EmailSenderRegistry;
|
|
10
|
+
/** Lists configurable events across all registered senders, with each sender enforcing access.
|
|
11
|
+
* Refuses a sender claiming another sender's sources. @complexity One list call per sender. */
|
|
12
|
+
export declare function listEmailSources(required: EmailSenderContext & {
|
|
13
|
+
readonly registry: EmailSenderRegistry;
|
|
14
|
+
}, _optional?: Record<string, never>): Promise<readonly EmailSourceSummary[]>;
|
|
15
|
+
//# sourceMappingURL=email-sender-registry.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createProviderRegistry } from "@jini-ai/core/provider-registry";
|
|
2
|
+
import { EmailSenderError } from "./email-sender.js";
|
|
3
|
+
/** Creates an isolated registry, throwing on invalid ids or duplicate built-in senders.
|
|
4
|
+
* @complexity O(s) registrations for s senders; keyed lookup remains O(1). */
|
|
5
|
+
export function createEmailSenderRegistry(required, _optional = {}) {
|
|
6
|
+
return createProviderRegistry({ contract: "email_sender" }, {
|
|
7
|
+
seed: required.senders.map(sender => ({ id: sender.id, label: sender.label, provider: sender })),
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/** Lists configurable events across all registered senders, with each sender enforcing access.
|
|
11
|
+
* Refuses a sender claiming another sender's sources. @complexity One list call per sender. */
|
|
12
|
+
export async function listEmailSources(required, _optional = {}) {
|
|
13
|
+
const sources = [];
|
|
14
|
+
for (const { id, provider } of required.registry.list({})) {
|
|
15
|
+
const listed = await provider.listSources({ workspaceId: required.workspaceId, principalId: required.principalId });
|
|
16
|
+
if (listed.some(source => source.senderId !== id))
|
|
17
|
+
throw new EmailSenderError({ code: "ownership" });
|
|
18
|
+
sources.push(...listed);
|
|
19
|
+
}
|
|
20
|
+
return sources;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=email-sender-registry.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MOVE: Event data and recipient authority belong to the originating feature, independently of
|
|
3
|
+
* delivery providers. Hosts bind authorization, templates and persistence; this surface sends nothing.
|
|
4
|
+
*/
|
|
5
|
+
import type { EmailAddress, MailerSendOptions } from "./types.js";
|
|
6
|
+
/** Explicit caller scope, including for background jobs running as a host service principal. */
|
|
7
|
+
export interface EmailSenderContext {
|
|
8
|
+
readonly workspaceId: string;
|
|
9
|
+
readonly principalId: string;
|
|
10
|
+
}
|
|
11
|
+
/** A concrete event source; the same event name may occur for several sources or senders. */
|
|
12
|
+
export interface EmailSourceRef {
|
|
13
|
+
readonly senderId: string;
|
|
14
|
+
readonly sourceId: string;
|
|
15
|
+
}
|
|
16
|
+
export interface EmailSourceSummary extends EmailSourceRef {
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly eventName: string;
|
|
19
|
+
}
|
|
20
|
+
/** Token is the exact placeholder name, without braces. Labels are presentation only. */
|
|
21
|
+
export interface EmailSourceField {
|
|
22
|
+
readonly token: string;
|
|
23
|
+
readonly label: string;
|
|
24
|
+
readonly required: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Recipient ids are source-owned choices, never caller-supplied addresses or expressions. */
|
|
27
|
+
export interface EmailRecipientChoice {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly label: string;
|
|
30
|
+
}
|
|
31
|
+
export interface EmailReadiness {
|
|
32
|
+
readonly ready: boolean;
|
|
33
|
+
readonly blockers: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
/** Persisted by the sender's existing write owner, never by a template-side relationship. */
|
|
36
|
+
export interface EmailBindingInput {
|
|
37
|
+
readonly bindingId: string;
|
|
38
|
+
readonly enabled: boolean;
|
|
39
|
+
readonly templateId: string;
|
|
40
|
+
readonly recipientId: string;
|
|
41
|
+
readonly replyTo: string;
|
|
42
|
+
}
|
|
43
|
+
export interface EmailBinding extends EmailBindingInput {
|
|
44
|
+
readonly activatedAt: string | null;
|
|
45
|
+
}
|
|
46
|
+
export interface EmailSourceDescription extends EmailSourceSummary {
|
|
47
|
+
readonly fields: readonly EmailSourceField[];
|
|
48
|
+
readonly permittedRecipients: readonly EmailRecipientChoice[];
|
|
49
|
+
readonly purpose: NonNullable<MailerSendOptions["purpose"]>;
|
|
50
|
+
readonly lane: NonNullable<MailerSendOptions["lane"]>;
|
|
51
|
+
readonly binding: EmailBinding | null;
|
|
52
|
+
readonly readiness: EmailReadiness;
|
|
53
|
+
}
|
|
54
|
+
/** Only allowlisted event values and recipients cross this boundary; no stored event row. */
|
|
55
|
+
export interface ResolvedEmailEvent {
|
|
56
|
+
/** Authoritative ISO event time, compared with the binding's latest activation to prevent backfill. */
|
|
57
|
+
readonly occurredAt: string;
|
|
58
|
+
readonly values: Readonly<Record<string, string>>;
|
|
59
|
+
readonly recipients: readonly {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly address: EmailAddress;
|
|
62
|
+
}[];
|
|
63
|
+
}
|
|
64
|
+
export type EmailSourceRequest = EmailSenderContext & {
|
|
65
|
+
readonly sourceId: string;
|
|
66
|
+
};
|
|
67
|
+
/** Every operation must authorize its own scope. Resolution also verifies event/source ownership.
|
|
68
|
+
* No example-listing API: hosts may offer an authorized latest submission or a labeled sample. */
|
|
69
|
+
export interface EmailSender {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly label: string;
|
|
72
|
+
listSources(required: EmailSenderContext, optional?: Record<string, never>): Promise<readonly EmailSourceSummary[]>;
|
|
73
|
+
describeSource(required: EmailSourceRequest, optional?: Record<string, never>): Promise<EmailSourceDescription>;
|
|
74
|
+
resolveEvent(required: EmailSourceRequest & {
|
|
75
|
+
readonly eventId: string;
|
|
76
|
+
}, optional?: Record<string, never>): Promise<ResolvedEmailEvent>;
|
|
77
|
+
configureEmail(required: EmailSourceRequest & {
|
|
78
|
+
readonly binding: EmailBindingInput;
|
|
79
|
+
}, optional?: Record<string, never>): Promise<EmailBinding>;
|
|
80
|
+
}
|
|
81
|
+
/** Describe must report all placeholders, including those absent from the template declarations.
|
|
82
|
+
* Render must use send-safe rendering, supplied values only, and refuse a changed version. */
|
|
83
|
+
export interface EmailTemplatePort {
|
|
84
|
+
describe(required: EmailSenderContext & {
|
|
85
|
+
readonly templateId: string;
|
|
86
|
+
}, optional?: Record<string, never>): Promise<{
|
|
87
|
+
readonly version: string;
|
|
88
|
+
readonly fields: readonly string[];
|
|
89
|
+
readonly placeholders: readonly string[];
|
|
90
|
+
}>;
|
|
91
|
+
render(required: EmailSenderContext & {
|
|
92
|
+
readonly templateId: string;
|
|
93
|
+
readonly version: string;
|
|
94
|
+
readonly values: Readonly<Record<string, string>>;
|
|
95
|
+
}, optional?: Record<string, never>): Promise<{
|
|
96
|
+
readonly subject: string;
|
|
97
|
+
readonly html: string;
|
|
98
|
+
readonly text: string;
|
|
99
|
+
readonly missingFields: readonly string[];
|
|
100
|
+
}>;
|
|
101
|
+
}
|
|
102
|
+
/** Caller-safe failures never include event values, recipients or credentials. */
|
|
103
|
+
export declare class EmailSenderError extends Error {
|
|
104
|
+
readonly code: "forbidden" | "not_found" | "ownership" | "configuration" | "not_ready" | "incompatible" | "missing_value";
|
|
105
|
+
constructor(required: {
|
|
106
|
+
readonly code: EmailSenderError["code"];
|
|
107
|
+
}, _optional?: Record<string, never>);
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=email-sender.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Caller-safe failures never include event values, recipients or credentials. */
|
|
2
|
+
export class EmailSenderError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(required, _optional = {}) {
|
|
5
|
+
super(`Email sender: ${required.code}`);
|
|
6
|
+
this.code = required.code;
|
|
7
|
+
this.name = "EmailSenderError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=email-sender.js.map
|