@criterionx/server 0.3.2 → 0.3.4

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/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import * as hono from 'hono';
2
+ import { Context, Next, Hono } from 'hono';
1
3
  import { Decision, Result, JsonSchema } from '@criterionx/core';
2
4
  export { DecisionSchema, JsonSchema, extractDecisionSchema, toJsonSchema } from '@criterionx/core';
3
- import { Hono } from 'hono';
4
5
 
5
6
  /**
6
7
  * Context passed to hooks
@@ -58,6 +59,74 @@ interface MetricsOptions$1 {
58
59
  /** Histogram buckets for latency in seconds */
59
60
  buckets?: number[];
60
61
  }
62
+ /**
63
+ * Log entry emitted for each evaluation request
64
+ */
65
+ interface LogEntry {
66
+ /** Unique request identifier */
67
+ requestId: string;
68
+ /** ID of the decision that was evaluated */
69
+ decisionId: string;
70
+ /** Result status or ERROR */
71
+ status: "OK" | "NO_MATCH" | "INVALID_INPUT" | "INVALID_OUTPUT" | "ERROR";
72
+ /** Duration of the evaluation in milliseconds */
73
+ durationMs: number;
74
+ /** ISO timestamp when the request completed */
75
+ timestamp: string;
76
+ }
77
+ /**
78
+ * Logger function type - user provides their own implementation
79
+ */
80
+ type LoggerFn = (entry: LogEntry) => void;
81
+ /**
82
+ * Logging configuration options
83
+ */
84
+ interface LoggingOptions {
85
+ /** Enable request logging (default: false) */
86
+ enabled?: boolean;
87
+ /** Custom logger function - receives structured log entries */
88
+ logger: LoggerFn;
89
+ }
90
+ /**
91
+ * Rate limit info returned by store
92
+ */
93
+ interface RateLimitInfo {
94
+ /** Current request count in window */
95
+ count: number;
96
+ /** Unix timestamp (ms) when window resets */
97
+ resetTime: number;
98
+ }
99
+ /**
100
+ * Rate limit store interface for custom implementations
101
+ *
102
+ * Implement this interface to use external stores like Redis
103
+ * for distributed rate limiting.
104
+ */
105
+ interface RateLimitStore {
106
+ /** Increment counter for key and return current info */
107
+ increment(key: string): Promise<RateLimitInfo>;
108
+ /** Reset counter for key */
109
+ reset(key: string): Promise<void>;
110
+ }
111
+ /**
112
+ * Rate limiting configuration options
113
+ */
114
+ interface RateLimitOptions {
115
+ /** Enable rate limiting (default: false) */
116
+ enabled?: boolean;
117
+ /** Time window in milliseconds (default: 60000 = 1 minute) */
118
+ windowMs?: number;
119
+ /** Maximum requests per window (default: 100) */
120
+ max?: number;
121
+ /** Custom key generator function (default: client IP) */
122
+ keyGenerator?: (c: hono.Context) => string;
123
+ /** Custom handler for rate limit exceeded (default: 429 JSON response) */
124
+ handler?: (c: hono.Context) => Response;
125
+ /** Skip rate limiting for certain requests (default: skip /health, /metrics) */
126
+ skip?: (c: hono.Context) => boolean;
127
+ /** Custom store for distributed rate limiting (default: in-memory) */
128
+ store?: RateLimitStore;
129
+ }
61
130
  /**
62
131
  * OpenAPI info object
63
132
  */
@@ -111,6 +180,10 @@ interface ServerOptions {
111
180
  metrics?: MetricsOptions$1;
112
181
  /** OpenAPI spec generation configuration */
113
182
  openapi?: OpenAPIOptions;
183
+ /** Request logging configuration */
184
+ logging?: LoggingOptions;
185
+ /** Rate limiting configuration */
186
+ rateLimit?: RateLimitOptions;
114
187
  }
115
188
  /**
116
189
  * Request body for decision evaluation
@@ -118,8 +191,28 @@ interface ServerOptions {
118
191
  interface EvaluateRequest {
119
192
  /** Input data for the decision */
120
193
  input: unknown;
121
- /** Profile to use (overrides default) */
194
+ /** Profile to use (overrides default and profileVersion) */
122
195
  profile?: unknown;
196
+ /** Profile version to use (e.g., "v1", "conservative") */
197
+ profileVersion?: string;
198
+ }
199
+ /**
200
+ * Profile version info for listing
201
+ */
202
+ interface ProfileVersionInfo {
203
+ /** Version identifier (null for default) */
204
+ version: string | null;
205
+ /** Whether this is the default profile */
206
+ isDefault: boolean;
207
+ }
208
+ /**
209
+ * Response for listing profile versions
210
+ */
211
+ interface ProfileListResponse {
212
+ /** Decision ID */
213
+ decisionId: string;
214
+ /** Available profile versions */
215
+ versions: ProfileVersionInfo[];
123
216
  }
124
217
  /**
125
218
  * Decision info for listing
@@ -133,7 +226,7 @@ interface DecisionInfo {
133
226
  /**
134
227
  * Error codes for structured error responses
135
228
  */
136
- type ErrorCode = "DECISION_NOT_FOUND" | "INVALID_JSON" | "MISSING_INPUT" | "MISSING_PROFILE" | "VALIDATION_ERROR" | "EVALUATION_ERROR" | "INTERNAL_ERROR";
229
+ type ErrorCode = "DECISION_NOT_FOUND" | "INVALID_JSON" | "MISSING_INPUT" | "MISSING_PROFILE" | "VALIDATION_ERROR" | "EVALUATION_ERROR" | "INTERNAL_ERROR" | "RATE_LIMIT_EXCEEDED";
137
230
  /**
138
231
  * Structured error response
139
232
  */
@@ -266,9 +359,9 @@ interface Operation {
266
359
  };
267
360
  };
268
361
  };
269
- responses: Record<string, Response>;
362
+ responses: Record<string, Response$1>;
270
363
  }
271
- interface Response {
364
+ interface Response$1 {
272
365
  description: string;
273
366
  content?: {
274
367
  "application/json": {
@@ -287,6 +380,40 @@ declare function generateOpenAPISpec(decisions: Decision<any, any, any>[], info?
287
380
  */
288
381
  declare function generateSwaggerUIHtml(specUrl: string): string;
289
382
 
383
+ /**
384
+ * Default in-memory rate limit store
385
+ *
386
+ * Simple implementation for single-instance deployments.
387
+ * For distributed deployments, use a custom store (e.g., Redis).
388
+ */
389
+ declare class InMemoryRateLimitStore implements RateLimitStore {
390
+ private hits;
391
+ private windowMs;
392
+ constructor(windowMs: number);
393
+ increment(key: string): Promise<RateLimitInfo>;
394
+ reset(key: string): Promise<void>;
395
+ /**
396
+ * Get current info for a key (for testing/debugging)
397
+ */
398
+ getInfo(key: string): RateLimitInfo | undefined;
399
+ /**
400
+ * Clear all entries (for testing)
401
+ */
402
+ clear(): void;
403
+ }
404
+ /**
405
+ * Create rate limiting middleware for Hono
406
+ *
407
+ * @example
408
+ * ```typescript
409
+ * app.use("*", createRateLimitMiddleware({
410
+ * windowMs: 60000, // 1 minute
411
+ * max: 100, // 100 requests per window
412
+ * }));
413
+ * ```
414
+ */
415
+ declare function createRateLimitMiddleware(options: RateLimitOptions): (c: Context, next: Next) => Promise<void | Response>;
416
+
290
417
  /**
291
418
  * Criterion Server
292
419
  *
@@ -302,9 +429,14 @@ declare class CriterionServer {
302
429
  private metricsOptions;
303
430
  private openApiOptions;
304
431
  private openApiSpec;
432
+ private loggingOptions;
305
433
  private startTime;
306
434
  constructor(options: ServerOptions);
307
435
  private setupRoutes;
436
+ /**
437
+ * Get available profile versions for a decision
438
+ */
439
+ private getProfileVersions;
308
440
  private generateDocsHtml;
309
441
  /**
310
442
  * Get the Hono app instance (for custom middleware)
@@ -324,4 +456,4 @@ declare class CriterionServer {
324
456
  */
325
457
  declare function createServer(options: ServerOptions): CriterionServer;
326
458
 
327
- export { type AfterEvaluateHook, type BeforeEvaluateHook, CriterionServer, type DecisionInfo, type ErrorCode, type ErrorResponse, type EvaluateRequest, type HealthResponse, type HealthStatus, type HookContext, type Hooks, METRIC_EVALUATIONS_TOTAL, METRIC_EVALUATION_DURATION_SECONDS, METRIC_RULE_MATCHES_TOTAL, MetricsCollector, type MetricsOptions$1 as MetricsOptions, type OnErrorHook, type OpenAPIInfo, type OpenAPIOptions, type OpenAPISpec, type ServerOptions, createServer, generateEndpointSchema, generateOpenAPISpec, generateSwaggerUIHtml };
459
+ export { type AfterEvaluateHook, type BeforeEvaluateHook, CriterionServer, type DecisionInfo, type ErrorCode, type ErrorResponse, type EvaluateRequest, type HealthResponse, type HealthStatus, type HookContext, type Hooks, InMemoryRateLimitStore, type LogEntry, type LoggerFn, type LoggingOptions, METRIC_EVALUATIONS_TOTAL, METRIC_EVALUATION_DURATION_SECONDS, METRIC_RULE_MATCHES_TOTAL, MetricsCollector, type MetricsOptions$1 as MetricsOptions, type OnErrorHook, type OpenAPIInfo, type OpenAPIOptions, type OpenAPISpec, type ProfileListResponse, type ProfileVersionInfo, type RateLimitInfo, type RateLimitOptions, type RateLimitStore, type ServerOptions, createRateLimitMiddleware, createServer, generateEndpointSchema, generateOpenAPISpec, generateSwaggerUIHtml };
package/dist/index.js CHANGED
@@ -452,6 +452,84 @@ function generateSwaggerUIHtml(specUrl) {
452
452
  </html>`;
453
453
  }
454
454
 
455
+ // src/rate-limit.ts
456
+ var InMemoryRateLimitStore = class {
457
+ hits = /* @__PURE__ */ new Map();
458
+ windowMs;
459
+ constructor(windowMs) {
460
+ this.windowMs = windowMs;
461
+ }
462
+ async increment(key) {
463
+ const now = Date.now();
464
+ const record = this.hits.get(key);
465
+ if (!record || now >= record.resetTime) {
466
+ const resetTime = now + this.windowMs;
467
+ this.hits.set(key, { count: 1, resetTime });
468
+ return { count: 1, resetTime };
469
+ }
470
+ record.count++;
471
+ return { count: record.count, resetTime: record.resetTime };
472
+ }
473
+ async reset(key) {
474
+ this.hits.delete(key);
475
+ }
476
+ /**
477
+ * Get current info for a key (for testing/debugging)
478
+ */
479
+ getInfo(key) {
480
+ return this.hits.get(key);
481
+ }
482
+ /**
483
+ * Clear all entries (for testing)
484
+ */
485
+ clear() {
486
+ this.hits.clear();
487
+ }
488
+ };
489
+ function defaultKeyGenerator(c) {
490
+ return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
491
+ }
492
+ function defaultSkip(c) {
493
+ const path = c.req.path;
494
+ return path === "/health" || path === "/metrics";
495
+ }
496
+ function createRateLimitMiddleware(options) {
497
+ const windowMs = options.windowMs ?? 6e4;
498
+ const max = options.max ?? 100;
499
+ const store = options.store ?? new InMemoryRateLimitStore(windowMs);
500
+ const keyGenerator = options.keyGenerator ?? defaultKeyGenerator;
501
+ const skip = options.skip ?? defaultSkip;
502
+ return async (c, next) => {
503
+ if (skip(c)) {
504
+ return next();
505
+ }
506
+ const key = keyGenerator(c);
507
+ const info = await store.increment(key);
508
+ c.header("X-RateLimit-Limit", String(max));
509
+ c.header("X-RateLimit-Remaining", String(Math.max(0, max - info.count)));
510
+ c.header("X-RateLimit-Reset", String(Math.floor(info.resetTime / 1e3)));
511
+ if (info.count > max) {
512
+ const retryAfter = Math.max(1, Math.ceil((info.resetTime - Date.now()) / 1e3));
513
+ c.header("Retry-After", String(retryAfter));
514
+ if (options.handler) {
515
+ return options.handler(c);
516
+ }
517
+ return c.json(
518
+ {
519
+ error: {
520
+ code: "RATE_LIMIT_EXCEEDED",
521
+ message: "Too many requests, please try again later"
522
+ },
523
+ retryAfter,
524
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
525
+ },
526
+ 429
527
+ );
528
+ }
529
+ return next();
530
+ };
531
+ }
532
+
455
533
  // src/server.ts
456
534
  import { Hono } from "hono";
457
535
  import { cors } from "hono/cors";
@@ -483,6 +561,7 @@ var CriterionServer = class {
483
561
  metricsOptions;
484
562
  openApiOptions;
485
563
  openApiSpec = null;
564
+ loggingOptions = null;
486
565
  startTime;
487
566
  constructor(options) {
488
567
  this.app = new Hono();
@@ -496,6 +575,9 @@ var CriterionServer = class {
496
575
  if (this.metricsOptions.enabled) {
497
576
  this.metricsCollector = new MetricsCollector(this.metricsOptions);
498
577
  }
578
+ if (options.logging?.enabled) {
579
+ this.loggingOptions = options.logging;
580
+ }
499
581
  for (const decision of options.decisions) {
500
582
  this.decisions.set(decision.id, decision);
501
583
  }
@@ -513,6 +595,9 @@ var CriterionServer = class {
513
595
  if (options.cors !== false) {
514
596
  this.app.use("*", cors());
515
597
  }
598
+ if (options.rateLimit?.enabled) {
599
+ this.app.use("*", createRateLimitMiddleware(options.rateLimit));
600
+ }
516
601
  this.setupRoutes();
517
602
  }
518
603
  setupRoutes() {
@@ -628,16 +713,31 @@ var CriterionServer = class {
628
713
  }
629
714
  let profile = body.profile;
630
715
  if (!profile) {
631
- profile = this.profiles.get(id);
632
- if (!profile) {
633
- return c.json(
634
- createErrorResponse(
635
- "MISSING_PROFILE",
636
- `No profile provided and no default profile for decision: ${id}`,
637
- requestId
638
- ),
639
- 400
640
- );
716
+ if (body.profileVersion) {
717
+ const versionedKey = `${id}:${body.profileVersion}`;
718
+ profile = this.profiles.get(versionedKey);
719
+ if (!profile) {
720
+ return c.json(
721
+ createErrorResponse(
722
+ "MISSING_PROFILE",
723
+ `Profile version not found: ${body.profileVersion} for decision: ${id}`,
724
+ requestId
725
+ ),
726
+ 400
727
+ );
728
+ }
729
+ } else {
730
+ profile = this.profiles.get(id);
731
+ if (!profile) {
732
+ return c.json(
733
+ createErrorResponse(
734
+ "MISSING_PROFILE",
735
+ `No profile provided and no default profile for decision: ${id}`,
736
+ requestId
737
+ ),
738
+ 400
739
+ );
740
+ }
641
741
  }
642
742
  }
643
743
  let ctx = {
@@ -679,6 +779,16 @@ var CriterionServer = class {
679
779
  if (this.hooks.afterEvaluate) {
680
780
  await this.hooks.afterEvaluate(ctx, result);
681
781
  }
782
+ if (this.loggingOptions) {
783
+ const entry = {
784
+ requestId,
785
+ decisionId: id,
786
+ status: result.status,
787
+ durationMs: performance.now() - startTime,
788
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
789
+ };
790
+ this.loggingOptions.logger(entry);
791
+ }
682
792
  const statusCode = result.status === "OK" ? 200 : 400;
683
793
  return c.json(result, statusCode);
684
794
  } catch (error) {
@@ -692,6 +802,16 @@ var CriterionServer = class {
692
802
  if (this.hooks.onError) {
693
803
  await this.hooks.onError(ctx, err);
694
804
  }
805
+ if (this.loggingOptions) {
806
+ const entry = {
807
+ requestId,
808
+ decisionId: id,
809
+ status: "ERROR",
810
+ durationMs: performance.now() - startTime,
811
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
812
+ };
813
+ this.loggingOptions.logger(entry);
814
+ }
695
815
  return c.json(
696
816
  createErrorResponse(
697
817
  "EVALUATION_ERROR",
@@ -703,6 +823,18 @@ var CriterionServer = class {
703
823
  );
704
824
  }
705
825
  });
826
+ this.app.get("/decisions/:id/profiles", (c) => {
827
+ const id = c.req.param("id");
828
+ const decision = this.decisions.get(id);
829
+ if (!decision) {
830
+ return c.json(
831
+ createErrorResponse("DECISION_NOT_FOUND", `Decision not found: ${id}`),
832
+ 404
833
+ );
834
+ }
835
+ const versions = this.getProfileVersions(id);
836
+ return c.json({ decisionId: id, versions });
837
+ });
706
838
  this.app.get("/docs", (c) => {
707
839
  const decisions = Array.from(this.decisions.values()).map((d) => ({
708
840
  id: d.id,
@@ -713,6 +845,23 @@ var CriterionServer = class {
713
845
  return c.html(html);
714
846
  });
715
847
  }
848
+ /**
849
+ * Get available profile versions for a decision
850
+ */
851
+ getProfileVersions(decisionId) {
852
+ const versions = [];
853
+ const prefix = `${decisionId}:`;
854
+ if (this.profiles.has(decisionId)) {
855
+ versions.push({ version: null, isDefault: true });
856
+ }
857
+ for (const key of this.profiles.keys()) {
858
+ if (key.startsWith(prefix)) {
859
+ const version = key.slice(prefix.length);
860
+ versions.push({ version, isDefault: false });
861
+ }
862
+ }
863
+ return versions;
864
+ }
716
865
  generateDocsHtml(decisions) {
717
866
  return `<!DOCTYPE html>
718
867
  <html lang="en">
@@ -956,10 +1105,12 @@ function createServer(options) {
956
1105
  }
957
1106
  export {
958
1107
  CriterionServer,
1108
+ InMemoryRateLimitStore,
959
1109
  METRIC_EVALUATIONS_TOTAL,
960
1110
  METRIC_EVALUATION_DURATION_SECONDS,
961
1111
  METRIC_RULE_MATCHES_TOTAL,
962
1112
  MetricsCollector,
1113
+ createRateLimitMiddleware,
963
1114
  createServer,
964
1115
  extractDecisionSchema,
965
1116
  generateEndpointSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@criterionx/server",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "HTTP server for Criterion decisions with auto-generated docs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,7 +42,7 @@
42
42
  "@hono/node-server": "^1.13.0",
43
43
  "hono": "^4.6.0",
44
44
  "zod-to-json-schema": "^3.24.0",
45
- "@criterionx/core": "0.3.2"
45
+ "@criterionx/core": "0.3.4"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "zod": "^3.22.0"