@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 +30 -0
- package/dist/actions.d.ts +1 -1
- package/dist/core/oauth/metadata.js +1 -1
- package/dist/modules/actions/index.d.ts +1 -1
- package/dist/modules/actions/index.js +1 -4
- package/dist/modules/actions/runtime/check.js +12 -1
- package/dist/modules/actions/runtime/email.d.ts +39 -0
- package/dist/modules/actions/runtime/email.js +91 -0
- package/dist/modules/actions/runtime/email.test.d.ts +1 -0
- package/dist/modules/actions/runtime/runAction.js +108 -4
- package/dist/modules/actions/tasks/builtins.d.ts +4 -3
- package/dist/modules/actions/tasks/builtins.js +6 -3
- package/dist/modules/actions/tasks/email.js +20 -15
- package/dist/modules/actions/tasks/flow.js +20 -1
- package/dist/modules/actions/tasks/registry.d.ts +1 -3
- package/dist/modules/actions/tasks/registry.js +3 -12
- package/dist/modules/actions/transport/catalogHandler.d.ts +1 -1
- package/dist/modules/actions/transport/catalogHandler.js +1 -1
- package/dist/modules/actions/types.d.ts +7 -6
- package/dist/modules/connectors/engine.js +1 -1
- package/package.json +6 -4
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,
|
|
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';
|
|
@@ -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,
|
|
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
|
|
228
|
-
|
|
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
|
|
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:
|
|
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
|
|
6
|
-
* `actions.tasks`. `
|
|
7
|
-
*
|
|
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
|
|
12
|
-
* `actions.tasks`. `
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
47
|
+
if (!isSingleEmailAddress(recipient)) throw new Error("The recipient is not one email address");
|
|
46
48
|
const answerTo = replyTo.trim();
|
|
47
|
-
if (answerTo !== "" && !
|
|
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.
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
113
|
-
email
|
|
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
|
-
/**
|
|
168
|
-
email?:
|
|
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 {
|
|
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.
|
|
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.
|
|
62
|
-
"@plitzi/sdk-shared": "0.35.
|
|
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",
|