@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/queueBinding.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
}
|
package/src/rateLimiter.test.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
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
|
-
})
|
package/src/rateLimiter.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
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 }
|
|
@@ -1,134 +0,0 @@
|
|
|
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
|
-
})
|
package/src/replayStores.test.ts
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { InMemoryRedisLikeClient } from './redisClient'
|
|
3
|
-
import {
|
|
4
|
-
createRedisReplayStore,
|
|
5
|
-
RedisReplayStore,
|
|
6
|
-
ClusterRedisReplayStore,
|
|
7
|
-
createClusterReplayStore,
|
|
8
|
-
type ReplayStore,
|
|
9
|
-
type RedisClientLike,
|
|
10
|
-
} from './replayStores'
|
|
11
|
-
import { DistributedReplayCache } from './protocolReplay'
|
|
12
|
-
import { createEnvelope } from './protocol'
|
|
13
|
-
|
|
14
|
-
describe('InMemoryRedisLikeClient (reference client)', () => {
|
|
15
|
-
it('SET NX returns OK on first write and null when the key already exists', async () => {
|
|
16
|
-
const client = new InMemoryRedisLikeClient()
|
|
17
|
-
const first = await client.set('k', '1', { nx: true, pxMs: 1000 })
|
|
18
|
-
const second = await client.set('k', '1', { nx: true, pxMs: 1000 })
|
|
19
|
-
expect(first).toBe('OK')
|
|
20
|
-
expect(second).toBeNull()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('SET NX succeeds again after the PX TTL elapses', async () => {
|
|
24
|
-
let now = 1000
|
|
25
|
-
const client = new InMemoryRedisLikeClient({ now: () => now })
|
|
26
|
-
expect(await client.set('k', '1', { nx: true, pxMs: 500 })).toBe('OK')
|
|
27
|
-
now = 1400
|
|
28
|
-
expect(await client.set('k', '1', { nx: true, pxMs: 500 })).toBeNull()
|
|
29
|
-
now = 1600
|
|
30
|
-
expect(await client.set('k', '1', { nx: true, pxMs: 500 })).toBe('OK')
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
describe('createRedisReplayStore', () => {
|
|
35
|
-
it('reserve returns true for a new key and false for a duplicate', async () => {
|
|
36
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
37
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
38
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('reserves distinct keys independently', async () => {
|
|
42
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
43
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
44
|
-
expect(await store.reserve('sender|m2|n2', 5000, 1000)).toBe(true)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('sets the Redis key with the prefix and a positive PX derived from expiry minus now', async () => {
|
|
48
|
-
const client = new InMemoryRedisLikeClient()
|
|
49
|
-
const setSpy = vi.spyOn(client, 'set')
|
|
50
|
-
const store = createRedisReplayStore(client, { keyPrefix: 'aip:replay:' })
|
|
51
|
-
await store.reserve('sender|m1|n1', 5000, 1000)
|
|
52
|
-
expect(setSpy).toHaveBeenCalledWith('aip:replay:sender|m1|n1', '1', { nx: true, pxMs: 4000 })
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('clamps PX to at least 1ms when expiry is not in the future', async () => {
|
|
56
|
-
const client = new InMemoryRedisLikeClient()
|
|
57
|
-
const setSpy = vi.spyOn(client, 'set')
|
|
58
|
-
const store = createRedisReplayStore(client)
|
|
59
|
-
await store.reserve('sender|m1|n1', 1000, 1000)
|
|
60
|
-
expect(setSpy).toHaveBeenCalledWith('aip:replay:sender|m1|n1', '1', { nx: true, pxMs: 1 })
|
|
61
|
-
})
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
describe('createRedisReplayStore degraded behavior', () => {
|
|
65
|
-
function throwingClient() {
|
|
66
|
-
return {
|
|
67
|
-
set: async () => {
|
|
68
|
-
throw new Error('redis unreachable')
|
|
69
|
-
},
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
it('errorBehavior "reject" returns false and fires onDegraded when the client throws', async () => {
|
|
74
|
-
const onDegraded = vi.fn()
|
|
75
|
-
const store = createRedisReplayStore(throwingClient(), { errorBehavior: 'reject', onDegraded })
|
|
76
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
77
|
-
expect(onDegraded).toHaveBeenCalledOnce()
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('errorBehavior "allow" returns true and fires onDegraded when the client throws', async () => {
|
|
81
|
-
const onDegraded = vi.fn()
|
|
82
|
-
const store = createRedisReplayStore(throwingClient(), { errorBehavior: 'allow', onDegraded })
|
|
83
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
84
|
-
expect(onDegraded).toHaveBeenCalledOnce()
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('errorBehavior "fallback" (default) degrades to a local store and fires onDegraded', async () => {
|
|
88
|
-
const onDegraded = vi.fn()
|
|
89
|
-
const store = createRedisReplayStore(throwingClient(), { onDegraded })
|
|
90
|
-
// first call to the degraded local store reserves successfully
|
|
91
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
92
|
-
// second identical call is caught locally as a replay
|
|
93
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
94
|
-
expect(onDegraded).toHaveBeenCalledTimes(2)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('errorBehavior "fallback" delegates to an explicitly provided local store', async () => {
|
|
98
|
-
const onDegraded = vi.fn()
|
|
99
|
-
const fallback = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
100
|
-
const store = createRedisReplayStore(throwingClient(), { fallback, onDegraded })
|
|
101
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
102
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
103
|
-
expect(onDegraded).toHaveBeenCalledTimes(2)
|
|
104
|
-
})
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
describe('createRedisReplayStore batch (reserveMany)', () => {
|
|
108
|
-
it('reserves a batch, deduplicating within the batch and against prior reservations', async () => {
|
|
109
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
110
|
-
await store.reserve('sender|m0|n0', 5000, 1000)
|
|
111
|
-
const results = await store.reserveMany!(
|
|
112
|
-
[
|
|
113
|
-
{ key: 'sender|m0|n0', expiresAtMs: 5000 }, // already reserved → false
|
|
114
|
-
{ key: 'sender|m1|n1', expiresAtMs: 5000 }, // new → true
|
|
115
|
-
{ key: 'sender|m1|n1', expiresAtMs: 5000 }, // duplicate within batch → false
|
|
116
|
-
],
|
|
117
|
-
1000,
|
|
118
|
-
)
|
|
119
|
-
expect(results).toEqual([false, true, false])
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('uses the client pipeline when one is available (single round-trip)', async () => {
|
|
123
|
-
const client = new InMemoryRedisLikeClient()
|
|
124
|
-
const pipelineSpy = vi.spyOn(client, 'pipeline')
|
|
125
|
-
const store = createRedisReplayStore(client)
|
|
126
|
-
await store.reserveMany!(
|
|
127
|
-
[
|
|
128
|
-
{ key: 'sender|m1|n1', expiresAtMs: 5000 },
|
|
129
|
-
{ key: 'sender|m2|n2', expiresAtMs: 5000 },
|
|
130
|
-
],
|
|
131
|
-
1000,
|
|
132
|
-
)
|
|
133
|
-
expect(pipelineSpy).toHaveBeenCalledOnce()
|
|
134
|
-
})
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
describe('DistributedReplayCache batch delegation', () => {
|
|
138
|
-
it('consumeMany routes through the store reserveMany when present', async () => {
|
|
139
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
140
|
-
const reserveManySpy = vi.spyOn(store, 'reserveMany')
|
|
141
|
-
const cache = new DistributedReplayCache(store)
|
|
142
|
-
const e1 = { ...createEnvelope({ sender: 's', intent: 'PING', content: 'p', messageId: 'm1', nonce: 'n1', nowMs: 1000 }), signature: undefined } as never
|
|
143
|
-
const e2 = { ...createEnvelope({ sender: 's', intent: 'PING', content: 'p', messageId: 'm2', nonce: 'n2', nowMs: 1000 }), signature: undefined } as never
|
|
144
|
-
const results = await cache.consumeMany([e1, e2], 1000)
|
|
145
|
-
expect(reserveManySpy).toHaveBeenCalledOnce()
|
|
146
|
-
expect(results.map((r) => r.ok)).toEqual([true, true])
|
|
147
|
-
})
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// New ReplayStore interface and class tests (check()-based API)
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Build a mock RedisClientLike whose SET NX behaviour can be scripted:
|
|
156
|
-
* first call returns 'OK' (fresh), subsequent calls return null (replay).
|
|
157
|
-
*/
|
|
158
|
-
function mockRedisClient(): RedisClientLike {
|
|
159
|
-
let callCount = 0
|
|
160
|
-
return {
|
|
161
|
-
set: vi.fn(async (_key: string, _value: string, _opts?: { nx?: boolean; px?: number }) => {
|
|
162
|
-
callCount++
|
|
163
|
-
return callCount === 1 ? 'OK' : null
|
|
164
|
-
}),
|
|
165
|
-
get: vi.fn(async (_key: string) => null),
|
|
166
|
-
del: vi.fn(async (_key: string) => 0),
|
|
167
|
-
quit: vi.fn(async () => undefined),
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
describe('RedisReplayStore (check() interface)', () => {
|
|
172
|
-
it('first call returns false (fresh) and second call returns true (replay)', async () => {
|
|
173
|
-
const client = mockRedisClient()
|
|
174
|
-
const store = new RedisReplayStore({ client })
|
|
175
|
-
const fresh = await store.check('nonce-abc', 30_000)
|
|
176
|
-
const replay = await store.check('nonce-abc', 30_000)
|
|
177
|
-
expect(fresh).toBe(false)
|
|
178
|
-
expect(replay).toBe(true)
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('satisfies the ReplayStore interface', () => {
|
|
182
|
-
const store: ReplayStore = new RedisReplayStore({ client: mockRedisClient() })
|
|
183
|
-
expect(typeof store.check).toBe('function')
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
it('uses default keyPrefix "7h3:nonce:"', async () => {
|
|
187
|
-
const client = mockRedisClient()
|
|
188
|
-
const store = new RedisReplayStore({ client })
|
|
189
|
-
await store.check('my-nonce', 5000)
|
|
190
|
-
expect(client.set).toHaveBeenCalledWith('7h3:nonce:my-nonce', '1', { nx: true, px: 5000 })
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
it('respects a custom keyPrefix', async () => {
|
|
194
|
-
const client = mockRedisClient()
|
|
195
|
-
const store = new RedisReplayStore({ client, keyPrefix: 'custom:' })
|
|
196
|
-
await store.check('n1', 1000)
|
|
197
|
-
expect(client.set).toHaveBeenCalledWith('custom:n1', '1', { nx: true, px: 1000 })
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
it('clamps TTL to at least 1ms', async () => {
|
|
201
|
-
const client = mockRedisClient()
|
|
202
|
-
const store = new RedisReplayStore({ client })
|
|
203
|
-
await store.check('n1', 0)
|
|
204
|
-
expect(client.set).toHaveBeenCalledWith('7h3:nonce:n1', '1', { nx: true, px: 1 })
|
|
205
|
-
})
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
describe('createRedisReplayStore opts-object overload', () => {
|
|
209
|
-
it('returns a RedisReplayStore with the correct keyPrefix', () => {
|
|
210
|
-
const store = createRedisReplayStore({ keyPrefix: '7h3:nonce:' })
|
|
211
|
-
expect(store).toBeInstanceOf(RedisReplayStore)
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
it('returns a RedisReplayStore with default keyPrefix when none specified', () => {
|
|
215
|
-
const store = createRedisReplayStore({ client: mockRedisClient() })
|
|
216
|
-
expect(store).toBeInstanceOf(RedisReplayStore)
|
|
217
|
-
})
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
describe('ClusterRedisReplayStore', () => {
|
|
221
|
-
it('check() returns false (fresh) when ALL nodes say fresh', async () => {
|
|
222
|
-
// Build two nodes each with a fresh client
|
|
223
|
-
const makeClient = () => {
|
|
224
|
-
const c = mockRedisClient()
|
|
225
|
-
return c
|
|
226
|
-
}
|
|
227
|
-
const node1 = new RedisReplayStore({ client: makeClient() })
|
|
228
|
-
const node2 = new RedisReplayStore({ client: makeClient() })
|
|
229
|
-
const cluster = new ClusterRedisReplayStore([node1, node2])
|
|
230
|
-
// First call: both nodes return OK → fresh
|
|
231
|
-
const result = await cluster.check('nonce-xyz', 10_000)
|
|
232
|
-
expect(result).toBe(false)
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
it('check() returns true (replay) when ANY node reports replay', async () => {
|
|
236
|
-
// node1: already seen (returns null immediately)
|
|
237
|
-
const replayClient: RedisClientLike = {
|
|
238
|
-
set: vi.fn(async () => null), // always null = always replay
|
|
239
|
-
get: vi.fn(async () => null),
|
|
240
|
-
del: vi.fn(async () => 0),
|
|
241
|
-
quit: vi.fn(async () => undefined),
|
|
242
|
-
}
|
|
243
|
-
// node2: fresh (returns OK)
|
|
244
|
-
const freshClient: RedisClientLike = {
|
|
245
|
-
set: vi.fn(async () => 'OK'),
|
|
246
|
-
get: vi.fn(async () => null),
|
|
247
|
-
del: vi.fn(async () => 0),
|
|
248
|
-
quit: vi.fn(async () => undefined),
|
|
249
|
-
}
|
|
250
|
-
const node1 = new RedisReplayStore({ client: replayClient })
|
|
251
|
-
const node2 = new RedisReplayStore({ client: freshClient })
|
|
252
|
-
const cluster = new ClusterRedisReplayStore([node1, node2])
|
|
253
|
-
const result = await cluster.check('nonce-xyz', 10_000)
|
|
254
|
-
expect(result).toBe(true)
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
it('queries all nodes in parallel via Promise.all', async () => {
|
|
258
|
-
const clients = [mockRedisClient(), mockRedisClient(), mockRedisClient()]
|
|
259
|
-
const nodes = clients.map((c) => new RedisReplayStore({ client: c }))
|
|
260
|
-
const cluster = new ClusterRedisReplayStore(nodes)
|
|
261
|
-
await cluster.check('nonce-parallel', 5000)
|
|
262
|
-
for (const c of clients) {
|
|
263
|
-
expect(c.set).toHaveBeenCalledOnce()
|
|
264
|
-
}
|
|
265
|
-
})
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
describe('createClusterReplayStore', () => {
|
|
269
|
-
it('returns a ClusterRedisReplayStore from an array of URLs', () => {
|
|
270
|
-
const cluster = createClusterReplayStore(['redis://node1:6379', 'redis://node2:6379'])
|
|
271
|
-
expect(cluster).toBeInstanceOf(ClusterRedisReplayStore)
|
|
272
|
-
})
|
|
273
|
-
})
|