@prostojs/wf
Version:
Generic workflow framework
185 lines (184 loc) • 5.23 kB
JavaScript
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 };