-
Notifications
You must be signed in to change notification settings - Fork 0
feat: peer protocol compatibility check between jack instances #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { describe, expect, test } from 'bun:test' | ||
| import { getApp } from '../app' | ||
| import { AppConfig } from '../lib/config' | ||
| import { SERVER_VERSION } from '../lib/version' | ||
|
|
||
| const envs = { ENVIRONMENT: 'test', ENABLE_LOGS: false, LOG_LEVEL: 'fatal' } as any | ||
|
|
||
| function buildApp() { | ||
| const config = AppConfig.parse({ | ||
| jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, | ||
| servers: [], | ||
| peers: [], | ||
| }) | ||
| return getApp(envs, config, { servers: [], peers: [] }) | ||
| } | ||
|
|
||
| describe('GET /handshake', () => { | ||
| test('returns the server identity and version with a valid api key', async () => { | ||
| const res = await buildApp().request('/handshake?apikey=test-api-key') | ||
| expect(res.status).toBe(200) | ||
| expect(await res.json()).toEqual({ name: 'jack', version: SERVER_VERSION }) | ||
| }) | ||
|
|
||
| test('rejects a request without an api key', async () => { | ||
| const res = await buildApp().request('/handshake') | ||
| expect(res.status).toBe(401) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' | ||
| import { http, HttpResponse } from 'msw' | ||
| import { setupServer } from 'msw/node' | ||
| import { getApp } from '../app' | ||
| import { AppConfig } from '../lib/config' | ||
| import { PeerConnector } from '../lib/servers/peer' | ||
| import { ServersController } from '../modules/servers/servers.controllers' | ||
|
|
||
| const envs = { ENVIRONMENT: 'test', ENABLE_LOGS: false, LOG_LEVEL: 'fatal' } as any | ||
|
|
||
| const server = setupServer() | ||
| beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) | ||
| afterEach(() => server.resetHandlers()) | ||
| afterAll(() => server.close()) | ||
|
|
||
| function makePeer(url = 'http://peer.test') { | ||
| return new PeerConnector({ url, apiKey: 'peer-key', name: 'Friend Jack' }) | ||
| } | ||
|
|
||
| describe('PeerConnector handshake compatibility', () => { | ||
| test('initializes against a compatible peer, sends its api key, and records its version', async () => { | ||
| const seenHeaders: Record<string, string | null> = {} | ||
| server.use( | ||
| http.get('http://peer.test/handshake', ({ request }) => { | ||
| seenHeaders.apiKey = request.headers.get('x-api-key') | ||
| return HttpResponse.json({ name: 'jack', version: '0.1.0' }) | ||
| }), | ||
| ) | ||
| const peer = makePeer() | ||
| peer.init() | ||
| await peer.initialization | ||
|
|
||
| expect(peer.isInitialized).toBe(true) | ||
| expect(peer.peerVersion).toBe('0.1.0') | ||
| expect(peer.initializationError).toBeNull() | ||
| expect(seenHeaders.apiKey).toBe('peer-key') | ||
| }) | ||
|
|
||
| test('fails on a peer whose version is below the minimum', async () => { | ||
| server.use( | ||
| http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack', version: '0.0.9' })), | ||
| ) | ||
| const peer = makePeer() | ||
| peer.init() | ||
| await peer.initialization?.catch(() => {}) | ||
|
|
||
| expect(peer.isInitialized).toBe(false) | ||
| expect(peer.initializationError).toContain('incompatible peer-protocol version') | ||
| expect(peer.initializationError).toContain('got 0.0.9') | ||
| }) | ||
|
|
||
| test('fails when the handshake has no version field', async () => { | ||
| server.use( | ||
| http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack' })), | ||
| ) | ||
| const peer = makePeer() | ||
| peer.init() | ||
| await peer.initialization?.catch(() => {}) | ||
|
|
||
| expect(peer.isInitialized).toBe(false) | ||
| expect(peer.initializationError).toContain('got none') | ||
| }) | ||
|
|
||
| test('fails when the handshake version is malformed (null / non-string)', async () => { | ||
| server.use( | ||
| http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack', version: null })), | ||
| ) | ||
| const peer = makePeer() | ||
| peer.init() | ||
| await peer.initialization?.catch(() => {}) | ||
|
|
||
| expect(peer.isInitialized).toBe(false) | ||
| expect(peer.initializationError).toContain('incompatible peer-protocol version') | ||
| expect(peer.initializationError).toContain('got none') | ||
| }) | ||
|
|
||
| test('treats an old peer with no /handshake route (404) as incompatible', async () => { | ||
| server.use( | ||
| http.get('http://peer.test/handshake', () => new HttpResponse(null, { status: 404 })), | ||
| ) | ||
| const peer = makePeer() | ||
| peer.init() | ||
| await peer.initialization?.catch(() => {}) | ||
|
|
||
| expect(peer.isInitialized).toBe(false) | ||
| expect(peer.initializationError).toContain('incompatible peer-protocol version') | ||
| }) | ||
|
|
||
| test('propagates an auth failure without claiming a version mismatch', async () => { | ||
| server.use( | ||
| http.get('http://peer.test/handshake', () => new HttpResponse(null, { status: 401 })), | ||
| ) | ||
| const peer = makePeer() | ||
| peer.init() | ||
| await peer.initialization?.catch(() => {}) | ||
|
|
||
| expect(peer.isInitialized).toBe(false) | ||
| expect(peer.initializationError).not.toContain('incompatible peer-protocol version') | ||
| }) | ||
| }) | ||
|
|
||
| describe('ServersController surfaces peer version', () => { | ||
| test('listServers includes each peer reported version', () => { | ||
| const fakePeer = { | ||
| name: 'Friend Jack', | ||
| url: 'http://peer.test', | ||
| type: 'jack', | ||
| isInitialized: true, | ||
| initializationError: null, | ||
| peerVersion: '0.1.0', | ||
| } as any | ||
| const controller = new ServersController({ servers: [], peers: [fakePeer] }) | ||
|
|
||
| const { peers } = controller.listServers() | ||
| expect(peers).toHaveLength(1) | ||
| expect(peers[0]).toMatchObject({ name: 'Friend Jack', version: '0.1.0' }) | ||
| }) | ||
|
|
||
| test('GET /servers exposes the peer version through the real route', async () => { | ||
| server.use( | ||
| http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack', version: '0.1.0' })), | ||
| ) | ||
| const peer = makePeer() | ||
| peer.init() | ||
| await peer.initialization | ||
|
|
||
| const config = AppConfig.parse({ | ||
| jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, | ||
| servers: [], | ||
| peers: [], | ||
| }) | ||
| const app = getApp(envs, config, { servers: [], peers: [peer] }) | ||
|
|
||
| const res = await app.request('/servers?apikey=test-api-key') | ||
| expect(res.status).toBe(200) | ||
| const body = await res.json() as { peers: Array<{ name: string, version: string | null }> } | ||
| expect(body.peers).toHaveLength(1) | ||
| expect(body.peers[0]).toMatchObject({ name: 'Friend Jack', version: '0.1.0' }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { describe, expect, test } from 'bun:test' | ||
| import { compareVersions, isPeerVersionCompatible, MIN_PEER_PROTOCOL_VERSION, SERVER_VERSION } from '../lib/version' | ||
|
|
||
| describe('compareVersions', () => { | ||
| test('returns 0 for equal versions', () => { | ||
| expect(compareVersions('0.1.0', '0.1.0')).toBe(0) | ||
| }) | ||
|
|
||
| test('compares by major, then minor, then patch', () => { | ||
| expect(compareVersions('1.0.0', '0.9.9')).toBe(1) | ||
| expect(compareVersions('0.2.0', '0.1.9')).toBe(1) | ||
| expect(compareVersions('0.1.2', '0.1.1')).toBe(1) | ||
| expect(compareVersions('0.1.0', '0.2.0')).toBe(-1) | ||
| expect(compareVersions('0.0.9', '0.1.0')).toBe(-1) | ||
| }) | ||
|
|
||
| test('throws on a malformed version string', () => { | ||
| expect(() => compareVersions('abc', '0.1.0')).toThrow('Invalid version string: "abc"') | ||
| expect(() => compareVersions('0.1.0', '1.2')).toThrow('Invalid version string: "1.2"') | ||
| }) | ||
| }) | ||
|
|
||
| describe('isPeerVersionCompatible', () => { | ||
| test('accepts the minimum and anything above it', () => { | ||
| expect(isPeerVersionCompatible(MIN_PEER_PROTOCOL_VERSION)).toBe(true) | ||
| expect(isPeerVersionCompatible('0.1.0')).toBe(true) | ||
| expect(isPeerVersionCompatible('1.0.0')).toBe(true) | ||
| }) | ||
|
|
||
| test('rejects versions below the minimum', () => { | ||
| expect(isPeerVersionCompatible('0.0.9')).toBe(false) | ||
| }) | ||
|
|
||
| test('rejects malformed or empty versions', () => { | ||
| expect(isPeerVersionCompatible('')).toBe(false) | ||
| expect(isPeerVersionCompatible('nope')).toBe(false) | ||
| }) | ||
|
|
||
| test('SERVER_VERSION is itself compatible', () => { | ||
| expect(isPeerVersionCompatible(SERVER_VERSION)).toBe(true) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { AppError } from './AppError' | ||
|
|
||
| /** | ||
| * A peer reported a peer-protocol version we can't talk to — or is too old to | ||
| * report one at all. Thrown during init so the connector fails loudly and the | ||
| * mismatch surfaces in /servers (initialized:false + initializationError). | ||
| */ | ||
| export class IncompatiblePeerError extends AppError { | ||
| constructor(message: string, cause?: unknown) { | ||
| super(message, 'INCOMPATIBLE_PEER', { cause }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // jack's own version, reported to peers over /handshake. This doubles as the | ||
| // peer-protocol version: a bump here signals a potential protocol change. | ||
| export const SERVER_VERSION = '0.1.0' | ||
|
|
||
| // Oldest peer version we can still talk to. Peers below this — or peers too old | ||
| // to expose a version at all — are rejected at init time as incompatible. | ||
| export const MIN_PEER_PROTOCOL_VERSION = '0.1.0' | ||
|
|
||
| const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/ | ||
|
|
||
| function parseVersion(version: string): [number, number, number] | null { | ||
| const match = VERSION_PATTERN.exec(version.trim()) | ||
| if (!match) | ||
| return null | ||
| return [Number(match[1]), Number(match[2]), Number(match[3])] | ||
| } | ||
|
|
||
| /** | ||
| * Compare two `x.y.z` versions numerically by major, then minor, then patch. | ||
| * Returns -1 if `a < b`, 0 if equal, 1 if `a > b`. Throws on malformed input. | ||
| */ | ||
| export function compareVersions(a: string, b: string): -1 | 0 | 1 { | ||
| const parsedA = parseVersion(a) | ||
| const parsedB = parseVersion(b) | ||
| if (!parsedA) | ||
| throw new Error(`Invalid version string: "${a}"`) | ||
| if (!parsedB) | ||
| throw new Error(`Invalid version string: "${b}"`) | ||
| for (let i = 0; i < 3; i++) { | ||
| if (parsedA[i]! < parsedB[i]!) | ||
| return -1 | ||
| if (parsedA[i]! > parsedB[i]!) | ||
| return 1 | ||
| } | ||
| return 0 | ||
| } | ||
|
|
||
| /** | ||
| * Whether a peer's reported version is new enough to talk to (>= the minimum we | ||
| * support). A malformed or empty version is treated as incompatible. | ||
| */ | ||
| export function isPeerVersionCompatible(version: string): boolean { | ||
| if (!parseVersion(version)) | ||
| return false | ||
| return compareVersions(version, MIN_PEER_PROTOCOL_VERSION) >= 0 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.