diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 3e8ad1fa..64ee5a3b 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -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. diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 9ec805ff..bc6a7b72 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -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 | null; reason: string; diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 7857e72d..94199d02 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -18,6 +18,7 @@ export { type CultNetRawPayloadEncoding, CULTNET_ERROR_CODES, cultNetTextError, + cultNetWriteNotAuthorizedError, type CultNetErrorCode, type CultNetErrorDetails, type CultNetErrorMessage, diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 9676d0f0..32ee4163 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -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, diff --git a/packages/cultnet-ts/test/cultnet-error.test.ts b/packages/cultnet-ts/test/cultnet-error.test.ts index 42ef0753..e234f105 100644 --- a/packages/cultnet-ts/test/cultnet-error.test.ts +++ b/packages/cultnet-ts/test/cultnet-error.test.ts @@ -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"); +}); diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 1bcfca0d..37d963a8 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -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"); +});