JSPM

  • Created
  • Published
  • Downloads 4699
  • Score
    100M100P100Q101585F
  • License Apache-2.0

Fastify REST API server for parmanasystems governance runtime.

Package Exports

  • @parmanasystems/server

Readme

@parmanasystems/server

Fastify REST API server for the parmanasystems deterministic governance runtime.

npm


Overview

@parmanasystems/server is an HTTP wrapper over @parmanasystems/execution. It exposes the governance execution pipeline over a REST API so that any language or platform can execute and independently verify governance decisions without embedding the TypeScript SDK directly.


Installation

npm install @parmanasystems/server

Quick start

# Start with default ephemeral Ed25519 keypair (development)
npx Parmana-server

# Start with persistent keys from environment variables
Parmana_PRIVATE_KEY="$(cat private.pem)" Parmana_PUBLIC_KEY="$(cat public.pem)" npx Parmana-server

# Enable API key authentication
Parmana_API_KEY=my-secret-key npx Parmana-server

The server listens on http://0.0.0.0:3000 by default.


Environment variables

Variable Required Description
PORT No HTTP port (default: 3000)
HOST No Bind address (default: 0.0.0.0)
Parmana_API_KEY No When set, all routes require Authorization: Bearer <key>
Parmana_PRIVATE_KEY No PEM-encoded Ed25519 private key for signing
Parmana_PUBLIC_KEY No PEM-encoded Ed25519 public key for verification

When Parmana_PRIVATE_KEY and Parmana_PUBLIC_KEY are absent, the server generates an ephemeral Ed25519 keypair on startup. This is suitable for development but means attestations cannot be verified after a restart.


API routes

GET /health

Returns runtime status, version, and per-subsystem health checks.

curl http://localhost:3000/health
{
  "status": "ok",
  "version": "1.2.3",
  "timestamp": "2026-05-03T10:00:00.000Z",
  "checks": {
    "runtime_manifest": "ok",
    "signing_key": "ok",
    "audit_db": "unconfigured"
  }
}

status is "ok" when all checks are "ok" or "unconfigured". It is "degraded" if any check is "error" or "unavailable". The HTTP status code is always 200 — callers decide how to act on a degraded response.

Check values:

Check Values Meaning
runtime_manifest ok / error Whether getRuntimeManifest() succeeds
signing_key ok / unconfigured Parmana_PRIVATE_KEY env var set, or dev key on disk
audit_db ok / unavailable / unconfigured DB reachable / unreachable / AUDIT_DATABASE_URL not set

POST /execute

Runs the deterministic governance runtime and returns a signed ExecutionAttestation.

Request body validation:

Field Type minLength maxLength Pattern
policy_id string 1 128 ^[a-zA-Z0-9_-]+$
policy_version string 1 32 ^v?\d+(\.\d+){0,2}([-+][\w.-]+)?$
decision_type string 1 64 ^[a-zA-Z0-9_-]+$
signals_hash string 64 64 ^[0-9a-f]{64}$

Extra fields not listed above are rejected with 400 Bad Request (additionalProperties: false).

curl -X POST http://localhost:3000/execute \
  -H "Content-Type: application/json" \
  -d '{
    "policy_id":      "loan-approval",
    "policy_version": "v1",
    "decision_type":  "approve",
    "signals_hash":   "a3f1e2d4b5c6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
  }'
{
  "result": {
    "execution_id":    "550e8400-e29b-41d4-a716-446655440000",
    "policy_id":       "loan-approval",
    "policy_version":  "v1",
    "schema_version":  "1.0.0",
    "runtime_version": "1.0.0",
    "runtime_hash":    "a1b2c3...",
    "decision":        "approve",
    "signals_hash":    "a3f1e2d4b5c6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
    "executed_at":     "2025-05-02T10:00:00.000Z"
  },
  "signature": "base64-encoded-Ed25519-signature"
}

Error responses:

Status Meaning
400 Missing required fields, failed pattern/length validation, or unexpected fields
422 Execution failed (token expired, replay detected, etc.)

POST /verify

Independently verifies an ExecutionAttestation. Pass the response from POST /execute directly.

curl -X POST http://localhost:3000/verify \
  -H "Content-Type: application/json" \
  -d '{ "result": { ... }, "signature": "..." }'
{
  "valid": true,
  "checks": {
    "signature_verified": true,
    "runtime_verified":   true,
    "schema_compatible":  true
  }
}

Error responses:

Status Meaning
400 Malformed attestation body
422 Verification threw an unexpected error

Audit routes (requires AUDIT_DATABASE_URL)

When AUDIT_DATABASE_URL is configured, five read-only audit query routes are registered:

Route Description
GET /audit/decisions Decision timeline. Query params: limit (default 100), offset, policy_id, decision, from, to
GET /audit/decisions/:executionId Single decision row with full ExecutionAttestation JSONB
GET /audit/security Security event dashboard. Query params: from, to, limit
GET /audit/stats Aggregate counts: total decisions, decisions today, verifications, security events, API calls
GET /audit/verifications/:executionId All verification attempts for an execution, newest first

All audit routes return 404 if the database is not configured.


Stub endpoints (501 Not Implemented)

These endpoints are defined in the OpenAPI spec and will be implemented in future releases:

Endpoint Description
GET /runtime/manifest Returns the signed bundle manifest for the active runtime
GET /runtime/capabilities Lists runtime capabilities
POST /evaluate Dry-run policy evaluation without attestation
POST /simulate Full simulation mode — no side effects

Rate limits

All routes are rate-limited per API key (when Parmana_API_KEY is set) or per client IP (in dev mode).

Route Limit
POST /execute 100 req/min
POST /verify 200 req/min
GET /audit/* 60 req/min
GET /health 300 req/min
GET /runtime/* 60 req/min
POST /evaluate 60 req/min
POST /simulate 60 req/min

When authenticated, the rate limit key is sha256(Parmana_API_KEY). In dev mode (no Parmana_API_KEY), the key falls back to X-Forwarded-ForX-Real-IP → socket IP.

Every response includes rate limit headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 1714640460

Payload size limits (enforced independently of rate limits):

Route Max body
POST /execute 64 KB
POST /verify 64 KB
POST /evaluate 64 KB
POST /simulate 64 KB
All other routes 1 MB (global default)

Requests exceeding the limit receive 413 Payload Too Large. GET routes carry no body so no limit applies.

When a rate limit is exceeded the server responds with 429 Too Many Requests:

{
  "error": "Rate limit exceeded",
  "limit": 100,
  "remaining": 0,
  "reset": 1714640460
}

CORS

Cross-origin requests are controlled via the CORS_ORIGIN environment variable.

CORS_ORIGIN value Behaviour
(not set) Allow http://localhost:5173 and http://localhost:8080 (dev default)
http://localhost:5173,https://app.example.com Allow those two origins only
* Reflect any request origin (allows all — use with care in production)
# Single origin
CORS_ORIGIN=https://app.example.com

# Multiple origins (comma-separated, no spaces)
CORS_ORIGIN=https://app.example.com,https://admin.example.com

credentials: true is set on all CORS responses. This means browsers will include cookies and Authorization headers on cross-origin requests. When using CORS_ORIGIN=*, the server reflects the specific request origin rather than sending a literal * so that credentials continue to work.

Allowed methods: GET, POST.
Allowed request headers: Content-Type, Authorization, X-Request-ID.
Exposed response headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
Preflight cache (Access-Control-Max-Age): 86400 s (24 h).


Security headers

All responses include the following HTTP security headers, set via @fastify/helmet:

Header Value Purpose
Content-Security-Policy default-src 'none'; frame-ancestors 'none' Blocks all resource loading and framing — this is a pure API, not a browser app
Cross-Origin-Resource-Policy cross-origin Allows cross-origin reads (needed for browser clients consuming the API)
Strict-Transport-Security max-age=31536000; includeSubDomains; preload Enforces HTTPS for 1 year across all subdomains; eligible for HSTS preload list
X-Content-Type-Options nosniff Prevents MIME-type sniffing
X-Frame-Options DENY Blocks the response from being embedded in a frame
X-DNS-Prefetch-Control off Disables DNS prefetching
Referrer-Policy no-referrer Suppresses the Referer header on all requests
X-Download-Options noopen Prevents IE from executing downloaded files in the browser context
X-XSS-Protection 0 Disables the legacy XSS auditor (modern browsers ignore it; CSP is the correct control)
X-Powered-By (removed) Suppressed to avoid leaking server technology

Cross-Origin-Embedder-Policy is intentionally disabled — it would block cross-origin API requests from browser clients that have not opted in to COEP.


Graceful shutdown

The server handles SIGTERM and SIGINT (Ctrl-C) with a clean, ordered shutdown:

  1. Stops accepting new connections (app.close()).
  2. Waits for in-flight requests to complete.
  3. Closes the PostgreSQL audit pool (auditDb.disconnect()), if configured.
  4. Logs "Server closed cleanly" and exits 0.

If shutdown takes longer than 10 seconds the process force-exits with code 1 and logs "Graceful shutdown timed out, forcing exit". The timeout is unref()-ed so it does not extend the process lifetime on its own.

SIGTERM/SIGINT
  └─ app.close()         — drain in-flight HTTP requests
  └─ auditDb.disconnect() — close postgres pool (if configured)
  └─ process.exit(0)

Logging

The server uses pino structured JSON logging via Fastify.

Log level

LOG_LEVEL Default
trace / debug / info / warn / error / fatal debug in development, info in production

Set NODE_ENV=production or LOG_LEVEL=info to suppress debug output. LOG_LEVEL takes priority over NODE_ENV.

Request ID (X-Request-ID)

Every request gets a unique ID. The server:

  • Reuses X-Request-ID from the incoming request if present.
  • Generates a crypto.randomUUID() otherwise.
  • Echoes the ID back in the X-Request-ID response header.
  • Includes reqId in every structured log line for that request.
curl -H "X-Request-ID: my-trace-id" http://localhost:3000/health
# Response header: X-Request-ID: my-trace-id

Redacted fields

The following fields are replaced with [REDACTED] in all log output:

Field Why
req.headers.authorization Bearer token must not appear in logs
req.body.signature Ed25519 signature is key material
req.body.attestation.signature Nested signature in verify requests

Structured log events

Event Level Fields
Governance decision executed info reqId, policy_id, policy_version, decision_type
Governance decision failed warn reqId, error
Attestation verified info reqId, valid, checks
Authentication failure warn reqId, reason: "auth_failure"

Authentication

When Parmana_API_KEY is set, all requests must include:

Authorization: Bearer <your-api-key>

Requests without a valid bearer token receive 401 Unauthorized.

When Parmana_API_KEY is unset, the auth hook is disabled. This is the default development mode.


OpenAPI specification

The full OpenAPI 3.0.3 specification is available at openapi.json in the repository root.

To regenerate it:

npx tsx scripts/export-openapi.ts

Programmatic usage

import { createServer } from "@parmanasystems/server";

const { app, auditDb } = await createServer();
await app.listen({ port: 3000, host: "0.0.0.0" });

// On shutdown:
// await auditDb?.disconnect();
// await app.close();

License

Apache-2.0