cultnet-ts: typed write_not_authorized factory and canonical schema id
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled

cultNetWriteNotAuthorizedError(recordKey, reason?) writes the C# ForRemoteWriteRefused
bytes (pinned to error-vectors.json). CultNetDocumentRegistry.canonicalSchemaId resolves
the id a put or delete writes under, from the id alone, for a server's write authority.
No behaviour change for existing callers; the registry already had a delete apply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-07 19:35:21 +00:00
commit d0c0156874
6 changed files with 55 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

@ -204,6 +204,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: reason && reason.trim() ? `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,15 @@ 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");
});

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");
});