@7h3/protocol 0.1.2 → 0.5.0
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/.dockerignore +19 -0
- package/.github/dependabot.yml +32 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/publish.yml +59 -0
- package/.github/workflows/scorecard.yml +37 -0
- package/7h3.example.yaml +125 -0
- package/CHANGELOG.md +152 -0
- package/CONTRIBUTING.md +82 -0
- package/Dockerfile +73 -0
- package/GOVERNANCE.md +62 -0
- package/README.md +1317 -6
- package/SECURITY.md +70 -0
- package/bench-results/replay-cache-full-1777891033256.json +10 -0
- package/bench-results/replay-cache-full-1777896317488.json +10 -0
- package/bench-results/replay-cache-full-1777900993184.json +10 -0
- package/bench-results/replay-cache-full-1777901019285.json +10 -0
- package/bench-results/replay-cache-quick-1777870170126.json +10 -0
- package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
- package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
- package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
- package/bench-results/wire-codecs-full-1777891019803.json +93 -0
- package/bench-results/wire-codecs-full-1777896260964.json +93 -0
- package/bench-results/wire-codecs-full-1777901004247.json +93 -0
- package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
- package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
- package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
- package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
- package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
- package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
- package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
- package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
- package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
- package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
- package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
- package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
- package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
- package/bin/7h3.ts +406 -0
- package/conformance/7h3_v0_1.json +77 -0
- package/conformance/7h3_v0_1_binary.json +20 -0
- package/conformance/aip_v0_1_binary.json +20 -0
- package/docker-compose.yaml +77 -0
- package/docs/ADOPTION_PLAN.md +120 -0
- package/docs/AGENTS.md +77 -0
- package/docs/AIP_RFC_v0.1.md +97 -0
- package/docs/AI_DECISION_CARD.md +122 -0
- package/docs/AI_RUNTIME_POLICY.json +126 -0
- package/docs/AI_RUNTIME_POLICY.yaml +110 -0
- package/docs/BACKPRESSURE_TUNING.md +65 -0
- package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
- package/docs/BINARY_CODEC_BENCH.md +23 -0
- package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
- package/docs/CLOCK_SKEW_POLICY.md +30 -0
- package/docs/DISTRIBUTED_REPLAY.md +142 -0
- package/docs/FUZZ_CAMPAIGN.md +121 -0
- package/docs/GATEWAY.md +195 -0
- package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
- package/docs/KEY_REVOCATION.md +69 -0
- package/docs/MCP_WRAPPER.md +159 -0
- package/docs/MIGRATION_GUIDE.md +40 -0
- package/docs/OPERATORS.md +184 -0
- package/docs/PERF_REGRESSION_POLICY.md +34 -0
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
- package/docs/RELEASE_GATE.md +25 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
- package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
- package/docs/TELEMETRY.md +41 -0
- package/docs/THREAT_MODEL.md +89 -0
- package/docs/VERSIONING_POLICY.md +30 -0
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/eslint.config.js +15 -0
- package/fuzz/ts/harness-decode.ts +136 -0
- package/fuzz/ts/harness-verify.ts +121 -0
- package/fuzz/ts/run.ts +35 -0
- package/mcp-server/README.md +38 -0
- package/mcp-server/package-lock.json +1187 -0
- package/mcp-server/package.json +35 -0
- package/mcp-server/src/index.ts +236 -0
- package/mcp-server/tsconfig.json +14 -0
- package/package.json +121 -13
- package/scripts/aip-framework-quickstart.ts +110 -0
- package/scripts/aip-mcp-gateway.ts +38 -0
- package/scripts/aip-mcp-wrap-demo.ts +72 -0
- package/scripts/aip-quickstart.ts +60 -0
- package/scripts/bench-diff.ts +118 -0
- package/scripts/bench-protocol-e2e.ts +937 -0
- package/scripts/bench-protocol-openloop.ts +1397 -0
- package/scripts/bench-replay-cache.ts +76 -0
- package/scripts/bench-signature-profiles.ts +180 -0
- package/scripts/bench-wire-codecs.ts +161 -0
- package/scripts/build-binary-conformance.ts +36 -0
- package/scripts/build-release-dashboard.ts +175 -0
- package/scripts/canary-rollout.ts +38 -0
- package/scripts/mcpGatewayCli.test.ts +116 -0
- package/scripts/prepare-aip-package.ts +88 -0
- package/scripts/regen-conformance-sigs.ts +18 -0
- package/scripts/release-gate.ts +19 -0
- package/scripts/validate-runtime-policy.ts +18 -0
- package/sdk/browser/index.test.ts +162 -0
- package/sdk/browser/index.ts +257 -0
- package/sdk/browser/package.json +13 -0
- package/sdk/go/cbor.go +551 -0
- package/sdk/go/cbor_test.go +232 -0
- package/sdk/go/encryption.go +280 -0
- package/sdk/go/encryption_test.go +318 -0
- package/sdk/go/go.mod +7 -0
- package/sdk/go/go.sum +4 -0
- package/sdk/go/http.go +135 -0
- package/sdk/go/protocol.go +324 -0
- package/sdk/go/protocol_test.go +334 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/go/webhook.go +136 -0
- package/sdk/pq/package-lock.json +1358 -0
- package/sdk/pq/package.json +42 -0
- package/sdk/pq/src/index.test.ts +143 -0
- package/sdk/pq/src/index.ts +166 -0
- package/sdk/pq/tsconfig.json +14 -0
- package/sdk/pq/vitest.config.ts +7 -0
- package/sdk/python/README.md +18 -0
- package/sdk/python/protocol_7h3/__init__.py +46 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/http.py +212 -0
- package/sdk/python/protocol_7h3/keys.py +149 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/protocol.py +525 -0
- package/sdk/python/protocol_7h3/queue.py +118 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/protocol_7h3/webhook.py +116 -0
- package/sdk/python/pyproject.toml +40 -0
- package/sdk/python/tests/test_conformance.py +110 -0
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/python/tests/test_http.py +305 -0
- package/sdk/python/tests/test_keys.py +417 -0
- package/sdk/python/tests/test_queue.py +120 -0
- package/sdk/python/tests/test_webhook.py +345 -0
- package/sdk/rust/Cargo.lock +371 -0
- package/sdk/rust/Cargo.toml +25 -0
- package/sdk/rust/README.md +31 -0
- package/sdk/rust/fuzz/Cargo.toml +29 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
- package/sdk/rust/src/http.rs +145 -0
- package/sdk/rust/src/keys.rs +161 -0
- package/sdk/rust/src/lib.rs +688 -0
- package/sdk/rust/src/queue.rs +79 -0
- package/sdk/rust/src/webhook.rs +86 -0
- package/sdk/rust/tests/conformance.rs +148 -0
- package/sdk/rust/tests/gateway.rs +130 -0
- package/sdk/rust/tests/http_webhook_queue.rs +201 -0
- package/sdk/rust/tests/keys.rs +189 -0
- package/sdk/threshold/index.d.ts +68 -0
- package/sdk/threshold/index.d.ts.map +1 -0
- package/sdk/threshold/index.js +254 -0
- package/sdk/threshold/package-lock.json +1361 -0
- package/sdk/threshold/package.json +39 -0
- package/sdk/threshold/src/index.d.ts +68 -0
- package/sdk/threshold/src/index.d.ts.map +1 -0
- package/sdk/threshold/src/index.js +254 -0
- package/sdk/threshold/src/index.test.ts +238 -0
- package/sdk/threshold/src/index.ts +355 -0
- package/sdk/threshold/tsconfig.json +19 -0
- package/sdk/threshold/vitest.config.ts +12 -0
- package/src/agentAdapter.test.ts +48 -0
- package/src/agentAdapter.ts +56 -0
- package/src/auditLog.test.ts +145 -0
- package/src/auditLog.ts +147 -0
- package/src/capability.test.ts +504 -0
- package/src/capability.ts +380 -0
- package/src/cborCodec.test.ts +263 -0
- package/src/cborCodec.ts +339 -0
- package/src/conformance.test.ts +136 -0
- package/src/conformanceVectors.ts +99 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/frameworkAdapters.test.ts +290 -0
- package/src/frameworkAdapters.ts +261 -0
- package/src/gateway.test.ts +343 -0
- package/src/gateway.ts +246 -0
- package/src/grpcBinding.test.ts +211 -0
- package/src/grpcBinding.ts +103 -0
- package/src/httpBinding.test.ts +376 -0
- package/src/httpBinding.ts +189 -0
- package/src/index.ts +39 -0
- package/src/keyInfra.test.ts +278 -0
- package/src/keyInfra.ts +228 -0
- package/src/keyRegistry.ts +59 -0
- package/src/keyRotation.test.ts +78 -0
- package/src/keyRotation.ts +72 -0
- package/src/mcpGateway.test.ts +129 -0
- package/src/mcpGateway.ts +250 -0
- package/src/mcpTransports.test.ts +92 -0
- package/src/mcpTransports.ts +169 -0
- package/src/mcpWrapper.test.ts +179 -0
- package/src/mcpWrapper.ts +206 -0
- package/src/otel.ts +136 -0
- package/src/policyEnforcer.test.ts +99 -0
- package/src/policyEnforcer.ts +169 -0
- package/src/policyTelemetryFeedback.test.ts +25 -0
- package/src/policyTelemetryFeedback.ts +38 -0
- package/src/protocol.bench.ts +37 -0
- package/{protocol.d.ts → src/protocol.d.ts} +2 -1
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.test.ts +155 -0
- package/src/protocol.ts +414 -0
- package/src/protocolAgent.test.ts +105 -0
- package/src/protocolAgent.ts +169 -0
- package/src/protocolBinary.test.ts +165 -0
- package/src/protocolBinary.ts +312 -0
- package/src/protocolCapabilities.ts +70 -0
- package/src/protocolFuzz.advanced.test.ts +235 -0
- package/src/protocolFuzz.test.ts +111 -0
- package/src/protocolNegative.test.ts +97 -0
- package/src/protocolReplay.test.ts +71 -0
- package/src/protocolReplay.ts +194 -0
- package/src/protocolTransport.test.ts +556 -0
- package/src/protocolTransport.ts +483 -0
- package/src/queueBinding.test.ts +130 -0
- package/src/queueBinding.ts +102 -0
- package/src/rateLimiter.test.ts +96 -0
- package/src/rateLimiter.ts +46 -0
- package/src/redisClient.ts +140 -0
- package/src/redisIntegration.test.ts +134 -0
- package/src/replayStores.test.ts +273 -0
- package/src/replayStores.ts +215 -0
- package/src/revocation.test.ts +98 -0
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +87 -0
- package/src/routePolicy.ts +72 -0
- package/src/runtimePolicy.test.ts +49 -0
- package/src/runtimePolicy.ts +81 -0
- package/src/runtimePolicyManager.test.ts +29 -0
- package/src/runtimePolicyManager.ts +50 -0
- package/src/runtimePolicyPresets.ts +43 -0
- package/src/signedResponse.test.ts +111 -0
- package/src/signedResponse.ts +83 -0
- package/src/stream.test.ts +254 -0
- package/src/stream.ts +417 -0
- package/src/telemetry.test.ts +251 -0
- package/src/telemetry.ts +299 -0
- package/src/webhookBinding.test.ts +144 -0
- package/src/webhookBinding.ts +115 -0
- package/src/wsBinding.test.ts +221 -0
- package/src/wsBinding.ts +200 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +23 -0
- package/vite.lib.config.ts +16 -0
- package/vitest.config.ts +11 -0
- package/agentAdapter.d.ts +0 -26
- package/conformanceVectors.d.ts +0 -20
- package/frameworkAdapters.d.ts +0 -72
- package/index.d.ts +0 -20
- package/index.js +0 -1702
- package/keyRotation.d.ts +0 -20
- package/mcpGateway.d.ts +0 -37
- package/mcpTransports.d.ts +0 -62
- package/mcpWrapper.d.ts +0 -83
- package/policyEnforcer.d.ts +0 -50
- package/policyTelemetryFeedback.d.ts +0 -11
- package/protocolAgent.d.ts +0 -58
- package/protocolBinary.d.ts +0 -8
- package/protocolCapabilities.d.ts +0 -24
- package/protocolReplay.d.ts +0 -35
- package/protocolTransport.d.ts +0 -73
- package/redisClient.d.ts +0 -49
- package/replayStores.d.ts +0 -32
- package/revocation.d.ts +0 -71
- package/runtimePolicy.d.ts +0 -24
- package/runtimePolicyManager.d.ts +0 -15
- package/runtimePolicyPresets.d.ts +0 -11
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ProtocolEnvelope,
|
|
3
|
+
createEnvelope,
|
|
4
|
+
signEnvelopeEd25519,
|
|
5
|
+
verifyEnvelopeEd25519,
|
|
6
|
+
} from './protocol'
|
|
7
|
+
|
|
8
|
+
export interface QueueSignOptions {
|
|
9
|
+
privateKey: string
|
|
10
|
+
sender: string
|
|
11
|
+
recipient?: string
|
|
12
|
+
ttlMs?: number
|
|
13
|
+
keyId?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface QueueVerifyOptions {
|
|
17
|
+
publicKey: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SignedEnvelope = ProtocolEnvelope
|
|
21
|
+
|
|
22
|
+
export interface QueueMessage<T> {
|
|
23
|
+
envelope: SignedEnvelope
|
|
24
|
+
payload: T
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Signs a payload for queue transit (Kafka/SQS/Pub-Sub/RabbitMQ).
|
|
29
|
+
* Returns a JSON string: {"envelope": SignedEnvelope, "payload": T}
|
|
30
|
+
* Default TTL is 1 hour (3 600 000 ms).
|
|
31
|
+
*/
|
|
32
|
+
export async function signQueueMessage<T>(
|
|
33
|
+
payload: T,
|
|
34
|
+
opts: QueueSignOptions,
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
const ttlMs = opts.ttlMs ?? 3_600_000
|
|
37
|
+
const content = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
|
38
|
+
|
|
39
|
+
const envelope = createEnvelope({
|
|
40
|
+
sender: opts.sender,
|
|
41
|
+
recipient: opts.recipient,
|
|
42
|
+
intent: 'TASK',
|
|
43
|
+
content,
|
|
44
|
+
ttlMs,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const signed = await signEnvelopeEd25519(envelope, opts.privateKey, opts.keyId)
|
|
48
|
+
|
|
49
|
+
const message: QueueMessage<T> = { envelope: signed, payload }
|
|
50
|
+
return JSON.stringify(message)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parses a queue message JSON string and verifies the envelope signature.
|
|
55
|
+
* Throws if the message is malformed or the signature is invalid.
|
|
56
|
+
*/
|
|
57
|
+
export async function verifyQueueMessage<T>(
|
|
58
|
+
message: string,
|
|
59
|
+
opts: QueueVerifyOptions,
|
|
60
|
+
): Promise<{ payload: T; envelope: ProtocolEnvelope }> {
|
|
61
|
+
let parsed: QueueMessage<T>
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(message) as QueueMessage<T>
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error('Queue message is not valid JSON')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!parsed.envelope || typeof parsed.envelope !== 'object') {
|
|
69
|
+
throw new Error('Queue message missing envelope')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const valid = await verifyEnvelopeEd25519(parsed.envelope, opts.publicKey)
|
|
73
|
+
if (!valid) {
|
|
74
|
+
throw new Error('Queue message signature verification failed')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { payload: parsed.payload, envelope: parsed.envelope }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verifies a batch of queue message strings.
|
|
82
|
+
* Never throws — returns per-message success/failure objects.
|
|
83
|
+
*/
|
|
84
|
+
export async function verifyQueueBatch<T>(
|
|
85
|
+
messages: string[],
|
|
86
|
+
opts: QueueVerifyOptions,
|
|
87
|
+
): Promise<Array<{ ok: true; payload: T; envelope: ProtocolEnvelope } | { ok: false; raw: string; error: string }>> {
|
|
88
|
+
return Promise.all(
|
|
89
|
+
messages.map(async (raw) => {
|
|
90
|
+
try {
|
|
91
|
+
const { payload, envelope } = await verifyQueueMessage<T>(raw, opts)
|
|
92
|
+
return { ok: true as const, payload, envelope }
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false as const,
|
|
96
|
+
raw,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { SlidingWindowRateLimiter } from './rateLimiter'
|
|
3
|
+
|
|
4
|
+
describe('SlidingWindowRateLimiter', () => {
|
|
5
|
+
let limiter: SlidingWindowRateLimiter
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
limiter = new SlidingWindowRateLimiter()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('check() allows when under limit', () => {
|
|
12
|
+
const result = limiter.check('alice', { requests: 5, windowMs: 1000 })
|
|
13
|
+
expect(result.allowed).toBe(true)
|
|
14
|
+
expect(result.remaining).toBe(4) // 4 left after this one would be consumed
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('check() does not record a timestamp', () => {
|
|
18
|
+
const policy = { requests: 1, windowMs: 1000 }
|
|
19
|
+
limiter.check('alice', policy)
|
|
20
|
+
limiter.check('alice', policy)
|
|
21
|
+
// Still allowed — check doesn't consume
|
|
22
|
+
const result = limiter.check('alice', policy)
|
|
23
|
+
expect(result.allowed).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('consume() records and blocks at limit', () => {
|
|
27
|
+
const policy = { requests: 2, windowMs: 10_000 }
|
|
28
|
+
const r1 = limiter.consume('alice', policy)
|
|
29
|
+
expect(r1.allowed).toBe(true)
|
|
30
|
+
expect(r1.remaining).toBe(1)
|
|
31
|
+
|
|
32
|
+
const r2 = limiter.consume('alice', policy)
|
|
33
|
+
expect(r2.allowed).toBe(true)
|
|
34
|
+
expect(r2.remaining).toBe(0)
|
|
35
|
+
|
|
36
|
+
const r3 = limiter.consume('alice', policy)
|
|
37
|
+
expect(r3.allowed).toBe(false)
|
|
38
|
+
expect(r3.remaining).toBe(0)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('consume() sliding window expires old timestamps', () => {
|
|
42
|
+
const policy = { requests: 2, windowMs: 1000 }
|
|
43
|
+
const baseMs = 10_000
|
|
44
|
+
|
|
45
|
+
// Fill the window at t=0
|
|
46
|
+
limiter.consume('alice', policy, baseMs)
|
|
47
|
+
limiter.consume('alice', policy, baseMs)
|
|
48
|
+
|
|
49
|
+
// Blocked at t=500 (still inside window)
|
|
50
|
+
const blocked = limiter.consume('alice', policy, baseMs + 500)
|
|
51
|
+
expect(blocked.allowed).toBe(false)
|
|
52
|
+
|
|
53
|
+
// Allowed at t=1001 (first entry has expired)
|
|
54
|
+
const allowed = limiter.consume('alice', policy, baseMs + 1001)
|
|
55
|
+
expect(allowed.allowed).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reset() clears a specific key', () => {
|
|
59
|
+
const policy = { requests: 1, windowMs: 10_000 }
|
|
60
|
+
limiter.consume('alice', policy)
|
|
61
|
+
const blocked = limiter.consume('alice', policy)
|
|
62
|
+
expect(blocked.allowed).toBe(false)
|
|
63
|
+
|
|
64
|
+
limiter.reset('alice')
|
|
65
|
+
const allowed = limiter.consume('alice', policy)
|
|
66
|
+
expect(allowed.allowed).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('clear() clears all keys', () => {
|
|
70
|
+
const policy = { requests: 1, windowMs: 10_000 }
|
|
71
|
+
limiter.consume('alice', policy)
|
|
72
|
+
limiter.consume('bob', policy)
|
|
73
|
+
|
|
74
|
+
limiter.clear()
|
|
75
|
+
|
|
76
|
+
expect(limiter.consume('alice', policy).allowed).toBe(true)
|
|
77
|
+
expect(limiter.consume('bob', policy).allowed).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('resetMs is non-zero when window has entries', () => {
|
|
81
|
+
const policy = { requests: 5, windowMs: 1000 }
|
|
82
|
+
const baseMs = 50_000
|
|
83
|
+
limiter.consume('alice', policy, baseMs)
|
|
84
|
+
const result = limiter.check('alice', policy, baseMs + 100)
|
|
85
|
+
expect(result.resetMs).toBeGreaterThan(0)
|
|
86
|
+
expect(result.resetMs).toBeLessThanOrEqual(1000)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('different keys are independent', () => {
|
|
90
|
+
const policy = { requests: 1, windowMs: 10_000 }
|
|
91
|
+
limiter.consume('alice', policy)
|
|
92
|
+
// alice is blocked, but bob is not
|
|
93
|
+
expect(limiter.consume('alice', policy).allowed).toBe(false)
|
|
94
|
+
expect(limiter.consume('bob', policy).allowed).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type RateLimitPolicy = { requests: number; windowMs: number }
|
|
2
|
+
export type RateLimitResult = { allowed: boolean; remaining: number; resetMs: number }
|
|
3
|
+
|
|
4
|
+
class SlidingWindowRateLimiter {
|
|
5
|
+
private windows: Map<string, number[]> = new Map()
|
|
6
|
+
|
|
7
|
+
// Check without recording — pure read
|
|
8
|
+
check(key: string, policy: RateLimitPolicy, nowMs = Date.now()): RateLimitResult {
|
|
9
|
+
const cutoff = nowMs - policy.windowMs
|
|
10
|
+
const timestamps = (this.windows.get(key) ?? []).filter(t => t > cutoff)
|
|
11
|
+
const used = timestamps.length
|
|
12
|
+
const allowed = used < policy.requests
|
|
13
|
+
const remaining = Math.max(0, policy.requests - used - (allowed ? 1 : 0))
|
|
14
|
+
// resetMs: time until the oldest timestamp falls out of window (0 if window empty)
|
|
15
|
+
const resetMs = timestamps.length > 0 ? Math.max(0, timestamps[0] + policy.windowMs - nowMs) : 0
|
|
16
|
+
return { allowed, remaining, resetMs }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Consume — record a timestamp if allowed
|
|
20
|
+
consume(key: string, policy: RateLimitPolicy, nowMs = Date.now()): RateLimitResult {
|
|
21
|
+
const cutoff = nowMs - policy.windowMs
|
|
22
|
+
const timestamps = (this.windows.get(key) ?? []).filter(t => t > cutoff)
|
|
23
|
+
const used = timestamps.length
|
|
24
|
+
const allowed = used < policy.requests
|
|
25
|
+
|
|
26
|
+
if (allowed) {
|
|
27
|
+
timestamps.push(nowMs)
|
|
28
|
+
this.windows.set(key, timestamps)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const remaining = Math.max(0, policy.requests - timestamps.length)
|
|
32
|
+
const resetMs = timestamps.length > 0 ? Math.max(0, timestamps[0] + policy.windowMs - nowMs) : 0
|
|
33
|
+
return { allowed, remaining, resetMs }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
reset(key: string): void {
|
|
37
|
+
this.windows.delete(key)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.windows.clear()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const globalRateLimiter = new SlidingWindowRateLimiter()
|
|
46
|
+
export { SlidingWindowRateLimiter }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Redis command surface the AIP stores depend on.
|
|
3
|
+
*
|
|
4
|
+
* The library stays client-agnostic: any Redis client (ioredis, node-redis,
|
|
5
|
+
* Upstash, a cluster proxy, ...) can be adapted to this interface, so
|
|
6
|
+
* `@7h3/protocol` ships with no Redis dependency of its own.
|
|
7
|
+
*
|
|
8
|
+
* # High-Availability topologies
|
|
9
|
+
*
|
|
10
|
+
* ## Redis Sentinel
|
|
11
|
+
* ioredis connects to a Sentinel group transparently — adapt the same client
|
|
12
|
+
* to `RedisLikeClient` with the same three-line wrapper. The library requires
|
|
13
|
+
* no topology-specific changes:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import Redis from 'ioredis'
|
|
17
|
+
* import { adaptIoredis } from './adaptIoredis' // your adapter module
|
|
18
|
+
*
|
|
19
|
+
* const client = new Redis({
|
|
20
|
+
* sentinels: [
|
|
21
|
+
* { host: 'sentinel-1', port: 26379 },
|
|
22
|
+
* { host: 'sentinel-2', port: 26379 },
|
|
23
|
+
* { host: 'sentinel-3', port: 26379 },
|
|
24
|
+
* ],
|
|
25
|
+
* name: 'mymaster',
|
|
26
|
+
* })
|
|
27
|
+
* const aipRedis = adaptIoredis(client) // same adapter as standalone Redis
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* ## Redis Cluster
|
|
31
|
+
* Cluster mode shards keys across nodes using CRC16. AIP replay keys
|
|
32
|
+
* (`aip:replay:{sender}:{messageId}:{nonce}`) and revocation keys
|
|
33
|
+
* (`aip:revoked:{keyId}`) are independent — no cross-slot transactions are
|
|
34
|
+
* required. Adapt a Cluster client the same way as a standalone client.
|
|
35
|
+
*
|
|
36
|
+
* ## Upstash / serverless Redis
|
|
37
|
+
* Upstash's HTTP-based Redis client exposes a `set(key, value, options)`
|
|
38
|
+
* interface compatible with `RedisLikeClient` without any wrapping.
|
|
39
|
+
*
|
|
40
|
+
* ## Operational requirements
|
|
41
|
+
* - **Clock sync**: AIP TTL checks require clocks within the configured skew
|
|
42
|
+
* window (default ±30 s). NTP or PTP is required across all nodes.
|
|
43
|
+
* - **HA of the replay store itself**: the protocol degrades gracefully on
|
|
44
|
+
* Redis outage (`errorBehavior: 'fallback'` or `'allow'`), but a hard
|
|
45
|
+
* `'reject'` posture prevents replay-store outage from becoming a
|
|
46
|
+
* denial-of-service vector. Choose the posture that matches your threat model.
|
|
47
|
+
* - See `docs/DISTRIBUTED_REPLAY.md` for full operational guidance.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
export interface RedisSetOptions {
|
|
51
|
+
/** Only set when the key does not already exist (Redis `NX`). */
|
|
52
|
+
nx?: boolean
|
|
53
|
+
/** Expire the key after this many milliseconds (Redis `PX`). */
|
|
54
|
+
pxMs?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A queued, chainable batch of `set` commands flushed in a single round-trip. */
|
|
58
|
+
export interface RedisPipelineLike {
|
|
59
|
+
set(key: string, value: string, options?: RedisSetOptions): RedisPipelineLike
|
|
60
|
+
exec(): Promise<Array<'OK' | null>>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RedisLikeClient {
|
|
64
|
+
/** Returns `'OK'` when the value was written, or `null` when `NX` prevented it. */
|
|
65
|
+
set(key: string, value: string, options?: RedisSetOptions): Promise<'OK' | null>
|
|
66
|
+
/** Truthy when the key exists and has not expired. */
|
|
67
|
+
get?(key: string): Promise<string | null>
|
|
68
|
+
/** Removes a key; returns the number of keys removed. */
|
|
69
|
+
del?(key: string): Promise<number>
|
|
70
|
+
/** Optional batch pipeline; when present, batch reserves use a single round-trip. */
|
|
71
|
+
pipeline?(): RedisPipelineLike
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Entry {
|
|
75
|
+
value: string
|
|
76
|
+
expiresAt?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* In-memory reference implementation of {@link RedisLikeClient}.
|
|
81
|
+
*
|
|
82
|
+
* It is a faithful stand-in for a single Redis node's `SET NX PX` / `GET` /
|
|
83
|
+
* `DEL` semantics — useful for tests, local development, and as the default
|
|
84
|
+
* degraded-mode fallback. It is NOT a distributed store: it provides no
|
|
85
|
+
* cross-process guarantees and must not be used as a production replay store
|
|
86
|
+
* for a horizontally scaled deployment.
|
|
87
|
+
*/
|
|
88
|
+
export class InMemoryRedisLikeClient implements RedisLikeClient {
|
|
89
|
+
private readonly entries = new Map<string, Entry>()
|
|
90
|
+
private readonly now: () => number
|
|
91
|
+
|
|
92
|
+
constructor(options: { now?: () => number } = {}) {
|
|
93
|
+
this.now = options.now ?? (() => Date.now())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private live(key: string): Entry | undefined {
|
|
97
|
+
const entry = this.entries.get(key)
|
|
98
|
+
if (!entry) return undefined
|
|
99
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= this.now()) {
|
|
100
|
+
this.entries.delete(key)
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
return entry
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async set(key: string, value: string, options: RedisSetOptions = {}): Promise<'OK' | null> {
|
|
107
|
+
if (options.nx && this.live(key)) return null
|
|
108
|
+
const expiresAt = options.pxMs !== undefined ? this.now() + options.pxMs : undefined
|
|
109
|
+
this.entries.set(key, { value, expiresAt })
|
|
110
|
+
return 'OK'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async get(key: string): Promise<string | null> {
|
|
114
|
+
return this.live(key)?.value ?? null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async del(key: string): Promise<number> {
|
|
118
|
+
const existed = this.live(key) !== undefined
|
|
119
|
+
this.entries.delete(key)
|
|
120
|
+
return existed ? 1 : 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pipeline(): RedisPipelineLike {
|
|
124
|
+
const commands: Array<{ key: string; value: string; options?: RedisSetOptions }> = []
|
|
125
|
+
const pipeline: RedisPipelineLike = {
|
|
126
|
+
set: (key: string, value: string, options?: RedisSetOptions) => {
|
|
127
|
+
commands.push({ key, value, options })
|
|
128
|
+
return pipeline
|
|
129
|
+
},
|
|
130
|
+
exec: async () => {
|
|
131
|
+
const results: Array<'OK' | null> = []
|
|
132
|
+
for (const command of commands) {
|
|
133
|
+
results.push(await this.set(command.key, command.value, command.options))
|
|
134
|
+
}
|
|
135
|
+
return results
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
return pipeline
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import net from 'node:net'
|
|
2
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
3
|
+
import type { RedisLikeClient, RedisSetOptions } from './redisClient'
|
|
4
|
+
import { createRedisReplayStore } from './replayStores'
|
|
5
|
+
import { createRedisRevocationStore } from './revocation'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal RESP client over a raw socket — just enough of the Redis wire
|
|
9
|
+
* protocol to drive SET/GET/DEL. Dependency-free so the integration test can
|
|
10
|
+
* run against a real redis-server without adding an npm Redis client.
|
|
11
|
+
*/
|
|
12
|
+
class NetRedisClient implements RedisLikeClient {
|
|
13
|
+
private socket!: net.Socket
|
|
14
|
+
private buffer = Buffer.alloc(0)
|
|
15
|
+
private readonly waiters: Array<(reply: string | number | null | Error) => void> = []
|
|
16
|
+
|
|
17
|
+
connect(host = '127.0.0.1', port = 6379): Promise<void> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
this.socket = net.createConnection({ host, port }, resolve)
|
|
20
|
+
this.socket.on('error', reject)
|
|
21
|
+
this.socket.on('data', (chunk) => {
|
|
22
|
+
this.buffer = Buffer.concat([this.buffer, chunk])
|
|
23
|
+
this.drain()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close(): void {
|
|
29
|
+
this.socket?.end()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private drain(): void {
|
|
33
|
+
while (this.waiters.length > 0) {
|
|
34
|
+
const parsed = this.parseReply(this.buffer)
|
|
35
|
+
if (!parsed) return
|
|
36
|
+
this.buffer = this.buffer.subarray(parsed.consumed)
|
|
37
|
+
this.waiters.shift()!(parsed.value)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private parseReply(buf: Buffer): { value: string | number | null | Error; consumed: number } | null {
|
|
42
|
+
const end = buf.indexOf('\r\n')
|
|
43
|
+
if (end < 0) return null
|
|
44
|
+
const type = String.fromCharCode(buf[0]!)
|
|
45
|
+
const line = buf.toString('utf8', 1, end)
|
|
46
|
+
const headerConsumed = end + 2
|
|
47
|
+
if (type === '+') return { value: line, consumed: headerConsumed }
|
|
48
|
+
if (type === '-') return { value: new Error(line), consumed: headerConsumed }
|
|
49
|
+
if (type === ':') return { value: Number(line), consumed: headerConsumed }
|
|
50
|
+
if (type === '$') {
|
|
51
|
+
const len = Number(line)
|
|
52
|
+
if (len === -1) return { value: null, consumed: headerConsumed }
|
|
53
|
+
const dataEnd = headerConsumed + len
|
|
54
|
+
if (buf.length < dataEnd + 2) return null
|
|
55
|
+
return { value: buf.toString('utf8', headerConsumed, dataEnd), consumed: dataEnd + 2 }
|
|
56
|
+
}
|
|
57
|
+
return { value: new Error(`unsupported RESP type: ${type}`), consumed: headerConsumed }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private command(args: string[]): Promise<string | number | null> {
|
|
61
|
+
const payload = `*${args.length}\r\n${args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join('')}`
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
this.waiters.push((reply) => (reply instanceof Error ? reject(reply) : resolve(reply)))
|
|
64
|
+
this.socket.write(payload)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async set(key: string, value: string, options: RedisSetOptions = {}): Promise<'OK' | null> {
|
|
69
|
+
const args = ['SET', key, value]
|
|
70
|
+
if (options.pxMs !== undefined) args.push('PX', String(options.pxMs))
|
|
71
|
+
if (options.nx) args.push('NX')
|
|
72
|
+
const reply = await this.command(args)
|
|
73
|
+
return reply === 'OK' ? 'OK' : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async get(key: string): Promise<string | null> {
|
|
77
|
+
const reply = await this.command(['GET', key])
|
|
78
|
+
return reply === null ? null : String(reply)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async del(key: string): Promise<number> {
|
|
82
|
+
const reply = await this.command(['DEL', key])
|
|
83
|
+
return Number(reply)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function redisAvailable(): Promise<NetRedisClient | null> {
|
|
88
|
+
const client = new NetRedisClient()
|
|
89
|
+
try {
|
|
90
|
+
await client.connect()
|
|
91
|
+
return client
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const client = await redisAvailable()
|
|
98
|
+
const runOrSkip = client ? describe : describe.skip
|
|
99
|
+
if (!client) {
|
|
100
|
+
console.warn('[redisIntegration] redis-server unreachable on 127.0.0.1:6379 — skipping live integration tests')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Unique namespace per run so concurrent/previous runs never collide.
|
|
104
|
+
const ns = `aip:test:${Date.now()}:${Math.floor(Math.random() * 1e6)}:`
|
|
105
|
+
|
|
106
|
+
runOrSkip('Redis integration (live server)', () => {
|
|
107
|
+
const touched: string[] = []
|
|
108
|
+
|
|
109
|
+
afterAll(async () => {
|
|
110
|
+
if (!client) return
|
|
111
|
+
for (const key of touched) await client.del(key)
|
|
112
|
+
client.close()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('replay store reserves a key once against real Redis', async () => {
|
|
116
|
+
const prefix = `${ns}replay:`
|
|
117
|
+
const store = createRedisReplayStore(client!, { keyPrefix: prefix })
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
touched.push(`${prefix}s|m1|n1`)
|
|
120
|
+
expect(await store.reserve('s|m1|n1', now + 5000, now)).toBe(true)
|
|
121
|
+
expect(await store.reserve('s|m1|n1', now + 5000, now)).toBe(false)
|
|
122
|
+
expect(await store.reserve('s|m2|n2', now + 5000, now)).toBe(true)
|
|
123
|
+
touched.push(`${prefix}s|m2|n2`)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('revocation store reflects a revoke against real Redis', async () => {
|
|
127
|
+
const prefix = `${ns}revoked:`
|
|
128
|
+
const store = createRedisRevocationStore(client!, { keyPrefix: prefix, cacheTtlMs: 0 })
|
|
129
|
+
touched.push(`${prefix}agent.a agent-k1`)
|
|
130
|
+
expect(await store.isRevoked('agent.a', 'agent-k1')).toBe(false)
|
|
131
|
+
await store.revoke('agent.a', 'agent-k1')
|
|
132
|
+
expect(await store.isRevoked('agent.a', 'agent-k1')).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
})
|