Compare commits

..

10 Commits

Author SHA1 Message Date
nav
ccdf167daf feat: add crypto module (Ed25519 key generation, sign, verify)
Moves verifySignature, signMessage, generateKeyPair and utility functions
from Yonexus.Client into Protocol so Server no longer depends on Client
at build time or runtime.
2026-04-16 10:36:55 +00:00
h z
d2a16bcb02 Merge pull request 'dev/2026-04-08' (#1) from dev/2026-04-08 into main
Reviewed-on: #1
2026-04-13 09:33:41 +00:00
nav
2611304084 chore: align protocol validation tooling 2026-04-09 05:03:03 +00:00
nav
8744a771a2 Tighten protocol typings for strict consumers 2026-04-09 04:37:59 +00:00
nav
a7e1a9c210 test(protocol): add codec coverage 2026-04-09 00:36:37 +00:00
nav
7e69a08443 feat: add shared auth proof helpers 2026-04-08 22:04:37 +00:00
nav
fbe1457ab6 Add protocol codec tests and vitest setup 2026-04-08 21:04:55 +00:00
nav
4d8c787dbc feat(protocol): add shared protocol error helpers 2026-04-08 20:33:25 +00:00
nav
fb4cd6e45b feat(protocol): add codec module with encode/decode and envelope builders
- Add encodeBuiltin/decodeBuiltin for builtin message wire format
- Add parseRuleMessage/parseRewrittenRuleMessage for rule dispatch
- Add type-safe envelope builders for all builtin message types
- Export codec module from index.ts
2026-04-08 20:20:11 +00:00
root
de9c41fc88 feat: add shared protocol types 2026-04-08 20:03:28 +00:00
13 changed files with 2894 additions and 1 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
*.log

View File

@@ -7,7 +7,8 @@ It is referenced as a git submodule by both `Yonexus.Server` and `Yonexus.Client
## Contents
- `PROTOCOL.md` — full protocol specification
- TypeScript type definitions (planned: `src/types.ts`)
- `src/types.ts` — shared builtin envelope / payload TypeScript definitions
- `src/index.ts` — package export surface
- Canonical JSON shape references
## Purpose

1826
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "yonexus-protocol",
"version": "0.1.0",
"description": "Yonexus shared communication protocol",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^5.0.0",
"vitest": "^1.0.0"
},
"files": [
"dist/",
"src/",
"PROTOCOL.md"
]
}

73
src/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { AuthRequestPayload } from "./types.js";
export const AUTH_NONCE_LENGTH = 24;
export const AUTH_MAX_TIMESTAMP_DRIFT_SECONDS = 10;
export const AUTH_MAX_ATTEMPTS_PER_WINDOW = 10;
export const AUTH_ATTEMPT_WINDOW_SECONDS = 10;
export const AUTH_RECENT_NONCE_WINDOW_SIZE = 10;
export interface AuthProofMaterial {
secret: string;
nonce: string;
timestamp: number;
}
export function createAuthProofMaterial(input: AuthProofMaterial): AuthProofMaterial {
return {
secret: input.secret,
nonce: input.nonce,
timestamp: input.timestamp
};
}
export function serializeAuthProofMaterial(input: AuthProofMaterial): string {
const material = createAuthProofMaterial(input);
return JSON.stringify({
secret: material.secret,
nonce: material.nonce,
timestamp: material.timestamp
});
}
export function isValidAuthNonce(nonce: string): boolean {
return /^[A-Za-z0-9_-]{24}$/.test(nonce);
}
export function isTimestampFresh(
proofTimestamp: number,
now: number,
maxDriftSeconds: number = AUTH_MAX_TIMESTAMP_DRIFT_SECONDS
): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } {
const drift = proofTimestamp - now;
if (Math.abs(drift) < maxDriftSeconds) {
return { ok: true };
}
return {
ok: false,
reason: drift < 0 ? "stale_timestamp" : "future_timestamp"
};
}
export function createAuthRequestSigningInput(input: {
secret: string;
nonce: string;
proofTimestamp: number;
}): string {
return serializeAuthProofMaterial({
secret: input.secret,
nonce: input.nonce,
timestamp: input.proofTimestamp
});
}
export function extractAuthRequestSigningInput(
payload: Pick<AuthRequestPayload, "nonce" | "proofTimestamp">,
secret: string
): string {
return createAuthRequestSigningInput({
secret,
nonce: payload.nonce,
proofTimestamp: payload.proofTimestamp
});
}

360
src/codec.ts Normal file
View File

@@ -0,0 +1,360 @@
import {
type BuiltinEnvelope,
type BuiltinMessageType,
type BuiltinPayloadMap
} from "./types.js";
/**
* Yonexus Protocol Codec
*
* Provides unified string protocol parsing and serialization.
*
* Wire formats:
* - builtin::{json} for protocol messages
* - ${rule_identifier}::${message_content} for application messages
*/
// Reserved rule identifier for builtin messages
export const BUILTIN_RULE = "builtin" as const;
// Delimiter for rule messages
const RULE_DELIMITER = "::";
/**
* Errors that can occur during encoding/decoding
*/
export class CodecError extends Error {
constructor(message: string) {
super(message);
this.name = "CodecError";
}
}
/**
* Result of parsing a rule message
*/
export interface ParsedRuleMessage {
/** The rule identifier (first segment before ::) */
readonly ruleIdentifier: string;
/** The raw content after the first :: delimiter */
readonly content: string;
}
/**
* Result of parsing a server-rewritten rule message
*/
export interface ParsedRewrittenRuleMessage extends ParsedRuleMessage {
/** The sender identifier (second segment) */
readonly senderIdentifier: string;
/** The actual message content (after sender identifier) */
readonly messageContent: string;
}
/**
* Encode a builtin envelope to wire format.
*
* @param envelope - The builtin envelope to encode
* @returns Wire format string: builtin::{json}
* @throws CodecError if envelope is invalid or JSON serialization fails
*/
export function encodeBuiltin<TType extends BuiltinMessageType>(
envelope: BuiltinEnvelope<TType, BuiltinPayloadMap[TType]>
): string {
if (!envelope || typeof envelope !== "object") {
throw new CodecError("Envelope must be an object");
}
if (!envelope.type || typeof envelope.type !== "string") {
throw new CodecError("Envelope must have a valid 'type' field");
}
// Build the envelope with defaults
const fullEnvelope: BuiltinEnvelope = {
timestamp: Math.floor(Date.now() / 1000),
...envelope
};
try {
const json = JSON.stringify(fullEnvelope);
return `${BUILTIN_RULE}${RULE_DELIMITER}${json}`;
} catch (cause) {
throw new CodecError(
`Failed to serialize envelope: ${cause instanceof Error ? cause.message : String(cause)}`
);
}
}
/**
* Decode a builtin message from wire format.
*
* @param raw - The raw wire message (expected: builtin::{json})
* @returns Parsed builtin envelope
* @throws CodecError if format is invalid or JSON parsing fails
*/
export function decodeBuiltin(raw: string): BuiltinEnvelope {
if (typeof raw !== "string") {
throw new CodecError("Input must be a string");
}
// Find the first :: delimiter
const delimiterIndex = raw.indexOf(RULE_DELIMITER);
if (delimiterIndex === -1) {
throw new CodecError("Malformed message: missing :: delimiter");
}
const ruleIdentifier = raw.slice(0, delimiterIndex);
const content = raw.slice(delimiterIndex + RULE_DELIMITER.length);
if (ruleIdentifier !== BUILTIN_RULE) {
throw new CodecError(
`Expected rule identifier '${BUILTIN_RULE}', got '${ruleIdentifier}'`
);
}
if (content.length === 0) {
throw new CodecError("Empty builtin payload");
}
try {
const parsed = JSON.parse(content) as unknown;
if (!parsed || typeof parsed !== "object") {
throw new CodecError("Builtin payload must be a JSON object");
}
const envelope = parsed as Record<string, unknown>;
if (!envelope.type || typeof envelope.type !== "string") {
throw new CodecError("Missing or invalid 'type' field in envelope");
}
return envelope as unknown as BuiltinEnvelope;
} catch (cause) {
if (cause instanceof CodecError) {
throw cause;
}
throw new CodecError(
`Failed to parse JSON payload: ${cause instanceof Error ? cause.message : String(cause)}`
);
}
}
/**
* Parse a rule message from wire format.
*
* Only splits on the first :: delimiter, preserving any :: in the content.
*
* @param raw - The raw wire message
* @returns Parsed rule message with identifier and content
* @throws CodecError if format is invalid
*/
export function parseRuleMessage(raw: string): ParsedRuleMessage {
if (typeof raw !== "string") {
throw new CodecError("Input must be a string");
}
const delimiterIndex = raw.indexOf(RULE_DELIMITER);
if (delimiterIndex === -1) {
throw new CodecError(
`Malformed rule message: missing '${RULE_DELIMITER}' delimiter`
);
}
const ruleIdentifier = raw.slice(0, delimiterIndex);
const content = raw.slice(delimiterIndex + RULE_DELIMITER.length);
if (ruleIdentifier.length === 0) {
throw new CodecError("Empty rule identifier");
}
// Validate rule identifier doesn't contain spaces or control characters
if (!/^[a-zA-Z0-9_-]+$/.test(ruleIdentifier)) {
throw new CodecError(
`Invalid rule identifier '${ruleIdentifier}': must be alphanumeric with underscores/hyphens only`
);
}
// Check for reserved builtin identifier
if (ruleIdentifier === BUILTIN_RULE) {
throw new CodecError(
`Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages`
);
}
return {
ruleIdentifier,
content
};
}
/**
* Parse a server-rewritten rule message.
*
* Server rewrites: ${rule}::${content} -> ${rule}::${sender}::${content}
*
* @param raw - The raw wire message from server
* @returns Parsed rewritten message with sender identifier
* @throws CodecError if format is invalid
*/
export function parseRewrittenRuleMessage(raw: string): ParsedRewrittenRuleMessage {
if (typeof raw !== "string") {
throw new CodecError("Input must be a string");
}
// Find first delimiter
const firstDelimiterIndex = raw.indexOf(RULE_DELIMITER);
if (firstDelimiterIndex === -1) {
throw new CodecError(
`Malformed rule message: missing '${RULE_DELIMITER}' delimiter`
);
}
const ruleIdentifier = raw.slice(0, firstDelimiterIndex);
const afterRule = raw.slice(firstDelimiterIndex + RULE_DELIMITER.length);
if (ruleIdentifier.length === 0) {
throw new CodecError("Empty rule identifier");
}
// Find second delimiter
const secondDelimiterIndex = afterRule.indexOf(RULE_DELIMITER);
if (secondDelimiterIndex === -1) {
throw new CodecError(
"Malformed rewritten message: missing sender identifier delimiter"
);
}
const senderIdentifier = afterRule.slice(0, secondDelimiterIndex);
const messageContent = afterRule.slice(secondDelimiterIndex + RULE_DELIMITER.length);
if (senderIdentifier.length === 0) {
throw new CodecError("Empty sender identifier");
}
return {
ruleIdentifier,
senderIdentifier,
content: afterRule, // raw content after rule (includes sender::message)
messageContent
};
}
/**
* Encode a rule message to wire format.
*
* @param ruleIdentifier - The rule identifier
* @param content - The message content
* @returns Wire format string: ${rule_identifier}::${message_content}
* @throws CodecError if ruleIdentifier is invalid or reserved
*/
export function encodeRuleMessage(
ruleIdentifier: string,
content: string
): string {
if (typeof ruleIdentifier !== "string" || ruleIdentifier.length === 0) {
throw new CodecError("Rule identifier must be a non-empty string");
}
if (typeof content !== "string") {
throw new CodecError("Content must be a string");
}
// Validate rule identifier format
if (!/^[a-zA-Z0-9_-]+$/.test(ruleIdentifier)) {
throw new CodecError(
`Invalid rule identifier '${ruleIdentifier}': must be alphanumeric with underscores/hyphens only`
);
}
if (ruleIdentifier === BUILTIN_RULE) {
throw new CodecError(
`Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages`
);
}
return `${ruleIdentifier}${RULE_DELIMITER}${content}`;
}
/**
* Encode a server-rewritten rule message.
*
* @param ruleIdentifier - The rule identifier
* @param senderIdentifier - The client identifier that sent the message
* @param content - The original message content
* @returns Wire format string: ${rule}::${sender}::${content}
* @throws CodecError if identifiers are invalid
*/
export function encodeRewrittenRuleMessage(
ruleIdentifier: string,
senderIdentifier: string,
content: string
): string {
if (typeof ruleIdentifier !== "string" || ruleIdentifier.length === 0) {
throw new CodecError("Rule identifier must be a non-empty string");
}
if (typeof senderIdentifier !== "string" || senderIdentifier.length === 0) {
throw new CodecError("Sender identifier must be a non-empty string");
}
if (typeof content !== "string") {
throw new CodecError("Content must be a string");
}
if (ruleIdentifier === BUILTIN_RULE) {
throw new CodecError(
`Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages`
);
}
return `${ruleIdentifier}${RULE_DELIMITER}${senderIdentifier}${RULE_DELIMITER}${content}`;
}
/**
* Check if a raw message is a builtin message.
*
* @param raw - The raw wire message
* @returns True if the message starts with "builtin::"
*/
export function isBuiltinMessage(raw: string): boolean {
return typeof raw === "string" && raw.startsWith(`${BUILTIN_RULE}${RULE_DELIMITER}`);
}
/**
* Create a type-safe builtin envelope builder.
*
* @param type - The builtin message type
* @returns A builder function that creates envelopes of that type
*/
export function createEnvelopeBuilder<TType extends keyof BuiltinPayloadMap>(
type: TType
) {
return (
payload: BuiltinPayloadMap[TType],
options?: { requestId?: string; timestamp?: number }
): BuiltinEnvelope<TType, BuiltinPayloadMap[TType]> => {
return {
type,
requestId: options?.requestId,
timestamp: options?.timestamp ?? Math.floor(Date.now() / 1000),
payload
};
};
}
// Pre-built envelope builders for common message types
export const buildHello = createEnvelopeBuilder("hello");
export const buildHelloAck = createEnvelopeBuilder("hello_ack");
export const buildPairRequest = createEnvelopeBuilder("pair_request");
export const buildPairConfirm = createEnvelopeBuilder("pair_confirm");
export const buildPairSuccess = createEnvelopeBuilder("pair_success");
export const buildPairFailed = createEnvelopeBuilder("pair_failed");
export const buildAuthRequest = createEnvelopeBuilder("auth_request");
export const buildAuthSuccess = createEnvelopeBuilder("auth_success");
export const buildAuthFailed = createEnvelopeBuilder("auth_failed");
export const buildRePairRequired = createEnvelopeBuilder("re_pair_required");
export const buildHeartbeat = createEnvelopeBuilder("heartbeat");
export const buildHeartbeatAck = createEnvelopeBuilder("heartbeat_ack");
export const buildStatusUpdate = createEnvelopeBuilder("status_update");
export const buildDisconnectNotice = createEnvelopeBuilder("disconnect_notice");
export const buildError = createEnvelopeBuilder("error");

49
src/crypto.ts Normal file
View File

@@ -0,0 +1,49 @@
import { randomBytes, generateKeyPair as _generateKeyPair, sign, verify } from "node:crypto";
export interface KeyPair {
readonly privateKey: string;
readonly publicKey: string;
readonly algorithm: "Ed25519";
}
export async function generateKeyPair(): Promise<KeyPair> {
const { publicKey, privateKey } = await new Promise<{ publicKey: string; privateKey: string }>((resolve, reject) => {
_generateKeyPair(
"ed25519",
{
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
},
(err, pubKey, privKey) => (err ? reject(err) : resolve({ publicKey: pubKey, privateKey: privKey }))
);
});
return { privateKey, publicKey, algorithm: "Ed25519" };
}
export async function signMessage(privateKeyPem: string, message: Buffer | string): Promise<string> {
return sign(null, typeof message === "string" ? Buffer.from(message) : message, privateKeyPem).toString("base64");
}
export async function verifySignature(publicKeyPem: string, message: Buffer | string, signature: string): Promise<boolean> {
try {
return verify(null, typeof message === "string" ? Buffer.from(message) : message, publicKeyPem, Buffer.from(signature, "base64"));
} catch {
return false;
}
}
export function generatePairingCode(): string {
const bytes = randomBytes(8);
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 12; i++) code += chars[bytes[i % bytes.length] % chars.length];
return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`;
}
export function generateSecret(): string {
return randomBytes(32).toString("base64url");
}
export function generateNonce(): string {
return randomBytes(18).toString("base64url").slice(0, 24);
}

93
src/errors.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { BuiltinEnvelope, ErrorPayload, ProtocolErrorCode, TypedBuiltinEnvelope } from "./types.js";
export type ProtocolErrorCategory =
| "configuration"
| "protocol"
| "authentication"
| "pairing"
| "runtime";
export interface ProtocolErrorOptions {
message?: string;
details?: Record<string, unknown>;
cause?: unknown;
}
const protocolErrorCategories: Record<ProtocolErrorCode, ProtocolErrorCategory> = {
MALFORMED_MESSAGE: "protocol",
UNSUPPORTED_PROTOCOL_VERSION: "protocol",
IDENTIFIER_NOT_ALLOWED: "protocol",
PAIRING_REQUIRED: "pairing",
PAIRING_EXPIRED: "pairing",
ADMIN_NOTIFICATION_FAILED: "pairing",
AUTH_FAILED: "authentication",
NONCE_COLLISION: "authentication",
RATE_LIMITED: "authentication",
RE_PAIR_REQUIRED: "authentication",
CLIENT_OFFLINE: "runtime",
INTERNAL_ERROR: "runtime"
};
export function getProtocolErrorCategory(code: ProtocolErrorCode): ProtocolErrorCategory {
return protocolErrorCategories[code];
}
export class YonexusProtocolError extends Error {
readonly code: ProtocolErrorCode;
readonly category: ProtocolErrorCategory;
readonly details?: Record<string, unknown>;
override readonly cause?: unknown;
constructor(code: ProtocolErrorCode, options: ProtocolErrorOptions = {}) {
super(options.message ?? code);
this.name = "YonexusProtocolError";
this.code = code;
this.category = getProtocolErrorCategory(code);
this.details = options.details;
this.cause = options.cause;
}
toPayload(): ErrorPayload {
return {
code: this.code,
message: this.message,
details: this.details
};
}
toEnvelope(timestamp: number = Math.floor(Date.now() / 1000)): TypedBuiltinEnvelope<"error"> {
return {
type: "error",
timestamp,
payload: this.toPayload()
};
}
}
export function createProtocolError(
code: ProtocolErrorCode,
options: ProtocolErrorOptions = {}
): YonexusProtocolError {
return new YonexusProtocolError(code, options);
}
export function protocolErrorFromPayload(
payload: ErrorPayload,
cause?: unknown
): YonexusProtocolError {
return new YonexusProtocolError(payload.code, {
message: payload.message,
details: payload.details,
cause
});
}
export function isProtocolError(value: unknown): value is YonexusProtocolError {
return value instanceof YonexusProtocolError;
}
export function isProtocolErrorEnvelope(
envelope: BuiltinEnvelope
): envelope is TypedBuiltinEnvelope<"error"> {
return envelope.type === "error";
}

5
src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from "./types.js";
export * from "./codec.js";
export * from "./errors.js";
export * from "./auth.js";
export * from "./crypto.js";

182
src/types.ts Normal file
View File

@@ -0,0 +1,182 @@
export const YONEXUS_PROTOCOL_VERSION = "1" as const;
export const builtinMessageTypes = [
"hello",
"hello_ack",
"pair_request",
"pair_confirm",
"pair_success",
"pair_failed",
"auth_request",
"auth_success",
"auth_failed",
"re_pair_required",
"heartbeat",
"heartbeat_ack",
"status_update",
"disconnect_notice",
"error"
] as const;
export type BuiltinMessageType = (typeof builtinMessageTypes)[number];
export interface BuiltinEnvelope<
TType extends BuiltinMessageType = BuiltinMessageType,
TPayload = Record<string, unknown>
> {
type: TType;
requestId?: string;
timestamp?: number;
payload?: TPayload;
}
export interface HelloPayload extends Record<string, unknown> {
identifier: string;
hasSecret: boolean;
hasKeyPair: boolean;
publicKey?: string;
protocolVersion: string;
}
export type HelloAckNextAction =
| "pair_required"
| "auth_required"
| "rejected"
| "waiting_pair_confirm";
export interface HelloAckPayload extends Record<string, unknown> {
identifier: string;
nextAction: HelloAckNextAction;
}
export type AdminNotificationStatus = "sent" | "failed";
export interface PairRequestPayload extends Record<string, unknown> {
identifier: string;
expiresAt: number;
ttlSeconds: number;
adminNotification: AdminNotificationStatus;
codeDelivery: "out_of_band";
}
export interface PairConfirmPayload extends Record<string, unknown> {
identifier: string;
pairingCode: string;
}
export interface PairSuccessPayload extends Record<string, unknown> {
identifier: string;
secret: string;
pairedAt: number;
}
export type PairFailedReason =
| "expired"
| "invalid_code"
| "identifier_not_allowed"
| "admin_notification_failed"
| "internal_error";
export interface PairFailedPayload extends Record<string, unknown> {
identifier: string;
reason: PairFailedReason;
}
export interface AuthRequestPayload extends Record<string, unknown> {
identifier: string;
nonce: string;
proofTimestamp: number;
signature: string;
publicKey?: string;
}
export interface AuthSuccessPayload extends Record<string, unknown> {
identifier: string;
authenticatedAt: number;
status: "online";
}
export type AuthFailedReason =
| "unknown_identifier"
| "not_paired"
| "invalid_signature"
| "invalid_secret"
| "stale_timestamp"
| "future_timestamp"
| "nonce_collision"
| "rate_limited"
| "re_pair_required";
export interface AuthFailedPayload extends Record<string, unknown> {
identifier: string;
reason: AuthFailedReason;
}
export interface RePairRequiredPayload extends Record<string, unknown> {
identifier: string;
reason: "nonce_collision" | "rate_limited" | "trust_revoked";
}
export interface HeartbeatPayload extends Record<string, unknown> {
identifier: string;
status: "alive";
}
export interface HeartbeatAckPayload extends Record<string, unknown> {
identifier: string;
status: "online" | "unstable" | "offline";
}
export interface StatusUpdatePayload extends Record<string, unknown> {
identifier: string;
status: "online" | "unstable" | "offline";
reason: string;
}
export interface DisconnectNoticePayload extends Record<string, unknown> {
identifier: string;
reason: string;
}
export type ProtocolErrorCode =
| "MALFORMED_MESSAGE"
| "UNSUPPORTED_PROTOCOL_VERSION"
| "IDENTIFIER_NOT_ALLOWED"
| "PAIRING_REQUIRED"
| "PAIRING_EXPIRED"
| "ADMIN_NOTIFICATION_FAILED"
| "AUTH_FAILED"
| "NONCE_COLLISION"
| "RATE_LIMITED"
| "RE_PAIR_REQUIRED"
| "CLIENT_OFFLINE"
| "INTERNAL_ERROR";
export interface ErrorPayload extends Record<string, unknown> {
code: ProtocolErrorCode;
message?: string;
details?: Record<string, unknown>;
}
export type BuiltinPayloadMap = {
hello: HelloPayload;
hello_ack: HelloAckPayload;
pair_request: PairRequestPayload;
pair_confirm: PairConfirmPayload;
pair_success: PairSuccessPayload;
pair_failed: PairFailedPayload;
auth_request: AuthRequestPayload;
auth_success: AuthSuccessPayload;
auth_failed: AuthFailedPayload;
re_pair_required: RePairRequiredPayload;
heartbeat: HeartbeatPayload;
heartbeat_ack: HeartbeatAckPayload;
status_update: StatusUpdatePayload;
disconnect_notice: DisconnectNoticePayload;
error: ErrorPayload;
};
export type TypedBuiltinEnvelope<TType extends keyof BuiltinPayloadMap> = BuiltinEnvelope<
TType,
BuiltinPayloadMap[TType]
>;

252
tests/codec.test.ts Normal file
View File

@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import {
buildAuthFailed,
buildAuthRequest,
buildAuthSuccess,
buildDisconnectNotice,
buildError,
buildHeartbeat,
buildHeartbeatAck,
buildHello,
buildHelloAck,
buildPairConfirm,
buildPairFailed,
buildPairRequest,
buildPairSuccess,
buildRePairRequired,
buildStatusUpdate,
CodecError,
decodeBuiltin,
encodeBuiltin,
encodeRewrittenRuleMessage,
encodeRuleMessage,
isBuiltinMessage,
parseRewrittenRuleMessage,
parseRuleMessage
} from "../src/index.js";
describe("Protocol Codec - encodeBuiltin / decodeBuiltin", () => {
it("encodes and decodes a hello envelope", () => {
const envelope = buildHello(
{
identifier: "client-a",
hasSecret: true,
hasKeyPair: true,
publicKey: "pk-test",
protocolVersion: "1"
},
{ requestId: "req-001" }
);
const encoded = encodeBuiltin(envelope);
expect(encoded.startsWith("builtin::")).toBe(true);
const decoded = decodeBuiltin(encoded);
expect(decoded.type).toBe("hello");
expect(decoded.requestId).toBe("req-001");
expect(decoded.payload).toMatchObject({
identifier: "client-a",
hasSecret: true,
hasKeyPair: true,
publicKey: "pk-test",
protocolVersion: "1"
});
});
it("encodes and decodes all builtin message types", () => {
const testCases = [
{
builder: buildHelloAck,
payload: { identifier: "client-a", nextAction: "pair_required" as const }
},
{
builder: buildPairRequest,
payload: {
identifier: "client-a",
expiresAt: 1_710_000_000,
ttlSeconds: 300,
adminNotification: "sent" as const,
codeDelivery: "out_of_band" as const
}
},
{
builder: buildPairConfirm,
payload: { identifier: "client-a", pairingCode: "ABCD-1234-XYZ" }
},
{
builder: buildPairSuccess,
payload: { identifier: "client-a", secret: "secret-xyz", pairedAt: 1_710_000_000 }
},
{
builder: buildPairFailed,
payload: { identifier: "client-a", reason: "expired" as const }
},
{
builder: buildAuthRequest,
payload: {
identifier: "client-a",
nonce: "RANDOM24CHARACTERSTRINGX",
proofTimestamp: 1_710_000_000,
signature: "sig-base64",
publicKey: "pk-test"
}
},
{
builder: buildAuthSuccess,
payload: { identifier: "client-a", authenticatedAt: 1_710_000_000, status: "online" as const }
},
{
builder: buildAuthFailed,
payload: { identifier: "client-a", reason: "invalid_signature" as const }
},
{
builder: buildRePairRequired,
payload: { identifier: "client-a", reason: "nonce_collision" as const }
},
{
builder: buildHeartbeat,
payload: { identifier: "client-a", status: "alive" as const }
},
{
builder: buildHeartbeatAck,
payload: { identifier: "client-a", status: "online" as const }
},
{
builder: buildStatusUpdate,
payload: { identifier: "client-a", status: "unstable" as const, reason: "heartbeat_timeout_7m" }
},
{
builder: buildDisconnectNotice,
payload: { identifier: "client-a", reason: "heartbeat_timeout_11m" }
},
{
builder: buildError,
payload: { code: "MALFORMED_MESSAGE" as const, message: "Invalid JSON" }
}
];
for (const { builder, payload } of testCases) {
const envelope = builder(payload as never, { requestId: "test-req" });
const encoded = encodeBuiltin(envelope);
const decoded = decodeBuiltin(encoded);
expect(decoded.type).toBe(envelope.type);
expect(decoded.requestId).toBe("test-req");
expect(decoded.payload).toMatchObject(payload);
}
});
it("throws CodecError for malformed messages", () => {
expect(() => decodeBuiltin("not-builtin::{")).toThrow(CodecError);
expect(() => decodeBuiltin("builtin::not-json")).toThrow(CodecError);
expect(() => decodeBuiltin("builtin::{}")).toThrow(CodecError); // missing type
expect(() => decodeBuiltin("no-delimiter")).toThrow(CodecError);
expect(() => decodeBuiltin("")).toThrow(CodecError);
});
it("throws CodecError for invalid envelope input", () => {
expect(() => encodeBuiltin(null as never)).toThrow(CodecError);
expect(() => encodeBuiltin({} as never)).toThrow(CodecError);
expect(() => encodeBuiltin({ type: 123 } as never)).toThrow(CodecError);
});
});
describe("Protocol Codec - Rule Message Parsing", () => {
it("parses rule messages correctly", () => {
const parsed = parseRuleMessage("chat_sync::{\"body\":\"hello\"}");
expect(parsed.ruleIdentifier).toBe("chat_sync");
expect(parsed.content).toBe('{"body":"hello"}');
});
it("handles content containing :: delimiters", () => {
const parsed = parseRuleMessage("rule::a::b::c");
expect(parsed.ruleIdentifier).toBe("rule");
expect(parsed.content).toBe("a::b::c");
});
it("throws CodecError for invalid rule messages", () => {
expect(() => parseRuleMessage("no-delimiter")).toThrow(CodecError);
expect(() => parseRuleMessage(":")).toThrow(CodecError);
expect(() => parseRuleMessage("")).toThrow(CodecError);
expect(() => parseRuleMessage("::content")).toThrow(CodecError); // empty identifier
});
it("throws CodecError for reserved builtin identifier", () => {
expect(() => parseRuleMessage("builtin::something")).toThrow(
/reserved/
);
});
it("throws CodecError for invalid rule identifier characters", () => {
expect(() => parseRuleMessage("rule with spaces::content")).toThrow(CodecError);
// Note: special chars in content are allowed - only the rule identifier is validated
expect(() => parseRuleMessage("rule::with::special!::chars")).not.toThrow(); // content can contain special chars
});
it("isBuiltinMessage identifies builtin messages", () => {
expect(isBuiltinMessage("builtin::{\"type\":\"hello\"}")).toBe(true);
expect(isBuiltinMessage("rule::content")).toBe(false);
expect(isBuiltinMessage("not-builtin::content")).toBe(false);
expect(isBuiltinMessage("")).toBe(false);
});
});
describe("Protocol Codec - Server Rewritten Messages", () => {
it("parses rewritten messages with sender identifier", () => {
const parsed = parseRewrittenRuleMessage("chat_sync::client-a::{\"body\":\"hello\"}");
expect(parsed.ruleIdentifier).toBe("chat_sync");
expect(parsed.senderIdentifier).toBe("client-a");
expect(parsed.messageContent).toBe('{"body":"hello"}');
expect(parsed.content).toBe("client-a::{\"body\":\"hello\"}");
});
it("handles complex content with multiple delimiters", () => {
const parsed = parseRewrittenRuleMessage("rule::sender::a::b::c");
expect(parsed.ruleIdentifier).toBe("rule");
expect(parsed.senderIdentifier).toBe("sender");
expect(parsed.messageContent).toBe("a::b::c");
});
it("throws CodecError for malformed rewritten messages", () => {
expect(() => parseRewrittenRuleMessage("no-delimiters")).toThrow(CodecError);
expect(() => parseRewrittenRuleMessage("rule::only-one")).toThrow(/sender/);
expect(() => parseRewrittenRuleMessage("::sender::content")).toThrow(/Empty/);
// Note: "rule:::content" has empty sender which is caught by "missing sender identifier delimiter" check
expect(() => parseRewrittenRuleMessage("rule:::content")).toThrow(/sender/);
});
});
describe("Protocol Codec - encodeRuleMessage", () => {
it("encodes rule messages correctly", () => {
const encoded = encodeRuleMessage("chat_sync", '{"body":"hello"}');
expect(encoded).toBe('chat_sync::{"body":"hello"}');
});
it("throws CodecError for invalid rule identifiers", () => {
expect(() => encodeRuleMessage("", "content")).toThrow(CodecError);
expect(() => encodeRuleMessage("builtin", "content")).toThrow(/reserved/);
expect(() => encodeRuleMessage("rule with spaces", "content")).toThrow(CodecError);
expect(() => encodeRuleMessage("rule!", "content")).toThrow(CodecError);
});
it("throws CodecError for non-string content", () => {
expect(() => encodeRuleMessage("rule", null as never)).toThrow(CodecError);
});
});
describe("Protocol Codec - encodeRewrittenRuleMessage", () => {
it("encodes rewritten messages correctly", () => {
const encoded = encodeRewrittenRuleMessage("chat_sync", "client-a", '{"body":"hello"}');
expect(encoded).toBe('chat_sync::client-a::{"body":"hello"}');
});
it("throws CodecError for reserved rule identifier", () => {
expect(() => encodeRewrittenRuleMessage("builtin", "sender", "content")).toThrow(/reserved/);
});
it("throws CodecError for empty identifiers", () => {
expect(() => encodeRewrittenRuleMessage("", "sender", "content")).toThrow(CodecError);
expect(() => encodeRewrittenRuleMessage("rule", "", "content")).toThrow(CodecError);
});
});

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node"
}
});