@jini-ai/platform 0.6.12 → 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.
- package/CHANGELOG.md +33 -0
- package/README.md +52 -0
- package/dist/asset-cache.js +27 -13
- package/dist/command.d.ts +1 -1
- package/dist/command.js +11 -8
- package/dist/download.js +26 -32
- package/dist/fetch-with-timeout.d.ts +54 -0
- package/dist/fetch-with-timeout.js +129 -2
- package/dist/fs/directory-user-config.js +2 -1
- package/dist/fs/directory-workspace.js +3 -1
- package/dist/fs/guarded-reader.d.ts +19 -47
- package/dist/fs/guarded-reader.js +82 -13
- package/dist/mail/email-sender-registry.d.ts +15 -0
- package/dist/mail/email-sender-registry.js +22 -0
- package/dist/mail/email-sender.d.ts +109 -0
- package/dist/mail/email-sender.js +10 -0
- package/dist/mail/index.d.ts +15 -2
- package/dist/mail/index.js +8 -1
- package/dist/mail/mail-status-cache.d.ts +33 -0
- package/dist/mail/mail-status-cache.js +56 -0
- package/dist/mail/prepare-sender-email.d.ts +35 -0
- package/dist/mail/prepare-sender-email.js +68 -0
- package/dist/mail/resolve-mailer.d.ts +95 -0
- package/dist/mail/resolve-mailer.js +348 -0
- package/dist/mail/senders.d.ts +5 -0
- package/dist/mail/senders.js +5 -0
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
- package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
- package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
- package/dist/secrets/credential-sets/vault.service.js +18 -9
- package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
- package/package.json +8 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { EmailSenderError } from "./email-sender.js";
|
|
2
|
+
/** Refuses unsupported declarations and undeclared placeholders before activation or rendering.
|
|
3
|
+
* @complexity O(f + d + p) source fields, declarations and placeholders; no I/O. */
|
|
4
|
+
export function validateEmailSourceTemplate(required, _optional = {}) {
|
|
5
|
+
const available = new Set(required.fields.map(field => field.token));
|
|
6
|
+
const declared = new Set(required.template.fields);
|
|
7
|
+
if (required.template.fields.some(token => !available.has(token)))
|
|
8
|
+
throw new EmailSenderError({ code: "incompatible" });
|
|
9
|
+
if (required.template.placeholders.some(token => !declared.has(token)))
|
|
10
|
+
throw new EmailSenderError({ code: "incompatible" });
|
|
11
|
+
}
|
|
12
|
+
/** Selects declared values, refusing missing required answers and warning on absent optional ones. */
|
|
13
|
+
function templateValues(required, _optional = {}) {
|
|
14
|
+
const fields = new Map(required.fields.map(field => [field.token, field]));
|
|
15
|
+
const warnings = [];
|
|
16
|
+
const values = Object.fromEntries(required.tokens.map(token => {
|
|
17
|
+
const value = Object.hasOwn(required.values, token) ? required.values[token] : undefined;
|
|
18
|
+
if (typeof value === "string" && value !== "")
|
|
19
|
+
return [token, value];
|
|
20
|
+
if (fields.get(token)?.required)
|
|
21
|
+
throw new EmailSenderError({ code: "missing_value" });
|
|
22
|
+
warnings.push(token);
|
|
23
|
+
return [token, ""];
|
|
24
|
+
}));
|
|
25
|
+
return { values, warnings };
|
|
26
|
+
}
|
|
27
|
+
/** Resolves only a source-permitted recipient; configuration and missing answers fail separately.
|
|
28
|
+
* @complexity O(permitted choices + resolved recipients); no I/O. */
|
|
29
|
+
function recipientAddress({ source, resolved, recipientId }, _optional = {}) {
|
|
30
|
+
if (!source.permittedRecipients.some(choice => choice.id === recipientId))
|
|
31
|
+
throw new EmailSenderError({ code: "configuration" });
|
|
32
|
+
const recipient = resolved.recipients.find(choice => choice.id === recipientId);
|
|
33
|
+
if (!recipient)
|
|
34
|
+
throw new EmailSenderError({ code: "missing_value" });
|
|
35
|
+
return recipient.address;
|
|
36
|
+
}
|
|
37
|
+
/** Resolves authoritative event data and the saved binding, then calls the existing renderer.
|
|
38
|
+
* No preview samples, envelope overrides or delivery effects enter this operation.
|
|
39
|
+
* @throws EmailSenderError for ownership, inactive/pre-activation events, readiness, compatibility or missing data; port errors propagate.
|
|
40
|
+
* @complexity O(fields + declarations + placeholders); one describe, resolve and render per event. */
|
|
41
|
+
export async function prepareSenderEmail(required, _optional = {}) {
|
|
42
|
+
const { sender, templates, workspaceId, principalId, sourceId, eventId } = required;
|
|
43
|
+
const context = { workspaceId, principalId, sourceId };
|
|
44
|
+
const source = await sender.describeSource(context);
|
|
45
|
+
if (source.senderId !== sender.id || source.sourceId !== sourceId)
|
|
46
|
+
throw new EmailSenderError({ code: "ownership" });
|
|
47
|
+
const binding = source.binding;
|
|
48
|
+
if (!binding?.enabled)
|
|
49
|
+
throw new EmailSenderError({ code: "configuration" });
|
|
50
|
+
if (!source.readiness.ready)
|
|
51
|
+
throw new EmailSenderError({ code: "not_ready" });
|
|
52
|
+
const templateContext = { workspaceId, principalId, templateId: binding.templateId };
|
|
53
|
+
const template = await templates.describe(templateContext);
|
|
54
|
+
validateEmailSourceTemplate({ fields: source.fields, template });
|
|
55
|
+
const resolved = await sender.resolveEvent({ ...context, eventId });
|
|
56
|
+
// MOVE: Compare against the latest activation so re-enabling cannot revive a backlog.
|
|
57
|
+
if (!binding.activatedAt || !(Date.parse(resolved.occurredAt) >= Date.parse(binding.activatedAt)))
|
|
58
|
+
throw new EmailSenderError({ code: "configuration" });
|
|
59
|
+
const to = recipientAddress({ source, resolved, recipientId: binding.recipientId });
|
|
60
|
+
const { values, warnings } = templateValues({ fields: source.fields, tokens: template.fields, values: resolved.values });
|
|
61
|
+
const rendered = await templates.render({ ...templateContext, version: template.version, values });
|
|
62
|
+
if (rendered.missingFields.length > 0)
|
|
63
|
+
throw new EmailSenderError({ code: "missing_value" });
|
|
64
|
+
return { bindingId: binding.bindingId, templateId: binding.templateId, templateVersion: template.version, to,
|
|
65
|
+
replyTo: { email: binding.replyTo }, subject: rendered.subject, html: rendered.html, text: rendered.text,
|
|
66
|
+
purpose: source.purpose, lane: source.lane, warnings };
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=prepare-sender-email.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { HttpClientPort } from "@jini-ai/core/primitives";
|
|
2
|
+
import type { DomainRequirement } from "@jini-ai/core/provider-registry";
|
|
3
|
+
import type { MailAdapterCredential } from "./adapter-module.js";
|
|
4
|
+
import { type MailDomainReadCredentialPort } from "./domain-read.js";
|
|
5
|
+
import type { MailCredentialFact, MailCredentialPort, MailProviderCatalog, MailProviderCheck, MailProviderConfigurationReport, MailProviderSettingPort, MailStatusReport, SenderDomainVerificationPort } from "./engine-contracts.js";
|
|
6
|
+
import type { MailDeliveryLane, MailerPort } from "./ports.js";
|
|
7
|
+
import { type MailProviderModule, type MailProviderSetting } from "./provider.js";
|
|
8
|
+
/** Required host boundaries. HTTP is passed through unchanged; the engine never calls it. */
|
|
9
|
+
export interface ResolveMailerDeps<TRecord extends MailCredentialFact = MailCredentialFact> {
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
mode: "local" | "production";
|
|
12
|
+
providerSetting: MailProviderSettingPort;
|
|
13
|
+
credentials: MailCredentialPort;
|
|
14
|
+
/** Metadata only. Preserve the host's row shape for its projection (e.g. SMTP username presence);
|
|
15
|
+
* the engine only interprets the separate readFact fingerprint. No decryption here. */
|
|
16
|
+
listCredentialFacts: (required: Record<string, never>) => Promise<readonly TRecord[]>;
|
|
17
|
+
loadCatalog: (required: Record<string, never>) => Promise<MailProviderCatalog>;
|
|
18
|
+
httpClient: HttpClientPort;
|
|
19
|
+
/** Host retains provider ABI binding and credential-aware scrubbing of results/throws. */
|
|
20
|
+
createProviderMailer: (required: {
|
|
21
|
+
provider: MailProviderModule;
|
|
22
|
+
credential: MailAdapterCredential;
|
|
23
|
+
}) => MailerPort;
|
|
24
|
+
/** Synchronous safe DTO projection; labels, setup forms and product copy stay in the host. */
|
|
25
|
+
describeProviders: (required: {
|
|
26
|
+
catalog: MailProviderCatalog;
|
|
27
|
+
records: readonly TRecord[];
|
|
28
|
+
setting: MailProviderSetting;
|
|
29
|
+
}) => MailProviderConfigurationReport;
|
|
30
|
+
unconfiguredMessage: (required: {
|
|
31
|
+
lane: MailDeliveryLane;
|
|
32
|
+
}) => string;
|
|
33
|
+
missingCredentialReason: (required: {
|
|
34
|
+
label: string;
|
|
35
|
+
}) => string;
|
|
36
|
+
credentialFailureReason: (required: {
|
|
37
|
+
label: string;
|
|
38
|
+
err: unknown;
|
|
39
|
+
}) => string;
|
|
40
|
+
}
|
|
41
|
+
export interface ResolveMailerOptions {
|
|
42
|
+
/** Delivery policy decorator; identity when absent. */
|
|
43
|
+
wrapProviderMailer?: (required: {
|
|
44
|
+
providerId: string;
|
|
45
|
+
provider: MailProviderModule;
|
|
46
|
+
mailer: MailerPort;
|
|
47
|
+
}) => MailerPort;
|
|
48
|
+
readSenderEmail?: () => Promise<string | undefined>;
|
|
49
|
+
resolveDomainReadCredential?: MailDomainReadCredentialPort;
|
|
50
|
+
warn?: (message: string) => void;
|
|
51
|
+
clock?: {
|
|
52
|
+
nowMs(): number;
|
|
53
|
+
};
|
|
54
|
+
statusTtlMs?: number;
|
|
55
|
+
domainRequirementsTimeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
/** The control surface remains stable across host adoption, including invalidate on setting writes. */
|
|
58
|
+
export interface ResolvedMailer {
|
|
59
|
+
/** Safe to use immediately; each send awaits its lane's resolution. */
|
|
60
|
+
mailer: MailerPort;
|
|
61
|
+
/** Resolves once the initial setting + transactional resolution settle (a test seam). */
|
|
62
|
+
ready: Promise<void>;
|
|
63
|
+
/** Refreshes unresolved configuration and awaits the current driver without sending. */
|
|
64
|
+
settle: () => Promise<void>;
|
|
65
|
+
/** Cached briefly; `refresh` rebuilds. Each report carries `checkedAt` and the live `lastError`. */
|
|
66
|
+
status: (optional?: {
|
|
67
|
+
refresh?: boolean;
|
|
68
|
+
}) => Promise<MailStatusReport>;
|
|
69
|
+
/** The host's safe per-provider configuration DTO. Never a secret. */
|
|
70
|
+
describeProviders: () => Promise<MailProviderConfigurationReport>;
|
|
71
|
+
/** Drops cached provider mailers and the cached status; the next send and read resolve fresh. */
|
|
72
|
+
invalidate: () => void;
|
|
73
|
+
laneReady: (required: {
|
|
74
|
+
lane: MailDeliveryLane;
|
|
75
|
+
}) => Promise<boolean>;
|
|
76
|
+
senderDomainVerification: SenderDomainVerificationPort;
|
|
77
|
+
/** Whether `id` may be selected: a registered hosted provider whose saved key resolves. Sends nothing. */
|
|
78
|
+
checkProvider: (required: {
|
|
79
|
+
id: string;
|
|
80
|
+
}) => Promise<MailProviderCheck>;
|
|
81
|
+
/** Null for another workspace, absent sender/provider/key or unsupported verification.
|
|
82
|
+
* Provider failures throw caller-safe ToolInputError; the deadline throws Error. */
|
|
83
|
+
domainRequirements: (required: {
|
|
84
|
+
workspaceId: string;
|
|
85
|
+
}) => Promise<DomainRequirement | null>;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build a lane router and its control surface. The host binds catalogs, secrets, safe copy and DTOs.
|
|
89
|
+
* Each send reads selection and metadata; boot starts resolution synchronously. Selection faults
|
|
90
|
+
* refuse safely; status/description setup faults propagate and are never cached.
|
|
91
|
+
* @complexity O(providers) cached mailers/flights; status holds one report for its TTL.
|
|
92
|
+
* @example const control = createResolvedMailer(hostPorts, { clock }); await control.ready;
|
|
93
|
+
*/
|
|
94
|
+
export declare function createResolvedMailer<TRecord extends MailCredentialFact = MailCredentialFact>(required: ResolveMailerDeps<TRecord>, optional?: ResolveMailerOptions): ResolvedMailer;
|
|
95
|
+
//# sourceMappingURL=resolve-mailer.d.ts.map
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit lane selection, live credential rotation and cached provider status.
|
|
3
|
+
* Saved keys never select a provider. Local fallback uses the catalog's console provider;
|
|
4
|
+
* production refuses. Host ports retain trusted loading, credential copy and configuration DTOs.
|
|
5
|
+
* capabilities() is synchronous and reports console until transactional resolution settles;
|
|
6
|
+
* sends always await their own lane. Invalidation detaches obsolete work without cancelling it.
|
|
7
|
+
*/
|
|
8
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
9
|
+
import { readMailDomain } from "./domain-read.js";
|
|
10
|
+
import { createTtlCache, MAIL_STATUS_TTL_MS } from "./mail-status-cache.js";
|
|
11
|
+
import { credentialSecrets, safeMailProviderText } from "./provider-error.js";
|
|
12
|
+
import { computeMailStatus, initialMailProviderSetting } from "./provider.js";
|
|
13
|
+
import { laneForPurpose } from "./policy.js";
|
|
14
|
+
import { createSenderDomainVerification } from "./sender-domain-verification.js";
|
|
15
|
+
const CONSOLE_CAPABILITIES = { driver: "console", supportsIdempotencyKey: false, supportsWebhookFeedback: false, maxBatchSize: 1, supportsAttachments: true };
|
|
16
|
+
/** Metadata identifies the exact saved version without decryption. @complexity O(1). */
|
|
17
|
+
function rowFingerprint(record) {
|
|
18
|
+
return record ? `${record.id}:${record.vaultRevision ?? ""}:${record.updatedAt}` : "absent";
|
|
19
|
+
}
|
|
20
|
+
/** Any failed recipient wins; only a wholly accepted, nonempty operation clears a send failure.
|
|
21
|
+
* Results and exceptions pass through unchanged. @complexity O(messages) time, O(1) space. */
|
|
22
|
+
function observeFailures(required) {
|
|
23
|
+
const { mailer, providerId, record, accepted } = required;
|
|
24
|
+
const seen = (results) => {
|
|
25
|
+
const failure = results.find(result => !result.ok);
|
|
26
|
+
if (failure && !failure.ok) {
|
|
27
|
+
record({ providerId, source: "send", code: failure.errorCode, message: failure.message });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (results.length > 0)
|
|
31
|
+
accepted();
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
capabilities: () => mailer.capabilities({}),
|
|
35
|
+
send: async (input, options) => { const result = await mailer.send(input, options); seen([result]); return result; },
|
|
36
|
+
sendBatch: async (input, options) => { const results = await mailer.sendBatch(input, options); seen(results); return results; },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Refusals report console so availability checks never mistake them for delivery. @complexity O(messages) for batches. */
|
|
40
|
+
function refusingMailer(required) {
|
|
41
|
+
const result = { ok: false, retryable: false, errorCode: required.errorCode, message: required.message };
|
|
42
|
+
return { capabilities: () => CONSOLE_CAPABILITIES, send: async () => result, sendBatch: async ({ messages }) => messages.map(() => result) };
|
|
43
|
+
}
|
|
44
|
+
/** Diagnostic sinks cannot throw across the mail boundary; production warns once per reason. */
|
|
45
|
+
function warnOnce({ state, key, message }) {
|
|
46
|
+
if (state.deps.mode !== "production" || state.warned.has(key))
|
|
47
|
+
return;
|
|
48
|
+
state.warned.add(key);
|
|
49
|
+
try {
|
|
50
|
+
state.warn(message);
|
|
51
|
+
}
|
|
52
|
+
catch { /* Diagnostics cannot reject a send. */ }
|
|
53
|
+
}
|
|
54
|
+
/** Never retain provider/body details. Repeated checks must not hide a failed send. */
|
|
55
|
+
function recordError({ state, error }) {
|
|
56
|
+
const { providerId, source } = error;
|
|
57
|
+
if (source === "check" && state.lastError?.source === "send")
|
|
58
|
+
return;
|
|
59
|
+
state.lastError = { providerId, source, message: source === "send" ? "The provider did not confirm acceptance. Check provider configuration." : "The provider configuration check could not complete.", at: new Date(state.nowMs()).toISOString() };
|
|
60
|
+
}
|
|
61
|
+
/** First boot remains unconfigured even with saved keys; failed persistence is retried on a later read. */
|
|
62
|
+
async function initialize(state) {
|
|
63
|
+
const setting = initialMailProviderSetting({});
|
|
64
|
+
await state.deps.providerSetting.write({ setting }).catch(() => warnOnce({ state, key: "initialization", message: "[mail] the mail provider setting could not be saved; retrying on the next send" }));
|
|
65
|
+
return setting;
|
|
66
|
+
}
|
|
67
|
+
/** Only initialization is single-flight: every call still reads the current selection first. */
|
|
68
|
+
async function currentSetting(state) {
|
|
69
|
+
const stored = await state.deps.providerSetting.read({});
|
|
70
|
+
if (stored)
|
|
71
|
+
return stored;
|
|
72
|
+
state.initialization ??= initialize(state).finally(() => { state.initialization = undefined; });
|
|
73
|
+
return state.initialization;
|
|
74
|
+
}
|
|
75
|
+
/** Keyless providers never touch the credential metadata store. */
|
|
76
|
+
async function fingerprintFor({ state, label }) {
|
|
77
|
+
if (label === undefined)
|
|
78
|
+
return "";
|
|
79
|
+
return rowFingerprint(await state.deps.credentials.readFact({ label }));
|
|
80
|
+
}
|
|
81
|
+
/** The host catalog guarantees the built-in console provider. Its mailer shares the policy wrapper. */
|
|
82
|
+
async function consoleMailer({ state, catalog }) {
|
|
83
|
+
const provider = catalog.registry.get({ id: "console" }).provider;
|
|
84
|
+
const mailer = state.wrap({ providerId: "console", provider, mailer: state.deps.createProviderMailer({ provider, credential: { token: "" } }) });
|
|
85
|
+
state.resolved.set("console", { mailer, fingerprint: "" });
|
|
86
|
+
return mailer;
|
|
87
|
+
}
|
|
88
|
+
/** Local mode keeps working through console; production refuses and logs safe host copy once. */
|
|
89
|
+
async function fallback(required) {
|
|
90
|
+
const { state } = required;
|
|
91
|
+
if (state.deps.mode !== "production")
|
|
92
|
+
return state.resolved.get("console")?.mailer ?? consoleMailer(required);
|
|
93
|
+
warnOnce({ state, key: required.errorCode + required.message, message: required.message });
|
|
94
|
+
return refusingMailer(required);
|
|
95
|
+
}
|
|
96
|
+
/** A missing key or failed decrypt uses the host's established caller-safe refusal wording. */
|
|
97
|
+
async function credentialOrReason({ state, catalog, id, label }) {
|
|
98
|
+
try {
|
|
99
|
+
const credential = await state.deps.credentials.resolve({ catalog, id });
|
|
100
|
+
if (credential)
|
|
101
|
+
return { credential };
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
return { reason: state.deps.credentialFailureReason({ label, err }) };
|
|
105
|
+
}
|
|
106
|
+
return { reason: state.deps.missingCredentialReason({ label }) };
|
|
107
|
+
}
|
|
108
|
+
/** A catalog label means a saved key is required; keyless providers receive an empty token. */
|
|
109
|
+
function providerCredential({ state, catalog, id }) {
|
|
110
|
+
const label = catalog.credentialLabelFor({ id });
|
|
111
|
+
return label === undefined ? Promise.resolve({ credential: { token: "" } }) : credentialOrReason({ state, catalog, id, label });
|
|
112
|
+
}
|
|
113
|
+
/** Preserve provider selection refusals, including the catalog's trusted plugin-load diagnostics. */
|
|
114
|
+
function unavailable(required) {
|
|
115
|
+
return fallback({ ...required, errorCode: "MAIL_PROVIDER_UNAVAILABLE", message: `[mail] the selected mail provider '${required.id}' ${required.detail}, so outbound mail is NOT being sent` });
|
|
116
|
+
}
|
|
117
|
+
/** A successful operation only clears a failure from the same provider. */
|
|
118
|
+
function acceptProvider({ state, id }) {
|
|
119
|
+
if (state.lastError?.source === "send" && state.lastError.providerId === id)
|
|
120
|
+
state.lastError = undefined;
|
|
121
|
+
}
|
|
122
|
+
/** Construct only after metadata/key/metadata agree; a generation change prevents caching obsolete work.
|
|
123
|
+
* @complexity One catalog load, two metadata reads and one decrypt per attempt; races retry in order. */
|
|
124
|
+
async function resolveProvider({ state, id, startedIn }) {
|
|
125
|
+
const catalog = await state.deps.loadCatalog({});
|
|
126
|
+
const entry = catalog.registry.get({ id });
|
|
127
|
+
if (!entry) {
|
|
128
|
+
const refusals = catalog.refusals.length > 0 ? ` (${catalog.refusals.join("; ")})` : "";
|
|
129
|
+
return unavailable({ state, catalog, id, detail: `is not installed${refusals}` });
|
|
130
|
+
}
|
|
131
|
+
// Fingerprint BEFORE reading the key: a rotation racing this read then re-resolves next send.
|
|
132
|
+
// An unreadable store leaves key lookup to report why; "unknown" re-resolves once it reads.
|
|
133
|
+
const label = catalog.credentialLabelFor({ id });
|
|
134
|
+
const fingerprint = await fingerprintFor({ state, label }).catch(() => "unknown");
|
|
135
|
+
const found = await providerCredential({ state, catalog, id });
|
|
136
|
+
if (!found.credential)
|
|
137
|
+
return unavailable({ state, catalog, id, detail: found.reason });
|
|
138
|
+
const credential = found.credential;
|
|
139
|
+
const latest = await fingerprintFor({ state, label }).catch(() => fingerprint);
|
|
140
|
+
if (latest !== fingerprint)
|
|
141
|
+
return resolveProvider({ state, id, startedIn: state.generation });
|
|
142
|
+
const observed = observeFailures({ mailer: state.deps.createProviderMailer({ provider: entry.provider, credential }), providerId: id,
|
|
143
|
+
record: error => recordError({ state, error }), accepted: () => acceptProvider({ state, id }) });
|
|
144
|
+
const mailer = state.wrap({ providerId: id, provider: entry.provider, mailer: observed });
|
|
145
|
+
if (startedIn === state.generation)
|
|
146
|
+
state.resolved.set(id, { mailer, fingerprint, ...(label === undefined ? {} : { label }) });
|
|
147
|
+
return mailer;
|
|
148
|
+
}
|
|
149
|
+
/** Boot and early sends share one resolution per provider. Old completions rejoin the current generation. */
|
|
150
|
+
function resolveOnce({ state, id }) {
|
|
151
|
+
const existing = state.inFlight.get(id);
|
|
152
|
+
if (existing)
|
|
153
|
+
return existing;
|
|
154
|
+
const startedIn = state.generation;
|
|
155
|
+
const started = resolveProvider({ state, id, startedIn }).then(mailer => startedIn === state.generation ? mailer : resolveOnce({ state, id }))
|
|
156
|
+
.finally(() => { if (state.inFlight.get(id) === started)
|
|
157
|
+
state.inFlight.delete(id); });
|
|
158
|
+
state.inFlight.set(id, started);
|
|
159
|
+
return started;
|
|
160
|
+
}
|
|
161
|
+
/** Reuse unchanged rows; metadata faults keep a usable cached mailer. @complexity One metadata read per send. */
|
|
162
|
+
async function currentMailer({ state, id }) {
|
|
163
|
+
const entry = state.resolved.get(id);
|
|
164
|
+
if (!entry)
|
|
165
|
+
return resolveOnce({ state, id });
|
|
166
|
+
if (entry.label === undefined)
|
|
167
|
+
return entry.mailer;
|
|
168
|
+
const fingerprint = await fingerprintFor({ state, label: entry.label }).catch(() => entry.fingerprint);
|
|
169
|
+
if (fingerprint === entry.fingerprint)
|
|
170
|
+
return entry.mailer;
|
|
171
|
+
if (state.resolved.get(id) === entry)
|
|
172
|
+
state.resolved.delete(id);
|
|
173
|
+
return resolveOnce({ state, id });
|
|
174
|
+
}
|
|
175
|
+
/** Fresh lane selection precedes every resolution and updates the synchronous transactional projection. */
|
|
176
|
+
async function mailerForLane({ state, lane }) {
|
|
177
|
+
const id = (await currentSetting(state))[lane];
|
|
178
|
+
const mailer = id === null
|
|
179
|
+
? await fallback({ state, catalog: await state.deps.loadCatalog({}), errorCode: "MAIL_UNCONFIGURED", message: state.deps.unconfiguredMessage({ lane }) })
|
|
180
|
+
: await currentMailer({ state, id });
|
|
181
|
+
if (lane === "transactional")
|
|
182
|
+
state.lastTransactional = mailer;
|
|
183
|
+
return mailer;
|
|
184
|
+
}
|
|
185
|
+
/** Settings/catalog faults become a refusal rather than throwing across the routing boundary. */
|
|
186
|
+
function forLane(required) {
|
|
187
|
+
return mailerForLane(required).catch(() => refusingMailer({ errorCode: "MAIL_PROVIDER_UNAVAILABLE", message: "Mail provider selection failed; check configuration." }));
|
|
188
|
+
}
|
|
189
|
+
/** The public mailer preserves synchronous capability reporting and awaits the proper lane for sends. */
|
|
190
|
+
function createRouter(state) {
|
|
191
|
+
return {
|
|
192
|
+
capabilities: () => {
|
|
193
|
+
if (state.lastTransactional === undefined)
|
|
194
|
+
void forLane({ state, lane: "transactional" });
|
|
195
|
+
return state.lastTransactional?.capabilities({}) ?? CONSOLE_CAPABILITIES;
|
|
196
|
+
},
|
|
197
|
+
send: async (input, options = {}) => (await forLane({ state, lane: laneForPurpose({ purpose: options.purpose }) })).send(input, options),
|
|
198
|
+
sendBatch: async (input, options = {}) => (await forLane({ state, lane: laneForPurpose({ purpose: options.purpose }) })).sendBatch(input, options),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Sender settings errors count as unset; parsing retains the original lowercase/trim behavior. */
|
|
202
|
+
async function readSenderDomain(state) {
|
|
203
|
+
const email = await state.options.readSenderEmail?.().catch(() => undefined);
|
|
204
|
+
return email?.split("@")[1]?.trim().toLowerCase() || undefined;
|
|
205
|
+
}
|
|
206
|
+
/** Domain status shares selection/credentials with delivery, while keeping its own ordered reads. */
|
|
207
|
+
function createDomainVerification({ state, resolveDomainReadCredential }) {
|
|
208
|
+
return createSenderDomainVerification({
|
|
209
|
+
workspaceId: state.deps.workspaceId, httpClient: state.deps.httpClient,
|
|
210
|
+
findProvider: async ({ id }) => (await state.deps.loadCatalog({})).registry.get({ id })?.provider,
|
|
211
|
+
resolveCredential: async ({ id }) => state.deps.credentials.resolve({ catalog: await state.deps.loadCatalog({}), id }),
|
|
212
|
+
resolveDomainReadCredential,
|
|
213
|
+
readProviderId: async () => (await currentSetting(state)).transactional,
|
|
214
|
+
readSenderEmail: async () => state.options.readSenderEmail ? state.options.readSenderEmail() : undefined,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Separate requirements read with permission-only fallback; deadline covers provider calls, not setup.
|
|
218
|
+
* Registry validation guarantees domainRequirements when domainVerification is true.
|
|
219
|
+
* @complexity At most two provider calls; diagnostic output is capped at 1000 characters. */
|
|
220
|
+
async function requestDomainRequirements(required, optional = {}) {
|
|
221
|
+
const { ctx, providerId, provider, domain } = required;
|
|
222
|
+
let timer;
|
|
223
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
224
|
+
timer = setTimeout(() => reject(new Error("The mail provider could not list its required DNS records.")), optional.timeoutMs ?? 5_000);
|
|
225
|
+
});
|
|
226
|
+
try {
|
|
227
|
+
const result = await Promise.race([readMailDomain({ ctx, providerId, read: context => provider.domainRequirements(context, { domain }), resolveDomainReadCredential: required.resolveDomainReadCredential }), timeout]);
|
|
228
|
+
if (result.status === "ok")
|
|
229
|
+
return result.value;
|
|
230
|
+
throw new ToolInputError({ message: safeMailProviderText({ text: `${result.error.code}: ${result.error.message}` }, { secrets: credentialSecrets({ credential: ctx.credential }) }) });
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Resolve requirements only for this workspace and its active transactional provider. */
|
|
237
|
+
async function domainRequirements(required) {
|
|
238
|
+
const { state, resolveDomainReadCredential } = required;
|
|
239
|
+
if (required.workspaceId !== state.deps.workspaceId)
|
|
240
|
+
return null;
|
|
241
|
+
const [setting, catalog, domain] = await Promise.all([currentSetting(state), state.deps.loadCatalog({}), readSenderDomain(state)]);
|
|
242
|
+
const id = setting.transactional;
|
|
243
|
+
const provider = id === null ? undefined : catalog.registry.get({ id })?.provider;
|
|
244
|
+
if (!domain || !provider?.capabilities.domainVerification)
|
|
245
|
+
return null;
|
|
246
|
+
const { credential } = await providerCredential({ state, catalog, id: id });
|
|
247
|
+
const ctx = credential ? { workspaceId: state.deps.workspaceId, mode: "live", credential, httpClient: state.deps.httpClient } : null;
|
|
248
|
+
return ctx ? requestDomainRequirements({ ctx, providerId: id, provider, domain, resolveDomainReadCredential }, { timeoutMs: state.options.domainRequirementsTimeoutMs }) : null;
|
|
249
|
+
}
|
|
250
|
+
/** Unique lane keys resolve in parallel; each failed lookup records a safe check note. @complexity O(lanes). */
|
|
251
|
+
async function laneCredentials(required) {
|
|
252
|
+
const { state, catalog, setting } = required;
|
|
253
|
+
const ids = [...new Set([setting.transactional, setting.bulk].filter((id) => id !== null))];
|
|
254
|
+
const lookup = (id) => state.deps.credentials.resolve({ catalog, id }).catch((err) => {
|
|
255
|
+
// Only a provider with a label reaches the store, so a thrown lookup has a defined label.
|
|
256
|
+
recordError({ state, error: { providerId: id, source: "check", message: state.deps.credentialFailureReason({ label: catalog.credentialLabelFor({ id }), err }) } });
|
|
257
|
+
return null;
|
|
258
|
+
});
|
|
259
|
+
const found = await Promise.all(ids.map(async (id) => [id, await lookup(id)]));
|
|
260
|
+
return new Map(found.filter((pair) => pair[1] !== null));
|
|
261
|
+
}
|
|
262
|
+
/** Row-list faults affect presence facts but do not prevent independent key resolution. */
|
|
263
|
+
function statusCredentialFacts({ state, deps }) {
|
|
264
|
+
return deps.listCredentialFacts({}).catch((err) => {
|
|
265
|
+
recordError({ state, error: { providerId: null, source: "check", message: `the saved mail credentials could not be listed (${err instanceof Error ? err.message : String(err)})` } });
|
|
266
|
+
return [];
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** Unanswered/unsupported checks produce no sender warning; only no_sender means unset. */
|
|
270
|
+
function statusDomainState(state) {
|
|
271
|
+
if (state === "no_sender")
|
|
272
|
+
return null;
|
|
273
|
+
return ["verified", "pending", "failed", "not_found"].includes(state) ? state : undefined;
|
|
274
|
+
}
|
|
275
|
+
/** Start independent reads together, then await lane keys and domain verification in that order.
|
|
276
|
+
* Null means no sender; an unanswered check stays undefined and must not claim a sender is missing.
|
|
277
|
+
* @complexity O(c + p) credential facts/providers, O(lanes) decrypts; domain work uses its own deadline. */
|
|
278
|
+
async function computeStatusReport(required) {
|
|
279
|
+
const { state, deps, senderDomainVerification } = required;
|
|
280
|
+
const checkedAt = new Date(state.nowMs()).toISOString();
|
|
281
|
+
const records = statusCredentialFacts({ state, deps });
|
|
282
|
+
const [setting, catalog, rows] = await Promise.all([currentSetting(state), state.deps.loadCatalog({}), records, readSenderDomain(state)]);
|
|
283
|
+
const credentials = await laneCredentials({ state, catalog, setting });
|
|
284
|
+
const verdict = await senderDomainVerification();
|
|
285
|
+
const domainState = statusDomainState(verdict.state);
|
|
286
|
+
if (verdict.state === "unknown")
|
|
287
|
+
recordError({ state, error: { providerId: verdict.providerId, source: "check", message: "Provider domain check unavailable." } });
|
|
288
|
+
const status = computeMailStatus({ setting, registry: catalog.registry, hasCredential: id => credentials.has(id) }, domainState === undefined ? {} : { senderDomainState: domainState });
|
|
289
|
+
const { providers } = deps.describeProviders({ catalog, records: rows, setting });
|
|
290
|
+
return { ...status, providers, refusals: catalog.refusals, checkedAt, senderDomainVerification: verdict };
|
|
291
|
+
}
|
|
292
|
+
/** Failed sends overlay cached status immediately; configuration alone never proves sending. */
|
|
293
|
+
function statusWithLastError({ state, report }) {
|
|
294
|
+
const sendFailed = state.lastError?.source === "send" && report.lanes.some(lane => lane.providerId === state.lastError?.providerId);
|
|
295
|
+
return state.lastError ? { ...report, state: sendFailed && report.state === "ready" ? "degraded" : report.state, lastError: state.lastError } : report;
|
|
296
|
+
}
|
|
297
|
+
/** Host descriptions receive fresh, parallel setting/catalog/metadata reads. Failures propagate. */
|
|
298
|
+
async function describeProviders({ state, deps }) {
|
|
299
|
+
const [setting, catalog, records] = await Promise.all([currentSetting(state), state.deps.loadCatalog({}), deps.listCredentialFacts({})]);
|
|
300
|
+
return deps.describeProviders({ catalog, records, setting });
|
|
301
|
+
}
|
|
302
|
+
/** Selection checks require a hosted provider and a usable key; they send nothing. */
|
|
303
|
+
async function checkProvider({ state, id }) {
|
|
304
|
+
const catalog = await state.deps.loadCatalog({});
|
|
305
|
+
const provider = catalog.registry.get({ id })?.provider;
|
|
306
|
+
if (!provider)
|
|
307
|
+
return { ok: false, reason: `'${id}' is not an installed mail provider (installed: ${catalog.registry.list().map(entry => entry.id).join(", ")})` };
|
|
308
|
+
if (!provider.capabilities.hosted)
|
|
309
|
+
return { ok: false, reason: `'${id}' only prints mail locally and never delivers it` };
|
|
310
|
+
const found = await providerCredential({ state, catalog, id });
|
|
311
|
+
return found.credential ? { ok: true, label: provider.label } : { ok: false, reason: `'${id}' ${found.reason}` };
|
|
312
|
+
}
|
|
313
|
+
/** Detach pending work and clear all provider/capability projections; errors retain their original precedence. */
|
|
314
|
+
function invalidate(state) {
|
|
315
|
+
state.generation += 1;
|
|
316
|
+
state.inFlight.clear();
|
|
317
|
+
state.resolved.clear();
|
|
318
|
+
state.lastTransactional = undefined;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Build a lane router and its control surface. The host binds catalogs, secrets, safe copy and DTOs.
|
|
322
|
+
* Each send reads selection and metadata; boot starts resolution synchronously. Selection faults
|
|
323
|
+
* refuse safely; status/description setup faults propagate and are never cached.
|
|
324
|
+
* @complexity O(providers) cached mailers/flights; status holds one report for its TTL.
|
|
325
|
+
* @example const control = createResolvedMailer(hostPorts, { clock }); await control.ready;
|
|
326
|
+
*/
|
|
327
|
+
export function createResolvedMailer(required, optional = {}) {
|
|
328
|
+
const state = { deps: required, options: optional, nowMs: () => (optional.clock ?? { nowMs: () => Date.now() }).nowMs(),
|
|
329
|
+
warn: optional.warn ?? (message => console.warn(message)),
|
|
330
|
+
wrap: optional.wrapProviderMailer ?? (({ mailer }) => mailer), resolved: new Map(), inFlight: new Map(), warned: new Set(), generation: 0,
|
|
331
|
+
initialization: undefined, lastTransactional: undefined, lastError: undefined };
|
|
332
|
+
const ready = forLane({ state, lane: "transactional" }).then(() => undefined);
|
|
333
|
+
const mailer = createRouter(state);
|
|
334
|
+
const resolveDomainReadCredential = input => optional.resolveDomainReadCredential?.(input) ?? Promise.resolve(null);
|
|
335
|
+
const senderDomainVerification = createDomainVerification({ state, resolveDomainReadCredential });
|
|
336
|
+
const statusCache = createTtlCache({ load: () => computeStatusReport({ state, deps: required, senderDomainVerification }), ttlMs: optional.statusTtlMs ?? MAIL_STATUS_TTL_MS, nowMs: state.nowMs }, { minRefreshIntervalMs: 5_000 });
|
|
337
|
+
return {
|
|
338
|
+
mailer, ready, senderDomainVerification,
|
|
339
|
+
settle: async () => { await forLane({ state, lane: "transactional" }); },
|
|
340
|
+
status: async (options = {}) => statusWithLastError({ state, report: await statusCache.get(options) }),
|
|
341
|
+
describeProviders: () => describeProviders({ state, deps: required }),
|
|
342
|
+
invalidate: () => { invalidate(state); statusCache.invalidate(); },
|
|
343
|
+
laneReady: async ({ lane }) => (await forLane({ state, lane })).capabilities({}).driver !== "console",
|
|
344
|
+
checkProvider: input => checkProvider({ state, ...input }),
|
|
345
|
+
domainRequirements: input => domainRequirements({ state, ...input, resolveDomainReadCredential }),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
//# sourceMappingURL=resolve-mailer.js.map
|
|
@@ -24,10 +24,7 @@ export declare class InMemoryVaultRepo implements VaultRepoPort {
|
|
|
24
24
|
/** O(n) memory scan, n stored rows; clones only the default, never its siblings. */
|
|
25
25
|
findDefault(input: VaultDefaultQuery, _options?: VaultOptions): Promise<VaultRecord | null>;
|
|
26
26
|
list(input: VaultQuery, _options?: VaultOptions): Promise<VaultRecord[]>;
|
|
27
|
-
put(input:
|
|
28
|
-
record: VaultRecord;
|
|
29
|
-
expectedRevision: number | null;
|
|
30
|
-
}, _options?: VaultOptions): Promise<VaultRecord>;
|
|
27
|
+
put(input: Parameters<VaultRepoPort["put"]>[0], _options?: VaultOptions): Promise<VaultRecord>;
|
|
31
28
|
/** Synchronous mutation owner shared with batch staging; default moves never re-seal. */
|
|
32
29
|
private putRecord;
|
|
33
30
|
patchMeta(input: {
|
|
@@ -81,7 +81,9 @@ export class InMemoryVaultRepo {
|
|
|
81
81
|
const group = this.groupRows({ groupKey });
|
|
82
82
|
if (group.some(row => row.id !== record.id && row.label === record.label))
|
|
83
83
|
throw new VaultError({ code: "conflict" });
|
|
84
|
-
|
|
84
|
+
// Auto-select the first row so an ordinary save is usable without a separate default action.
|
|
85
|
+
// Explicit false opts out, keeping unattached project keys from becoming vendor defaults.
|
|
86
|
+
const saved = structuredClone({ ...record, isDefault: record.isDefault ?? (group.length === 0) });
|
|
85
87
|
// Clear first, matching a SQL partial UNIQUE index; no transient duplicate default is required.
|
|
86
88
|
if (saved.isDefault)
|
|
87
89
|
this.clearDefaults({ group });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { KeyringPort, SecretSealerPort } from "../ports.js";
|
|
2
|
-
import { type VaultContext, type VaultIdentity, type VaultMeta, type VaultMetaParsers, type VaultCopyResult, type VaultDefaultQuery, type VaultOptions, type VaultQuery, type
|
|
2
|
+
import { type VaultContext, type VaultIdentity, type VaultMeta, type VaultMetaParsers, type VaultCopyResult, type VaultDefaultQuery, type VaultOptions, type VaultQuery, type VaultPutRecord, type VaultRepoPort, type VaultSummary } from "./vault.types.js";
|
|
3
3
|
export interface VaultServiceDeps {
|
|
4
4
|
repo: VaultRepoPort;
|
|
5
5
|
keyring: KeyringPort;
|
|
@@ -24,10 +24,10 @@ export interface VaultPutInput extends VaultContext {
|
|
|
24
24
|
* Creation requires a payload (which may be null). Payloads are always sealed JSON.
|
|
25
25
|
*/
|
|
26
26
|
payload?: VaultMeta | null | undefined;
|
|
27
|
-
/** Omitted preserves same-group default status;
|
|
28
|
-
*
|
|
27
|
+
/** Omitted preserves same-group default status; creation/provider moves let the repository
|
|
28
|
+
* apply its first-row rule. Explicit false opts out; true selects the destination default.
|
|
29
29
|
*/
|
|
30
|
-
isDefault?: boolean;
|
|
30
|
+
isDefault?: boolean | undefined;
|
|
31
31
|
/** A host-selected token, never a URI or serialized connection object. */
|
|
32
32
|
hintToken?: string;
|
|
33
33
|
}
|
|
@@ -50,6 +50,7 @@ export declare class SecretsVaultService {
|
|
|
50
50
|
constructor(deps: VaultServiceDeps, _options?: VaultOptions);
|
|
51
51
|
private authorize;
|
|
52
52
|
private parse;
|
|
53
|
+
/** Resolve a matching identity; deletion can retain legacy metadata without interpreting it. */
|
|
53
54
|
private find;
|
|
54
55
|
private seal;
|
|
55
56
|
private open;
|
|
@@ -61,14 +62,18 @@ export declare class SecretsVaultService {
|
|
|
61
62
|
* Prepared records contain envelopes and stay private to the host, never a public read model.
|
|
62
63
|
* @example const record = await vault.prepare(input); await repo.put({ record, expectedRevision: input.expectedRevision }); // inside the host transaction
|
|
63
64
|
*/
|
|
64
|
-
prepare(input: VaultPutInput, _options?: VaultOptions): Promise<
|
|
65
|
+
prepare(input: VaultPutInput, _options?: VaultOptions): Promise<VaultPutRecord>;
|
|
65
66
|
/** Resolve the target identity and apply the service-level CAS precheck. */
|
|
66
67
|
private findForPut;
|
|
67
68
|
private parsePutMeta;
|
|
68
69
|
/** Omitted payload keeps the existing envelope and hints without opening or sealing. */
|
|
69
70
|
private putEnvelope;
|
|
70
|
-
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership.
|
|
71
|
-
|
|
71
|
+
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership.
|
|
72
|
+
* `provider`, when given, must match the row's group before its envelope is opened (a stale
|
|
73
|
+
* selection never decrypts another group's secret). */
|
|
74
|
+
get(input: VaultIdentity, { provider }?: {
|
|
75
|
+
provider?: string;
|
|
76
|
+
}): Promise<{
|
|
72
77
|
summary: VaultSummary;
|
|
73
78
|
payload: VaultMeta | null;
|
|
74
79
|
} | null>;
|
|
@@ -103,7 +108,8 @@ export declare class SecretsVaultService {
|
|
|
103
108
|
private copyRow;
|
|
104
109
|
/** Guarded read of the source row, then a CAS write of the resealed target row. */
|
|
105
110
|
private transferRow;
|
|
106
|
-
/** Overwrite keeps the target's default, revision chain and creation time.
|
|
111
|
+
/** Overwrite keeps the target's default, revision chain and creation time. A new copy leaves
|
|
112
|
+
* placement unspecified, so the repo's first-row rule applies (never the source's default flag). */
|
|
107
113
|
private writeCopy;
|
|
108
114
|
/** Human-managed scoped inventory. No envelope or plaintext is opened or returned. */
|
|
109
115
|
list(input: VaultQuery, _options?: VaultOptions): Promise<VaultSummary[]>;
|
|
@@ -116,7 +122,9 @@ export declare class SecretsVaultService {
|
|
|
116
122
|
rotate(input: VaultIdentity & {
|
|
117
123
|
expectedRevision: number;
|
|
118
124
|
}, _options?: VaultOptions): Promise<VaultSummary>;
|
|
119
|
-
/** Idempotent for missing rows; existing rows require matching context and revision.
|
|
125
|
+
/** Idempotent for missing rows; existing rows require matching context and revision.
|
|
126
|
+
* Deletion needs no metadata interpretation, so unupgraded legacy rows remain removable.
|
|
127
|
+
*/
|
|
120
128
|
delete(input: VaultIdentity & {
|
|
121
129
|
expectedRevision: number;
|
|
122
130
|
}, _options?: VaultOptions): Promise<boolean>;
|