@arnilo/prism-server 0.0.12 → 0.0.13

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/CHANGELOG.md CHANGED
@@ -1,4 +1,17 @@
1
1
  # Changelog
2
+
3
+ ## [0.0.13] - 2026-07-24
4
+
5
+ ### Changed
6
+
7
+ - Released with exact 0.0.13 graph.
8
+
9
+ ## [0.0.12] - 2026-07-23
10
+
11
+ ### Added
12
+
13
+ - Optional deployment seams: `createPrismHealthHandler`, `createPrismDrainController`, host `rateLimit` adapter (+ `createMemoryRateLimiter`), ownership-scoped `createPrismEventReplay` / `createPrismReplayHandler`, and `createPrismDeploymentLease` for worker/coordinator election over existing leases. No queue adapter (deferred pending measured Postgres polling need).
14
+
2
15
  ## [0.0.12] - 2026-07-22
3
16
 
4
17
  ### Changed
package/README.md CHANGED
@@ -26,6 +26,27 @@ const response = await handler(new Request("https://api.example.test/prism/agent
26
26
 
27
27
  Routes: direct/SSE agent run, explicitly selected durable agent status/resume through `agentRuns`, direct/SSE workflow run, durable workflow enqueue/status/cancel/resume/replay, and optional ownership-scoped schedule create/list/pause/resume/trigger/delete. `agentRuns` uses core `createAgentRunLifecycle()`; no lifecycle route exists by default. All bodies, responses, events, queues, concurrency, and timeouts are bounded.
28
28
 
29
- Nothing is exposed by default. Authorization is required; ownership comes only from its result. No listener, framework, auth provider, user database, credential discovery, or hidden package activation ships.
29
+ Optional deployment helpers (compose beside the API handler no embedded listener):
30
+
31
+ ```ts
32
+ import {
33
+ createPrismDrainController,
34
+ createPrismHealthHandler,
35
+ createMemoryRateLimiter,
36
+ createPrismDeploymentLease,
37
+ } from "@arnilo/prism-server";
38
+
39
+ const drain = createPrismDrainController();
40
+ const handler = createPrismHandler({
41
+ agents: { support: agent },
42
+ authorize,
43
+ drain,
44
+ rateLimit: createMemoryRateLimiter({ maxRequests: 60, windowMs: 60_000 }),
45
+ });
46
+ const health = createPrismHealthHandler({ drain, ready: () => store.ping() });
47
+ // Workers: createWorkflowCoordinator(...). Coordinators: createPrismDeploymentLease({ key: "coordinator", ... }).
48
+ ```
49
+
50
+ Nothing is exposed by default. Authorization is required; ownership comes only from its result. No listener, framework, auth provider, user database, credential discovery, queue adapter, or hidden package activation ships.
30
51
 
31
52
  Full API, route, limits, security, and deployment notes: [`docs/server.md`](../../docs/server.md).
@@ -0,0 +1,31 @@
1
+ import type { LeaseRecord, LeaseStore, OwnershipScope } from "@arnilo/prism";
2
+ /** Lease namespace for process-role election (coordinator vs worker hosts). */
3
+ export declare const PRISM_DEPLOYMENT_LEASE_NAMESPACE = "prism.server.deployment";
4
+ export interface PrismDeploymentLeaseOptions {
5
+ readonly leases: LeaseStore;
6
+ /** Stable process / replica id. */
7
+ readonly ownerId: string;
8
+ /**
9
+ * Role key within the deployment namespace.
10
+ * Convention: `coordinator` for schedule/admission leadership; workers omit or use host-defined keys.
11
+ */
12
+ readonly key: string;
13
+ readonly ownership?: OwnershipScope;
14
+ readonly ttlMs?: number;
15
+ }
16
+ export interface PrismDeploymentLease {
17
+ readonly namespace: typeof PRISM_DEPLOYMENT_LEASE_NAMESPACE;
18
+ readonly key: string;
19
+ readonly ownerId: string;
20
+ tryAcquire(signal?: AbortSignal): Promise<LeaseRecord | null>;
21
+ renew(token: string, signal?: AbortSignal): Promise<LeaseRecord | null>;
22
+ release(token: string, signal?: AbortSignal): Promise<boolean>;
23
+ get(signal?: AbortSignal): Promise<LeaseRecord | null>;
24
+ }
25
+ /**
26
+ * Thin LeaseStore wrapper for worker/coordinator election.
27
+ * Workers run `createWorkflowCoordinator` (workflows package) for queued runs;
28
+ * a single coordinator replica holds this lease before ticking schedules / admitting drain decisions.
29
+ * No embedded listener or container orchestrator.
30
+ */
31
+ export declare function createPrismDeploymentLease(options: PrismDeploymentLeaseOptions): PrismDeploymentLease;
@@ -0,0 +1,49 @@
1
+ import { PrismServerError } from "./types.js";
2
+ /** Lease namespace for process-role election (coordinator vs worker hosts). */
3
+ export const PRISM_DEPLOYMENT_LEASE_NAMESPACE = "prism.server.deployment";
4
+ /**
5
+ * Thin LeaseStore wrapper for worker/coordinator election.
6
+ * Workers run `createWorkflowCoordinator` (workflows package) for queued runs;
7
+ * a single coordinator replica holds this lease before ticking schedules / admitting drain decisions.
8
+ * No embedded listener or container orchestrator.
9
+ */
10
+ export function createPrismDeploymentLease(options) {
11
+ if (!options.ownerId)
12
+ throw new PrismServerError("ownerId is required", 500, "ERR_PRISM_SERVER_CONFIG");
13
+ if (!options.key)
14
+ throw new PrismServerError("key is required", 500, "ERR_PRISM_SERVER_CONFIG");
15
+ const ttlMs = options.ttlMs ?? 30_000;
16
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
17
+ throw new PrismServerError("ttlMs must be a positive safe integer", 500, "ERR_PRISM_SERVER_CONFIG");
18
+ }
19
+ const ownership = options.ownership ?? {};
20
+ const base = {
21
+ namespace: PRISM_DEPLOYMENT_LEASE_NAMESPACE,
22
+ key: options.key,
23
+ ownerId: options.ownerId,
24
+ ...ownership,
25
+ };
26
+ return {
27
+ namespace: PRISM_DEPLOYMENT_LEASE_NAMESPACE,
28
+ key: options.key,
29
+ ownerId: options.ownerId,
30
+ tryAcquire(signal) {
31
+ return options.leases.tryAcquireLease({ ...base, ttlMs, signal });
32
+ },
33
+ renew(token, signal) {
34
+ return options.leases.renewLease({ ...base, token, ttlMs, signal });
35
+ },
36
+ release(token, signal) {
37
+ return options.leases.releaseLease({ ...base, token, signal });
38
+ },
39
+ get(signal) {
40
+ return options.leases.getLease({
41
+ namespace: PRISM_DEPLOYMENT_LEASE_NAMESPACE,
42
+ key: options.key,
43
+ signal,
44
+ ...ownership,
45
+ });
46
+ },
47
+ };
48
+ }
49
+ //# sourceMappingURL=deployment.js.map
@@ -0,0 +1,23 @@
1
+ export interface PrismDrainControllerOptions {
2
+ /** Admit cutoff / host exit budget after `beginDrain`. Default 30s, hard 5 min. */
3
+ readonly deadlineMs?: number;
4
+ }
5
+ export interface PrismDrainSnapshot {
6
+ readonly status: "serving" | "draining";
7
+ readonly draining: boolean;
8
+ readonly startedAt?: string;
9
+ readonly deadlineAt?: string;
10
+ readonly deadlineMs: number;
11
+ }
12
+ export interface PrismDrainController {
13
+ readonly isDraining: boolean;
14
+ beginDrain(options?: {
15
+ readonly deadlineMs?: number;
16
+ }): PrismDrainSnapshot;
17
+ /** Rejects new admits while draining (503). Control-plane reads may skip this. */
18
+ assertAdmit(): void;
19
+ snapshot(): PrismDrainSnapshot;
20
+ }
21
+ export declare function createPrismDrainController(options?: PrismDrainControllerOptions): PrismDrainController;
22
+ /** Operations that create or continue work; blocked during drain. Status/cancel/list remain open. */
23
+ export declare function isAdmitOperation(operation: string): boolean;
package/dist/drain.js ADDED
@@ -0,0 +1,57 @@
1
+ import { DEFAULT_DRAIN_DEADLINE_MS, HARD_DRAIN_DEADLINE_MS, resolvePrismDeploymentLimits } from "./limits.js";
2
+ import { PrismServerError } from "./types.js";
3
+ export function createPrismDrainController(options = {}) {
4
+ const defaults = resolvePrismDeploymentLimits({ drainDeadlineMs: options.deadlineMs });
5
+ let draining = false;
6
+ let startedAt;
7
+ let deadlineAt;
8
+ let deadlineMs = defaults.drainDeadlineMs;
9
+ return {
10
+ get isDraining() {
11
+ return draining;
12
+ },
13
+ beginDrain(input) {
14
+ if (!draining) {
15
+ draining = true;
16
+ startedAt = new Date().toISOString();
17
+ deadlineMs = resolveDeadline(input?.deadlineMs ?? options.deadlineMs ?? DEFAULT_DRAIN_DEADLINE_MS);
18
+ deadlineAt = new Date(Date.now() + deadlineMs).toISOString();
19
+ }
20
+ return this.snapshot();
21
+ },
22
+ assertAdmit() {
23
+ if (draining) {
24
+ throw new PrismServerError("Server is draining", 503, "ERR_PRISM_SERVER_DRAINING");
25
+ }
26
+ },
27
+ snapshot() {
28
+ return {
29
+ status: draining ? "draining" : "serving",
30
+ draining,
31
+ ...(startedAt === undefined ? {} : { startedAt }),
32
+ ...(deadlineAt === undefined ? {} : { deadlineAt }),
33
+ deadlineMs,
34
+ };
35
+ },
36
+ };
37
+ }
38
+ function resolveDeadline(value) {
39
+ if (!Number.isSafeInteger(value) || value < 1 || value > HARD_DRAIN_DEADLINE_MS) {
40
+ throw new RangeError(`drainDeadlineMs must be a positive safe integer <= ${HARD_DRAIN_DEADLINE_MS}`);
41
+ }
42
+ return value;
43
+ }
44
+ /** Operations that create or continue work; blocked during drain. Status/cancel/list remain open. */
45
+ export function isAdmitOperation(operation) {
46
+ return (operation === "agent.run" ||
47
+ operation === "agent.stream" ||
48
+ operation === "agent.resume" ||
49
+ operation === "workflow.run" ||
50
+ operation === "workflow.stream" ||
51
+ operation === "workflow.enqueue" ||
52
+ operation === "workflow.resume" ||
53
+ operation === "workflow.replay" ||
54
+ operation === "schedule.create" ||
55
+ operation === "schedule.trigger");
56
+ }
57
+ //# sourceMappingURL=drain.js.map
package/dist/handler.js CHANGED
@@ -1,5 +1,6 @@
1
- import { AgentRunStateError } from "@arnilo/prism";
1
+ import { AgentRunStateError, assertIdentityActive, assertIdentityMatchesOwnership } from "@arnilo/prism";
2
2
  import { cancelWorkflowRun, createWorkflowEventBus, enqueueWorkflow, getWorkflowRun, replayWorkflow, resumeWorkflow, runWorkflow, } from "@arnilo/prism-workflows";
3
+ import { isAdmitOperation } from "./drain.js";
3
4
  import { resolvePrismServerLimits } from "./limits.js";
4
5
  import { PrismServerError } from "./types.js";
5
6
  const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
@@ -39,6 +40,24 @@ export function createPrismHandler(options) {
39
40
  const authorization = await authorize(options, request, route.operation, route.capabilityId, limits.requestTimeoutMs);
40
41
  if (!authorization)
41
42
  throw new PrismServerError("Forbidden", 403, "ERR_PRISM_SERVER_FORBIDDEN");
43
+ if (options.rateLimit) {
44
+ const decision = await options.rateLimit({
45
+ request,
46
+ operation: route.operation,
47
+ capabilityId: route.capabilityId,
48
+ authorization,
49
+ signal: request.signal,
50
+ });
51
+ if (decision !== true) {
52
+ const headers = {};
53
+ if (decision.retryAfterMs !== undefined && Number.isSafeInteger(decision.retryAfterMs) && decision.retryAfterMs > 0) {
54
+ headers["retry-after"] = String(Math.ceil(decision.retryAfterMs / 1000));
55
+ }
56
+ throw new PrismServerError(decision.message ?? "Rate limit exceeded", 429, decision.code ?? "ERR_PRISM_SERVER_RATE_LIMIT", Object.keys(headers).length ? headers : undefined);
57
+ }
58
+ }
59
+ if (options.drain && isAdmitOperation(route.operation))
60
+ options.drain.assertAdmit();
42
61
  if (route.kind.startsWith("schedule-")) {
43
62
  const selectedSchedules = options.schedules;
44
63
  if (!selectedSchedules)
@@ -139,6 +158,7 @@ export function createPrismHandler(options) {
139
158
  const runConfig = {
140
159
  ...runOptions,
141
160
  ownership: authorization.ownership,
161
+ identity: authorization.identity,
142
162
  metadata: { ...runOptions?.metadata, ...authorization.metadata },
143
163
  redactor: options.redactor,
144
164
  signal: owned.signal,
@@ -418,6 +438,15 @@ async function authorize(options, request, operation, capabilityId, timeoutMs) {
418
438
  }
419
439
  if (!result || !hasOwnership(result.ownership))
420
440
  return false;
441
+ if (result.identity) {
442
+ try {
443
+ assertIdentityActive(result.identity);
444
+ assertIdentityMatchesOwnership(result.identity, result.ownership);
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
421
450
  return result;
422
451
  }
423
452
  function hasOwnership(value) {
@@ -693,7 +722,10 @@ function errorResponse(error, limits, options) {
693
722
  const code = mapped?.code ?? (agentState ? "ERR_PRISM_SERVER_NOT_FOUND" : known ? error.code : status === 499 ? "ERR_PRISM_SERVER_ABORTED" : "ERR_PRISM_SERVER_INTERNAL");
694
723
  const message = mapped?.message ?? (agentState ? "Not found" : known ? error.message : status === 499 ? "Request aborted" : "Internal server error");
695
724
  try {
696
- return json({ error: { code, message } }, status, limits, options);
725
+ const response = json({ error: { code, message } }, status, limits, options);
726
+ if (known && error.headers)
727
+ return addHeaders(response, error.headers);
728
+ return response;
697
729
  }
698
730
  catch {
699
731
  return new Response(null, { status });
@@ -0,0 +1,20 @@
1
+ import { type PrismDeploymentLimits } from "./limits.js";
2
+ import type { PrismDrainController } from "./drain.js";
3
+ import { type PrismRequestHandler } from "./types.js";
4
+ export interface CreatePrismHealthHandlerOptions {
5
+ /** Path prefix. Default `/health`. Routes: `/livez`, `/readyz`, and prefix itself. */
6
+ readonly basePath?: string;
7
+ /** Liveness probe. Default always true. Must stay O(1)/bounded. */
8
+ readonly live?: () => boolean | Promise<boolean>;
9
+ /** Readiness probe (deps). Default true. Must stay O(1)/bounded. */
10
+ readonly ready?: () => boolean | Promise<boolean>;
11
+ readonly drain?: PrismDrainController;
12
+ /**
13
+ * Optional extra fields for `?detail=1`. Never include secrets/tenant payloads.
14
+ * Emitted only when `authorizeDetail` returns true.
15
+ */
16
+ readonly detail?: () => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
17
+ readonly authorizeDetail?: (request: Request) => boolean | Promise<boolean>;
18
+ readonly limits?: PrismDeploymentLimits;
19
+ }
20
+ export declare function createPrismHealthHandler(options?: CreatePrismHealthHandlerOptions): PrismRequestHandler;
package/dist/health.js ADDED
@@ -0,0 +1,103 @@
1
+ import { resolvePrismDeploymentLimits, } from "./limits.js";
2
+ import { PrismServerError } from "./types.js";
3
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
4
+ export function createPrismHealthHandler(options = {}) {
5
+ const limits = resolvePrismDeploymentLimits(options.limits);
6
+ const base = normalizeHealthBase(options.basePath ?? "/health");
7
+ return async (request) => {
8
+ try {
9
+ if (request.method !== "GET" && request.method !== "HEAD") {
10
+ throw new PrismServerError("Method not allowed", 405, "ERR_PRISM_SERVER_METHOD");
11
+ }
12
+ const url = new URL(request.url);
13
+ const path = url.pathname.replace(/\/$/, "") || "/";
14
+ const livePath = `${base}/livez`;
15
+ const readyPath = `${base}/readyz`;
16
+ if (path !== base && path !== livePath && path !== readyPath) {
17
+ throw new PrismServerError("Not found", 404, "ERR_PRISM_SERVER_NOT_FOUND");
18
+ }
19
+ const wantDetail = url.searchParams.get("detail") === "1";
20
+ if (wantDetail) {
21
+ const allowed = options.authorizeDetail ? await options.authorizeDetail(request) : false;
22
+ if (!allowed)
23
+ throw new PrismServerError("Forbidden", 403, "ERR_PRISM_SERVER_FORBIDDEN");
24
+ }
25
+ const live = options.live ? await options.live() : true;
26
+ const readyCheck = options.ready ? await options.ready() : true;
27
+ const drainSnap = options.drain?.snapshot();
28
+ const draining = drainSnap?.draining === true;
29
+ const ready = readyCheck && !draining;
30
+ if (path === livePath) {
31
+ return healthJson({ status: live ? "ok" : "fail", live }, live ? 200 : 503, limits, request.method);
32
+ }
33
+ if (path === readyPath) {
34
+ return healthJson({
35
+ status: ready ? "ok" : "fail",
36
+ ready,
37
+ ...(drainSnap ? { draining: drainSnap.draining } : {}),
38
+ }, ready ? 200 : 503, limits, request.method);
39
+ }
40
+ const body = {
41
+ status: live && ready ? "ok" : "fail",
42
+ live,
43
+ ready,
44
+ };
45
+ if (drainSnap) {
46
+ body.drain = {
47
+ status: drainSnap.status,
48
+ draining: drainSnap.draining,
49
+ ...(drainSnap.deadlineAt === undefined ? {} : { deadlineAt: drainSnap.deadlineAt }),
50
+ };
51
+ }
52
+ if (wantDetail && options.detail) {
53
+ const extra = await options.detail();
54
+ assertSafeDetail(extra);
55
+ body.detail = extra;
56
+ }
57
+ const ok = live && ready;
58
+ return healthJson(body, ok ? 200 : 503, limits, request.method);
59
+ }
60
+ catch (error) {
61
+ if (error instanceof PrismServerError) {
62
+ return new Response(JSON.stringify({ error: { code: error.code, message: error.message } }), {
63
+ status: error.status,
64
+ headers: JSON_HEADERS,
65
+ });
66
+ }
67
+ return new Response(JSON.stringify({ error: { code: "ERR_PRISM_SERVER", message: "Health check failed" } }), {
68
+ status: 500,
69
+ headers: JSON_HEADERS,
70
+ });
71
+ }
72
+ };
73
+ }
74
+ function healthJson(body, status, limits, method) {
75
+ const text = JSON.stringify(body);
76
+ if (Buffer.byteLength(text, "utf8") > limits.maxHealthBytes) {
77
+ throw new PrismServerError("Health response too large", 507, "ERR_PRISM_SERVER_HEALTH_LIMIT");
78
+ }
79
+ if (method === "HEAD")
80
+ return new Response(null, { status, headers: JSON_HEADERS });
81
+ return new Response(text, { status, headers: JSON_HEADERS });
82
+ }
83
+ function normalizeHealthBase(basePath) {
84
+ if (!basePath.startsWith("/") || basePath.includes("?") || basePath.includes("#")) {
85
+ throw new PrismServerError("Invalid health basePath", 500, "ERR_PRISM_SERVER_CONFIG");
86
+ }
87
+ return basePath.replace(/\/$/, "") || "/health";
88
+ }
89
+ function assertSafeDetail(value) {
90
+ for (const key of Object.keys(value)) {
91
+ const lower = key.toLowerCase();
92
+ if (lower.includes("secret") ||
93
+ lower.includes("token") ||
94
+ lower.includes("password") ||
95
+ lower.includes("credential") ||
96
+ lower === "authorization" ||
97
+ lower === "prompt" ||
98
+ lower === "body") {
99
+ throw new PrismServerError("Health detail key not allowed", 500, "ERR_PRISM_SERVER_HEALTH_DETAIL");
100
+ }
101
+ }
102
+ }
103
+ //# sourceMappingURL=health.js.map
package/dist/index.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  export { createPrismHandler } from "./handler.js";
2
- export { DEFAULT_MAX_REQUEST_BYTES, HARD_MAX_REQUEST_BYTES, DEFAULT_MAX_RESPONSE_BYTES, HARD_MAX_RESPONSE_BYTES, DEFAULT_MAX_EVENT_BYTES, HARD_MAX_EVENT_BYTES, DEFAULT_MAX_STREAM_BYTES, HARD_MAX_STREAM_BYTES, DEFAULT_MAX_STREAM_EVENTS, HARD_MAX_STREAM_EVENTS, DEFAULT_MAX_CONCURRENT_RUNS, HARD_MAX_CONCURRENT_RUNS, DEFAULT_MAX_QUEUED_EVENTS, HARD_MAX_QUEUED_EVENTS, DEFAULT_REQUEST_TIMEOUT_MS, HARD_REQUEST_TIMEOUT_MS, resolvePrismServerLimits, } from "./limits.js";
3
- export type { PrismServerLimits, ResolvedPrismServerLimits, } from "./limits.js";
2
+ export { createPrismDrainController, isAdmitOperation } from "./drain.js";
3
+ export { createPrismHealthHandler } from "./health.js";
4
+ export { createMemoryRateLimiter } from "./rate-limit.js";
5
+ export { createPrismEventReplay, createPrismReplayHandler } from "./replay.js";
6
+ export { createPrismDeploymentLease, PRISM_DEPLOYMENT_LEASE_NAMESPACE } from "./deployment.js";
7
+ export { DEFAULT_MAX_REQUEST_BYTES, HARD_MAX_REQUEST_BYTES, DEFAULT_MAX_RESPONSE_BYTES, HARD_MAX_RESPONSE_BYTES, DEFAULT_MAX_EVENT_BYTES, HARD_MAX_EVENT_BYTES, DEFAULT_MAX_STREAM_BYTES, HARD_MAX_STREAM_BYTES, DEFAULT_MAX_STREAM_EVENTS, HARD_MAX_STREAM_EVENTS, DEFAULT_MAX_CONCURRENT_RUNS, HARD_MAX_CONCURRENT_RUNS, DEFAULT_MAX_QUEUED_EVENTS, HARD_MAX_QUEUED_EVENTS, DEFAULT_REQUEST_TIMEOUT_MS, HARD_REQUEST_TIMEOUT_MS, DEFAULT_MAX_HEALTH_BYTES, HARD_MAX_HEALTH_BYTES, DEFAULT_DRAIN_DEADLINE_MS, HARD_DRAIN_DEADLINE_MS, DEFAULT_MAX_REPLAY_EVENTS, HARD_MAX_REPLAY_EVENTS, DEFAULT_MAX_REPLAY_CURSOR_BYTES, HARD_MAX_REPLAY_CURSOR_BYTES, resolvePrismServerLimits, resolvePrismDeploymentLimits, } from "./limits.js";
8
+ export type { PrismServerLimits, ResolvedPrismServerLimits, PrismDeploymentLimits, ResolvedPrismDeploymentLimits, } from "./limits.js";
4
9
  export type { PrismServerOperation, PrismServerAuthorization, PrismServerAuthorizationInput, PrismServerAuthorizer, PrismAgentExposure, PrismAgentRunExposure, PrismWorkflowExposure, PrismScheduleExposure, CreatePrismHandlerOptions, PrismRequestHandler, } from "./types.js";
10
+ export type { PrismDrainController, PrismDrainControllerOptions, PrismDrainSnapshot } from "./drain.js";
11
+ export type { CreatePrismHealthHandlerOptions } from "./health.js";
12
+ export type { PrismServerRateLimiter, PrismServerRateLimitDenial, PrismServerRateLimitInput, MemoryRateLimiterOptions, } from "./rate-limit.js";
13
+ export type { PrismEventReplay, PrismEventReplayRequest, CreatePrismEventReplayOptions, CreatePrismReplayHandlerOptions, } from "./replay.js";
14
+ export type { PrismDeploymentLease, PrismDeploymentLeaseOptions } from "./deployment.js";
5
15
  export { PrismServerError } from "./types.js";
6
16
  export declare const packageName = "@arnilo/prism-server";
package/dist/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  export { createPrismHandler } from "./handler.js";
2
- export { DEFAULT_MAX_REQUEST_BYTES, HARD_MAX_REQUEST_BYTES, DEFAULT_MAX_RESPONSE_BYTES, HARD_MAX_RESPONSE_BYTES, DEFAULT_MAX_EVENT_BYTES, HARD_MAX_EVENT_BYTES, DEFAULT_MAX_STREAM_BYTES, HARD_MAX_STREAM_BYTES, DEFAULT_MAX_STREAM_EVENTS, HARD_MAX_STREAM_EVENTS, DEFAULT_MAX_CONCURRENT_RUNS, HARD_MAX_CONCURRENT_RUNS, DEFAULT_MAX_QUEUED_EVENTS, HARD_MAX_QUEUED_EVENTS, DEFAULT_REQUEST_TIMEOUT_MS, HARD_REQUEST_TIMEOUT_MS, resolvePrismServerLimits, } from "./limits.js";
2
+ export { createPrismDrainController, isAdmitOperation } from "./drain.js";
3
+ export { createPrismHealthHandler } from "./health.js";
4
+ export { createMemoryRateLimiter } from "./rate-limit.js";
5
+ export { createPrismEventReplay, createPrismReplayHandler } from "./replay.js";
6
+ export { createPrismDeploymentLease, PRISM_DEPLOYMENT_LEASE_NAMESPACE } from "./deployment.js";
7
+ export { DEFAULT_MAX_REQUEST_BYTES, HARD_MAX_REQUEST_BYTES, DEFAULT_MAX_RESPONSE_BYTES, HARD_MAX_RESPONSE_BYTES, DEFAULT_MAX_EVENT_BYTES, HARD_MAX_EVENT_BYTES, DEFAULT_MAX_STREAM_BYTES, HARD_MAX_STREAM_BYTES, DEFAULT_MAX_STREAM_EVENTS, HARD_MAX_STREAM_EVENTS, DEFAULT_MAX_CONCURRENT_RUNS, HARD_MAX_CONCURRENT_RUNS, DEFAULT_MAX_QUEUED_EVENTS, HARD_MAX_QUEUED_EVENTS, DEFAULT_REQUEST_TIMEOUT_MS, HARD_REQUEST_TIMEOUT_MS, DEFAULT_MAX_HEALTH_BYTES, HARD_MAX_HEALTH_BYTES, DEFAULT_DRAIN_DEADLINE_MS, HARD_DRAIN_DEADLINE_MS, DEFAULT_MAX_REPLAY_EVENTS, HARD_MAX_REPLAY_EVENTS, DEFAULT_MAX_REPLAY_CURSOR_BYTES, HARD_MAX_REPLAY_CURSOR_BYTES, resolvePrismServerLimits, resolvePrismDeploymentLimits, } from "./limits.js";
3
8
  export { PrismServerError } from "./types.js";
4
9
  export const packageName = "@arnilo/prism-server";
5
10
  //# sourceMappingURL=index.js.map
package/dist/limits.d.ts CHANGED
@@ -14,6 +14,15 @@ export declare const DEFAULT_MAX_QUEUED_EVENTS = 128;
14
14
  export declare const HARD_MAX_QUEUED_EVENTS = 4096;
15
15
  export declare const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
16
16
  export declare const HARD_REQUEST_TIMEOUT_MS: number;
17
+ /** Phase 8 freeze: health body 4 KiB / 64 KiB; drain deadline 30 s / 5 min; replay page 100 / 500; cursor 4 / 16 KiB. */
18
+ export declare const DEFAULT_MAX_HEALTH_BYTES: number;
19
+ export declare const HARD_MAX_HEALTH_BYTES: number;
20
+ export declare const DEFAULT_DRAIN_DEADLINE_MS = 30000;
21
+ export declare const HARD_DRAIN_DEADLINE_MS: number;
22
+ export declare const DEFAULT_MAX_REPLAY_EVENTS = 100;
23
+ export declare const HARD_MAX_REPLAY_EVENTS = 500;
24
+ export declare const DEFAULT_MAX_REPLAY_CURSOR_BYTES: number;
25
+ export declare const HARD_MAX_REPLAY_CURSOR_BYTES: number;
17
26
  export interface PrismServerLimits {
18
27
  readonly maxRequestBytes?: number;
19
28
  readonly maxResponseBytes?: number;
@@ -34,4 +43,17 @@ export interface ResolvedPrismServerLimits {
34
43
  readonly maxQueuedEvents: number;
35
44
  readonly requestTimeoutMs: number;
36
45
  }
46
+ export interface PrismDeploymentLimits {
47
+ readonly maxHealthBytes?: number;
48
+ readonly drainDeadlineMs?: number;
49
+ readonly maxReplayEvents?: number;
50
+ readonly maxReplayCursorBytes?: number;
51
+ }
52
+ export interface ResolvedPrismDeploymentLimits {
53
+ readonly maxHealthBytes: number;
54
+ readonly drainDeadlineMs: number;
55
+ readonly maxReplayEvents: number;
56
+ readonly maxReplayCursorBytes: number;
57
+ }
37
58
  export declare function resolvePrismServerLimits(input?: PrismServerLimits): ResolvedPrismServerLimits;
59
+ export declare function resolvePrismDeploymentLimits(input?: PrismDeploymentLimits): ResolvedPrismDeploymentLimits;
package/dist/limits.js CHANGED
@@ -14,6 +14,15 @@ export const DEFAULT_MAX_QUEUED_EVENTS = 128;
14
14
  export const HARD_MAX_QUEUED_EVENTS = 4096;
15
15
  export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
16
16
  export const HARD_REQUEST_TIMEOUT_MS = 30 * 60_000;
17
+ /** Phase 8 freeze: health body 4 KiB / 64 KiB; drain deadline 30 s / 5 min; replay page 100 / 500; cursor 4 / 16 KiB. */
18
+ export const DEFAULT_MAX_HEALTH_BYTES = 4 * 1024;
19
+ export const HARD_MAX_HEALTH_BYTES = 64 * 1024;
20
+ export const DEFAULT_DRAIN_DEADLINE_MS = 30_000;
21
+ export const HARD_DRAIN_DEADLINE_MS = 5 * 60_000;
22
+ export const DEFAULT_MAX_REPLAY_EVENTS = 100;
23
+ export const HARD_MAX_REPLAY_EVENTS = 500;
24
+ export const DEFAULT_MAX_REPLAY_CURSOR_BYTES = 4 * 1024;
25
+ export const HARD_MAX_REPLAY_CURSOR_BYTES = 16 * 1024;
17
26
  export function resolvePrismServerLimits(input = {}) {
18
27
  return {
19
28
  maxRequestBytes: bounded(input.maxRequestBytes, DEFAULT_MAX_REQUEST_BYTES, HARD_MAX_REQUEST_BYTES, "maxRequestBytes"),
@@ -26,6 +35,14 @@ export function resolvePrismServerLimits(input = {}) {
26
35
  requestTimeoutMs: bounded(input.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, HARD_REQUEST_TIMEOUT_MS, "requestTimeoutMs"),
27
36
  };
28
37
  }
38
+ export function resolvePrismDeploymentLimits(input = {}) {
39
+ return {
40
+ maxHealthBytes: bounded(input.maxHealthBytes, DEFAULT_MAX_HEALTH_BYTES, HARD_MAX_HEALTH_BYTES, "maxHealthBytes"),
41
+ drainDeadlineMs: bounded(input.drainDeadlineMs, DEFAULT_DRAIN_DEADLINE_MS, HARD_DRAIN_DEADLINE_MS, "drainDeadlineMs"),
42
+ maxReplayEvents: bounded(input.maxReplayEvents, DEFAULT_MAX_REPLAY_EVENTS, HARD_MAX_REPLAY_EVENTS, "maxReplayEvents"),
43
+ maxReplayCursorBytes: bounded(input.maxReplayCursorBytes, DEFAULT_MAX_REPLAY_CURSOR_BYTES, HARD_MAX_REPLAY_CURSOR_BYTES, "maxReplayCursorBytes"),
44
+ };
45
+ }
29
46
  function bounded(value, fallback, cap, name) {
30
47
  const resolved = value ?? fallback;
31
48
  if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > cap) {
@@ -0,0 +1,26 @@
1
+ import type { PrismServerAuthorization, PrismServerOperation } from "./types.js";
2
+ export interface PrismServerRateLimitDenial {
3
+ readonly retryAfterMs?: number;
4
+ /** Attributable denial code for hosts/logs. Default ERR_PRISM_SERVER_RATE_LIMIT. */
5
+ readonly code?: string;
6
+ readonly message?: string;
7
+ }
8
+ export interface PrismServerRateLimitInput {
9
+ readonly request: Request;
10
+ readonly operation: PrismServerOperation;
11
+ readonly capabilityId: string;
12
+ readonly authorization: PrismServerAuthorization;
13
+ readonly signal: AbortSignal;
14
+ }
15
+ /** Return `true` to admit; return a denial object to short-circuit with 429. */
16
+ export type PrismServerRateLimiter = (input: PrismServerRateLimitInput) => true | PrismServerRateLimitDenial | Promise<true | PrismServerRateLimitDenial>;
17
+ export interface MemoryRateLimiterOptions {
18
+ readonly maxRequests: number;
19
+ readonly windowMs: number;
20
+ /** Default: identity tenant/account/user + operation. */
21
+ readonly key?: (input: PrismServerRateLimitInput) => string;
22
+ /** Cap distinct keys retained (oldest eviction). Default 1024. */
23
+ readonly maxKeys?: number;
24
+ }
25
+ /** Tiny in-memory sliding window for tests/single-process hosts. Not a distributed limiter. */
26
+ export declare function createMemoryRateLimiter(options: MemoryRateLimiterOptions): PrismServerRateLimiter;
@@ -0,0 +1,41 @@
1
+ /** Tiny in-memory sliding window for tests/single-process hosts. Not a distributed limiter. */
2
+ export function createMemoryRateLimiter(options) {
3
+ if (!Number.isSafeInteger(options.maxRequests) || options.maxRequests < 1) {
4
+ throw new RangeError("maxRequests must be a positive safe integer");
5
+ }
6
+ if (!Number.isSafeInteger(options.windowMs) || options.windowMs < 1) {
7
+ throw new RangeError("windowMs must be a positive safe integer");
8
+ }
9
+ const maxKeys = options.maxKeys ?? 1024;
10
+ if (!Number.isSafeInteger(maxKeys) || maxKeys < 1)
11
+ throw new RangeError("maxKeys must be a positive safe integer");
12
+ const buckets = new Map();
13
+ return (input) => {
14
+ input.signal.throwIfAborted();
15
+ const key = options.key?.(input) ?? defaultKey(input);
16
+ const now = Date.now();
17
+ const windowStart = now - options.windowMs;
18
+ let stamps = (buckets.get(key) ?? []).filter((t) => t > windowStart);
19
+ if (stamps.length >= options.maxRequests) {
20
+ const oldest = stamps[0] ?? now;
21
+ return {
22
+ retryAfterMs: Math.max(1, oldest + options.windowMs - now),
23
+ code: "ERR_PRISM_SERVER_RATE_LIMIT",
24
+ message: "Rate limit exceeded",
25
+ };
26
+ }
27
+ stamps = [...stamps, now];
28
+ if (!buckets.has(key) && buckets.size >= maxKeys) {
29
+ const first = buckets.keys().next().value;
30
+ if (first !== undefined)
31
+ buckets.delete(first);
32
+ }
33
+ buckets.set(key, stamps);
34
+ return true;
35
+ };
36
+ }
37
+ function defaultKey(input) {
38
+ const o = input.authorization.ownership;
39
+ return `${o.tenantId ?? ""}\0${o.accountId ?? ""}\0${o.userId ?? ""}\0${input.operation}\0${input.capabilityId}`;
40
+ }
41
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1,25 @@
1
+ import type { AgentEventRecord, OwnershipScope, PersistencePage, ProductionPersistenceStore } from "@arnilo/prism";
2
+ import { type PrismDeploymentLimits } from "./limits.js";
3
+ export interface PrismEventReplayRequest {
4
+ readonly ownership: OwnershipScope;
5
+ readonly sessionId: string;
6
+ readonly runId: string;
7
+ readonly cursor?: string;
8
+ readonly signal?: AbortSignal;
9
+ }
10
+ export interface PrismEventReplay {
11
+ page(input: PrismEventReplayRequest): Promise<PersistencePage<AgentEventRecord>>;
12
+ }
13
+ export interface CreatePrismEventReplayOptions {
14
+ readonly limits?: PrismDeploymentLimits;
15
+ }
16
+ /** Ownership-scoped, cursor-paginated durable event replay. Does not re-run work. */
17
+ export declare function createPrismEventReplay(store: Pick<ProductionPersistenceStore, "queryEvents">, options?: CreatePrismEventReplayOptions): PrismEventReplay;
18
+ export interface CreatePrismReplayHandlerOptions {
19
+ readonly replay: PrismEventReplay;
20
+ readonly authorize: (request: Request) => false | OwnershipScope | Promise<false | OwnershipScope>;
21
+ readonly basePath?: string;
22
+ readonly limits?: PrismDeploymentLimits;
23
+ }
24
+ /** Optional HTTP adapter: POST `{ sessionId, runId, cursor? }` → ownership-scoped page. */
25
+ export declare function createPrismReplayHandler(options: CreatePrismReplayHandlerOptions): import("./types.js").PrismRequestHandler;
package/dist/replay.js ADDED
@@ -0,0 +1,116 @@
1
+ import { resolvePrismDeploymentLimits, } from "./limits.js";
2
+ import { PrismServerError } from "./types.js";
3
+ /** Ownership-scoped, cursor-paginated durable event replay. Does not re-run work. */
4
+ export function createPrismEventReplay(store, options = {}) {
5
+ const limits = resolvePrismDeploymentLimits(options.limits);
6
+ return {
7
+ async page(input) {
8
+ input.signal?.throwIfAborted();
9
+ assertOwnership(input.ownership);
10
+ if (!input.sessionId || !input.runId) {
11
+ throw new PrismServerError("sessionId and runId are required", 400, "ERR_PRISM_SERVER_REPLAY");
12
+ }
13
+ if (input.cursor !== undefined)
14
+ assertCursor(input.cursor, limits);
15
+ const page = await store.queryEvents({
16
+ sessionId: input.sessionId,
17
+ runId: input.runId,
18
+ cursor: input.cursor,
19
+ limit: limits.maxReplayEvents,
20
+ order: "asc",
21
+ redacted: true,
22
+ ...input.ownership,
23
+ });
24
+ if (page.items.length > limits.maxReplayEvents) {
25
+ throw new PrismServerError("Replay page exceeds limit", 507, "ERR_PRISM_SERVER_REPLAY_LIMIT");
26
+ }
27
+ if (page.items.some((record) => !record.redacted)) {
28
+ throw new PrismServerError("Replay page must be redacted", 500, "ERR_PRISM_SERVER_REPLAY");
29
+ }
30
+ if (page.nextCursor !== undefined)
31
+ assertCursor(page.nextCursor, limits);
32
+ return page;
33
+ },
34
+ };
35
+ }
36
+ /** Optional HTTP adapter: POST `{ sessionId, runId, cursor? }` → ownership-scoped page. */
37
+ export function createPrismReplayHandler(options) {
38
+ const base = (options.basePath ?? "/prism/replay/events").replace(/\/$/, "");
39
+ const limits = resolvePrismDeploymentLimits(options.limits);
40
+ return async (request) => {
41
+ try {
42
+ if (request.method !== "POST") {
43
+ throw new PrismServerError("Method not allowed", 405, "ERR_PRISM_SERVER_METHOD");
44
+ }
45
+ const path = new URL(request.url).pathname.replace(/\/$/, "");
46
+ if (path !== base)
47
+ throw new PrismServerError("Not found", 404, "ERR_PRISM_SERVER_NOT_FOUND");
48
+ const ownership = await options.authorize(request);
49
+ if (!ownership)
50
+ throw new PrismServerError("Forbidden", 403, "ERR_PRISM_SERVER_FORBIDDEN");
51
+ assertOwnership(ownership);
52
+ const body = await readSmallJson(request, limits.maxReplayCursorBytes + 1024);
53
+ const page = await options.replay.page({
54
+ ownership,
55
+ sessionId: readId(body.sessionId, "sessionId"),
56
+ runId: readId(body.runId, "runId"),
57
+ cursor: body.cursor === undefined ? undefined : readCursor(body.cursor, limits),
58
+ signal: request.signal,
59
+ });
60
+ return new Response(JSON.stringify(page), {
61
+ status: 200,
62
+ headers: { "content-type": "application/json; charset=utf-8" },
63
+ });
64
+ }
65
+ catch (error) {
66
+ if (error instanceof PrismServerError) {
67
+ return new Response(JSON.stringify({ error: { code: error.code, message: error.message } }), {
68
+ status: error.status,
69
+ headers: { "content-type": "application/json; charset=utf-8" },
70
+ });
71
+ }
72
+ return new Response(JSON.stringify({ error: { code: "ERR_PRISM_SERVER", message: "Replay failed" } }), {
73
+ status: 500,
74
+ headers: { "content-type": "application/json; charset=utf-8" },
75
+ });
76
+ }
77
+ };
78
+ }
79
+ function assertOwnership(ownership) {
80
+ if (![ownership.tenantId, ownership.accountId, ownership.userId].some((v) => typeof v === "string" && v.length > 0)) {
81
+ throw new PrismServerError("Forbidden", 403, "ERR_PRISM_SERVER_FORBIDDEN");
82
+ }
83
+ }
84
+ function assertCursor(cursor, limits) {
85
+ if (typeof cursor !== "string" || Buffer.byteLength(cursor, "utf8") > limits.maxReplayCursorBytes) {
86
+ throw new PrismServerError("Replay cursor exceeds limit", 400, "ERR_PRISM_SERVER_REPLAY_CURSOR");
87
+ }
88
+ }
89
+ function readCursor(value, limits) {
90
+ if (typeof value !== "string")
91
+ throw new PrismServerError("cursor must be a string", 400, "ERR_PRISM_SERVER_BODY");
92
+ assertCursor(value, limits);
93
+ return value;
94
+ }
95
+ function readId(value, name) {
96
+ if (typeof value !== "string" || !value || value.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(value)) {
97
+ throw new PrismServerError(`Invalid ${name}`, 400, "ERR_PRISM_SERVER_BODY");
98
+ }
99
+ return value;
100
+ }
101
+ async function readSmallJson(request, maxBytes) {
102
+ const text = await request.text();
103
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
104
+ throw new PrismServerError("Request body too large", 413, "ERR_PRISM_SERVER_BODY_LIMIT");
105
+ }
106
+ try {
107
+ const value = JSON.parse(text);
108
+ if (!value || typeof value !== "object" || Array.isArray(value))
109
+ throw new Error("object");
110
+ return value;
111
+ }
112
+ catch {
113
+ throw new PrismServerError("Invalid JSON object body", 400, "ERR_PRISM_SERVER_BODY");
114
+ }
115
+ }
116
+ //# sourceMappingURL=replay.js.map
package/dist/types.d.ts CHANGED
@@ -1,9 +1,13 @@
1
- import type { Agent, AgentRunLifecycle, AgentSession, OwnershipScope, RunOptions, SecretRedactor } from "@arnilo/prism";
1
+ import type { Agent, AgentIdentity, AgentRunLifecycle, AgentSession, OwnershipScope, RunOptions, SecretRedactor } from "@arnilo/prism";
2
2
  import type { RunWorkflowOptions, WorkflowCheckpointAdapter, WorkflowDefinition, WorkflowSchedules } from "@arnilo/prism-workflows";
3
+ import type { PrismDrainController } from "./drain.js";
3
4
  import type { PrismServerLimits } from "./limits.js";
5
+ import type { PrismServerRateLimiter } from "./rate-limit.js";
4
6
  export type PrismServerOperation = "agent.run" | "agent.stream" | "agent.status" | "agent.resume" | "workflow.run" | "workflow.stream" | "workflow.status" | "workflow.cancel" | "workflow.resume" | "workflow.enqueue" | "workflow.replay" | "schedule.create" | "schedule.list" | "schedule.pause" | "schedule.resume" | "schedule.trigger" | "schedule.delete";
5
7
  export interface PrismServerAuthorization {
6
8
  readonly ownership: OwnershipScope;
9
+ /** Host-verified identity; when set must project onto `ownership` without widening. */
10
+ readonly identity?: AgentIdentity;
7
11
  readonly metadata?: Readonly<Record<string, unknown>>;
8
12
  }
9
13
  export interface PrismServerAuthorizationInput {
@@ -40,10 +44,15 @@ export interface CreatePrismHandlerOptions {
40
44
  readonly redactor?: SecretRedactor;
41
45
  readonly limits?: PrismServerLimits;
42
46
  readonly disconnectAborts?: boolean;
47
+ /** Optional graceful drain; blocks admit operations with 503 while draining. */
48
+ readonly drain?: PrismDrainController;
49
+ /** Optional host rate-limit adapter; runs after authorize, before admit/session create. */
50
+ readonly rateLimit?: PrismServerRateLimiter;
43
51
  }
44
52
  export type PrismRequestHandler = (request: Request) => Promise<Response>;
45
53
  export declare class PrismServerError extends Error {
46
54
  readonly status: number;
47
55
  readonly code: string;
48
- constructor(message: string, status?: number, code?: string);
56
+ readonly headers?: Readonly<Record<string, string>> | undefined;
57
+ constructor(message: string, status?: number, code?: string, headers?: Readonly<Record<string, string>> | undefined);
49
58
  }
package/dist/types.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export class PrismServerError extends Error {
2
2
  status;
3
3
  code;
4
- constructor(message, status = 500, code = "ERR_PRISM_SERVER") {
4
+ headers;
5
+ constructor(message, status = 500, code = "ERR_PRISM_SERVER", headers) {
5
6
  super(message);
6
7
  this.status = status;
7
8
  this.code = code;
9
+ this.headers = headers;
8
10
  this.name = "PrismServerError";
9
11
  }
10
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-server",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Optional framework-free Web Request-to-Response handler for explicitly selected Prism agents and workflows.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,8 +25,8 @@
25
25
  "pack:dry-run": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {
28
- "@arnilo/prism": "0.0.12",
29
- "@arnilo/prism-workflows": "0.0.12"
28
+ "@arnilo/prism": "0.0.13",
29
+ "@arnilo/prism-workflows": "0.0.13"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@arnilo/prism": "file:../..",