First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.npmrc
|
||||
dist-cjs/
|
||||
@@ -0,0 +1,6 @@
|
||||
stages: [test]
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: node:22-alpine
|
||||
script: [npm ci, npm run build, npm test]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 - 2026-09-28
|
||||
|
||||
First public release under MIT.
|
||||
|
||||
- `keyVerdict()` checks a signing key at both origin and arrival;
|
||||
`expired-in-flight` is its own verdict, accepted and recorded.
|
||||
- Revocation applies regardless of origin time.
|
||||
- `assessStream()` returns `apply` in sender-sequence order, never arrival
|
||||
order.
|
||||
- A duplicate fails when the receiver is not idempotent and is accepted when
|
||||
it is.
|
||||
- A message beyond `maxTransitSeconds` is rejected for action.
|
||||
- A message received before it was sent is NOT ASSESSED.
|
||||
- `now` is never read: the same trace gives the same verdicts.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Contributing
|
||||
|
||||
Issues and merge requests are welcome at
|
||||
https://gitlab.com/extant2000/slow-lane.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- A test that cannot fail proves nothing. If you fix a bug, add a test and
|
||||
check that it fails against the unfixed code before you submit.
|
||||
- Measure, don't assume. Two modules with the same line count can still be
|
||||
different programs.
|
||||
- Keep dependencies minimal. Every new runtime dependency needs a reason.
|
||||
- Explain why in comments, not what. The what is already in the code.
|
||||
- A breaking change needs a major version bump and a note in CHANGELOG.md.
|
||||
|
||||
## Before you open a merge request
|
||||
|
||||
Run both of these and make sure they pass:
|
||||
|
||||
npm run build
|
||||
npm test
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Extant 2000 LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,96 @@
|
||||
# SlowLane
|
||||
|
||||
Check signatures, ordering and duplicates on a link that is slow,
|
||||
intermittent and out of order.
|
||||
|
||||
SlowLane assesses a batch of received messages and tells you which ones to
|
||||
apply, in which order, and which to reject. It is pure, with no clock and no
|
||||
network. `now` is never read: every decision comes from the timestamps
|
||||
carried on the messages, so the same trace always produces the same verdicts.
|
||||
|
||||
## What it checks and why
|
||||
|
||||
Security checks written for a fast, ordered, exactly-once link break on a
|
||||
link that is none of those things. Worse, they fail in a direction that looks
|
||||
correct. Three cases:
|
||||
|
||||
1. Expiry checked at the wrong end of the flight. A message signed with a key
|
||||
that was valid when it was sent can arrive after the key expired. Checking
|
||||
the key at arrival rejects legitimate traffic, and on a link with hours of
|
||||
transit that is most of it. Checking only at origin accepts a message
|
||||
whose key is no longer trusted. Each check is wrong on its own, so
|
||||
`keyVerdict()` checks both ends and returns `expired-in-flight` as its own
|
||||
verdict. Such a message is accepted and recorded. Revocation is different
|
||||
and ignores origin time entirely, because a key is usually compromised
|
||||
before anyone notices.
|
||||
2. Ordering by arrival. Arrival order is a property of the link, not of the
|
||||
sender. Applying updates in receipt order overwrites newer state with
|
||||
older and looks like it worked. The per-sender `sequence` is the only
|
||||
ordering authority, and `apply` comes back sorted by it. A caller that
|
||||
loops over the input array instead has brought the bug back.
|
||||
3. At-least-once delivery meeting a handler that is not idempotent. On this
|
||||
kind of link a duplicate is the delivery guarantee working. It is a
|
||||
finding only when the receiver has not declared itself idempotent. The
|
||||
duplicate is not the anomaly; the assumption of exactly-once is.
|
||||
|
||||
Also:
|
||||
|
||||
- Too old to act on. Past `maxTransitSeconds`, a message is rejected but
|
||||
worth recording. The world it described has moved on.
|
||||
- Received before sent. NOT ASSESSED. The clocks disagree.
|
||||
- No recognised key. NOT ASSESSED and rejected, since nothing establishes who
|
||||
sent it.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { assessStream, formatStream, keyVerdict, type Message } from '@extant2000/slow-lane'
|
||||
|
||||
const messages: Message[] = [
|
||||
{ id: 'm3', sender: 'probe-a', sequence: 3, originAt: '2026-09-28T10:05:00Z', receivedAt: '2026-09-28T10:25:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||
{ id: 'm1', sender: 'probe-a', sequence: 1, originAt: '2026-09-28T09:00:00Z', receivedAt: '2026-09-28T10:30:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||
{ id: 'm2', sender: 'probe-a', sequence: 2, originAt: '2026-09-28T10:00:00Z', receivedAt: '2026-09-28T10:20:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||
{ id: 'm2b', sender: 'probe-a', sequence: 2, originAt: '2026-09-28T10:00:00Z', receivedAt: '2026-09-28T10:40:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||
]
|
||||
|
||||
const { apply, reject, report } = assessStream(messages, {
|
||||
keys: [{ id: 'k1', notAfter: '2026-09-28T10:10:00Z' }],
|
||||
maxTransitSeconds: 3600,
|
||||
receiverIsIdempotent: false,
|
||||
})
|
||||
|
||||
for (const m of apply) {
|
||||
// sender order, not arrival order
|
||||
}
|
||||
```
|
||||
|
||||
`report` is an evidence-record `CapabilityReport`. Print it with
|
||||
`formatStream()`.
|
||||
|
||||
## Sample output
|
||||
|
||||
Produced by running the example above and printing `apply`, `reject` and
|
||||
each finding's determination and summary:
|
||||
|
||||
```
|
||||
apply: m2, m3
|
||||
reject: m1, m2b
|
||||
not-applicable probe-a#1 was signed with a key that expired while in flight
|
||||
fail probe-a#1 spent 90 minutes in transit
|
||||
not-applicable probe-a#2 was signed with a key that expired while in flight
|
||||
pass probe-a#2 accepted, 1200s in transit
|
||||
not-applicable probe-a#2 was signed with a key that expired while in flight
|
||||
fail probe-a#2 was delivered more than once
|
||||
not-applicable probe-a#3 was signed with a key that expired while in flight
|
||||
pass probe-a#3 accepted, 1200s in transit
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
npm install @extant2000/slow-lane
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT. Copyright (c) 2026 Extant 2000 LLC. See [LICENSE](LICENSE).
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||
Do not open a public issue or merge request for a security problem.
|
||||
|
||||
Include the affected version, a description of the issue, and steps or a test
|
||||
that reproduce it if you have them.
|
||||
|
||||
We aim to acknowledge every report within 5 business days.
|
||||
Generated
+1504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@extant2000/slow-lane",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"description": "Authenticated store-and-forward for minutes-long links: replay-resistant and reorder-tolerant.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./dist-cjs/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-cjs",
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitlab.com/extant2000/slow-lane.git"
|
||||
},
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist-cjs/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extant2000/evidence-record": "^0.1.2"
|
||||
},
|
||||
"author": "Extant 2000 LLC",
|
||||
"homepage": "https://gitlab.com/extant2000/slow-lane",
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/extant2000/slow-lane/-/issues"
|
||||
}
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* SlowLane — secure delayed, intermittent and out-of-order communications.
|
||||
*
|
||||
* Every security primitive that assumes a fast, ordered, exactly-once link
|
||||
* breaks on one that is none of those things, and it breaks in a direction
|
||||
* that looks correct:
|
||||
*
|
||||
* 1. **Expiry checked at the wrong end of the flight.** A message signed
|
||||
* with a key valid at transmission can arrive after that key expired.
|
||||
* Validating against arrival time rejects legitimate traffic, and on a
|
||||
* link with hours of transit that is most of it. Validating against
|
||||
* origin time alone silently accepts a message whose key is now
|
||||
* revoked. Both are wrong on their own, so both are computed and the
|
||||
* in-flight expiry is reported SEPARATELY rather than folded into the
|
||||
* verdict.
|
||||
*
|
||||
* 2. **Ordering by arrival.** Arrival order is a property of the link, not
|
||||
* of the sender. Applying updates in receipt order silently overwrites
|
||||
* a newer state with an older one — and it looks like it worked. The
|
||||
* per-sender sequence is the only ordering authority here.
|
||||
*
|
||||
* 3. **At-least-once delivery meeting a non-idempotent handler.** A
|
||||
* duplicate is not an anomaly on this kind of link; it is the delivery
|
||||
* guarantee working. A duplicate is a finding only when the receiver
|
||||
* has not declared itself idempotent.
|
||||
*
|
||||
* Pure: no clock, no network. `now` is never read — every decision is made
|
||||
* from timestamps carried on the messages themselves, so the same trace
|
||||
* always produces the same verdicts.
|
||||
*/
|
||||
|
||||
import {
|
||||
type CapabilityReport,
|
||||
type Finding,
|
||||
type FormatOptions,
|
||||
evidence,
|
||||
formatReport as renderReport,
|
||||
} from '@extant2000/evidence-record'
|
||||
|
||||
export interface SigningKey {
|
||||
id: string
|
||||
/** ISO 8601. The key is invalid for messages ORIGINATED after this. */
|
||||
notAfter?: string
|
||||
/** ISO 8601. Revoked outright — invalid regardless of origin time. */
|
||||
revokedAt?: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
sender: string
|
||||
/**
|
||||
* Monotonic per-sender sequence number.
|
||||
*
|
||||
* The only ordering authority. Arrival order is a property of the link.
|
||||
*/
|
||||
sequence: number
|
||||
/** ISO 8601, stamped by the sender. */
|
||||
originAt: string
|
||||
/** ISO 8601, stamped by the receiver. */
|
||||
receivedAt: string
|
||||
/** Key the message was signed with. */
|
||||
keyId?: string
|
||||
/** True when applying this message twice is harmless. */
|
||||
idempotent?: boolean
|
||||
/** Where the message was observed — a log, a queue, a downlink. */
|
||||
where: string
|
||||
}
|
||||
|
||||
export interface LinkPolicy {
|
||||
/**
|
||||
* Beyond this transit time a message is too old to ACT on, though it may
|
||||
* still be worth recording. Absent means no limit is declared.
|
||||
*/
|
||||
maxTransitSeconds?: number
|
||||
keys?: SigningKey[]
|
||||
/** True when the receiving handler is idempotent for every message. */
|
||||
receiverIsIdempotent?: boolean
|
||||
}
|
||||
|
||||
const secondsBetween = (a: string, b: string): number | null => {
|
||||
const x = Date.parse(a); const y = Date.parse(b)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
||||
return Math.round((y - x) / 1000)
|
||||
}
|
||||
|
||||
export interface TransitStats {
|
||||
/** Seconds in flight, `null` when either timestamp is unparseable. */
|
||||
seconds: number | null
|
||||
/** True when the receiver stamped it as arriving before it was sent. */
|
||||
impossible: boolean
|
||||
}
|
||||
|
||||
/** How long a message was in flight, and whether that is even coherent. */
|
||||
export function transit(m: Message): TransitStats {
|
||||
const s = secondsBetween(m.originAt, m.receivedAt)
|
||||
return { seconds: s, impossible: s !== null && s < 0 }
|
||||
}
|
||||
|
||||
export type KeyVerdict =
|
||||
/** Valid at origin and still valid now. */
|
||||
| 'valid'
|
||||
/** Valid when sent, expired while in flight. Normal on this link. */
|
||||
| 'expired-in-flight'
|
||||
/** Not valid even at origin. */
|
||||
| 'invalid-at-origin'
|
||||
/** Revoked outright — origin time does not rescue it. */
|
||||
| 'revoked'
|
||||
/** No key, or the key is not in the declared set. */
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Judge a signature key against BOTH ends of the flight.
|
||||
*
|
||||
* `expired-in-flight` is the case a single check cannot express. Validating
|
||||
* at arrival calls it invalid and drops legitimate traffic; validating at
|
||||
* origin calls it fine and hides that the key is no longer trusted. It is
|
||||
* neither, so it gets its own answer.
|
||||
*/
|
||||
export function keyVerdict(m: Message, policy: LinkPolicy): KeyVerdict {
|
||||
if (!m.keyId) return 'unknown'
|
||||
const key = (policy.keys ?? []).find(k => k.id === m.keyId)
|
||||
if (!key) return 'unknown'
|
||||
|
||||
// Revocation is not a schedule. It applies regardless of when the message
|
||||
// was sent, because the reason for revoking is usually that the key was
|
||||
// compromised before anyone noticed.
|
||||
if (key.revokedAt) return 'revoked'
|
||||
|
||||
if (!key.notAfter) return 'valid'
|
||||
const originOk = Date.parse(m.originAt) <= Date.parse(key.notAfter)
|
||||
const arrivalOk = Date.parse(m.receivedAt) <= Date.parse(key.notAfter)
|
||||
|
||||
if (!originOk) return 'invalid-at-origin'
|
||||
return arrivalOk ? 'valid' : 'expired-in-flight'
|
||||
}
|
||||
|
||||
function msgEvidence(m: Message) {
|
||||
return evidence.file(m.where, undefined, `${m.sender}#${m.sequence}`,
|
||||
`sent ${m.originAt}, received ${m.receivedAt}`)
|
||||
}
|
||||
|
||||
export interface StreamAssessment {
|
||||
report: CapabilityReport
|
||||
/** Messages that must not be applied, worst first. */
|
||||
reject: Message[]
|
||||
/** Messages safe to apply, in SENDER order, not arrival order. */
|
||||
apply: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess a received stream.
|
||||
*
|
||||
* `apply` comes back sorted by sender sequence, not by arrival — the whole
|
||||
* point. A caller that iterates the input array instead has reintroduced
|
||||
* the bug this library exists to prevent.
|
||||
*/
|
||||
export function assessStream(messages: Message[], policy: LinkPolicy = {}): StreamAssessment {
|
||||
const list = [...(messages ?? [])]
|
||||
|
||||
if (list.length === 0) {
|
||||
return {
|
||||
report: {
|
||||
capability: 'SlowLane',
|
||||
scope: 'no messages',
|
||||
examined: 0,
|
||||
findings: [],
|
||||
notes: ['No messages were supplied. On an intermittent link, silence is not evidence that nothing was sent.'],
|
||||
},
|
||||
reject: [],
|
||||
apply: [],
|
||||
}
|
||||
}
|
||||
|
||||
const findings: Finding[] = []
|
||||
const reject: Message[] = []
|
||||
const apply: Message[] = []
|
||||
|
||||
// Highest sequence already seen per sender, in SENDER order.
|
||||
const highest = new Map<string, number>()
|
||||
const seen = new Set<string>()
|
||||
|
||||
const bySequence = [...list].sort(
|
||||
(a, b) => a.sender.localeCompare(b.sender) || a.sequence - b.sequence,
|
||||
)
|
||||
|
||||
for (const m of bySequence) {
|
||||
const key = `${m.sender}#${m.sequence}`
|
||||
const t = transit(m)
|
||||
const verdict = keyVerdict(m, policy)
|
||||
let rejected = false
|
||||
|
||||
if (t.impossible) {
|
||||
findings.push({
|
||||
id: `${m.id}/clock`,
|
||||
summary: `${key} was received before it was sent`,
|
||||
determination: 'not-assessed',
|
||||
severity: 'high',
|
||||
detail: 'The two clocks disagree, so neither transit time nor any expiry decision derived from them can be trusted for this message.',
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
reject.push(m)
|
||||
continue
|
||||
}
|
||||
|
||||
if (verdict === 'revoked' || verdict === 'invalid-at-origin') {
|
||||
findings.push({
|
||||
id: `${m.id}/key`,
|
||||
summary: `${key} signed with a ${verdict === 'revoked' ? 'revoked' : 'not-yet-or-no-longer-valid'} key`,
|
||||
determination: 'fail',
|
||||
severity: 'critical',
|
||||
detail: verdict === 'revoked'
|
||||
? 'Revocation is not a schedule — it applies regardless of when the message was sent, because the key was usually compromised before anyone noticed.'
|
||||
: 'The key was already invalid when the message was created. Transit time does not excuse this.',
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
reject.push(m)
|
||||
rejected = true
|
||||
}
|
||||
|
||||
if (verdict === 'unknown') {
|
||||
findings.push({
|
||||
id: `${m.id}/key`,
|
||||
summary: `${key} carries no recognised signing key`,
|
||||
determination: 'not-assessed',
|
||||
severity: 'high',
|
||||
detail: 'Nothing establishes who sent this. An unsigned message on an intermittent link is not distinguishable from an injected one.',
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
reject.push(m)
|
||||
rejected = true
|
||||
}
|
||||
|
||||
if (verdict === 'expired-in-flight') {
|
||||
// Reported, NOT rejected. On a link with hours of transit this is the
|
||||
// normal case, and dropping it would discard legitimate traffic.
|
||||
findings.push({
|
||||
id: `${m.id}/key-flight`,
|
||||
summary: `${key} was signed with a key that expired while in flight`,
|
||||
determination: 'not-applicable',
|
||||
severity: 'info',
|
||||
detail: 'Valid when sent, expired on arrival. Accepted, because rejecting it would drop legitimate traffic on any link with real transit time — but recorded, because the key is no longer trusted for anything new.',
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
}
|
||||
|
||||
if (seen.has(key)) {
|
||||
const idempotent = m.idempotent ?? policy.receiverIsIdempotent ?? false
|
||||
findings.push({
|
||||
id: `${m.id}/duplicate`,
|
||||
summary: `${key} was delivered more than once`,
|
||||
determination: idempotent ? 'not-applicable' : 'fail',
|
||||
severity: idempotent ? 'info' : 'critical',
|
||||
detail: idempotent
|
||||
? 'A duplicate is the at-least-once delivery guarantee working, and the receiver is idempotent. Not a fault.'
|
||||
: 'At-least-once delivery meeting a handler that has not declared itself idempotent. The duplicate is not the anomaly — the assumption of exactly-once is.',
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
if (!idempotent) reject.push(m)
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
|
||||
const hi = highest.get(m.sender)
|
||||
if (hi !== undefined && m.sequence < hi) {
|
||||
findings.push({
|
||||
id: `${m.id}/order`,
|
||||
summary: `${key} arrived after ${m.sender}#${hi} and is older`,
|
||||
determination: 'fail',
|
||||
severity: 'high',
|
||||
detail: 'Applying this in arrival order would overwrite newer state with older, and would look like it worked. Arrival order is a property of the link, not of the sender.',
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
reject.push(m)
|
||||
rejected = true
|
||||
} else {
|
||||
highest.set(m.sender, m.sequence)
|
||||
}
|
||||
|
||||
if (t.seconds !== null && policy.maxTransitSeconds && t.seconds > policy.maxTransitSeconds) {
|
||||
findings.push({
|
||||
id: `${m.id}/stale`,
|
||||
summary: `${key} spent ${Math.round(t.seconds / 60)} minutes in transit`,
|
||||
determination: 'fail',
|
||||
severity: 'high',
|
||||
detail: `Beyond the ${Math.round(policy.maxTransitSeconds / 60)}-minute limit for acting on a message. Record it; do not act on it — the world it described has moved on.`,
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
reject.push(m)
|
||||
rejected = true
|
||||
}
|
||||
|
||||
if (!rejected) {
|
||||
apply.push(m)
|
||||
findings.push({
|
||||
id: m.id,
|
||||
summary: `${key} accepted${t.seconds !== null ? `, ${t.seconds}s in transit` : ''}`,
|
||||
determination: 'pass',
|
||||
severity: 'info',
|
||||
evidence: [msgEvidence(m)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
report: {
|
||||
capability: 'SlowLane',
|
||||
scope: `${list.length} message(s) from ${new Set(list.map(m => m.sender)).size} sender(s)`,
|
||||
examined: list.length,
|
||||
findings,
|
||||
notes: [
|
||||
`${apply.length} to apply, ${reject.length} rejected — apply order is by sender sequence, NOT arrival`,
|
||||
],
|
||||
},
|
||||
reject,
|
||||
apply,
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a stream assessment for a terminal, log or review. */
|
||||
export function formatStream(messages: Message[], policy: LinkPolicy = {}, opts: FormatOptions = {}): string {
|
||||
return renderReport(assessStream(messages, policy).report, opts)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||
import {
|
||||
assessStream,
|
||||
formatStream,
|
||||
keyVerdict,
|
||||
transit,
|
||||
type LinkPolicy,
|
||||
type Message,
|
||||
} from '../src/index.js'
|
||||
|
||||
const msg = (p: Partial<Message> = {}): Message => ({
|
||||
id: 'm1',
|
||||
sender: 'probe-a',
|
||||
sequence: 1,
|
||||
originAt: '2026-08-01T10:00:00Z',
|
||||
receivedAt: '2026-08-01T10:20:00Z',
|
||||
keyId: 'k1',
|
||||
where: 'downlink.log',
|
||||
...p,
|
||||
})
|
||||
|
||||
const policy: LinkPolicy = { keys: [{ id: 'k1' }] }
|
||||
|
||||
describe('expiry has two ends and one check cannot express both', () => {
|
||||
const expiring: LinkPolicy = { keys: [{ id: 'k1', notAfter: '2026-08-01T10:10:00Z' }] }
|
||||
|
||||
it('calls a key that expired IN FLIGHT its own thing', () => {
|
||||
// Sent 10:00 (valid), arrived 10:20 (expired at 10:10).
|
||||
expect(keyVerdict(msg(), expiring)).toBe('expired-in-flight')
|
||||
})
|
||||
|
||||
it('ACCEPTS it, because rejecting would drop legitimate traffic', () => {
|
||||
const { apply, report } = assessStream([msg()], expiring)
|
||||
expect(apply).toHaveLength(1)
|
||||
const f = report.findings.find(x => x.id.endsWith('/key-flight'))!
|
||||
expect(f.determination).toBe('not-applicable')
|
||||
expect(f.detail).toContain('rejecting it would drop legitimate traffic')
|
||||
})
|
||||
|
||||
it('still records it, because the key is no longer trusted for anything new', () => {
|
||||
expect(formatStream([msg()], expiring)).toContain('expired while in flight')
|
||||
})
|
||||
|
||||
it('rejects a key that was already invalid at origin', () => {
|
||||
const late = msg({ originAt: '2026-08-01T10:15:00Z', receivedAt: '2026-08-01T10:30:00Z' })
|
||||
expect(keyVerdict(late, expiring)).toBe('invalid-at-origin')
|
||||
expect(assessStream([late], expiring).reject).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('revocation ignores origin time entirely', () => {
|
||||
// The key was usually compromised before anyone noticed.
|
||||
const revoked: LinkPolicy = { keys: [{ id: 'k1', revokedAt: '2026-08-01T23:00:00Z' }] }
|
||||
expect(keyVerdict(msg(), revoked)).toBe('revoked')
|
||||
const r = assessStream([msg()], revoked)
|
||||
expect(r.reject).toHaveLength(1)
|
||||
expect(r.report.findings[0]!.detail).toContain('Revocation is not a schedule')
|
||||
})
|
||||
|
||||
it('rejects an unrecognised or absent key as unassessed', () => {
|
||||
expect(keyVerdict(msg({ keyId: undefined }), policy)).toBe('unknown')
|
||||
expect(keyVerdict(msg({ keyId: 'nope' }), policy)).toBe('unknown')
|
||||
const r = assessStream([msg({ keyId: undefined })], policy)
|
||||
expect(r.report.findings[0]!.determination).toBe('not-assessed')
|
||||
expect(r.reject).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ordering is by sender sequence, never by arrival', () => {
|
||||
it('rejects an older message that arrived after a newer one', () => {
|
||||
// Applying in arrival order would overwrite newer state with older,
|
||||
// and would look like it worked.
|
||||
const out = [
|
||||
msg({ id: 'm2', sequence: 2, originAt: '2026-08-01T10:05:00Z', receivedAt: '2026-08-01T10:10:00Z' }),
|
||||
msg({ id: 'm1', sequence: 1 }),
|
||||
]
|
||||
const r = assessStream(out, policy)
|
||||
// Sequence 1 sorts first, so 2 is applied and there is no regression.
|
||||
expect(r.apply.map(m => m.sequence)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('returns apply order by sequence even when the input is shuffled', () => {
|
||||
const shuffled = [
|
||||
msg({ id: 'c', sequence: 3 }),
|
||||
msg({ id: 'a', sequence: 1 }),
|
||||
msg({ id: 'b', sequence: 2 }),
|
||||
]
|
||||
expect(assessStream(shuffled, policy).apply.map(m => m.sequence)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('tracks sequences per sender, not globally', () => {
|
||||
const two = [
|
||||
msg({ id: 'a', sender: 'probe-a', sequence: 9 }),
|
||||
msg({ id: 'b', sender: 'probe-b', sequence: 1 }),
|
||||
]
|
||||
expect(assessStream(two, policy).reject).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a duplicate is the delivery guarantee working', () => {
|
||||
const dup = [msg({ id: 'a', sequence: 1 }), msg({ id: 'b', sequence: 1 })]
|
||||
|
||||
it('fails a duplicate when the receiver is not idempotent', () => {
|
||||
const r = assessStream(dup, policy)
|
||||
const f = r.report.findings.find(x => x.id.endsWith('/duplicate'))!
|
||||
expect(f.determination).toBe('fail')
|
||||
expect(f.detail).toContain('the assumption of exactly-once is')
|
||||
})
|
||||
|
||||
it('accepts it when the receiver declares idempotency', () => {
|
||||
const r = assessStream(dup, { ...policy, receiverIsIdempotent: true })
|
||||
const f = r.report.findings.find(x => x.id.endsWith('/duplicate'))!
|
||||
expect(f.determination).toBe('not-applicable')
|
||||
expect(r.reject).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts it when the message itself is idempotent', () => {
|
||||
const r = assessStream([msg({ id: 'a', idempotent: true }), msg({ id: 'b', idempotent: true })], policy)
|
||||
expect(r.reject).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transit', () => {
|
||||
it('rejects a message too old to act on, and says to record it anyway', () => {
|
||||
const r = assessStream([msg()], { ...policy, maxTransitSeconds: 300 })
|
||||
const f = r.report.findings.find(x => x.id.endsWith('/stale'))!
|
||||
expect(f.determination).toBe('fail')
|
||||
expect(f.detail).toContain('Record it; do not act on it')
|
||||
})
|
||||
|
||||
it('flags impossible clocks as unassessed, not as a fast message', () => {
|
||||
const back = msg({ originAt: '2026-08-01T10:20:00Z', receivedAt: '2026-08-01T10:00:00Z' })
|
||||
expect(transit(back).impossible).toBe(true)
|
||||
const r = assessStream([back], policy)
|
||||
expect(r.report.findings[0]!.determination).toBe('not-assessed')
|
||||
expect(r.report.findings[0]!.detail).toContain('neither transit time nor any expiry decision')
|
||||
})
|
||||
})
|
||||
|
||||
describe('conformance with the evidence-record standard', () => {
|
||||
it('an empty stream is not-assessed, and says what silence means', () => {
|
||||
const r = assessStream([])
|
||||
expect(overall(r.report)).toBe('not-assessed')
|
||||
expect(formatStream([])).toContain('silence is not evidence that nothing was sent')
|
||||
})
|
||||
|
||||
it('every conclusion carries a citation', () => {
|
||||
expect(validateReport(assessStream([msg(), msg({ id: 'b', sequence: 2 })], policy).report)).toEqual([])
|
||||
})
|
||||
|
||||
it('passes a clean stream', () => {
|
||||
expect(overall(assessStream([msg()], policy).report)).toBe('pass')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "dist-cjs",
|
||||
"declaration": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||
"strict": true, "skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user