Compare commits
8 Commits
fb4cd6e45b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ccdf167daf | |||
| d2a16bcb02 | |||
| 2611304084 | |||
| 8744a771a2 | |||
| a7e1a9c210 | |||
| 7e69a08443 | |||
| fbe1457ab6 | |||
| 4d8c787dbc |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
1826
package-lock.json
generated
Normal file
1826
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
73
src/auth.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export function decodeBuiltin(raw: string): BuiltinEnvelope {
|
||||
throw new CodecError("Missing or invalid 'type' field in envelope");
|
||||
}
|
||||
|
||||
return envelope as BuiltinEnvelope;
|
||||
return envelope as unknown as BuiltinEnvelope;
|
||||
} catch (cause) {
|
||||
if (cause instanceof CodecError) {
|
||||
throw cause;
|
||||
|
||||
49
src/crypto.ts
Normal file
49
src/crypto.ts
Normal 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
93
src/errors.ts
Normal 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";
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./types.js";
|
||||
export * from "./codec.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./crypto.js";
|
||||
|
||||
32
src/types.ts
32
src/types.ts
@@ -22,7 +22,7 @@ export type BuiltinMessageType = (typeof builtinMessageTypes)[number];
|
||||
|
||||
export interface BuiltinEnvelope<
|
||||
TType extends BuiltinMessageType = BuiltinMessageType,
|
||||
TPayload extends Record<string, unknown> = Record<string, unknown>
|
||||
TPayload = Record<string, unknown>
|
||||
> {
|
||||
type: TType;
|
||||
requestId?: string;
|
||||
@@ -30,7 +30,7 @@ export interface BuiltinEnvelope<
|
||||
payload?: TPayload;
|
||||
}
|
||||
|
||||
export interface HelloPayload {
|
||||
export interface HelloPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
hasSecret: boolean;
|
||||
hasKeyPair: boolean;
|
||||
@@ -44,14 +44,14 @@ export type HelloAckNextAction =
|
||||
| "rejected"
|
||||
| "waiting_pair_confirm";
|
||||
|
||||
export interface HelloAckPayload {
|
||||
export interface HelloAckPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
nextAction: HelloAckNextAction;
|
||||
}
|
||||
|
||||
export type AdminNotificationStatus = "sent" | "failed";
|
||||
|
||||
export interface PairRequestPayload {
|
||||
export interface PairRequestPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
expiresAt: number;
|
||||
ttlSeconds: number;
|
||||
@@ -59,12 +59,12 @@ export interface PairRequestPayload {
|
||||
codeDelivery: "out_of_band";
|
||||
}
|
||||
|
||||
export interface PairConfirmPayload {
|
||||
export interface PairConfirmPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
pairingCode: string;
|
||||
}
|
||||
|
||||
export interface PairSuccessPayload {
|
||||
export interface PairSuccessPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
secret: string;
|
||||
pairedAt: number;
|
||||
@@ -77,12 +77,12 @@ export type PairFailedReason =
|
||||
| "admin_notification_failed"
|
||||
| "internal_error";
|
||||
|
||||
export interface PairFailedPayload {
|
||||
export interface PairFailedPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
reason: PairFailedReason;
|
||||
}
|
||||
|
||||
export interface AuthRequestPayload {
|
||||
export interface AuthRequestPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
nonce: string;
|
||||
proofTimestamp: number;
|
||||
@@ -90,7 +90,7 @@ export interface AuthRequestPayload {
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
export interface AuthSuccessPayload {
|
||||
export interface AuthSuccessPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
authenticatedAt: number;
|
||||
status: "online";
|
||||
@@ -107,33 +107,33 @@ export type AuthFailedReason =
|
||||
| "rate_limited"
|
||||
| "re_pair_required";
|
||||
|
||||
export interface AuthFailedPayload {
|
||||
export interface AuthFailedPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
reason: AuthFailedReason;
|
||||
}
|
||||
|
||||
export interface RePairRequiredPayload {
|
||||
export interface RePairRequiredPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
reason: "nonce_collision" | "rate_limited" | "trust_revoked";
|
||||
}
|
||||
|
||||
export interface HeartbeatPayload {
|
||||
export interface HeartbeatPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
status: "alive";
|
||||
}
|
||||
|
||||
export interface HeartbeatAckPayload {
|
||||
export interface HeartbeatAckPayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
status: "online" | "unstable" | "offline";
|
||||
}
|
||||
|
||||
export interface StatusUpdatePayload {
|
||||
export interface StatusUpdatePayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
status: "online" | "unstable" | "offline";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DisconnectNoticePayload {
|
||||
export interface DisconnectNoticePayload extends Record<string, unknown> {
|
||||
identifier: string;
|
||||
reason: string;
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export type ProtocolErrorCode =
|
||||
| "CLIENT_OFFLINE"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface ErrorPayload {
|
||||
export interface ErrorPayload extends Record<string, unknown> {
|
||||
code: ProtocolErrorCode;
|
||||
message?: string;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
252
tests/codec.test.ts
Normal file
252
tests/codec.test.ts
Normal 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
17
tsconfig.json
Normal 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
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user