@jini-ai/platform 0.6.11 → 0.6.15-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +52 -0
  3. package/dist/asset-cache.js +27 -13
  4. package/dist/command.d.ts +1 -1
  5. package/dist/command.js +11 -8
  6. package/dist/download.js +26 -32
  7. package/dist/fetch-with-timeout.d.ts +54 -0
  8. package/dist/fetch-with-timeout.js +129 -2
  9. package/dist/fs/directory-user-config.js +2 -1
  10. package/dist/fs/directory-workspace.js +3 -1
  11. package/dist/fs/guarded-reader.d.ts +19 -47
  12. package/dist/fs/guarded-reader.js +82 -13
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/mail/adapter-module.d.ts +6 -0
  16. package/dist/mail/delivery-wrapper.d.ts +46 -0
  17. package/dist/mail/delivery-wrapper.js +109 -0
  18. package/dist/mail/domain-read.d.ts +21 -0
  19. package/dist/mail/domain-read.js +17 -0
  20. package/dist/mail/email-sender-registry.d.ts +15 -0
  21. package/dist/mail/email-sender-registry.js +22 -0
  22. package/dist/mail/email-sender.d.ts +109 -0
  23. package/dist/mail/email-sender.js +10 -0
  24. package/dist/mail/engine-contracts.d.ts +129 -0
  25. package/dist/mail/engine-contracts.js +2 -0
  26. package/dist/mail/index.d.ts +15 -2
  27. package/dist/mail/index.js +8 -1
  28. package/dist/mail/mail-status-cache.d.ts +33 -0
  29. package/dist/mail/mail-status-cache.js +56 -0
  30. package/dist/mail/prepare-sender-email.d.ts +35 -0
  31. package/dist/mail/prepare-sender-email.js +68 -0
  32. package/dist/mail/provider-error.d.ts +18 -0
  33. package/dist/mail/provider-error.js +24 -0
  34. package/dist/mail/purpose-scoped-mailer.d.ts +12 -1
  35. package/dist/mail/purpose-scoped-mailer.js +23 -12
  36. package/dist/mail/resolve-mailer.d.ts +95 -0
  37. package/dist/mail/resolve-mailer.js +348 -0
  38. package/dist/mail/sender-domain-verification.d.ts +40 -0
  39. package/dist/mail/sender-domain-verification.js +71 -0
  40. package/dist/mail/senders.d.ts +5 -0
  41. package/dist/mail/senders.js +5 -0
  42. package/dist/mail/types.d.ts +19 -0
  43. package/dist/process.d.ts +27 -2
  44. package/dist/process.js +85 -2
  45. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
  46. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
  47. package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
  48. package/dist/secrets/credential-sets/vault.service.js +18 -9
  49. package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
  50. package/dist/secrets/index.d.ts +2 -0
  51. package/dist/secrets/index.js +1 -0
  52. package/dist/secrets/passphrase-envelope.d.ts +26 -0
  53. package/dist/secrets/passphrase-envelope.js +125 -0
  54. package/package.json +8 -2
@@ -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
- return [...new Set([oldHome, ...config.legacyHomes({}), home()])].map((root) => createDirectoryRegistry({ file: fileAt(path.join(root, registryName)), canonicalPath }));
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
- /** Refusal of a malformed, denied, escaped, or over-limit file operation. */
3
- export declare class FsFilePathError extends Error {
4
- constructor({ message }: {
5
- message: string;
6
- });
7
- }
8
- export interface DenyRules {
9
- segments: ReadonlySet<string>;
10
- filenamePatterns: readonly RegExp[];
11
- }
12
- export interface GuardedReaderLimits {
13
- maxFileBytes: number;
14
- binarySniffBytes: number;
15
- maxListedFiles: number;
16
- maxWalkDepth: number;
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
- // A distinct refusal class lets tool adapters treat rejected paths/content as actionable input
4
- // problems instead of redacting them as an internal crash.
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 = fs.openSync(target, "r");
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(MAX_FS_FILE_BYTES + 1);
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 > MAX_FS_FILE_BYTES)
264
- throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${MAX_FS_FILE_BYTES}-byte readable limit` });
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 stream = fs.createReadStream(target, { highWaterMark: Math.min(64 * 1024, maxBytes + 1) });
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
@@ -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,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