Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# @suaveplan/network-plugin-auth - Mesh membership authentication and cross-mesh relay authorization: join verifier, capability-token issuer, cross-mesh trust validator
## Summary
Mesh membership authentication and cross-mesh relay authorization. Bridges `@suaveplan/auth` into the mesh: peer join verification, capability-tier-bound tokens, cross-mesh trust store.
<!-- begin:auto-docs -->
## Documentation tree
- [issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
<!-- end:auto-docs --> - [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-auth",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Mesh membership authentication and cross-mesh relay authorization: join verifier, capability-token issuer, cross-mesh trust validator",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,61 @@
# MESH_AUTH_CODES
## Purpose
Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer fails membership verification, `TOKEN_EXPIRED` when a capability token's `expiresAt` is past, `TOKEN_INVALID_SIGNATURE` when the server signature does not verify, `CAPABILITY_DENIED` when a peer requests a capability outside its issued set, and `TRUST_NOT_ESTABLISHED` when cross-mesh relay traffic crosses an unknown boundary. The companion `MeshAuthError` class extends the cross-package `SuaveplanError` so consumers can pattern-match on `code` rather than parse messages.
## Features
- Five frozen codes registered under the `NET_AUTH` namespace.
- `MeshAuthError extends SuaveplanError<MeshAuthErrorCode>`.
- `name: "MeshAuthError"` on every instance — useful for log filters and dashboard alerts.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
- Codes are stable across versions — once registered they never disappear, only new codes are appended.
## Usage: Basic Example
```ts
import {
MESH_AUTH_CODES,
MeshAuthError,
} from "@suaveplan/network-plugin-auth";
throw new MeshAuthError(
MESH_AUTH_CODES.JOIN_REJECTED,
"auth.join-rejected",
{ context: { peerId: "alice", meshId: "m" } }
);
```
## Usage: Advanced Example
```ts
import {
MESH_AUTH_CODES,
MeshAuthError,
} from "@suaveplan/network-plugin-auth";
try {
await issuer.validate(token);
} catch (err) {
if (err instanceof MeshAuthError) {
if (err.code === MESH_AUTH_CODES.TOKEN_EXPIRED) {
return await issuer.renew(token);
}
if (err.code === MESH_AUTH_CODES.TOKEN_INVALID_SIGNATURE) {
await reportSecurityIncident(err.context);
}
}
throw err;
}
```
## API Reference
- `MESH_AUTH_CODES` — frozen `{ JOIN_REJECTED, TOKEN_EXPIRED, TOKEN_INVALID_SIGNATURE, CAPABILITY_DENIED, TRUST_NOT_ESTABLISHED }`.
- `type MeshAuthErrorCode` — union of registered code values.
- `class MeshAuthError extends SuaveplanError<MeshAuthErrorCode>` — typed throwable for every plugin failure mode.
## Implementation Notes
`TOKEN_INVALID_SIGNATURE` is treated as a security-relevant signal — callers SHOULD log it loudly because a valid client never produces it. The other four codes are recoverable: `JOIN_REJECTED` is a clean refusal, `TOKEN_EXPIRED` is fixable by renewal, `CAPABILITY_DENIED` is a permission boundary, and `TRUST_NOT_ESTABLISHED` should trigger an out-of-band trust handshake. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MESH_AUTH_CODES, MeshAuthError } from "./error-codes.js";
describe("MESH_AUTH_CODES", () => {
it("registers under NET_AUTH", () => {
for (const value of Object.values(MESH_AUTH_CODES)) {
expect(value).toMatch(/^NET_AUTH\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(MESH_AUTH_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"JOIN_REJECTED",
"TOKEN_EXPIRED",
"TOKEN_INVALID_SIGNATURE",
"CAPABILITY_DENIED",
"TRUST_NOT_ESTABLISHED",
] as const) {
expect(MESH_AUTH_CODES).toHaveProperty(key);
}
});
});
describe("MeshAuthError", () => {
it("extends SuaveplanError", () => {
const err = new MeshAuthError(
MESH_AUTH_CODES.JOIN_REJECTED,
"join-rejected"
);
expect(err.name).toBe("MeshAuthError");
expect(err.code).toBe(MESH_AUTH_CODES.JOIN_REJECTED);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new MeshAuthError(
MESH_AUTH_CODES.TOKEN_INVALID_SIGNATURE,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,23 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const MESH_AUTH_CODES = defineCodes("NET_AUTH", [
"JOIN_REJECTED",
"TOKEN_EXPIRED",
"TOKEN_INVALID_SIGNATURE",
"CAPABILITY_DENIED",
"TRUST_NOT_ESTABLISHED",
] as const);
export type MeshAuthErrorCode =
(typeof MESH_AUTH_CODES)[keyof typeof MESH_AUTH_CODES];
export class MeshAuthError extends SuaveplanError<MeshAuthErrorCode> {
public constructor(
code: MeshAuthErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "MeshAuthError";
}
}

View File

@@ -0,0 +1,32 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Errors
export {
MESH_AUTH_CODES,
MeshAuthError,
type MeshAuthErrorCode,
} from "./error-codes.js";
// Issuer
export {
type CapabilityToken,
CapabilityTokenIssuer,
type CapabilityTokenIssuerConfig,
type TokenSigner,
} from "./issuer/capability-token-issuer.js";
// Trust
export {
CrossMeshTrustValidator,
type CrossMeshTrustValidatorConfig,
type TrustDirection,
type TrustRelation,
} from "./trust/cross-mesh-trust-validator.js";
// Verifier
export {
type AuthProvider,
type Capability,
type JoinRequest,
type JoinResult,
MeshMembershipVerifier,
type MeshMembershipVerifierConfig,
} from "./verifier/mesh-membership-verifier.js";

View File

@@ -0,0 +1,79 @@
# CapabilityTokenIssuer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Issues server-signed capability tokens for mesh peers and validates incoming tokens. The token shape pins the peer's `tier`, allowed `capabilities`, and a `serverSignatureHex` covering a deterministic JSON canonicalisation. Decoupling the signer via `TokenSigner` lets consumers wire Ed25519 (production), HMAC (tests), or a hardware-backed signer without changes to the issuer surface.
## Features
- Deterministic JSON canonicalisation with sorted `capabilities`.
- Configurable `defaultTtlMs` (default 1 hour); per-call `ttlMs` overrides.
- Injectable `clock` for deterministic tests.
- `validate` throws `TOKEN_EXPIRED` then `TOKEN_INVALID_SIGNATURE` in that order so callers can pattern-match.
- `renew` re-issues without re-verifying the previous signature — callers that want pre-renewal checks must call `validate` first.
## Usage: Basic Example
```ts
import {
CapabilityTokenIssuer,
type TokenSigner,
} from "@suaveplan/network-plugin-auth";
const signer: TokenSigner = {
sign: async (bytes) => "deadbeef",
verify: async () => true,
};
const issuer = new CapabilityTokenIssuer({ signer });
const token = await issuer.issue("alice", "mesh-A", 0, ["host"]);
await issuer.validate(token);
```
## Usage: Advanced Example
```ts
import {
CapabilityTokenIssuer,
MESH_AUTH_CODES,
MeshAuthError,
} from "@suaveplan/network-plugin-auth";
let now = 1_000_000;
const issuer = new CapabilityTokenIssuer({
signer,
defaultTtlMs: 60_000,
clock: () => now,
});
const token = await issuer.issue("alice", "mesh-A", 1, ["subhost"]);
now += 70_000; // past expiry
try {
await issuer.validate(token);
} catch (err) {
if (
err instanceof MeshAuthError &&
err.code === MESH_AUTH_CODES.TOKEN_EXPIRED
) {
const renewed = await issuer.renew(token);
// renewed.expiresAt > now
}
}
```
## API Reference
- `interface CapabilityToken { peerId, meshId, tier, capabilities, issuedAt, expiresAt, serverSignatureHex }`.
- `interface TokenSigner { sign, verify }`.
- `interface CapabilityTokenIssuerConfig { signer, defaultTtlMs?, clock? }`.
- `issue(peerId, meshId, tier, capabilities, ttlMs?): Promise<CapabilityToken>`.
- `validate(token): Promise<void>` — throws `TOKEN_EXPIRED` / `TOKEN_INVALID_SIGNATURE`.
- `renew(token, ttlMs?): Promise<CapabilityToken>`.
## Implementation Notes
The canonicalisation sorts `capabilities` alphabetically before JSON-stringify so a permutation of the same set produces the same signature input. We use JSON rather than a custom binary frame because (a) the token is small, (b) consumers can debug it visually, and (c) `JSON.stringify` is fast and well-specified for our scalar/string-only field set. Renewal explicitly does NOT verify the prior signature — that would require the renewer to also be the verifier, which is not always true (e.g. a hardware HSM signs but the issuer doesn't have the verify key locally). Callers that want defense-in-depth should call `validate` before `renew`.

View File

@@ -0,0 +1,125 @@
import { MESH_AUTH_CODES, MeshAuthError } from "../error-codes.js";
import type { Capability } from "../verifier/mesh-membership-verifier.js";
/**
* Capability token issued by the mesh server.
*/
export interface CapabilityToken {
readonly peerId: string;
readonly meshId: string;
readonly tier: number;
readonly capabilities: readonly Capability[];
readonly issuedAt: number;
readonly expiresAt: number;
readonly serverSignatureHex: string;
}
/**
* Pluggable signer + verifier (Ed25519 via `@suaveplan/crypto-curve`,
* mock signer for tests, etc.).
*/
export interface TokenSigner {
sign(canonicalBytes: Uint8Array): string | Promise<string>;
verify(
canonicalBytes: Uint8Array,
signatureHex: string
): boolean | Promise<boolean>;
}
/**
* Configuration for `CapabilityTokenIssuer`.
*/
export interface CapabilityTokenIssuerConfig {
readonly signer: TokenSigner;
readonly defaultTtlMs?: number;
readonly clock?: () => number;
}
function canonicalize(
token: Omit<CapabilityToken, "serverSignatureHex">
): Uint8Array {
const json = JSON.stringify({
peerId: token.peerId,
meshId: token.meshId,
tier: token.tier,
capabilities: [...token.capabilities].sort(),
issuedAt: token.issuedAt,
expiresAt: token.expiresAt,
});
return new TextEncoder().encode(json);
}
/**
* Issues server-signed capability tokens for mesh peers and validates
* incoming tokens. The signer is injected so consumers can use Ed25519
* via `@suaveplan/crypto-curve`, a hash-based MAC for tests, etc.
*/
export class CapabilityTokenIssuer {
private readonly signer: TokenSigner;
private readonly defaultTtlMs: number;
private readonly clock: () => number;
public constructor(config: CapabilityTokenIssuerConfig) {
this.signer = config.signer;
this.defaultTtlMs = config.defaultTtlMs ?? 3_600_000;
this.clock = config.clock ?? (() => Date.now());
}
public async issue(
peerId: string,
meshId: string,
tier: number,
capabilities: readonly Capability[],
ttlMs?: number
): Promise<CapabilityToken> {
const issuedAt = this.clock();
const expiresAt = issuedAt + (ttlMs ?? this.defaultTtlMs);
const unsigned = {
peerId,
meshId,
tier,
capabilities,
issuedAt,
expiresAt,
};
const sig = await this.signer.sign(canonicalize(unsigned));
return { ...unsigned, serverSignatureHex: sig };
}
public async validate(token: CapabilityToken): Promise<void> {
if (token.expiresAt <= this.clock()) {
throw new MeshAuthError(
MESH_AUTH_CODES.TOKEN_EXPIRED,
"auth.token-expired",
{ context: { peerId: token.peerId } }
);
}
const ok = await this.signer.verify(
canonicalize({ ...token }),
token.serverSignatureHex
);
if (!ok) {
throw new MeshAuthError(
MESH_AUTH_CODES.TOKEN_INVALID_SIGNATURE,
"auth.token-bad-sig",
{ context: { peerId: token.peerId } }
);
}
}
public async renew(
token: CapabilityToken,
ttlMs?: number
): Promise<CapabilityToken> {
// Renewal does NOT re-validate signature — the server is the issuer
// and can refresh tokens that have expired. Callers that want
// signature checks before renewal should call `validate` first.
return this.issue(
token.peerId,
token.meshId,
token.tier,
token.capabilities,
ttlMs
);
}
}

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CapabilityTokenIssuer,
type TokenSigner,
} from "./capability-token-issuer.js";
function makeSigner(): TokenSigner & { signed: number; verified: number } {
let signed = 0;
let verified = 0;
return {
get signed() {
return signed;
},
get verified() {
return verified;
},
sign: async () => {
signed += 1;
return "sig";
},
verify: async (_bytes, signatureHex) => {
verified += 1;
return signatureHex === "sig";
},
};
}
describe("CapabilityTokenIssuer", () => {
it("issues a signed token", async () => {
const now = 1_000;
const issuer = new CapabilityTokenIssuer({
signer: makeSigner(),
defaultTtlMs: 5_000,
clock: () => now,
});
const token = await issuer.issue("alice", "mesh-A", 0, ["host"]);
expect(token.peerId).toBe("alice");
expect(token.tier).toBe(0);
expect(token.capabilities).toEqual(["host"]);
expect(token.issuedAt).toBe(1_000);
expect(token.expiresAt).toBe(6_000);
expect(token.serverSignatureHex).toBe("sig");
});
it("respects per-call ttlMs over the default", async () => {
const issuer = new CapabilityTokenIssuer({
signer: makeSigner(),
defaultTtlMs: 5_000,
clock: () => 0,
});
const token = await issuer.issue("alice", "m", 0, ["host"], 100);
expect(token.expiresAt).toBe(100);
});
it("validates a fresh signature", async () => {
const issuer = new CapabilityTokenIssuer({
signer: makeSigner(),
clock: () => 0,
});
const token = await issuer.issue("alice", "m", 0, ["host"]);
await issuer.validate(token);
});
it("throws TOKEN_EXPIRED when past expiry", async () => {
let now = 0;
const issuer = new CapabilityTokenIssuer({
signer: makeSigner(),
defaultTtlMs: 100,
clock: () => now,
});
const token = await issuer.issue("alice", "m", 0, ["host"]);
now = 200;
await expect(issuer.validate(token)).rejects.toThrow(
"auth.token-expired"
);
});
it("throws TOKEN_INVALID_SIGNATURE on bad signature", async () => {
const issuer = new CapabilityTokenIssuer({
signer: makeSigner(),
clock: () => 0,
});
const token = await issuer.issue("alice", "m", 0, ["host"]);
const tampered = { ...token, serverSignatureHex: "wrong" };
await expect(issuer.validate(tampered)).rejects.toThrow(
"auth.token-bad-sig"
);
});
it("renew returns a fresh token with new expiry", async () => {
let now = 0;
const issuer = new CapabilityTokenIssuer({
signer: makeSigner(),
defaultTtlMs: 100,
clock: () => now,
});
const original = await issuer.issue("alice", "m", 0, ["host"]);
now = 500;
const renewed = await issuer.renew(original);
expect(renewed.issuedAt).toBe(500);
expect(renewed.expiresAt).toBe(600);
});
it("uses Date.now-based clock when none injected", async () => {
const issuer = new CapabilityTokenIssuer({ signer: makeSigner() });
const token = await issuer.issue("alice", "m", 0, ["host"]);
expect(token.issuedAt).toBeGreaterThan(0);
});
it("canonicalises capabilities sorted alphabetically", async () => {
const sigInputs: string[] = [];
const signer: TokenSigner = {
sign: async (b) => {
sigInputs.push(new TextDecoder().decode(b));
return "sig";
},
verify: async () => true,
};
const issuer = new CapabilityTokenIssuer({ signer, clock: () => 0 });
await issuer.issue("alice", "m", 0, ["relay", "host", "audit"]);
expect(sigInputs[0]).toContain('"audit"');
// Should appear in sorted order
const idxAudit = sigInputs[0]!.indexOf("audit");
const idxHost = sigInputs[0]!.indexOf("host");
const idxRelay = sigInputs[0]!.indexOf("relay");
expect(idxAudit).toBeLessThan(idxHost);
expect(idxHost).toBeLessThan(idxRelay);
});
it("renew accepts a custom ttlMs", async () => {
const now = 0;
const issuer = new CapabilityTokenIssuer({
signer: makeSigner(),
defaultTtlMs: 100,
clock: () => now,
});
const original = await issuer.issue("alice", "m", 0, ["host"]);
const renewed = await issuer.renew(original, 9_999);
expect(renewed.expiresAt - renewed.issuedAt).toBe(9_999);
});
});

View File

@@ -0,0 +1,83 @@
# CrossMeshTrustValidator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks bidirectional or directional trust between mesh ids and rejects cross-mesh relay/backbone traffic that would cross an unknown boundary. Centralising trust state in a single validator means relay and backbone pipelines query one source of truth, eliminating the class of bugs where one path enforces trust and another path bypasses it.
## Features
- `TrustRelation` shape pins `fromMeshId`, `toMeshId`, allowed `directions` (`relay` / `backbone`), `establishedAt`, optional `expiresAt`.
- `add` / `remove` for explicit lifecycle.
- `assertTrusts(fromMeshId, toMeshId, direction)` throws `TRUST_NOT_ESTABLISHED` for unknown, expired, or direction-disallowed pairs.
- Initial set seedable via constructor `initial` array.
- Injectable `clock` for deterministic tests.
- `list()` exposed for observability dashboards.
## Usage: Basic Example
```ts
import { CrossMeshTrustValidator } from "@suaveplan/network-plugin-auth";
const trust = new CrossMeshTrustValidator({
initial: [
{
fromMeshId: "mesh-A",
toMeshId: "mesh-B",
directions: ["relay", "backbone"],
establishedAt: Date.now(),
},
],
});
trust.assertTrusts("mesh-A", "mesh-B", "relay");
```
## Usage: Advanced Example
```ts
import {
CrossMeshTrustValidator,
MESH_AUTH_CODES,
MeshAuthError,
} from "@suaveplan/network-plugin-auth";
const trust = new CrossMeshTrustValidator({
clock: () => Date.now(),
});
trust.add({
fromMeshId: "mesh-A",
toMeshId: "mesh-C",
directions: ["relay"], // backbone explicitly disallowed
establishedAt: Date.now(),
expiresAt: Date.now() + 86_400_000,
});
try {
trust.assertTrusts("mesh-A", "mesh-C", "backbone");
} catch (err) {
if (
err instanceof MeshAuthError &&
err.code === MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED
) {
// operator must establish backbone trust before this works
}
}
```
## API Reference
- `interface TrustRelation { fromMeshId, toMeshId, directions, establishedAt, expiresAt? }`.
- `type TrustDirection = "relay" | "backbone"`.
- `interface CrossMeshTrustValidatorConfig { clock?, initial? }`.
- `add(relation)`, `remove(fromMeshId, toMeshId): boolean`.
- `assertTrusts(fromMeshId, toMeshId, direction)` — throws `TRUST_NOT_ESTABLISHED` on any failure.
- `list(): readonly TrustRelation[]`.
- `clear()`.
## Implementation Notes
Trust is intentionally directional — `(A, B)` and `(B, A)` are distinct keys. Bidirectional trust requires two `add` calls. This avoids the class of bugs where one mesh trusts another but the reverse direction was assumed without being explicit. The `directions` field further restricts what kinds of cross-mesh traffic are allowed; a relay-only pairing cannot host backbone traffic until an operator explicitly upgrades the relation. `assertTrusts` checks expiry before direction so an expired relation surfaces as expired regardless of which direction was requested.

View File

@@ -0,0 +1,93 @@
import { MESH_AUTH_CODES, MeshAuthError } from "../error-codes.js";
/**
* One trust relationship in the cross-mesh trust store.
*/
export interface TrustRelation {
readonly fromMeshId: string;
readonly toMeshId: string;
readonly directions: readonly TrustDirection[];
readonly establishedAt: number;
readonly expiresAt?: number;
}
/**
* Directions in which trust flows.
*/
export type TrustDirection = "relay" | "backbone";
/**
* Configuration for `CrossMeshTrustValidator`.
*/
export interface CrossMeshTrustValidatorConfig {
readonly clock?: () => number;
readonly initial?: readonly TrustRelation[];
}
/**
* Tracks bidirectional or directional trust between mesh ids and rejects
* cross-mesh relay/backbone messages that would cross an unknown
* boundary. A common store of trust relations means relay and backbone
* pipelines consult one source of truth.
*/
export class CrossMeshTrustValidator {
private readonly relations = new Map<string, TrustRelation>();
private readonly clock: () => number;
public constructor(config: CrossMeshTrustValidatorConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
for (const r of config.initial ?? []) {
this.add(r);
}
}
public add(relation: TrustRelation): void {
const key = this.key(relation.fromMeshId, relation.toMeshId);
this.relations.set(key, relation);
}
public remove(fromMeshId: string, toMeshId: string): boolean {
return this.relations.delete(this.key(fromMeshId, toMeshId));
}
public assertTrusts(
fromMeshId: string,
toMeshId: string,
direction: TrustDirection
): void {
const rel = this.relations.get(this.key(fromMeshId, toMeshId));
if (rel === undefined) {
throw new MeshAuthError(
MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED,
"trust.not-established",
{ context: { fromMeshId, toMeshId, direction } }
);
}
if (rel.expiresAt !== undefined && rel.expiresAt <= this.clock()) {
throw new MeshAuthError(
MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED,
"trust.expired",
{ context: { fromMeshId, toMeshId, direction } }
);
}
if (!rel.directions.includes(direction)) {
throw new MeshAuthError(
MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED,
"trust.direction-not-allowed",
{ context: { fromMeshId, toMeshId, direction } }
);
}
}
public list(): readonly TrustRelation[] {
return [...this.relations.values()];
}
public clear(): void {
this.relations.clear();
}
private key(fromMeshId: string, toMeshId: string): string {
return `${fromMeshId}::${toMeshId}`;
}
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CrossMeshTrustValidator } from "./cross-mesh-trust-validator.js";
describe("CrossMeshTrustValidator", () => {
it("starts empty unless seeded", () => {
const v = new CrossMeshTrustValidator();
expect(v.list()).toEqual([]);
});
it("seeds initial relations from config", () => {
const v = new CrossMeshTrustValidator({
initial: [
{
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
},
],
});
expect(v.list()).toHaveLength(1);
});
it("asserts trust for known pairs and directions", () => {
const v = new CrossMeshTrustValidator();
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
});
expect(() => v.assertTrusts("A", "B", "relay")).not.toThrow();
});
it("rejects unknown pairs", () => {
const v = new CrossMeshTrustValidator();
expect(() => v.assertTrusts("A", "B", "relay")).toThrow(
"trust.not-established"
);
});
it("rejects expired relations", () => {
const now = 100;
const v = new CrossMeshTrustValidator({ clock: () => now });
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
expiresAt: 50,
});
expect(() => v.assertTrusts("A", "B", "relay")).toThrow(
"trust.expired"
);
});
it("rejects disallowed directions", () => {
const v = new CrossMeshTrustValidator();
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
});
expect(() => v.assertTrusts("A", "B", "backbone")).toThrow(
"trust.direction-not-allowed"
);
});
it("treats trust as directional", () => {
const v = new CrossMeshTrustValidator();
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
});
expect(() => v.assertTrusts("B", "A", "relay")).toThrow(
"trust.not-established"
);
});
it("remove deletes a relation", () => {
const v = new CrossMeshTrustValidator();
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
});
expect(v.remove("A", "B")).toBe(true);
expect(v.remove("A", "B")).toBe(false);
});
it("clear empties every relation", () => {
const v = new CrossMeshTrustValidator();
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
});
v.clear();
expect(v.list()).toEqual([]);
});
it("uses Date.now-based clock when none injected", () => {
const v = new CrossMeshTrustValidator();
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
expiresAt: Date.now() + 100_000,
});
expect(() => v.assertTrusts("A", "B", "relay")).not.toThrow();
});
it("respects no-expiry relations forever", () => {
const v = new CrossMeshTrustValidator({ clock: () => 1_000_000_000 });
v.add({
fromMeshId: "A",
toMeshId: "B",
directions: ["relay"],
establishedAt: 0,
});
expect(() => v.assertTrusts("A", "B", "relay")).not.toThrow();
});
});

View File

@@ -0,0 +1,86 @@
# MeshMembershipVerifier
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Walks a `JoinRequest` through a four-step verification: token validation via the injected `AuthProvider`, public-key match, mesh id match, capability allow-list. Returns a `JoinResult` with the assigned tier on success or throws `MeshAuthError` on the first failure. Decoupling auth via the `AuthProvider` interface keeps this plugin from depending directly on `@suaveplan/auth` while still being easy to wire up.
## Features
- Lowest-tier-wins capability mapping: `host` → 0, `subhost`/`bridge` → 1, others → 2.
- Mesh-id mismatch raises `JOIN_REJECTED`.
- Public-key mismatch raises `JOIN_REJECTED` (when the auth provider returns a public key).
- Disallowed capability raises `CAPABILITY_DENIED` with the offending name in `context`.
- Empty `claimedCapabilities` defaults to tier 2 (most restrictive) so a join with no capabilities still produces a deterministic result.
- `AuthProvider` is structural — any object with `verifyToken(token)` can plug in.
## Usage: Basic Example
```ts
import {
MeshMembershipVerifier,
type AuthProvider,
} from "@suaveplan/network-plugin-auth";
const authProvider: AuthProvider = {
verifyToken: async (t) => ({ identity: "alice" }),
};
const verifier = new MeshMembershipVerifier({
authProvider,
meshId: "mesh-A",
allowedCapabilities: ["host", "subhost", "relay"],
});
const result = await verifier.verifyJoinRequest({
peerId: "alice",
meshId: "mesh-A",
authToken: "...",
publicKeyHex: "...",
claimedCapabilities: ["host"],
});
```
## Usage: Advanced Example
```ts
import {
MESH_AUTH_CODES,
MeshAuthError,
MeshMembershipVerifier,
} from "@suaveplan/network-plugin-auth";
const verifier = new MeshMembershipVerifier({
authProvider,
meshId: "mesh-A",
allowedCapabilities: ["relay", "audit"],
});
try {
const result = await verifier.verifyJoinRequest(request);
promotePeer(result);
} catch (err) {
if (
err instanceof MeshAuthError &&
err.code === MESH_AUTH_CODES.CAPABILITY_DENIED
) {
await retryWithReducedCapabilities(request);
} else {
throw err;
}
}
```
## API Reference
- `type Capability = "host" | "subhost" | "relay" | "bridge" | "audit" | "create_session" | "join_session"`.
- `interface AuthProvider { verifyToken(token): Promise<{ identity, publicKeyHex? }> }`.
- `interface JoinRequest { peerId, meshId, authToken, publicKeyHex, claimedCapabilities }`.
- `interface JoinResult { accepted, peerId, assignedTier, capabilities, reason? }`.
- `interface MeshMembershipVerifierConfig { authProvider, meshId, allowedCapabilities }`.
- `verifyJoinRequest(request): Promise<JoinResult>`.
## Implementation Notes
The verifier intentionally treats every failure as a thrown exception rather than a `{ accepted: false, reason }` result so callers cannot accidentally proceed past a security check by ignoring a return value. The cost is one branch per call site, which is the correct shape for security-critical paths. The `publicKeyHex` mismatch check is gated on the auth provider returning a key; providers that only verify identity (no key binding) skip the check, matching the principle that we don't fabricate a verification we don't actually perform.

View File

@@ -0,0 +1,136 @@
import { MESH_AUTH_CODES, MeshAuthError } from "../error-codes.js";
/**
* Capabilities a peer can request when joining a mesh.
*/
export type Capability =
| "host"
| "subhost"
| "relay"
| "bridge"
| "audit"
| "create_session"
| "join_session";
/**
* Authentication provider — `@suaveplan/auth` can implement this, but
* the plugin doesn't import it directly so consumers can plug in any
* structurally-compatible provider.
*/
export interface AuthProvider {
verifyToken(token: string): Promise<{
readonly identity: string;
readonly publicKeyHex?: string;
}>;
}
/**
* Inbound join request.
*/
export interface JoinRequest {
readonly peerId: string;
readonly meshId: string;
readonly authToken: string;
readonly publicKeyHex: string;
readonly claimedCapabilities: readonly Capability[];
}
/**
* Outcome of a join verification.
*/
export interface JoinResult {
readonly accepted: boolean;
readonly peerId: string;
readonly assignedTier: number;
readonly capabilities: readonly Capability[];
readonly reason?: string;
}
/**
* Configuration for `MeshMembershipVerifier`.
*/
export interface MeshMembershipVerifierConfig {
readonly authProvider: AuthProvider;
readonly meshId: string;
readonly allowedCapabilities: readonly Capability[];
}
const TIER_FOR_CAPABILITY: Record<Capability, number> = {
host: 0,
subhost: 1,
bridge: 1,
relay: 2,
audit: 2,
create_session: 2,
join_session: 2,
};
/**
* Walks a `JoinRequest` through the four-step verification:
*
* 1. Token validates against `AuthProvider`.
* 2. Provider's `publicKeyHex` (when present) matches the request.
* 3. Mesh id matches the verifier's configured mesh.
* 4. Every claimed capability is in the verifier's allow-list.
*
* The assigned tier is the lowest tier number across the granted
* capabilities (lower number = higher rank).
*/
export class MeshMembershipVerifier {
private readonly authProvider: AuthProvider;
private readonly meshId: string;
private readonly allowedCapabilities: ReadonlySet<Capability>;
public constructor(config: MeshMembershipVerifierConfig) {
this.authProvider = config.authProvider;
this.meshId = config.meshId;
this.allowedCapabilities = new Set(config.allowedCapabilities);
}
public async verifyJoinRequest(request: JoinRequest): Promise<JoinResult> {
if (request.meshId !== this.meshId) {
throw new MeshAuthError(
MESH_AUTH_CODES.JOIN_REJECTED,
"auth.mesh-mismatch",
{ context: { expected: this.meshId, got: request.meshId } }
);
}
const claim = await this.authProvider.verifyToken(request.authToken);
if (
claim.publicKeyHex !== undefined &&
claim.publicKeyHex !== request.publicKeyHex
) {
throw new MeshAuthError(
MESH_AUTH_CODES.JOIN_REJECTED,
"auth.public-key-mismatch",
{ context: { peerId: request.peerId } }
);
}
const granted: Capability[] = [];
for (const cap of request.claimedCapabilities) {
if (!this.allowedCapabilities.has(cap)) {
throw new MeshAuthError(
MESH_AUTH_CODES.CAPABILITY_DENIED,
"auth.capability-denied",
{
context: {
peerId: request.peerId,
capability: cap,
},
}
);
}
granted.push(cap);
}
const tier =
granted.length === 0
? 2
: Math.min(...granted.map((c) => TIER_FOR_CAPABILITY[c]));
return {
accepted: true,
peerId: request.peerId,
assignedTier: tier,
capabilities: granted,
};
}
}

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type AuthProvider,
type JoinRequest,
MeshMembershipVerifier,
} from "./mesh-membership-verifier.js";
const okProvider: AuthProvider = {
verifyToken: async () => ({ identity: "alice" }),
};
const keyBoundProvider: AuthProvider = {
verifyToken: async () => ({
identity: "alice",
publicKeyHex: "deadbeef",
}),
};
const baseRequest: JoinRequest = {
peerId: "alice",
meshId: "mesh-A",
authToken: "tok",
publicKeyHex: "deadbeef",
claimedCapabilities: ["host"],
};
describe("MeshMembershipVerifier", () => {
it("accepts a valid join request and assigns tier 0 for host", async () => {
const v = new MeshMembershipVerifier({
authProvider: okProvider,
meshId: "mesh-A",
allowedCapabilities: ["host", "subhost", "relay"],
});
const result = await v.verifyJoinRequest(baseRequest);
expect(result.accepted).toBe(true);
expect(result.assignedTier).toBe(0);
expect(result.capabilities).toEqual(["host"]);
});
it("rejects mesh-id mismatch", async () => {
const v = new MeshMembershipVerifier({
authProvider: okProvider,
meshId: "mesh-A",
allowedCapabilities: ["host"],
});
await expect(
v.verifyJoinRequest({ ...baseRequest, meshId: "mesh-Z" })
).rejects.toThrow("auth.mesh-mismatch");
});
it("rejects public-key mismatch when provider returns a key", async () => {
const v = new MeshMembershipVerifier({
authProvider: keyBoundProvider,
meshId: "mesh-A",
allowedCapabilities: ["host"],
});
await expect(
v.verifyJoinRequest({ ...baseRequest, publicKeyHex: "cafebabe" })
).rejects.toThrow("auth.public-key-mismatch");
});
it("accepts when provider returns no key (no key binding to enforce)", async () => {
const v = new MeshMembershipVerifier({
authProvider: okProvider,
meshId: "mesh-A",
allowedCapabilities: ["host"],
});
const result = await v.verifyJoinRequest({
...baseRequest,
publicKeyHex: "anything",
});
expect(result.accepted).toBe(true);
});
it("rejects disallowed capabilities", async () => {
const v = new MeshMembershipVerifier({
authProvider: okProvider,
meshId: "mesh-A",
allowedCapabilities: ["relay"], // host disallowed
});
await expect(
v.verifyJoinRequest({
...baseRequest,
claimedCapabilities: ["host"],
})
).rejects.toThrow("auth.capability-denied");
});
it("assigns tier 2 for empty capability requests", async () => {
const v = new MeshMembershipVerifier({
authProvider: okProvider,
meshId: "mesh-A",
allowedCapabilities: ["host"],
});
const result = await v.verifyJoinRequest({
...baseRequest,
claimedCapabilities: [],
});
expect(result.assignedTier).toBe(2);
expect(result.capabilities).toEqual([]);
});
it("uses lowest tier across granted capabilities", async () => {
const v = new MeshMembershipVerifier({
authProvider: okProvider,
meshId: "mesh-A",
allowedCapabilities: ["host", "subhost", "relay"],
});
const result = await v.verifyJoinRequest({
...baseRequest,
claimedCapabilities: ["relay", "subhost"],
});
expect(result.assignedTier).toBe(1); // subhost wins over relay
});
it("matches public key when provider returns a matching key", async () => {
const v = new MeshMembershipVerifier({
authProvider: keyBoundProvider,
meshId: "mesh-A",
allowedCapabilities: ["host"],
});
const result = await v.verifyJoinRequest(baseRequest);
expect(result.accepted).toBe(true);
});
});

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-auth dep-graph", () => {
it("loads index.js (acyclic static imports)", async () => {
const mod = await import("../src/index.js");
expect(mod.MeshMembershipVerifier).toBeDefined();
expect(mod.CapabilityTokenIssuer).toBeDefined();
expect(mod.CrossMeshTrustValidator).toBeDefined();
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type AuthProvider,
CapabilityTokenIssuer,
CrossMeshTrustValidator,
MeshMembershipVerifier,
type TokenSigner,
} from "../../src/index.js";
describe("auth end-to-end", () => {
it("verifies join, issues token, validates trust across meshes", async () => {
const authProvider: AuthProvider = {
verifyToken: async () => ({
identity: "alice",
publicKeyHex: "deadbeef",
}),
};
const verifier = new MeshMembershipVerifier({
authProvider,
meshId: "mesh-A",
allowedCapabilities: ["host", "relay", "bridge"],
});
const signer: TokenSigner = {
sign: async () => "sig",
verify: async () => true,
};
const issuer = new CapabilityTokenIssuer({ signer, clock: () => 0 });
const result = await verifier.verifyJoinRequest({
peerId: "alice",
meshId: "mesh-A",
authToken: "tok",
publicKeyHex: "deadbeef",
claimedCapabilities: ["host", "bridge"],
});
expect(result.assignedTier).toBe(0);
const token = await issuer.issue(
result.peerId,
"mesh-A",
result.assignedTier,
result.capabilities
);
await issuer.validate(token);
const trust = new CrossMeshTrustValidator();
trust.add({
fromMeshId: "mesh-A",
toMeshId: "mesh-B",
directions: ["relay", "backbone"],
establishedAt: 0,
});
trust.assertTrusts("mesh-A", "mesh-B", "backbone");
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-auth",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});