x-openapi-flow
Advanced tools
| "use strict"; | ||
| const { createRuntimeFlowGuard, toErrorPayload } = require("./core"); | ||
| function resolveNestOperationId(context) { | ||
| const req = context && context.req; | ||
| if (req && req.openapi && req.openapi.operationId) { | ||
| return req.openapi.operationId; | ||
| } | ||
| if (req && req.operation && req.operation.operationId) { | ||
| return req.operation.operationId; | ||
| } | ||
| return null; | ||
| } | ||
| function defaultNestPath(req) { | ||
| if (!req) { | ||
| return "/"; | ||
| } | ||
| if (req.route && typeof req.route.path === "string") { | ||
| return req.route.path; | ||
| } | ||
| if (typeof req.path === "string") { | ||
| return req.path; | ||
| } | ||
| if (typeof req.originalUrl === "string") { | ||
| return req.originalUrl.split("?")[0]; | ||
| } | ||
| if (typeof req.url === "string") { | ||
| return req.url.split("?")[0]; | ||
| } | ||
| return "/"; | ||
| } | ||
| function createNestFlowMiddleware(options = {}) { | ||
| const guard = createRuntimeFlowGuard({ | ||
| ...options, | ||
| resolveOperationId: options.resolveOperationId || resolveNestOperationId, | ||
| }); | ||
| return async function xOpenApiFlowNestMiddleware(req, res, next) { | ||
| try { | ||
| await guard.enforce({ | ||
| req, | ||
| res, | ||
| method: req && req.method, | ||
| path: defaultNestPath(req), | ||
| params: (req && req.params) || {}, | ||
| }); | ||
| return next(); | ||
| } catch (error) { | ||
| const payload = { | ||
| error: toErrorPayload(error), | ||
| }; | ||
| const statusCode = (error && error.statusCode) || 500; | ||
| if (res && typeof res.status === "function" && typeof res.json === "function") { | ||
| return res.status(statusCode).json(payload); | ||
| } | ||
| return next(error); | ||
| } | ||
| }; | ||
| } | ||
| function createNestFlowCanActivate(options = {}) { | ||
| const guard = createRuntimeFlowGuard({ | ||
| ...options, | ||
| resolveOperationId: options.resolveOperationId || resolveNestOperationId, | ||
| }); | ||
| return async function xOpenApiFlowNestCanActivate(executionContext) { | ||
| const http = executionContext | ||
| && typeof executionContext.switchToHttp === "function" | ||
| ? executionContext.switchToHttp() | ||
| : null; | ||
| const req = http && typeof http.getRequest === "function" | ||
| ? http.getRequest() | ||
| : null; | ||
| const res = http && typeof http.getResponse === "function" | ||
| ? http.getResponse() | ||
| : null; | ||
| try { | ||
| await guard.enforce({ | ||
| req, | ||
| res, | ||
| executionContext, | ||
| method: req && req.method, | ||
| path: defaultNestPath(req), | ||
| params: (req && req.params) || {}, | ||
| }); | ||
| return true; | ||
| } catch (error) { | ||
| const payload = { | ||
| error: toErrorPayload(error), | ||
| }; | ||
| const statusCode = (error && error.statusCode) || 500; | ||
| if (res && typeof res.status === "function" && typeof res.json === "function") { | ||
| res.status(statusCode).json(payload); | ||
| return false; | ||
| } | ||
| throw error; | ||
| } | ||
| }; | ||
| } | ||
| module.exports = { | ||
| createNestFlowMiddleware, | ||
| createNestFlowCanActivate, | ||
| }; |
+39
-0
@@ -41,4 +41,29 @@ // Type definitions for x-openapi-flow | ||
| requireResourceIdForTransitions?: boolean; | ||
| /** | ||
| * Optional observability callback invoked on each runtime guard decision. | ||
| * Exceptions inside this callback are swallowed by the guard. | ||
| */ | ||
| onDecision?: (decision: RuntimeFlowDecision) => void; | ||
| } | ||
| export interface RuntimeFlowDecision { | ||
| decision: | ||
| | "allowed_transition" | ||
| | "allowed_idempotent_state" | ||
| | "allowed_initial_state" | ||
| | "skipped_unknown_operation" | ||
| | "denied_unknown_operation" | ||
| | "denied_missing_resource_id" | ||
| | "denied_missing_current_state" | ||
| | "denied_invalid_transition" | ||
| | "denied_missing_state_resolver"; | ||
| operationId?: string | null; | ||
| resourceId?: string | null; | ||
| currentState?: string | null; | ||
| nextState?: string | null; | ||
| method?: string; | ||
| path?: string; | ||
| durationMs: number; | ||
| } | ||
| export interface FlowOperation { | ||
@@ -93,5 +118,19 @@ operationId: string; | ||
| export type FastifyPreHandler = (request: object, reply: object) => Promise<void>; | ||
| export type NestMiddlewareFunction = (req: object, res: object, next: (err?: unknown) => void) => void; | ||
| export interface NestHttpContextLike { | ||
| getRequest: () => object; | ||
| getResponse: () => object; | ||
| } | ||
| export interface NestExecutionContextLike { | ||
| switchToHttp: () => NestHttpContextLike; | ||
| } | ||
| export type NestCanActivateFunction = (executionContext: NestExecutionContextLike) => Promise<boolean>; | ||
| export declare function createExpressFlowGuard(options: RuntimeFlowGuardOptions): ExpressMiddleware; | ||
| export declare function createFastifyFlowGuard(options: RuntimeFlowGuardOptions): FastifyPreHandler; | ||
| export declare function createNestFlowMiddleware(options: RuntimeFlowGuardOptions): NestMiddlewareFunction; | ||
| export declare function createNestFlowCanActivate(options: RuntimeFlowGuardOptions): NestCanActivateFunction; | ||
@@ -98,0 +137,0 @@ // --------------------------------------------------------------------------- |
@@ -29,2 +29,14 @@ "use strict"; | ||
| function safeEmitDecision(hook, payload) { | ||
| if (typeof hook !== "function") { | ||
| return; | ||
| } | ||
| try { | ||
| hook(payload); | ||
| } catch (_err) { | ||
| // Observability hook must never break request handling. | ||
| } | ||
| } | ||
| class RuntimeFlowGuard { | ||
@@ -42,2 +54,3 @@ constructor(options = {}) { | ||
| this.resolveOperationId = options.resolveOperationId || null; | ||
| this.onDecision = typeof options.onDecision === "function" ? options.onDecision : null; | ||
@@ -76,3 +89,16 @@ this.allowUnknownOperations = options.allowUnknownOperations === true; | ||
| async enforce(context = {}) { | ||
| const startedAt = Date.now(); | ||
| const emitDecision = (decision) => { | ||
| safeEmitDecision(this.onDecision, { | ||
| method: context.method, | ||
| path: context.path, | ||
| durationMs: Date.now() - startedAt, | ||
| ...decision, | ||
| }); | ||
| }; | ||
| if (typeof this.getCurrentState !== "function") { | ||
| emitDecision({ | ||
| decision: "denied_missing_state_resolver", | ||
| }); | ||
| throw missingStateResolverError(); | ||
@@ -93,5 +119,14 @@ } | ||
| if (this.allowUnknownOperations) { | ||
| emitDecision({ | ||
| decision: "skipped_unknown_operation", | ||
| operationId: context.operationId || operationFromResolver || null, | ||
| }); | ||
| return { ok: true, skipped: true, reason: "unknown_operation" }; | ||
| } | ||
| emitDecision({ | ||
| decision: "denied_unknown_operation", | ||
| operationId: context.operationId || operationFromResolver || null, | ||
| }); | ||
| throw unknownOperationError({ | ||
@@ -106,2 +141,6 @@ operationId: context.operationId || operationFromResolver, | ||
| if (!resourceId && operation.incomingFromStates.size > 0 && this.requireResourceIdForTransitions) { | ||
| emitDecision({ | ||
| decision: "denied_missing_resource_id", | ||
| operationId: operation.operationId, | ||
| }); | ||
| throw missingResourceIdError({ | ||
@@ -123,2 +162,9 @@ operationId: operation.operationId, | ||
| if (operation.incomingFromStates.size === 0 && this.allowMissingStateForInitial) { | ||
| emitDecision({ | ||
| decision: "allowed_initial_state", | ||
| operationId: operation.operationId, | ||
| resourceId, | ||
| currentState: null, | ||
| nextState: operation.currentState || null, | ||
| }); | ||
| return { | ||
@@ -132,2 +178,9 @@ ok: true, | ||
| emitDecision({ | ||
| decision: "denied_missing_current_state", | ||
| operationId: operation.operationId, | ||
| resourceId, | ||
| currentState: null, | ||
| }); | ||
| throw invalidTransitionError({ | ||
@@ -144,4 +197,11 @@ operationId: operation.operationId, | ||
| const isSameState = this.allowIdempotentState && normalizedState === String(operation.currentState); | ||
| const nextState = this.stateMachine.getNextState(normalizedState, operation.operationId); | ||
| if (!isAllowedFrom && !isSameState) { | ||
| emitDecision({ | ||
| decision: "denied_invalid_transition", | ||
| operationId: operation.operationId, | ||
| resourceId, | ||
| currentState: normalizedState, | ||
| }); | ||
| throw invalidTransitionError({ | ||
@@ -155,2 +215,10 @@ operationId: operation.operationId, | ||
| emitDecision({ | ||
| decision: isAllowedFrom ? "allowed_transition" : "allowed_idempotent_state", | ||
| operationId: operation.operationId, | ||
| resourceId, | ||
| currentState: normalizedState, | ||
| nextState, | ||
| }); | ||
| return { | ||
@@ -161,3 +229,3 @@ ok: true, | ||
| currentState: normalizedState, | ||
| nextState: this.stateMachine.getNextState(normalizedState, operation.operationId), | ||
| nextState, | ||
| }; | ||
@@ -164,0 +232,0 @@ } |
@@ -6,2 +6,3 @@ "use strict"; | ||
| const { createFastifyFlowGuard } = require("./fastify"); | ||
| const { createNestFlowMiddleware, createNestFlowCanActivate } = require("./nestjs"); | ||
| const { FlowGuardError } = require("./errors"); | ||
@@ -15,2 +16,4 @@ const { MemoryAdapter, FileAdapter, RedisAdapter, GenericSQLAdapter } = require("./adapters"); | ||
| createFastifyFlowGuard, | ||
| createNestFlowMiddleware, | ||
| createNestFlowCanActivate, | ||
| FlowGuardError, | ||
@@ -17,0 +20,0 @@ toErrorPayload, |
+1
-1
| { | ||
| "name": "x-openapi-flow", | ||
| "version": "1.7.1", | ||
| "version": "1.7.2", | ||
| "description": "Enforce, visualize and generate code from OpenAPI resource lifecycles β runtime guard, SDK gen, Postman/Insomnia/Redoc adapters, MCP sidecar for AI agents", | ||
@@ -5,0 +5,0 @@ "main": "lib/validator.js", |
+103
-5
@@ -125,2 +125,25 @@ <!-- Auto-generated from /README.md via scripts/sync-package-readme.js. Do not edit directly. --> | ||
| ## Start in 2 Minutes (Online Playground) | ||
| Prefer no local setup? Open the minimal runtime-guard demo directly in your browser: | ||
| - StackBlitz: https://stackblitz.com/github/tiago-marques/x-openapi-flow/tree/main/example/runtime-guard/minimal-order | ||
| - Codespaces (repo): https://github.com/tiago-marques/x-openapi-flow | ||
| Once open, run: | ||
| ```bash | ||
| npm install | ||
| npm run start | ||
| ``` | ||
| Then in another terminal: | ||
| ```bash | ||
| curl -s -X POST http://localhost:3110/orders | ||
| curl -i -X POST http://localhost:3110/orders/<id>/ship | ||
| ``` | ||
| Expected: `409 INVALID_STATE_TRANSITION`. | ||
| Fastest way to see value (guided scaffold): | ||
@@ -192,3 +215,3 @@ | ||
| - name: Validate OpenAPI flow rules | ||
| uses: tiago-marques/x-openapi-flow/.github/actions/validate@main | ||
| uses: tiago-marques/x-openapi-flow@v1 | ||
| with: | ||
@@ -371,3 +394,40 @@ openapi-file: openapi.flow.yaml | ||
| ### Observability Hooks (Metrics/Audit) | ||
| You can instrument runtime decisions with `onDecision` to feed Prometheus, logs, or tracing. | ||
| ```js | ||
| const { Counter } = require("prom-client"); | ||
| const { createExpressFlowGuard } = require("x-openapi-flow/lib/runtime-guard"); | ||
| const flowDecisions = new Counter({ | ||
| name: "xflow_runtime_guard_decisions_total", | ||
| help: "Runtime guard decisions by type and operation", | ||
| labelNames: ["decision", "operation_id"], | ||
| }); | ||
| app.use( | ||
| createExpressFlowGuard({ | ||
| openapi, | ||
| ...store.forGuard(), | ||
| onDecision(event) { | ||
| flowDecisions.inc({ | ||
| decision: event.decision, | ||
| operation_id: event.operationId || "unknown", | ||
| }); | ||
| }, | ||
| }) | ||
| ); | ||
| ``` | ||
| Common decision values include: | ||
| - `allowed_transition` | ||
| - `allowed_idempotent_state` | ||
| - `allowed_initial_state` | ||
| - `denied_invalid_transition` | ||
| - `denied_missing_resource_id` | ||
| - `denied_unknown_operation` | ||
| - `skipped_unknown_operation` | ||
| Want to see the value immediately? Use the official minimal demo: | ||
@@ -613,12 +673,25 @@ | ||
| Use x-openapi-flow runtime guard inside NestJS via a custom guard or interceptor. No dedicated package required: | ||
| Dedicated package: **x-openapi-flow-nestjs** | ||
| ```bash | ||
| npm install x-openapi-flow x-openapi-flow-nestjs | ||
| ``` | ||
| Then import from `x-openapi-flow-nestjs`. | ||
| This package wraps the official runtime-guard helpers and exposes a NestJS-first API. | ||
| Release automation for this package uses dedicated tags in the format `nestjs-v<version>` | ||
| (example: `nestjs-v0.1.0`) so it does not conflict with `x-openapi-flow` tags. | ||
| ### Option A: Middleware (drop-in) | ||
| ```ts | ||
| // flow-guard.middleware.ts | ||
| import { Injectable, NestMiddleware } from "@nestjs/common"; | ||
| import { createExpressFlowGuard, MemoryAdapter } from "x-openapi-flow/lib/runtime-guard"; | ||
| import { createFlowMiddleware, MemoryAdapter } from "x-openapi-flow-nestjs"; | ||
| import openapi from "./openapi.flow.json"; | ||
| const store = new MemoryAdapter(); // swap for RedisAdapter or GenericSQLAdapter in production | ||
| const guard = createExpressFlowGuard({ openapi, ...store.forGuard() }); | ||
| const guard = createFlowMiddleware({ openapi, ...store.forGuard() }); | ||
@@ -628,3 +701,3 @@ @Injectable() | ||
| use(req: any, res: any, next: () => void) { | ||
| guard(req, res, next); | ||
| guard.use(req, res, next); | ||
| } | ||
@@ -641,2 +714,21 @@ } | ||
| ### Option B: CanActivate Guard | ||
| ```ts | ||
| // flow.guard.ts | ||
| import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; | ||
| import { createFlowGuard, MemoryAdapter } from "x-openapi-flow-nestjs"; | ||
| import openapi from "./openapi.flow.json"; | ||
| const store = new MemoryAdapter(); | ||
| const flowGuard = createFlowGuard({ openapi, ...store.forGuard() }); | ||
| @Injectable() | ||
| export class FlowGuard implements CanActivate { | ||
| canActivate(context: ExecutionContext) { | ||
| return flowGuard.canActivate(context); | ||
| } | ||
| } | ||
| ``` | ||
| After each successful state-changing request, call `store.setState` to advance the resource: | ||
@@ -678,2 +770,8 @@ | ||
| ## Community and Proof | ||
| - **Share your case study** β [docs/wiki/community/Case-Study-Template.md](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/community/Case-Study-Template.md) | ||
| - **Open discussions and ideas** β use GitHub Issues to share adoption blockers, metrics, and integration stories | ||
| - **Prove impact with metrics** β track `denied_invalid_transition`, onboarding lead time, and CI catch rate before/after adoption | ||
| ## Roadmap | ||
@@ -680,0 +778,0 @@ |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
446534
2.07%44
2.33%10486
1.94%802
13.92%