CultLib/packages/cultnet-ts/README.md

317 lines
12 KiB
Markdown

# CultNet TS
`cultnet-ts` is the TypeScript port of the useful shape of GameCult's
networking layer: typed MessagePack messages, direct-pipe framing, CultLib-style
security/session helpers, and state replication that hands typed values into a
`CultCache` instead of making consumer code paw raw database envelopes.
This is not an HTTP wrapper wearing a fake mustache. It is for runtimes that
already trust a direct channel and want a compact shared contract over it.
## Scope
The library currently includes:
- JSON-schema-backed wire contracts for the first CultNet message set
- a MessagePack codec plus 4-byte length-prefixed framing for direct pipes
- a `CultNetPeer` wrapper over any Node `Duplex`
- CultLib-style client/server security options and `Secret` helpers
- session-token signing/validation compatible with the C# CultLib semantics
- explicit wire-contract selection instead of heuristic payload guessing
- `CultCacheTS` replication helpers for document put/delete/snapshot flows
- schema discovery catalog messages and a registry for asking what contracts a
peer can actually exchange safely
- RUDP socket transport, reconnect policy helpers, and
`CultNetRudpReconnectLoop` for controller-owned retry scheduling around a
caller-owned socket factory
- generated TypeScript contracts and runtime verifiers from canonical JSON
schemas owned elsewhere in the swarm
- an exact mirrored runtime validator and generated TS contract surface for
Ghostlight's canonical `ghostlight.agent_state.v0` payload
## Why This Exists
We want:
- one shared agent-state vocabulary across `Ghostlight`, `EpiphanyAgent`,
`VoidBot`, and whatever other little laboratory creature appears next
- typed persistence through `CultCache`
- direct pipes instead of every consumer inventing bespoke JSON fog
- authentication and session semantics that match the original CultLib intent
So the split is:
- `cultcache-ts`: typed local persistence
- `cultnet-ts`: typed transport, auth, and state replication
Same family. Different organs. Nobody needs a second spine growing out of the
transport layer.
## Contracts
Wire contracts live under `contracts/` as JSON Schema files.
Current message families:
- `cultnet.hello.v0`
- `cultnet.login.v0`
- `cultnet.register.v0`
- `cultnet.verify.v0`
- `cultnet.login_success.v0`
- `cultnet.error.v0`
- `cultnet.document_put.v0`
- `cultnet.document_delete.v0`
- `cultnet.document_put_raw.v0`
- `cultnet.snapshot_request.v0`
- `cultnet.snapshot_response.v0`
- `cultnet.snapshot_response_raw.v0`
- `cultnet.sample.change_name.v0`
- `cultnet.sample.chat.v0`
For shared agent state, the first payload contract is the existing Ghostlight
fixture format:
- payload schema version: `ghostlight.agent_state.v0`
- mirrored validator: `contracts/ghostlight.agent-state.schema.json`
That means `EpiphanyAgent` role dossiers and Ghostlight fixtures can travel over
CultNet without inventing a second ontology first.
## Generated Swarm Contracts
`cultnet-ts` can consume canonical JSON Schema files and generate:
- TypeScript contract classes/interfaces
- runtime schema verifiers
- parse-compatible schema objects that can be handed directly to
`CultCacheTS`
The manifest lives at [contracts/swarm-contracts.manifest.json](contracts/swarm-contracts.manifest.json).
Right now it points at Ghostlight's canonical agent-state schema, because
Ghostlight owns the swarm's most important mind-shape contract.
Generate the contract layer with:
```bash
npm run contracts:generate
```
That emits [src/generated/swarm-contracts.generated.ts](src/generated/swarm-contracts.generated.ts),
which exports:
- the generated TS contract shape
- named runtime verifier helpers such as
`validateGhostlightAgentStateGenerated(...)` and
`parseGhostlightAgentStateGenerated(...)`
- a runtime `validate(...)` / `parse(...)` contract object via
`defineJsonSchemaContract(...)`
- a contract object that `CultCacheTS` can persist directly without a second
hand-written Zod mirror
## Wire Contracts
`cultnet-ts` does not sniff random payloads and pretend that counts as rigor.
You choose the contract explicitly.
Current wire-contract options:
- `cultnet.schema.v0`
- the newer schema-versioned object contract for CultNet-native message
families such as hello, document replication, and snapshots
- `gamecult.networking.v0`
- the legacy C# `GameCult.Networking` MessagePack union contract for
login/register/verify/session/error plus the sample `ChangeNameMessage` and
`ChatMessage` payloads
So the transport can stay developer-friendly while the binary stops freelancing
away from the C# lineage.
## Content
`cultnet-ts/content` owns the TypeScript shapes of the CultMesh CDN
(`src/content.ts`, the twin of `CultMeshCdn.cs`): the artifact manifest and chunk
reference, their MessagePack form, hash normalization, both record-key grammars,
SHA-256 hashing through WebCrypto, and `packContent`. It is browser-safe: it
imports no `node:` module, so a browser bundle can take it.
- A manifest names its chunks by hash and carries no endpoint. Where the bytes
are fetched from is the caller's to resolve through CultMesh.
- Metadata is an ordered list of `[key, value]` entries, never an object: a JS
object reorders integer-like keys, and the entry order is the wire bytes.
Duplicate keys, integers a JS number cannot hold exactly, and payloads nested
past the shared depth limit are refused.
- The decoder reads what MessagePack-CSharp reads and refuses what it refuses:
nil in a string, tag or metadata slot is `null` and is written back as nil,
a missing trailing slot takes the reference's default, extra slots and trailing
bytes are skipped (a chunk reference's fifth slot is its element id, a string
or nil, `""` when absent; `packContent` writes the decimal offset as the id, as the
reference does), a float-encoded integer is refused, and invalid UTF-8
decodes to U+FFFD. A nil `chunks` is refused, as the reference's
`ValidateManifest` refuses it. `content-acceptance-vectors.json` is the
reference's own verdict on each of those shapes.
- Bytes and keys are held to files the C# reference writes
(`contracts/cultmesh/content-vectors.cs-written.json`,
`cdn-record-key-vectors.json`, `content-acceptance-vectors.json`) and to `content-vectors.rs-written.json`.
The TypeScript-written `content-vectors.ts-written.json` is judged by the C#
reference. Regenerate it with `npm run build --workspace packages/cultnet-ts`
and `node scripts/write-cultmesh-content-vectors.mjs`; never edit it by hand.
## Local Fast Lane
For direct-pipe neighbors that already share the same CultCache formatter
contract, `cultnet-ts` now has a raw replication lane:
- `cultnet.document_put_raw.v0`
- `cultnet.snapshot_response_raw.v0`
Those messages carry the canonical persisted MessagePack payload bytes plus the
typed envelope metadata. Paired with `CultCacheTS.putEnvelope(...)`, that means
the receiving cache can ingest the exact payload bytes directly instead of
decoding them into generic object soup and then re-encoding the same document
again.
This is cheaper. It is not magic:
- framing still copies
- MessagePack decoding still happens once on ingest
- ordinary `CultNetPeer` send/receive still materializes messages
But for local or near-local runtimes sharing the same contract, the fast lane
cuts out the dumbest part of the trip.
## Schema Discovery
Apps sharing a CultNet space should be able to ask each other what they can
exchange without resorting to folklore.
`cultnet-ts` now includes:
- `cultnet.schema_catalog_request.v0`
- `cultnet.schema_catalog_response.v0`
- `CultNetSchemaRegistry`
- `CultNetSchemaCatalog`
- `cultNetBuiltinSchemaRegistry`
Descriptors include:
- canonical `schemaId`
- discovery `kind`
- optional `schemaVersion`
- optional `documentType`
- supported `wireContracts`
- a SHA-256 `contentHash`
- optional inline `schemaJson`
The inline schema body travels as canonical JSON text, not as a loose object
graph. Discovery is supposed to reduce ambiguity, not smuggle in a cute new
polymorphic attack surface.
`CultNetSchemaRegistry` owns local schema definitions and can answer catalog
requests. `CultNetSchemaCatalog` owns remote descriptors and applies catalog
responses. `CultNetPeer.requestSchemaCatalog(...)`,
`fetchSchemaDescriptors(...)`, and `syncSchemaCatalog(...)` perform the common
message-id-matched request/response cycle so callers do not need to hand-roll
listener cleanup around every discovery exchange.
Shard discovery follows the same split: `CultNetShardCatalog` owns imported
`cultnet.shard_catalog_response.v0` descriptors, filters by schema id and
record key, and can answer `cultnet.shard_catalog_request.v0`. `CultNetPeer`
also exposes `requestShardCatalog(...)`, `fetchShardDescriptors(...)`, and
`syncShardCatalog(...)` for the same message-id-matched exchange. The built-in
schema registry advertises the shard catalog request/response schemas as
schema-v0 wire messages, so callers can discover the contracts before using
the helper path.
## Quick Example
```ts
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
import {
CultNetDocumentRegistry,
createTcpFramedCultNetPeer,
defineCultNetDocumentBinding,
ghostlightAgentStateGeneratedContract,
} from "cultnet-ts";
const ghostlightStateDefinition = defineDocumentType({
type: "ghostlight.agent-state",
schema: ghostlightAgentStateGeneratedContract,
});
const registry = new CultNetDocumentRegistry([
defineCultNetDocumentBinding({
definition: ghostlightStateDefinition,
payloadSchemaVersion: "ghostlight.agent_state.v0",
}),
]);
const peer = createTcpFramedCultNetPeer(stream, {
runtimeId: "node-worker",
wireContract: "cultnet.schema.v0",
transportId: "worker-tcp",
});
```
## Security Model
The security helpers intentionally mirror CultLib's shape:
- shared connection key -> SHA-256 derived AES-GCM key
- server-only session signing secret -> HMAC-SHA256 session token signing
- 12-byte nonce
- authentication tag prefixed onto ciphertext
- session token format: `base64url(payload).base64url(signature)`
- versioned C#/Python-compatible session token payloads:
`userGuidN|expiresUnix|sessionVersion`, while legacy two-field tokens still
validate with session version `0`
That means a TypeScript runtime can follow the same cryptographic and session
story as the C# version instead of inventing one more house religion around
"trust me, this pipe is probably fine."
For the legacy `gamecult.networking.v0` contract, `cultnet-ts` normalizes the
encrypted binary fields as base64url strings in the ergonomic TS surface and
maps them back to the exact C# union payload shape at the wire boundary.
## Current Limits
- This repo currently implements the client-side wire contract and pipe
ergonomics, not a full account server.
- The Ghostlight agent-state mirror is an exact copy of Ghostlight's current
canonical schema. If Ghostlight changes the contract, this mirror must be
updated in lockstep instead of playing coy with partial validation.
- The preferred direct transport path is `CultNetTransportConnection`.
`createTcpFramedCultNetPeer(...)` wraps a Node `Duplex` in the shared
`tcp_framed` profile before schema messages reach `CultNetPeer`. Passing a
raw `Duplex` to `CultNetPeer` remains as compatibility for older callers.
- The interop peer still keeps the TCP-framed lane for compatibility, but its
serve and dial commands also exercise schema-v0 over the shared RUDP socket
transport and advertise both transport profiles.
## Development
```bash
npm install
npm run contracts:generate
npm run build
npm run typecheck
npm run test
```
## Interop CI
The cross-runtime interop harness is meant to run in GitHub Actions on
`windows-latest`.
It expects this CultLib checkout with the in-tree TypeScript, Rust, Python,
C#, and Kotlin packages present. The workflow installs the toolchains, builds
the Kotlin interop jar through `packages/cultmesh-kotlin/build.ps1`, and then
lets the TypeScript harness build the Rust and C# interop peers it needs.
The workflow lives at
[.github/workflows/cultnet-interop.yml](.github/workflows/cultnet-interop.yml)
and drives the whole ritual through one command:
```bash
npm run test:interop
```