diff --git a/Directory.Build.props b/Directory.Build.props
index db26d4f3..10c8d749 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,8 @@
MIT
© Meta Vens and contributors
true
- 1.0.33
+ 1.0.35
+ $(CultLibPackageVersion)
true
diff --git a/package-lock.json b/package-lock.json
index 74a753a5..b105fb5c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -846,11 +846,11 @@
}
},
"packages/cultmesh-browser": {
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
- "cultnet-ts": "^0.1.1"
+ "cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
@@ -866,7 +866,7 @@
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"cultcache-ts": "^0.13.5",
- "cultnet-ts": "^0.1.1"
+ "cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
@@ -878,7 +878,7 @@
}
},
"packages/cultnet-ts": {
- "version": "0.1.1",
+ "version": "0.2.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md
index 4f5c7e8b..59a44016 100644
--- a/packages/cultmesh-browser/README.md
+++ b/packages/cultmesh-browser/README.md
@@ -43,10 +43,19 @@ selection, reconnect, or resubscription. This is deliberately distinct from an
Eve surface's `providerId`, which identifies the UI provider inside the Verse.
The physical socket is a replaceable route, not either stable identity.
+Odin returns exact authority-route bindings. The browser selects only routes
+bound to the requested authority and `cultmesh.documents.v1`, then performs a
+session-open handshake. It does not report `connected` until the peer proves
+the exact Verse, authority runtime, protocol, and route generation. Operation
+responses must also name that authority. Legacy catalogs are accepted only
+when one authority runtime makes the endpoint binding unambiguous.
+
The browser handshake carries authentication through the host application's
ordinary secure cookie or equivalent upgrade credential. The C# host adapter
rejects endpoints without an authorization predicate unless anonymous local
development is explicitly enabled.
Run `scripts/verify-eve-browser-network.mjs --eve-root ` for the
-real Chromium + C# headless chronology.
+real Chromium + C# headless chronology. The witness publishes a lower-priority
+decoy endpoint owned by another runtime and proves that neither client connects
+to it before and after route rotation.
diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json
index 41833562..c49062b0 100644
--- a/packages/cultmesh-browser/package.json
+++ b/packages/cultmesh-browser/package.json
@@ -1,6 +1,6 @@
{
"name": "cultmesh-browser",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
"type": "module",
"main": "dist/index.js",
@@ -41,7 +41,7 @@
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
- "cultnet-ts": "^0.1.1"
+ "cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts
index efeb4515..4dddfa7d 100644
--- a/packages/cultmesh-browser/src/index.ts
+++ b/packages/cultmesh-browser/src/index.ts
@@ -12,6 +12,8 @@ import {
type CultNetSnapshotResponseRawMessage,
type CultMeshVerseCatalogRequestMessage,
type CultMeshVerseCatalogResponseMessage,
+ type CultMeshSessionAcceptedMessage,
+ type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts";
export interface CultMeshBrowserIdentity {
@@ -21,11 +23,40 @@ export interface CultMeshBrowserIdentity {
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
endpoint: string;
- generation?: string;
+ protocolId?: string;
+ protocolIds?: readonly string[];
+ priority?: number;
+ generation: string;
+ certificate?: CultMeshBrowserRouteCertificate;
+}
+
+export interface CultMeshBrowserP256PublicKey {
+ keyId: string;
+ x: string;
+ y: string;
+}
+
+export interface CultMeshBrowserRouteCertificate {
+ providerKey: CultMeshBrowserP256PublicKey;
+ odinKeyId: string;
+ issuedAtUnixMilliseconds: number;
+ expiresAtUnixMilliseconds: number;
+ signature: string;
+}
+
+export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development";
+
+export interface CultMeshBrowserAuthorityTrustPolicy {
+ mode: CultMeshBrowserAuthorityTrustMode;
+ odinRoots?: readonly CultMeshBrowserP256PublicKey[];
+ now?: () => number;
}
export interface CultMeshBrowserRendezvous {
- resolve(identity: CultMeshBrowserIdentity): Promise;
+ resolve(
+ identity: CultMeshBrowserIdentity,
+ trust?: CultMeshBrowserAuthorityTrustPolicy,
+ ): Promise;
}
export interface CultMeshBrowserOdinRendezvousOptions {
@@ -73,13 +104,16 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
if (this.#options.maxFrameBytes <= 0) throw new Error("Odin rendezvous maxFrameBytes must be positive.");
}
- async resolve(identity: CultMeshBrowserIdentity): Promise {
+ async resolve(
+ identity: CultMeshBrowserIdentity,
+ trust: CultMeshBrowserAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] },
+ ): Promise {
requireText(identity.verseId, "verseId");
requireText(identity.authorityRuntimeId, "authorityRuntimeId");
const failures: Error[] = [];
for (const endpoint of this.#options.endpoints) {
try {
- return await this.resolveFrom(endpoint, identity);
+ return await this.resolveFrom(endpoint, identity, trust);
} catch (error) {
failures.push(error instanceof Error ? error : new Error(String(error)));
}
@@ -90,7 +124,11 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
);
}
- private resolveFrom(endpoint: string, identity: CultMeshBrowserIdentity): Promise {
+ private resolveFrom(
+ endpoint: string,
+ identity: CultMeshBrowserIdentity,
+ trust: CultMeshBrowserAuthorityTrustPolicy,
+ ): Promise {
const socket = this.#options.socketFactory(endpoint);
socket.binaryType = "arraybuffer";
const messageId = this.#options.createId();
@@ -141,7 +179,9 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
return;
}
if (message.schemaVersion !== "cultmesh.verse_catalog_response.v0" || message.messageId !== messageId) return;
- finish(undefined, selectOdinRoute(message, identity));
+ void selectOdinRoute(message, identity, trust)
+ .then(route => finish(undefined, route))
+ .catch(error => finish(error instanceof Error ? error : new Error(String(error))));
}).catch(error => finish(error instanceof Error ? error : new Error(String(error))));
};
socket.onerror = () => finish(new Error(`Odin WebSocket could not open '${endpoint}'.`));
@@ -159,6 +199,8 @@ export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity {
requestTimeoutMs?: number;
maxFrameBytes?: number;
createId?: () => string;
+ /** Consumer-owned authority roots. Remote sessions fail closed when omitted. */
+ trust?: CultMeshBrowserAuthorityTrustPolicy;
}
export interface CultMeshRawDocumentLeaseOptions {
@@ -173,6 +215,7 @@ export interface CultMeshOperationOptions {
payloadSchema: string;
payload: unknown;
idempotencyKey?: string;
+ /** @deprecated The connected authority is the operation target. A different value is rejected. */
targetRuntimeId?: string;
}
@@ -211,6 +254,7 @@ interface PendingSnapshot {
}
interface PendingOperation {
+ request: CultNetOperationRequestMessage;
resolve: (response: CultNetOperationResponseMessage) => void;
reject: (error: Error) => void;
timer: ReturnType;
@@ -293,6 +337,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024,
createId: options.createId ?? (() => crypto.randomUUID()),
socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)),
+ trust: options.trust ?? { mode: "authenticated-remote", odinRoots: [] },
};
}
@@ -338,6 +383,11 @@ export class CultMeshBrowserClient implements AsyncDisposable {
requireText(options.serviceId, "serviceId");
requireText(options.operation, "operation");
requireText(options.payloadSchema, "payloadSchema");
+ if (options.targetRuntimeId && options.targetRuntimeId !== this.identity.authorityRuntimeId) {
+ throw new Error(
+ `CultMesh operation target '${options.targetRuntimeId}' does not match connected authority '${this.identity.authorityRuntimeId}'.`,
+ );
+ }
await this.ensureConnected();
const messageId = options.idempotencyKey ?? this.#options.createId();
requireText(messageId, "operation idempotencyKey");
@@ -353,22 +403,19 @@ export class CultMeshBrowserClient implements AsyncDisposable {
payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encode(options.payload)),
sourceRuntimeId: this.runtimeId,
- ...(options.targetRuntimeId ? { targetRuntimeId: options.targetRuntimeId } : {}),
+ targetRuntimeId: this.identity.authorityRuntimeId,
};
const response = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (!this.#pendingOperations.delete(messageId)) return;
reject(new Error(`CultMesh operation '${messageId}' timed out.`));
}, this.#options.requestTimeoutMs);
- this.#pendingOperations.set(messageId, { resolve, reject, timer });
+ this.#pendingOperations.set(messageId, { request, resolve, reject, timer });
});
try {
this.send(request);
} catch (error) {
- const pending = this.#pendingOperations.get(messageId);
- if (pending) clearTimeout(pending.timer);
- this.#pendingOperations.delete(messageId);
- throw error;
+ this.scheduleReconnect();
}
return response;
}
@@ -466,6 +513,11 @@ export class CultMeshBrowserClient implements AsyncDisposable {
private connectSocket(): Promise {
this.throwIfDisposed();
+ if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve();
+ if (this.#reconnectTimer) {
+ clearTimeout(this.#reconnectTimer);
+ this.#reconnectTimer = undefined;
+ }
if (this.#connectPromise) return this.#connectPromise;
const generation = ++this.#socketGeneration;
this.setState(this.#socket ? "reconnecting" : "connecting");
@@ -476,11 +528,12 @@ export class CultMeshBrowserClient implements AsyncDisposable {
}
private async openSocket(generation: number): Promise {
- const route = await this.#options.rendezvous.resolve(this.identity);
+ const route = await this.#options.rendezvous.resolve(this.identity, this.#options.trust);
if (route.verseId !== this.identity.verseId ||
route.authorityRuntimeId !== this.identity.authorityRuntimeId) {
throw new Error("CultMesh rendezvous returned a route for the wrong stable identity.");
}
+ await verifyAuthorityRoute(route, this.#options.trust);
const endpoint = new URL(route.endpoint);
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
throw new Error(`CultMesh browser route must use ws:// or wss://, got '${route.endpoint}'.`);
@@ -489,13 +542,74 @@ export class CultMeshBrowserClient implements AsyncDisposable {
socket.binaryType = "arraybuffer";
try {
await new Promise((resolve, reject) => {
+ let opened = false;
+ let settled = false;
+ const handshakeId = this.#options.createId();
+ const clientNonce = randomNonce();
+ const sessionRequest: CultMeshSessionOpenMessage = {
+ schemaVersion: "cultmesh.session_open.v2",
+ messageId: handshakeId,
+ sourceRuntimeId: this.runtimeId,
+ verseId: this.identity.verseId,
+ authorityRuntimeId: this.identity.authorityRuntimeId,
+ protocolId: route.protocolId ?? "cultmesh.documents.v1",
+ routeGeneration: route.generation,
+ clientNonce,
+ };
+ const finish = (error?: Error) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (error) reject(error);
+ else resolve();
+ };
const timer = setTimeout(
- () => reject(new Error(`CultMesh WebSocket open timed out for '${route.endpoint}'.`)),
+ () => finish(new Error(
+ opened
+ ? `CultMesh authority handshake timed out for '${route.endpoint}'.`
+ : `CultMesh WebSocket open timed out for '${route.endpoint}'.`,
+ )),
this.#options.connectTimeoutMs,
);
- socket.onopen = () => { clearTimeout(timer); resolve(); };
- socket.onerror = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`)); };
- socket.onclose = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket closed while opening '${route.endpoint}'.`)); };
+ socket.onopen = () => {
+ opened = true;
+ try {
+ sendSocketMessage(socket, {
+ schemaVersion: "cultnet.hello.v0",
+ runtimeId: this.runtimeId,
+ runtimeKind: "browser",
+ supportedMessageVersions: [
+ "cultmesh.session_open.v2",
+ "cultmesh.session_accepted.v2",
+ "cultnet.database_subscribe.v0",
+ "cultnet.database_unsubscribe.v0",
+ "cultnet.database_change_raw.v0",
+ "cultnet.operation_request.v0",
+ "cultnet.operation_response.v0",
+ ],
+ }, this.#options.maxFrameBytes);
+ sendSocketMessage(socket, sessionRequest, this.#options.maxFrameBytes);
+ } catch (error) {
+ finish(error instanceof Error ? error : new Error(String(error)));
+ }
+ };
+ socket.onmessage = event => {
+ void decodeSocketMessage(event, this.#options.maxFrameBytes).then(message => {
+ if (message.schemaVersion === "cultnet.error.v0") {
+ finish(new Error(message.error));
+ return;
+ }
+ if (message.schemaVersion !== "cultmesh.session_accepted.v2" || message.messageId !== handshakeId) return;
+ void validateSessionAcceptance(
+ message,
+ sessionRequest,
+ route,
+ this.#options.trust,
+ ).then(finish).catch(error => finish(error instanceof Error ? error : new Error(String(error))));
+ }).catch(error => finish(error instanceof Error ? error : new Error(String(error))));
+ };
+ socket.onerror = () => finish(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`));
+ socket.onclose = () => finish(new Error(`CultMesh WebSocket closed before proving authority at '${route.endpoint}'.`));
});
} catch (error) {
socket.close(1000, "CultMesh route open failed");
@@ -511,19 +625,8 @@ export class CultMeshBrowserClient implements AsyncDisposable {
socket.onclose = () => this.handleClosed(generation);
this.setState("connected");
try {
- this.send({
- schemaVersion: "cultnet.hello.v0",
- runtimeId: this.runtimeId,
- runtimeKind: "browser",
- supportedMessageVersions: [
- "cultnet.database_subscribe.v0",
- "cultnet.database_unsubscribe.v0",
- "cultnet.database_change_raw.v0",
- "cultnet.operation_request.v0",
- "cultnet.operation_response.v0",
- ],
- });
await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease)));
+ for (const pending of this.#pendingOperations.values()) this.send(pending.request);
} catch (error) {
if (this.#socket === socket) this.#socket = undefined;
socket.onclose = null;
@@ -540,12 +643,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
clearTimeout(pending.timer);
pending.reject(error);
}
- for (const pending of this.#pendingOperations.values()) {
- clearTimeout(pending.timer);
- pending.reject(error);
- }
this.#pendingSnapshots.clear();
- this.#pendingOperations.clear();
this.setState("reconnecting");
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = undefined;
@@ -576,6 +674,15 @@ export class CultMeshBrowserClient implements AsyncDisposable {
case "cultnet.operation_response.v0": {
const pending = this.#pendingOperations.get(message.messageId);
if (!pending) return;
+ if (message.sourceRuntimeId !== this.identity.authorityRuntimeId) {
+ this.#pendingOperations.delete(message.messageId);
+ clearTimeout(pending.timer);
+ pending.reject(new Error(
+ `CultMesh operation response came from '${message.sourceRuntimeId ?? "unknown"}', expected '${this.identity.authorityRuntimeId}'.`,
+ ));
+ this.#socket?.close(1008, "CultMesh authority identity changed");
+ return;
+ }
this.#pendingOperations.delete(message.messageId);
clearTimeout(pending.timer);
if (message.payloadSchema === cultNetOperationFailureSchema) {
@@ -653,6 +760,15 @@ export class CultMeshBrowserClient implements AsyncDisposable {
for (const watcher of this.#stateWatchers) watcher(state);
}
+ private scheduleReconnect(): void {
+ if (this.#disposed || this.#reconnectTimer) return;
+ this.setState("reconnecting");
+ this.#reconnectTimer = setTimeout(() => {
+ this.#reconnectTimer = undefined;
+ void this.connectSocket().catch(() => this.scheduleReconnect());
+ }, this.#options.reconnectDelayMs);
+ }
+
private throwIfDisposed(): void {
if (this.#disposed) throw new Error("CultMesh browser client is disposed.");
}
@@ -729,35 +845,230 @@ async function decodeSocketMessage(event: MessageEvent, maxFrameBytes: number):
return parseCultNetMessage(decode(payload));
}
-function selectOdinRoute(
+async function selectOdinRoute(
response: CultMeshVerseCatalogResponseMessage,
identity: CultMeshBrowserIdentity,
-): CultMeshBrowserRoute {
- const matchingVerses = response.verses.filter(verse =>
- verse.verseId === identity.verseId &&
- verse.authorityRuntimeIds.includes(identity.authorityRuntimeId));
+ trust: CultMeshBrowserAuthorityTrustPolicy,
+): Promise {
+ const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId);
if (matchingVerses.length !== 1) {
throw new Error(
`Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`,
);
}
const verse = matchingVerses[0];
- const endpoints = verse.discoveryEndpoints.filter(endpoint => {
+ if (verse.authorityRoutes == null && verse.authorityRuntimeIds.length !== 1) {
+ throw new Error(
+ `Odin returned ambiguous legacy routes for Verse '${identity.verseId}'. Explicit authority route bindings are required.`,
+ );
+ }
+ if (verse.authorityRoutes == null && verse.authorityRuntimeIds[0] !== identity.authorityRuntimeId) {
+ throw new Error(
+ `Odin advertised no route for authority runtime '${identity.authorityRuntimeId}'.`,
+ );
+ }
+ const boundRoutes = verse.authorityRoutes ?? verse.discoveryEndpoints.map(endpoint => ({
+ authorityRuntimeId: verse.authorityRuntimeIds[0],
+ endpoint,
+ protocolIds: ["cultmesh.documents.v1"],
+ priority: 0,
+ generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoint].join(":"),
+ certificate: undefined,
+ }));
+ const routes = boundRoutes.filter(route =>
+ route.authorityRuntimeId === identity.authorityRuntimeId &&
+ route.protocolIds.includes("cultmesh.documents.v1") &&
+ (() => {
try {
- requireWebSocketEndpoint(endpoint, "Odin provider route");
+ requireWebSocketEndpoint(route.endpoint, "Odin provider route");
return true;
} catch {
return false;
}
- });
- if (endpoints.length === 0) {
+ })()).sort((left, right) => left.priority - right.priority || left.endpoint.localeCompare(right.endpoint));
+ if (routes.length === 0) {
throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`);
}
- return {
- ...identity,
- endpoint: endpoints[0],
- generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoints[0]].join(":"),
- };
+ const failures: Error[] = [];
+ for (const route of routes) {
+ const candidate: CultMeshBrowserRoute = {
+ ...identity,
+ endpoint: route.endpoint,
+ protocolId: "cultmesh.documents.v1",
+ protocolIds: route.protocolIds,
+ priority: route.priority,
+ generation: route.generation,
+ ...(route.certificate ? { certificate: {
+ providerKey: {
+ keyId: route.certificate.providerKeyId,
+ x: route.certificate.providerPublicKeyX,
+ y: route.certificate.providerPublicKeyY,
+ },
+ odinKeyId: route.certificate.odinKeyId,
+ issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
+ expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
+ signature: route.certificate.signature,
+ } } : {}),
+ };
+ try {
+ await verifyAuthorityRoute(candidate, trust);
+ return candidate;
+ } catch (error) {
+ failures.push(error instanceof Error ? error : new Error(String(error)));
+ }
+ }
+ throw new AggregateError(
+ failures,
+ `Odin advertised no trusted browser route for authority runtime '${identity.authorityRuntimeId}'.`,
+ );
+}
+
+async function validateSessionAcceptance(
+ message: CultMeshSessionAcceptedMessage,
+ request: CultMeshSessionOpenMessage,
+ route: CultMeshBrowserRoute,
+ trust: CultMeshBrowserAuthorityTrustPolicy,
+): Promise {
+ if (!message.accepted) return new Error(message.error ?? "CultMesh authority rejected the session.");
+ const protocolId = route.protocolId ?? "cultmesh.documents.v1";
+ if (message.verseId !== request.verseId ||
+ message.authorityRuntimeId !== request.authorityRuntimeId ||
+ message.protocolId !== protocolId ||
+ message.routeGeneration !== route.generation ||
+ message.clientNonce !== request.clientNonce) {
+ return new Error(
+ `CultMesh route proved '${message.verseId}/${message.authorityRuntimeId}/${message.protocolId}/${message.routeGeneration}', ` +
+ `expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
+ );
+ }
+ if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined;
+ const providerKey = route.certificate?.providerKey;
+ if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) {
+ return new Error("CultMesh authority did not prove possession of the Odin-certified provider key.");
+ }
+ if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) {
+ return new Error("CultMesh provider session proof is invalid.");
+ }
+ return undefined;
+}
+
+async function verifyAuthorityRoute(
+ route: CultMeshBrowserRoute,
+ trust: CultMeshBrowserAuthorityTrustPolicy,
+): Promise {
+ const certificate = route.certificate;
+ if (!certificate) {
+ if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return;
+ throw new Error("Remote CultMesh routes require an Odin-signed authority certificate.");
+ }
+ const endpoint = new URL(route.endpoint);
+ if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) {
+ throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection.");
+ }
+ const now = trust.now?.() ?? Date.now();
+ if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {
+ throw new Error("The Odin route certificate is not currently valid.");
+ }
+ const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId);
+ if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);
+ if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) {
+ throw new Error("The Odin route certificate signature is invalid.");
+ }
+}
+
+function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
+ const certificate = route.certificate!;
+ return canonicalFields(
+ "gamecult.cultmesh.route-certificate.v1",
+ route.verseId,
+ route.authorityRuntimeId,
+ route.endpoint,
+ [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"),
+ String(route.priority ?? 0),
+ route.generation,
+ certificate.providerKey.keyId,
+ certificate.providerKey.x,
+ certificate.providerKey.y,
+ certificate.odinKeyId,
+ String(certificate.issuedAtUnixMilliseconds),
+ String(certificate.expiresAtUnixMilliseconds),
+ );
+}
+
+function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
+ return canonicalFields(
+ "gamecult.cultmesh.session-proof.v1",
+ request.clientNonce,
+ request.messageId,
+ request.sourceRuntimeId,
+ request.verseId,
+ request.authorityRuntimeId,
+ request.protocolId,
+ endpoint,
+ request.routeGeneration,
+ );
+}
+
+function canonicalFields(...values: string[]): Uint8Array {
+ const encoder = new TextEncoder();
+ const encoded = values.map(value => encoder.encode(value));
+ const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0);
+ const result = new Uint8Array(total);
+ const view = new DataView(result.buffer);
+ let offset = 0;
+ for (const value of encoded) {
+ view.setUint32(offset, value.byteLength, false);
+ offset += 4;
+ result.set(value, offset);
+ offset += value.byteLength;
+ }
+ return result;
+}
+
+async function verifyP256(
+ key: CultMeshBrowserP256PublicKey,
+ payload: Uint8Array,
+ signatureBase64: string,
+): Promise {
+ try {
+ const x = base64ToBytes(key.x);
+ const y = base64ToBytes(key.y);
+ const signature = base64ToBytes(signatureBase64);
+ if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false;
+ const raw = new Uint8Array(65);
+ raw[0] = 4;
+ raw.set(x, 1);
+ raw.set(y, 33);
+ const publicKey = await crypto.subtle.importKey(
+ "raw",
+ raw.slice().buffer as ArrayBuffer,
+ { name: "ECDSA", namedCurve: "P-256" },
+ false,
+ ["verify"],
+ );
+ return await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ publicKey,
+ signature.slice().buffer as ArrayBuffer,
+ payload.slice().buffer as ArrayBuffer,
+ );
+ } catch {
+ return false;
+ }
+}
+
+function randomNonce(): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
+ return bytesToBase64(bytes);
+}
+
+function isLoopbackEndpoint(value: string): boolean {
+ try {
+ const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
+ } catch {
+ return false;
+ }
}
function requireText(value: string, field: string): void {
diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts
index 624bdf17..6c169d93 100644
--- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts
+++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts
@@ -9,6 +9,8 @@ import {
type CultNetMessage,
type CultNetOperationRequestMessage,
type CultNetRawDocumentRecord,
+ type CultMeshVerseCatalogResponseMessage,
+ type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts";
import {
@@ -18,9 +20,12 @@ import {
decodeCultNetOperationPayload,
decodeCultNetPayload,
type CultMeshBrowserRoute,
+ type CultMeshBrowserP256PublicKey,
type CultMeshBrowserSocket,
} from "../src/index.js";
+const localTrust = { mode: "local-development" as const };
+
test("Odin rendezvous resolves stable authority-runtime identity and observes route rotation", async () => {
const odin = new FakeOdin("ws://127.0.0.1:4050/mesh");
let id = 0;
@@ -34,20 +39,87 @@ test("Odin rendezvous resolves stable authority-runtime identity and observes ro
assert.equal((await rendezvous.resolve({
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
- })).endpoint, "ws://127.0.0.1:4050/mesh");
+ }, localTrust)).endpoint, "ws://127.0.0.1:4050/mesh");
odin.providerEndpoint = "ws://127.0.0.1:4060/mesh";
assert.equal((await rendezvous.resolve({
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
- })).endpoint, "ws://127.0.0.1:4060/mesh");
+ }, localTrust)).endpoint, "ws://127.0.0.1:4060/mesh");
await assert.rejects(
- rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }),
+ rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }, localTrust),
/could not resolve/,
);
});
+test("Odin rendezvous never selects another authority's faster endpoint", async () => {
+ const odin = new FakeOdin("ws://127.0.0.1:4050/mesh");
+ odin.decoyEndpoint = "ws://127.0.0.1:4049/decoy";
+ const rendezvous = new CultMeshBrowserOdinRendezvous({
+ endpoints: ["ws://127.0.0.1:4040/odin"],
+ runtimeId: "browser-route-binding-test",
+ socketFactory: () => odin.open(),
+ });
+
+ const route = await rendezvous.resolve({
+ verseId: "sample.counter",
+ authorityRuntimeId: "sample.counter-daemon",
+ }, localTrust);
+ assert.equal(route.endpoint, "ws://127.0.0.1:4050/mesh");
+});
+
+test("Odin rendezvous accepts one-authority legacy routes and rejects ambiguous legacy catalogs", async () => {
+ const odin = new FakeOdin("ws://127.0.0.1:4070/mesh");
+ odin.omitAuthorityRoutes = true;
+ const rendezvous = new CultMeshBrowserOdinRendezvous({
+ endpoints: ["ws://127.0.0.1:4040/odin"],
+ runtimeId: "browser-legacy-route-test",
+ socketFactory: () => odin.open(),
+ });
+
+ const route = await rendezvous.resolve({
+ verseId: "sample.counter",
+ authorityRuntimeId: "sample.counter-daemon",
+ }, localTrust);
+ assert.equal(route.endpoint, "ws://127.0.0.1:4070/mesh");
+
+ odin.decoyEndpoint = "ws://127.0.0.1:4069/decoy";
+ await assert.rejects(
+ rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon" }, localTrust),
+ (error: unknown) =>
+ error instanceof AggregateError &&
+ error.errors.some(inner => inner instanceof Error && /ambiguous legacy routes/.test(inner.message)),
+ );
+});
+
+test("Odin rendezvous rejects a poisoned route without vetoing a certified fallback", async () => {
+ const signed = await createSignedRoute("wss://provider.example/mesh");
+ const odin = new FakeOdin(signed.route.endpoint);
+ odin.authorityRoutesOverride = [
+ {
+ authorityRuntimeId: signed.route.authorityRuntimeId,
+ endpoint: "wss://poison.example/mesh",
+ protocolIds: ["cultmesh.documents.v1"],
+ priority: 0,
+ generation: "poisoned",
+ },
+ routeForCatalog(signed.route),
+ ];
+ const rendezvous = new CultMeshBrowserOdinRendezvous({
+ endpoints: ["ws://127.0.0.1:4040/odin"],
+ runtimeId: "browser-certified-fallback-test",
+ socketFactory: () => odin.open(),
+ });
+
+ const selected = await rendezvous.resolve(
+ { verseId: signed.route.verseId, authorityRuntimeId: signed.route.authorityRuntimeId },
+ { mode: "authenticated-remote", odinRoots: [signed.odinPublic] },
+ );
+
+ assert.equal(selected.endpoint, signed.route.endpoint);
+});
+
test("browser client leases provider state, invokes an operation, and follows route replacement", async () => {
const first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4);
const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9);
@@ -57,6 +129,7 @@ test("browser client leases provider state, invokes an operation, and follows ro
verseId: route.verseId,
authorityRuntimeId: route.authorityRuntimeId,
runtimeId: "browser-test",
+ trust: localTrust,
rendezvous: { resolve: async () => route },
createId: () => `id-${++id}`,
socketFactory: endpoint => endpoint === first.route.endpoint ? first.open() : second.open(),
@@ -103,6 +176,7 @@ test("browser client rejects wrong rendezvous identity and has no document write
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
runtimeId: "browser-test",
+ trust: localTrust,
rendezvous: {
resolve: async () => ({
...provider.route,
@@ -117,18 +191,85 @@ test("browser client rejects wrong rendezvous identity and has no document write
assert.equal("setDocument" in CultMeshBrowserClient.prototype, false);
});
+test("browser client rejects a route whose peer proves the wrong authority", async () => {
+ const provider = new FakeProvider("ws://127.0.0.1:4251/mesh", 1, false, "intruder-runtime");
+ await assert.rejects(
+ CultMeshBrowserClient.connect({
+ verseId: "sample.counter",
+ authorityRuntimeId: "sample.counter-daemon",
+ runtimeId: "browser-peer-proof-test",
+ trust: localTrust,
+ rendezvous: { resolve: async () => ({ ...provider.route, authorityRuntimeId: "sample.counter-daemon" }) },
+ socketFactory: () => provider.open(),
+ }),
+ /proved .*intruder-runtime.*expected .*sample.counter-daemon/,
+ );
+});
+
+test("browser client accepts an Odin-certified route only after provider nonce proof", async () => {
+ const fixture = await createSignedRoute("wss://provider.example/mesh");
+ const client = await CultMeshBrowserClient.connect({
+ ...fixture.route,
+ runtimeId: "browser-authenticated-test",
+ rendezvous: { resolve: async () => fixture.route },
+ trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] },
+ socketFactory: () => signedHandshakeSocket(fixture.route, fixture.providerPrivate),
+ });
+
+ assert.equal(client.state, "connected");
+ await client.dispose();
+});
+
+test("browser client rejects a credential-free authority impersonator", async () => {
+ const fixture = await createSignedRoute("wss://provider.example/mesh");
+ const impersonator = new FakeProvider("wss://provider.example/mesh", 1);
+
+ await assert.rejects(
+ CultMeshBrowserClient.connect({
+ ...fixture.route,
+ runtimeId: "browser-impersonation-test",
+ rendezvous: { resolve: async () => fixture.route },
+ trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] },
+ socketFactory: () => impersonator.open(),
+ }),
+ /did not prove possession/,
+ );
+});
+
+test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => {
+ const fixture = await createSignedRoute("wss://provider.example/mesh");
+ let opened = false;
+ const connect = (route: CultMeshBrowserRoute, now?: () => number) => CultMeshBrowserClient.connect({
+ ...route,
+ runtimeId: "browser-route-certificate-test",
+ rendezvous: { resolve: async () => route },
+ trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic], now },
+ socketFactory: () => {
+ opened = true;
+ return signedHandshakeSocket(route, fixture.providerPrivate);
+ },
+ });
+
+ await assert.rejects(connect({ ...fixture.route, endpoint: "wss://evil.example/mesh" }), /signature is invalid/);
+ assert.equal(opened, false);
+ await assert.rejects(connect(fixture.route, () => fixture.expiresAt), /not currently valid/);
+ assert.equal(opened, false);
+});
+
test("browser client bounds unanswered provider operations", async () => {
const route = {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
endpoint: "ws://127.0.0.1:4301/mesh",
+ generation: "route-4301",
};
const client = await CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-timeout-test",
+ trust: localTrust,
rendezvous: { resolve: async () => route },
requestTimeoutMs: 5,
- socketFactory: () => new FakeSocket(() => undefined, () => undefined),
+ socketFactory: () => handshakeOnlySocket(route),
});
await assert.rejects(
client.invoke({
@@ -148,6 +289,7 @@ test("browser client rejects a correlated framework failure without waiting for
const client = await CultMeshBrowserClient.connect({
...provider.route,
runtimeId: "browser-failure-test",
+ trust: localTrust,
rendezvous: { resolve: async () => provider.route },
requestTimeoutMs: 60_000,
socketFactory: () => provider.open(),
@@ -172,16 +314,153 @@ test("browser client rejects a correlated framework failure without waiting for
await client.dispose();
});
+test("browser client replays one idempotent operation after route loss without duplicating its effect", async () => {
+ const firstRoute = localRoute("ws://127.0.0.1:4311/mesh", "generation-1");
+ const secondRoute = localRoute("ws://127.0.0.1:4312/mesh", "generation-2");
+ let route = firstRoute;
+ let effectCount = 0;
+ let dropFirstResponse = true;
+ const results = new Map();
+ const open = () => {
+ let socket: FakeSocket;
+ socket = new FakeSocket(wire => {
+ const message = parseCultNetMessage(decode(wire));
+ if (message.schemaVersion === "cultmesh.session_open.v2") {
+ socket.deliver({
+ schemaVersion: "cultmesh.session_accepted.v2",
+ messageId: message.messageId,
+ accepted: true,
+ verseId: message.verseId,
+ authorityRuntimeId: message.authorityRuntimeId,
+ protocolId: message.protocolId,
+ routeGeneration: message.routeGeneration,
+ clientNonce: message.clientNonce,
+ providerKeyId: "",
+ providerSignature: "",
+ });
+ return;
+ }
+ if (message.schemaVersion !== "cultnet.operation_request.v0") return;
+ let response = results.get(message.messageId);
+ if (!response) {
+ effectCount++;
+ response = {
+ schemaVersion: "cultnet.operation_response.v0",
+ messageId: message.messageId,
+ serviceId: message.serviceId,
+ operation: message.operation,
+ status: "accepted",
+ payloadSchema: "sample.increment_receipt.v1",
+ payloadEncoding: "messagepack-base64",
+ payload: Buffer.from(encode({ count: effectCount })).toString("base64"),
+ sourceRuntimeId: "sample.counter-daemon",
+ };
+ results.set(message.messageId, response!);
+ }
+ if (dropFirstResponse) {
+ dropFirstResponse = false;
+ route = secondRoute;
+ socket.close(1012, "route lost after commit");
+ return;
+ }
+ socket.deliver(response!);
+ }, () => undefined);
+ return socket;
+ };
+ const client = await CultMeshBrowserClient.connect({
+ ...firstRoute,
+ runtimeId: "browser-operation-replay-test",
+ rendezvous: { resolve: async () => route },
+ trust: localTrust,
+ reconnectDelayMs: 1,
+ requestTimeoutMs: 2_000,
+ socketFactory: () => open(),
+ });
+
+ const response = await client.invoke({
+ serviceId: "sample.counter",
+ operation: "increment",
+ payloadSchema: "sample.increment.v1",
+ payload: { amount: 1 },
+ idempotencyKey: "replay-once",
+ });
+
+ assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 1 });
+ assert.equal(effectCount, 1);
+ await client.dispose();
+});
+
+test("an early caller reconnect cancels the scheduled reconnect and keeps one live socket", async () => {
+ const provider = new FakeProvider("ws://127.0.0.1:4361/mesh", 2);
+ const client = await CultMeshBrowserClient.connect({
+ ...provider.route,
+ runtimeId: "browser-single-reconnect-test",
+ rendezvous: { resolve: async () => provider.route },
+ trust: localTrust,
+ reconnectDelayMs: 40,
+ socketFactory: () => provider.open(),
+ });
+ const lease = await client.leaseRawDocument({
+ schemaId: "sample.counter_state.v1",
+ recordKey: "counter:main",
+ });
+
+ provider.dropConnections();
+ await client.invoke({
+ serviceId: "sample.counter",
+ operation: "increment",
+ payloadSchema: "sample.increment.v1",
+ payload: { amount: 1 },
+ });
+ await new Promise(resolve => setTimeout(resolve, 80));
+
+ assert.equal(provider.openCount, 2);
+ assert.equal(provider.liveSocketCount, 1);
+ assert.equal(provider.activeSubscriptionCount, 1);
+ lease.dispose();
+ await client.dispose();
+});
+
+test("browser client rejects an operation response from another runtime", async () => {
+ const provider = new FakeProvider(
+ "ws://127.0.0.1:4371/mesh",
+ 1,
+ false,
+ "sample.counter-daemon",
+ "intruder-runtime",
+ );
+ const client = await CultMeshBrowserClient.connect({
+ ...provider.route,
+ runtimeId: "browser-response-authority-test",
+ trust: localTrust,
+ rendezvous: { resolve: async () => provider.route },
+ socketFactory: () => provider.open(),
+ });
+
+ await assert.rejects(
+ client.invoke({
+ serviceId: "sample.counter",
+ operation: "increment",
+ payloadSchema: "sample.increment.v1",
+ payload: { amount: 1 },
+ }),
+ /response came from 'intruder-runtime'/,
+ );
+ await client.dispose();
+});
+
test("browser client rejects oversized outbound schema messages", async () => {
const route = {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
endpoint: "ws://127.0.0.1:4401/mesh",
+ generation: "route-4401",
};
await assert.rejects(
CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-size-test",
+ trust: localTrust,
rendezvous: { resolve: async () => route },
maxFrameBytes: 8,
socketFactory: () => new FakeSocket(() => undefined, () => undefined),
@@ -195,6 +474,7 @@ test("browser client reserves subscription identity while the initial lease open
const client = await CultMeshBrowserClient.connect({
...provider.route,
runtimeId: "browser-lease-race-test",
+ trust: localTrust,
rendezvous: { resolve: async () => provider.route },
socketFactory: () => provider.open(),
});
@@ -220,8 +500,17 @@ class FakeProvider {
#count: number;
#subscriptions = new Map>();
#rejectOperations: boolean;
+ #runtimeId: string;
+ #responseRuntimeId: string;
+ #openCount = 0;
- constructor(endpoint: string, count: number, rejectOperations = false) {
+ constructor(
+ endpoint: string,
+ count: number,
+ rejectOperations = false,
+ runtimeId = "sample.counter-daemon",
+ responseRuntimeId = runtimeId,
+ ) {
this.route = {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
@@ -230,6 +519,8 @@ class FakeProvider {
};
this.#count = count;
this.#rejectOperations = rejectOperations;
+ this.#runtimeId = runtimeId;
+ this.#responseRuntimeId = responseRuntimeId;
}
get activeSubscriptionCount(): number {
@@ -238,7 +529,20 @@ class FakeProvider {
return count;
}
+ get openCount(): number {
+ return this.#openCount;
+ }
+
+ get liveSocketCount(): number {
+ return this.#subscriptions.size;
+ }
+
+ dropConnections(): void {
+ for (const socket of [...this.#subscriptions.keys()]) socket.close(1012, "route dropped");
+ }
+
open(): FakeSocket {
+ this.#openCount++;
const socket = new FakeSocket(
wire => this.receive(socket, wire),
() => this.#subscriptions.delete(socket),
@@ -250,6 +554,9 @@ class FakeProvider {
private receive(socket: FakeSocket, wire: Uint8Array): void {
const message = parseCultNetMessage(decode(wire));
switch (message.schemaVersion) {
+ case "cultmesh.session_open.v2":
+ this.acceptSession(socket, message);
+ return;
case "cultnet.database_subscribe.v0":
this.subscribe(socket, message);
return;
@@ -264,6 +571,21 @@ class FakeProvider {
}
}
+ private acceptSession(socket: FakeSocket, message: CultMeshSessionOpenMessage): void {
+ socket.deliver({
+ schemaVersion: "cultmesh.session_accepted.v2",
+ messageId: message.messageId,
+ accepted: true,
+ verseId: message.verseId,
+ authorityRuntimeId: this.#runtimeId,
+ protocolId: message.protocolId,
+ routeGeneration: message.routeGeneration,
+ clientNonce: message.clientNonce,
+ providerKeyId: "",
+ providerSignature: "",
+ });
+ }
+
private subscribe(socket: FakeSocket, message: CultNetDatabaseSubscribeMessage): void {
this.#subscriptions.get(socket)!.set(message.subscriptionId, message);
socket.deliver({
@@ -274,6 +596,7 @@ class FakeProvider {
}
private invoke(socket: FakeSocket, message: CultNetOperationRequestMessage): void {
+ assert.equal(message.targetRuntimeId, this.#runtimeId);
if (this.#rejectOperations) {
socket.deliver({
schemaVersion: "cultnet.operation_response.v0",
@@ -288,7 +611,7 @@ class FakeProvider {
message: "Expected payload schema 'sample.increment.v1'.",
})),
diagnostics: ["Request schema did not match."],
- sourceRuntimeId: "sample.counter-daemon",
+ sourceRuntimeId: this.#responseRuntimeId,
});
return;
}
@@ -314,7 +637,7 @@ class FakeProvider {
payloadSchema: "sample.increment_receipt.v1",
payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encode({ count: this.#count })),
- sourceRuntimeId: "sample.counter-daemon",
+ sourceRuntimeId: this.#responseRuntimeId,
});
}
@@ -328,13 +651,16 @@ class FakeProvider {
storedAt: "2026-08-17T00:00:00Z",
payloadEncoding: "messagepack",
payload: encode({ count: this.#count }),
- sourceRuntimeId: "sample.counter-daemon",
+ sourceRuntimeId: this.#runtimeId,
};
}
}
class FakeOdin {
providerEndpoint: string;
+ decoyEndpoint: string | undefined;
+ omitAuthorityRoutes = false;
+ authorityRoutesOverride: NonNullable | undefined;
constructor(providerEndpoint: string) {
this.providerEndpoint = providerEndpoint;
@@ -359,7 +685,25 @@ class FakeOdin {
optionalPluginIds: [],
},
discoveryEndpoints: [this.providerEndpoint],
- authorityRuntimeIds: ["sample.counter-daemon"],
+ authorityRuntimeIds: this.decoyEndpoint
+ ? ["sample.counter-daemon", "sample.decoy-daemon"]
+ : ["sample.counter-daemon"],
+ ...(!this.omitAuthorityRoutes ? { authorityRoutes: this.authorityRoutesOverride ?? [
+ ...(this.decoyEndpoint ? [{
+ authorityRuntimeId: "sample.decoy-daemon",
+ endpoint: this.decoyEndpoint,
+ protocolIds: ["cultmesh.documents.v1"],
+ priority: 0,
+ generation: "decoy-generation",
+ }] : []),
+ {
+ authorityRuntimeId: "sample.counter-daemon",
+ endpoint: this.providerEndpoint,
+ protocolIds: ["cultmesh.documents.v1"],
+ priority: 10,
+ generation: this.providerEndpoint,
+ },
+ ] } : {}),
}],
});
}, () => undefined);
@@ -367,6 +711,62 @@ class FakeOdin {
}
}
+function routeForCatalog(route: CultMeshBrowserRoute): NonNullable<
+ CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"]
+>[number] {
+ return {
+ authorityRuntimeId: route.authorityRuntimeId,
+ endpoint: route.endpoint,
+ protocolIds: [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])],
+ priority: route.priority ?? 0,
+ generation: route.generation,
+ ...(route.certificate ? {
+ certificate: {
+ providerKeyId: route.certificate.providerKey.keyId,
+ providerPublicKeyX: route.certificate.providerKey.x,
+ providerPublicKeyY: route.certificate.providerKey.y,
+ odinKeyId: route.certificate.odinKeyId,
+ issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
+ expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
+ signature: route.certificate.signature,
+ },
+ } : {}),
+ };
+}
+
+function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket {
+ let socket: FakeSocket;
+ socket = new FakeSocket(wire => {
+ const message = parseCultNetMessage(decode(wire));
+ if (message.schemaVersion !== "cultmesh.session_open.v2") return;
+ socket.deliver({
+ schemaVersion: "cultmesh.session_accepted.v2",
+ messageId: message.messageId,
+ accepted: true,
+ verseId: route.verseId,
+ authorityRuntimeId: route.authorityRuntimeId,
+ protocolId: message.protocolId,
+ routeGeneration: message.routeGeneration,
+ clientNonce: message.clientNonce,
+ providerKeyId: "",
+ providerSignature: "",
+ });
+ }, () => undefined);
+ return socket;
+}
+
+function localRoute(endpoint: string, generation: string): CultMeshBrowserRoute {
+ return {
+ verseId: "sample.counter",
+ authorityRuntimeId: "sample.counter-daemon",
+ endpoint,
+ protocolId: "cultmesh.documents.v1",
+ protocolIds: ["cultmesh.documents.v1"],
+ priority: 0,
+ generation,
+ };
+}
+
class FakeSocket implements CultMeshBrowserSocket {
binaryType: BinaryType = "arraybuffer";
readyState = 0;
@@ -407,6 +807,116 @@ class FakeSocket implements CultMeshBrowserSocket {
}
}
+async function createSignedRoute(endpoint: string): Promise<{
+ route: CultMeshBrowserRoute;
+ odinPublic: CultMeshBrowserP256PublicKey;
+ providerPrivate: CryptoKey;
+ expiresAt: number;
+}> {
+ const odin = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
+ const provider = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
+ const odinPublic = await exportPublic("odin-root-1", odin.publicKey);
+ const providerPublic = await exportPublic("provider-1", provider.publicKey);
+ const issuedAt = Date.now() - 1_000;
+ const expiresAt = Date.now() + 60_000;
+ const unsigned: CultMeshBrowserRoute = {
+ verseId: "sample.counter",
+ authorityRuntimeId: "sample.counter-daemon",
+ endpoint,
+ protocolId: "cultmesh.documents.v1",
+ protocolIds: ["cultmesh.documents.v1"],
+ priority: 0,
+ generation: "signed-generation-1",
+ certificate: {
+ providerKey: providerPublic,
+ odinKeyId: odinPublic.keyId,
+ issuedAtUnixMilliseconds: issuedAt,
+ expiresAtUnixMilliseconds: expiresAt,
+ signature: "",
+ },
+ };
+ const signature = await crypto.subtle.sign(
+ { name: "ECDSA", hash: "SHA-256" },
+ odin.privateKey,
+ testCanonicalRoute(unsigned).slice().buffer as ArrayBuffer,
+ );
+ return {
+ route: { ...unsigned, certificate: { ...unsigned.certificate!, signature: toBase64(new Uint8Array(signature)) } },
+ odinPublic,
+ providerPrivate: provider.privateKey,
+ expiresAt,
+ };
+}
+
+function signedHandshakeSocket(route: CultMeshBrowserRoute, providerPrivate: CryptoKey): FakeSocket {
+ let socket: FakeSocket;
+ socket = new FakeSocket(wire => {
+ const message = parseCultNetMessage(decode(wire));
+ if (message.schemaVersion !== "cultmesh.session_open.v2") return;
+ void crypto.subtle.sign(
+ { name: "ECDSA", hash: "SHA-256" },
+ providerPrivate,
+ testCanonicalSession(message, route.endpoint).slice().buffer as ArrayBuffer,
+ ).then(signature => socket.deliver({
+ schemaVersion: "cultmesh.session_accepted.v2",
+ messageId: message.messageId,
+ accepted: true,
+ verseId: message.verseId,
+ authorityRuntimeId: message.authorityRuntimeId,
+ protocolId: message.protocolId,
+ routeGeneration: message.routeGeneration,
+ clientNonce: message.clientNonce,
+ providerKeyId: route.certificate!.providerKey.keyId,
+ providerSignature: toBase64(new Uint8Array(signature)),
+ }));
+ }, () => undefined);
+ return socket;
+}
+
+async function exportPublic(keyId: string, key: CryptoKey): Promise {
+ const jwk = await crypto.subtle.exportKey("jwk", key);
+ return { keyId, x: urlBase64ToBase64(jwk.x!), y: urlBase64ToBase64(jwk.y!) };
+}
+
+function testCanonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
+ const certificate = route.certificate!;
+ return testCanonical(
+ "gamecult.cultmesh.route-certificate.v1", route.verseId, route.authorityRuntimeId, route.endpoint,
+ [...route.protocolIds!].sort().join("\u001f"), String(route.priority), route.generation,
+ certificate.providerKey.keyId, certificate.providerKey.x, certificate.providerKey.y,
+ certificate.odinKeyId, String(certificate.issuedAtUnixMilliseconds), String(certificate.expiresAtUnixMilliseconds),
+ );
+}
+
+function testCanonicalSession(message: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
+ return testCanonical(
+ "gamecult.cultmesh.session-proof.v1", message.clientNonce, message.messageId, message.sourceRuntimeId,
+ message.verseId, message.authorityRuntimeId, message.protocolId, endpoint, message.routeGeneration,
+ );
+}
+
+function testCanonical(...values: string[]): Uint8Array {
+ const chunks = values.map(value => new TextEncoder().encode(value));
+ const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + 4 + chunk.length, 0));
+ const view = new DataView(bytes.buffer);
+ let offset = 0;
+ for (const chunk of chunks) {
+ view.setUint32(offset, chunk.length, false);
+ offset += 4;
+ bytes.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return bytes;
+}
+
+function toBase64(bytes: Uint8Array): string {
+ return Buffer.from(bytes).toString("base64");
+}
+
+function urlBase64ToBase64(value: string): string {
+ return Buffer.from(value, "base64url").toString("base64");
+}
+
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json
index a1cc77c4..58a3c159 100644
--- a/packages/cultmesh-ts/package.json
+++ b/packages/cultmesh-ts/package.json
@@ -44,7 +44,7 @@
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"cultcache-ts": "^0.13.5",
- "cultnet-ts": "^0.1.1"
+ "cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
diff --git a/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json
new file mode 100644
index 00000000..aca1ff2b
--- /dev/null
+++ b/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json",
+ "title": "CultMesh Session Accepted",
+ "type": "object",
+ "required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"],
+ "additionalProperties": false,
+ "properties": {
+ "schemaVersion": { "const": "cultmesh.session_accepted.v2" },
+ "messageId": { "type": "string", "minLength": 1 },
+ "accepted": { "type": "boolean" },
+ "verseId": { "type": "string", "minLength": 1 },
+ "authorityRuntimeId": { "type": "string", "minLength": 1 },
+ "protocolId": { "type": "string", "minLength": 1 },
+ "routeGeneration": { "type": "string" },
+ "clientNonce": { "type": "string" },
+ "providerKeyId": { "type": "string" },
+ "providerSignature": { "type": "string" },
+ "error": { "type": "string", "minLength": 1 }
+ }
+}
diff --git a/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json
new file mode 100644
index 00000000..937f3e28
--- /dev/null
+++ b/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json",
+ "title": "CultMesh Session Open",
+ "type": "object",
+ "required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"],
+ "additionalProperties": false,
+ "properties": {
+ "schemaVersion": { "const": "cultmesh.session_open.v2" },
+ "messageId": { "type": "string", "minLength": 1 },
+ "sourceRuntimeId": { "type": "string", "minLength": 1 },
+ "verseId": { "type": "string", "minLength": 1 },
+ "authorityRuntimeId": { "type": "string", "minLength": 1 },
+ "protocolId": { "type": "string", "minLength": 1 },
+ "routeGeneration": { "type": "string" },
+ "clientNonce": { "type": "string", "minLength": 1 }
+ }
+}
diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json
index 21367d09..f23ef5f7 100644
--- a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json
+++ b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json
@@ -18,6 +18,33 @@
"optionalPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }
}
},
+ "authorityRoute": {
+ "type": "object",
+ "required": ["authorityRuntimeId", "endpoint", "protocolIds", "priority", "generation"],
+ "additionalProperties": false,
+ "properties": {
+ "authorityRuntimeId": { "type": "string", "minLength": 1 },
+ "endpoint": { "type": "string", "minLength": 1 },
+ "protocolIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
+ "priority": { "type": "integer", "minimum": 0 },
+ "generation": { "type": "string", "minLength": 1 },
+ "certificate": { "$ref": "#/$defs/routeCertificate" }
+ }
+ },
+ "routeCertificate": {
+ "type": "object",
+ "required": ["providerKeyId", "providerPublicKeyX", "providerPublicKeyY", "odinKeyId", "issuedAtUnixMilliseconds", "expiresAtUnixMilliseconds", "signature"],
+ "additionalProperties": false,
+ "properties": {
+ "providerKeyId": { "type": "string", "minLength": 1 },
+ "providerPublicKeyX": { "type": "string", "minLength": 1 },
+ "providerPublicKeyY": { "type": "string", "minLength": 1 },
+ "odinKeyId": { "type": "string", "minLength": 1 },
+ "issuedAtUnixMilliseconds": { "type": "integer", "minimum": 0 },
+ "expiresAtUnixMilliseconds": { "type": "integer", "minimum": 1 },
+ "signature": { "type": "string", "minLength": 1 }
+ }
+ },
"verse": {
"type": "object",
"required": ["verseId", "displayName", "authorityModel", "compatibility", "discoveryEndpoints", "authorityRuntimeIds"],
@@ -29,6 +56,7 @@
"compatibility": { "$ref": "#/$defs/compatibility" },
"discoveryEndpoints": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"authorityRuntimeIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
+ "authorityRoutes": { "type": "array", "items": { "$ref": "#/$defs/authorityRoute" } },
"parentVerseId": { "type": "string", "minLength": 1 },
"description": { "type": "string" }
}
diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json
index 6ff29340..df129a42 100644
--- a/packages/cultnet-ts/package.json
+++ b/packages/cultnet-ts/package.json
@@ -1,6 +1,6 @@
{
"name": "cultnet-ts",
- "version": "0.1.1",
+ "version": "0.2.0",
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts
index 1c3998ae..c71cdcc1 100644
--- a/packages/cultnet-ts/src/contracts.ts
+++ b/packages/cultnet-ts/src/contracts.ts
@@ -30,6 +30,8 @@ import operationRequestSchema from "../contracts/cultnet.operation-request.schem
import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json";
import verseCatalogRequestSchema from "../contracts/cultmesh.verse-catalog-request.schema.json";
import verseCatalogResponseSchema from "../contracts/cultmesh.verse-catalog-response.schema.json";
+import sessionOpenSchema from "../contracts/cultmesh.session-open.schema.json";
+import sessionAcceptedSchema from "../contracts/cultmesh.session-accepted.schema.json";
import ghostlightAgentStateSchema from "../contracts/ghostlight.agent-state.schema.json";
export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0";
@@ -59,7 +61,9 @@ export type CultNetSchemaVersion =
| "cultnet.operation_request.v0"
| "cultnet.operation_response.v0"
| "cultmesh.verse_catalog_request.v0"
- | "cultmesh.verse_catalog_response.v0";
+ | "cultmesh.verse_catalog_response.v0"
+ | "cultmesh.session_open.v2"
+ | "cultmesh.session_accepted.v2";
export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract";
export type CultNetRawPayloadEncoding = "messagepack";
@@ -350,10 +354,55 @@ export interface CultMeshVerseDescriptorMessage {
compatibility: CultMeshVerseCompatibilityMessage;
discoveryEndpoints: string[];
authorityRuntimeIds: string[];
+ authorityRoutes?: CultMeshAuthorityRouteMessage[];
parentVerseId?: string;
description?: string;
}
+export interface CultMeshAuthorityRouteMessage {
+ authorityRuntimeId: string;
+ endpoint: string;
+ protocolIds: string[];
+ priority: number;
+ generation: string;
+ certificate?: CultMeshRouteCertificateMessage;
+}
+
+export interface CultMeshRouteCertificateMessage {
+ providerKeyId: string;
+ providerPublicKeyX: string;
+ providerPublicKeyY: string;
+ odinKeyId: string;
+ issuedAtUnixMilliseconds: number;
+ expiresAtUnixMilliseconds: number;
+ signature: string;
+}
+
+export interface CultMeshSessionOpenMessage {
+ schemaVersion: "cultmesh.session_open.v2";
+ messageId: string;
+ sourceRuntimeId: string;
+ verseId: string;
+ authorityRuntimeId: string;
+ protocolId: string;
+ routeGeneration: string;
+ clientNonce: string;
+}
+
+export interface CultMeshSessionAcceptedMessage {
+ schemaVersion: "cultmesh.session_accepted.v2";
+ messageId: string;
+ accepted: boolean;
+ verseId: string;
+ authorityRuntimeId: string;
+ protocolId: string;
+ routeGeneration: string;
+ clientNonce: string;
+ providerKeyId: string;
+ providerSignature: string;
+ error?: string;
+}
+
export interface CultMeshVerseCatalogRequestMessage {
schemaVersion: "cultmesh.verse_catalog_request.v0";
messageId: string;
@@ -618,7 +667,9 @@ export type CultNetMessage =
| CultNetOperationRequestMessage
| CultNetOperationResponseMessage
| CultMeshVerseCatalogRequestMessage
- | CultMeshVerseCatalogResponseMessage;
+ | CultMeshVerseCatalogResponseMessage
+ | CultMeshSessionOpenMessage
+ | CultMeshSessionAcceptedMessage;
const ajv = new Ajv2020({
allErrors: true,
@@ -651,6 +702,8 @@ const CULTNET_MESSAGE_SCHEMAS = [
operationResponseSchema,
verseCatalogRequestSchema,
verseCatalogResponseSchema,
+ sessionOpenSchema,
+ sessionAcceptedSchema,
] as const;
const cultNetValidators = new Map();
@@ -1198,6 +1251,9 @@ function normalizeCultNetOptionalNulls(
}
}
return;
+ case "cultmesh.session_accepted.v2":
+ stripNullProperties(candidate, ["error"]);
+ return;
case "cultnet.schema_catalog_request.v0":
stripNullProperties(candidate, ["schemaIds", "kinds"]);
return;
diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts
index e213ef6f..111b7361 100644
--- a/packages/cultnet-ts/test/cultnet.test.ts
+++ b/packages/cultnet-ts/test/cultnet.test.ts
@@ -84,10 +84,32 @@ test("CultNet validates the canonical CultMesh Verse catalog messages", () => {
},
discoveryEndpoints: ["wss://provider.example/cultmesh"],
authorityRuntimeIds: ["sample.counter-provider"],
+ authorityRoutes: [{
+ authorityRuntimeId: "sample.counter-provider",
+ endpoint: "wss://provider.example/cultmesh",
+ protocolIds: ["cultmesh.documents.v1"],
+ priority: 0,
+ generation: "provider-route-1",
+ }],
}],
});
assert.equal(response.schemaVersion, "cultmesh.verse_catalog_response.v0");
assert.equal(response.verses[0].authorityRuntimeIds[0], "sample.counter-provider");
+ assert.equal(response.verses[0].authorityRoutes![0].generation, "provider-route-1");
+
+ const session = parseCultNetMessage({
+ schemaVersion: "cultmesh.session_accepted.v2",
+ messageId: "session-1",
+ accepted: true,
+ verseId: "sample.counter",
+ authorityRuntimeId: "sample.counter-provider",
+ protocolId: "cultmesh.documents.v1",
+ routeGeneration: "provider-route-1",
+ clientNonce: "bm9uY2U=",
+ providerKeyId: "provider-key-1",
+ providerSignature: "c2lnbmF0dXJl",
+ });
+ assert.equal(session.schemaVersion, "cultmesh.session_accepted.v2");
assert.throws(() => parseCultNetMessage({
...response,
diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj
index 7de0bdbd..50b41781 100644
--- a/samples/eve-browser-network/EveBrowserNetworkSample.csproj
+++ b/samples/eve-browser-network/EveBrowserNetworkSample.csproj
@@ -4,11 +4,20 @@
enable
enable
$(MSBuildThisFileDirectory)..\..\..\Eve
+ false
+ 1.0.35
+ 0.3.3
+ $(PackageFeed)
-
+
+
+
+
+
+
diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs
index 4b60a18e..bfadd7db 100644
--- a/samples/eve-browser-network/Program.cs
+++ b/samples/eve-browser-network/Program.cs
@@ -37,13 +37,30 @@ static async Task RunOdinAsync(Args arguments)
if (string.IsNullOrWhiteSpace(arguments.ProviderEndpoint))
throw new ArgumentException("The Odin fixture requires --provider-endpoint.");
using var catalog = new CultMeshVerseCatalog();
+ var routes = new List
+ {
+ new(
+ arguments.AuthorityRuntimeId,
+ arguments.ProviderEndpoint,
+ [CultMeshProtocols.Documents.Value],
+ priority: 10,
+ generation: arguments.RouteGeneration)
+ };
+ if (!string.IsNullOrWhiteSpace(arguments.DecoyEndpoint))
+ {
+ routes.Add(new CultMeshAuthorityRoute(
+ arguments.DecoyAuthorityRuntimeId,
+ arguments.DecoyEndpoint,
+ [CultMeshProtocols.Documents.Value],
+ priority: 0,
+ generation: arguments.DecoyRouteGeneration));
+ }
catalog.Upsert(new CultMeshVerseDescriptor(
arguments.VerseId,
arguments.VerseName,
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility(arguments.TransportVersion, arguments.RulesHash),
- discoveryEndpoints: [arguments.ProviderEndpoint],
- authorityRuntimeIds: [arguments.AuthorityRuntimeId],
+ authorityRoutes: routes,
description: "Local executable Odin fixture for the browser/network onboarding witness."));
await using var schemaServer = new CultNetWebSocketSchemaServer();
@@ -97,6 +114,12 @@ static async Task RunProviderAsync(Args arguments)
await node.FlushAsync();
await using var schemaServer = new CultNetWebSocketSchemaServer();
+ using var identity = new CultMeshSessionIdentityServer(
+ schemaServer,
+ arguments.AuthorityRuntimeId,
+ [arguments.VerseId],
+ [CultMeshProtocols.Documents.Value],
+ [arguments.RouteGeneration]);
using var subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, node.Database);
using var operationGate = new SemaphoreSlim(1, 1);
using var operations = new CultNetOperationServer(schemaServer, arguments.AuthorityRuntimeId)
@@ -175,6 +198,10 @@ static async Task RunHeadlessAsync(Args arguments)
CreateClient = CreateClient,
TransportVersion = arguments.TransportVersion
},
+ Sessions = new CultMeshSessionManagerOptions
+ {
+ Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
+ },
Connectors =
[
new CultMeshUriSchemaTransportConnector(
@@ -347,12 +374,16 @@ sealed class Args
public string StatePath { get; private init; } = "sample-counter.cc";
public string OdinEndpoint { get; private init; } = "";
public string ProviderEndpoint { get; private init; } = "";
+ public string DecoyEndpoint { get; private init; } = "";
public string Token { get; private init; } = "sample-session";
public string VerseId { get; private init; } = "sample.counter";
public string VerseName { get; private init; } = "CultMesh browser counter";
public string AuthorityRuntimeId { get; private init; } = "sample.counter-daemon";
+ public string DecoyAuthorityRuntimeId { get; private init; } = "sample.decoy-daemon";
public string TransportVersion { get; private init; } = "cultmesh.v0";
public string RulesHash { get; private init; } = "sample-counter-v1";
+ public string RouteGeneration { get; private init; } = "sample-counter-route-1";
+ public string DecoyRouteGeneration { get; private init; } = "sample-decoy-route-1";
public int ExpectedCount { get; private init; } = 2;
public int BenchmarkOperations { get; private init; } = 10_000;
@@ -369,12 +400,16 @@ sealed class Args
StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"),
OdinEndpoint = named.GetValueOrDefault("--odin", ""),
ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""),
+ DecoyEndpoint = named.GetValueOrDefault("--decoy-endpoint", ""),
Token = named.GetValueOrDefault("--token", "sample-session"),
VerseId = named.GetValueOrDefault("--verse-id", "sample.counter"),
VerseName = named.GetValueOrDefault("--verse-name", "CultMesh browser counter"),
AuthorityRuntimeId = named.GetValueOrDefault("--authority-runtime-id", "sample.counter-daemon"),
+ DecoyAuthorityRuntimeId = named.GetValueOrDefault("--decoy-authority-runtime-id", "sample.decoy-daemon"),
TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v0"),
RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"),
+ RouteGeneration = named.GetValueOrDefault("--route-generation", "sample-counter-route-1"),
+ DecoyRouteGeneration = named.GetValueOrDefault("--decoy-route-generation", "sample-decoy-route-1"),
ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount)
? int.Parse(expectedCount)
: 2,
diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md
index 7ad20a00..445bfb9d 100644
--- a/samples/eve-browser-network/README.md
+++ b/samples/eve-browser-network/README.md
@@ -12,6 +12,9 @@ This is the real-process continuation of `samples/eve-two-runtime`:
without application transport or reconnect code;
- one separate local Odin fixture answers the canonical CultMesh Verse catalog
over binary CultNet WebSocket messages;
+- one wrong-authority provider is deliberately advertised on a better-priority
+ route, and both clients must ignore it because the route is bound to another
+ runtime;
- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket
messages through the authenticated host adapter.
diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts
index 3947516b..8251a0a3 100644
--- a/samples/eve-browser-network/browser/main.ts
+++ b/samples/eve-browser-network/browser/main.ts
@@ -32,6 +32,7 @@ try {
verseId: "sample.counter",
authorityRuntimeId: "sample.counter-daemon",
runtimeId: "sample.chromium",
+ trust: { mode: "local-development" },
rendezvous: new CultMeshBrowserOdinRendezvous({
endpoints: [odinEndpoint],
runtimeId: "sample.chromium.discovery",
@@ -86,7 +87,11 @@ try {
window.__sampleCount = decodeCultNetPayload<{ count: number }>(counter.current!).count;
window.__sampleReady = true;
} catch (error) {
- window.__sampleError = error instanceof Error ? error.stack || error.message : String(error);
+ const details = error instanceof AggregateError
+ ? error.errors.map(inner => inner instanceof Error ? inner.stack || inner.message : String(inner)).join("\n---\n")
+ : "";
+ window.__sampleError = (error instanceof Error ? error.stack || error.message : String(error))
+ + (details ? `\n${details}` : "");
host.textContent = window.__sampleError;
throw error;
}
diff --git a/scripts/build-quic-native.ps1 b/scripts/build-quic-native.ps1
index 2de12bde..68496180 100644
--- a/scripts/build-quic-native.ps1
+++ b/scripts/build-quic-native.ps1
@@ -30,7 +30,14 @@ if (-not (Test-Path -LiteralPath $packagePath)) {
"https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.Schannel/$version"
if ($LASTEXITCODE -ne 0) { throw "MsQuic package download failed with exit code $LASTEXITCODE" }
}
-if ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ne $expectedPackageSha256) {
+$sha256 = [Security.Cryptography.SHA256]::Create()
+try {
+ $packageBytes = [IO.File]::ReadAllBytes($packagePath)
+ $packageSha256 = [BitConverter]::ToString($sha256.ComputeHash($packageBytes)).Replace("-", "")
+} finally {
+ $sha256.Dispose()
+}
+if ($packageSha256 -ne $expectedPackageSha256) {
throw "MsQuic package digest does not match the pinned release."
}
if (-not (Test-Path -LiteralPath (Join-Path $nativeRoot "include\msquic.h"))) {
diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1
index bb12e0c6..804f78df 100644
--- a/scripts/build-unity-package.ps1
+++ b/scripts/build-unity-package.ps1
@@ -1,6 +1,8 @@
param(
[string] $Configuration = "Release",
[string] $OutputDirectory = "artifacts\unity\org.gamecult.cultlib",
+ [string] $NuGetConfig = "",
+ [switch] $NoRestore,
[switch] $UpdateTemplate
)
@@ -8,9 +10,11 @@ $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.csproj"
+$webSocketProjectPath = Join-Path $repoRoot "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj"
$quicProjectPath = Join-Path $repoRoot "src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj"
$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib"
$publishRoot = Join-Path $repoRoot "artifacts\unity-publish"
+$webSocketPublishRoot = Join-Path $repoRoot "artifacts\unity-websocket-publish"
$quicPublishRoot = Join-Path $repoRoot "artifacts\unity-quic-publish"
$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native"
$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) {
@@ -26,15 +30,38 @@ if (Test-Path -LiteralPath $publishRoot) {
if (Test-Path -LiteralPath $quicPublishRoot) {
Remove-Item -LiteralPath $quicPublishRoot -Recurse -Force
}
+if (Test-Path -LiteralPath $webSocketPublishRoot) {
+ Remove-Item -LiteralPath $webSocketPublishRoot -Recurse -Force
+}
if (Test-Path -LiteralPath $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force
}
-dotnet publish $projectPath -c $Configuration -o $publishRoot
+$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
+$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
+if ($NoRestore) { $publishArguments += "--no-restore" }
+if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
+ $publishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
+}
+& dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib publish failed with exit code $LASTEXITCODE"
}
-dotnet publish $quicProjectPath -c $Configuration -o $quicPublishRoot
+$webSocketPublishArguments = @("publish", $webSocketProjectPath, "-c", $Configuration, "-f", "netstandard2.1", "-o", $webSocketPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
+if ($NoRestore) { $webSocketPublishArguments += "--no-restore" }
+if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
+ $webSocketPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
+}
+& dotnet @webSocketPublishArguments
+if ($LASTEXITCODE -ne 0) {
+ throw "CultLib WebSocket publish failed with exit code $LASTEXITCODE"
+}
+$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
+if ($NoRestore) { $quicPublishArguments += "--no-restore" }
+if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
+ $quicPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
+}
+& dotnet @quicPublishArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib native QUIC managed publish failed with exit code $LASTEXITCODE"
}
@@ -54,6 +81,7 @@ $expectedAssemblies = @(
"GameCult.Mesh.dll",
"GameCult.Mesh.Quic.Native.dll",
"GameCult.Networking.dll",
+ "GameCult.Networking.WebSockets.dll",
"Isopoh.Cryptography.Argon2.dll",
"Isopoh.Cryptography.Blake2b.dll",
"Isopoh.Cryptography.SecureArray.dll",
@@ -79,6 +107,9 @@ foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") {
foreach ($assembly in Get-ChildItem -LiteralPath $quicPublishRoot -Filter "*.dll") {
$publishedByName[$assembly.Name] = $assembly
}
+foreach ($assembly in Get-ChildItem -LiteralPath $webSocketPublishRoot -Filter "*.dll") {
+ $publishedByName[$assembly.Name] = $assembly
+}
foreach ($assemblyName in $expectedAssemblies) {
if (-not $publishedByName.ContainsKey($assemblyName)) {
throw "CultLib Unity package is missing expected assembly: $assemblyName"
@@ -103,6 +134,26 @@ New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party No
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") `
-Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt")
+$meshPackagePath = Join-Path $pluginRoot "GameCult.Mesh.dll"
+$meshPackageVersion = [Reflection.AssemblyName]::GetAssemblyName($meshPackagePath).Version.ToString()
+if ($meshPackageVersion -ne "$unityPackageVersion.0") {
+ throw "Packaged GameCult.Mesh.dll version '$meshPackageVersion' does not match package '$unityPackageVersion'."
+}
+$meshPackageMetadata = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($meshPackagePath))
+foreach ($requiredType in @(
+ "GameCult.Mesh.CultMeshAuthorityTrustPolicy",
+ "GameCult.Mesh.CultMeshRouteCertificate",
+ "GameCult.Mesh.CultMeshHttpsContentTransportConnector")) {
+ if (-not $meshPackageMetadata.Contains($requiredType.Split('.')[-1])) {
+ throw "Packaged GameCult.Mesh.dll is missing required API '$requiredType'."
+ }
+}
+$webSocketPackageMetadata = [Text.Encoding]::UTF8.GetString(
+ [IO.File]::ReadAllBytes((Join-Path $pluginRoot "GameCult.Networking.WebSockets.dll")))
+if (-not $webSocketPackageMetadata.Contains("CultNetWebSocketSchemaClient")) {
+ throw "Packaged WebSocket assembly is missing the Unity-compatible CultNet client."
+}
+
if ($UpdateTemplate) {
$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins"
foreach ($assemblyName in $expectedAssemblies) {
diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1
index 14fa7340..2b853aab 100644
--- a/scripts/pack-nuget.ps1
+++ b/scripts/pack-nuget.ps1
@@ -1,6 +1,7 @@
param(
[string] $Configuration = "Release",
[string] $OutputDirectory = "artifacts\nuget",
+ [string] $PackageVersion = "",
[switch] $SkipConsumerSmoke
)
@@ -18,6 +19,7 @@ $projects = @(
"src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj",
"src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj",
"src\GameCult.Networking\GameCult.Networking.csproj",
+ "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj",
"src\GameCult.Mesh\GameCult.Mesh.csproj"
)
@@ -27,8 +29,14 @@ if (Test-Path -LiteralPath $outputRoot) {
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
foreach ($project in $projects) {
- dotnet pack (Join-Path $repoRoot $project) -c $Configuration -o $outputRoot `
- --nologo --verbosity quiet -p:NoWarn=1591%3BCS8632
+ $packArguments = @(
+ "pack", (Join-Path $repoRoot $project), "-c", $Configuration, "-o", $outputRoot,
+ "--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632"
+ )
+ if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) {
+ $packArguments += "-p:CultLibPackageVersion=$PackageVersion"
+ }
+ & dotnet @packArguments
if ($LASTEXITCODE -ne 0) {
throw "NuGet pack failed for $project with exit code $LASTEXITCODE"
}
@@ -48,6 +56,7 @@ $expectedPackages = @(
"GameCult.Caching.MessagePack.Analyzers.$version.nupkg",
"GameCult.Caching.MessagePack.$version.nupkg",
"GameCult.Networking.$version.nupkg",
+ "GameCult.Networking.WebSockets.$version.nupkg",
"GameCult.Mesh.$version.nupkg"
)
foreach ($package in $expectedPackages) {
@@ -62,6 +71,7 @@ if (-not $SkipConsumerSmoke) {
Remove-Item -LiteralPath $smokeRoot -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null
+ $consumerPackages = Join-Path $smokeRoot "packages"
$projectDocument = @"
@@ -69,6 +79,8 @@ if (-not $SkipConsumerSmoke) {
Exe
enable
$outputRoot;https://api.nuget.org/v3/index.json
+ $consumerPackages
+ true
diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs
index 3f633dc9..8c865b1d 100644
--- a/scripts/verify-eve-browser-network.mjs
+++ b/scripts/verify-eve-browser-network.mjs
@@ -14,12 +14,22 @@ import { chromium } from "playwright-core";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const eveFlag = process.argv.indexOf("--eve-root");
const eveRoot = resolve(eveFlag >= 0 ? process.argv[eveFlag + 1] : join(repoRoot, "..", "Eve"));
+const packageFeedFlag = process.argv.indexOf("--package-feed");
+const packageFeed = packageFeedFlag >= 0 ? resolve(process.argv[packageFeedFlag + 1]) : undefined;
+const cultLibVersionFlag = process.argv.indexOf("--cultlib-package-version");
+const cultLibPackageVersion = cultLibVersionFlag >= 0 ? process.argv[cultLibVersionFlag + 1] : undefined;
+const eveSurfaceVersionFlag = process.argv.indexOf("--eve-surface-package-version");
+const eveSurfacePackageVersion = eveSurfaceVersionFlag >= 0 ? process.argv[eveSurfaceVersionFlag + 1] : undefined;
+const nugetConfigFlag = process.argv.indexOf("--nuget-config");
+const nugetConfig = nugetConfigFlag >= 0 ? resolve(process.argv[nugetConfigFlag + 1]) : undefined;
const sampleRoot = join(repoRoot, "samples", "eve-browser-network");
const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-"));
const statePath = join(workRoot, "counter.cc");
+const decoyStatePath = join(workRoot, "decoy-counter.cc");
const bundlePath = join(workRoot, "bundle.js");
const token = "sample-session";
let provider;
+let decoyProvider;
let replacementProvider;
let odin;
let headless;
@@ -41,27 +51,52 @@ try {
alias: {
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"),
+ "cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
+ "cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"),
},
logLevel: "warning",
});
const bundle = await readFile(bundlePath, "utf8");
assert.doesNotMatch(bundle, /(?:from\s*["']node:|require\(["']node:)/u, "browser bundle contains a Node builtin import");
- await run("dotnet", [
+ const dotnetBuildArguments = [
"build",
join(sampleRoot, "EveBrowserNetworkSample.csproj"),
"-m:1",
"--verbosity", "quiet",
"-p:NoWarn=1591%3BCS8632",
- `-p:EveRoot=${eveRoot}`,
- ]);
+ "-p:NuGetAudit=false",
+ "-p:RestoreIgnoreFailedSources=true",
+ ];
+ if (nugetConfig) dotnetBuildArguments.push(`-p:RestoreConfigFile=${nugetConfig}`);
+ if (packageFeed) {
+ if (!cultLibPackageVersion || !eveSurfacePackageVersion) {
+ throw new Error("Package artifact builds require both CultLib and Eve surface package versions.");
+ }
+ dotnetBuildArguments.push(
+ "-p:UsePackageArtifacts=true",
+ `-p:PackageFeed=${packageFeed}`,
+ `-p:CultLibPackageVersion=${cultLibPackageVersion}`,
+ `-p:EveSurfacePackageVersion=${eveSurfacePackageVersion}`,
+ );
+ } else {
+ dotnetBuildArguments.push(`-p:EveRoot=${eveRoot}`, `-p:CultLibRoot=${repoRoot}`);
+ }
+ await run("dotnet", dotnetBuildArguments);
const providerPort = await freePort();
+ const decoyProviderPort = await freePort();
const replacementProviderPort = await freePort();
const odinPort = await freePort();
const httpPort = await freePort();
provider = await startProvider(providerPort);
const endpoint = await provider.waitFor("PROVIDER_READY ");
- odin = await startOdin(odinPort, endpoint);
+ decoyProvider = await startProvider(decoyProviderPort, {
+ authorityRuntimeId: "sample.decoy-daemon",
+ routeGeneration: "sample-decoy-route-1",
+ statePath: decoyStatePath,
+ });
+ const decoyEndpoint = await decoyProvider.waitFor("PROVIDER_READY ");
+ odin = await startOdin(odinPort, endpoint, decoyEndpoint);
const odinEndpoint = await odin.waitFor("ODIN_READY ");
headless = startDotnet([
"headless", "--odin", odinEndpoint, "--verse-id", "sample.counter",
@@ -91,7 +126,7 @@ try {
const restartedEndpoint = await replacementProvider.waitFor("PROVIDER_READY ");
assert.notEqual(restartedEndpoint, endpoint);
await stop(odin.process);
- odin = await startOdin(odinPort, restartedEndpoint);
+ odin = await startOdin(odinPort, restartedEndpoint, decoyEndpoint);
assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint);
await stop(provider.process);
provider = replacementProvider;
@@ -122,6 +157,7 @@ try {
console.log(JSON.stringify({
providerEndpoint: endpoint,
replacementProviderEndpoint: restartedEndpoint,
+ rejectedWrongAuthorityEndpoint: decoyEndpoint,
odinEndpoint,
browserRuntime: "chromium",
headlessRuntime: "csharp",
@@ -136,21 +172,31 @@ try {
if (browser) await browser.close().catch(() => undefined);
if (headless) await stop(headless.process);
if (provider) await stop(provider.process);
+ if (decoyProvider) await stop(decoyProvider.process);
if (replacementProvider) await stop(replacementProvider.process);
if (odin) await stop(odin.process);
if (httpServer) await new Promise(resolve => httpServer.close(resolve));
await rm(workRoot, { recursive: true, force: true });
}
-async function startProvider(port) {
- return startDotnet(["provider", "--port", String(port), "--state", statePath, "--token", token]);
+async function startProvider(port, options = {}) {
+ return startDotnet([
+ "provider", "--port", String(port),
+ "--state", options.statePath ?? statePath,
+ "--authority-runtime-id", options.authorityRuntimeId ?? "sample.counter-daemon",
+ "--route-generation", options.routeGeneration ?? "sample-counter-route-1",
+ "--token", token,
+ ]);
}
-async function startOdin(port, providerEndpoint) {
+async function startOdin(port, providerEndpoint, decoyEndpoint) {
return startDotnet([
"odin",
"--port", String(port),
"--provider-endpoint", providerEndpoint,
+ "--decoy-endpoint", decoyEndpoint,
+ "--decoy-authority-runtime-id", "sample.decoy-daemon",
+ "--decoy-route-generation", "sample-decoy-route-1",
"--token", token,
]);
}
diff --git a/scripts/verify-eve-getting-started.ps1 b/scripts/verify-eve-getting-started.ps1
index eb653aa6..4a87b19d 100644
--- a/scripts/verify-eve-getting-started.ps1
+++ b/scripts/verify-eve-getting-started.ps1
@@ -25,43 +25,62 @@ if ($cultMeshClientSource -match 'ConnectAsync\s*\(\s*string\s+(endpointId|verse
$cultMeshClientSource -match 'Lease(Document|Collection)Async[^\(]*\(\s*string\s+(endpointId|verseId)') {
throw "CultMeshClient must not expose an ambiguous one-string session identity."
}
-$discoverySource = Get-Content -LiteralPath (Join-Path $cultLibRoot "src\GameCult.Mesh\CultMeshDiscoveryService.cs") -Raw
-if ($discoverySource -notmatch 'AuthorityRuntimeIds\.Contains\(query\.AuthorityRuntimeId') {
- throw "CultMesh discovery must prove that a selected Verse route advertises the requested authority runtime."
-}
-
$dotnetPackageRoot = Join-Path ([IO.Path]::GetTempPath()) `
("eve-getting-started-nuget-" + [guid]::NewGuid().ToString("N"))
+$sourceRevision = (& git -C $cultLibRoot rev-parse --short=12 HEAD).Trim()
+if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceRevision)) {
+ throw "Could not determine the CultLib source revision for local package identity."
+}
+$verificationNonce = [guid]::NewGuid().ToString("N").Substring(0, 8)
+$cultLibLocalVersion = "1.0.35-local.$sourceRevision.$verificationNonce"
+$surfaceLocalVersion = "0.3.3-local.$sourceRevision.$verificationNonce"
try {
& (Join-Path $EveRoot "scripts\pack-dotnet-surface.ps1") `
-CultLibRoot $cultLibRoot `
- -OutputDirectory $dotnetPackageRoot
+ -OutputDirectory $dotnetPackageRoot `
+ -CultLibPackageVersion $cultLibLocalVersion `
+ -SurfacePackageVersion $surfaceLocalVersion
if ($LASTEXITCODE -ne 0) {
throw "The clean .NET package-consumer checkpoint failed."
}
+
+ $cultLibPackage = Get-ChildItem -LiteralPath $dotnetPackageRoot -Filter "GameCult.Mesh.*.nupkg" |
+ Where-Object { $_.Name -notlike "*.snupkg" } |
+ Select-Object -First 1
+ $surfacePackage = Get-ChildItem -LiteralPath $dotnetPackageRoot -Filter "GameCult.Eve.Surface.*.nupkg" |
+ Where-Object { $_.Name -notlike "*.snupkg" } |
+ Select-Object -First 1
+ if (-not $cultLibPackage -or -not $surfacePackage) {
+ throw "The local package feed is missing CultMesh or Eve surface artifacts."
+ }
+ $cultLibVersion = [regex]::Match($cultLibPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Groups[1].Value
+ $surfaceVersion = [regex]::Match($surfacePackage.Name, '^GameCult\.Eve\.Surface\.(.+)\.nupkg$').Groups[1].Value
+
+ & (Join-Path $PSScriptRoot "verify-eve-two-runtime-sample.ps1") `
+ -EveRoot $EveRoot `
+ -SkipDependencyInstall:$SkipDependencyInstall
+ if ($LASTEXITCODE -ne 0) {
+ throw "The clean package-consumer checkpoint failed."
+ }
+
+ $nodeCommand = Get-Command node -ErrorAction Stop
+ $playwrightCli = Join-Path $cultLibRoot "node_modules\playwright-core\cli.js"
+ if (-not (Test-Path -LiteralPath $playwrightCli -PathType Leaf)) {
+ throw "playwright-core is missing after dependency setup: $playwrightCli"
+ }
+
+ & $nodeCommand.Source (Join-Path $PSScriptRoot "verify-eve-browser-network.mjs") `
+ --eve-root $EveRoot `
+ --package-feed $dotnetPackageRoot `
+ --cultlib-package-version $cultLibVersion `
+ --eve-surface-package-version $surfaceVersion
+ if ($LASTEXITCODE -ne 0) {
+ throw "The real browser/C# network checkpoint failed."
+ }
+
+ Write-Host "Eve getting-started verification passed: clean .NET and TypeScript artifacts, DOM and headless convergence, real Chromium and C# clients, Odin discovery, authority-bound routes, receipts, persistence, and route replacement."
} finally {
if (Test-Path -LiteralPath $dotnetPackageRoot) {
Remove-Item -LiteralPath $dotnetPackageRoot -Recurse -Force
}
}
-
-& (Join-Path $PSScriptRoot "verify-eve-two-runtime-sample.ps1") `
- -EveRoot $EveRoot `
- -SkipDependencyInstall:$SkipDependencyInstall
-if ($LASTEXITCODE -ne 0) {
- throw "The clean package-consumer checkpoint failed."
-}
-
-$nodeCommand = Get-Command node -ErrorAction Stop
-$playwrightCli = Join-Path $cultLibRoot "node_modules\playwright-core\cli.js"
-if (-not (Test-Path -LiteralPath $playwrightCli -PathType Leaf)) {
- throw "playwright-core is missing after dependency setup: $playwrightCli"
-}
-
-& $nodeCommand.Source (Join-Path $PSScriptRoot "verify-eve-browser-network.mjs") `
- --eve-root $EveRoot
-if ($LASTEXITCODE -ne 0) {
- throw "The real browser/C# network checkpoint failed."
-}
-
-Write-Host "Eve getting-started verification passed: clean .NET and TypeScript artifacts, DOM and headless convergence, real Chromium and C# clients, Odin discovery, receipts, persistence, and route replacement."
diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
index 191febef..6af00795 100644
--- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
+++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
@@ -17,9 +17,18 @@ namespace GameCult.Caching.MessagePack;
///
public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
{
- private const string IndexedFormatVersion = "cultcache.store.v2.directory-indexed";
+ /// Fault-probe stage emitted before each immutable record page write.
+ public const string BeforeRecordPageWriteStage = "before-record-page-write";
+ /// Fault-probe stage emitted after all pages exist and before manifest finality.
+ public const string BeforeManifestCommitStage = "before-manifest-commit";
+ /// Read-probe stage emitted while the selected manifest generation is leased.
+ public const string AfterManifestReadStage = "after-manifest-read";
+ private const string LegacyIndexedFormatVersion = "cultcache.store.v2.directory-indexed";
+ private const string LegacyMetadataPageFormatVersion = "cultcache.store.v3.directory-immutable-pages";
+ private const string IndexedFormatVersion = "cultcache.store.v4.directory-content-addressed-pages";
private readonly FileInfo _manifestFile;
private readonly DirectoryInfo _recordDirectory;
+ private readonly object _mutationGate = new();
private readonly ConcurrentDictionary _dirtyKeys = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _deletedKeys = new(StringComparer.Ordinal);
private readonly HashSet _hydratedKeys = new(StringComparer.Ordinal);
@@ -27,6 +36,8 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
private Dictionary _legacyInlineRecords = new(StringComparer.Ordinal);
private CultSchemaCatalogEntry[] _durableCatalog = Array.Empty();
private bool _needsIndexUpgrade;
+ private bool _manifestUsesImmutablePages;
+ private bool _manifestUsesMetadataPages;
///
/// Gets or sets the predicate that selects indexed record payloads for hydration.
@@ -34,6 +45,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
///
public Func? HydrationFilter { get; set; }
+ /// Optional deterministic fault/progress hook for durability tests and host diagnostics.
+ public Action? FlushStageProbe { get; set; }
+
+ /// Optional deterministic read hook for generation-consistency tests and host diagnostics.
+ public Action? ReadStageProbe { get; set; }
+
///
/// Creates a paged MessagePack backing store.
///
@@ -53,8 +70,65 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
///
public static string DefaultRecordDirectoryPath(string manifestPath) => manifestPath + ".records";
+ ///
+ /// Reads one complete persisted generation without deserializing documents through a
+ /// runtime registry. Indexed manifests are resolved only through the pages they name;
+ /// orphaned pages from older generations are never included.
+ ///
+ public CultPersistedStoreSnapshot ReadPersistedGeneration()
+ {
+ lock (_mutationGate)
+ using (AcquireCommitLease())
+ {
+ var manifest = ReadManifest();
+ var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
+ var records = indexed
+ ? manifest.Records
+ .OrderBy(record => record.Key, StringComparer.Ordinal)
+ .Select(record => ReadPersistedRecordPage(record, manifest.FormatVersion, out _))
+ .ToArray()
+ : ReadLegacyPersistedGenerationRecords(manifest);
+ return new CultPersistedStoreSnapshot
+ {
+ FormatVersion = manifest.FormatVersion,
+ SchemaCatalog = manifest.SchemaCatalog.ToArray(),
+ Records = records
+ };
+ }
+ }
+
+ private CultPersistedRecord[] ReadLegacyPersistedGenerationRecords(CultPersistedStoreSnapshot manifest)
+ {
+ var records = manifest.Records
+ .Where(record => !string.IsNullOrWhiteSpace(record.Key))
+ .ToList();
+ _recordDirectory.Refresh();
+ if (_recordDirectory.Exists)
+ {
+ records.AddRange(_recordDirectory.EnumerateFiles("*.msgpack")
+ .OrderBy(file => file.Name, StringComparer.Ordinal)
+ .Select(file => CultDocumentMessagePackSerialization.DeserializePersistedRecord(
+ ReadAllBytesShared(file.FullName))));
+ }
+
+ return records
+ .Where(record => !string.IsNullOrWhiteSpace(record.Key))
+ .GroupBy(record => record.Key, StringComparer.Ordinal)
+ .Select(group => group.Last())
+ .OrderBy(record => record.Key, StringComparer.Ordinal)
+ .ToArray();
+ }
+
///
public override void PullAll()
+ {
+ lock (_mutationGate)
+ PullAllCore();
+ }
+
+ private void PullAllCore()
{
var traceStartup = string.Equals(
Environment.GetEnvironmentVariable("CULTCACHE_TRACE_STARTUP_PHASES"),
@@ -68,42 +142,50 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
startupPhase.Restart();
}
- _recordDirectory.Refresh();
- if (!File.Exists(_manifestFile.FullName) && !_recordDirectory.Exists)
- {
- SetLastSchemaMigrationReports(Array.Empty());
- IsDirty = false;
- return;
- }
-
- var manifest = ReadManifest();
- Trace($"manifest records={manifest.Records.Length}");
- _durableCatalog = manifest.SchemaCatalog;
var reports = new List();
var loaded = new Dictionary(StringComparer.Ordinal);
- var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
- if (indexed)
+ using (AcquireCommitLease())
{
- _needsIndexUpgrade = false;
- _durableIndex = manifest.Records
- .Where(record => !string.IsNullOrWhiteSpace(record.Key))
- .GroupBy(record => record.Key, StringComparer.Ordinal)
- .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
+ _recordDirectory.Refresh();
+ if (!File.Exists(_manifestFile.FullName) && !_recordDirectory.Exists)
+ {
+ SetLastSchemaMigrationReports(Array.Empty());
+ IsDirty = false;
+ return;
+ }
- LoadIndexedRecords(manifest, loaded, reports);
- Trace($"indexed-pages loaded={loaded.Count}");
- }
- else
- {
- // V1 manifests have no page index. Hydrate once to discover the durable keys;
- // the next flush replaces this compatibility path with the indexed manifest.
- _needsIndexUpgrade = true;
- var legacyRecords = LoadLegacyRecords(manifest, loaded, reports);
- _durableIndex = legacyRecords
- .Where(record => !string.IsNullOrWhiteSpace(record.Key))
- .GroupBy(record => record.Key, StringComparer.Ordinal)
- .ToDictionary(group => group.Key, group => ToIndexRecord(group.Last()), StringComparer.Ordinal);
- Trace($"legacy-pages loaded={loaded.Count}");
+ var manifest = ReadManifest();
+ ReadStageProbe?.Invoke(AfterManifestReadStage);
+ Trace($"manifest records={manifest.Records.Length}");
+ _durableCatalog = manifest.SchemaCatalog;
+ var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
+ if (indexed)
+ {
+ _manifestUsesImmutablePages = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
+ _manifestUsesMetadataPages = string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
+ _needsIndexUpgrade = !_manifestUsesImmutablePages;
+ _durableIndex = manifest.Records
+ .Where(record => !string.IsNullOrWhiteSpace(record.Key))
+ .GroupBy(record => record.Key, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
+
+ LoadIndexedRecords(manifest, loaded, reports);
+ Trace($"indexed-pages loaded={loaded.Count}");
+ }
+ else
+ {
+ // V1 manifests have no page index. Hydrate once to discover the durable keys;
+ // the next flush replaces this compatibility path with the indexed manifest.
+ _needsIndexUpgrade = true;
+ var legacyRecords = LoadLegacyRecords(manifest, loaded, reports);
+ _durableIndex = legacyRecords
+ .Where(record => !string.IsNullOrWhiteSpace(record.Key))
+ .GroupBy(record => record.Key, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => ToIndexRecord(group.Last()), StringComparer.Ordinal);
+ Trace($"legacy-pages loaded={loaded.Count}");
+ }
}
foreach (var pair in loaded)
@@ -146,26 +228,46 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
///
public override void PullSelected(Func selector)
{
- if (selector == null) throw new ArgumentNullException(nameof(selector));
- var manifest = ReadManifest();
- if (!string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal))
- {
- PullAll();
- return;
- }
+ lock (_mutationGate)
+ PullSelectedCore(selector);
+ }
- _durableCatalog = manifest.SchemaCatalog;
- _durableIndex = manifest.Records
- .Where(record => !string.IsNullOrWhiteSpace(record.Key))
- .GroupBy(record => record.Key, StringComparer.Ordinal)
- .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
- var selected = manifest.Records
- .Where(record => selector(ToMetadata(record)))
- .OrderBy(record => record.Key, StringComparer.Ordinal)
- .ToArray();
+ private void PullSelectedCore(Func selector)
+ {
+ if (selector == null) throw new ArgumentNullException(nameof(selector));
+ var fallbackToFullPull = false;
+ CultPersistedRecord[] selected = Array.Empty();
var loaded = new Dictionary(StringComparer.Ordinal);
var reports = new List();
- LoadRecordPages(selected, manifest.SchemaCatalog, loaded, reports);
+ using (AcquireCommitLease())
+ {
+ var manifest = ReadManifest();
+ ReadStageProbe?.Invoke(AfterManifestReadStage);
+ fallbackToFullPull = !string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) &&
+ !string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) &&
+ !string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
+ if (!fallbackToFullPull)
+ {
+ _manifestUsesImmutablePages = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
+ _manifestUsesMetadataPages = string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
+ _needsIndexUpgrade = !_manifestUsesImmutablePages;
+ _durableCatalog = manifest.SchemaCatalog;
+ _durableIndex = manifest.Records
+ .Where(record => !string.IsNullOrWhiteSpace(record.Key))
+ .GroupBy(record => record.Key, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
+ selected = manifest.Records
+ .Where(record => selector(ToMetadata(record)))
+ .OrderBy(record => record.Key, StringComparer.Ordinal)
+ .ToArray();
+ LoadRecordPages(selected, manifest.SchemaCatalog, loaded, reports);
+ }
+ }
+ if (fallbackToFullPull)
+ {
+ PullAllCore();
+ return;
+ }
PublishSelected(loaded, selected.Select(record => record.Key).ToHashSet(StringComparer.Ordinal));
SetLastSchemaMigrationReports(reports);
IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty;
@@ -174,29 +276,88 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
///
public override bool ContainsDurableRecord(CultRecordKey key)
{
- return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
+ lock (_mutationGate)
+ return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
}
///
public override void Push(CultStoredDocument entry)
{
- Entries[entry.Key.Value] = entry;
- _dirtyKeys[entry.Key.Value] = true;
- _deletedKeys.TryRemove(entry.Key.Value, out _);
- IsDirty = true;
+ lock (_mutationGate)
+ {
+ Entries[entry.Key.Value] = entry;
+ _dirtyKeys[entry.Key.Value] = true;
+ _deletedKeys.TryRemove(entry.Key.Value, out _);
+ IsDirty = true;
+ }
}
///
public override void Delete(CultStoredDocument entry)
{
- Entries.TryRemove(entry.Key.Value, out _);
- _dirtyKeys.TryRemove(entry.Key.Value, out _);
- _deletedKeys[entry.Key.Value] = true;
- IsDirty = true;
+ lock (_mutationGate)
+ {
+ Entries.TryRemove(entry.Key.Value, out _);
+ _dirtyKeys.TryRemove(entry.Key.Value, out _);
+ _deletedKeys[entry.Key.Value] = true;
+ IsDirty = true;
+ }
+ }
+
+ ///
+ public override void CommitBatch(
+ IReadOnlyCollection upserts,
+ IReadOnlyCollection deletes,
+ bool soft)
+ {
+ lock (_mutationGate)
+ {
+ var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
+ var previousDirtyKeys = _dirtyKeys.Keys.ToArray();
+ var previousDeletedKeys = _deletedKeys.Keys.ToArray();
+ var wasDirty = IsDirty;
+ try
+ {
+ foreach (var entry in deletes)
+ {
+ Entries.TryRemove(entry.Key.Value, out _);
+ _dirtyKeys.TryRemove(entry.Key.Value, out _);
+ _deletedKeys[entry.Key.Value] = true;
+ }
+ foreach (var entry in upserts)
+ {
+ Entries[entry.Key.Value] = entry;
+ _dirtyKeys[entry.Key.Value] = true;
+ _deletedKeys.TryRemove(entry.Key.Value, out _);
+ }
+ IsDirty = true;
+ PushAllCore(soft);
+ }
+ catch
+ {
+ Entries.Clear();
+ foreach (var pair in previousEntries)
+ Entries[pair.Key] = pair.Value;
+ _dirtyKeys.Clear();
+ foreach (var key in previousDirtyKeys)
+ _dirtyKeys[key] = true;
+ _deletedKeys.Clear();
+ foreach (var key in previousDeletedKeys)
+ _deletedKeys[key] = true;
+ IsDirty = wasDirty;
+ throw;
+ }
+ }
}
///
public override void PushAll(bool soft = false)
+ {
+ lock (_mutationGate)
+ PushAllCore(soft);
+ }
+
+ private void PushAllCore(bool soft)
{
if (!IsDirty && !_needsIndexUpgrade)
{
@@ -205,15 +366,17 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Directory.CreateDirectory(_manifestFile.DirectoryName!);
Directory.CreateDirectory(_recordDirectory.FullName);
+ using var commitLease = AcquireCommitLease();
var currentManifest = ReadManifest();
- var currentIndex = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal)
+ var currentManifestIsIndexed = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(currentManifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(currentManifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
+ var currentManifestUsesImmutablePages = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
+ var currentManifestUsesMetadataPages = string.Equals(currentManifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
+ var currentIndex = currentManifestIsIndexed
? currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal)
: new Dictionary(_durableIndex, StringComparer.Ordinal);
- var precommitIndex = currentIndex.Values
- .Select(ToIndexRecord)
- .OrderBy(record => record.Key, StringComparer.Ordinal)
- .ToArray();
foreach (var key in _deletedKeys.Keys)
currentIndex.Remove(key);
foreach (var key in _dirtyKeys.Keys)
@@ -233,18 +396,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
.Where(entry => usedSchemaIds.Contains(entry.SchemaId))
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
.ToArray();
- var precommitCatalog = currentManifest.SchemaCatalog
- .Concat(targetCatalog)
- .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal)
- .Select(group => group.Last())
- .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
- .ToArray();
-
- WriteManifest(precommitCatalog, precommitIndex);
-
var keysToWrite = (_needsIndexUpgrade ? Entries.Keys : _dirtyKeys.Keys)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
+ var pageWriteIndex = 0;
foreach (var key in keysToWrite)
{
if (!Entries.TryGetValue(key, out var stored))
@@ -257,31 +412,56 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
document,
stored.Descriptor.DocumentType,
Registry));
- WriteFileAtomically(RecordPath(key), CultDocumentMessagePackSerialization.SerializePersistedRecord(record));
+ var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(record);
+ var indexRecord = ToContentAddressedIndexRecord(record, pagePayload);
+ currentIndex[key] = indexRecord;
+ FlushStageProbe?.Invoke(BeforeRecordPageWriteStage, pageWriteIndex++);
+ WriteFileAtomically(
+ ContentAddressedRecordPath(indexRecord),
+ pagePayload);
+ }
+
+ if (!currentManifestUsesImmutablePages)
+ {
+ foreach (var metadata in currentManifest.Records
+ .Where(record => !_deletedKeys.ContainsKey(record.Key) && !_dirtyKeys.ContainsKey(record.Key))
+ .OrderBy(record => record.Key, StringComparer.Ordinal))
+ {
+ var sourcePath = currentManifestUsesMetadataPages
+ ? MetadataRecordPath(metadata)
+ : LegacyRecordPath(metadata.Key);
+ if (!File.Exists(sourcePath))
+ continue;
+ var pagePayload = ReadAllBytesShared(sourcePath);
+ var indexRecord = ToContentAddressedIndexRecord(metadata, pagePayload);
+ currentIndex[metadata.Key] = indexRecord;
+ var targetPath = ContentAddressedRecordPath(indexRecord);
+ if (!File.Exists(targetPath))
+ WriteFileAtomically(targetPath, pagePayload);
+ }
}
foreach (var pair in _legacyInlineRecords.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
if (_deletedKeys.ContainsKey(pair.Key) || _dirtyKeys.ContainsKey(pair.Key))
continue;
+ var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(pair.Value);
+ var indexRecord = ToContentAddressedIndexRecord(pair.Value, pagePayload);
+ currentIndex[pair.Key] = indexRecord;
WriteFileAtomically(
- RecordPath(pair.Key),
- CultDocumentMessagePackSerialization.SerializePersistedRecord(pair.Value));
- }
-
- foreach (var key in _deletedKeys.Keys.OrderBy(value => value, StringComparer.Ordinal))
- {
- var path = RecordPath(key);
- if (File.Exists(path))
- {
- File.Delete(path);
- }
+ ContentAddressedRecordPath(indexRecord),
+ pagePayload);
}
+ FlushStageProbe?.Invoke(BeforeManifestCommitStage, pageWriteIndex);
WriteManifest(targetCatalog, currentIndex.Values
.OrderBy(record => record.Key, StringComparer.Ordinal)
.ToArray());
+ _manifestUsesImmutablePages = true;
+ _manifestUsesMetadataPages = false;
+ DeleteUnreferencedRecordPages(currentIndex.Values);
+
_durableCatalog = targetCatalog;
_durableIndex = currentIndex;
_legacyInlineRecords.Clear();
@@ -367,13 +547,11 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
{
var started = tracePages ? Stopwatch.GetTimestamp() : 0L;
var metadata = records[index];
- var path = RecordPath(metadata.Key);
- if (!File.Exists(path))
- return;
- var pagePayload = ReadAllBytesShared(path);
- var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
- if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
- throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
+ var record = ReadPersistedRecordPage(
+ metadata,
+ _manifestUsesImmutablePages,
+ _manifestUsesMetadataPages,
+ out var pagePayload);
var catalog = ResolveLegacyUncataloguedRecordCatalog(record, catalogEntries);
recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalog);
storedRecords[index] = ToStoredDocument(
@@ -509,6 +687,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Payload = Array.Empty()
};
+ private static CultPersistedRecord ToContentAddressedIndexRecord(
+ CultPersistedRecord record,
+ byte[] pagePayload) => new()
+ {
+ Key = record.Key,
+ SchemaId = record.SchemaId,
+ StoredAt = record.StoredAt,
+ Payload = HashPayload(pagePayload)
+ };
+
private CultPersistedStoreSnapshot ReadManifest()
{
if (File.Exists(_manifestFile.FullName))
@@ -607,7 +795,82 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
: "";
}
- private string RecordPath(string key) => Path.Combine(_recordDirectory.FullName, $"{HashKey(key)}.msgpack");
+ private string LegacyRecordPath(string key) => Path.Combine(_recordDirectory.FullName, $"{HashKey(key)}.msgpack");
+
+ private string MetadataRecordPath(CultPersistedRecord metadata) => Path.Combine(
+ _recordDirectory.FullName,
+ $"{HashKey(metadata.Key + "\0" + metadata.SchemaId + "\0" + metadata.StoredAt)}.msgpack");
+
+ private string ContentAddressedRecordPath(CultPersistedRecord metadata)
+ {
+ if (metadata.Payload == null || metadata.Payload.Length != 32)
+ throw new InvalidDataException($"Record '{metadata.Key}' has no committed SHA-256 page identity.");
+ var hash = BitConverter.ToString(metadata.Payload).Replace("-", string.Empty).ToLowerInvariant();
+ return Path.Combine(_recordDirectory.FullName, $"{hash}.msgpack");
+ }
+
+ private static byte[] HashPayload(byte[] payload)
+ {
+ using var sha256 = SHA256.Create();
+ return sha256.ComputeHash(payload);
+ }
+
+ private void DeleteUnreferencedRecordPages(IEnumerable durableRecords)
+ {
+ var referenced = durableRecords
+ .Select(ContentAddressedRecordPath)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var file in _recordDirectory.EnumerateFiles("*.msgpack"))
+ {
+ if (referenced.Contains(file.FullName))
+ continue;
+ try
+ {
+ File.Delete(file.FullName);
+ }
+ catch (IOException)
+ {
+ // An orphan is harmless: no committed manifest references it.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Cleanup is best effort and never changes the committed generation.
+ }
+ }
+ }
+
+ private CultPersistedRecord ReadPersistedRecordPage(
+ CultPersistedRecord metadata,
+ string formatVersion,
+ out byte[] pagePayload) =>
+ ReadPersistedRecordPage(
+ metadata,
+ string.Equals(formatVersion, IndexedFormatVersion, StringComparison.Ordinal),
+ string.Equals(formatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal),
+ out pagePayload);
+
+ private CultPersistedRecord ReadPersistedRecordPage(
+ CultPersistedRecord metadata,
+ bool usesContentAddressedPages,
+ bool usesMetadataPages,
+ out byte[] pagePayload)
+ {
+ var path = usesContentAddressedPages
+ ? ContentAddressedRecordPath(metadata)
+ : usesMetadataPages
+ ? MetadataRecordPath(metadata)
+ : LegacyRecordPath(metadata.Key);
+ if (!File.Exists(path))
+ throw new InvalidDataException($"Committed record page '{path}' is missing from the selected manifest generation.");
+ pagePayload = ReadAllBytesShared(path);
+ if (usesContentAddressedPages &&
+ !HashPayload(pagePayload).SequenceEqual(metadata.Payload))
+ throw new InvalidDataException($"Record page '{path}' does not match its committed content hash.");
+ var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
+ if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
+ throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
+ return record;
+ }
private static string HashKey(string key)
{
@@ -662,6 +925,30 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
return buffer.ToArray();
}
+ private FileStream AcquireCommitLease()
+ {
+ Directory.CreateDirectory(_recordDirectory.FullName);
+ var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock");
+ var started = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ return new FileStream(
+ lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.WriteThrough);
+ }
+ catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
+ {
+ Thread.Sleep(10);
+ }
+ }
+ }
+
private static void ReplaceExistingFile(string sourcePath, string destinationPath)
{
for (var attempt = 0; attempt < 3; attempt++)
@@ -681,6 +968,6 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
}
}
- File.Copy(sourcePath, destinationPath, overwrite: true);
+ File.Replace(sourcePath, destinationPath, null);
}
}
diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
index 66d0e135..e7ffd54f 100644
--- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
+++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
@@ -39,6 +39,21 @@ These are **not** domain fields and should not be exposed on app-facing types:
Those belong to the CultCache persistence layer.
+## Transaction Visibility
+
+An authoritative multi-record change uses `CultCache.ExecuteTransactionAsync`.
+The executing async flow sees its buffered overlay while staging; other readers
+and all observers continue to see the prior committed generation. The backing
+store commits the complete batch before the live cache swaps to it. Observer
+notifications run only after the transaction context and commit lock have been
+released, so an observer-triggered write begins a separate transaction.
+
+An exception before durable finality discards the overlay. It cannot become
+visible through a later flush. A transaction currently permits at most one
+durable backing store because independent stores cannot provide one atomic
+commit boundary. Applications that require authoritative writes may configure
+`CultNetDatabase` to reject record-at-a-time writes outside this primitive.
+
## Canonical Store Shape
A persisted CultCache file should contain:
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index 21b437af..cf24c47a 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -7,6 +7,7 @@ using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using GameCult.Logging;
using R3;
@@ -1278,6 +1279,9 @@ namespace GameCult.Caching
private readonly ConditionalWeakTable