@7h3/protocol 0.5.0 → 0.5.1
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/README.md +6 -1317
- package/agentAdapter.d.ts +26 -0
- package/auditLog.d.ts +54 -0
- package/capability.d.ts +67 -0
- package/cborCodec.d.ts +34 -0
- package/conformanceVectors.d.ts +20 -0
- package/encryption.d.ts +85 -0
- package/envelopeCbor.d.ts +33 -0
- package/frameworkAdapters.d.ts +72 -0
- package/gateway.d.ts +53 -0
- package/grpcBinding.d.ts +29 -0
- package/httpBinding.d.ts +50 -0
- package/index.d.ts +39 -0
- package/index.js +3662 -0
- package/keyInfra.d.ts +64 -0
- package/keyRegistry.d.ts +9 -0
- package/keyRotation.d.ts +20 -0
- package/mcpGateway.d.ts +37 -0
- package/mcpTransports.d.ts +62 -0
- package/mcpWrapper.d.ts +83 -0
- package/otel.d.ts +65 -0
- package/package.json +13 -121
- package/policyEnforcer.d.ts +50 -0
- package/policyTelemetryFeedback.d.ts +11 -0
- package/{src/protocol.d.ts → protocol.d.ts} +1 -2
- package/protocolAgent.d.ts +58 -0
- package/protocolBinary.d.ts +8 -0
- package/protocolCapabilities.d.ts +24 -0
- package/protocolReplay.d.ts +35 -0
- package/protocolTransport.d.ts +73 -0
- package/queueBinding.d.ts +43 -0
- package/rateLimiter.d.ts +18 -0
- package/{src/redisClient.ts → redisClient.d.ts} +25 -75
- package/replayStores.d.ts +98 -0
- package/revocation.d.ts +71 -0
- package/routePolicy.d.ts +30 -0
- package/runtimePolicy.d.ts +24 -0
- package/runtimePolicyManager.d.ts +15 -0
- package/runtimePolicyPresets.d.ts +11 -0
- package/signedResponse.d.ts +21 -0
- package/stream.d.ts +59 -0
- package/telemetry.d.ts +79 -0
- package/webhookBinding.d.ts +30 -0
- package/wsBinding.d.ts +52 -0
- package/.dockerignore +0 -19
- package/.github/dependabot.yml +0 -32
- package/.github/workflows/ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -59
- package/.github/workflows/scorecard.yml +0 -37
- package/7h3.example.yaml +0 -125
- package/CHANGELOG.md +0 -152
- package/CONTRIBUTING.md +0 -82
- package/Dockerfile +0 -73
- package/GOVERNANCE.md +0 -62
- package/SECURITY.md +0 -70
- package/bench-results/replay-cache-full-1777891033256.json +0 -10
- package/bench-results/replay-cache-full-1777896317488.json +0 -10
- package/bench-results/replay-cache-full-1777900993184.json +0 -10
- package/bench-results/replay-cache-full-1777901019285.json +0 -10
- package/bench-results/replay-cache-quick-1777870170126.json +0 -10
- package/bench-results/signature-profiles-quick-1775875160079.json +0 -85
- package/bench-results/signature-profiles-quick-1775983539716.json +0 -85
- package/bench-results/signature-profiles-quick-1776237913190.json +0 -85
- package/bench-results/wire-codecs-full-1777891019803.json +0 -93
- package/bench-results/wire-codecs-full-1777896260964.json +0 -93
- package/bench-results/wire-codecs-full-1777901004247.json +0 -93
- package/bench-results/wire-codecs-quick-1775972879056.json +0 -93
- package/bench-results/wire-codecs-quick-1775983541111.json +0 -93
- package/bench-results/wire-codecs-quick-1776237914299.json +0 -93
- package/bench-results/wire-codecs-quick-1777841285236.json +0 -93
- package/bench-results/wire-codecs-quick-1777841321772.json +0 -93
- package/bench-results/wire-codecs-quick-1777841330408.json +0 -93
- package/bench-results/wire-codecs-quick-1777852886082.json +0 -93
- package/bench-results/wire-codecs-quick-1777852988773.json +0 -93
- package/bench-results/wire-codecs-quick-1777870188095.json +0 -93
- package/bench-results/wire-codecs-quick-1777870263918.json +0 -93
- package/bench-results/wire-codecs-quick-1777870455034.json +0 -93
- package/bench-results/wire-codecs-quick-1778816163081.json +0 -93
- package/bench-results/wire-codecs-quick-1778843936130.json +0 -93
- package/bin/7h3.ts +0 -406
- package/conformance/7h3_v0_1.json +0 -77
- package/conformance/7h3_v0_1_binary.json +0 -20
- package/conformance/aip_v0_1_binary.json +0 -20
- package/docker-compose.yaml +0 -77
- package/docs/ADOPTION_PLAN.md +0 -120
- package/docs/AGENTS.md +0 -77
- package/docs/AIP_RFC_v0.1.md +0 -97
- package/docs/AI_DECISION_CARD.md +0 -122
- package/docs/AI_RUNTIME_POLICY.json +0 -126
- package/docs/AI_RUNTIME_POLICY.yaml +0 -110
- package/docs/BACKPRESSURE_TUNING.md +0 -65
- package/docs/BENCHMARK_CLAIM_MATRIX.md +0 -42
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +0 -169
- package/docs/BINARY_CODEC_BENCH.md +0 -23
- package/docs/CLEAN_CLONE_RUNBOOK.md +0 -36
- package/docs/CLOCK_SKEW_POLICY.md +0 -30
- package/docs/DISTRIBUTED_REPLAY.md +0 -142
- package/docs/FUZZ_CAMPAIGN.md +0 -121
- package/docs/GATEWAY.md +0 -195
- package/docs/KEY_MANAGEMENT_POLICY.md +0 -53
- package/docs/KEY_REVOCATION.md +0 -69
- package/docs/MCP_WRAPPER.md +0 -159
- package/docs/MIGRATION_GUIDE.md +0 -40
- package/docs/OPERATORS.md +0 -184
- package/docs/PERF_REGRESSION_POLICY.md +0 -34
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +0 -219
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +0 -135
- package/docs/RELEASE_GATE.md +0 -25
- package/docs/RELEASE_NOTES_v0.1.0.md +0 -54
- package/docs/SECURITY_REVIEW_2026-06-05.md +0 -165
- package/docs/TELEMETRY.md +0 -41
- package/docs/THREAT_MODEL.md +0 -89
- package/docs/VERSIONING_POLICY.md +0 -30
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/banner.svg +0 -123
- package/eslint.config.js +0 -15
- package/fuzz/ts/harness-decode.ts +0 -136
- package/fuzz/ts/harness-verify.ts +0 -121
- package/fuzz/ts/run.ts +0 -35
- package/mcp-server/README.md +0 -38
- package/mcp-server/package-lock.json +0 -1187
- package/mcp-server/package.json +0 -35
- package/mcp-server/src/index.ts +0 -236
- package/mcp-server/tsconfig.json +0 -14
- package/scripts/aip-framework-quickstart.ts +0 -110
- package/scripts/aip-mcp-gateway.ts +0 -38
- package/scripts/aip-mcp-wrap-demo.ts +0 -72
- package/scripts/aip-quickstart.ts +0 -60
- package/scripts/bench-diff.ts +0 -118
- package/scripts/bench-protocol-e2e.ts +0 -937
- package/scripts/bench-protocol-openloop.ts +0 -1397
- package/scripts/bench-replay-cache.ts +0 -76
- package/scripts/bench-signature-profiles.ts +0 -180
- package/scripts/bench-wire-codecs.ts +0 -161
- package/scripts/build-binary-conformance.ts +0 -36
- package/scripts/build-release-dashboard.ts +0 -175
- package/scripts/canary-rollout.ts +0 -38
- package/scripts/mcpGatewayCli.test.ts +0 -116
- package/scripts/prepare-aip-package.ts +0 -88
- package/scripts/regen-conformance-sigs.ts +0 -18
- package/scripts/release-gate.ts +0 -19
- package/scripts/validate-runtime-policy.ts +0 -18
- package/sdk/browser/index.test.ts +0 -162
- package/sdk/browser/index.ts +0 -257
- package/sdk/browser/package.json +0 -13
- package/sdk/go/cbor.go +0 -551
- package/sdk/go/cbor_test.go +0 -232
- package/sdk/go/encryption.go +0 -280
- package/sdk/go/encryption_test.go +0 -318
- package/sdk/go/go.mod +0 -7
- package/sdk/go/go.sum +0 -4
- package/sdk/go/http.go +0 -135
- package/sdk/go/protocol.go +0 -324
- package/sdk/go/protocol_test.go +0 -334
- package/sdk/go/replay.go +0 -121
- package/sdk/go/replay_test.go +0 -149
- package/sdk/go/webhook.go +0 -136
- package/sdk/pq/package-lock.json +0 -1358
- package/sdk/pq/package.json +0 -42
- package/sdk/pq/src/index.test.ts +0 -143
- package/sdk/pq/src/index.ts +0 -166
- package/sdk/pq/tsconfig.json +0 -14
- package/sdk/pq/vitest.config.ts +0 -7
- package/sdk/python/README.md +0 -18
- package/sdk/python/protocol_7h3/__init__.py +0 -46
- package/sdk/python/protocol_7h3/encryption.py +0 -252
- package/sdk/python/protocol_7h3/http.py +0 -212
- package/sdk/python/protocol_7h3/keys.py +0 -149
- package/sdk/python/protocol_7h3/pq.py +0 -244
- package/sdk/python/protocol_7h3/protocol.py +0 -525
- package/sdk/python/protocol_7h3/queue.py +0 -118
- package/sdk/python/protocol_7h3/replay.py +0 -98
- package/sdk/python/protocol_7h3/webhook.py +0 -116
- package/sdk/python/pyproject.toml +0 -40
- package/sdk/python/tests/test_conformance.py +0 -110
- package/sdk/python/tests/test_encryption.py +0 -206
- package/sdk/python/tests/test_http.py +0 -305
- package/sdk/python/tests/test_keys.py +0 -417
- package/sdk/python/tests/test_queue.py +0 -120
- package/sdk/python/tests/test_webhook.py +0 -345
- package/sdk/rust/Cargo.lock +0 -371
- package/sdk/rust/Cargo.toml +0 -25
- package/sdk/rust/README.md +0 -31
- package/sdk/rust/fuzz/Cargo.toml +0 -29
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +0 -46
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +0 -11
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +0 -59
- package/sdk/rust/src/http.rs +0 -145
- package/sdk/rust/src/keys.rs +0 -161
- package/sdk/rust/src/lib.rs +0 -688
- package/sdk/rust/src/queue.rs +0 -79
- package/sdk/rust/src/webhook.rs +0 -86
- package/sdk/rust/tests/conformance.rs +0 -148
- package/sdk/rust/tests/gateway.rs +0 -130
- package/sdk/rust/tests/http_webhook_queue.rs +0 -201
- package/sdk/rust/tests/keys.rs +0 -189
- package/sdk/threshold/index.d.ts +0 -68
- package/sdk/threshold/index.d.ts.map +0 -1
- package/sdk/threshold/index.js +0 -254
- package/sdk/threshold/package-lock.json +0 -1361
- package/sdk/threshold/package.json +0 -39
- package/sdk/threshold/src/index.d.ts +0 -68
- package/sdk/threshold/src/index.d.ts.map +0 -1
- package/sdk/threshold/src/index.js +0 -254
- package/sdk/threshold/src/index.test.ts +0 -238
- package/sdk/threshold/src/index.ts +0 -355
- package/sdk/threshold/tsconfig.json +0 -19
- package/sdk/threshold/vitest.config.ts +0 -12
- package/src/agentAdapter.test.ts +0 -48
- package/src/agentAdapter.ts +0 -56
- package/src/auditLog.test.ts +0 -145
- package/src/auditLog.ts +0 -147
- package/src/capability.test.ts +0 -504
- package/src/capability.ts +0 -380
- package/src/cborCodec.test.ts +0 -263
- package/src/cborCodec.ts +0 -339
- package/src/conformance.test.ts +0 -136
- package/src/conformanceVectors.ts +0 -99
- package/src/encryption.test.ts +0 -206
- package/src/encryption.ts +0 -245
- package/src/envelopeCbor.ts +0 -140
- package/src/frameworkAdapters.test.ts +0 -290
- package/src/frameworkAdapters.ts +0 -261
- package/src/gateway.test.ts +0 -343
- package/src/gateway.ts +0 -246
- package/src/grpcBinding.test.ts +0 -211
- package/src/grpcBinding.ts +0 -103
- package/src/httpBinding.test.ts +0 -376
- package/src/httpBinding.ts +0 -189
- package/src/index.ts +0 -39
- package/src/keyInfra.test.ts +0 -278
- package/src/keyInfra.ts +0 -228
- package/src/keyRegistry.ts +0 -59
- package/src/keyRotation.test.ts +0 -78
- package/src/keyRotation.ts +0 -72
- package/src/mcpGateway.test.ts +0 -129
- package/src/mcpGateway.ts +0 -250
- package/src/mcpTransports.test.ts +0 -92
- package/src/mcpTransports.ts +0 -169
- package/src/mcpWrapper.test.ts +0 -179
- package/src/mcpWrapper.ts +0 -206
- package/src/otel.ts +0 -136
- package/src/policyEnforcer.test.ts +0 -99
- package/src/policyEnforcer.ts +0 -169
- package/src/policyTelemetryFeedback.test.ts +0 -25
- package/src/policyTelemetryFeedback.ts +0 -38
- package/src/protocol.bench.ts +0 -37
- package/src/protocol.d.ts.map +0 -1
- package/src/protocol.js +0 -294
- package/src/protocol.test.ts +0 -155
- package/src/protocol.ts +0 -414
- package/src/protocolAgent.test.ts +0 -105
- package/src/protocolAgent.ts +0 -169
- package/src/protocolBinary.test.ts +0 -165
- package/src/protocolBinary.ts +0 -312
- package/src/protocolCapabilities.ts +0 -70
- package/src/protocolFuzz.advanced.test.ts +0 -235
- package/src/protocolFuzz.test.ts +0 -111
- package/src/protocolNegative.test.ts +0 -97
- package/src/protocolReplay.test.ts +0 -71
- package/src/protocolReplay.ts +0 -194
- package/src/protocolTransport.test.ts +0 -556
- package/src/protocolTransport.ts +0 -483
- package/src/queueBinding.test.ts +0 -130
- package/src/queueBinding.ts +0 -102
- package/src/rateLimiter.test.ts +0 -96
- package/src/rateLimiter.ts +0 -46
- package/src/redisIntegration.test.ts +0 -134
- package/src/replayStores.test.ts +0 -273
- package/src/replayStores.ts +0 -215
- package/src/revocation.test.ts +0 -98
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +0 -87
- package/src/routePolicy.ts +0 -72
- package/src/runtimePolicy.test.ts +0 -49
- package/src/runtimePolicy.ts +0 -81
- package/src/runtimePolicyManager.test.ts +0 -29
- package/src/runtimePolicyManager.ts +0 -50
- package/src/runtimePolicyPresets.ts +0 -43
- package/src/signedResponse.test.ts +0 -111
- package/src/signedResponse.ts +0 -83
- package/src/stream.test.ts +0 -254
- package/src/stream.ts +0 -417
- package/src/telemetry.test.ts +0 -251
- package/src/telemetry.ts +0 -299
- package/src/webhookBinding.test.ts +0 -144
- package/src/webhookBinding.ts +0 -115
- package/src/wsBinding.test.ts +0 -221
- package/src/wsBinding.ts +0 -200
- package/tsconfig.json +0 -15
- package/tsconfig.lib.json +0 -23
- package/vite.lib.config.ts +0 -16
- package/vitest.config.ts +0 -11
package/src/replayStores.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import type { DistributedReplayStore } from './protocolReplay'
|
|
2
|
-
import { InMemoryRedisLikeClient, type RedisLikeClient } from './redisClient'
|
|
3
|
-
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// ReplayStore — high-level check() interface (returns true = REPLAY)
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Minimal Redis client surface required by RedisReplayStore.
|
|
10
|
-
*
|
|
11
|
-
* Distinct from the internal RedisLikeClient: this interface uses a `quit()`
|
|
12
|
-
* lifecycle method and does not require pipeline support, making it easy to
|
|
13
|
-
* adapt any Redis client (ioredis, node-redis, Upstash, etc.) without coupling
|
|
14
|
-
* to the internal pipeline abstraction.
|
|
15
|
-
*/
|
|
16
|
-
export interface RedisClientLike {
|
|
17
|
-
set(key: string, value: string, opts?: { nx?: boolean; px?: number }): Promise<string | null>
|
|
18
|
-
get(key: string): Promise<string | null>
|
|
19
|
-
del(key: string): Promise<number | any>
|
|
20
|
-
quit(): Promise<any>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Gateway-facing replay store interface.
|
|
25
|
-
*
|
|
26
|
-
* `check()` atomically marks a key as seen and returns whether it was already
|
|
27
|
-
* present: `false` = fresh (first time seen), `true` = replay (already seen).
|
|
28
|
-
*/
|
|
29
|
-
export interface ReplayStore {
|
|
30
|
-
check(key: string, ttlMs: number): Promise<boolean>
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Redis-backed implementation of {@link ReplayStore}.
|
|
35
|
-
*
|
|
36
|
-
* Uses SET … NX PX for an atomic "set if not exists with TTL" operation.
|
|
37
|
-
* The operation is single-round-trip and safe under concurrent access from
|
|
38
|
-
* multiple gateway instances.
|
|
39
|
-
*/
|
|
40
|
-
export class RedisReplayStore implements ReplayStore {
|
|
41
|
-
private readonly keyPrefix: string
|
|
42
|
-
private readonly client: RedisClientLike
|
|
43
|
-
|
|
44
|
-
constructor(opts: { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike } = {}) {
|
|
45
|
-
this.keyPrefix = opts.keyPrefix ?? '7h3:nonce:'
|
|
46
|
-
if (opts.client) {
|
|
47
|
-
this.client = opts.client
|
|
48
|
-
} else {
|
|
49
|
-
// Lazily adapt InMemoryRedisLikeClient to RedisClientLike for zero-dep default
|
|
50
|
-
const inner = new InMemoryRedisLikeClient()
|
|
51
|
-
this.client = {
|
|
52
|
-
set: (key, value, setOpts) =>
|
|
53
|
-
inner.set(key, value, { nx: setOpts?.nx, pxMs: setOpts?.px }),
|
|
54
|
-
get: (key) => inner.get ? inner.get(key) : Promise.resolve(null),
|
|
55
|
-
del: (key) => inner.del ? inner.del(key) : Promise.resolve(0),
|
|
56
|
-
quit: () => Promise.resolve(),
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Returns `false` if the key is fresh (first time seen — key was set in Redis),
|
|
63
|
-
* or `true` if the key is a replay (key already existed — SET NX returned null).
|
|
64
|
-
*/
|
|
65
|
-
async check(key: string, ttlMs: number): Promise<boolean> {
|
|
66
|
-
const redisKey = `${this.keyPrefix}${key}`
|
|
67
|
-
const result = await this.client.set(redisKey, '1', { nx: true, px: Math.max(1, ttlMs) })
|
|
68
|
-
// SET NX returns 'OK' when the key was newly set (fresh), null when already present (replay)
|
|
69
|
-
return result === null
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Cluster-aware replay store that wraps multiple {@link RedisReplayStore}
|
|
75
|
-
* instances — one per Redis shard / node.
|
|
76
|
-
*
|
|
77
|
-
* A message is treated as a replay if ANY node has already seen the nonce.
|
|
78
|
-
* All nodes are checked in parallel via Promise.all.
|
|
79
|
-
*/
|
|
80
|
-
export class ClusterRedisReplayStore implements ReplayStore {
|
|
81
|
-
private readonly nodes: RedisReplayStore[]
|
|
82
|
-
|
|
83
|
-
constructor(nodes: RedisReplayStore[]) {
|
|
84
|
-
this.nodes = nodes
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async check(key: string, ttlMs: number): Promise<boolean> {
|
|
88
|
-
const results = await Promise.all(this.nodes.map((node) => node.check(key, ttlMs)))
|
|
89
|
-
// If any node reports replay (true), the request is a replay
|
|
90
|
-
return results.some((seen) => seen)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Convenience factory that creates a {@link RedisReplayStore} from a URL
|
|
96
|
-
* or options object.
|
|
97
|
-
*/
|
|
98
|
-
export function createRedisReplayStore(
|
|
99
|
-
clientOrOpts: RedisLikeClient | { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike },
|
|
100
|
-
options?: RedisReplayStoreOptions,
|
|
101
|
-
): DistributedReplayStore
|
|
102
|
-
|
|
103
|
-
export function createRedisReplayStore(
|
|
104
|
-
clientOrOpts: RedisLikeClient | { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike },
|
|
105
|
-
options: RedisReplayStoreOptions = {},
|
|
106
|
-
): DistributedReplayStore | RedisReplayStore {
|
|
107
|
-
// Detect old (client-first) vs new (opts-object) call style
|
|
108
|
-
if (isRedisLikeClient(clientOrOpts)) {
|
|
109
|
-
return _createDistributedReplayStore(clientOrOpts, options)
|
|
110
|
-
}
|
|
111
|
-
// New style: return a RedisReplayStore instance
|
|
112
|
-
return new RedisReplayStore(clientOrOpts as { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike })
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function isRedisLikeClient(val: unknown): val is RedisLikeClient {
|
|
116
|
-
return (
|
|
117
|
-
val !== null &&
|
|
118
|
-
typeof val === 'object' &&
|
|
119
|
-
typeof (val as any).set === 'function' &&
|
|
120
|
-
// RedisLikeClient does NOT have a `quit` method — RedisClientLike does
|
|
121
|
-
// Pipeline presence distinguishes internal RedisLikeClient
|
|
122
|
-
(typeof (val as any).pipeline === 'function' || !('quit' in val))
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Creates a {@link ClusterRedisReplayStore} from an array of Redis URLs.
|
|
128
|
-
* Each URL is used to create an independent {@link RedisReplayStore} node.
|
|
129
|
-
*/
|
|
130
|
-
export function createClusterReplayStore(redisUrls: string[]): ClusterRedisReplayStore {
|
|
131
|
-
const nodes = redisUrls.map((url) => new RedisReplayStore({ redisUrl: url }))
|
|
132
|
-
return new ClusterRedisReplayStore(nodes)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* What to do when the Redis client throws (network blip, server down).
|
|
137
|
-
*
|
|
138
|
-
* - `fallback` (default): degrade to a local in-memory store so traffic keeps
|
|
139
|
-
* flowing with single-node replay protection still in force. Quality +
|
|
140
|
-
* reliability + scalability: Redis is never a fleet-wide kill switch.
|
|
141
|
-
* - `reject`: fail closed — deny the message because uniqueness cannot be confirmed.
|
|
142
|
-
* - `allow`: fail open — accept the message (skip the check) to maximise uptime.
|
|
143
|
-
*/
|
|
144
|
-
export type ReplayErrorBehavior = 'fallback' | 'reject' | 'allow'
|
|
145
|
-
|
|
146
|
-
export interface RedisReplayStoreOptions {
|
|
147
|
-
/** Key namespace in Redis. Default `aip:replay:`. */
|
|
148
|
-
keyPrefix?: string
|
|
149
|
-
/** Behavior when the Redis client throws. Default `fallback`. */
|
|
150
|
-
errorBehavior?: ReplayErrorBehavior
|
|
151
|
-
/** Local store used when `errorBehavior` is `fallback`. Defaults to an in-memory-backed store. */
|
|
152
|
-
fallback?: DistributedReplayStore
|
|
153
|
-
/** Observability hook fired whenever a Redis error forces degraded handling. */
|
|
154
|
-
onDegraded?: (error: unknown, context: { key: string; behavior: ReplayErrorBehavior }) => void
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Internal factory — creates a {@link DistributedReplayStore} backed by
|
|
159
|
-
* Redis-style `SET NX PX`. Used by the overloaded `createRedisReplayStore`
|
|
160
|
-
* when a `RedisLikeClient` is passed as the first argument.
|
|
161
|
-
*
|
|
162
|
-
* Drop the returned store into `new DistributedReplayCache(store)` (or pass a
|
|
163
|
-
* `DistributedReplayCache` as the transport `replayCache`).
|
|
164
|
-
*/
|
|
165
|
-
function _createDistributedReplayStore(
|
|
166
|
-
client: RedisLikeClient,
|
|
167
|
-
options: RedisReplayStoreOptions = {},
|
|
168
|
-
): DistributedReplayStore {
|
|
169
|
-
const keyPrefix = options.keyPrefix ?? 'aip:replay:'
|
|
170
|
-
const errorBehavior = options.errorBehavior ?? 'fallback'
|
|
171
|
-
const onDegraded = options.onDegraded
|
|
172
|
-
const fallback =
|
|
173
|
-
options.fallback ??
|
|
174
|
-
(errorBehavior === 'fallback'
|
|
175
|
-
? // The local fallback runs over a non-throwing in-memory client, so it
|
|
176
|
-
// needs no fallback of its own — `reject` terminates the chain.
|
|
177
|
-
_createDistributedReplayStore(new InMemoryRedisLikeClient(), { keyPrefix, errorBehavior: 'reject' })
|
|
178
|
-
: undefined)
|
|
179
|
-
|
|
180
|
-
function degrade(error: unknown, key: string, expiresAtMs: number, nowMs: number): Promise<boolean> | boolean {
|
|
181
|
-
onDegraded?.(error, { key, behavior: errorBehavior })
|
|
182
|
-
if (errorBehavior === 'allow') return true
|
|
183
|
-
if (errorBehavior === 'reject') return false
|
|
184
|
-
return fallback ? fallback.reserve(key, expiresAtMs, nowMs) : false
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
async reserve(key: string, expiresAtMs: number, nowMs: number): Promise<boolean> {
|
|
189
|
-
const pxMs = Math.max(1, expiresAtMs - nowMs)
|
|
190
|
-
try {
|
|
191
|
-
const result = await client.set(`${keyPrefix}${key}`, '1', { nx: true, pxMs })
|
|
192
|
-
return result === 'OK'
|
|
193
|
-
} catch (error) {
|
|
194
|
-
return degrade(error, key, expiresAtMs, nowMs)
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
|
|
198
|
-
async reserveMany(entries: Array<{ key: string; expiresAtMs: number }>, nowMs: number): Promise<boolean[]> {
|
|
199
|
-
if (entries.length === 0) return []
|
|
200
|
-
if (client.pipeline) {
|
|
201
|
-
try {
|
|
202
|
-
const batch = client.pipeline()
|
|
203
|
-
for (const { key, expiresAtMs } of entries) {
|
|
204
|
-
batch.set(`${keyPrefix}${key}`, '1', { nx: true, pxMs: Math.max(1, expiresAtMs - nowMs) })
|
|
205
|
-
}
|
|
206
|
-
const results = await batch.exec()
|
|
207
|
-
return results.map((result) => result === 'OK')
|
|
208
|
-
} catch (error) {
|
|
209
|
-
return Promise.all(entries.map((entry) => degrade(error, entry.key, entry.expiresAtMs, nowMs)))
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return Promise.all(entries.map((entry) => this.reserve(entry.key, entry.expiresAtMs, nowMs)))
|
|
213
|
-
},
|
|
214
|
-
}
|
|
215
|
-
}
|
package/src/revocation.test.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { InMemoryRedisLikeClient } from './redisClient'
|
|
3
|
-
import {
|
|
4
|
-
InMemoryRevocationStore,
|
|
5
|
-
createRedisRevocationStore,
|
|
6
|
-
withRevocationCheck,
|
|
7
|
-
} from './revocation'
|
|
8
|
-
import type { SignatureVerificationMaterial } from './protocol'
|
|
9
|
-
|
|
10
|
-
describe('InMemoryRevocationStore', () => {
|
|
11
|
-
it('reports a key as not revoked until it is revoked', async () => {
|
|
12
|
-
const store = new InMemoryRevocationStore()
|
|
13
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(false)
|
|
14
|
-
await store.revoke('agent.a', 'k1')
|
|
15
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(true)
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('scopes revocation to the exact (sender, keyId) pair', async () => {
|
|
19
|
-
const store = new InMemoryRevocationStore()
|
|
20
|
-
await store.revoke('agent.a', 'k1')
|
|
21
|
-
expect(await store.isRevoked('agent.a', 'k2')).toBe(false)
|
|
22
|
-
expect(await store.isRevoked('agent.b', 'k1')).toBe(false)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('honors a time-bounded revocation (untilMs)', async () => {
|
|
26
|
-
let now = 1000
|
|
27
|
-
const store = new InMemoryRevocationStore({ now: () => now })
|
|
28
|
-
await store.revoke('agent.a', 'k1', { untilMs: 2000 })
|
|
29
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(true)
|
|
30
|
-
now = 2500
|
|
31
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(false)
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
describe('createRedisRevocationStore', () => {
|
|
36
|
-
it('revoke writes a key that isRevoked reads back', async () => {
|
|
37
|
-
const store = createRedisRevocationStore(new InMemoryRedisLikeClient())
|
|
38
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(false)
|
|
39
|
-
await store.revoke('agent.a', 'k1')
|
|
40
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(true)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('caches reads so repeated checks within the freshness window hit Redis once', async () => {
|
|
44
|
-
let now = 1000
|
|
45
|
-
const client = new InMemoryRedisLikeClient({ now: () => now })
|
|
46
|
-
const getSpy = vi.spyOn(client, 'get')
|
|
47
|
-
const store = createRedisRevocationStore(client, { cacheTtlMs: 5000, now: () => now })
|
|
48
|
-
await store.isRevoked('agent.a', 'k1')
|
|
49
|
-
await store.isRevoked('agent.a', 'k1')
|
|
50
|
-
expect(getSpy).toHaveBeenCalledOnce()
|
|
51
|
-
now = 7000 // past the cache window
|
|
52
|
-
await store.isRevoked('agent.a', 'k1')
|
|
53
|
-
expect(getSpy).toHaveBeenCalledTimes(2)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('fails closed by default: a Redis error means the key is treated as revoked', async () => {
|
|
57
|
-
const onDegraded = vi.fn()
|
|
58
|
-
const throwing = {
|
|
59
|
-
set: async () => 'OK' as const,
|
|
60
|
-
get: async () => {
|
|
61
|
-
throw new Error('redis unreachable')
|
|
62
|
-
},
|
|
63
|
-
}
|
|
64
|
-
const store = createRedisRevocationStore(throwing, { onDegraded })
|
|
65
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(true)
|
|
66
|
-
expect(onDegraded).toHaveBeenCalledOnce()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('errorBehavior "allow" treats a Redis error as not revoked', async () => {
|
|
70
|
-
const throwing = {
|
|
71
|
-
set: async () => 'OK' as const,
|
|
72
|
-
get: async () => {
|
|
73
|
-
throw new Error('redis unreachable')
|
|
74
|
-
},
|
|
75
|
-
}
|
|
76
|
-
const store = createRedisRevocationStore(throwing, { errorBehavior: 'allow' })
|
|
77
|
-
expect(await store.isRevoked('agent.a', 'k1')).toBe(false)
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
describe('withRevocationCheck (signature resolver wrapper)', () => {
|
|
82
|
-
const material: SignatureVerificationMaterial = { alg: 'HS256', secret: 's3cret' }
|
|
83
|
-
const innerResolver = async () => material
|
|
84
|
-
const signature = { alg: 'HS256' as const, keyId: 'k1', value: 'sig' }
|
|
85
|
-
|
|
86
|
-
it('returns material for a key that is not revoked', async () => {
|
|
87
|
-
const store = new InMemoryRevocationStore()
|
|
88
|
-
const resolver = withRevocationCheck(innerResolver, store)
|
|
89
|
-
expect(await resolver(signature, 'agent.a')).toEqual(material)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('returns undefined (verification fails) for a revoked key', async () => {
|
|
93
|
-
const store = new InMemoryRevocationStore()
|
|
94
|
-
await store.revoke('agent.a', 'k1')
|
|
95
|
-
const resolver = withRevocationCheck(innerResolver, store)
|
|
96
|
-
expect(await resolver(signature, 'agent.a')).toBeUndefined()
|
|
97
|
-
})
|
|
98
|
-
})
|
package/src/revocation.ts
DELETED
|
Binary file
|
package/src/routePolicy.test.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { matchGlob, matchPolicy, isAllowedSender, type RoutePolicy } from './routePolicy'
|
|
3
|
-
|
|
4
|
-
describe('matchGlob', () => {
|
|
5
|
-
it('exact match', () => {
|
|
6
|
-
expect(matchGlob('/health', '/health')).toBe(true)
|
|
7
|
-
expect(matchGlob('/health', '/healthz')).toBe(false)
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
it('single * does not cross segments', () => {
|
|
11
|
-
expect(matchGlob('/api/*', '/api/users')).toBe(true)
|
|
12
|
-
expect(matchGlob('/api/*', '/api/users/list')).toBe(false)
|
|
13
|
-
expect(matchGlob('/api/*', '/api/')).toBe(true) // empty segment is still "within"
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('double ** matches any depth', () => {
|
|
17
|
-
expect(matchGlob('/api/**', '/api/users')).toBe(true)
|
|
18
|
-
expect(matchGlob('/api/**', '/api/users/list')).toBe(true)
|
|
19
|
-
expect(matchGlob('/api/**', '/api/a/b/c/d')).toBe(true)
|
|
20
|
-
expect(matchGlob('/api/**', '/other/users')).toBe(false)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('? matches single non-slash char', () => {
|
|
24
|
-
expect(matchGlob('/v?/api', '/v1/api')).toBe(true)
|
|
25
|
-
expect(matchGlob('/v?/api', '/v12/api')).toBe(false)
|
|
26
|
-
expect(matchGlob('/v?/api', '/v//api')).toBe(false)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('handles regex metacharacters in literal path', () => {
|
|
30
|
-
expect(matchGlob('/api/v1.0/health', '/api/v1.0/health')).toBe(true)
|
|
31
|
-
expect(matchGlob('/api/v1.0/health', '/api/v100/health')).toBe(false)
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
describe('matchPolicy', () => {
|
|
36
|
-
const policies: RoutePolicy[] = [
|
|
37
|
-
{ path: '/health', require: 'none' },
|
|
38
|
-
{ path: '/api/admin/**', require: 'ed25519', allowedSenders: ['admin-agent'] },
|
|
39
|
-
{ path: '/api/*', require: 'any' },
|
|
40
|
-
{ path: '/webhook/**', require: 'hmac' },
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
it('returns first matching policy (exact before wildcard)', () => {
|
|
44
|
-
const result = matchPolicy(policies, '/health')
|
|
45
|
-
expect(result?.require).toBe('none')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('matches /api/users with /api/*', () => {
|
|
49
|
-
const result = matchPolicy(policies, '/api/users')
|
|
50
|
-
expect(result?.require).toBe('any')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('/api/admin/delete matches /api/admin/** before /api/*', () => {
|
|
54
|
-
const result = matchPolicy(policies, '/api/admin/delete')
|
|
55
|
-
expect(result?.require).toBe('ed25519')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('matches deep webhook path', () => {
|
|
59
|
-
const result = matchPolicy(policies, '/webhook/stripe/events')
|
|
60
|
-
expect(result?.require).toBe('hmac')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('returns null for no match', () => {
|
|
64
|
-
const result = matchPolicy(policies, '/unknown')
|
|
65
|
-
expect(result).toBeNull()
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
describe('isAllowedSender', () => {
|
|
70
|
-
it('allows all senders when allowedSenders not set', () => {
|
|
71
|
-
const policy: RoutePolicy = { path: '/api/*', require: 'any' }
|
|
72
|
-
expect(isAllowedSender(policy, 'any-agent')).toBe(true)
|
|
73
|
-
expect(isAllowedSender(policy, 'another-agent')).toBe(true)
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it('allows only listed senders', () => {
|
|
77
|
-
const policy: RoutePolicy = { path: '/api/*', require: 'ed25519', allowedSenders: ['trusted-agent', 'another-agent'] }
|
|
78
|
-
expect(isAllowedSender(policy, 'trusted-agent')).toBe(true)
|
|
79
|
-
expect(isAllowedSender(policy, 'another-agent')).toBe(true)
|
|
80
|
-
expect(isAllowedSender(policy, 'unknown-agent')).toBe(false)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('empty allowedSenders array allows all', () => {
|
|
84
|
-
const policy: RoutePolicy = { path: '/api/*', require: 'any', allowedSenders: [] }
|
|
85
|
-
expect(isAllowedSender(policy, 'anyone')).toBe(true)
|
|
86
|
-
})
|
|
87
|
-
})
|
package/src/routePolicy.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
export type PolicyRequirement = 'ed25519' | 'hmac' | 'any' | 'none'
|
|
2
|
-
|
|
3
|
-
export interface RoutePolicy {
|
|
4
|
-
path: string
|
|
5
|
-
require: PolicyRequirement
|
|
6
|
-
rateLimit?: { requests: number; windowMs: number }
|
|
7
|
-
allowedSenders?: string[]
|
|
8
|
-
signResponse?: boolean
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Translate a glob pattern to a RegExp.
|
|
13
|
-
* Supported:
|
|
14
|
-
* - exact: '/health'
|
|
15
|
-
* - '?' → any single non-slash char
|
|
16
|
-
* - '*' → any chars within a single segment (no slash)
|
|
17
|
-
* - '**' → any chars at any depth (including slashes)
|
|
18
|
-
*/
|
|
19
|
-
export function matchGlob(pattern: string, path: string): boolean {
|
|
20
|
-
// Build regex from pattern, handling ** before * to avoid double-expanding
|
|
21
|
-
let regexStr = '^'
|
|
22
|
-
let i = 0
|
|
23
|
-
while (i < pattern.length) {
|
|
24
|
-
const ch = pattern[i]
|
|
25
|
-
if (ch === '*') {
|
|
26
|
-
if (pattern[i + 1] === '*') {
|
|
27
|
-
// Double star: matches anything including /
|
|
28
|
-
regexStr += '.*'
|
|
29
|
-
i += 2
|
|
30
|
-
} else {
|
|
31
|
-
// Single star: matches any non-slash sequence
|
|
32
|
-
regexStr += '[^/]*'
|
|
33
|
-
i += 1
|
|
34
|
-
}
|
|
35
|
-
} else if (ch === '?') {
|
|
36
|
-
// Single char, not slash
|
|
37
|
-
regexStr += '[^/]'
|
|
38
|
-
i += 1
|
|
39
|
-
} else {
|
|
40
|
-
// Escape regex metacharacters in literal segments
|
|
41
|
-
regexStr += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
42
|
-
i += 1
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
regexStr += '$'
|
|
46
|
-
|
|
47
|
-
return new RegExp(regexStr).test(path)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Find the first matching policy for a given path.
|
|
52
|
-
* Returns null if no policy matches.
|
|
53
|
-
*/
|
|
54
|
-
export function matchPolicy(policies: RoutePolicy[], path: string): RoutePolicy | null {
|
|
55
|
-
for (const policy of policies) {
|
|
56
|
-
if (matchGlob(policy.path, path)) {
|
|
57
|
-
return policy
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Check whether a sender is permitted by a policy.
|
|
65
|
-
* If allowedSenders is not set, all senders are allowed.
|
|
66
|
-
*/
|
|
67
|
-
export function isAllowedSender(policy: RoutePolicy, sender: string): boolean {
|
|
68
|
-
if (!policy.allowedSenders || policy.allowedSenders.length === 0) {
|
|
69
|
-
return true
|
|
70
|
-
}
|
|
71
|
-
return policy.allowedSenders.includes(sender)
|
|
72
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { loadRuntimePolicy, parseRuntimePolicyJson, validateRuntimePolicy } from './runtimePolicy'
|
|
3
|
-
|
|
4
|
-
describe('runtime policy loader', () => {
|
|
5
|
-
it('parses valid policy JSON', () => {
|
|
6
|
-
const policy = parseRuntimePolicyJson(
|
|
7
|
-
JSON.stringify({
|
|
8
|
-
version: '1.0',
|
|
9
|
-
name: 'gluv-runtime-policy',
|
|
10
|
-
status: 'active',
|
|
11
|
-
}),
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
expect(policy.version).toBe('1.0')
|
|
15
|
-
expect(policy.name).toBe('gluv-runtime-policy')
|
|
16
|
-
expect(policy.status).toBe('active')
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('rejects invalid JSON', () => {
|
|
20
|
-
expect(() => parseRuntimePolicyJson('{')).toThrow(/Invalid runtime policy JSON/i)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('rejects invalid root object', () => {
|
|
24
|
-
expect(() => validateRuntimePolicy('not-an-object')).toThrow(/root must be an object/i)
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('rejects missing required fields', () => {
|
|
28
|
-
expect(() => validateRuntimePolicy({ name: 'x', status: 'active' })).toThrow(/version/i)
|
|
29
|
-
expect(() => validateRuntimePolicy({ version: '1.0', status: 'active' })).toThrow(/name/i)
|
|
30
|
-
expect(() => validateRuntimePolicy({ version: '1.0', name: 'x' })).toThrow(/status/i)
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('loads policy using injected reader and path', async () => {
|
|
34
|
-
const policy = await loadRuntimePolicy({
|
|
35
|
-
path: '/virtual/AI_RUNTIME_POLICY.json',
|
|
36
|
-
readTextFile: async (filePath) => {
|
|
37
|
-
expect(filePath).toBe('/virtual/AI_RUNTIME_POLICY.json')
|
|
38
|
-
return JSON.stringify({
|
|
39
|
-
version: '1.0',
|
|
40
|
-
name: 'gluv-runtime-policy',
|
|
41
|
-
status: 'active',
|
|
42
|
-
owner: 'platform-ai-ops',
|
|
43
|
-
})
|
|
44
|
-
},
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
expect(policy.owner).toBe('platform-ai-ops')
|
|
48
|
-
})
|
|
49
|
-
})
|
package/src/runtimePolicy.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
export interface RuntimePolicy {
|
|
2
|
-
version: string
|
|
3
|
-
name: string
|
|
4
|
-
status: string
|
|
5
|
-
owner?: string
|
|
6
|
-
updated_at?: string
|
|
7
|
-
hard_invariants?: Record<string, unknown>
|
|
8
|
-
slo_defaults?: Record<string, unknown>
|
|
9
|
-
mode_selection?: Record<string, unknown>
|
|
10
|
-
guardrails?: Record<string, unknown>
|
|
11
|
-
tuning?: Record<string, unknown>
|
|
12
|
-
adaptive_flow?: Record<string, unknown>
|
|
13
|
-
benchmark_policy?: Record<string, unknown>
|
|
14
|
-
runtime_decision_tree?: unknown[]
|
|
15
|
-
operator_handoff_required_fields?: string[]
|
|
16
|
-
[key: string]: unknown
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface LoadRuntimePolicyOptions {
|
|
20
|
-
path?: string
|
|
21
|
-
readTextFile?: (filePath: string) => Promise<string>
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function asNonEmptyString(value: unknown, field: string): string {
|
|
29
|
-
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
30
|
-
throw new Error(`Invalid runtime policy: '${field}' must be a non-empty string`)
|
|
31
|
-
}
|
|
32
|
-
return value
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function validateRuntimePolicy(value: unknown): RuntimePolicy {
|
|
36
|
-
if (!isRecord(value)) {
|
|
37
|
-
throw new Error('Invalid runtime policy: root must be an object')
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const version = asNonEmptyString(value.version, 'version')
|
|
41
|
-
const name = asNonEmptyString(value.name, 'name')
|
|
42
|
-
const status = asNonEmptyString(value.status, 'status')
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
...value,
|
|
46
|
-
version,
|
|
47
|
-
name,
|
|
48
|
-
status,
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function parseRuntimePolicyJson(jsonText: string): RuntimePolicy {
|
|
53
|
-
let parsed: unknown
|
|
54
|
-
try {
|
|
55
|
-
parsed = JSON.parse(jsonText)
|
|
56
|
-
} catch (error) {
|
|
57
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
58
|
-
throw new Error(`Invalid runtime policy JSON: ${message}`)
|
|
59
|
-
}
|
|
60
|
-
return validateRuntimePolicy(parsed)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function defaultReadTextFile(filePath: string): Promise<string> {
|
|
64
|
-
if (typeof fetch === 'function') {
|
|
65
|
-
const response = await fetch(filePath)
|
|
66
|
-
if (!response.ok) {
|
|
67
|
-
throw new Error(`Failed to fetch runtime policy at '${filePath}': ${response.status} ${response.statusText}`)
|
|
68
|
-
}
|
|
69
|
-
return response.text()
|
|
70
|
-
}
|
|
71
|
-
throw new Error(
|
|
72
|
-
"No default runtime file reader available in this environment. Provide 'readTextFile' in loadRuntimePolicy(options).",
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export async function loadRuntimePolicy(options: LoadRuntimePolicyOptions = {}): Promise<RuntimePolicy> {
|
|
77
|
-
const filePath = options.path ?? 'AI_RUNTIME_POLICY.json'
|
|
78
|
-
const readTextFile = options.readTextFile ?? defaultReadTextFile
|
|
79
|
-
const text = await readTextFile(filePath)
|
|
80
|
-
return parseRuntimePolicyJson(text)
|
|
81
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { RuntimePolicyManager } from './runtimePolicyManager'
|
|
3
|
-
|
|
4
|
-
describe('runtime policy manager', () => {
|
|
5
|
-
it('loads initial policy and exposes snapshot', async () => {
|
|
6
|
-
const manager = new RuntimePolicyManager({
|
|
7
|
-
path: '/virtual/AI_RUNTIME_POLICY.json',
|
|
8
|
-
readTextFile: async () =>
|
|
9
|
-
JSON.stringify({
|
|
10
|
-
version: '1.0',
|
|
11
|
-
name: 'gluv-runtime-policy',
|
|
12
|
-
status: 'active',
|
|
13
|
-
hard_invariants: {},
|
|
14
|
-
slo_defaults: {},
|
|
15
|
-
tuning: {
|
|
16
|
-
batch_size: { low_traffic: [4, 8], medium_traffic: [8, 32], high_traffic: [32, 64] },
|
|
17
|
-
inflight_cap: { low_traffic: [16, 64], medium_traffic: [64, 256], high_traffic: [256, 1024] },
|
|
18
|
-
retry_backoff_ms: { low_traffic: [1, 2], medium_traffic: [2, 8], high_traffic: [4, 16] },
|
|
19
|
-
max_retry_attempts: 3,
|
|
20
|
-
retriable_status_codes: [503],
|
|
21
|
-
},
|
|
22
|
-
}),
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
const loaded = await manager.loadInitial()
|
|
26
|
-
expect(loaded.policy.name).toBe('gluv-runtime-policy')
|
|
27
|
-
expect(manager.current().policy.version).toBe('1.0')
|
|
28
|
-
})
|
|
29
|
-
})
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { bootstrapRuntimePolicyEnforcer, type BootstrapRuntimePolicyEnforcerOptions, type PolicyEnforcer } from './policyEnforcer'
|
|
2
|
-
import type { RuntimePolicy } from './runtimePolicy'
|
|
3
|
-
|
|
4
|
-
export interface RuntimePolicySnapshot {
|
|
5
|
-
policy: RuntimePolicy
|
|
6
|
-
enforcer: PolicyEnforcer
|
|
7
|
-
loadedAtMs: number
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export class RuntimePolicyManager {
|
|
11
|
-
private snapshot: RuntimePolicySnapshot | null = null
|
|
12
|
-
private readonly options: BootstrapRuntimePolicyEnforcerOptions
|
|
13
|
-
|
|
14
|
-
constructor(options: BootstrapRuntimePolicyEnforcerOptions = {}) {
|
|
15
|
-
this.options = options
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async loadInitial(): Promise<RuntimePolicySnapshot> {
|
|
19
|
-
const loaded = await bootstrapRuntimePolicyEnforcer(this.options)
|
|
20
|
-
this.snapshot = {
|
|
21
|
-
...loaded,
|
|
22
|
-
loadedAtMs: Date.now(),
|
|
23
|
-
}
|
|
24
|
-
return this.snapshot
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
current(): RuntimePolicySnapshot {
|
|
28
|
-
if (!this.snapshot) {
|
|
29
|
-
throw new Error('Runtime policy not loaded. Call loadInitial() first.')
|
|
30
|
-
}
|
|
31
|
-
return this.snapshot
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async refresh(): Promise<RuntimePolicySnapshot> {
|
|
35
|
-
const previous = this.snapshot
|
|
36
|
-
try {
|
|
37
|
-
const loaded = await bootstrapRuntimePolicyEnforcer(this.options)
|
|
38
|
-
this.snapshot = {
|
|
39
|
-
...loaded,
|
|
40
|
-
loadedAtMs: Date.now(),
|
|
41
|
-
}
|
|
42
|
-
return this.snapshot
|
|
43
|
-
} catch (error) {
|
|
44
|
-
if (previous) {
|
|
45
|
-
return previous
|
|
46
|
-
}
|
|
47
|
-
throw error
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|