@wooksjs/event-wf 0.7.9 → 0.7.11

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,38 +167,32 @@ 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);
180
- if (!state) return {
181
- error: "Invalid or expired workflow state",
182
- status: 400
183
- };
174
+ const state = await strategy.consume(token);
175
+ if (!state) {
176
+ response.setStatus(410);
177
+ return { error: "Invalid or expired workflow state" };
178
+ }
184
179
  if (state.schemaId !== (wfid ?? "")) {
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
193
186
  });
194
187
  } else if (wfid) {
195
- if (config.allow?.length && !config.allow.includes(wfid)) return {
196
- error: `Workflow '${wfid}' is not allowed`,
197
- status: 403
198
- };
199
- if (config.block?.includes(wfid)) return {
200
- error: `Workflow '${wfid}' is blocked`,
201
- status: 403
202
- };
188
+ if (config.allow?.length && !config.allow.includes(wfid)) {
189
+ response.setStatus(403);
190
+ return { error: `Workflow '${wfid}' is not allowed` };
191
+ }
192
+ if (config.block?.includes(wfid)) {
193
+ response.setStatus(403);
194
+ return { error: `Workflow '${wfid}' is blocked` };
195
+ }
203
196
  const strategy = resolveStrategy(wfid);
204
197
  ctx.set(stateStrategyKey, strategy);
205
198
  const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
@@ -207,10 +200,10 @@ async function handleWfOutletRequest(config, deps) {
207
200
  input,
208
201
  eventContext: ctx
209
202
  });
210
- } else return {
211
- error: "Missing wfs (state token) or wfid (workflow ID)",
212
- status: 400
213
- };
203
+ } else {
204
+ response.setStatus(400);
205
+ return { error: "Missing wfs (state token) or wfid (workflow ID)" };
206
+ }
214
207
  if (output.finished) {
215
208
  if (config.onFinished) return config.onFinished({
216
209
  context: output.state.context,
@@ -219,32 +212,37 @@ async function handleWfOutletRequest(config, deps) {
219
212
  const finished = ctx.get(wfFinishedKey);
220
213
  if (finished?.cookies) for (const [name, cookie] of Object.entries(finished.cookies)) response.setCookie(name, cookie.value, cookie.options);
221
214
  if (finished?.type === "redirect") {
215
+ response.setStatus(finished.status ?? 302);
222
216
  response.setHeader("location", finished.value);
223
- return { status: finished.status ?? 302 };
217
+ return "";
218
+ }
219
+ if (finished) {
220
+ if (finished.status) response.setStatus(finished.status);
221
+ return finished.value;
224
222
  }
225
- if (finished) return finished.value;
226
223
  return { finished: true };
227
224
  }
228
225
  if (output.inputRequired) {
229
226
  const outletReq = output.inputRequired;
230
227
  const outletHandler = registry.get(outletReq.outlet);
231
- if (!outletHandler) return {
232
- error: `Unknown outlet: '${outletReq.outlet}'`,
233
- status: 500
234
- };
228
+ if (!outletHandler) {
229
+ response.setStatus(500);
230
+ return { error: `Unknown outlet: '${outletReq.outlet}'` };
231
+ }
235
232
  const strategy = ctx.get(stateStrategyKey);
236
233
  const stateWithMeta = {
237
234
  ...output.state,
238
235
  meta: { outlet: outletReq.outlet }
239
236
  };
240
237
  const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0);
241
- if (tokenWrite === "cookie") response.setCookie(tokenName, newToken, {
238
+ const outOfBand = outletHandler.tokenDelivery === "out-of-band";
239
+ if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
242
240
  httpOnly: true,
243
241
  sameSite: "Strict",
244
242
  path: "/"
245
243
  });
246
244
  const result = await outletHandler.deliver(outletReq, newToken);
247
- if (tokenWrite === "body" && result?.response && typeof result.response === "object") return {
245
+ if (tokenWrite === "body" && !outOfBand && result?.response && typeof result.response === "object") return {
248
246
  ...result.response,
249
247
  [tokenName]: newToken
250
248
  };
@@ -274,6 +272,7 @@ async function handleWfOutletRequest(config, deps) {
274
272
  function createHttpOutlet(opts) {
275
273
  return {
276
274
  name: "http",
275
+ tokenDelivery: "caller",
277
276
  async deliver(request, _token) {
278
277
  const body = opts?.transform ? opts.transform(request.payload, request.context) : typeof request.payload === "object" && request.payload !== null ? {
279
278
  ...request.payload,
@@ -302,6 +301,7 @@ function createHttpOutlet(opts) {
302
301
  function createEmailOutlet(send) {
303
302
  return {
304
303
  name: "email",
304
+ tokenDelivery: "out-of-band",
305
305
  async deliver(request, token) {
306
306
  await send({
307
307
  target: request.target ?? "",
@@ -339,7 +339,7 @@ function createOutletHandler(wfApp) {
339
339
  }
340
340
 
341
341
  //#endregion
342
- //#region node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
342
+ //#region node_modules/.pnpm/@prostojs+wf@0.2.0/node_modules/@prostojs/wf/dist/outlets/index.mjs
343
343
  /**
344
344
  * Generic outlet request. Use for custom outlets.
345
345
  *
@@ -391,6 +391,29 @@ function outletEmail(target, template, context) {
391
391
  *
392
392
  * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
393
393
  *
394
+ * ## Security warning — replay
395
+ *
396
+ * This strategy is STATELESS. It cannot enforce single-use semantics:
397
+ * `consume()` is a no-op alias for `retrieve()` because there is no
398
+ * server-side record to delete. Anyone who obtains a copy of the token
399
+ * (browser history, server logs, shoulder-surfing, intermediate proxy,
400
+ * shared device) can replay it until the TTL expires.
401
+ *
402
+ * Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
403
+ *
404
+ * 1. Every workflow step is idempotent — re-executing a step with the same
405
+ * input produces no harmful side effects (pure data collection,
406
+ * validation-only steps).
407
+ * 2. The flow is not security-sensitive — no credential changes, financial
408
+ * operations, account provisioning, permission grants, or any other
409
+ * privileged action.
410
+ *
411
+ * For auth flows (login, password reset, invite accept), financial
412
+ * operations, or anything with meaningful side effects, use
413
+ * `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
414
+ * supports true single-use tokens via atomic `getAndDelete` at the store
415
+ * layer.
416
+ *
394
417
  * @example
395
418
  * const strategy = new EncapsulatedStateStrategy({
396
419
  * secret: crypto.randomBytes(32),
@@ -433,7 +456,14 @@ var EncapsulatedStateStrategy = class {
433
456
  async retrieve(token) {
434
457
  return this.decrypt(token);
435
458
  }
436
- /** Same as retrieve (stateless — cannot truly invalidate a token). */
459
+ /**
460
+ * Stateless — CANNOT invalidate the token. Returns identical result to
461
+ * `retrieve()`. See the class-level security warning.
462
+ *
463
+ * This method exists only to satisfy the `WfStateStrategy` contract.
464
+ * Callers that need true single-use semantics must use
465
+ * `HandleStateStrategy`.
466
+ */
437
467
  async consume(token) {
438
468
  return this.decrypt(token);
439
469
  }
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,38 +144,32 @@ 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);
157
- if (!state) return {
158
- error: "Invalid or expired workflow state",
159
- status: 400
160
- };
151
+ const state = await strategy.consume(token);
152
+ if (!state) {
153
+ response.setStatus(410);
154
+ return { error: "Invalid or expired workflow state" };
155
+ }
161
156
  if (state.schemaId !== (wfid ?? "")) {
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
170
163
  });
171
164
  } else if (wfid) {
172
- if (config.allow?.length && !config.allow.includes(wfid)) return {
173
- error: `Workflow '${wfid}' is not allowed`,
174
- status: 403
175
- };
176
- if (config.block?.includes(wfid)) return {
177
- error: `Workflow '${wfid}' is blocked`,
178
- status: 403
179
- };
165
+ if (config.allow?.length && !config.allow.includes(wfid)) {
166
+ response.setStatus(403);
167
+ return { error: `Workflow '${wfid}' is not allowed` };
168
+ }
169
+ if (config.block?.includes(wfid)) {
170
+ response.setStatus(403);
171
+ return { error: `Workflow '${wfid}' is blocked` };
172
+ }
180
173
  const strategy = resolveStrategy(wfid);
181
174
  ctx.set(stateStrategyKey, strategy);
182
175
  const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
@@ -184,10 +177,10 @@ async function handleWfOutletRequest(config, deps) {
184
177
  input,
185
178
  eventContext: ctx
186
179
  });
187
- } else return {
188
- error: "Missing wfs (state token) or wfid (workflow ID)",
189
- status: 400
190
- };
180
+ } else {
181
+ response.setStatus(400);
182
+ return { error: "Missing wfs (state token) or wfid (workflow ID)" };
183
+ }
191
184
  if (output.finished) {
192
185
  if (config.onFinished) return config.onFinished({
193
186
  context: output.state.context,
@@ -196,32 +189,37 @@ async function handleWfOutletRequest(config, deps) {
196
189
  const finished = ctx.get(wfFinishedKey);
197
190
  if (finished?.cookies) for (const [name, cookie] of Object.entries(finished.cookies)) response.setCookie(name, cookie.value, cookie.options);
198
191
  if (finished?.type === "redirect") {
192
+ response.setStatus(finished.status ?? 302);
199
193
  response.setHeader("location", finished.value);
200
- return { status: finished.status ?? 302 };
194
+ return "";
195
+ }
196
+ if (finished) {
197
+ if (finished.status) response.setStatus(finished.status);
198
+ return finished.value;
201
199
  }
202
- if (finished) return finished.value;
203
200
  return { finished: true };
204
201
  }
205
202
  if (output.inputRequired) {
206
203
  const outletReq = output.inputRequired;
207
204
  const outletHandler = registry.get(outletReq.outlet);
208
- if (!outletHandler) return {
209
- error: `Unknown outlet: '${outletReq.outlet}'`,
210
- status: 500
211
- };
205
+ if (!outletHandler) {
206
+ response.setStatus(500);
207
+ return { error: `Unknown outlet: '${outletReq.outlet}'` };
208
+ }
212
209
  const strategy = ctx.get(stateStrategyKey);
213
210
  const stateWithMeta = {
214
211
  ...output.state,
215
212
  meta: { outlet: outletReq.outlet }
216
213
  };
217
214
  const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0);
218
- if (tokenWrite === "cookie") response.setCookie(tokenName, newToken, {
215
+ const outOfBand = outletHandler.tokenDelivery === "out-of-band";
216
+ if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
219
217
  httpOnly: true,
220
218
  sameSite: "Strict",
221
219
  path: "/"
222
220
  });
223
221
  const result = await outletHandler.deliver(outletReq, newToken);
224
- if (tokenWrite === "body" && result?.response && typeof result.response === "object") return {
222
+ if (tokenWrite === "body" && !outOfBand && result?.response && typeof result.response === "object") return {
225
223
  ...result.response,
226
224
  [tokenName]: newToken
227
225
  };
@@ -251,6 +249,7 @@ async function handleWfOutletRequest(config, deps) {
251
249
  function createHttpOutlet(opts) {
252
250
  return {
253
251
  name: "http",
252
+ tokenDelivery: "caller",
254
253
  async deliver(request, _token) {
255
254
  const body = opts?.transform ? opts.transform(request.payload, request.context) : typeof request.payload === "object" && request.payload !== null ? {
256
255
  ...request.payload,
@@ -279,6 +278,7 @@ function createHttpOutlet(opts) {
279
278
  function createEmailOutlet(send) {
280
279
  return {
281
280
  name: "email",
281
+ tokenDelivery: "out-of-band",
282
282
  async deliver(request, token) {
283
283
  await send({
284
284
  target: request.target ?? "",
@@ -316,7 +316,7 @@ function createOutletHandler(wfApp) {
316
316
  }
317
317
 
318
318
  //#endregion
319
- //#region node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
319
+ //#region node_modules/.pnpm/@prostojs+wf@0.2.0/node_modules/@prostojs/wf/dist/outlets/index.mjs
320
320
  /**
321
321
  * Generic outlet request. Use for custom outlets.
322
322
  *
@@ -368,6 +368,29 @@ function outletEmail(target, template, context) {
368
368
  *
369
369
  * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
370
370
  *
371
+ * ## Security warning — replay
372
+ *
373
+ * This strategy is STATELESS. It cannot enforce single-use semantics:
374
+ * `consume()` is a no-op alias for `retrieve()` because there is no
375
+ * server-side record to delete. Anyone who obtains a copy of the token
376
+ * (browser history, server logs, shoulder-surfing, intermediate proxy,
377
+ * shared device) can replay it until the TTL expires.
378
+ *
379
+ * Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
380
+ *
381
+ * 1. Every workflow step is idempotent — re-executing a step with the same
382
+ * input produces no harmful side effects (pure data collection,
383
+ * validation-only steps).
384
+ * 2. The flow is not security-sensitive — no credential changes, financial
385
+ * operations, account provisioning, permission grants, or any other
386
+ * privileged action.
387
+ *
388
+ * For auth flows (login, password reset, invite accept), financial
389
+ * operations, or anything with meaningful side effects, use
390
+ * `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
391
+ * supports true single-use tokens via atomic `getAndDelete` at the store
392
+ * layer.
393
+ *
371
394
  * @example
372
395
  * const strategy = new EncapsulatedStateStrategy({
373
396
  * secret: crypto.randomBytes(32),
@@ -410,7 +433,14 @@ var EncapsulatedStateStrategy = class {
410
433
  async retrieve(token) {
411
434
  return this.decrypt(token);
412
435
  }
413
- /** Same as retrieve (stateless — cannot truly invalidate a token). */
436
+ /**
437
+ * Stateless — CANNOT invalidate the token. Returns identical result to
438
+ * `retrieve()`. See the class-level security warning.
439
+ *
440
+ * This method exists only to satisfy the `WfStateStrategy` contract.
441
+ * Callers that need true single-use semantics must use
442
+ * `HandleStateStrategy`.
443
+ */
414
444
  async consume(token) {
415
445
  return this.decrypt(token);
416
446
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooksjs/event-wf",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "description": "@wooksjs/event-wf",
5
5
  "keywords": [
6
6
  "app",
@@ -37,22 +37,22 @@
37
37
  }
38
38
  },
39
39
  "dependencies": {
40
- "@prostojs/wf": "^0.1.1"
40
+ "@prostojs/wf": "^0.2.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "typescript": "^5.9.3",
44
44
  "vitest": "^3.2.4",
45
- "@wooksjs/event-core": "^0.7.9",
46
- "@wooksjs/event-http": "^0.7.9",
47
- "@wooksjs/http-body": "^0.7.9",
48
- "wooks": "^0.7.9"
45
+ "@wooksjs/event-core": "^0.7.11",
46
+ "@wooksjs/event-http": "^0.7.11",
47
+ "@wooksjs/http-body": "^0.7.11",
48
+ "wooks": "^0.7.11"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@prostojs/logger": "^0.4.3",
52
- "@wooksjs/event-core": "^0.7.9",
53
- "@wooksjs/event-http": "^0.7.9",
54
- "@wooksjs/http-body": "^0.7.9",
55
- "wooks": "^0.7.9"
52
+ "@wooksjs/event-core": "^0.7.11",
53
+ "@wooksjs/event-http": "^0.7.11",
54
+ "@wooksjs/http-body": "^0.7.11",
55
+ "wooks": "^0.7.11"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "@wooksjs/event-http": {