@archstone/emitter-support
Advanced tools
+74
-1
@@ -298,2 +298,75 @@ import { Lifecycle, IRTool, IR, IRField, IRResourceRegistry } from '@archstone/compiler'; | ||
| } | ||
| /** | ||
| * The store a distributed `SharedWindowRateLimitCounter` writes through. Deliberately ONE | ||
| * method, and deliberately the exact shape Redis's `INCR` + `EXPIRE` pair already has, because | ||
| * that is the shape almost every shared store can honour: **atomically increment the integer at | ||
| * `key`, ensure it expires in at most `ttlSeconds`, and return the value after the increment.** | ||
| * | ||
| * The atomicity requirement is not decorative. Two instances incrementing the same key | ||
| * concurrently must observe two different return values, or the limit silently becomes | ||
| * "N per window per instance" — which is exactly the defect `InMemoryRateLimitCounter` has by | ||
| * construction, moved to a shared store and made harder to see. A read-modify-write over an | ||
| * eventually-consistent KV (Cloudflare KV, S3) does **not** satisfy this; a Redis-compatible | ||
| * store, a Cloudflare Durable Object, or a single SQL row updated with `RETURNING` does. | ||
| */ | ||
| interface SharedCounterStore { | ||
| incrementWithTtl(key: string, ttlSeconds: number): Promise<number>; | ||
| } | ||
| /** | ||
| * A `RateLimitCounter` for multi-instance and edge deployments — the production counterpart to | ||
| * `InMemoryRateLimitCounter`, which is per-process and therefore not a limit at all once more | ||
| * than one process serves traffic. | ||
| * | ||
| * Windowing is **fixed**, identical to `InMemoryRateLimitCounter`'s and for the same reason | ||
| * (the interface leaves it to the implementation, and two shipped implementations that disagree | ||
| * about what a window is would be a trap): time is sliced into non-overlapping | ||
| * `windowSeconds`-wide buckets aligned to the epoch, and the bucket start is folded into the | ||
| * store key. The counter therefore never has to read, compare or reset anything — a new window | ||
| * is simply a new key, which is why a store only needs `INCR`-with-TTL and never a transaction. | ||
| * | ||
| * TTL is `windowSeconds` plus a small grace, so a key outlives its own window slightly rather | ||
| * than expiring underneath a request that is still being counted, and no key survives longer | ||
| * than it can be useful. Nothing depends on the grace value for correctness — a key that | ||
| * expires early can only ever undercount toward zero, never over the limit. | ||
| * | ||
| * **Clock skew across instances is real and bounded here.** Two instances whose clocks differ | ||
| * by less than one window agree on the bucket for all but the instants near a boundary; at the | ||
| * boundary a request may land in the neighbouring bucket. That is the standard fixed-window | ||
| * trade and it is stated rather than hidden: a limit of N per window admits up to 2N across an | ||
| * unlucky boundary pair. Deployments that cannot accept that need a sliding-window store | ||
| * implementation, which this interface permits (the store may key however it likes) but this | ||
| * class does not attempt. | ||
| */ | ||
| declare class SharedWindowRateLimitCounter implements RateLimitCounter { | ||
| private readonly store; | ||
| private readonly now; | ||
| private readonly prefix; | ||
| private readonly graceSeconds; | ||
| constructor(store: SharedCounterStore, opts?: { | ||
| /** Injectable for deterministic tests, exactly as `InMemoryRateLimitCounter` does. */ | ||
| now?: () => number; | ||
| /** Namespace for the store keys, so one Redis/DO can serve several deployments. */ | ||
| prefix?: string; | ||
| /** Extra seconds of key lifetime beyond the window. Correctness does not depend on it. */ | ||
| graceSeconds?: number; | ||
| }); | ||
| increment(key: string, windowSeconds: number): Promise<number>; | ||
| } | ||
| /** | ||
| * Adapter for any Redis-compatible client — `ioredis`, `node-redis`, Upstash's REST client — | ||
| * **duck-typed on purpose**: this package takes no dependency on any of them, and the deployer | ||
| * passes the client they already have. The two methods used are the two every one of them | ||
| * exposes. | ||
| * | ||
| * `incr` then `expire` is two round-trips and is NOT a transaction. That is safe here for one | ||
| * specific reason worth stating: `incr` alone is the atomic part that decides the returned | ||
| * count, and `expire` only bounds the key's lifetime. A crash between them leaves a key with no | ||
| * TTL — it keeps counting for that window and is superseded by the next window's key, so the | ||
| * failure mode is a leaked key, never a missed limit. Deployers who mind the leak can pass a | ||
| * client whose `incr` is a Lua script or pipeline instead; the interface does not care. | ||
| */ | ||
| declare function redisSharedCounterStore(client: { | ||
| incr(key: string): Promise<number>; | ||
| expire(key: string, seconds: number): Promise<unknown>; | ||
| }): SharedCounterStore; | ||
| /** #45 / ADD-45 D-2: the one new reason code this increment adds. Distinct from every code | ||
@@ -596,2 +669,2 @@ * `evaluatePolicy` returns (BR-29's closed four) and from the two lifecycle codes — a client or | ||
| export { type AuditCaller, type AuditSink, type AuditWritable, type BuildExecutionRecordInput, type ExecutionConsumer, type ExecutionDenialReason, type ExecutionPhase, type ExecutionRecord, type ExecutionStatus, type Exposure, type ExposureHint, type HealthStatus, type HintLevel, InMemoryRateLimitCounter, LIFECYCLE_BLOCKED_REASON, LIFECYCLE_UNEVALUATABLE_REASON, type MappingResult, type MappingStatus, type NamedTool, type PolicyCaller, type PolicyDecision, type PolicyDenial, type PolicyDenialReason, RATE_LIMIT_EXCEEDED_REASON, REDACTED, type RateLimitCounter, type RateLimitDecision, type RateLimitDenial, type RateLimitDenialReason, Registry, type ToolNameCollision, applyResponseMapping, auditNow, buildExecutionRecord, combineExposure, contractViolationMessage, emitExecutionRecord, evaluatePolicy, evaluateRateLimit, inputJsonSchema, jsonLinesAuditSink, lifecycleExposure, objectJsonSchema, toolName }; | ||
| export { type AuditCaller, type AuditSink, type AuditWritable, type BuildExecutionRecordInput, type ExecutionConsumer, type ExecutionDenialReason, type ExecutionPhase, type ExecutionRecord, type ExecutionStatus, type Exposure, type ExposureHint, type HealthStatus, type HintLevel, InMemoryRateLimitCounter, LIFECYCLE_BLOCKED_REASON, LIFECYCLE_UNEVALUATABLE_REASON, type MappingResult, type MappingStatus, type NamedTool, type PolicyCaller, type PolicyDecision, type PolicyDenial, type PolicyDenialReason, RATE_LIMIT_EXCEEDED_REASON, REDACTED, type RateLimitCounter, type RateLimitDecision, type RateLimitDenial, type RateLimitDenialReason, Registry, type SharedCounterStore, SharedWindowRateLimitCounter, type ToolNameCollision, applyResponseMapping, auditNow, buildExecutionRecord, combineExposure, contractViolationMessage, emitExecutionRecord, evaluatePolicy, evaluateRateLimit, inputJsonSchema, jsonLinesAuditSink, lifecycleExposure, objectJsonSchema, redisSharedCounterStore, toolName }; |
+42
-3
@@ -360,2 +360,31 @@ // src/lowering.ts | ||
| }; | ||
| var SharedWindowRateLimitCounter = class { | ||
| store; | ||
| now; | ||
| prefix; | ||
| graceSeconds; | ||
| constructor(store, opts = {}) { | ||
| this.store = store; | ||
| this.now = opts.now ?? (() => Date.now()); | ||
| this.prefix = opts.prefix ?? "archstone:rl"; | ||
| this.graceSeconds = opts.graceSeconds ?? 5; | ||
| } | ||
| async increment(key, windowSeconds) { | ||
| const windowMs = windowSeconds * 1e3; | ||
| const windowStart = Math.floor(this.now() / windowMs) * windowMs; | ||
| return this.store.incrementWithTtl( | ||
| `${this.prefix}:${key}:${windowStart}`, | ||
| windowSeconds + this.graceSeconds | ||
| ); | ||
| } | ||
| }; | ||
| function redisSharedCounterStore(client) { | ||
| return { | ||
| async incrementWithTtl(key, ttlSeconds) { | ||
| const count = await client.incr(key); | ||
| if (count === 1) await client.expire(key, ttlSeconds); | ||
| return count; | ||
| } | ||
| }; | ||
| } | ||
| var RATE_LIMIT_EXCEEDED_REASON = "rate_limit_exceeded"; | ||
@@ -378,5 +407,13 @@ var ALLOWED2 = { allowed: true }; | ||
| } | ||
| const counts = await Promise.all( | ||
| rules.map((rule) => counter.increment(rateLimitKey(rule.id, caller.principal), rule.rateLimit.windowSeconds)) | ||
| ); | ||
| let counts; | ||
| try { | ||
| counts = await Promise.all( | ||
| rules.map((rule) => counter.increment(rateLimitKey(rule.id, caller.principal), rule.rateLimit.windowSeconds)) | ||
| ); | ||
| } catch { | ||
| return deny2( | ||
| "policy_unevaluatable", | ||
| `capability '${tool.id}' declares spec.rateLimit but its RateLimitCounter could not be consulted \u2014 refusing (fail-closed)` | ||
| ); | ||
| } | ||
| for (let i = 0; i < rules.length; i++) { | ||
@@ -511,2 +548,3 @@ const { maxInvocations, windowSeconds } = rules[i].rateLimit; | ||
| Registry, | ||
| SharedWindowRateLimitCounter, | ||
| applyResponseMapping, | ||
@@ -524,4 +562,5 @@ auditNow, | ||
| objectJsonSchema, | ||
| redisSharedCounterStore, | ||
| toolName | ||
| }; | ||
| //# sourceMappingURL=index.js.map |
+3
-3
| { | ||
| "name": "@archstone/emitter-support", | ||
| "version": "0.11.7", | ||
| "version": "0.12.0", | ||
| "private": false, | ||
@@ -40,7 +40,7 @@ "type": "module", | ||
| "dependencies": { | ||
| "@archstone/compiler": "0.11.7" | ||
| "@archstone/compiler": "0.12.0" | ||
| }, | ||
| "devDependencies": { | ||
| "tsup": "^8.5.1", | ||
| "@archstone/schema": "0.11.7" | ||
| "@archstone/schema": "0.12.0" | ||
| }, | ||
@@ -47,0 +47,0 @@ "scripts": { |
Sorry, the diff of this file is too big to display
175256
8.32%1218
10.13%+ Added
+ Added
- Removed
- Removed
Updated