UNPKG

@prostojs/wf

Version:

Generic workflow framework

185 lines (184 loc) 5.23 kB
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto"; //#region src/outlets/helpers.ts /** * Generic outlet request. Use for custom outlets. * * @example * return outlet('pending-task', { * payload: ApprovalForm, * target: managerId, * context: { orderId, amount }, * }) */ function outlet(name, data) { return { inputRequired: { outlet: name, ...data } }; } /** * Pause for HTTP form input. The outlet returns the payload (form definition) * and state token in the HTTP response. * * @example * return outletHttp(LoginForm) * return outletHttp(LoginForm, { error: 'Invalid credentials' }) */ function outletHttp(payload, context) { return outlet("http", { payload, context }); } /** * Pause and send email with a magic link containing the state token. * * @example * return outletEmail('user@test.com', 'invite', { name: 'Alice' }) */ function outletEmail(target, template, context) { return outlet("email", { target, template, context }); } //#endregion //#region src/outlets/state/encapsulated.ts /** * Self-contained AES-256-GCM encrypted state strategy. * * Workflow state is encrypted into a base64url token that travels with the * transport (cookie, URL param, hidden field). No server-side storage needed. * * Token format: `base64url(iv[12] + authTag[16] + ciphertext)` * * @example * const strategy = new EncapsulatedStateStrategy({ * secret: crypto.randomBytes(32), * defaultTtl: 3600_000, // 1 hour * }); * const token = await strategy.persist(state); * const recovered = await strategy.retrieve(token); */ var EncapsulatedStateStrategy = class { /** @throws if secret is not exactly 32 bytes */ constructor(config) { this.config = config; this.key = typeof config.secret === "string" ? Buffer.from(config.secret, "hex") : config.secret; if (this.key.length !== 32) throw new Error("EncapsulatedStateStrategy: secret must be exactly 32 bytes"); } /** * Encrypt workflow state into a self-contained token. * @param state — workflow state to persist * @param options.ttl — time-to-live in ms (overrides defaultTtl) * @returns base64url-encoded encrypted token */ async persist(state, options) { const ttl = options?.ttl ?? this.config.defaultTtl ?? 0; const exp = ttl > 0 ? Date.now() + ttl : 0; const payload = JSON.stringify({ s: state, e: exp }); const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", this.key, iv); const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([ iv, tag, encrypted ]).toString("base64url"); } /** Decrypt and return workflow state. Returns null if token is invalid, expired, or tampered. */ async retrieve(token) { return this.decrypt(token); } /** Same as retrieve (stateless — cannot truly invalidate a token). */ async consume(token) { return this.decrypt(token); } decrypt(token) { try { const buf = Buffer.from(token, "base64url"); if (buf.length < 28) return null; const iv = buf.subarray(0, 12); const tag = buf.subarray(12, 28); const ciphertext = buf.subarray(28); const decipher = createDecipheriv("aes-256-gcm", this.key, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); const { s: state, e: exp } = JSON.parse(decrypted.toString("utf8")); if (exp > 0 && Date.now() > exp) return null; return state; } catch { return null; } } }; //#endregion //#region src/outlets/state/handle.ts var HandleStateStrategy = class { constructor(config) { this.config = config; } async persist(state, options) { const handle = (this.config.generateHandle ?? randomUUID)(); const ttl = options?.ttl ?? this.config.defaultTtl ?? 0; const expiresAt = ttl > 0 ? Date.now() + ttl : void 0; await this.config.store.set(handle, state, expiresAt); return handle; } async retrieve(token) { return (await this.config.store.get(token))?.state ?? null; } async consume(token) { return (await this.config.store.getAndDelete(token))?.state ?? null; } }; //#endregion //#region src/outlets/state/memory.ts /** * In-memory state store for development and testing. * State is lost on process restart. */ var WfStateStoreMemory = class { constructor() { this.store = /* @__PURE__ */ new Map(); } async set(handle, state, expiresAt) { this.store.set(handle, { state, expiresAt }); } async get(handle) { const entry = this.store.get(handle); if (!entry) return null; if (entry.expiresAt && Date.now() > entry.expiresAt) { this.store.delete(handle); return null; } return entry; } async delete(handle) { this.store.delete(handle); } async getAndDelete(handle) { const entry = await this.get(handle); if (entry) this.store.delete(handle); return entry; } async cleanup() { const now = Date.now(); let count = 0; for (const [handle, entry] of this.store) if (entry.expiresAt && now > entry.expiresAt) { this.store.delete(handle); count++; } return count; } }; //#endregion export { EncapsulatedStateStrategy, HandleStateStrategy, WfStateStoreMemory, outlet, outletEmail, outletHttp };