@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.
Files changed (276) hide show
  1. package/.dockerignore +19 -0
  2. package/.github/dependabot.yml +32 -0
  3. package/.github/workflows/ci.yml +31 -0
  4. package/.github/workflows/publish.yml +59 -0
  5. package/.github/workflows/scorecard.yml +37 -0
  6. package/7h3.example.yaml +125 -0
  7. package/CHANGELOG.md +152 -0
  8. package/CONTRIBUTING.md +82 -0
  9. package/Dockerfile +73 -0
  10. package/GOVERNANCE.md +62 -0
  11. package/README.md +1317 -6
  12. package/SECURITY.md +70 -0
  13. package/bench-results/replay-cache-full-1777891033256.json +10 -0
  14. package/bench-results/replay-cache-full-1777896317488.json +10 -0
  15. package/bench-results/replay-cache-full-1777900993184.json +10 -0
  16. package/bench-results/replay-cache-full-1777901019285.json +10 -0
  17. package/bench-results/replay-cache-quick-1777870170126.json +10 -0
  18. package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
  19. package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
  20. package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
  21. package/bench-results/wire-codecs-full-1777891019803.json +93 -0
  22. package/bench-results/wire-codecs-full-1777896260964.json +93 -0
  23. package/bench-results/wire-codecs-full-1777901004247.json +93 -0
  24. package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
  25. package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
  26. package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
  27. package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
  28. package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
  29. package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
  30. package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
  31. package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
  32. package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
  33. package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
  34. package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
  35. package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
  36. package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
  37. package/bin/7h3.ts +406 -0
  38. package/conformance/7h3_v0_1.json +77 -0
  39. package/conformance/7h3_v0_1_binary.json +20 -0
  40. package/conformance/aip_v0_1_binary.json +20 -0
  41. package/docker-compose.yaml +77 -0
  42. package/docs/ADOPTION_PLAN.md +120 -0
  43. package/docs/AGENTS.md +77 -0
  44. package/docs/AIP_RFC_v0.1.md +97 -0
  45. package/docs/AI_DECISION_CARD.md +122 -0
  46. package/docs/AI_RUNTIME_POLICY.json +126 -0
  47. package/docs/AI_RUNTIME_POLICY.yaml +110 -0
  48. package/docs/BACKPRESSURE_TUNING.md +65 -0
  49. package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
  50. package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
  51. package/docs/BINARY_CODEC_BENCH.md +23 -0
  52. package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
  53. package/docs/CLOCK_SKEW_POLICY.md +30 -0
  54. package/docs/DISTRIBUTED_REPLAY.md +142 -0
  55. package/docs/FUZZ_CAMPAIGN.md +121 -0
  56. package/docs/GATEWAY.md +195 -0
  57. package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
  58. package/docs/KEY_REVOCATION.md +69 -0
  59. package/docs/MCP_WRAPPER.md +159 -0
  60. package/docs/MIGRATION_GUIDE.md +40 -0
  61. package/docs/OPERATORS.md +184 -0
  62. package/docs/PERF_REGRESSION_POLICY.md +34 -0
  63. package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
  64. package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
  65. package/docs/RELEASE_GATE.md +25 -0
  66. package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
  67. package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
  68. package/docs/TELEMETRY.md +41 -0
  69. package/docs/THREAT_MODEL.md +89 -0
  70. package/docs/VERSIONING_POLICY.md +30 -0
  71. package/docs/assets/banner-github.png +0 -0
  72. package/docs/assets/banner.png +0 -0
  73. package/docs/assets/banner.svg +123 -0
  74. package/eslint.config.js +15 -0
  75. package/fuzz/ts/harness-decode.ts +136 -0
  76. package/fuzz/ts/harness-verify.ts +121 -0
  77. package/fuzz/ts/run.ts +35 -0
  78. package/mcp-server/README.md +38 -0
  79. package/mcp-server/package-lock.json +1187 -0
  80. package/mcp-server/package.json +35 -0
  81. package/mcp-server/src/index.ts +236 -0
  82. package/mcp-server/tsconfig.json +14 -0
  83. package/package.json +121 -13
  84. package/scripts/aip-framework-quickstart.ts +110 -0
  85. package/scripts/aip-mcp-gateway.ts +38 -0
  86. package/scripts/aip-mcp-wrap-demo.ts +72 -0
  87. package/scripts/aip-quickstart.ts +60 -0
  88. package/scripts/bench-diff.ts +118 -0
  89. package/scripts/bench-protocol-e2e.ts +937 -0
  90. package/scripts/bench-protocol-openloop.ts +1397 -0
  91. package/scripts/bench-replay-cache.ts +76 -0
  92. package/scripts/bench-signature-profiles.ts +180 -0
  93. package/scripts/bench-wire-codecs.ts +161 -0
  94. package/scripts/build-binary-conformance.ts +36 -0
  95. package/scripts/build-release-dashboard.ts +175 -0
  96. package/scripts/canary-rollout.ts +38 -0
  97. package/scripts/mcpGatewayCli.test.ts +116 -0
  98. package/scripts/prepare-aip-package.ts +88 -0
  99. package/scripts/regen-conformance-sigs.ts +18 -0
  100. package/scripts/release-gate.ts +19 -0
  101. package/scripts/validate-runtime-policy.ts +18 -0
  102. package/sdk/browser/index.test.ts +162 -0
  103. package/sdk/browser/index.ts +257 -0
  104. package/sdk/browser/package.json +13 -0
  105. package/sdk/go/cbor.go +551 -0
  106. package/sdk/go/cbor_test.go +232 -0
  107. package/sdk/go/encryption.go +280 -0
  108. package/sdk/go/encryption_test.go +318 -0
  109. package/sdk/go/go.mod +7 -0
  110. package/sdk/go/go.sum +4 -0
  111. package/sdk/go/http.go +135 -0
  112. package/sdk/go/protocol.go +324 -0
  113. package/sdk/go/protocol_test.go +334 -0
  114. package/sdk/go/replay.go +121 -0
  115. package/sdk/go/replay_test.go +149 -0
  116. package/sdk/go/webhook.go +136 -0
  117. package/sdk/pq/package-lock.json +1358 -0
  118. package/sdk/pq/package.json +42 -0
  119. package/sdk/pq/src/index.test.ts +143 -0
  120. package/sdk/pq/src/index.ts +166 -0
  121. package/sdk/pq/tsconfig.json +14 -0
  122. package/sdk/pq/vitest.config.ts +7 -0
  123. package/sdk/python/README.md +18 -0
  124. package/sdk/python/protocol_7h3/__init__.py +46 -0
  125. package/sdk/python/protocol_7h3/encryption.py +252 -0
  126. package/sdk/python/protocol_7h3/http.py +212 -0
  127. package/sdk/python/protocol_7h3/keys.py +149 -0
  128. package/sdk/python/protocol_7h3/pq.py +244 -0
  129. package/sdk/python/protocol_7h3/protocol.py +525 -0
  130. package/sdk/python/protocol_7h3/queue.py +118 -0
  131. package/sdk/python/protocol_7h3/replay.py +98 -0
  132. package/sdk/python/protocol_7h3/webhook.py +116 -0
  133. package/sdk/python/pyproject.toml +40 -0
  134. package/sdk/python/tests/test_conformance.py +110 -0
  135. package/sdk/python/tests/test_encryption.py +206 -0
  136. package/sdk/python/tests/test_http.py +305 -0
  137. package/sdk/python/tests/test_keys.py +417 -0
  138. package/sdk/python/tests/test_queue.py +120 -0
  139. package/sdk/python/tests/test_webhook.py +345 -0
  140. package/sdk/rust/Cargo.lock +371 -0
  141. package/sdk/rust/Cargo.toml +25 -0
  142. package/sdk/rust/README.md +31 -0
  143. package/sdk/rust/fuzz/Cargo.toml +29 -0
  144. package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
  145. package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
  146. package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
  147. package/sdk/rust/src/http.rs +145 -0
  148. package/sdk/rust/src/keys.rs +161 -0
  149. package/sdk/rust/src/lib.rs +688 -0
  150. package/sdk/rust/src/queue.rs +79 -0
  151. package/sdk/rust/src/webhook.rs +86 -0
  152. package/sdk/rust/tests/conformance.rs +148 -0
  153. package/sdk/rust/tests/gateway.rs +130 -0
  154. package/sdk/rust/tests/http_webhook_queue.rs +201 -0
  155. package/sdk/rust/tests/keys.rs +189 -0
  156. package/sdk/threshold/index.d.ts +68 -0
  157. package/sdk/threshold/index.d.ts.map +1 -0
  158. package/sdk/threshold/index.js +254 -0
  159. package/sdk/threshold/package-lock.json +1361 -0
  160. package/sdk/threshold/package.json +39 -0
  161. package/sdk/threshold/src/index.d.ts +68 -0
  162. package/sdk/threshold/src/index.d.ts.map +1 -0
  163. package/sdk/threshold/src/index.js +254 -0
  164. package/sdk/threshold/src/index.test.ts +238 -0
  165. package/sdk/threshold/src/index.ts +355 -0
  166. package/sdk/threshold/tsconfig.json +19 -0
  167. package/sdk/threshold/vitest.config.ts +12 -0
  168. package/src/agentAdapter.test.ts +48 -0
  169. package/src/agentAdapter.ts +56 -0
  170. package/src/auditLog.test.ts +145 -0
  171. package/src/auditLog.ts +147 -0
  172. package/src/capability.test.ts +504 -0
  173. package/src/capability.ts +380 -0
  174. package/src/cborCodec.test.ts +263 -0
  175. package/src/cborCodec.ts +339 -0
  176. package/src/conformance.test.ts +136 -0
  177. package/src/conformanceVectors.ts +99 -0
  178. package/src/encryption.test.ts +206 -0
  179. package/src/encryption.ts +245 -0
  180. package/src/envelopeCbor.ts +140 -0
  181. package/src/frameworkAdapters.test.ts +290 -0
  182. package/src/frameworkAdapters.ts +261 -0
  183. package/src/gateway.test.ts +343 -0
  184. package/src/gateway.ts +246 -0
  185. package/src/grpcBinding.test.ts +211 -0
  186. package/src/grpcBinding.ts +103 -0
  187. package/src/httpBinding.test.ts +376 -0
  188. package/src/httpBinding.ts +189 -0
  189. package/src/index.ts +39 -0
  190. package/src/keyInfra.test.ts +278 -0
  191. package/src/keyInfra.ts +228 -0
  192. package/src/keyRegistry.ts +59 -0
  193. package/src/keyRotation.test.ts +78 -0
  194. package/src/keyRotation.ts +72 -0
  195. package/src/mcpGateway.test.ts +129 -0
  196. package/src/mcpGateway.ts +250 -0
  197. package/src/mcpTransports.test.ts +92 -0
  198. package/src/mcpTransports.ts +169 -0
  199. package/src/mcpWrapper.test.ts +179 -0
  200. package/src/mcpWrapper.ts +206 -0
  201. package/src/otel.ts +136 -0
  202. package/src/policyEnforcer.test.ts +99 -0
  203. package/src/policyEnforcer.ts +169 -0
  204. package/src/policyTelemetryFeedback.test.ts +25 -0
  205. package/src/policyTelemetryFeedback.ts +38 -0
  206. package/src/protocol.bench.ts +37 -0
  207. package/{protocol.d.ts → src/protocol.d.ts} +2 -1
  208. package/src/protocol.d.ts.map +1 -0
  209. package/src/protocol.js +294 -0
  210. package/src/protocol.test.ts +155 -0
  211. package/src/protocol.ts +414 -0
  212. package/src/protocolAgent.test.ts +105 -0
  213. package/src/protocolAgent.ts +169 -0
  214. package/src/protocolBinary.test.ts +165 -0
  215. package/src/protocolBinary.ts +312 -0
  216. package/src/protocolCapabilities.ts +70 -0
  217. package/src/protocolFuzz.advanced.test.ts +235 -0
  218. package/src/protocolFuzz.test.ts +111 -0
  219. package/src/protocolNegative.test.ts +97 -0
  220. package/src/protocolReplay.test.ts +71 -0
  221. package/src/protocolReplay.ts +194 -0
  222. package/src/protocolTransport.test.ts +556 -0
  223. package/src/protocolTransport.ts +483 -0
  224. package/src/queueBinding.test.ts +130 -0
  225. package/src/queueBinding.ts +102 -0
  226. package/src/rateLimiter.test.ts +96 -0
  227. package/src/rateLimiter.ts +46 -0
  228. package/src/redisClient.ts +140 -0
  229. package/src/redisIntegration.test.ts +134 -0
  230. package/src/replayStores.test.ts +273 -0
  231. package/src/replayStores.ts +215 -0
  232. package/src/revocation.test.ts +98 -0
  233. package/src/revocation.ts +0 -0
  234. package/src/routePolicy.test.ts +87 -0
  235. package/src/routePolicy.ts +72 -0
  236. package/src/runtimePolicy.test.ts +49 -0
  237. package/src/runtimePolicy.ts +81 -0
  238. package/src/runtimePolicyManager.test.ts +29 -0
  239. package/src/runtimePolicyManager.ts +50 -0
  240. package/src/runtimePolicyPresets.ts +43 -0
  241. package/src/signedResponse.test.ts +111 -0
  242. package/src/signedResponse.ts +83 -0
  243. package/src/stream.test.ts +254 -0
  244. package/src/stream.ts +417 -0
  245. package/src/telemetry.test.ts +251 -0
  246. package/src/telemetry.ts +299 -0
  247. package/src/webhookBinding.test.ts +144 -0
  248. package/src/webhookBinding.ts +115 -0
  249. package/src/wsBinding.test.ts +221 -0
  250. package/src/wsBinding.ts +200 -0
  251. package/tsconfig.json +15 -0
  252. package/tsconfig.lib.json +23 -0
  253. package/vite.lib.config.ts +16 -0
  254. package/vitest.config.ts +11 -0
  255. package/agentAdapter.d.ts +0 -26
  256. package/conformanceVectors.d.ts +0 -20
  257. package/frameworkAdapters.d.ts +0 -72
  258. package/index.d.ts +0 -20
  259. package/index.js +0 -1702
  260. package/keyRotation.d.ts +0 -20
  261. package/mcpGateway.d.ts +0 -37
  262. package/mcpTransports.d.ts +0 -62
  263. package/mcpWrapper.d.ts +0 -83
  264. package/policyEnforcer.d.ts +0 -50
  265. package/policyTelemetryFeedback.d.ts +0 -11
  266. package/protocolAgent.d.ts +0 -58
  267. package/protocolBinary.d.ts +0 -8
  268. package/protocolCapabilities.d.ts +0 -24
  269. package/protocolReplay.d.ts +0 -35
  270. package/protocolTransport.d.ts +0 -73
  271. package/redisClient.d.ts +0 -49
  272. package/replayStores.d.ts +0 -32
  273. package/revocation.d.ts +0 -71
  274. package/runtimePolicy.d.ts +0 -24
  275. package/runtimePolicyManager.d.ts +0 -15
  276. 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
+ })