@jini-ai/platform 0.6.10 → 0.6.12
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 +17 -0
- package/dist/fs/atomic-write.d.ts +1 -1
- package/dist/fs/atomic-write.js +18 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mail/adapter-module.d.ts +6 -0
- package/dist/mail/delivery-wrapper.d.ts +46 -0
- package/dist/mail/delivery-wrapper.js +109 -0
- package/dist/mail/domain-read.d.ts +21 -0
- package/dist/mail/domain-read.js +17 -0
- package/dist/mail/engine-contracts.d.ts +129 -0
- package/dist/mail/engine-contracts.js +2 -0
- package/dist/mail/provider-error.d.ts +18 -0
- package/dist/mail/provider-error.js +24 -0
- package/dist/mail/purpose-scoped-mailer.d.ts +12 -1
- package/dist/mail/purpose-scoped-mailer.js +23 -12
- package/dist/mail/sender-domain-verification.d.ts +40 -0
- package/dist/mail/sender-domain-verification.js +71 -0
- package/dist/mail/types.d.ts +19 -0
- package/dist/process.d.ts +27 -2
- package/dist/process.js +85 -2
- package/dist/secrets/index.d.ts +2 -0
- package/dist/secrets/index.js +1 -0
- package/dist/secrets/passphrase-envelope.d.ts +26 -0
- package/dist/secrets/passphrase-envelope.js +125 -0
- package/dist/web/web-page.d.ts +4 -1
- package/dist/web/web-page.js +23 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @jini-ai/platform
|
|
2
2
|
|
|
3
|
+
## 0.6.12
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- platform: `listProcessUsage()` RSS/CPU sampler for macOS, Windows and Linux; `@jini-ai/platform/secrets` adds `sealWithPassphrase`/`openWithPassphrase` (portable passphrase envelope for encrypted exports); internal mail engine contracts, provider-error redaction, domain reads and per-provider delivery wrapper (not yet exported).
|
|
8
|
+
- @jini-ai/core@0.6.12
|
|
9
|
+
|
|
10
|
+
## 0.6.11
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- Tool inputs apply a sole near-miss and declared input aliases; plugin-load outcomes are reported; `web_fetch` reads SVG/JS as text; `content_read` suggests its resource first; the browser falls back to system Chrome.
|
|
15
|
+
- `./node` file-tree projects with revision undo and a React-free `./session`; `./html` gains a non-throwing region scan and pure region edits; async atomic writes in platform.
|
|
16
|
+
- Updated dependencies
|
|
17
|
+
- Updated dependencies
|
|
18
|
+
- @jini-ai/core@0.6.11
|
|
19
|
+
|
|
3
20
|
## 0.6.10
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
|
@@ -109,7 +109,7 @@ export declare function writeJsonFileAtomic(required: Omit<AtomicWriteRequired,
|
|
|
109
109
|
* Parent creation supports runtime-state and secret stores without another writer implementation. */
|
|
110
110
|
export declare function writeFileAtomicAsync(required: {
|
|
111
111
|
filePath: string;
|
|
112
|
-
content: string;
|
|
112
|
+
content: string | Uint8Array;
|
|
113
113
|
}, options?: AtomicWriteOptions & {
|
|
114
114
|
fs?: AtomicAsyncFilesystem;
|
|
115
115
|
createParent?: boolean;
|
package/dist/fs/atomic-write.js
CHANGED
|
@@ -232,7 +232,7 @@ export async function writeFileAtomicAsync(required, options = {}) {
|
|
|
232
232
|
closed = true;
|
|
233
233
|
if (options.refuseSymlink)
|
|
234
234
|
await ignoreMissingAsync(async () => assertNotSymlink(await filesystem.lstat(filePath), options));
|
|
235
|
-
await filesystem
|
|
235
|
+
await renameWithRetry({ filesystem, from: temporary, to: filePath });
|
|
236
236
|
renamed = true;
|
|
237
237
|
await syncDirectoryBestEffortAsync({ directory: path.dirname(filePath), open: (path, flags) => filesystem.open(path, flags) }, { platform });
|
|
238
238
|
}
|
|
@@ -244,6 +244,23 @@ export async function writeFileAtomicAsync(required, options = {}) {
|
|
|
244
244
|
throw error;
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
|
+
/** Editors, antivirus and dev-server watchers can hold Windows destinations briefly.
|
|
248
|
+
* Retry only transient rename refusals; retain the same temporary file and fail after 5 tries.
|
|
249
|
+
* @complexity O(1) time/space, at most 150ms of backoff plus five filesystem calls.
|
|
250
|
+
*/
|
|
251
|
+
async function renameWithRetry({ filesystem, from, to }) {
|
|
252
|
+
for (let attempt = 0;; attempt += 1) {
|
|
253
|
+
try {
|
|
254
|
+
await filesystem.rename(from, to);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (attempt >= 4 || !hasErrorCode(error, ['EPERM', 'EBUSY']))
|
|
259
|
+
throw error;
|
|
260
|
+
await new Promise(resolve => setTimeout(resolve, 10 * 2 ** attempt));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
247
264
|
/** Pretty JSON can retain a state-store's trailing newline without changing its serialization. */
|
|
248
265
|
export async function writeJsonFileAtomicAsync(required, options = {}) {
|
|
249
266
|
const { data, ...writer } = required;
|
package/dist/index.d.ts
CHANGED
|
@@ -36,8 +36,8 @@ export { createCommandInvocation, createPackageManagerInvocation } from "./comma
|
|
|
36
36
|
export { omitEnvironmentKeys } from "./child-environment.js";
|
|
37
37
|
export type { ResolveSystemProxyEnvOptions, SystemProxyCommandRunner } from "./proxy-env.js";
|
|
38
38
|
export { mergeProxyAwareEnv, parseMacosScutilProxyOutput, parseWindowsInternetSettingsProxyOutput, resolveSystemProxyEnv, } from "./proxy-env.js";
|
|
39
|
-
export type { ProcessSnapshot, ProcessStampContract, ProcessStampField, ProcessStampShape, SpawnProcessRequest, StampedProcessMatchCriteria, StopProcessesResult, } from "./process.js";
|
|
40
|
-
export { collectProcessTreePids, createProcessStampArgs, isProcessAlive, listProcessSnapshots, matchesProcessStamp, matchesStampedProcess, readFlagValue, readProcessStamp, readProcessStampFromCommand, spawnBackgroundProcess, spawnLoggedProcess, stopProcesses, waitForProcessExit, } from "./process.js";
|
|
39
|
+
export type { ProcessUsage, ProcessUsagePorts, ProcessUsageOptions, ProcessSnapshot, ProcessStampContract, ProcessStampField, ProcessStampShape, SpawnProcessRequest, StampedProcessMatchCriteria, StopProcessesResult, } from "./process.js";
|
|
40
|
+
export { collectProcessTreePids, createProcessStampArgs, isProcessAlive, listProcessSnapshots, listProcessUsage, matchesProcessStamp, matchesStampedProcess, readFlagValue, readProcessStamp, readProcessStampFromCommand, spawnBackgroundProcess, spawnLoggedProcess, stopProcesses, waitForProcessExit, } from "./process.js";
|
|
41
41
|
export type { AtomicCopyFileOptions, AtomicCopyFileResult, RemovePathBestEffortOptions, RemovePathBestEffortResult, } from "./fs.js";
|
|
42
42
|
export { atomicCopyFile, pathContains, readLogTail, removePathBestEffort } from "./fs.js";
|
|
43
43
|
export type { HttpWaitOptions } from "./http.js";
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
export { createCommandInvocation, createPackageManagerInvocation } from "./command.js";
|
|
35
35
|
export { omitEnvironmentKeys } from "./child-environment.js";
|
|
36
36
|
export { mergeProxyAwareEnv, parseMacosScutilProxyOutput, parseWindowsInternetSettingsProxyOutput, resolveSystemProxyEnv, } from "./proxy-env.js";
|
|
37
|
-
export { collectProcessTreePids, createProcessStampArgs, isProcessAlive, listProcessSnapshots, matchesProcessStamp, matchesStampedProcess, readFlagValue, readProcessStamp, readProcessStampFromCommand, spawnBackgroundProcess, spawnLoggedProcess, stopProcesses, waitForProcessExit, } from "./process.js";
|
|
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
40
|
export { FETCH_TIMEOUT_MS, FetchTimeoutError, fetchWithTimeout } from "./fetch-with-timeout.js";
|
|
@@ -15,6 +15,12 @@ export interface MailAdapterCreateContext {
|
|
|
15
15
|
}
|
|
16
16
|
/** Provider modules return the ordinary mail port; the shared contract names no vendor. */
|
|
17
17
|
export interface MailAdapterModule {
|
|
18
|
+
/** Optional host-resolved account connection for domain reads only. The host validates the
|
|
19
|
+
* HTTPS origin and plugin ownership; this credential must never replace the sending key. */
|
|
20
|
+
readonly domainReadConnection?: {
|
|
21
|
+
readonly serverId: string;
|
|
22
|
+
readonly baseUrl: string;
|
|
23
|
+
};
|
|
18
24
|
create(context: MailAdapterCreateContext): MailerPort;
|
|
19
25
|
}
|
|
20
26
|
//# sourceMappingURL=adapter-module.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider wrapper around the existing delivery policy. Test mode is read for every recipient;
|
|
3
|
+
* limiter and observation-warning state live for the lifetime of the wrapper factory.
|
|
4
|
+
* The purpose gate still wraps the whole mailer at composition; this policy sits inside it.
|
|
5
|
+
*/
|
|
6
|
+
import { type MailResendPolicy } from "./policy.js";
|
|
7
|
+
import type { MailCapturePort, MailerPort, MailSendLedgerPort, MailSuppressionRepoPort } from "./ports.js";
|
|
8
|
+
import type { MailProviderModule } from "./provider.js";
|
|
9
|
+
import type { MailerSendOptions, MailerSendResult, MailTestModeSetting, OutboundEmail } from "./types.js";
|
|
10
|
+
export interface MailPolicyWrapperDeps {
|
|
11
|
+
ledger: MailSendLedgerPort;
|
|
12
|
+
suppression: MailSuppressionRepoPort;
|
|
13
|
+
/** Fresh host settings read; a mode change applies to the next recipient. */
|
|
14
|
+
readTestMode: () => Promise<MailTestModeSetting>;
|
|
15
|
+
/** The host owns purpose/module-specific resend decisions. */
|
|
16
|
+
resendPolicy: (required: {
|
|
17
|
+
options: MailerSendOptions;
|
|
18
|
+
}) => MailResendPolicy;
|
|
19
|
+
observe?: (required: {
|
|
20
|
+
message: OutboundEmail;
|
|
21
|
+
options: MailerSendOptions;
|
|
22
|
+
providerId: string;
|
|
23
|
+
mode: MailTestModeSetting;
|
|
24
|
+
result: MailerSendResult;
|
|
25
|
+
}) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface MailPolicyWrapperOptions {
|
|
28
|
+
/** Capture without a sink refuses rather than sending. */
|
|
29
|
+
capture?: MailCapturePort;
|
|
30
|
+
/** Limiter clock in ms; defaults to Date.now. */
|
|
31
|
+
now?: () => number;
|
|
32
|
+
/** Fixed observation-failure diagnostic; logger faults cannot affect delivery. */
|
|
33
|
+
warn?: (line: string) => void;
|
|
34
|
+
}
|
|
35
|
+
export interface MailPolicyWrapperInput {
|
|
36
|
+
providerId: string;
|
|
37
|
+
provider: MailProviderModule;
|
|
38
|
+
mailer: MailerPort;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the wrapper applied to each resolved provider. Settings/policy failures propagate;
|
|
42
|
+
* observer and diagnostic failures preserve the exact policy result.
|
|
43
|
+
* @complexity One settings read plus policy round-trips per recipient; batches run sequentially.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createMailPolicyWrapper(required: MailPolicyWrapperDeps, optional?: MailPolicyWrapperOptions): (required: MailPolicyWrapperInput, optional?: Record<string, never>) => MailerPort;
|
|
46
|
+
//# sourceMappingURL=delivery-wrapper.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider wrapper around the existing delivery policy. Test mode is read for every recipient;
|
|
3
|
+
* limiter and observation-warning state live for the lifetime of the wrapper factory.
|
|
4
|
+
* The purpose gate still wraps the whole mailer at composition; this policy sits inside it.
|
|
5
|
+
*/
|
|
6
|
+
import { createMailDeliveryPolicy } from "./policy.js";
|
|
7
|
+
import { createMailLaneLimiter } from "./rate-limit.js";
|
|
8
|
+
const CAPTURE_UNAVAILABLE = { ok: false, retryable: false, errorCode: "MAIL_TEST_CAPTURE_UNAVAILABLE", message: "mail test mode is set to capture, but no capture store is available; nothing was sent" };
|
|
9
|
+
/** Maps settings to policy test mode; null means capture was requested without a sink. */
|
|
10
|
+
function toTestMode(required) {
|
|
11
|
+
const { setting, capture } = required;
|
|
12
|
+
if (setting.mode === "redirect")
|
|
13
|
+
return { kind: "redirect", to: { email: setting.to } };
|
|
14
|
+
if (setting.mode === "capture")
|
|
15
|
+
return capture ? { kind: "capture", sink: capture } : null;
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Preserve serialization order and null-provider approvals: these bytes are caller-owned. */
|
|
19
|
+
function approvalChanged(required) {
|
|
20
|
+
const { message, options, providerId, mailer, setting } = required;
|
|
21
|
+
const approval = options.mailTestApproval;
|
|
22
|
+
const actual = { providerId, driver: mailer.capabilities({}).driver, from: message.from, mode: setting.mode, effectiveRecipient: setting.mode === "redirect" ? setting.to : message.to.email };
|
|
23
|
+
return !!approval && JSON.stringify({ ...actual, providerId: approval.providerId === null ? null : providerId }) !== JSON.stringify(approval);
|
|
24
|
+
}
|
|
25
|
+
/** Delivery has already happened: activity storage and diagnostics must never alter its result. */
|
|
26
|
+
function createResultObserver(required, optional) {
|
|
27
|
+
let observationWarned = false;
|
|
28
|
+
return async function observeResult(event) {
|
|
29
|
+
try {
|
|
30
|
+
await required.observe?.(event);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
if (observationWarned)
|
|
34
|
+
return;
|
|
35
|
+
observationWarned = true;
|
|
36
|
+
try {
|
|
37
|
+
(optional.warn ?? console.warn)("[mail] activity observation failed");
|
|
38
|
+
}
|
|
39
|
+
catch { /* Diagnostics cannot reject a send. */ }
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** One token bucket per provider id, shared across all mailers from this factory. */
|
|
44
|
+
function createLimiterLookup(optional) {
|
|
45
|
+
const limiters = new Map();
|
|
46
|
+
return function limiterFor({ provider, providerId }) {
|
|
47
|
+
const limit = provider.capabilities.rateLimit;
|
|
48
|
+
if (!limit)
|
|
49
|
+
return undefined;
|
|
50
|
+
const existing = limiters.get(providerId);
|
|
51
|
+
if (existing)
|
|
52
|
+
return existing;
|
|
53
|
+
const created = createMailLaneLimiter({ limit }, optional.now ? { now: optional.now } : {});
|
|
54
|
+
limiters.set(providerId, created);
|
|
55
|
+
return created;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Convert the host's complete options to the existing mail port without changing delivery policy. */
|
|
59
|
+
function sendToMailer(required) {
|
|
60
|
+
const { idempotencyKey, workspaceId, sourceContext, ...optional } = required.options;
|
|
61
|
+
return required.mailer.send({ message: required.message, idempotencyKey, workspaceId, sourceContext }, optional);
|
|
62
|
+
}
|
|
63
|
+
/** Read → approve → capture check → policy → observe. Rejections propagate before observation. */
|
|
64
|
+
function createRecipientDelivery(required) {
|
|
65
|
+
const { providerId, mailer, deps, optional, limiterFor, observeResult } = required;
|
|
66
|
+
return async function deliverOne({ message, options }) {
|
|
67
|
+
const setting = await deps.readTestMode();
|
|
68
|
+
if (approvalChanged({ message, options, providerId, mailer, setting })) {
|
|
69
|
+
return { ok: false, retryable: false, errorCode: "MAIL_TEST_APPROVAL_CHANGED", message: "Mail configuration changed. Prepare a new approval; nothing was sent." };
|
|
70
|
+
}
|
|
71
|
+
const testMode = toTestMode({ setting, ...(optional.capture ? { capture: optional.capture } : {}) });
|
|
72
|
+
if (testMode === null)
|
|
73
|
+
return CAPTURE_UNAVAILABLE;
|
|
74
|
+
const limiter = limiterFor(required);
|
|
75
|
+
const policy = createMailDeliveryPolicy({ providerId, ledger: deps.ledger, suppression: deps.suppression, supportsAttachments: mailer.capabilities({}).supportsAttachments }, { ...(limiter ? { limiter } : {}), ...(testMode ? { testMode } : {}), resendPolicy: () => deps.resendPolicy({ options }) });
|
|
76
|
+
const result = await policy.deliver({ message, options, send: (call) => sendToMailer({ message: call.message, options, mailer }) });
|
|
77
|
+
await observeResult({ message, options, providerId, mode: setting, result });
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Build a provider mailer; batches retain ordered, per-recipient policy results and indexed keys. */
|
|
82
|
+
function wrapProviderMailer(required) {
|
|
83
|
+
const deliverOne = createRecipientDelivery(required);
|
|
84
|
+
return {
|
|
85
|
+
capabilities: () => required.mailer.capabilities({}),
|
|
86
|
+
send: ({ message, ...delivery }, optional = {}) => deliverOne({ message, options: { ...delivery, ...optional } }),
|
|
87
|
+
async sendBatch({ messages, ...delivery }, optional = {}) {
|
|
88
|
+
const options = { ...delivery, ...optional };
|
|
89
|
+
// Per-recipient ledger rows need per-recipient dispatch. A shared key makes messages 2..n
|
|
90
|
+
// look like retries of 1. Await each recipient so partial results and effects stay ordered.
|
|
91
|
+
const results = [];
|
|
92
|
+
for (const [index, message] of messages.entries())
|
|
93
|
+
results.push(await deliverOne({ message, options: { ...options, idempotencyKey: `${options.idempotencyKey}:${index}` } }));
|
|
94
|
+
return results;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build the wrapper applied to each resolved provider. Settings/policy failures propagate;
|
|
100
|
+
* observer and diagnostic failures preserve the exact policy result.
|
|
101
|
+
* @complexity One settings read plus policy round-trips per recipient; batches run sequentially.
|
|
102
|
+
*/
|
|
103
|
+
export function createMailPolicyWrapper(required, optional = {}) {
|
|
104
|
+
const state = { deps: required, optional, limiterFor: createLimiterLookup(optional), observeResult: createResultObserver(required, optional) };
|
|
105
|
+
return function wrap(input, _optional = {}) {
|
|
106
|
+
return wrapProviderMailer({ ...input, ...state });
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=delivery-wrapper.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProviderResult } from "@jini-ai/core/provider-registry";
|
|
2
|
+
import type { MailAdapterCredential } from "./adapter-module.js";
|
|
3
|
+
import type { MailProviderCallContext } from "./provider.js";
|
|
4
|
+
/** The active provider's plugin can supply a separate, read-capable account credential. */
|
|
5
|
+
export type MailDomainReadCredentialPort = (required: {
|
|
6
|
+
id: string;
|
|
7
|
+
}, options?: Record<string, never>) => Promise<MailAdapterCredential | null>;
|
|
8
|
+
/** Only permission refusals justify asking for a more capable account connection. */
|
|
9
|
+
export declare function isDomainReadPermissionRefused(required: {
|
|
10
|
+
result: ProviderResult<unknown>;
|
|
11
|
+
}, _options?: Record<string, never>): boolean;
|
|
12
|
+
/** Read with the sending key first, then the plugin's account key on a permission refusal.
|
|
13
|
+
* The replacement context is read-only and never reaches the sending mailer.
|
|
14
|
+
* @complexity At most two provider reads and one account-credential lookup. */
|
|
15
|
+
export declare function readMailDomain<T>(required: {
|
|
16
|
+
ctx: MailProviderCallContext;
|
|
17
|
+
providerId: string;
|
|
18
|
+
read: (ctx: MailProviderCallContext) => Promise<ProviderResult<T>>;
|
|
19
|
+
resolveDomainReadCredential?: MailDomainReadCredentialPort;
|
|
20
|
+
}, _options?: Record<string, never>): Promise<ProviderResult<T>>;
|
|
21
|
+
//# sourceMappingURL=domain-read.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Only permission refusals justify asking for a more capable account connection. */
|
|
2
|
+
export function isDomainReadPermissionRefused(required, _options = {}) {
|
|
3
|
+
const { result } = required;
|
|
4
|
+
return result.status === "rejected" && result.error.code === "forbidden";
|
|
5
|
+
}
|
|
6
|
+
/** Read with the sending key first, then the plugin's account key on a permission refusal.
|
|
7
|
+
* The replacement context is read-only and never reaches the sending mailer.
|
|
8
|
+
* @complexity At most two provider reads and one account-credential lookup. */
|
|
9
|
+
export async function readMailDomain(required, _options = {}) {
|
|
10
|
+
const { ctx, providerId, read, resolveDomainReadCredential } = required;
|
|
11
|
+
const result = await read(ctx);
|
|
12
|
+
if (!isDomainReadPermissionRefused({ result }) || !resolveDomainReadCredential)
|
|
13
|
+
return result;
|
|
14
|
+
const credential = await resolveDomainReadCredential({ id: providerId }).catch(() => null);
|
|
15
|
+
return credential ? read({ ...ctx, credential }) : result;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=domain-read.js.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { MailAdapterCredential } from "./adapter-module.js";
|
|
2
|
+
import type { MailDomainState, MailProviderCapabilities, MailProviderRegistry, MailProviderSetting, MailStatus } from "./provider.js";
|
|
3
|
+
/** Metadata only: the host reads these facts before decrypting a credential. */
|
|
4
|
+
export interface MailCredentialFact {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly vaultRevision?: number;
|
|
8
|
+
readonly updatedAt: string;
|
|
9
|
+
}
|
|
10
|
+
/** Credential presence exposed in configuration reports; never a token or username. */
|
|
11
|
+
export interface MailProviderCredentialFacts {
|
|
12
|
+
readonly required: boolean;
|
|
13
|
+
readonly saved: boolean;
|
|
14
|
+
readonly label?: string;
|
|
15
|
+
readonly credentialId?: string;
|
|
16
|
+
readonly updatedAt?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Declarative setup copy belongs to the host or plugin. */
|
|
19
|
+
export interface MailProviderField {
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly label: string;
|
|
22
|
+
readonly type: "secret" | "text" | "url" | "email";
|
|
23
|
+
readonly required: boolean;
|
|
24
|
+
readonly defaultValue?: string;
|
|
25
|
+
readonly placeholder?: string;
|
|
26
|
+
readonly help?: string;
|
|
27
|
+
readonly helpUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MailProviderSetup {
|
|
30
|
+
readonly fields: readonly MailProviderField[];
|
|
31
|
+
readonly helpUrl?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Safe host-supplied projection; configuration building and product copy stay in the host. */
|
|
34
|
+
export interface MailProviderConfiguration {
|
|
35
|
+
readonly pluginId?: string;
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly label: string;
|
|
38
|
+
readonly origin: "core" | "plugin";
|
|
39
|
+
readonly hosted: boolean;
|
|
40
|
+
readonly lanes: readonly ("transactional" | "bulk")[];
|
|
41
|
+
readonly capabilities: Pick<MailProviderCapabilities, "feedback" | "domainVerification" | "rateLimit">;
|
|
42
|
+
readonly credential: MailProviderCredentialFacts;
|
|
43
|
+
readonly selectable: boolean;
|
|
44
|
+
readonly refusal?: string;
|
|
45
|
+
readonly setup: MailProviderSetup;
|
|
46
|
+
}
|
|
47
|
+
export interface MailProviderConfigurationReport {
|
|
48
|
+
readonly setting: MailProviderSetting;
|
|
49
|
+
readonly providers: readonly MailProviderConfiguration[];
|
|
50
|
+
readonly refusals: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
/** A provider failure safe for callers; contains neither credentials nor message bodies. */
|
|
53
|
+
export interface MailProviderError {
|
|
54
|
+
readonly at: string;
|
|
55
|
+
readonly providerId: string | null;
|
|
56
|
+
readonly source: "send" | "check";
|
|
57
|
+
readonly code?: string;
|
|
58
|
+
readonly message: string;
|
|
59
|
+
}
|
|
60
|
+
/** Alias the provider's existing verdict vocabulary instead of defining another taxonomy. */
|
|
61
|
+
export type ProviderDomainState = MailDomainState;
|
|
62
|
+
/** Provider verification is independent of DNS answers and credential presence. */
|
|
63
|
+
export type SenderDomainVerification = {
|
|
64
|
+
state: ProviderDomainState;
|
|
65
|
+
domain: string;
|
|
66
|
+
providerId: string;
|
|
67
|
+
} | {
|
|
68
|
+
state: "no_sender";
|
|
69
|
+
providerId: string | null;
|
|
70
|
+
} | {
|
|
71
|
+
state: "no_provider";
|
|
72
|
+
domain: string;
|
|
73
|
+
providerId: string | null;
|
|
74
|
+
} | {
|
|
75
|
+
state: "unsupported";
|
|
76
|
+
domain: string;
|
|
77
|
+
providerId: string;
|
|
78
|
+
} | {
|
|
79
|
+
state: "unknown";
|
|
80
|
+
domain?: string;
|
|
81
|
+
providerId: string | null;
|
|
82
|
+
reason: string;
|
|
83
|
+
};
|
|
84
|
+
export interface MailStatusReport extends MailStatus {
|
|
85
|
+
readonly senderDomainVerification?: SenderDomainVerification;
|
|
86
|
+
readonly providers: readonly MailProviderConfiguration[];
|
|
87
|
+
readonly refusals: readonly string[];
|
|
88
|
+
readonly checkedAt: string;
|
|
89
|
+
readonly lastError?: MailProviderError;
|
|
90
|
+
}
|
|
91
|
+
export type MailProviderCheck = {
|
|
92
|
+
ok: true;
|
|
93
|
+
label: string;
|
|
94
|
+
} | {
|
|
95
|
+
ok: false;
|
|
96
|
+
reason: string;
|
|
97
|
+
};
|
|
98
|
+
/** Host-built catalog: trusted loading, labels and setup copy remain outside the engine. */
|
|
99
|
+
export interface MailProviderCatalog {
|
|
100
|
+
readonly registry: MailProviderRegistry;
|
|
101
|
+
readonly pluginIds: readonly string[];
|
|
102
|
+
readonly refusals: readonly string[];
|
|
103
|
+
credentialLabelFor(required: {
|
|
104
|
+
id: string;
|
|
105
|
+
}): string | undefined;
|
|
106
|
+
setupFor(required: {
|
|
107
|
+
id: string;
|
|
108
|
+
}): MailProviderSetup;
|
|
109
|
+
}
|
|
110
|
+
/** Host settings adapter; reads do not infer provider selection from saved credentials. */
|
|
111
|
+
export interface MailProviderSettingPort {
|
|
112
|
+
read(required: Record<string, never>): Promise<MailProviderSetting | null>;
|
|
113
|
+
write(required: {
|
|
114
|
+
setting: MailProviderSetting;
|
|
115
|
+
}): Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
/** Host owns metadata lookup and decryption. Metadata may fail independently of key resolution. */
|
|
118
|
+
export interface MailCredentialPort {
|
|
119
|
+
readFact(required: {
|
|
120
|
+
label: string;
|
|
121
|
+
}): Promise<MailCredentialFact | undefined>;
|
|
122
|
+
resolve(required: {
|
|
123
|
+
catalog: MailProviderCatalog;
|
|
124
|
+
id: string;
|
|
125
|
+
}): Promise<MailAdapterCredential | null>;
|
|
126
|
+
}
|
|
127
|
+
/** Read-only provider verdict; implementations return unknown on lookup/provider failures. */
|
|
128
|
+
export type SenderDomainVerificationPort = () => Promise<SenderDomainVerification>;
|
|
129
|
+
//# sourceMappingURL=engine-contracts.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Provider diagnostics cross the host boundary only after Jini's secret scrubber, then a cap.
|
|
2
|
+
* Exact credential values are supplied where available; redaction precedes truncation so a token
|
|
3
|
+
* crossing the length boundary cannot leave a readable prefix. Mail also masks URL token parameters
|
|
4
|
+
* (including fragments) through Jini's caller-owned patterns.
|
|
5
|
+
* @complexity O(n) in diagnostic text for a bounded set of credential values; output <= 1000 chars.
|
|
6
|
+
*/
|
|
7
|
+
export declare function safeMailProviderText({ text }: {
|
|
8
|
+
text: string;
|
|
9
|
+
}, { secrets }?: {
|
|
10
|
+
secrets?: readonly string[];
|
|
11
|
+
}): string;
|
|
12
|
+
/** The credential's string values, so exact redaction also catches keys no pattern recognises.
|
|
13
|
+
* @complexity O(k) credential fields.
|
|
14
|
+
*/
|
|
15
|
+
export declare function credentialSecrets({ credential }: {
|
|
16
|
+
credential: object;
|
|
17
|
+
}, _options?: Record<string, never>): string[];
|
|
18
|
+
//# sourceMappingURL=provider-error.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { redactSecrets } from "@jini-ai/core";
|
|
2
|
+
/** Provider diagnostics cross the host boundary only after Jini's secret scrubber, then a cap.
|
|
3
|
+
* Exact credential values are supplied where available; redaction precedes truncation so a token
|
|
4
|
+
* crossing the length boundary cannot leave a readable prefix. Mail also masks URL token parameters
|
|
5
|
+
* (including fragments) through Jini's caller-owned patterns.
|
|
6
|
+
* @complexity O(n) in diagnostic text for a bounded set of credential values; output <= 1000 chars.
|
|
7
|
+
*/
|
|
8
|
+
export function safeMailProviderText({ text }, { secrets = [] } = {}) {
|
|
9
|
+
return redactSecrets({ input: text }, {
|
|
10
|
+
exactSecrets: secrets,
|
|
11
|
+
redactPii: false,
|
|
12
|
+
// Any secret-shaped URL parameter (token, key, secret, signature, code, password), not only `token=`:
|
|
13
|
+
// provider errors echo whole request URLs, and the parameter name varies by provider. The value runs
|
|
14
|
+
// to the next `&` so a trailing `#fragment` is masked with it.
|
|
15
|
+
extraPatterns: [{ name: "url_secret_param", regex: /[?&#;][\w.-]*(?:token|key|secret|signature|sig|code|password|pwd|auth)[\w.-]*=[^&\s"'<>]+/gi }],
|
|
16
|
+
}).slice(0, 1000);
|
|
17
|
+
}
|
|
18
|
+
/** The credential's string values, so exact redaction also catches keys no pattern recognises.
|
|
19
|
+
* @complexity O(k) credential fields.
|
|
20
|
+
*/
|
|
21
|
+
export function credentialSecrets({ credential }, _options = {}) {
|
|
22
|
+
return Object.values(credential).filter((value) => typeof value === "string");
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=provider-error.js.map
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
/** Unknown and absent lanes require a durable notification path in production.
|
|
2
2
|
* Local mode and explicit interactive sends proceed without that gate.
|
|
3
3
|
*/
|
|
4
|
-
import type { MailerPort, DeliveryReadinessPolicy } from "./ports.js";
|
|
4
|
+
import type { MailerPort, MailerSendOptions, DeliveryReadinessPolicy } from "./ports.js";
|
|
5
5
|
type RuntimeMode = "production" | "local";
|
|
6
6
|
export interface WrapMailerWithPurposeGateOptions {
|
|
7
7
|
inner: MailerPort;
|
|
8
8
|
mode: RuntimeMode;
|
|
9
9
|
readiness: DeliveryReadinessPolicy;
|
|
10
10
|
}
|
|
11
|
+
export interface PurposeGateRequired {
|
|
12
|
+
mode: RuntimeMode;
|
|
13
|
+
readiness: DeliveryReadinessPolicy;
|
|
14
|
+
options: Pick<MailerSendOptions, "lane" | "sourceContext">;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Ask the delivery gate without delivering: unknown/absent lanes require durable readiness.
|
|
18
|
+
* Invalid modes throw TypeError; readiness errors propagate so callers can report unknown.
|
|
19
|
+
* @complexity O(1), with at most one call to the supplied readiness predicate.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isPurposeGateOpen(required: PurposeGateRequired): boolean;
|
|
11
22
|
/** Validates the mode at wrap time and gates notification sends/batches before delivery.
|
|
12
23
|
* Unknown modes throw TypeError; only explicit local mode bypasses durable readiness.
|
|
13
24
|
* @complexity O(1) policy work per send/batch, before delegating delivery.
|
|
@@ -9,6 +9,25 @@ function resolveLane(options) {
|
|
|
9
9
|
function refusalError(capabilityName) {
|
|
10
10
|
return new Error(`MAILER_SEND_REFUSED_NO_DURABLE_PATH: notification-lane mailer send refused for capability "${capabilityName}" — no durable outbox path registered`);
|
|
11
11
|
}
|
|
12
|
+
function validateMode(mode) {
|
|
13
|
+
if (mode !== "production" && mode !== "local") {
|
|
14
|
+
throw new TypeError('mailer mode must be "production" or "local"');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Ask the delivery gate without delivering: unknown/absent lanes require durable readiness.
|
|
19
|
+
* Invalid modes throw TypeError; readiness errors propagate so callers can report unknown.
|
|
20
|
+
* @complexity O(1), with at most one call to the supplied readiness predicate.
|
|
21
|
+
*/
|
|
22
|
+
export function isPurposeGateOpen(required) {
|
|
23
|
+
const { mode, readiness, options } = required;
|
|
24
|
+
validateMode(mode);
|
|
25
|
+
if (mode === "local")
|
|
26
|
+
return true;
|
|
27
|
+
if (resolveLane(options) !== "notification")
|
|
28
|
+
return true;
|
|
29
|
+
return readiness.isReady({ capabilityName: options.sourceContext?.module ?? "unknown" });
|
|
30
|
+
}
|
|
12
31
|
// Decorate the existing port rather than inventing a second delivery contract. The caller resolves
|
|
13
32
|
// mode once at composition; never re-read the environment per send and change policy mid-process.
|
|
14
33
|
/** Validates the mode at wrap time and gates notification sends/batches before delivery.
|
|
@@ -18,19 +37,11 @@ function refusalError(capabilityName) {
|
|
|
18
37
|
*/
|
|
19
38
|
export function wrapMailerWithPurposeGate(options) {
|
|
20
39
|
const { inner, mode, readiness } = options;
|
|
21
|
-
// Validate
|
|
22
|
-
|
|
23
|
-
throw new TypeError('mailer mode must be "production" or "local"');
|
|
24
|
-
}
|
|
40
|
+
// Validate at composition: typos must never silently disable durable delivery.
|
|
41
|
+
validateMode(mode);
|
|
25
42
|
function checkGate(sendOptions) {
|
|
26
|
-
if (mode
|
|
27
|
-
|
|
28
|
-
const lane = resolveLane(sendOptions);
|
|
29
|
-
if (lane !== "notification")
|
|
30
|
-
return;
|
|
31
|
-
const capabilityName = sendOptions.sourceContext?.module ?? "unknown";
|
|
32
|
-
if (!readiness.isReady({ capabilityName })) {
|
|
33
|
-
throw refusalError(capabilityName);
|
|
43
|
+
if (!isPurposeGateOpen({ mode, readiness, options: sendOptions })) {
|
|
44
|
+
throw refusalError(sendOptions.sourceContext?.module ?? "unknown");
|
|
34
45
|
}
|
|
35
46
|
}
|
|
36
47
|
return {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MailAdapterCredential } from "./adapter-module.js";
|
|
2
|
+
import type { MailProviderModule } from "./provider.js";
|
|
3
|
+
import type { HttpClientPort } from "@jini-ai/core/primitives";
|
|
4
|
+
import type { SenderDomainVerificationPort } from "./engine-contracts.js";
|
|
5
|
+
import { type MailDomainReadCredentialPort } from "./domain-read.js";
|
|
6
|
+
/**
|
|
7
|
+
* @file The sender domain's state at the active transactional provider (its own `domainStatus`),
|
|
8
|
+
* for callers that need the provider verdict. Kept apart from the DNS
|
|
9
|
+
* check on purpose: a provider can report `pending` after every record resolves, and DNS can match
|
|
10
|
+
* while the provider was never asked. Each reason the provider cannot be asked is named apart, so
|
|
11
|
+
* "DNS verification unavailable for this provider" is a state of its own, not a failure.
|
|
12
|
+
*/
|
|
13
|
+
export interface SenderDomainVerificationDeps {
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
/** The host's guarded outbound-HTTP seam, handed through to the provider. */
|
|
16
|
+
httpClient: HttpClientPort;
|
|
17
|
+
/** The installed providers, read fresh (host catalog). */
|
|
18
|
+
findProvider: (required: {
|
|
19
|
+
id: string;
|
|
20
|
+
}) => Promise<MailProviderModule | undefined>;
|
|
21
|
+
/** The provider's saved key, or `null` when none is saved (host credential resolver). */
|
|
22
|
+
resolveCredential: (required: {
|
|
23
|
+
id: string;
|
|
24
|
+
}) => Promise<MailAdapterCredential | null>;
|
|
25
|
+
resolveDomainReadCredential?: MailDomainReadCredentialPort;
|
|
26
|
+
/** The configured transactional lane. */
|
|
27
|
+
readProviderId: () => Promise<string | null>;
|
|
28
|
+
/** The configured sender address. */
|
|
29
|
+
readSenderEmail: () => Promise<string | undefined>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the never-throwing provider-verdict port. Lookup failures become explicit verdicts;
|
|
33
|
+
* the deadline covers the whole check and clears its timer without cancelling provider work.
|
|
34
|
+
* @complexity Per call: two settings reads, one catalog load, at most two key lookups and two provider calls; O(1) space.
|
|
35
|
+
* @example createSenderDomainVerification(deps)()
|
|
36
|
+
*/
|
|
37
|
+
export declare function createSenderDomainVerification(required: SenderDomainVerificationDeps, options?: {
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}): SenderDomainVerificationPort;
|
|
40
|
+
//# sourceMappingURL=sender-domain-verification.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { credentialSecrets, safeMailProviderText } from "./provider-error.js";
|
|
2
|
+
import { isDomainReadPermissionRefused, readMailDomain } from "./domain-read.js";
|
|
3
|
+
/** The configured sender's domain, lower-cased; undefined when unset or unreadable.
|
|
4
|
+
* @complexity O(n) time and space in the sender address length; one settings read. */
|
|
5
|
+
async function senderDomain(deps) {
|
|
6
|
+
const email = await deps.readSenderEmail().catch(() => undefined);
|
|
7
|
+
return email?.split("@")[1]?.trim().toLowerCase() || undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Read domain status, trying the plugin's account on permission refusal; failures stay `unknown`.
|
|
10
|
+
* @complexity At most two provider reads and two key lookups; diagnostic output is capped at 1000 characters. */
|
|
11
|
+
async function askProvider(required) {
|
|
12
|
+
const { deps, provider, providerId, domain } = required;
|
|
13
|
+
const credential = await deps.resolveCredential({ id: providerId }).catch(() => null);
|
|
14
|
+
if (!credential)
|
|
15
|
+
return { state: "unknown", domain, providerId, reason: "the provider's key is not saved" };
|
|
16
|
+
// Same call context `resolve-mailer.ts` builds for a provider call: this workspace, live, its key.
|
|
17
|
+
const ctx = { workspaceId: deps.workspaceId, mode: "live", credential, httpClient: deps.httpClient };
|
|
18
|
+
const result = await readMailDomain({ ctx, providerId, read: context => provider.domainStatus(context, { domain }), ...(deps.resolveDomainReadCredential ? { resolveDomainReadCredential: deps.resolveDomainReadCredential } : {}) }).catch(() => null);
|
|
19
|
+
if (result?.status === "ok")
|
|
20
|
+
return { state: result.value.state, domain, providerId };
|
|
21
|
+
return { state: "unknown", domain, providerId, reason: providerReadFailureReason({ result, credential }) };
|
|
22
|
+
}
|
|
23
|
+
/** A refusal both keys hit is named plainly; any other provider answer keeps its code+message,
|
|
24
|
+
* secret-redacted (the sending key's exact values plus Jini's patterns) and capped.
|
|
25
|
+
* @complexity O(n) diagnostic text plus credential values; output is capped at 1000 characters.
|
|
26
|
+
*/
|
|
27
|
+
function providerReadFailureReason(required) {
|
|
28
|
+
const { result, credential } = required;
|
|
29
|
+
if (!result)
|
|
30
|
+
return "the provider did not answer the domain check";
|
|
31
|
+
if (isDomainReadPermissionRefused({ result }))
|
|
32
|
+
return "the sending key can't check domains, and no connected account could check it either";
|
|
33
|
+
return safeMailProviderText({ text: `${result.error.code}: ${result.error.message}` }, { secrets: credentialSecrets({ credential }) });
|
|
34
|
+
}
|
|
35
|
+
/** Resolve the configured sender against its provider, independently of DNS answers.
|
|
36
|
+
* Settings start together; catalog and credential reads follow in their original await order.
|
|
37
|
+
* @complexity Two settings reads, one catalog lookup and at most two provider reads; O(1) space.
|
|
38
|
+
*/
|
|
39
|
+
async function checkSenderDomain(deps) {
|
|
40
|
+
const [providerId, domain] = await Promise.all([deps.readProviderId().catch(() => null), senderDomain(deps)]);
|
|
41
|
+
if (!domain)
|
|
42
|
+
return { state: "no_sender", providerId };
|
|
43
|
+
const provider = providerId === null ? undefined : await deps.findProvider({ id: providerId }).catch(() => undefined);
|
|
44
|
+
if (!provider)
|
|
45
|
+
return { state: "no_provider", domain, providerId };
|
|
46
|
+
const id = providerId;
|
|
47
|
+
if (!provider.domainStatus)
|
|
48
|
+
return { state: "unsupported", domain, providerId: id };
|
|
49
|
+
return askProvider({ deps, provider, providerId: id, domain });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the never-throwing provider-verdict port. Lookup failures become explicit verdicts;
|
|
53
|
+
* the deadline covers the whole check and clears its timer without cancelling provider work.
|
|
54
|
+
* @complexity Per call: two settings reads, one catalog load, at most two key lookups and two provider calls; O(1) space.
|
|
55
|
+
* @example createSenderDomainVerification(deps)()
|
|
56
|
+
*/
|
|
57
|
+
export function createSenderDomainVerification(required, options = {}) {
|
|
58
|
+
return async () => {
|
|
59
|
+
let timer;
|
|
60
|
+
const timeout = new Promise(resolve => {
|
|
61
|
+
timer = setTimeout(() => resolve({ state: "unknown", providerId: null, reason: "provider_domain_check_timed_out" }), options.timeoutMs ?? 5_000);
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([checkSenderDomain(required), timeout]);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=sender-domain-verification.js.map
|
package/dist/mail/types.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface MailerSendOptions {
|
|
|
39
39
|
* distinguish them. Unmapped/absent lanes resolve to notification rather than opening the
|
|
40
40
|
* interactive bypass; the caller-facing facade applies that fail-closed allowlist. */
|
|
41
41
|
lane?: "interactive" | "notification";
|
|
42
|
+
/** Configuration facts approved by the caller, rechecked immediately before delivery. */
|
|
43
|
+
mailTestApproval?: MailTestApproval;
|
|
42
44
|
}
|
|
43
45
|
/** Serializable outcomes cross the adapter ABI; live thrown objects cannot be carried as results. */
|
|
44
46
|
export type MailerSendResult = {
|
|
@@ -89,4 +91,21 @@ export type MailerSendRequired = MailerDelivery & {
|
|
|
89
91
|
export type MailerBatchRequired = MailerDelivery & {
|
|
90
92
|
messages: readonly OutboundEmail[];
|
|
91
93
|
};
|
|
94
|
+
/** Persisted test-mode shape; capture availability is decided separately by the delivery wrapper. */
|
|
95
|
+
export type MailTestModeSetting = {
|
|
96
|
+
mode: "off";
|
|
97
|
+
} | {
|
|
98
|
+
mode: "redirect";
|
|
99
|
+
to: string;
|
|
100
|
+
} | {
|
|
101
|
+
mode: "capture";
|
|
102
|
+
};
|
|
103
|
+
/** Preserve field order when constructing/comparing approvals: approval bytes are caller-owned. */
|
|
104
|
+
export interface MailTestApproval {
|
|
105
|
+
providerId: string | null;
|
|
106
|
+
driver: string;
|
|
107
|
+
from: OutboundEmail["from"];
|
|
108
|
+
mode: MailTestModeSetting["mode"];
|
|
109
|
+
effectiveRecipient: string;
|
|
110
|
+
}
|
|
92
111
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/process.d.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* Windows `Get-CimInstance`), walk a process tree, and stop a set of PIDs with
|
|
8
8
|
* SIGTERM-then-SIGKILL escalation.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Uses `command` for invocation construction and `shell`'s direct buffered
|
|
11
|
+
* executor for bounded usage samples; keeps a private `errorCode` copy.
|
|
12
12
|
*/
|
|
13
13
|
import { type ChildProcess } from "node:child_process";
|
|
14
14
|
import { type CommandInvocationRequest } from "./command.js";
|
|
15
|
+
import { execFileBuffered } from "./shell.js";
|
|
15
16
|
export type ProcessStampShape = object;
|
|
16
17
|
export type ProcessStampField<TStamp extends ProcessStampShape> = Extract<keyof TStamp, string>;
|
|
17
18
|
export type ProcessStampContract<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>> = {
|
|
@@ -32,6 +33,22 @@ export type ProcessSnapshot = {
|
|
|
32
33
|
pid: number;
|
|
33
34
|
ppid: number;
|
|
34
35
|
};
|
|
36
|
+
/** Display-only usage: RSS is bytes; CPU is cumulative seconds across all cores. */
|
|
37
|
+
export type ProcessUsage = {
|
|
38
|
+
pid: number;
|
|
39
|
+
ppid: number;
|
|
40
|
+
rssBytes: number;
|
|
41
|
+
cpuSeconds: number;
|
|
42
|
+
/** Executable basename only; empty when the OS provides no name. */
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
45
|
+
export type ProcessUsagePorts = {
|
|
46
|
+
platform?: NodeJS.Platform;
|
|
47
|
+
run?: typeof execFileBuffered;
|
|
48
|
+
};
|
|
49
|
+
export type ProcessUsageOptions = {
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
};
|
|
35
52
|
export type StampedProcessMatchCriteria<TStamp extends ProcessStampShape> = Partial<TStamp>;
|
|
36
53
|
export type StopProcessesResult = {
|
|
37
54
|
alreadyStopped: boolean;
|
|
@@ -139,6 +156,14 @@ export declare function waitForProcessExit(pid: number | null | undefined, timeo
|
|
|
139
156
|
* @returns The current process snapshots (empty on error).
|
|
140
157
|
*/
|
|
141
158
|
export declare function listProcessSnapshots(): Promise<ProcessSnapshot[]>;
|
|
159
|
+
/**
|
|
160
|
+
* Sample process RSS and cumulative CPU without reading command lines. Returns
|
|
161
|
+
* [] on command, timeout, buffer or JSON failure. RSS totals may count shared
|
|
162
|
+
* pages more than once; CPU percentages require deltas between samples.
|
|
163
|
+
* @param input - Optional host platform and bounded command-execution port.
|
|
164
|
+
* @param options - Execution timeout (default 5000ms); output is capped at 8MiB.
|
|
165
|
+
*/
|
|
166
|
+
export declare function listProcessUsage(input?: ProcessUsagePorts, options?: ProcessUsageOptions): Promise<ProcessUsage[]>;
|
|
142
167
|
/**
|
|
143
168
|
* Collect the transitive set of descendant PIDs (including the roots) from a
|
|
144
169
|
* process snapshot list, returned sorted by numeric PID descending for deterministic ordering.
|
package/dist/process.js
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* Windows `Get-CimInstance`), walk a process tree, and stop a set of PIDs with
|
|
8
8
|
* SIGTERM-then-SIGKILL escalation.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Uses `command` for invocation construction and `shell`'s direct buffered
|
|
11
|
+
* executor for bounded usage samples; keeps a private `errorCode` copy.
|
|
12
12
|
*/
|
|
13
13
|
import { execFile, spawn } from "node:child_process";
|
|
14
|
+
import { posix, win32 } from "node:path";
|
|
14
15
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
16
|
import { createCommandInvocation } from "./command.js";
|
|
17
|
+
import { execFileBuffered } from "./shell.js";
|
|
16
18
|
/** @internal Extract a Node `error.code` as a string, or `null` when the value carries no code. */
|
|
17
19
|
function errorCode(error) {
|
|
18
20
|
if (typeof error !== "object" || error == null || !("code" in error))
|
|
@@ -285,6 +287,87 @@ export async function listProcessSnapshots() {
|
|
|
285
287
|
return [];
|
|
286
288
|
}
|
|
287
289
|
}
|
|
290
|
+
/** @internal Decode ps cumulative CPU time, including macOS fractional seconds and days. */
|
|
291
|
+
function parseCpuTime(value) {
|
|
292
|
+
const match = value.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
|
|
293
|
+
if (!match)
|
|
294
|
+
return NaN;
|
|
295
|
+
return Number(match[1] ?? 0) * 86400 + Number(match[2] ?? 0) * 3600
|
|
296
|
+
+ Number(match[3]) * 60 + Number(match[4]);
|
|
297
|
+
}
|
|
298
|
+
/** @internal Reject incomplete metrics instead of presenting missing usage as zero. */
|
|
299
|
+
function validProcessUsage(usage) {
|
|
300
|
+
const { pid, ppid, rssBytes, cpuSeconds } = usage;
|
|
301
|
+
return [pid, ppid].every(Number.isSafeInteger) && [rssBytes, cpuSeconds].every(Number.isFinite)
|
|
302
|
+
&& [pid, ppid, rssBytes, cpuSeconds].every((value) => value >= 0);
|
|
303
|
+
}
|
|
304
|
+
/** @internal Read only comm, never args/command, so credentials cannot enter the result. */
|
|
305
|
+
function parsePsUsage(stdout) {
|
|
306
|
+
return stdout.split(/\r?\n/).flatMap((line) => {
|
|
307
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S.*)$/);
|
|
308
|
+
if (!match)
|
|
309
|
+
return [];
|
|
310
|
+
const usage = {
|
|
311
|
+
pid: Number(match[1]), ppid: Number(match[2]), rssBytes: Number(match[3]) * 1024,
|
|
312
|
+
cpuSeconds: parseCpuTime(match[4]), name: posix.basename(match[5].trim()),
|
|
313
|
+
};
|
|
314
|
+
return validProcessUsage(usage) ? [usage] : [];
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** @internal CIM serializes numeric fields as numbers or strings; null is not zero usage. */
|
|
318
|
+
function cimNumber(value) {
|
|
319
|
+
if (typeof value === "number")
|
|
320
|
+
return value;
|
|
321
|
+
return typeof value === "string" && value.trim() ? Number(value) : NaN;
|
|
322
|
+
}
|
|
323
|
+
/** @internal Convert Windows 100ns CPU counters and retain nameless process rows. */
|
|
324
|
+
function parseCimUsage(stdout) {
|
|
325
|
+
if (!stdout.trim())
|
|
326
|
+
return [];
|
|
327
|
+
const payload = JSON.parse(stdout);
|
|
328
|
+
return (Array.isArray(payload) ? payload : [payload]).flatMap((record) => {
|
|
329
|
+
if (!record || typeof record !== "object")
|
|
330
|
+
return [];
|
|
331
|
+
const row = record;
|
|
332
|
+
const usage = {
|
|
333
|
+
pid: cimNumber(row.ProcessId), ppid: cimNumber(row.ParentProcessId),
|
|
334
|
+
rssBytes: cimNumber(row.WorkingSetSize),
|
|
335
|
+
cpuSeconds: (cimNumber(row.UserModeTime) + cimNumber(row.KernelModeTime)) / 10_000_000,
|
|
336
|
+
name: typeof row.Name === "string" ? win32.basename(row.Name.trim()) : "",
|
|
337
|
+
};
|
|
338
|
+
return validProcessUsage(usage) ? [usage] : [];
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/** @internal Fixed executable and argv, invoked directly without a command shell. */
|
|
342
|
+
function processUsageCommand(windows) {
|
|
343
|
+
return windows
|
|
344
|
+
? { command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command",
|
|
345
|
+
"$ErrorActionPreference = 'Stop'; Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, WorkingSetSize, UserModeTime, KernelModeTime, Name | ConvertTo-Json -Compress"] }
|
|
346
|
+
: { command: "ps", args: ["-axo", "pid=,ppid=,rss=,time=,comm="] };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Sample process RSS and cumulative CPU without reading command lines. Returns
|
|
350
|
+
* [] on command, timeout, buffer or JSON failure. RSS totals may count shared
|
|
351
|
+
* pages more than once; CPU percentages require deltas between samples.
|
|
352
|
+
* @param input - Optional host platform and bounded command-execution port.
|
|
353
|
+
* @param options - Execution timeout (default 5000ms); output is capped at 8MiB.
|
|
354
|
+
*/
|
|
355
|
+
export async function listProcessUsage(input = {}, options = {}) {
|
|
356
|
+
const windows = (input.platform ?? process.platform) === "win32";
|
|
357
|
+
const { command, args } = processUsageCommand(windows);
|
|
358
|
+
try {
|
|
359
|
+
const result = await (input.run ?? execFileBuffered)(command, args, {
|
|
360
|
+
encoding: "utf8", timeout: options.timeoutMs ?? 5000, maxBuffer: 8 * 1024 * 1024,
|
|
361
|
+
windowsHide: true,
|
|
362
|
+
});
|
|
363
|
+
if (!result.ok)
|
|
364
|
+
return [];
|
|
365
|
+
return windows ? parseCimUsage(result.stdout) : parsePsUsage(result.stdout);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
288
371
|
/**
|
|
289
372
|
* Collect the transitive set of descendant PIDs (including the roots) from a
|
|
290
373
|
* process snapshot list, returned sorted by numeric PID descending for deterministic ordering.
|
package/dist/secrets/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { SealedSecret } from "./types.js";
|
|
2
2
|
export type { KeyringPort, SecretSealerPort } from "./ports.js";
|
|
3
3
|
export { AesGcmSecretSealer } from "./secret-sealer.aesgcm.js";
|
|
4
|
+
export { sealWithPassphrase, openWithPassphrase } from "./passphrase-envelope.js";
|
|
5
|
+
export type { PassphraseEnvelopeOptions } from "./passphrase-envelope.js";
|
|
4
6
|
export { EnvOrFileKeyring } from "./keyring.env.js";
|
|
5
7
|
export type { EnvOrFileKeyringRequired, EnvOrFileKeyringOptions } from "./keyring.env.js";
|
|
6
8
|
export type { RootKeyHandle as SiteKeyHandle } from "./ports.js";
|
package/dist/secrets/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { AesGcmSecretSealer } from "./secret-sealer.aesgcm.js";
|
|
2
|
+
export { sealWithPassphrase, openWithPassphrase } from "./passphrase-envelope.js";
|
|
2
3
|
export { EnvOrFileKeyring } from "./keyring.env.js";
|
|
3
4
|
export { FixedRootKeyKeyring as FixedSiteKeyKeyring } from "./keyring.env.js"; // site-key-legacy: implementation binding; remove on/after 2026-11-01
|
|
4
5
|
export { UnusableRootKeyError as UnusableSiteKeyError } from "./keyring.env.js"; // site-key-legacy: implementation binding; remove on/after 2026-11-01
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Optional randomness port for reproducible wire fixtures; production uses Node's CSPRNG. */
|
|
2
|
+
export interface PassphraseEnvelopeOptions {
|
|
3
|
+
randomBytesFn?: (input: {
|
|
4
|
+
byteLength: number;
|
|
5
|
+
}) => Uint8Array;
|
|
6
|
+
}
|
|
7
|
+
/** Encrypts a UTF-8 payload (up to 16 MiB) into portable JSON, without host key material.
|
|
8
|
+
* Uses asynchronous scrypt and the existing sealer; performs no filesystem or environment I/O.
|
|
9
|
+
* Rejects invalid inputs or randomness. Passphrases are not normalized; only an empty one is refused,
|
|
10
|
+
* because sealing under "" turns the export into obfuscation (strength policy stays with the host).
|
|
11
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space for n payload bytes and bounded KDF costs.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sealWithPassphrase({ payload, passphrase }: {
|
|
14
|
+
payload: string;
|
|
15
|
+
passphrase: string;
|
|
16
|
+
}, options?: PassphraseEnvelopeOptions): Promise<string>;
|
|
17
|
+
/** Authenticates a portable JSON envelope before returning its exact UTF-8 payload.
|
|
18
|
+
* Rejects malformed/oversized envelopes and unsupported KDF costs before deriving a key.
|
|
19
|
+
* Wrong passphrases and modified ciphertext/header reject without returning partial plaintext.
|
|
20
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space with fixed size and KDF ceilings.
|
|
21
|
+
*/
|
|
22
|
+
export declare function openWithPassphrase({ envelope, passphrase }: {
|
|
23
|
+
envelope: string;
|
|
24
|
+
passphrase: string;
|
|
25
|
+
}, _options?: Record<string, never>): Promise<string>;
|
|
26
|
+
//# sourceMappingURL=passphrase-envelope.d.ts.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomBytes, scrypt } from "node:crypto";
|
|
2
|
+
import { FixedRootKeyKeyring } from "./keyring.env.js";
|
|
3
|
+
import { AesGcmSecretSealer } from "./secret-sealer.aesgcm.js";
|
|
4
|
+
const FORMAT = "jini-passphrase-envelope";
|
|
5
|
+
const HKDF_SALT = "jini-passphrase-envelope-v1";
|
|
6
|
+
const MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
const MAX_ENVELOPE_BYTES = 24 * 1024 * 1024;
|
|
8
|
+
const SCRYPT_MAX_MEMORY = 128 * 1024 * 1024;
|
|
9
|
+
/** Encrypts a UTF-8 payload (up to 16 MiB) into portable JSON, without host key material.
|
|
10
|
+
* Uses asynchronous scrypt and the existing sealer; performs no filesystem or environment I/O.
|
|
11
|
+
* Rejects invalid inputs or randomness. Passphrases are not normalized; only an empty one is refused,
|
|
12
|
+
* because sealing under "" turns the export into obfuscation (strength policy stays with the host).
|
|
13
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space for n payload bytes and bounded KDF costs.
|
|
14
|
+
*/
|
|
15
|
+
export async function sealWithPassphrase({ payload, passphrase }, options = {}) {
|
|
16
|
+
assertText(payload, MAX_PAYLOAD_BYTES);
|
|
17
|
+
assertText(passphrase, MAX_PAYLOAD_BYTES);
|
|
18
|
+
if (passphrase === "")
|
|
19
|
+
throw new TypeError("Passphrase envelope: passphrase must not be empty");
|
|
20
|
+
const randomBytesFn = options.randomBytesFn ?? (({ byteLength }) => randomBytes(byteLength));
|
|
21
|
+
const salt = Buffer.from(randomBytesFn({ byteLength: 16 }));
|
|
22
|
+
if (salt.length !== 16)
|
|
23
|
+
throw new TypeError("Passphrase envelope: salt must contain exactly 16 bytes");
|
|
24
|
+
const header = {
|
|
25
|
+
format: FORMAT, version: 1,
|
|
26
|
+
kdf: { name: "scrypt", salt: salt.toString("hex"), N: 32768, r: 8, p: 1 },
|
|
27
|
+
};
|
|
28
|
+
const keyring = await deriveKeyring({ passphrase, kdf: header.kdf });
|
|
29
|
+
const sealer = new AesGcmSecretSealer({ keyring }, { randomBytesFn });
|
|
30
|
+
const sealed = await sealer.seal({
|
|
31
|
+
plaintext: payload, key: await keyring.activeKey({}), aad: JSON.stringify(header),
|
|
32
|
+
});
|
|
33
|
+
return JSON.stringify({ header, sealed });
|
|
34
|
+
}
|
|
35
|
+
/** Authenticates a portable JSON envelope before returning its exact UTF-8 payload.
|
|
36
|
+
* Rejects malformed/oversized envelopes and unsupported KDF costs before deriving a key.
|
|
37
|
+
* Wrong passphrases and modified ciphertext/header reject without returning partial plaintext.
|
|
38
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space with fixed size and KDF ceilings.
|
|
39
|
+
*/
|
|
40
|
+
export async function openWithPassphrase({ envelope, passphrase }, _options = {}) {
|
|
41
|
+
assertText(passphrase, MAX_PAYLOAD_BYTES);
|
|
42
|
+
const parsed = parseEnvelope({ envelope });
|
|
43
|
+
const keyring = await deriveKeyring({ passphrase, kdf: parsed.header.kdf });
|
|
44
|
+
const sealer = new AesGcmSecretSealer({ keyring });
|
|
45
|
+
try {
|
|
46
|
+
return await sealer.open({ sealed: parsed.sealed }, { aad: JSON.stringify(parsed.header) });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Never include the passphrase, payload, or crypto-library error context in recovery errors.
|
|
50
|
+
throw new Error("Passphrase envelope: authentication failed");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The format fixes r/p and accepts only bounded power-of-two N costs, preventing KDF DoS.
|
|
54
|
+
* The full header is AAD, including additional fields, so metadata cannot be silently modified.
|
|
55
|
+
*/
|
|
56
|
+
function parseEnvelope({ envelope }) {
|
|
57
|
+
assertText(envelope, MAX_ENVELOPE_BYTES);
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(envelope);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new TypeError("Passphrase envelope: invalid JSON");
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(parsed) || !isRecord(parsed.header))
|
|
66
|
+
throw invalidEnvelope();
|
|
67
|
+
if (parsed.header.format !== FORMAT || parsed.header.version !== 1)
|
|
68
|
+
throw invalidEnvelope();
|
|
69
|
+
assertKdf(parsed.header.kdf);
|
|
70
|
+
assertSealed(parsed.sealed);
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
function assertKdf(kdf) {
|
|
74
|
+
if (!isRecord(kdf))
|
|
75
|
+
throw invalidEnvelope();
|
|
76
|
+
if (kdf.name !== "scrypt" || typeof kdf.salt !== "string")
|
|
77
|
+
throw invalidEnvelope();
|
|
78
|
+
if (!/^[a-f0-9]{32}$/i.test(kdf.salt))
|
|
79
|
+
throw invalidEnvelope();
|
|
80
|
+
if (![16384, 32768, 65536].includes(kdf.N) || kdf.r !== 8 || kdf.p !== 1) {
|
|
81
|
+
throw invalidEnvelope();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function assertSealed(sealed) {
|
|
85
|
+
if (!isRecord(sealed))
|
|
86
|
+
throw invalidEnvelope();
|
|
87
|
+
if (sealed.alg !== "aes-256-gcm" || sealed.keyId !== "v1")
|
|
88
|
+
throw invalidEnvelope();
|
|
89
|
+
assertBase64(sealed.nonce, 12, 12);
|
|
90
|
+
assertBase64(sealed.ciphertext, 16, MAX_PAYLOAD_BYTES + 16);
|
|
91
|
+
}
|
|
92
|
+
function assertBase64(value, minBytes, maxBytes) {
|
|
93
|
+
if (typeof value !== "string")
|
|
94
|
+
throw invalidEnvelope();
|
|
95
|
+
const bytes = Buffer.from(value, "base64");
|
|
96
|
+
if (bytes.length < minBytes || bytes.length > maxBytes || bytes.toString("base64") !== value) {
|
|
97
|
+
throw invalidEnvelope();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function assertText(value, maxBytes) {
|
|
101
|
+
if (typeof value !== "string")
|
|
102
|
+
throw new TypeError("Passphrase envelope: expected a string");
|
|
103
|
+
if (Buffer.byteLength(value, "utf8") > maxBytes)
|
|
104
|
+
throw new RangeError("Passphrase envelope: size limit exceeded");
|
|
105
|
+
}
|
|
106
|
+
function isRecord(value) {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
109
|
+
function invalidEnvelope() {
|
|
110
|
+
return new TypeError("Passphrase envelope: invalid or unsupported envelope");
|
|
111
|
+
}
|
|
112
|
+
/** scrypt supplies only a temporary root; the existing keyring owns HKDF domain separation. */
|
|
113
|
+
async function deriveKeyring({ passphrase, kdf }) {
|
|
114
|
+
const root = await new Promise((resolve, reject) => {
|
|
115
|
+
scrypt(passphrase, Buffer.from(kdf.salt, "hex"), 32, { N: kdf.N, r: kdf.r, p: kdf.p, maxmem: SCRYPT_MAX_MEMORY }, (error, key) => error ? reject(error) : resolve(key));
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
return new FixedRootKeyKeyring({ hex: root.toString("hex"), hkdfSalt: HKDF_SALT });
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
// The keyring copies the derived bytes; do not retain the temporary scrypt buffer as well.
|
|
122
|
+
root.fill(0);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=passphrase-envelope.js.map
|
package/dist/web/web-page.d.ts
CHANGED
|
@@ -77,6 +77,8 @@ export interface WebFetchResult {
|
|
|
77
77
|
meta: Record<string, string>;
|
|
78
78
|
fetchedAt: string;
|
|
79
79
|
rendered?: true;
|
|
80
|
+
/** Explains a browser-unavailable fallback; the returned content has not executed JavaScript. */
|
|
81
|
+
note?: string;
|
|
80
82
|
data?: RenderedWebPage["data"];
|
|
81
83
|
dataUnmatched?: RenderedWebPage["dataUnmatched"];
|
|
82
84
|
dataTruncated?: boolean;
|
|
@@ -129,7 +131,8 @@ export interface WebFetchPorts {
|
|
|
129
131
|
* @complexity O(url length + filter characters), with at most 20 filters of 500 characters each.
|
|
130
132
|
*/
|
|
131
133
|
export declare function readWebFetchInput(input: Record<string, unknown>, _optional?: Record<string, never>): WebFetchInput;
|
|
132
|
-
/** text/*, HTML/XHTML, XML (incl. RSS/Atom/sitemaps), JSON (incl. +json);
|
|
134
|
+
/** text/*, JavaScript, SVG, HTML/XHTML, XML (incl. RSS/Atom/sitemaps), JSON (incl. +json);
|
|
135
|
+
* a missing type is judged by the body. */
|
|
133
136
|
export declare function isAllowedContentType({ mediaType }: {
|
|
134
137
|
mediaType: string;
|
|
135
138
|
}, _optional?: Record<string, never>): boolean;
|
package/dist/web/web-page.js
CHANGED
|
@@ -108,11 +108,12 @@ function header(headers, name) {
|
|
|
108
108
|
return value;
|
|
109
109
|
return "";
|
|
110
110
|
}
|
|
111
|
-
/** text/*, HTML/XHTML, XML (incl. RSS/Atom/sitemaps), JSON (incl. +json);
|
|
111
|
+
/** text/*, JavaScript, SVG, HTML/XHTML, XML (incl. RSS/Atom/sitemaps), JSON (incl. +json);
|
|
112
|
+
* a missing type is judged by the body. */
|
|
112
113
|
export function isAllowedContentType({ mediaType }, _optional = {}) {
|
|
113
114
|
if (mediaType === "" || mediaType.startsWith("text/"))
|
|
114
115
|
return true;
|
|
115
|
-
return /^application\/(?:xhtml\+xml|xml|json|[\w.+-]+\+(?:xml|json))$/.test(mediaType);
|
|
116
|
+
return /^(?:image\/svg\+xml|application\/(?:javascript|ecmascript|xhtml\+xml|xml|json|[\w.+-]+\+(?:xml|json)))$/.test(mediaType);
|
|
116
117
|
}
|
|
117
118
|
function charsetOf(contentType, body, isHtml) {
|
|
118
119
|
const fromHeader = /charset\s*=\s*"?([\w.:-]+)/i.exec(contentType)?.[1];
|
|
@@ -178,7 +179,7 @@ function render(format, body, isHtml, pageUrl, ports) {
|
|
|
178
179
|
/** Renderer error codes whose meaning the model can act on; any other code falls through to transport mapping. */
|
|
179
180
|
const RENDER_ERROR_MESSAGES = {
|
|
180
181
|
WEB_RENDER_BUSY: "web_fetch_page: render capacity is busy; retry shortly.",
|
|
181
|
-
WEB_RENDER_UNAVAILABLE: "web_fetch_page: this deployment cannot render pages: its headless browser (Playwright Chromium) is not installed or failed to start.",
|
|
182
|
+
WEB_RENDER_UNAVAILABLE: "web_fetch_page: this deployment cannot render pages: its headless browser (Playwright Chromium or system Chrome) is not installed or failed to start.",
|
|
182
183
|
WEB_RENDER_REFUSED: "web_fetch_page: the rendered page target is not a public internet address (including after redirects). Localhost, private-network and cloud-metadata addresses are refused.",
|
|
183
184
|
};
|
|
184
185
|
function errorCode({ error }, _optional = {}) {
|
|
@@ -268,11 +269,26 @@ async function enforceRateLimit({ ports, url, rateLimitScope, observe }) {
|
|
|
268
269
|
throw new ToolInputError({ message: `web_fetch_page: too many fetches from ${host} in the last minute. Retry in ${verdict.retryAfterSeconds} seconds.` });
|
|
269
270
|
}
|
|
270
271
|
}
|
|
272
|
+
/** Only an unavailable browser permits a guarded raw fetch; screenshots still require a browser.
|
|
273
|
+
* Reuses the call's deadline, rate-limit check and caller-safe error mapping.
|
|
274
|
+
* @complexity O(response bytes), with the same byte and redirect bounds as the ordinary fetch. */
|
|
271
275
|
async function fetchRendered({ input, ports, url }, { signal, timeoutMs }) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
try {
|
|
277
|
+
if (!ports.renderPage)
|
|
278
|
+
throw Object.assign(new Error("Browser unavailable"), { code: "WEB_RENDER_UNAVAILABLE" });
|
|
279
|
+
const page = await ports.renderPage({ input: { ...input, url: url.href } }, { signal, timeoutMs });
|
|
280
|
+
return projectRendered({ input, ports, url, page });
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
// Refusals, capacity, navigation and cancellation must never trigger a different network path.
|
|
284
|
+
if (signal.aborted || input.screenshot || errorCode({ error }) !== "WEB_RENDER_UNAVAILABLE")
|
|
285
|
+
throw error;
|
|
286
|
+
const response = await send(ports, url, signal, timeoutMs);
|
|
287
|
+
return {
|
|
288
|
+
...project({ input, ports, url, response }),
|
|
289
|
+
note: "Browser unavailable; returned a guarded non-rendered fetch. JavaScript was not executed.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
276
292
|
}
|
|
277
293
|
function project({ input, ports, url, response }) {
|
|
278
294
|
const contentType = header(response.headers, "content-type");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/platform",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
4
4
|
"description": "Generic OS/platform primitives: process exec and lifecycle, filesystem containment, HTTP readiness polling, toolchain discovery, blob storage, AWS signing, and an SSRF-safe asset cache.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -200,7 +200,7 @@
|
|
|
200
200
|
"!dist/**/*.map"
|
|
201
201
|
],
|
|
202
202
|
"dependencies": {
|
|
203
|
-
"@jini-ai/core": "^0.6.
|
|
203
|
+
"@jini-ai/core": "^0.6.12"
|
|
204
204
|
},
|
|
205
205
|
"publishConfig": {
|
|
206
206
|
"access": "public",
|