@wooksjs/event-wf 0.7.14 → 0.7.16

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
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
2
  let _wooksjs_event_core = require("@wooksjs/event-core");
3
3
  let _wooksjs_event_http = require("@wooksjs/event-http");
4
4
  let _wooksjs_http_body = require("@wooksjs/http-body");
5
- let node_crypto = require("node:crypto");
5
+ let _prostojs_wf_outlets = require("@prostojs/wf/outlets");
6
6
  let _prostojs_wf = require("@prostojs/wf");
7
7
  let wooks = require("wooks");
8
8
 
@@ -57,8 +57,6 @@ function resumeWfContext(options, seeds, fn) {
57
57
  //#region packages/event-wf/src/outlets/outlet-context.ts
58
58
  /** Registered outlet handlers, keyed by name */
59
59
  const outletsRegistryKey = (0, _wooksjs_event_core.key)("wf.outlets.registry");
60
- /** Active state strategy for current request */
61
- const stateStrategyKey = (0, _wooksjs_event_core.key)("wf.outlets.stateStrategy");
62
60
  /** Finished response set by workflow steps */
63
61
  const wfFinishedKey = (0, _wooksjs_event_core.key)("wf.outlets.finished");
64
62
 
@@ -72,7 +70,6 @@ const wfFinishedKey = (0, _wooksjs_event_core.key)("wf.outlets.finished");
72
70
  * where steps need to inspect or modify outlet state directly.
73
71
  */
74
72
  const useWfOutlet = (0, _wooksjs_event_core.defineWook)((ctx) => ({
75
- getStateStrategy: () => ctx.get(stateStrategyKey),
76
73
  getOutlets: () => ctx.get(outletsRegistryKey),
77
74
  getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
78
75
  }));
@@ -96,8 +93,81 @@ const useWfFinished = (0, _wooksjs_event_core.defineWook)((ctx) => ({
96
93
  get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
97
94
  }));
98
95
 
96
+ //#endregion
97
+ //#region packages/event-wf/src/strategy-context.ts
98
+ /** Name of the strategy currently active for the running workflow. */
99
+ const stateStrategyNameKey = (0, _wooksjs_event_core.key)("wf.strategyName");
100
+ /** Strategy names must match this regex (validated at swap call + config). */
101
+ const STRATEGY_NAME_RE = /^[A-Za-z0-9_-]+$/;
102
+
103
+ //#endregion
104
+ //#region packages/event-wf/src/outlets/use-wf-strategy.ts
105
+ /**
106
+ * Composable for inspecting / swapping the active state strategy name from
107
+ * within a workflow step. The new name applies to the NEXT pause — it travels
108
+ * back to the outlet trigger via `output.inputRequired.stateStrategy`, and
109
+ * persists in the issued token's prefix.
110
+ *
111
+ * The composable only validates the name FORMAT (regex). Existence in the
112
+ * trigger's strategy registry is validated at pause time by the trigger
113
+ * itself — `swapStrategy('typo')` here will not throw; the trigger will
114
+ * throw on pause when it can't find 'typo' in its registry.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * // Step that escalates from in-memory state to a durable KV store before
119
+ * // pausing for a long-running approval:
120
+ * app.step('await-approval', {
121
+ * handler: () => {
122
+ * swapStrategy('kv')
123
+ * return outletHttp({ fields: ['decision'] })
124
+ * },
125
+ * })
126
+ * ```
127
+ */
128
+ const useWfStrategy = (0, _wooksjs_event_core.defineWook)((ctx) => ({
129
+ current: () => ctx.get(stateStrategyNameKey),
130
+ swap: (name) => {
131
+ if (!STRATEGY_NAME_RE.test(name)) throw new Error(`swapStrategy: invalid name '${name}' — must match ${STRATEGY_NAME_RE}`);
132
+ ctx.set(stateStrategyNameKey, name);
133
+ }
134
+ }));
135
+ /**
136
+ * Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
137
+ * can write `return swapStrategy('kv')` when no outlet pause follows.
138
+ */
139
+ function swapStrategy(name) {
140
+ useWfStrategy().swap(name);
141
+ }
142
+
99
143
  //#endregion
100
144
  //#region packages/event-wf/src/outlets/trigger.ts
145
+ function wrapToken(name, raw) {
146
+ return `${name}.${raw}`;
147
+ }
148
+ function unwrapToken(token) {
149
+ const i = token.indexOf(".");
150
+ if (i <= 0 || i === token.length - 1) return null;
151
+ return {
152
+ name: token.slice(0, i),
153
+ raw: token.slice(i + 1)
154
+ };
155
+ }
156
+ function normalizeStateConfig(state) {
157
+ if (typeof state === "object" && state !== null && "strategies" in state) {
158
+ const registry = state.strategies;
159
+ for (const name of Object.keys(registry)) if (!STRATEGY_NAME_RE.test(name)) throw new Error(`Invalid strategy name '${name}': must match /^[A-Za-z0-9_-]+$/`);
160
+ const def = state.default;
161
+ return {
162
+ registry,
163
+ resolveDefaultName: typeof def === "function" ? def : (_wfid) => def
164
+ };
165
+ }
166
+ return {
167
+ registry: { default: state },
168
+ resolveDefaultName: () => "default"
169
+ };
170
+ }
101
171
  /**
102
172
  * Handle an HTTP request that starts or resumes a workflow.
103
173
  *
@@ -129,6 +199,7 @@ async function handleWfOutletRequest(config, deps) {
129
199
  const registry = new Map(config.outlets.map((o) => [o.name, o]));
130
200
  ctx.set(outletsRegistryKey, registry);
131
201
  ctx.set(wfFinishedKey, void 0);
202
+ const { registry: strategyRegistry, resolveDefaultName } = normalizeStateConfig(config.state);
132
203
  const { parseBody } = (0, _wooksjs_http_body.useBody)();
133
204
  const { params } = (0, _wooksjs_event_http.useUrlParams)();
134
205
  const { getCookie } = (0, _wooksjs_event_http.useCookies)();
@@ -144,27 +215,31 @@ async function handleWfOutletRequest(config, deps) {
144
215
  }
145
216
  const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
146
217
  const input = body?.input;
147
- const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
148
218
  let output;
149
- let strategyReResolved = false;
219
+ let incomingName;
220
+ let incomingRaw;
150
221
  if (token) {
151
- const strategy = resolveStrategy(wfid ?? "");
152
- ctx.set(stateStrategyKey, strategy);
153
- const state = await strategy.consume(token);
222
+ const unwrapped = unwrapToken(token);
223
+ if (!unwrapped) {
224
+ response.setStatus(410);
225
+ return { error: "Invalid workflow state token" };
226
+ }
227
+ const strategy = Object.prototype.hasOwnProperty.call(strategyRegistry, unwrapped.name) ? strategyRegistry[unwrapped.name] : void 0;
228
+ if (!strategy) {
229
+ response.setStatus(410);
230
+ return { error: "Invalid workflow state token" };
231
+ }
232
+ incomingName = unwrapped.name;
233
+ incomingRaw = unwrapped.raw;
234
+ const state = await strategy.consume(unwrapped.raw);
154
235
  if (!state) {
155
236
  response.setStatus(410);
156
237
  return { error: "Invalid or expired workflow state" };
157
238
  }
158
- if (state.schemaId !== (wfid ?? "")) {
159
- const realStrategy = resolveStrategy(state.schemaId);
160
- if (realStrategy !== strategy) {
161
- ctx.set(stateStrategyKey, realStrategy);
162
- strategyReResolved = true;
163
- }
164
- }
165
239
  output = await deps.resume(state, {
166
240
  input,
167
- eventContext: ctx
241
+ eventContext: ctx,
242
+ strategy: { name: incomingName }
168
243
  });
169
244
  } else if (wfid) {
170
245
  if (config.allow?.length && !config.allow.includes(wfid)) {
@@ -175,12 +250,13 @@ async function handleWfOutletRequest(config, deps) {
175
250
  response.setStatus(403);
176
251
  return { error: `Workflow '${wfid}' is blocked` };
177
252
  }
178
- const strategy = resolveStrategy(wfid);
179
- ctx.set(stateStrategyKey, strategy);
253
+ const defaultName = resolveDefaultName(wfid);
254
+ if (!(Object.prototype.hasOwnProperty.call(strategyRegistry, defaultName) ? strategyRegistry[defaultName] : void 0)) throw new Error(`Default strategy '${defaultName}' not found in registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
180
255
  const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
181
256
  output = await deps.start(wfid, initialContext, {
182
257
  input,
183
- eventContext: ctx
258
+ eventContext: ctx,
259
+ strategy: { name: defaultName }
184
260
  });
185
261
  } else {
186
262
  response.setStatus(400);
@@ -211,13 +287,16 @@ async function handleWfOutletRequest(config, deps) {
211
287
  response.setStatus(500);
212
288
  return { error: `Unknown outlet: '${outletReq.outlet}'` };
213
289
  }
214
- const strategy = ctx.get(stateStrategyKey);
290
+ const finalName = outletReq.stateStrategy;
291
+ if (finalName === void 0) throw new Error("Workflow paused without `stateStrategy` on inputRequired — the WF adapter must augment the output with the active strategy name.");
292
+ const finalStrategy = Object.prototype.hasOwnProperty.call(strategyRegistry, finalName) ? strategyRegistry[finalName] : void 0;
293
+ if (!finalStrategy) throw new Error(`Workflow paused with unknown strategy '${finalName}' — step swapped to a name not in the trigger's registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
215
294
  const stateWithMeta = {
216
295
  ...output.state,
217
296
  meta: { outlet: outletReq.outlet }
218
297
  };
219
- const reuseHandle = token && !strategyReResolved ? { handle: token } : void 0;
220
- const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle);
298
+ const reuseHandle = incomingName !== void 0 && incomingName === finalName && incomingRaw !== void 0 ? { handle: incomingRaw } : void 0;
299
+ const newToken = wrapToken(finalName, await finalStrategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle));
221
300
  const outOfBand = outletHandler.tokenDelivery === "out-of-band";
222
301
  if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
223
302
  httpOnly: true,
@@ -320,219 +399,6 @@ function createOutletHandler(wfApp) {
320
399
  });
321
400
  }
322
401
 
323
- //#endregion
324
- //#region node_modules/.pnpm/@prostojs+wf@0.2.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
325
- /**
326
- * Generic outlet request. Use for custom outlets.
327
- *
328
- * @example
329
- * return outlet('pending-task', {
330
- * payload: ApprovalForm,
331
- * target: managerId,
332
- * context: { orderId, amount },
333
- * })
334
- */
335
- function outlet(name, data) {
336
- return { inputRequired: {
337
- outlet: name,
338
- ...data
339
- } };
340
- }
341
- /**
342
- * Pause for HTTP form input. The outlet returns the payload (form definition)
343
- * and state token in the HTTP response.
344
- *
345
- * @example
346
- * return outletHttp(LoginForm)
347
- * return outletHttp(LoginForm, { error: 'Invalid credentials' })
348
- */
349
- function outletHttp(payload, context) {
350
- return outlet("http", {
351
- payload,
352
- context
353
- });
354
- }
355
- /**
356
- * Pause and send email with a magic link containing the state token.
357
- *
358
- * @example
359
- * return outletEmail('user@test.com', 'invite', { name: 'Alice' })
360
- */
361
- function outletEmail(target, template, context) {
362
- return outlet("email", {
363
- target,
364
- template,
365
- context
366
- });
367
- }
368
- /**
369
- * Self-contained AES-256-GCM encrypted state strategy.
370
- *
371
- * Workflow state is encrypted into a base64url token that travels with the
372
- * transport (cookie, URL param, hidden field). No server-side storage needed.
373
- *
374
- * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
375
- *
376
- * ## Security warning — replay
377
- *
378
- * This strategy is STATELESS. It cannot enforce single-use semantics:
379
- * `consume()` is a no-op alias for `retrieve()` because there is no
380
- * server-side record to delete. Anyone who obtains a copy of the token
381
- * (browser history, server logs, shoulder-surfing, intermediate proxy,
382
- * shared device) can replay it until the TTL expires.
383
- *
384
- * Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
385
- *
386
- * 1. Every workflow step is idempotent — re-executing a step with the same
387
- * input produces no harmful side effects (pure data collection,
388
- * validation-only steps).
389
- * 2. The flow is not security-sensitive — no credential changes, financial
390
- * operations, account provisioning, permission grants, or any other
391
- * privileged action.
392
- *
393
- * For auth flows (login, password reset, invite accept), financial
394
- * operations, or anything with meaningful side effects, use
395
- * `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
396
- * supports true single-use tokens via atomic `getAndDelete` at the store
397
- * layer.
398
- *
399
- * @example
400
- * const strategy = new EncapsulatedStateStrategy({
401
- * secret: crypto.randomBytes(32),
402
- * defaultTtl: 3600_000, // 1 hour
403
- * });
404
- * const token = await strategy.persist(state);
405
- * const recovered = await strategy.retrieve(token);
406
- */
407
- var EncapsulatedStateStrategy = class {
408
- /** @throws if secret is not exactly 32 bytes */
409
- constructor(config) {
410
- this.config = config;
411
- this.key = typeof config.secret === "string" ? Buffer.from(config.secret, "hex") : config.secret;
412
- if (this.key.length !== 32) throw new Error("EncapsulatedStateStrategy: secret must be exactly 32 bytes");
413
- }
414
- /**
415
- * Encrypt workflow state into a self-contained token.
416
- *
417
- * NOTE: the `overrides.handle` hint from `WfStateStrategy` is silently
418
- * ignored — the encapsulated token IS the ciphertext of the state, so a
419
- * fixed handle cannot map to changing state. Callers that need handle
420
- * stability across calls must use `HandleStateStrategy`.
421
- *
422
- * @param state — workflow state to persist
423
- * @param options.ttl — time-to-live in ms (overrides defaultTtl)
424
- * @returns base64url-encoded encrypted token
425
- */
426
- async persist(state, options, _overrides) {
427
- const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
428
- const exp = ttl > 0 ? Date.now() + ttl : 0;
429
- const payload = JSON.stringify({
430
- s: state,
431
- e: exp
432
- });
433
- const iv = (0, node_crypto.randomBytes)(12);
434
- const cipher = (0, node_crypto.createCipheriv)("aes-256-gcm", this.key, iv);
435
- const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
436
- const tag = cipher.getAuthTag();
437
- return Buffer.concat([
438
- iv,
439
- tag,
440
- encrypted
441
- ]).toString("base64url");
442
- }
443
- /** Decrypt and return workflow state. Returns null if token is invalid, expired, or tampered. */
444
- async retrieve(token) {
445
- return this.decrypt(token);
446
- }
447
- /**
448
- * Stateless — CANNOT invalidate the token. Returns identical result to
449
- * `retrieve()`. See the class-level security warning.
450
- *
451
- * This method exists only to satisfy the `WfStateStrategy` contract.
452
- * Callers that need true single-use semantics must use
453
- * `HandleStateStrategy`.
454
- */
455
- async consume(token) {
456
- return this.decrypt(token);
457
- }
458
- decrypt(token) {
459
- try {
460
- const buf = Buffer.from(token, "base64url");
461
- if (buf.length < 28) return null;
462
- const iv = buf.subarray(0, 12);
463
- const tag = buf.subarray(12, 28);
464
- const ciphertext = buf.subarray(28);
465
- const decipher = (0, node_crypto.createDecipheriv)("aes-256-gcm", this.key, iv);
466
- decipher.setAuthTag(tag);
467
- const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
468
- const { s: state, e: exp } = JSON.parse(decrypted.toString("utf8"));
469
- if (exp > 0 && Date.now() > exp) return null;
470
- return state;
471
- } catch {
472
- return null;
473
- }
474
- }
475
- };
476
- var HandleStateStrategy = class {
477
- constructor(config) {
478
- this.config = config;
479
- }
480
- async persist(state, options, overrides) {
481
- const handle = overrides?.handle ?? (this.config.generateHandle ?? node_crypto.randomUUID)();
482
- const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
483
- const expiresAt = ttl > 0 ? Date.now() + ttl : void 0;
484
- await this.config.store.set(handle, state, expiresAt);
485
- return handle;
486
- }
487
- async retrieve(token) {
488
- return (await this.config.store.get(token))?.state ?? null;
489
- }
490
- async consume(token) {
491
- return (await this.config.store.getAndDelete(token))?.state ?? null;
492
- }
493
- };
494
- /**
495
- * In-memory state store for development and testing.
496
- * State is lost on process restart.
497
- */
498
- var WfStateStoreMemory = class {
499
- constructor() {
500
- this.store = /* @__PURE__ */ new Map();
501
- }
502
- async set(handle, state, expiresAt) {
503
- this.store.set(handle, {
504
- state,
505
- expiresAt
506
- });
507
- }
508
- async get(handle) {
509
- const entry = this.store.get(handle);
510
- if (!entry) return null;
511
- if (entry.expiresAt && Date.now() > entry.expiresAt) {
512
- this.store.delete(handle);
513
- return null;
514
- }
515
- return entry;
516
- }
517
- async delete(handle) {
518
- this.store.delete(handle);
519
- }
520
- async getAndDelete(handle) {
521
- const entry = await this.get(handle);
522
- if (entry) this.store.delete(handle);
523
- return entry;
524
- }
525
- async cleanup() {
526
- const now = Date.now();
527
- let count = 0;
528
- for (const [handle, entry] of this.store) if (entry.expiresAt && now > entry.expiresAt) {
529
- this.store.delete(handle);
530
- count++;
531
- }
532
- return count;
533
- }
534
- };
535
-
536
402
  //#endregion
537
403
  //#region packages/event-wf/src/workflow.ts
538
404
  /** Workflow engine that resolves steps via Wooks router lookup. */
@@ -640,6 +506,7 @@ var WooksWf = class extends wooks.WooksAdapterBase {
640
506
  indexes,
641
507
  input
642
508
  }, async () => {
509
+ if (opts?.strategy?.name !== void 0) (0, _wooksjs_event_core.current)().set(stateStrategyNameKey, opts.strategy.name);
643
510
  const { handlers: foundHandlers } = this.wooks.lookup("WF_FLOW", `/${schemaId}`.replace(/^\/+/u, "/"));
644
511
  const handlers = foundHandlers || this.opts?.onNotFound && [this.opts.onNotFound] || null;
645
512
  if (handlers && handlers.length > 0) {
@@ -673,6 +540,10 @@ var WooksWf = class extends wooks.WooksAdapterBase {
673
540
  throw error;
674
541
  }
675
542
  clean();
543
+ if (result.inputRequired) {
544
+ const finalName = (0, _wooksjs_event_core.current)().get(stateStrategyNameKey);
545
+ if (finalName !== void 0) result.inputRequired.stateStrategy = finalName;
546
+ }
676
547
  if (result.resume) result.resume = (_input) => this.resume(result.state, {
677
548
  input: _input,
678
549
  spy,
@@ -717,15 +588,30 @@ function createWfApp(opts, wooks$2) {
717
588
  }
718
589
 
719
590
  //#endregion
720
- exports.EncapsulatedStateStrategy = EncapsulatedStateStrategy;
721
- exports.HandleStateStrategy = HandleStateStrategy;
591
+ Object.defineProperty(exports, 'EncapsulatedStateStrategy', {
592
+ enumerable: true,
593
+ get: function () {
594
+ return _prostojs_wf_outlets.EncapsulatedStateStrategy;
595
+ }
596
+ });
597
+ Object.defineProperty(exports, 'HandleStateStrategy', {
598
+ enumerable: true,
599
+ get: function () {
600
+ return _prostojs_wf_outlets.HandleStateStrategy;
601
+ }
602
+ });
722
603
  Object.defineProperty(exports, 'StepRetriableError', {
723
604
  enumerable: true,
724
605
  get: function () {
725
606
  return _prostojs_wf.StepRetriableError;
726
607
  }
727
608
  });
728
- exports.WfStateStoreMemory = WfStateStoreMemory;
609
+ Object.defineProperty(exports, 'WfStateStoreMemory', {
610
+ enumerable: true,
611
+ get: function () {
612
+ return _prostojs_wf_outlets.WfStateStoreMemory;
613
+ }
614
+ });
729
615
  exports.WooksWf = WooksWf;
730
616
  exports.createEmailOutlet = createEmailOutlet;
731
617
  exports.createHttpOutlet = createHttpOutlet;
@@ -733,11 +619,27 @@ exports.createOutletHandler = createOutletHandler;
733
619
  exports.createWfApp = createWfApp;
734
620
  exports.createWfContext = createWfContext;
735
621
  exports.handleWfOutletRequest = handleWfOutletRequest;
736
- exports.outlet = outlet;
737
- exports.outletEmail = outletEmail;
738
- exports.outletHttp = outletHttp;
622
+ Object.defineProperty(exports, 'outlet', {
623
+ enumerable: true,
624
+ get: function () {
625
+ return _prostojs_wf_outlets.outlet;
626
+ }
627
+ });
628
+ Object.defineProperty(exports, 'outletEmail', {
629
+ enumerable: true,
630
+ get: function () {
631
+ return _prostojs_wf_outlets.outletEmail;
632
+ }
633
+ });
634
+ Object.defineProperty(exports, 'outletHttp', {
635
+ enumerable: true,
636
+ get: function () {
637
+ return _prostojs_wf_outlets.outletHttp;
638
+ }
639
+ });
739
640
  exports.resumeKey = resumeKey;
740
641
  exports.resumeWfContext = resumeWfContext;
642
+ exports.swapStrategy = swapStrategy;
741
643
  Object.defineProperty(exports, 'useLogger', {
742
644
  enumerable: true,
743
645
  get: function () {
@@ -753,5 +655,6 @@ Object.defineProperty(exports, 'useRouteParams', {
753
655
  exports.useWfFinished = useWfFinished;
754
656
  exports.useWfOutlet = useWfOutlet;
755
657
  exports.useWfState = useWfState;
658
+ exports.useWfStrategy = useWfStrategy;
756
659
  exports.wfKind = wfKind;
757
660
  exports.wfShortcuts = wfShortcuts;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as _wooksjs_event_core from '@wooksjs/event-core';
2
2
  import { EventContextOptions, EventKindSeeds, EventContext } from '@wooksjs/event-core';
3
3
  export { EventContext, EventContextOptions, useLogger, useRouteParams } from '@wooksjs/event-core';
4
4
  import * as _prostojs_wf_outlets from '@prostojs/wf/outlets';
5
- import { WfStateStrategy, WfOutlet, WfOutletRequest } from '@prostojs/wf/outlets';
5
+ import { WfOutletRequest, WfStateStrategy, WfOutlet } from '@prostojs/wf/outlets';
6
6
  export { EncapsulatedStateStrategy, HandleStateStrategy, WfOutlet, WfOutletRequest, WfOutletResult, WfState, WfStateStore, WfStateStoreMemory, WfStateStrategy, outlet, outletEmail, outletHttp } from '@prostojs/wf/outlets';
7
7
  import { TFlowOutput, Workflow, Step, TWorkflowSpy, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
8
8
  export { StepRetriableError, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
@@ -50,7 +50,6 @@ declare function resumeWfContext<R>(options: EventContextOptions, seeds: EventKi
50
50
  * where steps need to inspect or modify outlet state directly.
51
51
  */
52
52
  declare const useWfOutlet: _wooksjs_event_core.WookComposable<{
53
- getStateStrategy: () => _prostojs_wf_outlets.WfStateStrategy;
54
53
  getOutlets: () => Map<string, _prostojs_wf_outlets.WfOutlet>;
55
54
  getOutlet: (name: string) => _prostojs_wf_outlets.WfOutlet | null;
56
55
  }>;
@@ -85,6 +84,58 @@ declare const useWfFinished: _wooksjs_event_core.WookComposable<{
85
84
  get: () => WfFinishedResponse | undefined;
86
85
  }>;
87
86
 
87
+ /**
88
+ * Composable for inspecting / swapping the active state strategy name from
89
+ * within a workflow step. The new name applies to the NEXT pause — it travels
90
+ * back to the outlet trigger via `output.inputRequired.stateStrategy`, and
91
+ * persists in the issued token's prefix.
92
+ *
93
+ * The composable only validates the name FORMAT (regex). Existence in the
94
+ * trigger's strategy registry is validated at pause time by the trigger
95
+ * itself — `swapStrategy('typo')` here will not throw; the trigger will
96
+ * throw on pause when it can't find 'typo' in its registry.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * // Step that escalates from in-memory state to a durable KV store before
101
+ * // pausing for a long-running approval:
102
+ * app.step('await-approval', {
103
+ * handler: () => {
104
+ * swapStrategy('kv')
105
+ * return outletHttp({ fields: ['decision'] })
106
+ * },
107
+ * })
108
+ * ```
109
+ */
110
+ declare const useWfStrategy: _wooksjs_event_core.WookComposable<{
111
+ /** Name of the strategy currently set for the next persist. */
112
+ current: () => string;
113
+ /**
114
+ * Swap the active strategy by name. Validates name format only; unknown
115
+ * names surface as a loud error at pause time from the trigger.
116
+ */
117
+ swap: (name: string) => void;
118
+ }>;
119
+ /**
120
+ * Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
121
+ * can write `return swapStrategy('kv')` when no outlet pause follows.
122
+ */
123
+ declare function swapStrategy(name: string): undefined;
124
+
125
+ /**
126
+ * `WfOutletRequest` extended with the strategy name that was active when the
127
+ * workflow paused. The WF adapter augments `output.inputRequired` with this
128
+ * field so callers can persist the next token under the post-swap strategy
129
+ * without depending on EventContext write-through.
130
+ *
131
+ * The field is named generically (`stateStrategy`) — outlet handlers that
132
+ * don't care about strategies ignore it; outlet triggers and offline
133
+ * resume drivers read it to look up the right strategy in their registry.
134
+ */
135
+ type WfPauseRequest<P = unknown> = WfOutletRequest<P> & {
136
+ stateStrategy?: string;
137
+ };
138
+
88
139
  interface WfOutletTokenConfig {
89
140
  /** Where to read state token from incoming request (default: `['body', 'query', 'cookie']`) */
90
141
  read?: Array<'body' | 'query' | 'cookie'>;
@@ -99,26 +150,28 @@ interface WfOutletTriggerConfig {
99
150
  /** Blacklist of workflow IDs. Checked after allow. */
100
151
  block?: string[];
101
152
  /**
102
- * State persistence strategy. Either a single strategy shared by all
103
- * workflows, or a function that returns a strategy per workflow ID.
153
+ * State persistence strategy. Two forms:
104
154
  *
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:
155
+ * - **Single-strategy shortcut**: pass a `WfStateStrategy` directly. The
156
+ * trigger registers it internally under the name `'default'`.
157
+ * - **Named map**: `{ strategies, default }` where `strategies` is a
158
+ * `Record<name, WfStateStrategy>` and `default` is either the name to use
159
+ * on workflow start or a function `(wfid) => name` that picks per
160
+ * workflow id. Steps may then call `swapStrategy(name)` to escalate the
161
+ * *next* outlet pause to a different strategy.
109
162
  *
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.
163
+ * The active strategy name is embedded in the issued token as `<name>.<raw>`,
164
+ * so resume always picks the strategy that persisted the state. Each
165
+ * strategy can therefore have its own independent storage (no need for
166
+ * shared keyspaces between strategies).
116
167
  *
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.
168
+ * Strategy names must match `/^[A-Za-z0-9_-]+$/` (validated at trigger
169
+ * invocation).
120
170
  */
121
- state: WfStateStrategy | ((wfid: string) => WfStateStrategy);
171
+ state: WfStateStrategy | {
172
+ strategies: Record<string, WfStateStrategy>;
173
+ default: string | ((wfid: string) => string);
174
+ };
122
175
  /** Registered outlets */
123
176
  outlets: WfOutlet[];
124
177
  /** Token configuration (reading, writing, naming) */
@@ -148,7 +201,10 @@ interface WfOutletTriggerDeps {
148
201
  start: (schemaId: string, context: unknown, opts?: {
149
202
  input?: unknown;
150
203
  eventContext?: unknown;
151
- }) => Promise<TFlowOutput<unknown, unknown, WfOutletRequest>>;
204
+ strategy?: {
205
+ name: string;
206
+ };
207
+ }) => Promise<TFlowOutput<unknown, unknown, WfPauseRequest>>;
152
208
  /** Resume a workflow. Provided by WooksWf or MoostWf. */
153
209
  resume: (state: {
154
210
  schemaId: string;
@@ -157,7 +213,10 @@ interface WfOutletTriggerDeps {
157
213
  }, opts?: {
158
214
  input?: unknown;
159
215
  eventContext?: unknown;
160
- }) => Promise<TFlowOutput<unknown, unknown, WfOutletRequest>>;
216
+ strategy?: {
217
+ name: string;
218
+ };
219
+ }) => Promise<TFlowOutput<unknown, unknown, WfPauseRequest>>;
161
220
  }
162
221
 
163
222
  /**
@@ -281,6 +340,20 @@ interface TWfRunOptions<I = unknown, T = unknown, IR = unknown> {
281
340
  * Pass `current()` from within an active event scope (HTTP handler, etc.).
282
341
  */
283
342
  eventContext?: EventContext;
343
+ /**
344
+ * Initial state strategy for the workflow run. Sets the strategy name on
345
+ * the WF event context so steps can inspect it via `useWfStrategy().current()`
346
+ * and swap it via `useWfStrategy().swap(name)`. The final post-swap name
347
+ * is reflected on `output.inputRequired.stateStrategy` (when paused) so
348
+ * callers can persist under the right keyspace without depending on
349
+ * EventContext write-through.
350
+ *
351
+ * The adapter only carries the name — strategy instances live in the
352
+ * caller (HTTP trigger, offline driver, etc.).
353
+ */
354
+ strategy?: {
355
+ name: string;
356
+ };
284
357
  }
285
358
  /** Wooks adapter for defining and executing workflow schemas with step-based routing. */
286
359
  declare class WooksWf<T = any, IR = any> extends WooksAdapterBase {
@@ -349,5 +422,5 @@ declare class WooksWf<T = any, IR = any> extends WooksAdapterBase {
349
422
  */
350
423
  declare function createWfApp<T>(opts?: TWooksWfOptions, wooks?: Wooks | WooksAdapterBase): WooksWf<T, any>;
351
424
 
352
- export { WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, resumeKey, resumeWfContext, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
353
- export type { TWFEventInput, TWfRunOptions, TWooksWfOptions, WfFinishedResponse, WfOutletTokenConfig, WfOutletTriggerConfig, WfOutletTriggerDeps };
425
+ export { WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, resumeKey, resumeWfContext, swapStrategy, useWfFinished, useWfOutlet, useWfState, useWfStrategy, wfKind, wfShortcuts };
426
+ export type { TWFEventInput, TWfRunOptions, TWooksWfOptions, WfFinishedResponse, WfOutletTokenConfig, WfOutletTriggerConfig, WfOutletTriggerDeps, WfPauseRequest };
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createEventContext, current, defineEventKind, defineWook, key, slot, useLogger, useRouteParams } from "@wooksjs/event-core";
2
2
  import { useCookies, useResponse, useUrlParams } from "@wooksjs/event-http";
3
3
  import { useBody } from "@wooksjs/http-body";
4
- import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
4
+ import { EncapsulatedStateStrategy, HandleStateStrategy, WfStateStoreMemory, outlet, outletEmail, outletHttp } from "@prostojs/wf/outlets";
5
5
  import { StepRetriableError, Workflow, createStep } from "@prostojs/wf";
6
6
  import { WooksAdapterBase } from "wooks";
7
7
 
@@ -56,8 +56,6 @@ function resumeWfContext(options, seeds, fn) {
56
56
  //#region packages/event-wf/src/outlets/outlet-context.ts
57
57
  /** Registered outlet handlers, keyed by name */
58
58
  const outletsRegistryKey = key("wf.outlets.registry");
59
- /** Active state strategy for current request */
60
- const stateStrategyKey = key("wf.outlets.stateStrategy");
61
59
  /** Finished response set by workflow steps */
62
60
  const wfFinishedKey = key("wf.outlets.finished");
63
61
 
@@ -71,7 +69,6 @@ const wfFinishedKey = key("wf.outlets.finished");
71
69
  * where steps need to inspect or modify outlet state directly.
72
70
  */
73
71
  const useWfOutlet = defineWook((ctx) => ({
74
- getStateStrategy: () => ctx.get(stateStrategyKey),
75
72
  getOutlets: () => ctx.get(outletsRegistryKey),
76
73
  getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
77
74
  }));
@@ -95,8 +92,81 @@ const useWfFinished = defineWook((ctx) => ({
95
92
  get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
96
93
  }));
97
94
 
95
+ //#endregion
96
+ //#region packages/event-wf/src/strategy-context.ts
97
+ /** Name of the strategy currently active for the running workflow. */
98
+ const stateStrategyNameKey = key("wf.strategyName");
99
+ /** Strategy names must match this regex (validated at swap call + config). */
100
+ const STRATEGY_NAME_RE = /^[A-Za-z0-9_-]+$/;
101
+
102
+ //#endregion
103
+ //#region packages/event-wf/src/outlets/use-wf-strategy.ts
104
+ /**
105
+ * Composable for inspecting / swapping the active state strategy name from
106
+ * within a workflow step. The new name applies to the NEXT pause — it travels
107
+ * back to the outlet trigger via `output.inputRequired.stateStrategy`, and
108
+ * persists in the issued token's prefix.
109
+ *
110
+ * The composable only validates the name FORMAT (regex). Existence in the
111
+ * trigger's strategy registry is validated at pause time by the trigger
112
+ * itself — `swapStrategy('typo')` here will not throw; the trigger will
113
+ * throw on pause when it can't find 'typo' in its registry.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * // Step that escalates from in-memory state to a durable KV store before
118
+ * // pausing for a long-running approval:
119
+ * app.step('await-approval', {
120
+ * handler: () => {
121
+ * swapStrategy('kv')
122
+ * return outletHttp({ fields: ['decision'] })
123
+ * },
124
+ * })
125
+ * ```
126
+ */
127
+ const useWfStrategy = defineWook((ctx) => ({
128
+ current: () => ctx.get(stateStrategyNameKey),
129
+ swap: (name) => {
130
+ if (!STRATEGY_NAME_RE.test(name)) throw new Error(`swapStrategy: invalid name '${name}' — must match ${STRATEGY_NAME_RE}`);
131
+ ctx.set(stateStrategyNameKey, name);
132
+ }
133
+ }));
134
+ /**
135
+ * Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
136
+ * can write `return swapStrategy('kv')` when no outlet pause follows.
137
+ */
138
+ function swapStrategy(name) {
139
+ useWfStrategy().swap(name);
140
+ }
141
+
98
142
  //#endregion
99
143
  //#region packages/event-wf/src/outlets/trigger.ts
144
+ function wrapToken(name, raw) {
145
+ return `${name}.${raw}`;
146
+ }
147
+ function unwrapToken(token) {
148
+ const i = token.indexOf(".");
149
+ if (i <= 0 || i === token.length - 1) return null;
150
+ return {
151
+ name: token.slice(0, i),
152
+ raw: token.slice(i + 1)
153
+ };
154
+ }
155
+ function normalizeStateConfig(state) {
156
+ if (typeof state === "object" && state !== null && "strategies" in state) {
157
+ const registry = state.strategies;
158
+ for (const name of Object.keys(registry)) if (!STRATEGY_NAME_RE.test(name)) throw new Error(`Invalid strategy name '${name}': must match /^[A-Za-z0-9_-]+$/`);
159
+ const def = state.default;
160
+ return {
161
+ registry,
162
+ resolveDefaultName: typeof def === "function" ? def : (_wfid) => def
163
+ };
164
+ }
165
+ return {
166
+ registry: { default: state },
167
+ resolveDefaultName: () => "default"
168
+ };
169
+ }
100
170
  /**
101
171
  * Handle an HTTP request that starts or resumes a workflow.
102
172
  *
@@ -128,6 +198,7 @@ async function handleWfOutletRequest(config, deps) {
128
198
  const registry = new Map(config.outlets.map((o) => [o.name, o]));
129
199
  ctx.set(outletsRegistryKey, registry);
130
200
  ctx.set(wfFinishedKey, void 0);
201
+ const { registry: strategyRegistry, resolveDefaultName } = normalizeStateConfig(config.state);
131
202
  const { parseBody } = useBody();
132
203
  const { params } = useUrlParams();
133
204
  const { getCookie } = useCookies();
@@ -143,27 +214,31 @@ async function handleWfOutletRequest(config, deps) {
143
214
  }
144
215
  const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
145
216
  const input = body?.input;
146
- const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
147
217
  let output;
148
- let strategyReResolved = false;
218
+ let incomingName;
219
+ let incomingRaw;
149
220
  if (token) {
150
- const strategy = resolveStrategy(wfid ?? "");
151
- ctx.set(stateStrategyKey, strategy);
152
- const state = await strategy.consume(token);
221
+ const unwrapped = unwrapToken(token);
222
+ if (!unwrapped) {
223
+ response.setStatus(410);
224
+ return { error: "Invalid workflow state token" };
225
+ }
226
+ const strategy = Object.prototype.hasOwnProperty.call(strategyRegistry, unwrapped.name) ? strategyRegistry[unwrapped.name] : void 0;
227
+ if (!strategy) {
228
+ response.setStatus(410);
229
+ return { error: "Invalid workflow state token" };
230
+ }
231
+ incomingName = unwrapped.name;
232
+ incomingRaw = unwrapped.raw;
233
+ const state = await strategy.consume(unwrapped.raw);
153
234
  if (!state) {
154
235
  response.setStatus(410);
155
236
  return { error: "Invalid or expired workflow state" };
156
237
  }
157
- if (state.schemaId !== (wfid ?? "")) {
158
- const realStrategy = resolveStrategy(state.schemaId);
159
- if (realStrategy !== strategy) {
160
- ctx.set(stateStrategyKey, realStrategy);
161
- strategyReResolved = true;
162
- }
163
- }
164
238
  output = await deps.resume(state, {
165
239
  input,
166
- eventContext: ctx
240
+ eventContext: ctx,
241
+ strategy: { name: incomingName }
167
242
  });
168
243
  } else if (wfid) {
169
244
  if (config.allow?.length && !config.allow.includes(wfid)) {
@@ -174,12 +249,13 @@ async function handleWfOutletRequest(config, deps) {
174
249
  response.setStatus(403);
175
250
  return { error: `Workflow '${wfid}' is blocked` };
176
251
  }
177
- const strategy = resolveStrategy(wfid);
178
- ctx.set(stateStrategyKey, strategy);
252
+ const defaultName = resolveDefaultName(wfid);
253
+ if (!(Object.prototype.hasOwnProperty.call(strategyRegistry, defaultName) ? strategyRegistry[defaultName] : void 0)) throw new Error(`Default strategy '${defaultName}' not found in registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
179
254
  const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
180
255
  output = await deps.start(wfid, initialContext, {
181
256
  input,
182
- eventContext: ctx
257
+ eventContext: ctx,
258
+ strategy: { name: defaultName }
183
259
  });
184
260
  } else {
185
261
  response.setStatus(400);
@@ -210,13 +286,16 @@ async function handleWfOutletRequest(config, deps) {
210
286
  response.setStatus(500);
211
287
  return { error: `Unknown outlet: '${outletReq.outlet}'` };
212
288
  }
213
- const strategy = ctx.get(stateStrategyKey);
289
+ const finalName = outletReq.stateStrategy;
290
+ if (finalName === void 0) throw new Error("Workflow paused without `stateStrategy` on inputRequired — the WF adapter must augment the output with the active strategy name.");
291
+ const finalStrategy = Object.prototype.hasOwnProperty.call(strategyRegistry, finalName) ? strategyRegistry[finalName] : void 0;
292
+ if (!finalStrategy) throw new Error(`Workflow paused with unknown strategy '${finalName}' — step swapped to a name not in the trigger's registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
214
293
  const stateWithMeta = {
215
294
  ...output.state,
216
295
  meta: { outlet: outletReq.outlet }
217
296
  };
218
- const reuseHandle = token && !strategyReResolved ? { handle: token } : void 0;
219
- const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle);
297
+ const reuseHandle = incomingName !== void 0 && incomingName === finalName && incomingRaw !== void 0 ? { handle: incomingRaw } : void 0;
298
+ const newToken = wrapToken(finalName, await finalStrategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle));
220
299
  const outOfBand = outletHandler.tokenDelivery === "out-of-band";
221
300
  if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
222
301
  httpOnly: true,
@@ -319,219 +398,6 @@ function createOutletHandler(wfApp) {
319
398
  });
320
399
  }
321
400
 
322
- //#endregion
323
- //#region node_modules/.pnpm/@prostojs+wf@0.2.1/node_modules/@prostojs/wf/dist/outlets/index.mjs
324
- /**
325
- * Generic outlet request. Use for custom outlets.
326
- *
327
- * @example
328
- * return outlet('pending-task', {
329
- * payload: ApprovalForm,
330
- * target: managerId,
331
- * context: { orderId, amount },
332
- * })
333
- */
334
- function outlet(name, data) {
335
- return { inputRequired: {
336
- outlet: name,
337
- ...data
338
- } };
339
- }
340
- /**
341
- * Pause for HTTP form input. The outlet returns the payload (form definition)
342
- * and state token in the HTTP response.
343
- *
344
- * @example
345
- * return outletHttp(LoginForm)
346
- * return outletHttp(LoginForm, { error: 'Invalid credentials' })
347
- */
348
- function outletHttp(payload, context) {
349
- return outlet("http", {
350
- payload,
351
- context
352
- });
353
- }
354
- /**
355
- * Pause and send email with a magic link containing the state token.
356
- *
357
- * @example
358
- * return outletEmail('user@test.com', 'invite', { name: 'Alice' })
359
- */
360
- function outletEmail(target, template, context) {
361
- return outlet("email", {
362
- target,
363
- template,
364
- context
365
- });
366
- }
367
- /**
368
- * Self-contained AES-256-GCM encrypted state strategy.
369
- *
370
- * Workflow state is encrypted into a base64url token that travels with the
371
- * transport (cookie, URL param, hidden field). No server-side storage needed.
372
- *
373
- * Token format: `base64url(iv[12] + authTag[16] + ciphertext)`
374
- *
375
- * ## Security warning — replay
376
- *
377
- * This strategy is STATELESS. It cannot enforce single-use semantics:
378
- * `consume()` is a no-op alias for `retrieve()` because there is no
379
- * server-side record to delete. Anyone who obtains a copy of the token
380
- * (browser history, server logs, shoulder-surfing, intermediate proxy,
381
- * shared device) can replay it until the TTL expires.
382
- *
383
- * Use `EncapsulatedStateStrategy` ONLY when BOTH of the following hold:
384
- *
385
- * 1. Every workflow step is idempotent — re-executing a step with the same
386
- * input produces no harmful side effects (pure data collection,
387
- * validation-only steps).
388
- * 2. The flow is not security-sensitive — no credential changes, financial
389
- * operations, account provisioning, permission grants, or any other
390
- * privileged action.
391
- *
392
- * For auth flows (login, password reset, invite accept), financial
393
- * operations, or anything with meaningful side effects, use
394
- * `HandleStateStrategy` with a durable `WfStateStore`. `HandleStateStrategy`
395
- * supports true single-use tokens via atomic `getAndDelete` at the store
396
- * layer.
397
- *
398
- * @example
399
- * const strategy = new EncapsulatedStateStrategy({
400
- * secret: crypto.randomBytes(32),
401
- * defaultTtl: 3600_000, // 1 hour
402
- * });
403
- * const token = await strategy.persist(state);
404
- * const recovered = await strategy.retrieve(token);
405
- */
406
- var EncapsulatedStateStrategy = class {
407
- /** @throws if secret is not exactly 32 bytes */
408
- constructor(config) {
409
- this.config = config;
410
- this.key = typeof config.secret === "string" ? Buffer.from(config.secret, "hex") : config.secret;
411
- if (this.key.length !== 32) throw new Error("EncapsulatedStateStrategy: secret must be exactly 32 bytes");
412
- }
413
- /**
414
- * Encrypt workflow state into a self-contained token.
415
- *
416
- * NOTE: the `overrides.handle` hint from `WfStateStrategy` is silently
417
- * ignored — the encapsulated token IS the ciphertext of the state, so a
418
- * fixed handle cannot map to changing state. Callers that need handle
419
- * stability across calls must use `HandleStateStrategy`.
420
- *
421
- * @param state — workflow state to persist
422
- * @param options.ttl — time-to-live in ms (overrides defaultTtl)
423
- * @returns base64url-encoded encrypted token
424
- */
425
- async persist(state, options, _overrides) {
426
- const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
427
- const exp = ttl > 0 ? Date.now() + ttl : 0;
428
- const payload = JSON.stringify({
429
- s: state,
430
- e: exp
431
- });
432
- const iv = randomBytes(12);
433
- const cipher = createCipheriv("aes-256-gcm", this.key, iv);
434
- const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
435
- const tag = cipher.getAuthTag();
436
- return Buffer.concat([
437
- iv,
438
- tag,
439
- encrypted
440
- ]).toString("base64url");
441
- }
442
- /** Decrypt and return workflow state. Returns null if token is invalid, expired, or tampered. */
443
- async retrieve(token) {
444
- return this.decrypt(token);
445
- }
446
- /**
447
- * Stateless — CANNOT invalidate the token. Returns identical result to
448
- * `retrieve()`. See the class-level security warning.
449
- *
450
- * This method exists only to satisfy the `WfStateStrategy` contract.
451
- * Callers that need true single-use semantics must use
452
- * `HandleStateStrategy`.
453
- */
454
- async consume(token) {
455
- return this.decrypt(token);
456
- }
457
- decrypt(token) {
458
- try {
459
- const buf = Buffer.from(token, "base64url");
460
- if (buf.length < 28) return null;
461
- const iv = buf.subarray(0, 12);
462
- const tag = buf.subarray(12, 28);
463
- const ciphertext = buf.subarray(28);
464
- const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
465
- decipher.setAuthTag(tag);
466
- const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
467
- const { s: state, e: exp } = JSON.parse(decrypted.toString("utf8"));
468
- if (exp > 0 && Date.now() > exp) return null;
469
- return state;
470
- } catch {
471
- return null;
472
- }
473
- }
474
- };
475
- var HandleStateStrategy = class {
476
- constructor(config) {
477
- this.config = config;
478
- }
479
- async persist(state, options, overrides) {
480
- const handle = overrides?.handle ?? (this.config.generateHandle ?? randomUUID)();
481
- const ttl = options?.ttl ?? this.config.defaultTtl ?? 0;
482
- const expiresAt = ttl > 0 ? Date.now() + ttl : void 0;
483
- await this.config.store.set(handle, state, expiresAt);
484
- return handle;
485
- }
486
- async retrieve(token) {
487
- return (await this.config.store.get(token))?.state ?? null;
488
- }
489
- async consume(token) {
490
- return (await this.config.store.getAndDelete(token))?.state ?? null;
491
- }
492
- };
493
- /**
494
- * In-memory state store for development and testing.
495
- * State is lost on process restart.
496
- */
497
- var WfStateStoreMemory = class {
498
- constructor() {
499
- this.store = /* @__PURE__ */ new Map();
500
- }
501
- async set(handle, state, expiresAt) {
502
- this.store.set(handle, {
503
- state,
504
- expiresAt
505
- });
506
- }
507
- async get(handle) {
508
- const entry = this.store.get(handle);
509
- if (!entry) return null;
510
- if (entry.expiresAt && Date.now() > entry.expiresAt) {
511
- this.store.delete(handle);
512
- return null;
513
- }
514
- return entry;
515
- }
516
- async delete(handle) {
517
- this.store.delete(handle);
518
- }
519
- async getAndDelete(handle) {
520
- const entry = await this.get(handle);
521
- if (entry) this.store.delete(handle);
522
- return entry;
523
- }
524
- async cleanup() {
525
- const now = Date.now();
526
- let count = 0;
527
- for (const [handle, entry] of this.store) if (entry.expiresAt && now > entry.expiresAt) {
528
- this.store.delete(handle);
529
- count++;
530
- }
531
- return count;
532
- }
533
- };
534
-
535
401
  //#endregion
536
402
  //#region packages/event-wf/src/workflow.ts
537
403
  /** Workflow engine that resolves steps via Wooks router lookup. */
@@ -639,6 +505,7 @@ var WooksWf = class extends WooksAdapterBase {
639
505
  indexes,
640
506
  input
641
507
  }, async () => {
508
+ if (opts?.strategy?.name !== void 0) current().set(stateStrategyNameKey, opts.strategy.name);
642
509
  const { handlers: foundHandlers } = this.wooks.lookup("WF_FLOW", `/${schemaId}`.replace(/^\/+/u, "/"));
643
510
  const handlers = foundHandlers || this.opts?.onNotFound && [this.opts.onNotFound] || null;
644
511
  if (handlers && handlers.length > 0) {
@@ -672,6 +539,10 @@ var WooksWf = class extends WooksAdapterBase {
672
539
  throw error;
673
540
  }
674
541
  clean();
542
+ if (result.inputRequired) {
543
+ const finalName = current().get(stateStrategyNameKey);
544
+ if (finalName !== void 0) result.inputRequired.stateStrategy = finalName;
545
+ }
675
546
  if (result.resume) result.resume = (_input) => this.resume(result.state, {
676
547
  input: _input,
677
548
  spy,
@@ -716,4 +587,4 @@ function createWfApp(opts, wooks) {
716
587
  }
717
588
 
718
589
  //#endregion
719
- export { EncapsulatedStateStrategy, HandleStateStrategy, StepRetriableError, WfStateStoreMemory, WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, outlet, outletEmail, outletHttp, resumeKey, resumeWfContext, useLogger, useRouteParams, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
590
+ export { EncapsulatedStateStrategy, HandleStateStrategy, StepRetriableError, WfStateStoreMemory, WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, outlet, outletEmail, outletHttp, resumeKey, resumeWfContext, swapStrategy, useLogger, useRouteParams, useWfFinished, useWfOutlet, useWfState, useWfStrategy, wfKind, wfShortcuts };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooksjs/event-wf",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "description": "@wooksjs/event-wf",
5
5
  "keywords": [
6
6
  "app",
@@ -42,17 +42,17 @@
42
42
  "devDependencies": {
43
43
  "typescript": "^5.9.3",
44
44
  "vitest": "^3.2.4",
45
- "@wooksjs/event-http": "^0.7.14",
46
- "wooks": "^0.7.14",
47
- "@wooksjs/http-body": "^0.7.14",
48
- "@wooksjs/event-core": "^0.7.14"
45
+ "@wooksjs/event-core": "^0.7.16",
46
+ "@wooksjs/event-http": "^0.7.16",
47
+ "wooks": "^0.7.16",
48
+ "@wooksjs/http-body": "^0.7.16"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@prostojs/logger": "^0.4.3",
52
- "@wooksjs/event-core": "^0.7.14",
53
- "@wooksjs/event-http": "^0.7.14",
54
- "wooks": "^0.7.14",
55
- "@wooksjs/http-body": "^0.7.14"
52
+ "@wooksjs/event-core": "^0.7.16",
53
+ "@wooksjs/http-body": "^0.7.16",
54
+ "wooks": "^0.7.16",
55
+ "@wooksjs/event-http": "^0.7.16"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "@wooksjs/event-http": {