@plitzi/sdk-server 0.35.4 → 0.35.6

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 CHANGED
@@ -1,5 +1,35 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.35.6
4
+
5
+ ### Patch Changes
6
+
7
+ - A failed server action can give back what it already did.
8
+
9
+ - **`flow.onFailure` ("On Failure") marks where the undo begins.** A run that reaches it has succeeded and ends there.
10
+ A run whose step failed jumps to it and runs the steps after it, in order, each still asking its own `when` — the
11
+ failure may have come before the thing to undo was ever done — with `{{ failure.step }}` and `{{ failure.message }}`
12
+ in scope. The run still ends failed, with the failure it had; an undo step that fails too is added to it.
13
+ - **The undo runs on its own clock and budget:** 5 seconds (or the run's own timeout if shorter) and its own outbound
14
+ request budget, because the run's may be exactly what ran out, and a caller closing the connection does not stop it.
15
+ A run that hit its deadline is undone too.
16
+ - **`defineAction` writes it from `onFailure: [...]`**, after the answer. `validateActionDocument` refuses an output
17
+ step or a second handler after one, warns about a handler with nothing to undo or nothing that undoes, and reserves
18
+ `failure` as a step id. `FAILURE_HANDLER_TASK` is exported from `@plitzi/sdk-shared/actions`.
19
+
20
+ - Updated dependencies
21
+ - @plitzi/plitzi-sdk@0.35.6
22
+ - @plitzi/sdk-shared@0.35.6
23
+
24
+ ## 0.35.5
25
+
26
+ ### Patch Changes
27
+
28
+ - v0.35.5
29
+ - Updated dependencies
30
+ - @plitzi/plitzi-sdk@0.35.5
31
+ - @plitzi/sdk-shared@0.35.5
32
+
3
33
  ## 0.35.4
4
34
 
5
35
  ### Patch Changes
package/dist/actions.d.ts CHANGED
@@ -56,7 +56,7 @@ export { createKvStore } from './modules/actions/runtime/kvStore';
56
56
  */
57
57
  export { createRunLogger, createRejectLogger } from './modules/actions/runtime/runLogger';
58
58
  export type { TaskRegistryOptions } from './modules/actions/tasks/registry';
59
- export type { ActionCredential, ActionDbDriver, ActionEmailAdapter, ActionEmailMessage, ActionKvAdapter, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './modules/actions/types';
59
+ export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionKvAdapter, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './modules/actions/types';
60
60
  export type { ActionsModule } from './modules/actions';
61
61
  export type { ActiveRun, RunGuards } from './modules/actions/runtime/guards';
62
62
  export type { ScheduleResult, ScheduleRunner, ScheduleTick } from './modules/actions/runtime/schedule';
@@ -1,4 +1,4 @@
1
- import { requestOrigin } from "@plitzi/sdk-server/kernel";
1
+ import { requestOrigin } from "../requestParser.js";
2
2
  //#region src/core/oauth/metadata.ts
3
3
  var AUTHORIZE_PATH = "/authorize";
4
4
  var TOKEN_PATH = "/token";
@@ -38,4 +38,4 @@ export type { ActiveRun, RunGuards } from './runtime/guards';
38
38
  export type { ScheduleResult, ScheduleRunner, ScheduleTick } from './runtime/schedule';
39
39
  export type { ActionTaskDescriptor } from './taskCatalog';
40
40
  export type { TaskRegistryOptions } from './tasks/registry';
41
- export type { ActionCredential, ActionDbDriver, ActionEmailAdapter, ActionEmailMessage, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
41
+ export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
@@ -25,10 +25,7 @@ import { handleActionCancel } from "./transport/cancelHandler.js";
25
25
  * the same as having no single-flight at all. Nothing outside this folder needs to know how a run is assembled.
26
26
  */
27
27
  var createActionsModule = (config) => {
28
- const registry = createTaskRegistry(config.tasks, {
29
- db: (config.dbDrivers?.length ?? 0) > 0,
30
- email: config.email !== void 0
31
- });
28
+ const registry = createTaskRegistry(config.tasks, { db: (config.dbDrivers?.length ?? 0) > 0 });
32
29
  const { runAction } = createActionRunner(config, registry, config.fetchImpl);
33
30
  /**
34
31
  * Single-flight over the store the deployment already gave the `kv` tasks — its own Redis, table or whatever it
@@ -1,6 +1,6 @@
1
1
  import { triggerParams } from "./triggers.js";
2
+ import { parseCron, readSmtpCredential, triggerHasStaleVerify, triggerVerify } from "@plitzi/sdk-shared/actions";
2
3
  import { hasValidToken } from "@plitzi/sdk-shared/helpers/twigWrapper";
3
- import { parseCron, triggerHasStaleVerify, triggerVerify } from "@plitzi/sdk-shared/actions";
4
4
  //#region src/modules/actions/runtime/check.ts
5
5
  /** A value the check can reason about: a literal the author typed, not a template resolved at run time. */
6
6
  var literal = (value) => {
@@ -82,6 +82,17 @@ var checkAction = async (entry, deps) => {
82
82
  const connector = literal(params.connector);
83
83
  if (connector && !await lookups.getConnector?.(spaceId, connector)) add("error", `nodes.${node.id}.params.connector`, `This space has no connector called "${connector}"`, "Add it in Connectors, or name one that exists");
84
84
  const identifier = literal(params.credential);
85
+ if (node.action === "email.send") {
86
+ const path = `nodes.${node.id}.params.credential`;
87
+ if (!identifier) {
88
+ add("error", path, "This step names no SMTP server to send through", "Add an SMTP credential and pick it here");
89
+ return;
90
+ }
91
+ const credential = await checkCredential(node, identifier);
92
+ if (!credential) return;
93
+ readSmtpCredential(credential).problems.forEach((problem) => add("error", path, `The credential "${identifier}" is not a usable SMTP server: ${problem.key} — ${problem.message}`));
94
+ return;
95
+ }
85
96
  if (!identifier) return;
86
97
  if (node.action === "db.query") {
87
98
  const credential = await checkCredential(node, identifier, "dsn");
@@ -0,0 +1,39 @@
1
+ import { HostLookup } from '../../../helpers/outboundGuard';
2
+ import { ActionKvStore } from '../types';
3
+ import { ActionEmailConfig, ActionEmailMessage } from '@plitzi/sdk-shared';
4
+ import { SmtpSettings } from '@plitzi/sdk-shared/actions';
5
+ export declare const DEFAULT_EMAIL_DAILY_LIMIT = 200;
6
+ /** Hands one message to the SMTP server of a space, at an address this module already resolved and judged. */
7
+ export type SmtpDelivery = (delivery: {
8
+ /** The IP to connect to. The host name travels separately, as what the TLS certificate must be for. */
9
+ address: string;
10
+ smtp: SmtpSettings;
11
+ message: ActionEmailMessage;
12
+ signal: AbortSignal;
13
+ }) => Promise<void>;
14
+ /** What the `email.send` task sends through: the policy around a space's own SMTP server, and the connection. */
15
+ export type ActionEmailSender = {
16
+ send: (spaceId: number, smtp: SmtpSettings, message: ActionEmailMessage, signal: AbortSignal) => Promise<void>;
17
+ };
18
+ export type EmailSenderOptions = ActionEmailConfig & {
19
+ /** The server's own store, NOT a space's: a flow must not be able to reach the counter that limits it. */
20
+ kv: ActionKvStore;
21
+ lookup?: HostLookup;
22
+ deliver?: SmtpDelivery;
23
+ now?: () => Date;
24
+ };
25
+ /**
26
+ * Sends a flow's message under the deployment's rules, in this order:
27
+ *
28
+ * 1. **Where it goes is settled.** A deployment's own `transport` owns its connection and receives the message as it
29
+ * is. Without one, the message goes to the SMTP server the space's credential names, and the host is resolved and
30
+ * every address judged first: a credential is typed by a customer and the connection starts inside the
31
+ * deployment's network, so `localhost` or an address that resolves to `10.0.0.5` is refused unless the deployment
32
+ * allowed it. The connection then goes to the address that was judged, so a name that answers differently a second
33
+ * later cannot be how it gets in.
34
+ * 2. **The message is counted against the space's day.** Before it leaves, and never given back: a failing server
35
+ * still spends the allowance, or retrying against an outage would be how the limit is walked past. A host refused
36
+ * in step 1 spends nothing, because nothing was attempted.
37
+ * 3. **The message is delivered.**
38
+ */
39
+ export declare const createEmailSender: ({ kv, dailyLimitPerSpace, allowPrivateHosts, transport, lookup, deliver, now }: EmailSenderOptions) => ActionEmailSender;
@@ -0,0 +1,91 @@
1
+ import { isBlockedHost } from "../../../helpers/outboundGuard.js";
2
+ import { lookup } from "node:dns/promises";
3
+ var DAY_SECONDS = 86400;
4
+ var CONNECTION_TIMEOUT_MS = 1e4;
5
+ var SOCKET_TIMEOUT_MS = 2e4;
6
+ var resolveAll = (hostname) => lookup(hostname, {
7
+ all: true,
8
+ verbatim: true
9
+ });
10
+ /** Loaded on the first message, so a server whose flows send no mail never loads a mail client to find that out. */
11
+ var deliverWithNodemailer = async ({ address, smtp, message, signal }) => {
12
+ const { createTransport } = await import("nodemailer");
13
+ const transport = createTransport({
14
+ host: address,
15
+ port: smtp.port,
16
+ secure: smtp.security === "tls",
17
+ requireTLS: smtp.security === "starttls",
18
+ ignoreTLS: smtp.security === "none",
19
+ tls: { servername: smtp.host },
20
+ ...smtp.username ? { auth: {
21
+ user: smtp.username,
22
+ pass: smtp.password
23
+ } } : {},
24
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
25
+ greetingTimeout: CONNECTION_TIMEOUT_MS,
26
+ socketTimeout: SOCKET_TIMEOUT_MS
27
+ });
28
+ const abort = () => transport.close();
29
+ signal.addEventListener("abort", abort, { once: true });
30
+ try {
31
+ await transport.sendMail({
32
+ from: {
33
+ name: smtp.fromName,
34
+ address: smtp.fromEmail
35
+ },
36
+ to: message.to,
37
+ subject: message.subject,
38
+ text: message.text,
39
+ ...message.replyTo ? { replyTo: message.replyTo } : {}
40
+ });
41
+ } finally {
42
+ signal.removeEventListener("abort", abort);
43
+ transport.close();
44
+ }
45
+ };
46
+ /**
47
+ * Sends a flow's message under the deployment's rules, in this order:
48
+ *
49
+ * 1. **Where it goes is settled.** A deployment's own `transport` owns its connection and receives the message as it
50
+ * is. Without one, the message goes to the SMTP server the space's credential names, and the host is resolved and
51
+ * every address judged first: a credential is typed by a customer and the connection starts inside the
52
+ * deployment's network, so `localhost` or an address that resolves to `10.0.0.5` is refused unless the deployment
53
+ * allowed it. The connection then goes to the address that was judged, so a name that answers differently a second
54
+ * later cannot be how it gets in.
55
+ * 2. **The message is counted against the space's day.** Before it leaves, and never given back: a failing server
56
+ * still spends the allowance, or retrying against an outage would be how the limit is walked past. A host refused
57
+ * in step 1 spends nothing, because nothing was attempted.
58
+ * 3. **The message is delivered.**
59
+ */
60
+ var createEmailSender = ({ kv, dailyLimitPerSpace = 200, allowPrivateHosts = false, transport, lookup = resolveAll, deliver = deliverWithNodemailer, now = () => /* @__PURE__ */ new Date() }) => {
61
+ const viaSmtp = async (smtp) => {
62
+ let addresses;
63
+ try {
64
+ addresses = await lookup(smtp.host);
65
+ } catch {
66
+ throw new Error(`The SMTP host "${smtp.host}" does not resolve`);
67
+ }
68
+ const [first] = addresses;
69
+ if (addresses.length === 0) throw new Error(`The SMTP host "${smtp.host}" does not resolve`);
70
+ if (!allowPrivateHosts && (isBlockedHost(smtp.host) || addresses.some(({ address }) => isBlockedHost(address)))) throw new Error(`The SMTP host "${smtp.host}" is on a private network, which this server does not connect to`);
71
+ return (delivery) => deliver({
72
+ address: first.address,
73
+ smtp: delivery.smtp,
74
+ message: delivery.message,
75
+ signal: delivery.signal
76
+ });
77
+ };
78
+ return { send: async (spaceId, smtp, message, signal) => {
79
+ const deliverMessage = transport ?? await viaSmtp(smtp);
80
+ const day = now().toISOString().slice(0, 10);
81
+ if (await kv.increment(`email-limit:${spaceId}:${day}`, 1, 2 * DAY_SECONDS) > dailyLimitPerSpace) throw new Error(`This space has sent its ${dailyLimitPerSpace} emails for today`);
82
+ await deliverMessage({
83
+ spaceId,
84
+ smtp,
85
+ message,
86
+ signal
87
+ });
88
+ } };
89
+ };
90
+ //#endregion
91
+ export { createEmailSender };
@@ -0,0 +1 @@
1
+ export {};
@@ -4,9 +4,11 @@ import { createKvStore } from "./kvStore.js";
4
4
  import { resolveLimits } from "./limits.js";
5
5
  import { createMemoryKv } from "./memoryKv.js";
6
6
  import { namespaceKv } from "./namespaceKv.js";
7
+ import { createEmailSender } from "./email.js";
7
8
  import { createRedactor, projectUser } from "./scope.js";
8
9
  import { precheckRun } from "./precheck.js";
9
10
  import onAbort from "../../../helpers/onAbort.js";
11
+ import { FAILURE_HANDLER_TASK } from "@plitzi/sdk-shared/actions";
10
12
  import { evaluateRuleGroup } from "@plitzi/sdk-shared/helpers/ruleEvaluator";
11
13
  import { hasValidToken, processTwig } from "@plitzi/sdk-shared/helpers/twigWrapper";
12
14
  //#region src/modules/actions/runtime/runAction.ts
@@ -15,6 +17,14 @@ var MAX_TWIG_RESOLUTION_PASSES = 5;
15
17
  * boundaries a flow actually has, and it keeps a long run to sixty reads a minute. */
16
18
  var CANCEL_POLL_MS = 1e3;
17
19
  /**
20
+ * The most a failed run's undo may take, on a clock of its own.
21
+ *
22
+ * Its own because the run's clock may be exactly what ran out: a run that hit its deadline has seats to give back
23
+ * too. Short because the caller is still waiting for the failure, and giving back what a flow took is a handful of
24
+ * writes, not another run. Never longer than the run itself was allowed.
25
+ */
26
+ var UNDO_TIMEOUT_MS = 5e3;
27
+ /**
18
28
  * The run's deadline, as a promise that loses patience.
19
29
  *
20
30
  * Aborting the controller is the polite half: a task that watches its signal stops, and the loop refuses to start
@@ -166,6 +176,21 @@ var errorNode = (action) => ({
166
176
  flowId: "",
167
177
  enabled: true
168
178
  });
179
+ /**
180
+ * Where a chain's undo begins, if it has one: the first `flow.onFailure` its steps reach.
181
+ *
182
+ * Per trigger, because each way in walks its own chain. Walked with a visited set: the validator refuses a chain that
183
+ * comes back on itself, but a stored document is still customer input, and a lookup must not be what spins.
184
+ */
185
+ var failureHandlerOf = (nodes, trigger) => {
186
+ const visited = /* @__PURE__ */ new Set();
187
+ let next = nodes[trigger.afterNode];
188
+ while (next && !visited.has(next.id)) {
189
+ if (next.action === FAILURE_HANDLER_TASK) return next;
190
+ visited.add(next.id);
191
+ next = nodes[next.afterNode];
192
+ }
193
+ };
169
194
  var withDefaults = (task, params) => Object.entries(task.params).reduce((acum, [key, param]) => {
170
195
  if (acum[key] === void 0 || acum[key] === "") acum[key] = param.defaultValue ?? "";
171
196
  return acum;
@@ -200,6 +225,10 @@ var runNode = async (node, scope, registry, buildContext) => {
200
225
  */
201
226
  var createActionRunner = (config, registry, baseFetch = fetch) => {
202
227
  const kv = createKvStore(config.kv ?? createMemoryKv());
228
+ const emailSender = createEmailSender({
229
+ ...config.email,
230
+ kv
231
+ });
203
232
  /** Never allowed to fail a run: a logging outage must not take an action down, the same rule metering follows. */
204
233
  const record = async (entry) => {
205
234
  try {
@@ -224,15 +253,21 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
224
253
  const deadline = createDeadline(request.emit ? limits.streamTimeoutMs : limits.timeoutMs, controller);
225
254
  const releaseOuter = onAbort(request.signal, () => controller.abort());
226
255
  const scopedKv = namespaceKv(kv, request.spaceId);
227
- const runFetch = createRunFetch(baseFetch, controller.signal, limits, [...request.lineage ?? [], entry.id]);
228
- const buildContext = (scope) => ({
256
+ const lineage = [...request.lineage ?? [], entry.id];
257
+ /**
258
+ * What a step runs with, for one abort signal and one outbound budget.
259
+ *
260
+ * Two of them per run at most: the flow's, and — only when it failed — the undo's, which must still be able to
261
+ * reach the outside world after the flow's signal was aborted or its request budget spent.
262
+ */
263
+ const contextFor = (signal, runFetch) => (scope) => ({
229
264
  runId,
230
265
  spaceId: request.spaceId,
231
266
  environment: request.environment,
232
267
  trigger: request.trigger,
233
268
  user: request.user,
234
269
  callerId: request.callerId,
235
- signal: controller.signal,
270
+ signal,
236
271
  scope,
237
272
  /**
238
273
  * The secret a STEP asked for, resolved inside that step and never in the flow scope.
@@ -260,9 +295,10 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
260
295
  fetch: runFetch,
261
296
  kv: scopedKv,
262
297
  dbDrivers: config.dbDrivers ?? [],
263
- email: config.email,
298
+ email: emailSender,
264
299
  emit: (chunk) => request.emit?.(redact(chunk))
265
300
  });
301
+ const buildContext = contextFor(controller.signal, createRunFetch(baseFetch, controller.signal, limits, lineage));
266
302
  const trace = [];
267
303
  const startedAt = Date.now();
268
304
  let failure;
@@ -299,11 +335,15 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
299
335
  * hits its deadline answers, releases its slot and closes its connection while whatever step ignored the abort
300
336
  * carries on in the background — which is the difference between a stuck STEP and a stuck server.
301
337
  */
338
+ const failureHandler = failureHandlerOf(document.nodes, triggerNode);
339
+ /** The step the flow was running when it stopped — what `{{ failure.step }}` names to the undo. */
340
+ let stepInFlight = "";
302
341
  const runFlow = async () => {
303
342
  let current = triggerNode;
304
343
  let executed = 0;
305
344
  let next = document.nodes[current.afterNode];
306
345
  while (next) {
346
+ if (next.action === FAILURE_HANDLER_TASK) break;
307
347
  if (controller.signal.aborted) {
308
348
  status = "aborted";
309
349
  break;
@@ -316,6 +356,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
316
356
  executed += 1;
317
357
  if (executed > limits.maxNodes) throw new ActionRunError("over_capacity", `Action exceeded its ${limits.maxNodes} step budget`);
318
358
  const startTime = Date.now();
359
+ stepInFlight = next.id;
319
360
  const outcome = await runNode(next, scope, registry, buildContext);
320
361
  trace.push({
321
362
  node: next,
@@ -332,6 +373,68 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
332
373
  next = document.nodes[current.afterNode];
333
374
  }
334
375
  };
376
+ /**
377
+ * Gives back what a run that did not finish already did: the steps after `flow.onFailure`.
378
+ *
379
+ * On a clock and an outbound budget of its own, because the run's may be exactly what ran out, and deliberately
380
+ * NOT tied to the caller's connection — somebody closing the tab is the last reason to leave seats taken. Each step
381
+ * still asks its own `when`: the failure may have come before the thing to undo was ever done. Nothing it does
382
+ * changes the run's status or its answer; an undo that fails too is added to the failure, never put in its place.
383
+ */
384
+ const undo = async (handler) => {
385
+ const undoController = new AbortController();
386
+ const undoDeadline = createDeadline(Math.min(limits.timeoutMs, UNDO_TIMEOUT_MS), undoController);
387
+ const buildUndoContext = contextFor(undoController.signal, createRunFetch(baseFetch, undoController.signal, limits, lineage));
388
+ scope.failure = {
389
+ step: stepInFlight,
390
+ message: failure ?? "",
391
+ status
392
+ };
393
+ trace.push({
394
+ node: handler,
395
+ status: "success",
396
+ result: redact(scope.failure),
397
+ postCallbacks: [],
398
+ startTime: Date.now(),
399
+ endTime: Date.now()
400
+ });
401
+ const runUndo = async () => {
402
+ let executed = 0;
403
+ let next = document.nodes[handler.afterNode];
404
+ while (next) {
405
+ executed += 1;
406
+ if (executed > limits.maxNodes) throw new ActionRunError("over_capacity", `Undoing the action exceeded its ${limits.maxNodes} step budget`);
407
+ const startTime = Date.now();
408
+ const outcome = await runNode(next, scope, registry, buildUndoContext);
409
+ trace.push({
410
+ node: next,
411
+ status: outcome.status,
412
+ result: redact(outcome.result),
413
+ postCallbacks: [],
414
+ startTime,
415
+ endTime: Date.now()
416
+ });
417
+ scope[next.id] = outcome.result;
418
+ next = document.nodes[next.afterNode];
419
+ }
420
+ };
421
+ try {
422
+ await Promise.race([runUndo(), undoDeadline.promise]);
423
+ } catch (error) {
424
+ const message = error instanceof Error ? error.message : String(error);
425
+ trace.push({
426
+ node: errorNode("undo"),
427
+ status: "failed",
428
+ result: redact({ error: message }),
429
+ postCallbacks: [],
430
+ startTime: Date.now(),
431
+ endTime: Date.now()
432
+ });
433
+ failure = `${failure ?? "Action failed"} — and undoing it failed too: ${message}`;
434
+ } finally {
435
+ undoDeadline.clear();
436
+ }
437
+ };
335
438
  /** Set when the run ends in a way the CALLER has to hear as a status code rather than as a result. */
336
439
  let fatal;
337
440
  try {
@@ -375,6 +478,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
375
478
  deadline.clear();
376
479
  releaseOuter();
377
480
  }
481
+ if (status !== "completed" && failureHandler?.enabled) await undo(failureHandler);
378
482
  const output = returned ?? {};
379
483
  await record({
380
484
  runId,
@@ -2,8 +2,9 @@ import { ActionTask } from '../types';
2
2
  /**
3
3
  * The tasks `sdk-server` itself ships: the ones that are mechanism rather than policy.
4
4
  *
5
- * Anything needing an account somewhere — AI, object storage — is the DEPLOYMENT's, registered through
6
- * `actions.tasks`. `db.query` and `email.send` sit in between: the task is shipped because its rules are the same
7
- * everywhere, the driver or transport is the deployment's, and the registry offers the task only when one was given.
5
+ * Anything needing an account somewhere the deployment owns — AI, object storage — is the DEPLOYMENT's, registered
6
+ * through `actions.tasks`. `email.send` is shipped because the account is the SPACE's: it sends through an SMTP server
7
+ * the space holds as a credential, so every server can offer it. `db.query` sits in between — the task is shipped,
8
+ * the engine driver is the deployment's, and the registry offers it only when one was given.
8
9
  */
9
10
  export declare const builtinTasks: ActionTask<Record<string, unknown>>[];
@@ -1,5 +1,6 @@
1
1
  import { authTasks } from "./auth.js";
2
2
  import { connectorTasks } from "./connector.js";
3
+ import { emailTasks } from "./email.js";
3
4
  import { flowTasks, streamTasks } from "./flow.js";
4
5
  import { httpTasks } from "./http.js";
5
6
  import { kvTasks } from "./kv.js";
@@ -8,9 +9,10 @@ import { transformTasks } from "./transform.js";
8
9
  /**
9
10
  * The tasks `sdk-server` itself ships: the ones that are mechanism rather than policy.
10
11
  *
11
- * Anything needing an account somewhere — AI, object storage — is the DEPLOYMENT's, registered through
12
- * `actions.tasks`. `db.query` and `email.send` sit in between: the task is shipped because its rules are the same
13
- * everywhere, the driver or transport is the deployment's, and the registry offers the task only when one was given.
12
+ * Anything needing an account somewhere the deployment owns — AI, object storage — is the DEPLOYMENT's, registered
13
+ * through `actions.tasks`. `email.send` is shipped because the account is the SPACE's: it sends through an SMTP server
14
+ * the space holds as a credential, so every server can offer it. `db.query` sits in between — the task is shipped,
15
+ * the engine driver is the deployment's, and the registry offers it only when one was given.
14
16
  */
15
17
  var builtinTasks = [
16
18
  ...flowTasks,
@@ -19,6 +21,7 @@ var builtinTasks = [
19
21
  ...connectorTasks,
20
22
  ...authTasks,
21
23
  ...kvTasks,
24
+ ...emailTasks,
22
25
  ...streamTasks
23
26
  ];
24
27
  //#endregion
@@ -1,19 +1,20 @@
1
+ import { isSingleEmailAddress, readSmtpCredential } from "@plitzi/sdk-shared/actions";
1
2
  //#region src/modules/actions/tasks/email.ts
2
- /**
3
- * One address, and nothing a mail header could be split on.
4
- *
5
- * Stricter than the standard on purpose: a comma or a semicolon is how one recipient becomes a hundred, and a line
6
- * break is how a subject becomes a second header. A flow renders what a visitor typed, so this is where that stops.
7
- */
8
- var ADDRESS = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/;
9
3
  var MAX_SUBJECT = 200;
10
4
  var MAX_TEXT = 2e4;
11
5
  var emailTasks = [{
12
6
  namespace: "email",
13
7
  action: "send",
14
8
  title: "Send Email",
15
- description: "Sends a plain-text message through the transport this deployment configured",
9
+ description: "Sends a plain-text message through an SMTP server this space holds as a credential",
16
10
  params: {
11
+ credential: {
12
+ type: "text",
13
+ canBind: false,
14
+ defaultValue: "",
15
+ label: "SMTP server (credential)",
16
+ credentialProvider: "smtp"
17
+ },
17
18
  to: {
18
19
  type: "text",
19
20
  canBind: true,
@@ -39,22 +40,26 @@ var emailTasks = [{
39
40
  label: "Reply to"
40
41
  }
41
42
  },
42
- run: async ({ to, subject, text, replyTo }, ctx) => {
43
- if (!ctx.email) throw new Error("This server has no email transport");
43
+ run: async ({ credential, to, subject, text, replyTo }, ctx) => {
44
+ const identifier = credential.trim();
45
+ if (identifier === "") throw new Error("This step names no SMTP credential to send through — add one in Credentials and pick it here");
44
46
  const recipient = to.trim();
45
- if (!ADDRESS.test(recipient)) throw new Error("The recipient is not one email address");
47
+ if (!isSingleEmailAddress(recipient)) throw new Error("The recipient is not one email address");
46
48
  const answerTo = replyTo.trim();
47
- if (answerTo !== "" && !ADDRESS.test(answerTo)) throw new Error("The reply-to is not one email address");
49
+ if (answerTo !== "" && !isSingleEmailAddress(answerTo)) throw new Error("The reply-to is not one email address");
48
50
  const title = subject.trim();
49
51
  if (title === "" || /[\r\n]/.test(title) || title.length > MAX_SUBJECT) throw new Error(`The subject must be one line of 1 to ${MAX_SUBJECT} characters`);
50
52
  if (text.trim() === "" || text.length > MAX_TEXT) throw new Error(`The text must have 1 to ${MAX_TEXT} characters`);
51
- await ctx.email.send({
52
- spaceId: ctx.spaceId,
53
+ const stored = await ctx.credential(identifier);
54
+ if (!stored) throw new Error(`This space has no SMTP credential called "${identifier}" — add it in Credentials`);
55
+ const reading = readSmtpCredential(stored);
56
+ if (!("settings" in reading)) throw new Error(`Credential "${identifier}" is not a usable SMTP server: ${reading.problems.map((problem) => `${problem.key} — ${problem.message}`).join("; ")}`);
57
+ await ctx.email.send(ctx.spaceId, reading.settings, {
53
58
  to: recipient,
54
59
  subject: title,
55
60
  text,
56
61
  ...answerTo === "" ? {} : { replyTo: answerTo }
57
- });
62
+ }, ctx.signal);
58
63
  return { sent: true };
59
64
  }
60
65
  }];
@@ -46,6 +46,24 @@ var fail = {
46
46
  }
47
47
  };
48
48
  /**
49
+ * Where a flow's undo begins — read by the runner, never run as a step.
50
+ *
51
+ * A run that reaches it has succeeded and ends there. A run whose step failed before it jumps here instead and runs
52
+ * the steps after it, each still asking its own `when` — the failure may have come before the thing to undo was ever
53
+ * done — and still ends as failed, with the failure it had. What failed is in their scope as `{{ failure.step }}` and
54
+ * `{{ failure.message }}`.
55
+ */
56
+ var onFailure = {
57
+ namespace: "flow",
58
+ action: "onFailure",
59
+ title: "On Failure",
60
+ description: "The steps after this one run only when a step before it fails, to undo what the flow already did",
61
+ params: {},
62
+ run: () => {
63
+ throw new Error("On Failure marks where a failed run’s undo begins; the runner never runs it as a step");
64
+ }
65
+ };
66
+ /**
49
67
  * Declares what the caller gets back — and IS the declaration.
50
68
  *
51
69
  * The flow scope is not the answer: it holds every node's raw result, including whatever a fetch happened to
@@ -102,7 +120,8 @@ var emit = {
102
120
  var flowTasks = [
103
121
  delay,
104
122
  fail,
105
- output
123
+ output,
124
+ onFailure
106
125
  ];
107
126
  var streamTasks = [emit];
108
127
  //#endregion
@@ -4,8 +4,6 @@ export declare const taskName: (task: Pick<ActionTask, "namespace" | "action">)
4
4
  export type TaskRegistryOptions = {
5
5
  /** At least one database driver is registered, so `db.query` has an engine to run against. */
6
6
  db?: boolean;
7
- /** An email transport is configured, so `email.send` has somewhere to hand a message. */
8
- email?: boolean;
9
7
  };
10
8
  /**
11
9
  * Builds the set of tasks this server can run.
@@ -14,4 +12,4 @@ export type TaskRegistryOptions = {
14
12
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
15
13
  * in that deployment does. Both are invisible until a run misbehaves in production.
16
14
  */
17
- export declare const createTaskRegistry: (custom?: ActionTask<never>[], { db, email }?: TaskRegistryOptions) => ActionTaskRegistry;
15
+ export declare const createTaskRegistry: (custom?: ActionTask<never>[], { db }?: TaskRegistryOptions) => ActionTaskRegistry;
@@ -1,14 +1,9 @@
1
1
  import { builtinTasks } from "./builtins.js";
2
2
  import { dbTasks } from "./db.js";
3
- import { emailTasks } from "./email.js";
4
3
  //#region src/modules/actions/tasks/registry.ts
5
4
  var NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
6
5
  var taskName = (task) => `${task.namespace}.${task.action}`;
7
- var reservedNamespaces = new Set([
8
- ...builtinTasks,
9
- ...dbTasks,
10
- ...emailTasks
11
- ].map((task) => task.namespace));
6
+ var reservedNamespaces = new Set([...builtinTasks, ...dbTasks].map((task) => task.namespace));
12
7
  /**
13
8
  * Builds the set of tasks this server can run.
14
9
  *
@@ -16,13 +11,9 @@ var reservedNamespaces = new Set([
16
11
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
17
12
  * in that deployment does. Both are invisible until a run misbehaves in production.
18
13
  */
19
- var createTaskRegistry = (custom = [], { db = false, email = false } = {}) => {
14
+ var createTaskRegistry = (custom = [], { db = false } = {}) => {
20
15
  const tasks = /* @__PURE__ */ new Map();
21
- [
22
- ...builtinTasks,
23
- ...db ? dbTasks : [],
24
- ...email ? emailTasks : []
25
- ].forEach((task) => tasks.set(taskName(task), {
16
+ (db ? [...builtinTasks, ...dbTasks] : builtinTasks).forEach((task) => tasks.set(taskName(task), {
26
17
  ...task,
27
18
  name: taskName(task)
28
19
  }));
@@ -9,7 +9,7 @@ export type ActionCatalogDeps = {
9
9
  *
10
10
  * The editor asks the SERVER what steps exist instead of holding a hardcoded list, and that is the whole reason a
11
11
  * self-hoster's own task appears in their builder with no fork: registering it is publishing it. It also means a
12
- * deployment with no mail transport never offers `email.send` — the catalog is what that deployment actually has,
12
+ * deployment with no database driver never offers `db.query` — the catalog is what that deployment actually has,
13
13
  * not what the SDK could imagine.
14
14
  *
15
15
  * Session-gated by the pipeline: the catalog names a deployment's server-side capabilities, which is not something
@@ -5,7 +5,7 @@ import { describeCatalog } from "../taskCatalog.js";
5
5
  *
6
6
  * The editor asks the SERVER what steps exist instead of holding a hardcoded list, and that is the whole reason a
7
7
  * self-hoster's own task appears in their builder with no fork: registering it is publishing it. It also means a
8
- * deployment with no mail transport never offers `email.send` — the catalog is what that deployment actually has,
8
+ * deployment with no database driver never offers `db.query` — the catalog is what that deployment actually has,
9
9
  * not what the SDK could imagine.
10
10
  *
11
11
  * Session-gated by the pipeline: the catalog names a deployment's server-side capabilities, which is not something
@@ -1,4 +1,5 @@
1
- import { ActionEmailAdapter, ActionEntry, ActionLimits, ActionRejectRecord, ActionRunRecord, ActionRunStatus, ActionTriggerType, ConnectorManifest, Environment, InteractionCallbackParam, InteractionNode, InteractionNodeStatus, SpaceRevision, SSRUser } from '@plitzi/sdk-shared';
1
+ import { ActionEmailSender } from './runtime/email';
2
+ import { ActionEmailConfig, ActionEntry, ActionLimits, ActionRejectRecord, ActionRunRecord, ActionRunStatus, ActionTriggerType, ConnectorManifest, Environment, InteractionCallbackParam, InteractionNode, InteractionNodeStatus, SpaceRevision, SSRUser } from '@plitzi/sdk-shared';
2
3
  /** Resolved secret material, as the connector engine already models it. */
3
4
  export type ActionCredential = Record<string, string>;
4
5
  /**
@@ -109,8 +110,8 @@ export type ActionTaskContext = {
109
110
  kv: ActionKvStore;
110
111
  /** The database engines this deployment registered, for the `db.query` task. */
111
112
  dbDrivers: ActionDbDriver[];
112
- /** The transport this deployment gave the `email.send` task. Absent → that task is not offered at all. */
113
- email?: ActionEmailAdapter;
113
+ /** How `email.send` reaches a space's SMTP server, under the deployment's limits. */
114
+ email: ActionEmailSender;
114
115
  /** Pushes a `data` frame to a streaming caller. A no-op when nobody negotiated a stream. */
115
116
  emit: (chunk: unknown) => void;
116
117
  };
@@ -164,8 +165,8 @@ export type ActionsConfig = {
164
165
  };
165
166
  /** Database engines this deployment lets a flow reach. Empty → the `db.query` task is not offered at all. */
166
167
  dbDrivers?: ActionDbDriver[];
167
- /** How a flow's mail leaves this server. Absent → the `email.send` task is not offered at all. */
168
- email?: ActionEmailAdapter;
168
+ /** The limits on `email.send`: messages per space per day, and whether an SMTP host may be on a private network. */
169
+ email?: ActionEmailConfig;
169
170
  /**
170
171
  * Called once per run that started, for a deployment that keeps a record.
171
172
  *
@@ -201,7 +202,7 @@ export type ActionsConfig = {
201
202
  };
202
203
  /** Re-exported so the module's own files import one place, and a deployment writing an `onRun` or an `onReject`
203
204
  * sees the same shapes the module emits. */
204
- export type { ActionEmailAdapter, ActionEmailMessage, ActionRejectRecord, ActionRunRecord } from '@plitzi/sdk-shared';
205
+ export type { ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionRejectRecord, ActionRunRecord } from '@plitzi/sdk-shared';
205
206
  export type ResolvedActionLimits = Required<ActionLimits>;
206
207
  export type ActionRunRequest = {
207
208
  entry: ActionEntry;
@@ -1,5 +1,5 @@
1
- import { getByPath } from "./getByPath.js";
2
1
  import { assertOutboundAllowed } from "../../helpers/outboundGuard.js";
2
+ import { getByPath } from "./getByPath.js";
3
3
  import { processTwig } from "@plitzi/sdk-shared/helpers/twigWrapper";
4
4
  import { BODYLESS_METHODS, DEFAULT_READ_ENDPOINT, EMPTY_RESPONSE_METHODS } from "@plitzi/sdk-shared/connectors";
5
5
  //#region src/modules/connectors/engine.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.35.4",
3
+ "version": "0.35.6",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -58,11 +58,12 @@
58
58
  "type": "module",
59
59
  "sideEffects": false,
60
60
  "dependencies": {
61
- "@plitzi/plitzi-sdk": "0.35.4",
62
- "@plitzi/sdk-shared": "0.35.4",
61
+ "@plitzi/plitzi-sdk": "0.35.6",
62
+ "@plitzi/sdk-shared": "0.35.6",
63
63
  "ejs": "^6.0.1",
64
64
  "esbuild": "^0.28.2",
65
- "jsonwebtoken": "^9.0.3"
65
+ "jsonwebtoken": "^9.0.3",
66
+ "nodemailer": "^10.0.9"
66
67
  },
67
68
  "peerDependencies": {
68
69
  "mysql2": "^3",
@@ -78,6 +79,7 @@
78
79
  "@types/ejs": "^3.1.5",
79
80
  "@types/jsonwebtoken": "^9.0.10",
80
81
  "@types/node": "^26.5.1",
82
+ "@types/nodemailer": "^8.0.1",
81
83
  "@types/react": "^19.3.0",
82
84
  "@vitejs/plugin-react": "^6.1.1",
83
85
  "@vitest/coverage-v8": "^5.0.0",