@wooksjs/event-wf 0.7.8 → 0.7.10

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/dist/index.cjs CHANGED
@@ -120,7 +120,6 @@ const useWfFinished = (0, __wooksjs_event_core.defineWook)((ctx) => ({
120
120
 
121
121
  //#endregion
122
122
  //#region packages/event-wf/src/outlets/trigger.ts
123
- const DEFAULT_CONSUME_TOKEN = { email: true };
124
123
  /**
125
124
  * Handle an HTTP request that starts or resumes a workflow.
126
125
  *
@@ -168,15 +167,11 @@ async function handleWfOutletRequest(config, deps) {
168
167
  const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
169
168
  const input = body?.input;
170
169
  const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
171
- const shouldConsume = (outletName) => {
172
- if (typeof tok.consume === "boolean") return tok.consume;
173
- return (tok.consume ?? DEFAULT_CONSUME_TOKEN)[outletName] ?? false;
174
- };
175
170
  let output;
176
171
  if (token) {
177
172
  const strategy = resolveStrategy(wfid ?? "");
178
173
  ctx.set(stateStrategyKey, strategy);
179
- const state = await strategy.retrieve(token);
174
+ const state = await strategy.consume(token);
180
175
  if (!state) return {
181
176
  error: "Invalid or expired workflow state",
182
177
  status: 400
@@ -185,8 +180,6 @@ async function handleWfOutletRequest(config, deps) {
185
180
  const realStrategy = resolveStrategy(state.schemaId);
186
181
  ctx.set(stateStrategyKey, realStrategy);
187
182
  }
188
- const outletName = state.meta?.outlet;
189
- if (outletName && shouldConsume(outletName)) await strategy.consume(token);
190
183
  output = await deps.resume(state, {
191
184
  input,
192
185
  eventContext: ctx
@@ -238,13 +231,14 @@ async function handleWfOutletRequest(config, deps) {
238
231
  meta: { outlet: outletReq.outlet }
239
232
  };
240
233
  const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0);
241
- if (tokenWrite === "cookie") response.setCookie(tokenName, newToken, {
234
+ const outOfBand = outletHandler.tokenDelivery === "out-of-band";
235
+ if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
242
236
  httpOnly: true,
243
237
  sameSite: "Strict",
244
238
  path: "/"
245
239
  });
246
240
  const result = await outletHandler.deliver(outletReq, newToken);
247
- if (tokenWrite === "body" && result?.response && typeof result.response === "object") return {
241
+ if (tokenWrite === "body" && !outOfBand && result?.response && typeof result.response === "object") return {
248
242
  ...result.response,
249
243
  [tokenName]: newToken
250
244
  };
@@ -274,6 +268,7 @@ async function handleWfOutletRequest(config, deps) {
274
268
  function createHttpOutlet(opts) {
275
269
  return {
276
270
  name: "http",
271
+ tokenDelivery: "caller",
277
272
  async deliver(request, _token) {
278
273
  const body = opts?.transform ? opts.transform(request.payload, request.context) : typeof request.payload === "object" && request.payload !== null ? {
279
274
  ...request.payload,
@@ -302,6 +297,7 @@ function createHttpOutlet(opts) {
302
297
  function createEmailOutlet(send) {
303
298
  return {
304
299
  name: "email",
300
+ tokenDelivery: "out-of-band",
305
301
  async deliver(request, token) {
306
302
  await send({
307
303
  target: request.target ?? "",
@@ -339,7 +335,7 @@ function createOutletHandler(wfApp) {
339
335
  }
340
336
 
341
337
  //#endregion
342
- //#region node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
338
+ //#region node_modules/.pnpm/@prostojs+wf@0.2.0/node_modules/@prostojs/wf/dist/outlets/index.mjs
343
339
  /**
344
340
  * Generic outlet request. Use for custom outlets.
345
341
  *
@@ -391,6 +387,29 @@ function outletEmail(target, template, context) {
391
387
  *
392
388
  * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
393
389
  *
390
+ * ## Security warning — replay
391
+ *
392
+ * This strategy is STATELESS. It cannot enforce single-use semantics:
393
+ * `consume()` is a no-op alias for `retrieve()` because there is no
394
+ * server-side record to delete. Anyone who obtains a copy of the token
395
+ * (browser history, server logs, shoulder-surfing, intermediate proxy,
396
+ * shared device) can replay it until the TTL expires.
397
+ *
398
+ * Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
399
+ *
400
+ * 1. Every workflow step is idempotent — re-executing a step with the same
401
+ * input produces no harmful side effects (pure data collection,
402
+ * validation-only steps).
403
+ * 2. The flow is not security-sensitive — no credential changes, financial
404
+ * operations, account provisioning, permission grants, or any other
405
+ * privileged action.
406
+ *
407
+ * For auth flows (login, password reset, invite accept), financial
408
+ * operations, or anything with meaningful side effects, use
409
+ * `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
410
+ * supports true single-use tokens via atomic `getAndDelete` at the store
411
+ * layer.
412
+ *
394
413
  * @example
395
414
  * const strategy = new EncapsulatedStateStrategy({
396
415
  * secret: crypto.randomBytes(32),
@@ -433,7 +452,14 @@ var EncapsulatedStateStrategy = class {
433
452
  async retrieve(token) {
434
453
  return this.decrypt(token);
435
454
  }
436
- /** Same as retrieve (stateless — cannot truly invalidate a token). */
455
+ /**
456
+ * Stateless — CANNOT invalidate the token. Returns identical result to
457
+ * `retrieve()`. See the class-level security warning.
458
+ *
459
+ * This method exists only to satisfy the `WfStateStrategy` contract.
460
+ * Callers that need true single-use semantics must use
461
+ * `HandleStateStrategy`.
462
+ */
437
463
  async consume(token) {
438
464
  return this.decrypt(token);
439
465
  }
package/dist/index.d.ts CHANGED
@@ -92,26 +92,36 @@ interface WfOutletTokenConfig {
92
92
  write?: 'body' | 'cookie';
93
93
  /** Parameter name for state token (default: `'wfs'`) */
94
94
  name?: string;
95
- /**
96
- * Token consumption mode per outlet. When `true`, the trigger calls
97
- * `strategy.consume()` (single-use token) instead of `strategy.retrieve()`
98
- * on resume. Defaults to `{ email: true }` — email magic links are consumed
99
- * on first use, HTTP tokens are reusable.
100
- *
101
- * Can be a boolean (applies to all outlets) or a per-outlet-name map.
102
- */
103
- consume?: boolean | Record<string, boolean>;
104
95
  }
105
96
  interface WfOutletTriggerConfig {
106
97
  /** Whitelist of allowed workflow IDs. If empty, all are allowed. */
107
98
  allow?: string[];
108
99
  /** Blacklist of workflow IDs. Checked after allow. */
109
100
  block?: string[];
110
- /** State persistence strategy */
101
+ /**
102
+ * State persistence strategy. Either a single strategy shared by all
103
+ * workflows, or a function that returns a strategy per workflow ID.
104
+ *
105
+ * **Constraint when using the function form.** The trigger resolves the
106
+ * strategy at resume time using the `wfid` from the request. If the resume
107
+ * request does not include `wfid` (e.g. cookie-only transport, token-only
108
+ * body), the trigger calls `config.state('')` — meaning:
109
+ *
110
+ * - EITHER all strategies returned by the function must share the same
111
+ * underlying storage (same Redis instance, same `WfStateStore`, same
112
+ * encryption key), so `consume`/`retrieve` operations work regardless of
113
+ * which strategy instance is picked;
114
+ * - OR every resume request must carry `wfid` so the correct strategy is
115
+ * always resolved.
116
+ *
117
+ * Violating this contract silently breaks single-use token invalidation:
118
+ * the `consume` call runs against the wrong strategy's storage, and the
119
+ * token remains live in the real strategy.
120
+ */
111
121
  state: WfStateStrategy | ((wfid: string) => WfStateStrategy);
112
122
  /** Registered outlets */
113
123
  outlets: WfOutlet[];
114
- /** Token configuration (reading, writing, naming, consumption) */
124
+ /** Token configuration (reading, writing, naming) */
115
125
  token?: WfOutletTokenConfig;
116
126
  /** Parameter name for workflow ID (default: `'wfid'`) */
117
127
  wfidName?: string;
package/dist/index.mjs CHANGED
@@ -97,7 +97,6 @@ const useWfFinished = defineWook((ctx) => ({
97
97
 
98
98
  //#endregion
99
99
  //#region packages/event-wf/src/outlets/trigger.ts
100
- const DEFAULT_CONSUME_TOKEN = { email: true };
101
100
  /**
102
101
  * Handle an HTTP request that starts or resumes a workflow.
103
102
  *
@@ -145,15 +144,11 @@ async function handleWfOutletRequest(config, deps) {
145
144
  const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
146
145
  const input = body?.input;
147
146
  const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
148
- const shouldConsume = (outletName) => {
149
- if (typeof tok.consume === "boolean") return tok.consume;
150
- return (tok.consume ?? DEFAULT_CONSUME_TOKEN)[outletName] ?? false;
151
- };
152
147
  let output;
153
148
  if (token) {
154
149
  const strategy = resolveStrategy(wfid ?? "");
155
150
  ctx.set(stateStrategyKey, strategy);
156
- const state = await strategy.retrieve(token);
151
+ const state = await strategy.consume(token);
157
152
  if (!state) return {
158
153
  error: "Invalid or expired workflow state",
159
154
  status: 400
@@ -162,8 +157,6 @@ async function handleWfOutletRequest(config, deps) {
162
157
  const realStrategy = resolveStrategy(state.schemaId);
163
158
  ctx.set(stateStrategyKey, realStrategy);
164
159
  }
165
- const outletName = state.meta?.outlet;
166
- if (outletName && shouldConsume(outletName)) await strategy.consume(token);
167
160
  output = await deps.resume(state, {
168
161
  input,
169
162
  eventContext: ctx
@@ -215,13 +208,14 @@ async function handleWfOutletRequest(config, deps) {
215
208
  meta: { outlet: outletReq.outlet }
216
209
  };
217
210
  const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0);
218
- if (tokenWrite === "cookie") response.setCookie(tokenName, newToken, {
211
+ const outOfBand = outletHandler.tokenDelivery === "out-of-band";
212
+ if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
219
213
  httpOnly: true,
220
214
  sameSite: "Strict",
221
215
  path: "/"
222
216
  });
223
217
  const result = await outletHandler.deliver(outletReq, newToken);
224
- if (tokenWrite === "body" && result?.response && typeof result.response === "object") return {
218
+ if (tokenWrite === "body" && !outOfBand && result?.response && typeof result.response === "object") return {
225
219
  ...result.response,
226
220
  [tokenName]: newToken
227
221
  };
@@ -251,6 +245,7 @@ async function handleWfOutletRequest(config, deps) {
251
245
  function createHttpOutlet(opts) {
252
246
  return {
253
247
  name: "http",
248
+ tokenDelivery: "caller",
254
249
  async deliver(request, _token) {
255
250
  const body = opts?.transform ? opts.transform(request.payload, request.context) : typeof request.payload === "object" && request.payload !== null ? {
256
251
  ...request.payload,
@@ -279,6 +274,7 @@ function createHttpOutlet(opts) {
279
274
  function createEmailOutlet(send) {
280
275
  return {
281
276
  name: "email",
277
+ tokenDelivery: "out-of-band",
282
278
  async deliver(request, token) {
283
279
  await send({
284
280
  target: request.target ?? "",
@@ -316,7 +312,7 @@ function createOutletHandler(wfApp) {
316
312
  }
317
313
 
318
314
  //#endregion
319
- //#region node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
315
+ //#region node_modules/.pnpm/@prostojs+wf@0.2.0/node_modules/@prostojs/wf/dist/outlets/index.mjs
320
316
  /**
321
317
  * Generic outlet request. Use for custom outlets.
322
318
  *
@@ -368,6 +364,29 @@ function outletEmail(target, template, context) {
368
364
  *
369
365
  * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
370
366
  *
367
+ * ## Security warning — replay
368
+ *
369
+ * This strategy is STATELESS. It cannot enforce single-use semantics:
370
+ * `consume()` is a no-op alias for `retrieve()` because there is no
371
+ * server-side record to delete. Anyone who obtains a copy of the token
372
+ * (browser history, server logs, shoulder-surfing, intermediate proxy,
373
+ * shared device) can replay it until the TTL expires.
374
+ *
375
+ * Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
376
+ *
377
+ * 1. Every workflow step is idempotent — re-executing a step with the same
378
+ * input produces no harmful side effects (pure data collection,
379
+ * validation-only steps).
380
+ * 2. The flow is not security-sensitive — no credential changes, financial
381
+ * operations, account provisioning, permission grants, or any other
382
+ * privileged action.
383
+ *
384
+ * For auth flows (login, password reset, invite accept), financial
385
+ * operations, or anything with meaningful side effects, use
386
+ * `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
387
+ * supports true single-use tokens via atomic `getAndDelete` at the store
388
+ * layer.
389
+ *
371
390
  * @example
372
391
  * const strategy = new EncapsulatedStateStrategy({
373
392
  * secret: crypto.randomBytes(32),
@@ -410,7 +429,14 @@ var EncapsulatedStateStrategy = class {
410
429
  async retrieve(token) {
411
430
  return this.decrypt(token);
412
431
  }
413
- /** Same as retrieve (stateless — cannot truly invalidate a token). */
432
+ /**
433
+ * Stateless — CANNOT invalidate the token. Returns identical result to
434
+ * `retrieve()`. See the class-level security warning.
435
+ *
436
+ * This method exists only to satisfy the `WfStateStrategy` contract.
437
+ * Callers that need true single-use semantics must use
438
+ * `HandleStateStrategy`.
439
+ */
414
440
  async consume(token) {
415
441
  return this.decrypt(token);
416
442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooksjs/event-wf",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "@wooksjs/event-wf",
5
5
  "keywords": [
6
6
  "app",
@@ -22,13 +22,8 @@
22
22
  "url": "git+https://github.com/wooksjs/wooksjs.git",
23
23
  "directory": "packages/event-wf"
24
24
  },
25
- "bin": {
26
- "wooksjs-event-wf-skill": "scripts/setup-skills.js"
27
- },
28
25
  "files": [
29
- "dist",
30
- "skills",
31
- "scripts"
26
+ "dist"
32
27
  ],
33
28
  "main": "dist/index.cjs",
34
29
  "module": "dist/index.mjs",
@@ -42,22 +37,22 @@
42
37
  }
43
38
  },
44
39
  "dependencies": {
45
- "@prostojs/wf": "^0.1.1"
40
+ "@prostojs/wf": "^0.2.0"
46
41
  },
47
42
  "devDependencies": {
48
43
  "typescript": "^5.9.3",
49
44
  "vitest": "^3.2.4",
50
- "@wooksjs/event-core": "^0.7.8",
51
- "@wooksjs/event-http": "^0.7.8",
52
- "wooks": "^0.7.8",
53
- "@wooksjs/http-body": "^0.7.8"
45
+ "@wooksjs/event-http": "^0.7.10",
46
+ "@wooksjs/event-core": "^0.7.10",
47
+ "wooks": "^0.7.10",
48
+ "@wooksjs/http-body": "^0.7.10"
54
49
  },
55
50
  "peerDependencies": {
56
51
  "@prostojs/logger": "^0.4.3",
57
- "@wooksjs/event-core": "^0.7.8",
58
- "@wooksjs/event-http": "^0.7.8",
59
- "wooks": "^0.7.8",
60
- "@wooksjs/http-body": "^0.7.8"
52
+ "@wooksjs/event-core": "^0.7.10",
53
+ "@wooksjs/event-http": "^0.7.10",
54
+ "@wooksjs/http-body": "^0.7.10",
55
+ "wooks": "^0.7.10"
61
56
  },
62
57
  "peerDependenciesMeta": {
63
58
  "@wooksjs/event-http": {
@@ -68,7 +63,6 @@
68
63
  }
69
64
  },
70
65
  "scripts": {
71
- "build": "rolldown -c ../../rolldown.config.mjs",
72
- "setup-skills": "node scripts/setup-skills.js"
66
+ "build": "rolldown -c ../../rolldown.config.mjs"
73
67
  }
74
68
  }
@@ -1,77 +0,0 @@
1
- #!/usr/bin/env node
2
- /* prettier-ignore */
3
- 'use strict'
4
-
5
- const fs = require('fs')
6
- const path = require('path')
7
- const os = require('os')
8
-
9
- const SKILL_NAME = 'wooksjs-event-wf'
10
- const SKILL_SRC = path.join(__dirname, '..', 'skills', SKILL_NAME)
11
-
12
- if (!fs.existsSync(SKILL_SRC)) {
13
- console.error(`No skills found at ${SKILL_SRC}`)
14
- console.error('Add your SKILL.md files to the skills/' + SKILL_NAME + '/ directory first.')
15
- process.exit(1)
16
- }
17
-
18
- const AGENTS = {
19
- 'Claude Code': { dir: '.claude/skills', global: path.join(os.homedir(), '.claude', 'skills') },
20
- 'Cursor': { dir: '.cursor/skills', global: path.join(os.homedir(), '.cursor', 'skills') },
21
- 'Windsurf': { dir: '.windsurf/skills', global: path.join(os.homedir(), '.windsurf', 'skills') },
22
- 'Codex': { dir: '.codex/skills', global: path.join(os.homedir(), '.codex', 'skills') },
23
- 'OpenCode': { dir: '.opencode/skills', global: path.join(os.homedir(), '.opencode', 'skills') },
24
- }
25
-
26
- const args = process.argv.slice(2)
27
- const isGlobal = args.includes('--global') || args.includes('-g')
28
- const isPostinstall = args.includes('--postinstall')
29
- let installed = 0, skipped = 0
30
- const installedDirs = []
31
-
32
- for (const [agentName, cfg] of Object.entries(AGENTS)) {
33
- const targetBase = isGlobal ? cfg.global : path.join(process.cwd(), cfg.dir)
34
- const agentRootDir = path.dirname(cfg.global) // Check if the agent has ever been installed globally
35
-
36
- // In postinstall mode: silently skip agents that aren't set up globally
37
- if (isPostinstall || isGlobal) {
38
- if (!fs.existsSync(agentRootDir)) { skipped++; continue }
39
- }
40
-
41
- const dest = path.join(targetBase, SKILL_NAME)
42
- try {
43
- fs.mkdirSync(dest, { recursive: true })
44
- fs.cpSync(SKILL_SRC, dest, { recursive: true })
45
- console.log(`✅ ${agentName}: installed to ${dest}`)
46
- installed++
47
- if (!isGlobal) installedDirs.push(cfg.dir + '/' + SKILL_NAME)
48
- } catch (err) {
49
- console.warn(`⚠️ ${agentName}: failed — ${err.message}`)
50
- }
51
- }
52
-
53
- // Add locally-installed skill dirs to .gitignore
54
- if (!isGlobal && installedDirs.length > 0) {
55
- const gitignorePath = path.join(process.cwd(), '.gitignore')
56
- let gitignoreContent = ''
57
- try { gitignoreContent = fs.readFileSync(gitignorePath, 'utf8') } catch {}
58
- const linesToAdd = installedDirs.filter(d => !gitignoreContent.includes(d))
59
- if (linesToAdd.length > 0) {
60
- const hasHeader = gitignoreContent.includes('# AI agent skills')
61
- const block = (gitignoreContent && !gitignoreContent.endsWith('\n') ? '\n' : '')
62
- + (hasHeader ? '' : '\n# AI agent skills (auto-generated by setup-skills)\n')
63
- + linesToAdd.join('\n') + '\n'
64
- fs.appendFileSync(gitignorePath, block)
65
- console.log(`📝 Added ${linesToAdd.length} entries to .gitignore`)
66
- }
67
- }
68
-
69
- if (installed === 0 && isPostinstall) {
70
- // Silence is fine — no agents present, nothing to do
71
- } else if (installed === 0 && skipped === Object.keys(AGENTS).length) {
72
- console.log('No agent directories detected. Try --global or run without it for project-local install.')
73
- } else if (installed === 0) {
74
- console.log('Nothing installed. Run without --global to install project-locally.')
75
- } else {
76
- console.log(`\n✨ Done! Restart your AI agent to pick up the "${SKILL_NAME}" skill.`)
77
- }
@@ -1,42 +0,0 @@
1
- ---
2
- name: wooksjs-event-wf
3
- description: Wooks Workflow framework — composable, step-based workflow engine for Node.js. Load when building workflow/process automation with wooks; defining workflow steps and flows; using workflow composables (useWfState, useRouteParams, useLogger); working with @wooksjs/event-core context (key, cached, defineWook, defineEventKind); creating conditional and looping flows; resuming paused workflows; handling user input requirements; using string-based step handlers; attaching workflow spies; working with StepRetriableError; creating custom event context composables for workflows; sharing parent event context (eventContext) for HTTP integration; accessing HTTP composables from workflow steps.
4
- ---
5
-
6
- # @wooksjs/event-wf
7
-
8
- A composable workflow framework for Node.js built on async context (AsyncLocalStorage) and `@prostojs/wf`. Define steps and flows as composable units — steps execute sequentially with conditional branching, loops, pause/resume, and user input handling. Context is scoped per workflow execution.
9
-
10
- ## How to use this skill
11
-
12
- Read the domain file that matches the task. Do not load all files — only what you need.
13
-
14
- | Domain | File | Load when... |
15
- | ------------------------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
16
- | Event context (core machinery) | [event-core.md](event-core.md) | Understanding `EventContext`, `key()`/`cached()`/`defineWook()`/`defineEventKind()`/`slot()`, creating custom composables, lazy evaluation and caching, building your own `use*()` functions |
17
- | Workflow app setup | [core.md](core.md) | Creating a workflow app, `createWfApp`, starting/resuming workflows, error handling, spies, testing, logging, sharing parent event context (`eventContext`), HTTP integration |
18
- | Steps & flows | [workflows.md](workflows.md) | Defining steps (`app.step`), defining flows (`app.flow`), workflow schemas, conditions, loops, user input, parametric steps, `useWfState`, `StepRetriableError`, string-based handlers |
19
-
20
- ## Quick reference
21
-
22
- ```ts
23
- import { createWfApp } from '@wooksjs/event-wf'
24
-
25
- const app = createWfApp<{ result: number }>()
26
-
27
- app.step('add', {
28
- input: 'number',
29
- handler: 'ctx.result += input',
30
- })
31
-
32
- app.flow('calculate', [
33
- { id: 'add', input: 5 },
34
- { id: 'add', input: 2 },
35
- { condition: 'result < 10', steps: [{ id: 'add', input: 3 }] },
36
- ])
37
-
38
- const output = await app.start('calculate', { result: 0 })
39
- console.log(output.state.context) // { result: 10 }
40
- ```
41
-
42
- Key exports: `createWfApp()`, `useWfState()`, `useRouteParams()`, `useLogger()`, `StepRetriableError`.