@xeplr/workflow 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +352 -0
  3. package/bin/www +7 -0
  4. package/db/xcfgSetup.js +8 -0
  5. package/env.required.js +41 -0
  6. package/index.js +313 -0
  7. package/lib/actionCatalog.js +193 -0
  8. package/lib/actions/jobRun.js +149 -0
  9. package/lib/actions/screenShow.js +55 -0
  10. package/lib/db.js +82 -0
  11. package/lib/envExposed.js +87 -0
  12. package/lib/flows.js +832 -0
  13. package/lib/flowsRouter.js +113 -0
  14. package/lib/router.js +260 -0
  15. package/lib/workflowRunner.js +649 -0
  16. package/migrations/0001_companies.sql +23 -0
  17. package/migrations/0002_workspaces.sql +22 -0
  18. package/migrations/0003_workflows.sql +35 -0
  19. package/migrations/0004_workflow_steps.sql +79 -0
  20. package/migrations/0005_workflow_runs.sql +49 -0
  21. package/migrations/0006_workflow_step_runs.sql +42 -0
  22. package/migrations/0007_workflow_resume_keys.sql +42 -0
  23. package/migrations/0008_workflow_run_edges.sql +43 -0
  24. package/migrations/0009_workflow_steps_layout.sql +11 -0
  25. package/migrations/0010_workflow_steps_sample_output.sql +17 -0
  26. package/migrations/0011_workflow_steps_params.sql +27 -0
  27. package/migrations/0012_workflows_kind.sql +27 -0
  28. package/migrations/0013_workflows_key.sql +48 -0
  29. package/migrations-auth/0001_workflow_access.sql +129 -0
  30. package/migrations-auth/0003_nav_menus.sql +62 -0
  31. package/migrations-auth/0004_flows_access.sql +76 -0
  32. package/models/Company.js +82 -0
  33. package/models/Workflow.js +63 -0
  34. package/models/WorkflowResumeKey.js +32 -0
  35. package/models/WorkflowRun.js +64 -0
  36. package/models/WorkflowRunEdge.js +49 -0
  37. package/models/WorkflowStep.js +66 -0
  38. package/models/WorkflowStepRun.js +49 -0
  39. package/models/Workspace.js +75 -0
  40. package/models/index.js +25 -0
  41. package/orchestration/standalone.js +123 -0
  42. package/package.json +69 -0
@@ -0,0 +1,55 @@
1
+ // screen-show — PARK THE RUN AND LET A PERSON FILL IN A SCREEN.
2
+ //
3
+ // The step behind every node of a 'screens' flow (see lib/flows.js). It is a
4
+ // `wait` step whose action does nothing at all, and the nothing is the whole
5
+ // design:
6
+ //
7
+ // 1. the engine mints the resume key BEFORE the action runs (see
8
+ // workflowRunner's executeFrom) and parks the run at status 'waiting';
9
+ // 2. this returns immediately, so there is nothing to undo and nothing that
10
+ // can fail between "the person opened the screen" and "the run is
11
+ // waiting for them";
12
+ // 3. the person submits; POST /flows/runs/:runId/submit resumes the step
13
+ // with what they typed, and resumeByKey MERGES that onto this output.
14
+ //
15
+ // ── why it returns {} and not { screen } ─────────────────────────────────
16
+ //
17
+ // The step's output IS the submitted values, and nothing else. Transitions on
18
+ // a screens flow read `output.<field>` where <field> is a field of the screen,
19
+ // so anything this action put in the output would occupy a name a screen might
20
+ // legitimately use — a screen with a field called `screen` would then branch on
21
+ // this action's own bookkeeping until the moment it was submitted.
22
+ //
23
+ // Which screen was shown is not lost by returning nothing: the resolved input
24
+ // is persisted on the step run (workflow_step_runs.input), so history records
25
+ // it, and the facade reads the screen off the STEP row (values.screen) rather
26
+ // than off a run, because that is where the flow's design lives.
27
+ //
28
+ // ── why it is not in the action palette ──────────────────────────────────
29
+ //
30
+ // It is registered (the engine resolves a step's actionName through the
31
+ // registry, so a step could not run without it) but hidden from GET /actions —
32
+ // see lib/actionCatalog.js's HIDDEN. Dropping one onto the ordinary workflow
33
+ // canvas would produce a step that parks forever: nothing outside the flows
34
+ // facade knows how to show a screen or how to resume it.
35
+
36
+ module.exports = {
37
+ name: 'screen-show',
38
+ description: 'Show a screen to a person and wait for them to submit it. The values they ' +
39
+ 'submit become this step\'s output, so a transition can branch on them.',
40
+ requires: [],
41
+
42
+ inputSchema: [
43
+ { name: 'screen', type: 'string', required: true, order: 1,
44
+ description: 'Key of the screen to show. The app that owns the screens resolves it; ' +
45
+ 'this engine only carries it.' }
46
+ ],
47
+
48
+ // Whatever the person submitted, which is not knowable from here — declared
49
+ // as nothing rather than as a lie. See the note above.
50
+ outputSchema: null,
51
+
52
+ execute: async function() {
53
+ return {};
54
+ }
55
+ };
package/lib/db.js ADDED
@@ -0,0 +1,82 @@
1
+ var { getConnection, registerMTs } = require('@xeplr/db');
2
+ var models = require('../models');
3
+
4
+ var _registered = false;
5
+ var _conn = null;
6
+
7
+ /**
8
+ * Bind workflow's own models to a connection. Config-driven — never reads
9
+ * process.env; that is orchestration/standalone.js's job when nothing else
10
+ * supplies one.
11
+ *
12
+ * @param {object} config
13
+ * @param {string} config.name - database name (workflow's OWN domain data —
14
+ * this is always workflow's responsibility, embedded or standalone; that
15
+ * data belongs to workflow, not whatever it's mounted into)
16
+ * @param {string} config.connection - connection string/encrypted blob, same
17
+ * shape @xeplr/db's getConnection already expects
18
+ * @param {object} [config.mts] - { l1: {name, header}, ... }. registerMTs()
19
+ * is PROCESS-GLOBAL — only pass this when nothing else in this process has
20
+ * already called it. A host app embedding workflow has already registered
21
+ * its own levels; passing config.mts in that case would silently overwrite
22
+ * them. Standalone mode passes it; registerWorkflow's embedded branch does
23
+ * not.
24
+ */
25
+ async function connectDb(config) {
26
+ config = config || {};
27
+ if (!config.name || !config.connection) {
28
+ throw new Error('connectDb: { name, connection } are required');
29
+ }
30
+ if (config.mts && !_registered) {
31
+ registerMTs(config.mts);
32
+ _registered = true;
33
+ }
34
+
35
+ // bind:false — A SECONDARY CONNECTION, never the process's global one.
36
+ //
37
+ // @xeplr/db's bindModels does `Model.knex(instance)` on OBJECTION'S BASE
38
+ // CLASS, so it is global: the last getConnection with bind:true wins for
39
+ // every model in the process, whoever declared it. Standalone that is
40
+ // harmless, because workflow is the only thing here. Embedded it is not —
41
+ // connecting to workflow's own database re-pointed the HOST's models at it
42
+ // too, and the host's own routes started failing on tables that were never
43
+ // in this database.
44
+ //
45
+ // So workflow binds its OWN models to its OWN connection and leaves the
46
+ // global binding alone. Exactly what @xeplr/auth's attach() does to reach
47
+ // the auth database from the api process, and what @xeplr/actions'
48
+ // attachConfig() does for xeplr_configs — this is the third instance of the
49
+ // same shape, not a new idea.
50
+ _conn = await getConnection(config.name, config.connection, {
51
+ bind: false,
52
+ connectionName: config.connectionName || 'workflow'
53
+ });
54
+ return _conn;
55
+ }
56
+
57
+ function conn() {
58
+ if (!_conn) throw new Error('workflow: connection not ready — await connectDb() first');
59
+ return _conn;
60
+ }
61
+
62
+ /**
63
+ * A workflow model BOUND to workflow's own connection.
64
+ *
65
+ * Every query in this package goes through here rather than through the class
66
+ * exported by models/index.js. An unbound class falls back to the global
67
+ * binding, which belongs to whatever else is in this process — the host app,
68
+ * embedded — so it would read the wrong database and mostly report that
69
+ * workflow's tables do not exist.
70
+ *
71
+ * Objection caches per (Model, knex) pair, so calling this per request costs a
72
+ * map lookup rather than a new class.
73
+ */
74
+ function model(name) {
75
+ var M = models[name];
76
+ if (!M) throw new Error('workflow: unknown model "' + name + '"');
77
+ return M.bindKnex(conn());
78
+ }
79
+
80
+ module.exports = connectDb;
81
+ module.exports.conn = conn;
82
+ module.exports.model = model;
@@ -0,0 +1,87 @@
1
+ // WHICH ENV VARS A WORKFLOW STEP MAY REFERENCE, as `{env.NAME}`.
2
+ //
3
+ // Nothing, unless named:
4
+ //
5
+ // WORKFLOW_ENV_EXPOSED=PUBLIC_BASE_URL,DEPLOY_TAG
6
+ //
7
+ // AN ALLOWLIST, NOT A DENYLIST, and that is the whole design. A denylist works
8
+ // on the day it is written and fails silently afterwards: the day somebody
9
+ // adds STRIPE_SECRET_KEY to the environment it is readable until a second
10
+ // person remembers to go and hide it. This way a new variable is invisible
11
+ // until it is deliberately published, so the failure mode of forgetting is
12
+ // "the template renders empty" rather than "the key leaked".
13
+ //
14
+ // WHY THIS MATTERS MORE THAN IT LOOKS. A resolved step input is not private:
15
+ //
16
+ // - POST /workflows/:id/steps/try ECHOES the interpolated input straight
17
+ // back to the browser, by design, so the builder can show what it sent
18
+ // (see lib/router.js). The step does not even have to be saved — `values`
19
+ // comes off the request body.
20
+ // - workflowRunner writes the resolved values into WorkflowStepRun.input,
21
+ // where they stay for the life of the run history.
22
+ //
23
+ // So without a list, "can author a workflow step" would silently mean "can
24
+ // read ENCRYPTION_KEY and AUTH_JWT_SECRET" — the two that decrypt every
25
+ // stored connection string and mint a token for any user. Both halves of that
26
+ // escalation look ordinary in an access-control table, which is exactly why
27
+ // it has to be shut off here rather than noticed later.
28
+ //
29
+ // KEEP SECRETS OFF THIS LIST. It is for values that are awkward to hardcode
30
+ // per environment and harmless to read: a public base URL, an environment
31
+ // tag, a bucket name. A credential belongs on the path where the ACTION reads
32
+ // it server-side at execution time and it never enters the template context
33
+ // at all — that is what `useCustomConnection: false` on the email actions
34
+ // does with SMTP_*, and it is strictly safer than any list.
35
+
36
+ // Exact names only. No prefixes and no globs: `PUBLIC_*` reads as a small
37
+ // convenience right up until somebody names a secret PUBLIC_something, and
38
+ // then the list no longer says what it exposes. A literal '*' matches a
39
+ // variable actually called '*', which does not exist — so the tempting
40
+ // shortcut fails closed rather than exposing everything.
41
+ function exposedNames() {
42
+ var raw = process.env.WORKFLOW_ENV_EXPOSED;
43
+ if (!raw) return [];
44
+ return String(raw).split(',')
45
+ .map(function(n) { return n.trim(); })
46
+ .filter(function(n) { return n.length > 0; });
47
+ }
48
+
49
+ /**
50
+ * The `env` branch of a step's interpolation context.
51
+ *
52
+ * Read per call rather than memoized at load: this module is required before
53
+ * the host has finished loading its .env in at least one boot order, and a
54
+ * snapshot taken then would be permanently empty with nothing to explain it.
55
+ * The cost is splitting a short string once per step.
56
+ *
57
+ * A name on the list that is not set is OMITTED rather than included as
58
+ * undefined — either way `{env.NAME}` renders as empty string, since that is
59
+ * what interpolate() does with any unknown path (see @xeplr/schema-handler's
60
+ * templating.js). Worth knowing when debugging: a misspelled name and an
61
+ * unexposed one look identical in the output.
62
+ */
63
+ function exposedEnv() {
64
+ var out = {};
65
+ exposedNames().forEach(function(name) {
66
+ if (process.env[name] !== undefined) out[name] = process.env[name];
67
+ });
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Say at boot what is readable, so it is a fact in the log rather than a
73
+ * thing you would have to go and derive from an .env file. A list that
74
+ * accidentally names a secret is only catchable if somebody can see it.
75
+ */
76
+ function logExposure() {
77
+ var names = exposedNames();
78
+ if (!names.length) return;
79
+ var missing = names.filter(function(n) { return process.env[n] === undefined; });
80
+ console.log('[workflow] {env.*} exposes ' + names.length + ' var(s) to step templates: ' + names.join(', '));
81
+ if (missing.length) {
82
+ console.warn('[workflow] WORKFLOW_ENV_EXPOSED names ' + missing.length +
83
+ ' var(s) that are not set (they resolve to empty string): ' + missing.join(', '));
84
+ }
85
+ }
86
+
87
+ module.exports = { exposedNames: exposedNames, exposedEnv: exposedEnv, logExposure: logExposure };