cultnet-ts: typed write_not_authorized factory and canonical schema id #36

Merged
libby merged 2 commits from libby/cultnet-ts-write-refusal into main 2026-11-07 19:36:37 +00:00
6 changed files with 59 additions and 0 deletions

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Added
- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority.
- `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members.
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.

View file

@ -1,3 +1,4 @@
import { isNullOrWhiteSpaceCSharp } from "./cultmesh-authority";
import type { DocumentPayload } from "@gamecult/cultcache-ts/document";
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js";
@ -204,6 +205,20 @@ export function cultNetTextError(error: string): CultNetErrorMessage {
return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null };
}
/**
* The refusal for a peer write its server's authority did not grant (C# `CultNetErrorMessage.ForRemoteWriteRefused`):
* code `write_not_authorized`, `details` naming the record in `recordKey`. A blank `reason` leaves the text bare.
*/
export function cultNetWriteNotAuthorizedError(recordKey: string, reason?: string | null): CultNetErrorMessage {
return {
schemaVersion: "cultnet.error.v0",
error: typeof reason === "string" && !isNullOrWhiteSpaceCSharp(reason) ? `write_not_authorized: ${reason}` : "write_not_authorized",
routingHint: null,
code: "write_not_authorized",
details: { field: "recordKey", value: recordKey, asOf: null, current: null },
};
}
export interface CultNetErrorRoutingHint {
shard: Record<string, unknown> | null;
reason: string;

View file

@ -18,6 +18,7 @@ export {
type CultNetRawPayloadEncoding,
CULTNET_ERROR_CODES,
cultNetTextError,
cultNetWriteNotAuthorizedError,
type CultNetErrorCode,
type CultNetErrorDetails,
type CultNetErrorMessage,

View file

@ -270,6 +270,20 @@ export class CultNetDocumentRegistry {
);
}
/**
* The canonical schema id a put or delete naming `schemaId` writes under, from the id alone, or `undefined` when no
* binding takes it (or several claim it). A server asks its write authority about this id, not the alias a peer sent.
*/
canonicalSchemaId(schemaId: string): string | undefined {
try {
const binding = this.getBySchemaId(schemaId);
return binding ? schemaIdForBinding(binding) : undefined;
} catch (error) {
if (error instanceof SchemaConflictError) return undefined;
throw error;
}
}
async applyDocumentDeleteMessage(
cache: CultCache,
message: CultNetDocumentDeleteMessage,

View file

@ -8,6 +8,7 @@ import { decode, encode } from "@msgpack/msgpack";
import {
CULTNET_ERROR_CODES,
cultNetSchemas,
cultNetWriteNotAuthorizedError,
encodeCultNetMessageForWire,
parseCultNetMessage,
type CultNetErrorMessage,
@ -81,3 +82,18 @@ test("a code outside the closed enum is refused, as is a malformed details objec
/cultnet\.error\.v0/,
);
});
test("the write_not_authorized factory writes the C# vector's bytes and names the key", () => {
const vector = vectors.find((v) => v.name === "write_not_authorized")!;
assert.equal(hexOf(wireOf(cultNetWriteNotAuthorizedError("some:key", "not granted"))), vector.messagePackHex);
assert.deepEqual(cultNetWriteNotAuthorizedError("some:key", "not granted"), vector.message);
});
test("a write_not_authorized refusal with no reason, or a blank one, says only the code", () => {
assert.equal(cultNetWriteNotAuthorizedError("k").error, "write_not_authorized");
assert.equal(cultNetWriteNotAuthorizedError("k", " ").error, "write_not_authorized");
assert.equal(cultNetWriteNotAuthorizedError("k", null).error, "write_not_authorized");
// C# string.IsNullOrWhiteSpace counts U+0085 as white space and not U+FEFF; JS trim() disagrees on both.
assert.equal(cultNetWriteNotAuthorizedError("k", "\u0085").error, "write_not_authorized");
assert.equal(cultNetWriteNotAuthorizedError("k", "\ufeff").error, "write_not_authorized: \ufeff");
});

View file

@ -3078,3 +3078,15 @@ test("document put message omits unset optional keys so the encoded wire has no
assert.equal(stamped.document.sourceRuntimeId, "r");
assert.deepEqual(stamped.document.tags, ["t"]);
});
test("CultNet registry names the canonical schema id of a compatible alias, and none for an unknown or conflicted id", () => {
const shape = z.object({ body: z.string() });
const owner = defineDocumentType({ type: "p3.owner", schemaId: "p3.owner.v2", compatibleSchemaIds: ["p3.owner.v1"], schema: shape });
const first = defineDocumentType({ type: "p3.first", schemaId: "p3.first", compatibleSchemaIds: ["p3.shared"], schema: shape });
const second = defineDocumentType({ type: "p3.second", schemaId: "p3.second", compatibleSchemaIds: ["p3.shared"], schema: shape });
const registry = new CultNetDocumentRegistry([owner, first, second].map(definition => defineCultNetDocumentBinding({ definition })));
assert.equal(registry.canonicalSchemaId("p3.owner.v1"), "p3.owner.v2");
assert.equal(registry.canonicalSchemaId("p3.owner.v2"), "p3.owner.v2");
assert.equal(registry.canonicalSchemaId("p3.nobody"), undefined);
assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none");
});