Compare commits
213 changed files with 787 additions and 43499 deletions
83
.github/workflows/cultmesh-portability.yml
vendored
83
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -8,13 +8,46 @@ on:
|
|||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
|
||||
# workflow_dispatch runs are never cancelled: a tag run carries a release.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
|
||||
|
||||
jobs:
|
||||
getting-started:
|
||||
name: ${{ matrix.os }} one-command multiplayer onboarding
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR.
|
||||
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }}
|
||||
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: CultLib
|
||||
|
||||
- name: Check out the matching Eve contract
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: GameCult/Eve
|
||||
# Eve main is the contract this sample is written against; the codex
|
||||
# branch this used to pin was never merged and stopped on 2026-08-25.
|
||||
ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }}
|
||||
path: Eve
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run the documented one-command checkpoint
|
||||
shell: pwsh
|
||||
run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve
|
||||
|
||||
reactive-and-resource-lifetime:
|
||||
name: ${{ matrix.os }} reactive semantics and leases
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
@ -22,16 +55,12 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# The forge runner (label `ubuntu-latest`) is Linux-only and no Windows runner exists: the Windows leg runs on workflow_dispatch only (GitHub or a Windows runner), the ubuntu-latest leg gates push and PR. Windows push/PR triggers return in the PR that registers a Windows runner.
|
||||
# The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR.
|
||||
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }}
|
||||
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The declared-dependency-range test builds the admitted releases from their tags.
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
|
@ -55,34 +84,6 @@ jobs:
|
|||
- name: Install Python CultLib packages
|
||||
run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
|
||||
# The forge runner images carry no cargo either (the cross-runtime store tests build the Rust interop example); GitHub's hosted images do.
|
||||
- name: Install Rust toolchain
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v cargo >/dev/null && [ ! -x "$HOME/.cargo/bin/cargo" ]; then
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
fi
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The forge runner images carry no pwsh; GitHub's Windows and hosted images do.
|
||||
- name: Install PowerShell
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v pwsh >/dev/null; then pwsh -Version; exit 0; fi
|
||||
SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
PWSH_VERSION=7.4.6
|
||||
curl -fsSL "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-x64.tar.gz" -o "${RUNNER_TEMP:-/tmp}/powershell.tar.gz"
|
||||
$SUDO mkdir -p /opt/microsoft/powershell/7
|
||||
$SUDO tar -xzf "${RUNNER_TEMP:-/tmp}/powershell.tar.gz" -C /opt/microsoft/powershell/7
|
||||
$SUDO chmod +x /opt/microsoft/powershell/7/pwsh
|
||||
$SUDO ln -sf /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh
|
||||
echo "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1" >> "$GITHUB_ENV"
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 pwsh -Version
|
||||
|
||||
- name: Verify explicit reactive scheduling across runtimes
|
||||
shell: pwsh
|
||||
run: ./scripts/verify-reactive-document-scaling.ps1
|
||||
|
|
@ -98,8 +99,8 @@ jobs:
|
|||
--logger "console;verbosity=minimal"
|
||||
|
||||
# The TypeScript side of the CultMesh authority rules. This job is the
|
||||
# only one that runs on Linux for main, codex/** and pull requests (and on
|
||||
# Windows too under workflow_dispatch): cultnet-interop.yml is windows-latest and dispatch-only, so
|
||||
# only one that runs on both Windows and Linux for main, codex/** and
|
||||
# pull requests: cultnet-interop.yml is windows-latest and main-only, so
|
||||
# until these steps existed the shared verifier's tests never ran on
|
||||
# Linux at all, and the browser client's never ran in CI on any OS.
|
||||
# packages/cultnet-ts verifies both vector files (content.test.ts judges both
|
||||
|
|
|
|||
2
.github/workflows/cultnet-interop.yml
vendored
2
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
name: CultNet cross-runtime interop
|
||||
|
||||
# workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge
|
||||
# runner is Ubuntu-only (no Windows runner exists; push and pull_request return in the PR that registers one), so a push or pull_request trigger would queue a job that can never run.
|
||||
# runner is Ubuntu-only, so a push or pull_request trigger would queue a job that can never run.
|
||||
# Run it from GitHub or any Windows runner.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
21
.github/workflows/publish-packages.yml
vendored
21
.github/workflows/publish-packages.yml
vendored
|
|
@ -8,26 +8,15 @@ name: Publish packages
|
|||
# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
|
||||
# consume the core stack.
|
||||
#
|
||||
# Tag a Python release to publish, for example `cultnet-py-v0.1.0`. The tag's
|
||||
# version must match the version in the package manifest;
|
||||
# scripts/release-after-suite.mjs refuses the release otherwise rather than
|
||||
# shipping a mislabelled artifact.
|
||||
#
|
||||
# npm publishing of @gamecult/cultcache-ts is deliberately off: `cultcache-ts-v*`
|
||||
# is not a tag trigger until it is ruled on (the cultcache-ts job below runs from
|
||||
# workflow_dispatch only, which publishes nothing). A tag run uses the workflow
|
||||
# file at the tagged commit, not the one on main, so a tag on an older commit
|
||||
# (`cultcache-ts-v0.15.0` sits on 7d292085, before the guard) still runs that
|
||||
# commit's triggers and jobs. Read this file at the tagged sha before pushing a tag.
|
||||
# Tag a release to publish, for example `cultcache-ts-v0.13.6` or
|
||||
# `cultnet-py-v0.1.0`. The tag's version must match the version in the package
|
||||
# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather
|
||||
# than shipping a mislabelled artifact.
|
||||
|
||||
on:
|
||||
push:
|
||||
# cultcache-ts-v* is deliberately not a trigger: publishing @gamecult/cultcache-ts is disabled
|
||||
# until it is ruled on, and whether NPM_TOKEN is set is unknown, so a pushed tag must not reach
|
||||
# `npm publish`. The cultcache-ts jobs below still run from workflow_dispatch, which publishes nothing.
|
||||
# (The comment sits above `tags:` because the tag-prefix pin in scripts/check-changelog-semver.test.mjs
|
||||
# reads the list as `tags:` followed directly by its items.)
|
||||
tags:
|
||||
- "cultcache-ts-v*"
|
||||
- "cultcache-py-v*"
|
||||
- "cultnet-py-v*"
|
||||
- "cultmesh-py-v*"
|
||||
|
|
|
|||
17
.github/workflows/quic-native.yml
vendored
17
.github/workflows/quic-native.yml
vendored
|
|
@ -18,12 +18,6 @@ on:
|
|||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
|
||||
# workflow_dispatch runs are never cancelled: a tag run carries a release.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
|
@ -55,12 +49,11 @@ jobs:
|
|||
|
||||
# workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds.
|
||||
# A dry (workflow_dispatch) run uploads artifacts and creates no release.
|
||||
# No Windows runner is registered on the forge, so this job is workflow_dispatch only
|
||||
# (run it on GitHub or a Windows runner). The tag trigger returns in the PR that registers one.
|
||||
# Until then a tag push skips the release job, which needs this build.
|
||||
build-win32-x64:
|
||||
name: Build win32-x64
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
|
|
@ -99,8 +92,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
# Q6 A: this pinned Debian 13 container is the only permitted Linux
|
||||
# release builder. The digest is the pin; the `debian:13` tag is only a
|
||||
# label. The artifact links against this image's glibc (2.41);
|
||||
# a bare runner's OS image has its own glibc (a GitHub ubuntu-latest image carried 2.39), which is not the deploy host's
|
||||
# label. The artifact links against this image's glibc (2.41); a bare
|
||||
# ubuntu-latest runner carries glibc 2.39, which is not the deploy host's
|
||||
# OS (docs/typescript-quic-realtime-cut.md:2192-2197).
|
||||
container:
|
||||
image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
|
||||
|
|
|
|||
17
CultLib.sln
17
CultLib.sln
|
|
@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSock
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}"
|
||||
|
|
@ -227,6 +231,18 @@ Global
|
|||
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
|
@ -306,6 +322,7 @@ Global
|
|||
{21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
|
||||
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
|
||||
{B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
|
||||
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,7 @@ start with the maintained
|
|||
series. Its first executable package-consumer checkpoint is
|
||||
[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
|
||||
The real-process browser continuation is
|
||||
[`samples/eve-browser-network`](samples/eve-browser-network/README.md); it
|
||||
needs a sibling Eve checkout (`--eve-root`) and is not in `CultLib.sln`.
|
||||
[`samples/eve-browser-network`](samples/eve-browser-network/README.md).
|
||||
One documented command runs both layers:
|
||||
|
||||
```powershell
|
||||
|
|
|
|||
|
|
@ -112,12 +112,8 @@ not guess. Adding a code is therefore a coordinated change across every runtime,
|
|||
copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and a captured-bytes case in
|
||||
`packages/cultnet-rs/tests/error_message.rs` for it. TypeScript reads the schema. The current codes are
|
||||
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`,
|
||||
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
|
||||
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
|
||||
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
|
||||
`limit_reached` (a receipt-capable server's per-session remembered-write table is full, so a new write is refused; `details` all null) and
|
||||
`message_invalid` (a request that lacks a required field or carries an ill-typed one: a document-put or delete, or a subscription without an id; `details.field` names the request field, or `message` when the whole frame cannot be read).
|
||||
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.
|
||||
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns) and
|
||||
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant).
|
||||
|
||||
## Live Invariants
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
|
|
@ -36,8 +36,8 @@
|
|||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
|
||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\".", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,195 +0,0 @@
|
|||
# CultNet fork-desk contract v0 (specification)
|
||||
|
||||
Status: specification, drafted for Heimdall (Bifrost receiver), Huginn (the second door) and Void (Ratatoskr desk). No runtime implements it yet.
|
||||
Rule source: `/opt/commons/handoff/fork-desk-answer-binding-20261110.md` (Heimdall; sections 1.1 to 1.5, 1.3a to 1.3d), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Where this page differs from that file it says so under "Differences from the binding file".
|
||||
Format source: the fork desk artifact in `/opt/commons/handoff/fork-desk-original/` (ruling `eureka-substrate:ruling:fork-desk-nibu-format`).
|
||||
Files here: `fork-card.schema.json` (one schema, a `$defs` entry per document), `fork-card-vectors.json`. Conformance: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript), both generated against vectors written by `scripts/gen-governance-vectors.py`. Each suite computes every hash with its own code and replays every scenario through a reference judge written from this page.
|
||||
|
||||
All documents are CultNet map-mode: every key written, nil where absent, closed schemas. Field names are snake_case as in the binding file and the mind's records; `schemaVersion` keeps CultNet's spelling (open choice 1). The bytes of every document are pinned in the vectors as `messagePackHex`.
|
||||
|
||||
## Documents
|
||||
|
||||
| schema id | what | who writes it |
|
||||
|---|---|---|
|
||||
| `gamecult.fork.card.v0` | the fork as shown | the publisher (a Mind's card projection) |
|
||||
| `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door |
|
||||
| `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only |
|
||||
| `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` |
|
||||
| `gamecult.fork.refusal.v0` | a body refused at the door; nothing stored | Bifrost |
|
||||
| `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s |
|
||||
| `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling |
|
||||
| `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk |
|
||||
| `gamecult.fork.list.v0` | a page of entries, counts and a stamp | Bifrost, projected from Huginn |
|
||||
| `gamecult.fork.read_request.v0` | read one fork (Transport) | the desk |
|
||||
| `gamecult.fork.entry.v0` | one card with its record | Bifrost, projected from Huginn |
|
||||
| `gamecult.fork.unavailable.v0` | the outcome is not confirmed (the second door or Huginn did not answer); retry with the same `request_id` | Bifrost |
|
||||
|
||||
### card
|
||||
|
||||
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, deciders[], status, ruling, ruled_from[], order, options_hash}`.
|
||||
|
||||
- `fork_id`: the mind's `question` key, `<campaign>:question:<label>`.
|
||||
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
|
||||
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `deciders`, `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
|
||||
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The list holds one to 64 ids** (Huginn's bound), on the card and in the per-call list alike (vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
|
||||
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members.
|
||||
|
||||
### Options hash (canonical bytes)
|
||||
|
||||
`options_hash` = lowercase hex SHA-256 of the UTF-8 bytes of the following, byte for byte:
|
||||
|
||||
1. Build an object with exactly the five members `title`, `context`, `options`, `recommendation`, `depends`. Each option is an object with exactly `id` and `text`.
|
||||
2. **NFC-normalise every string, object keys included.** No trimming, no newline rewriting, no case folding. A string with a lone surrogate is not a card (it has no UTF-8). Normalisation stability makes NFC stable across Unicode versions for already-assigned code points; a runtime's own normaliser is trusted and the vectors include a decomposed pair and astral code points (open choice 2).
|
||||
3. Serialise as RFC 8785 (JCS): no whitespace; object keys sorted by UTF-16 code unit, which for these ASCII keys is `context, depends, options, recommendation, title` and, inside an option, `id, text`; array order is as shown (option order and `depends` order are part of the hash).
|
||||
4. Strings are written in double quotes; only `"` and `\` are backslash-escaped, U+0008, U+0009, U+000A, U+000C, U+000D as `\b \t \n \f \r`, every other code point below U+0020 as `\u00xx` in lowercase hex; everything else is written raw as UTF-8 (U+007F, U+2028, `<`, `&` and astral code points included).
|
||||
5. **Numbers and nulls:** the member set contains none. There is no number to canonicalise (a number anywhere in the five members is a defect) and `recommendation` is never nil, so no nil is hashed.
|
||||
|
||||
`fork_id` is not in the hash and is bound beside it in its own field, so one hash cannot be replayed under another fork. The publisher computes the hash and stores it in the card; the client recomputes it from what it rendered and refuses to send on a mismatch; the receiver recomputes from its stored fork, never from the client.
|
||||
|
||||
Vectors `optionsHash`: the seven of the binding file (`heimdall_A..G`), computed by the Python generator, by the C# suite and by the TypeScript suite independently, plus text, title, context and `depends`-order mutations, a case showing the unshown members do not move the hash, escapes and markup, and astral ids. Each suite also mutates every shown member, an option id included, and asserts the hash changes.
|
||||
|
||||
### answer_request
|
||||
|
||||
`{schemaVersion, fork_id, options_hash, answer, retracted, retracts, answered_at, request_id}`. **`request_id`** is set by the client, a lowercase D-format UUID, one per intended answer, kept across retries; the first stored answer that carries it binds it to the stamped `answerer`. "Same body" means every field equal, `answered_at` included, so a client resends identical bytes. A **retry with the same `request_id` and the same body returns the stored outcome unchanged** (a `Refused` one included; a replayed `stale_fork` carries the **current** card, because a stored `desk_answer` has no card field and no storage is added for one) and **never adds a Revision**; the same `request_id` with a different body is `request_id_conflict` (409, a refusal, nothing stored). The same id from another account is another request, judged on its own: Y reusing X's `request_id` with a body identical to X's gets **Y's own new answer, with its own answer id, stamped Y**; X's stored outcome (a `Refused` one included) is never returned in its place, and Y's request neither conflicts with it nor replays it. An `of` that names the counted answer is allowed, because answer ids are not secret between deciders (vectors V14: an off-list X's `Refused`, then a decider Y's identical body is its own `Counted`; X's `Counted`, then Y's identical body is its own `Revision` whose `of` names X's answer, and X's later retry still returns X's answer). Both suites assert it on every scenario: a response's `answerer` is the step's account, and an id first bound by another account never yields that account's answer id or document. **`retracted` is part of the body**: the same `request_id` with `retracted: true` and then `false` (or the reverse) and every other field equal is `request_id_conflict`, for the illegal forms as for any other (vectors `R1_retracted_true_then_false_...`, `R1_retracted_false_then_true_...`). Each illegal form's own retry, byte for byte, replays its stored `bad_retraction` (vectors `R1_a_retraction_with_an_answer_...`, `R1_a_retraction_with_no_target_...`, `R1_a_plain_answer_with_a_target_...`). A body refused at step 1 or 2 binds nothing, so its id is free to reuse; so a target-less retraction resent with `retracted: false` (no answer, no retraction) is `body_invalid` at step 2, not a conflict. The lookup comes after steps 1 and 2 and before the fork is looked up, so a retry gets its stored outcome even if the card has since changed. `answer` is `{choice, note}` (at least one non-nil) or nil on a retraction. `choice` is an option id; `note` is the person's words, verbatim, 1 to 4000 **UTF-8 bytes**, not blank. A schema counts characters, not bytes, so its `maxLength: 4000` (code points) is only the looser bound and both suites enforce the byte limit as a rule: 4000 ASCII bytes pass; 1000 astral characters (4000 bytes) pass and 1001 (4004) fail; `e` plus a combining acute is 3 bytes, so 1333 of them and an `x` pass and 1334 fail; 1999 two-byte characters plus three ASCII (4001 bytes) fail. A note over the limit is `body_invalid`. `answered_at` is the client's claim. **A request never carries `answerer` or `authority`**: both are stamped by Huginn (`answerer` from the verified subject the receiver passes; `authority` from the door's configuration). A body that names either key at any depth is refused whole, never stripped (`body_forbidden_field`); any other unknown key is `body_unknown_field`. The request type Bifrost sends over the second door is this document; it has no field for either key, so a client cannot supply them by construction, and Bifrost's scan is the first line, not the only one. How Huginn receives the verified subject is the door's authenticated context, not a body field (open choice 3). In map-mode `retracted` is always written (the binding file's optional `retracted` becomes `false`).
|
||||
|
||||
### desk_answer (the stored kind)
|
||||
|
||||
`{schemaVersion, answer_id, fork_id, outcome, of, retracts, code, options_hash, choice, note, answered_at, received_at, claim_in_future, answerer, authority, request_id}`, key `<campaign>:desk_answer:<question-label>.n<seq>`; `answer_id` is `n<seq>`, assigned by Huginn per question, and **an answer is named by its answer id** everywhere (`retracts`, `of`, `ruled_from`).
|
||||
|
||||
The stamped `authority` field of a stored answer is an **attribution**: the role the verified account holds (`Operator`, `Steward`, `Member`), copied from Bifrost's state. A stored answer confers no authority. Authority exists only where a ruling cites a counted answer ("Operator authority" below); the field keeps the binding file's spelling (open choice 8). One tagged `outcome`, each variant carrying only its own link:
|
||||
|
||||
- `Counted`: the answer that counts. `of`, `retracts`, `code` nil.
|
||||
- `Revision`: a later passing answer, kept, not counted while the first stands. `of` names the counted answer at the time.
|
||||
- `Retraction`: the stored form of `retracted: true` plus `retracts`. `retracts` names the answer retracted; `choice` and `note` nil. The illegal pair (a retraction with no target, or a target with no retraction) cannot be written.
|
||||
- `Refused`: any refusal from steps 4 to 7 or R1 and R2, with its `code`, stored too. `choice` and `note` keep what the body carried, and so does `retracts`: a refused body that named a target (a retraction of any refusal code, or a plain answer carrying one) projects it as `n<seq>`, never nil; a refused body that named none has `retracts` nil. `of` is nil.
|
||||
|
||||
`received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint.
|
||||
|
||||
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
|
||||
|
||||
**Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`.
|
||||
|
||||
### Counting rules (in order; the first failure refuses with that code)
|
||||
|
||||
| step | rule | refusal | stored |
|
||||
|---|---|---|---|
|
||||
| 1 | verified session or bearer | `unauthenticated` (401) | no |
|
||||
| 2 | closed body; no `answerer`/`authority` at any depth; type-correct; not (no `answer` and not a retraction) | `body_forbidden_field`, `body_unknown_field`, `body_invalid` (400) | no |
|
||||
| 3 | `fork_id` is a stored fork | `fork_unknown` (404) | no |
|
||||
| 4 | `options_hash` equals the stored fork's hash | `stale_fork` (409) | **yes**; the result carries the current card |
|
||||
| 5 | the stamped `answerer` is on `deciders` | `not_a_decider` (403) | yes |
|
||||
| 6 | the fork is open (`status: open`) | `fork_closed` (409) | yes |
|
||||
| 7 | `choice`, if present, is an option id | `choice_invalid` (400) | yes |
|
||||
| 8 | first passing answer for `(fork_id, options_hash)` is `Counted`; later passing answers are `Revision`s naming the counted one | | yes |
|
||||
| 9 | a Matrix edit, redaction or reaction changes nothing | | no event is an answer |
|
||||
|
||||
**Retraction** (`retracted: true`, `retracts: <answer id>`, `answer: nil`, `options_hash` still carried, so step 4 applies) passes steps 1 to 6, then:
|
||||
|
||||
- R1 form: it carries no `answer`, it names a `Counted` or `Revision` answer of this fork under this `options_hash`; else `bad_retraction` (400). A retraction naming an answer already retracted is `already_retracted` (409). `retracts` on a body that is not a retraction is `bad_retraction`.
|
||||
- R2 who: the original answerer or any other decider; step 5 already requires a decider.
|
||||
- R3 is dropped and its label stays vacant, so R4 keeps its name in the binding file. A ruling that answers a fork closes it (Eureka and Heimdall, 2026-11-10), so a ruling cannot cite an answer on an open fork (that is the stale-click gap, and a card with `ruled_from` set and `status: open` is invalid), and a retraction after a ruling meets step 6: refused `fork_closed`, stored. There is no `ruled` code (vector `V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed`).
|
||||
- R4 counted answer: retracting the counted answer removes it from the count and the next passing answer in admission order counts again. Retracting an answer that was not counted changes nothing. After every answer is retracted, a fresh answer is `Counted`. Records stay append-only.
|
||||
|
||||
**The counted answer is derived**, not stored: the first `Counted` or `Revision` answer under the card's current `options_hash` that no `Retraction` names. When the card changes, answers under the old hash stop counting and the next answer under the new hash is `Counted` (vectors `a_changed_card_...`). The `record` projection carries it as `counted_answer_id`.
|
||||
|
||||
### Operator authority
|
||||
|
||||
A ruling may carry `authority: Operator` only if it cites (`ruling_cite.answer_id`) a stored `desk_answer` whose outcome is `Counted` and whose stamped attribution (the answer's `authority` field) is `Operator` (or a stamped relay of her words from a mapped door, which this contract does not model). The citation is what gives a ruling authority: an attribution on an answer is a fact about the account, and authority exists only where a ruling cites a counted answer. The cited answer id is what `ruled_from` lists on the closed card. Negative vectors (`rulingCites`): no citation, a `Revision`, a `Refused` answer, a Counted answer by a non-operator, an answer that does not exist. Other authorities need no citation.
|
||||
|
||||
## Transport
|
||||
|
||||
Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-spec-20261110.md`) and Heimdall's binding file, `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, section 1.3c, which is the source for the second door. **Everything in this section is spec text for Bifrost to build; no route, scope or audience below exists in Bifrost today.** The routes (with the `{mind}` segment), the scope names `desk.read` and `desk.answer`, the audience `bifrost-desk` and the auth order are Heimdall's rulings (his file, sections 4.1 to 4.3a); this page writes them as documents and vectors.
|
||||
|
||||
**Authorities.** Cards are projected **read-only from Huginn**: Bifrost never authors a card, a ruling or a status, and the desk never writes one. Bifrost verifies the bearer, resolves the actor from its `sub` (a `UserAccount.Id`), and for an answer forwards a typed `DeskAnswer` on the peer-checked second door to Huginn, then returns **Huginn's outcome unchanged**: the stored `desk_answer` in an `answer_result`, or its refusal. The wire of the second door is **pending**: Eureka's cut spec follows her record PR, so this page defines the documents the desk sees and not the bytes between Bifrost and Huginn (open choice 3).
|
||||
|
||||
| call | route (proposal) | request | response | token |
|
||||
|---|---|---|---|---|
|
||||
| 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` |
|
||||
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` |
|
||||
| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` |
|
||||
|
||||
**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`).
|
||||
|
||||
- **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened).
|
||||
- **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist.
|
||||
- **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's.
|
||||
- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome, and which forks a caller may answer is the decider list.
|
||||
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
|
||||
|
||||
Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled).
|
||||
|
||||
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document) and `request_id_conflict` are added to the door refusals.
|
||||
|
||||
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`** (the admission ordinal). `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
|
||||
|
||||
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
|
||||
|
||||
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
|
||||
|
||||
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid`; `listScenarios` (five scenarios over one Mind's five cards: paged open-only, recorded-only, all, `limit` 0, an empty Mind, each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
|
||||
|
||||
**Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service.
|
||||
|
||||
## Old format, the mind's record, and what maps to what
|
||||
|
||||
The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a shared store. The mind's `question` record is the source in the new design; the card is its projection.
|
||||
|
||||
| old desk | mind `question` record | card |
|
||||
|---|---|---|
|
||||
| `qid`, `id` | key `campaign:question:label` | `fork_id` |
|
||||
| `title` | `title` | `title` |
|
||||
| `body` | `question` (the long text) | `context` |
|
||||
| `depends`: a free string | `depends`: up to 8 prose lines (`huginn schema question`) | `depends`: the lines verbatim |
|
||||
| `options[{key, label, text}]` | `options[{label, text}]` | `options[{id, text}]` |
|
||||
| option `key` | option `label` (a slug, the id) | option `id` |
|
||||
| option `label` (a human title) | none | **dropped**: not stored, not hashed; shown text is `text` |
|
||||
| `rec` (a key) | `recommended` (a label) | `recommendation` (an id), never nil |
|
||||
| `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed |
|
||||
| `flag` (the provisional line) | none | `flag` (a string or nil), not hashed |
|
||||
| `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) |
|
||||
| `order` | none (admission ordinal) | `order` |
|
||||
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
|
||||
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
|
||||
| answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` |
|
||||
| `note`: her words verbatim | | `answer.note`, verbatim |
|
||||
| "Clear pick": `choice` back to null so the note stands alone | | an `answer` with `choice` nil and `note` set, as the first answer or as a `Revision` |
|
||||
| "Changing an answer is fine until it's admitted as a ruling" | | a later answer is a `Revision`; `Retraction` withdraws one; neither changes who counted first, and a ruling that answers the fork closes it, after which no answer or retraction is taken (`fork_closed`) |
|
||||
|
||||
Differences that need Void's choice: the old page **overwrites** one answer per question and the new store **appends**, so the booth cannot show "your answer" as one mutable field (it shows the latest answer by the person, plus its history); the old page needs no identity and the new one stamps `answerer` from a verified session; the old `label` has nowhere to live unless the card's `text` carries it.
|
||||
|
||||
## Transport note for Void: Matrix event versus how the mind carries questions
|
||||
|
||||
How a mind carries a question today: a typed `question` record in Huginn (`huginn view`, `huginn query`); the Persona relays it to the operator in chat, and the operator's reply comes back as chat text that Self reads and admits as a `ruling` in her words. Identity is whoever the chat attributes the message to; nothing binds an answer to the question text she saw.
|
||||
|
||||
Option A, **a Matrix event in the Mind's room with the answer as a reply.** Gains: it sits where people already are, needs no sign-in beyond Matrix, and the thread shows the exchange. Loses: the sender is an mxid, not a Bifrost account, so `answerer` and `authority` have no verified source; an edit or redaction changes the visible message but not the decision (binding rule 9), so the thread and the record can disagree; there is nowhere to carry `options_hash`, so a stale card cannot be refused; and a reply is free text, so `choice` is lost.
|
||||
|
||||
Option B, **Bifrost receives the answer and Huginn stores it** (the design in this page; Ratatoskr PR #24 also withdraws the Matrix carrier). Gains: a verified account, a bound hash, refusals that are stored and visible, one counted answer, retractions. Loses: the booth needs Bifrost sign-in, which does not reach chat yet (Bifrost PR #9 and #37 design it), so until then the desk is read-only; and the exchange is no longer in the room.
|
||||
|
||||
**Recommendation: B, with Matrix as presentation only.** The Mind may post a card as a listing and a link into the desk; no Matrix event is ever an answer (rule 9). Confidence: high on the rule (it is Heimdall's and Eureka's already), moderate on the interim, where read-only until sign-in is a real cost; if the operator needs to answer before sign-in reaches chat, the honest stopgap is the old path (chat text, Self admits a ruling in her words) rather than a Matrix reply the record cannot bind.
|
||||
|
||||
## Differences from the binding file
|
||||
|
||||
- Spelling: every field is as the binding file names it (`fork_id`, `options_hash`, `answered_at`, `retracted`, `retracts`, `answer.choice`, `answer.note`, `recommendation`, `depends`, option `id`), except that `schemaVersion` keeps CultNet's camelCase and `answer_id` replaces the earlier `receipt id` (Persona brief).
|
||||
- Map-mode writes every key: `retracted` is always present; nothing is "optional".
|
||||
- `Refused` stores every refusal from steps 4 to 7 and R1 and R2 (his section 1.3b); only steps 1 and 2 and `fork_unknown` are not stored. The binding file does not say whether `fork_unknown` is stored; it cannot be keyed without a question, so it is not (open choice 4).
|
||||
- The binding file's `choice_valid` has no named refusal code; this page names it `choice_invalid`. `fork_known` is `fork_unknown`. A type-invalid body is `body_invalid`.
|
||||
- `ruled` (his R3 and V10) is dropped: a ruling that answers a fork closes it, so the case is `fork_closed` (V4, V10a). His file drops it too.
|
||||
- Heimdall's section 1.3e (Q1 to Q6, V12 to V18) is the source for `request_id`; this page matches it, with two notes. Q1's open item is settled by his ruling: a replayed `stale_fork` returns the current card. In V14 a decider's identical body is a `Revision` whose `of` necessarily names the first account's counted answer id (the record shows it to every decider), so "never contains X's answer_id" holds for Y's answer id and document, not for `of` (Heimdall: `of` naming the counted answer is allowed); the vector with an off-list first account pins the case with a `Counted`, which has no `of`.
|
||||
- `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it.
|
||||
- Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid.
|
||||
- The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here.
|
||||
- Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs).
|
||||
|
||||
## Open choices
|
||||
|
||||
1. Case of field names: snake_case for the fork documents (as the binding file) beside camelCase for the vote documents (as Bifrost's serializer). One convention would be tidier.
|
||||
2. NFC across runtimes relies on each runtime's normaliser; a pinned table, as `write-receipt.md` does for letters, would remove the reliance. The risk is only for code points assigned after a runtime's Unicode version.
|
||||
3. How Huginn learns the verified subject: the second door's authenticated context (the peer credential names Bifrost the service, not the member), so the subject must travel beside the request. This contract has no envelope for it.
|
||||
4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`.
|
||||
5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale.
|
||||
6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves.
|
||||
7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the ordinal alone decides order.
|
||||
8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body.
|
||||
9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.)
|
||||
10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`).
|
||||
11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it.
|
||||
|
|
@ -1,986 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/fork-card.schema.json",
|
||||
"title": "CultNet fork-desk contract v0",
|
||||
"description": "A fork card, the answer request a decider sends, the stored desk_answer with its tagged outcome, the result and door refusals returned to the sender, the record a fork keeps, and the ruling citation. Map-mode: every key is written, nil where absent. A request never carries answerer or authority: Huginn stamps both. Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md. Spec: fork-card.md. Vectors: fork-card-vectors.json.",
|
||||
"$defs": {
|
||||
"hex64": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"instant": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"answerId": {
|
||||
"type": "string",
|
||||
"pattern": "^n[1-9][0-9]*$"
|
||||
},
|
||||
"option": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"ref": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kind",
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"title",
|
||||
"context",
|
||||
"options",
|
||||
"recommendation",
|
||||
"depends",
|
||||
"leans",
|
||||
"flag",
|
||||
"raised_in",
|
||||
"deciders",
|
||||
"status",
|
||||
"ruling",
|
||||
"ruled_from",
|
||||
"order",
|
||||
"options_hash"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.card.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"context": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/option"
|
||||
}
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"depends": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
}
|
||||
},
|
||||
"leans": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"flag": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"raised_in": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"deciders": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"maxItems": 64
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"open",
|
||||
"closed"
|
||||
]
|
||||
},
|
||||
"ruling": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ruled_from": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"words": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
]+$)[\\s\\S]+$",
|
||||
"description": "At most 4000 UTF-8 bytes, which a schema cannot say: maxLength here counts code points and is only the looser bound (a code point is at least one byte). The byte limit is a rule both suites enforce (fork-card.md, answer_request)."
|
||||
},
|
||||
"answerObject": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"note": {
|
||||
"$ref": "#/$defs/words"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"note": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"type": "null"
|
||||
},
|
||||
"note": {
|
||||
"$ref": "#/$defs/words"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"answerRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"options_hash",
|
||||
"answer",
|
||||
"retracted",
|
||||
"retracts",
|
||||
"answered_at",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.answer_request.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"answer": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerObject"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"retracted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"retracts": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerCounted": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Counted"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRevision": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Revision"
|
||||
},
|
||||
"of": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRetraction": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Retraction"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"type": "null"
|
||||
},
|
||||
"note": {
|
||||
"type": "null"
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRefused": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Refused"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"stale_fork",
|
||||
"not_a_decider",
|
||||
"fork_closed",
|
||||
"choice_invalid",
|
||||
"bad_retraction",
|
||||
"already_retracted"
|
||||
]
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswer": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerCounted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRevision"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRetraction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRefused"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answerResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer",
|
||||
"card",
|
||||
"counted_answer_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.answer_result.v0"
|
||||
},
|
||||
"answer": {
|
||||
"$ref": "#/$defs/deskAnswer"
|
||||
},
|
||||
"card": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/card"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"counted_answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.refusal.v0"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"unauthenticated",
|
||||
"body_forbidden_field",
|
||||
"body_unknown_field",
|
||||
"body_invalid",
|
||||
"fork_unknown",
|
||||
"insufficient_scope",
|
||||
"forbidden",
|
||||
"request_invalid",
|
||||
"request_id_conflict"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"options_hash",
|
||||
"counted_answer_id",
|
||||
"answers"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.record.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"counted_answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/deskAnswer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rulingCite": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"ruling_id",
|
||||
"authority",
|
||||
"answer_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.ruling_cite.v0"
|
||||
},
|
||||
"ruling_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"pattern": "^c[1-9][0-9]*$",
|
||||
"description": "Opaque to a client. c<N> means: the cards whose order is below N."
|
||||
},
|
||||
"stamp": {
|
||||
"type": "string",
|
||||
"pattern": "^s[0-9]+$",
|
||||
"description": "s<N>, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents. Two lists with the same stamp are the same state."
|
||||
},
|
||||
"listRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"status",
|
||||
"cursor",
|
||||
"limit"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.list_request.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"status": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"open",
|
||||
"recorded"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cursor": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/cursor"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 50
|
||||
}
|
||||
}
|
||||
},
|
||||
"readRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"fork_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.read_request.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"card",
|
||||
"record"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.entry.v0"
|
||||
},
|
||||
"card": {
|
||||
"$ref": "#/$defs/card"
|
||||
},
|
||||
"record": {
|
||||
"$ref": "#/$defs/record"
|
||||
}
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"stamp",
|
||||
"open_count",
|
||||
"recorded_count",
|
||||
"forks",
|
||||
"next_cursor"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.list.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"stamp": {
|
||||
"$ref": "#/$defs/stamp"
|
||||
},
|
||||
"open_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"recorded_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"forks": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/cursor"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailable": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error",
|
||||
"retryAfterSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.unavailable.v0"
|
||||
},
|
||||
"code": {
|
||||
"const": "unavailable"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "The outcome is NOT CONFIRMED: the request may have reached Huginn and been stored before the reply was lost. Retry with the same request_id to learn the outcome."
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
"description": "Set by the client, a lowercase D-format UUID. Bound to the stamped answerer by the first stored answer that carries it."
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,107 +0,0 @@
|
|||
# CultNet vote API v0 (specification)
|
||||
|
||||
Status: specification, drafted for Heimdall (Bifrost) and a Ratatoskr voting module. No server implements it yet.
|
||||
Files here: `vote-api.schema.json` (one schema, a `$defs` entry per document), `vote-api-vectors.json`. Conformance suites: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript). The vectors are written by `scripts/gen-governance-vectors.py`.
|
||||
Facts it rests on: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Bifrost `416f11a`; `416f11a..276f611` changes four files: a Standard-close concurrency fix in `MotionGovernanceService.cs`, two test files and `docs/deploy-main-to-live.md`; none touches the sealed API, so the facts hold at `276f611`) and Bifrost PR #37 (`docs/ratatoskr-voting-auth.md`, see "PR #37" below).
|
||||
|
||||
## Scope
|
||||
|
||||
**Which API covers which.** This contract covers **sealed-ballot motions only** (`gamecult.sealed_ballot.v0`). **Standard motions are not covered here.** Bifrost PR #37 also serves them (`PUT /api/votes/motions/{id}/ballot`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`), and that API is #37's own: this page defines no document for it, and a Standard motion id sent to any exchange below is answered `not_found`. Today Standard motions have no JSON read or cast API at all, no Mind path, and no receipt (open choice 1). The contract names four exchanges and one refusal document:
|
||||
|
||||
| exchange | request | response |
|
||||
|---|---|---|
|
||||
| motion list | `gamecult.vote.motion_list_request.v0` | `gamecult.vote.motion_list.v0` |
|
||||
| motion read | `gamecult.vote.motion_read_request.v0` | `gamecult.vote.motion.v0` |
|
||||
| cast (commit or reveal) | `gamecult.vote.cast.v0` | `gamecult.vote.receipt.v0` |
|
||||
| any refusal | | `gamecult.vote.refusal.v0` |
|
||||
|
||||
Every document is CultNet map-mode: all keys are written, nil where absent, and the schema is closed (`additionalProperties: false`). Instants are RFC 3339 UTC with a `Z` and up to seven fractional digits. Ids are lowercase `D`-format UUIDs. A motion states `highStakes`, a boolean fixed when the motion opens and never changed after (Bifrost PR #37, D2): it is the property that makes a commit demand a fresh sign-in (`reauthentication_required`). The bytes of every document are pinned by `messagePackHex` in the vectors (shortest integers, shortest strings, keys in the order the schema lists them); JSON with the same keys is the xenos-boundary form of the same documents (open choice 4: the wire format).
|
||||
|
||||
## Documents
|
||||
|
||||
- **motion_list_request**: `{schemaVersion, phase}`; `phase` is `Commit`, `Reveal`, `Closed` or nil for all.
|
||||
- **motion_list**: `{schemaVersion, motions: [{id, title, round, phase, commitClosesAtUtc, closesAtUtc, highStakes}]}`. **Heimdall must add this endpoint**: Bifrost has no list route for sealed motions today, only fetch-by-id. No pagination in v0 (open choice 2).
|
||||
- **motion_read_request**: `{schemaVersion, motionId}`.
|
||||
- **motion**: Bifrost's `SealedMotionState` with its names: `{schemaVersion, id, title, summary, round, priorMotionId, ballotSchema, phase, candidates (two or more), commitClosesAtUtc, closesAtUtc, highStakes, yourVoterId, yourCommitment (a receipt or nil), announcement}`. It never carries a tally, a turnout, or another voter's commitment or ballot (a `tally` key is a negative vector). `yourVoterId` is the voter's `UserAccount.Id`, the `voterUserAccountId` of the commitment.
|
||||
- **cast**: `{schemaVersion, motionId, verb, commitment, ballot}` with exactly one of two shapes: `verb: "commit"` with a 64-hex `commitment` and `ballot: nil`, or `verb: "reveal"` with `commitment: nil` and a `ballot`. One document for both verbs; today's two routes (`/commit`, `/reveal`) may stay, taking this document as the body, with a `verb` that must match the route (open choice 3).
|
||||
- **ballot** (inside a reveal): `{schema: "gamecult.sealed_ballot.v0", motionId, round, candidate, reason, salt, persona}`. `candidate` and `reason` are single-line and **already trimmed** (no Unicode White_Space at either end, using the White_Space list of `write-receipt.md`); the contract refuses an untrimmed value rather than trimming it, so the bytes hashed are the bytes sent. **This is a behaviour change Bifrost must make:** it trims `candidate` and `reason` silently on reveal today (facts file section 3c, `SealedBallotService.cs:154, 385-387`), so an untrimmed reveal is accepted and hashed trimmed; under this contract it is `invalid` (open choice 5). `reason` is 1 to 280 characters counted in UTF-16 code units, as Bifrost counts. A JSON Schema `maxLength` counts code points, so the schema alone accepts up to 280 astral characters; both suites add the rule (vectors `cast_reveal_reason_140_astral_280_units` valid, `ballot_reason_141_astral_282_units` refused). `salt` is 32 to 128 lowercase hex. `persona` is `""` for a member and a printable-ASCII, space-free id of at most 64 characters for a Mind.
|
||||
- **receipt**: Bifrost's `SealedBallotReceipt`: `{schemaVersion, motionId, voterUserAccountId, commitment, revision (>= 1), committedAtUtc, revealed}`. The receipt for a reveal has `revealed: true` and **echoes nothing of the ballot** (no candidate, reason or salt; negative vectors for each).
|
||||
- **refusal**: `{schemaVersion, code, error, retryAfterSeconds}`; the `code` set is closed:
|
||||
|
||||
| code | HTTP | trigger |
|
||||
|---|---|---|
|
||||
| `unauthenticated` | 401 | no bearer, or one Heimdall does not know (the API must answer this, not redirect to a sign-in page) |
|
||||
| `reauthentication_required` | 401 | a commit on a `highStakes` motion with no sign-in fresh enough (#37 D2: `prompt=login`, `max_age`); the voter signs in again at the top level and retries |
|
||||
| `forbidden` | 403 | authenticated but not an Active member, or a Mind key used on a route it may not call |
|
||||
| `not_found` | 404 | unknown motion id, a Standard motion id, or a reveal with no prior commit |
|
||||
| `wrong_phase` | 409 | commit outside Commit, reveal outside Reveal |
|
||||
| `contended` | 409 | a reveal raced the close fence: send it again |
|
||||
| `invalid` | 400 | the body fails the schema or an in-document rule (untrimmed text, candidate not in the motion, `round` or `motionId` mismatch, `persona` wrong for the credential) |
|
||||
| `commitment_mismatch` | 400 | a reveal whose canonical bytes do not hash to the stored commitment |
|
||||
| `payload_too_large` | 413 | body over 4096 bytes |
|
||||
| `rate_limited` | 429 | cast rate exceeded, per account; `retryAfterSeconds >= 1`, mirrored in `Retry-After` |
|
||||
| `held` | 503 | the Ratatoskr voting brake is set (#37 D5): nothing is read or stored while it is |
|
||||
|
||||
Each code has a vector (`refusals`), and every failure of the schema has a negative vector (`invalid`).
|
||||
|
||||
**Not on Bifrost's member API yet.** The **413 body cap of 4096 bytes** and the **429 per-account cast limit** are not on Bifrost's member sealed API today (facts file sections 3c and 5): both exist only on the Mind intake route (4096 bytes; 30 requests a minute per source address). #37 proposes 30 casts per 10 minutes per account for the member routes (its D6). `reauthentication_required` and `held` are #37 proposals (D2, D5) and exist nowhere in code. All four are spec text until Heimdall builds them.
|
||||
|
||||
**Two refusals Bifrost must split first.** Bifrost returns a reveal whose bytes do not hash to the stored commitment as a plain `400` `Invalid` ("The ballot does not match the stored commitment."), the same status and error type as every other bad body, so `commitment_mismatch` and `invalid` are one today. It returns a contended reveal (a race with the close fence, `SealedBallotService.cs:186-189`) as `WrongPhase` `409`, the same type as a reveal outside the Reveal phase, so `contended` and `wrong_phase` are one today. A client cannot tell "send it again" from "too late", or "wrong ballot" from "malformed body", until Bifrost splits each pair.
|
||||
|
||||
## Commitment
|
||||
|
||||
`commitment = lowercase hex SHA-256 of the UTF-8 bytes of`
|
||||
|
||||
gamecult.sealed_ballot.v0 \n {motionId} \n {voterUserAccountId} \n {persona} \n {round} \n {candidate} \n {reason} \n {salt}
|
||||
|
||||
with `\n` U+000A, ids in `D` format, `round` in decimal, `persona` empty for a member. This is Bifrost's formula as the code has it (`SealedBallotService.cs:154, 385-387`); `docs/sealed-ballot-motion.md:22-23` omits `{persona}` and is stale. The vectors give the canonical bytes (`canonicalHex`) and the digest for a member, a Mind, non-ASCII text, and a decomposed-versus-precomposed pair (no normalisation: they are different ballots). `pairs` pins what a reveal must match: the right ballot matches; a changed reason, another voter id, or a dropped persona does not, and the answer is `commitment_mismatch`.
|
||||
|
||||
## Receipts the voter can check later
|
||||
|
||||
A cast returns the receipt. The voter checks it later in two places that must agree byte for byte with it: `motion.yourCommitment` on a motion read (a commit receipt is present from the commit; the revision counts re-commits), and, after close, the `commitment` the result lists for them. The contract adds no receipt id or signature: the receipt is the commitment, its revision and the time (open choice 6).
|
||||
|
||||
## Personas
|
||||
|
||||
A Persona votes only on sealed motions, through a Mind key (Bifrost binds an Ed25519 public key 1:1 to an Active member account). The ballot's `persona` is the bound persona id and the commitment covers it, so a Mind's ballot cannot be replayed as a member's (`pairs`: dropping the persona does not match). The Mind-intake route today takes a signed `MindEnvelope` anonymously; this contract does not model that envelope, nonce or signature (open choice 7). Standard motions have no Mind path.
|
||||
|
||||
## Authentication
|
||||
|
||||
On the wire the credential is an opaque `Authorization: Bearer` header. The token route, claims, scopes and audience belong to Heimdall and Bifrost and are not designed here. A request without a usable bearer is answered `unauthenticated` as a typed refusal.
|
||||
|
||||
**Auth per route, now and after #37.** Routes are Bifrost's; this contract fixes the documents, not the paths (open choice 3).
|
||||
|
||||
| exchange | today (`/api/sealed-motions`, facts file 3c) | after #37 (`/api/votes`) |
|
||||
|---|---|---|
|
||||
| motion list | no route | bearer, scope `votes.read` |
|
||||
| motion read | session cookie + Active member | bearer, scope `votes.read` |
|
||||
| commit, reveal | session cookie + Active member | bearer, scope `votes.cast`, audience `bifrost-votes`; a high-stakes commit also needs a fresh sign-in |
|
||||
| Mind cast | anonymous; the Ed25519 signature on a `MindEnvelope` is the credential | unchanged: Minds never use `/api/votes` (#37) |
|
||||
| open a motion, Mind-key registry | session cookie + moderator | unchanged, cookie only |
|
||||
|
||||
Today a member route has **no bearer scheme**: it accepts the host-only `__Host-bifrost_session` cookie and answers an unauthenticated call with a redirect to the sign-in page, not a 401. After #37 `/api/votes` is bearer-only and answers a typed 401. The wire header stays an opaque bearer either way; only Bifrost knows which of the two it is checking.
|
||||
|
||||
## PR #37 (Bifrost `docs/ratatoskr-voting-auth.md`, head `447a7cb`)
|
||||
|
||||
Checked 2026-11-10 by an Eyes pass over the PR and the schema; conflicts for Heimdall:
|
||||
|
||||
1. **Standard motions.** The PR serves them (`PUT /api/votes/motions/{id}/ballot {choice, comment?}`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`); the contract is sealed-only and answers `not_found` for a Standard id.
|
||||
2. **Verb model.** The PR keeps `POST .../commit` and `.../reveal` and adds `GET .../result`; the contract has one `cast` document and no result document.
|
||||
3. **List and read as GET.** The PR names GET routes; the contract's request documents carry a `phase` filter and a `motionId`. The mapping (query string and path) is undefined until Heimdall fixes the routes.
|
||||
4. **Refusals.** The PR lists 401/403/404/409/429 with `WWW-Authenticate`; the contract adds 400 (`invalid`, `commitment_mismatch`) and 413, and a typed body.
|
||||
5. **Minds.** The PR says Minds never use `/api/votes` and keeps the anonymous signed `/mind` route; the contract carries `persona` in the ballot and a `forbidden` case for a Mind key on a route it may not call. The PR's token is never a Persona's.
|
||||
6. **Authority.** The contract's `unauthenticated` says a bearer "Heimdall does not know"; the PR makes Bifrost the OIDC issuer (a second client, scopes `votes.read` and `votes.cast`, audience `bifrost-votes`) and notes that Bifrost OIDC is not live (its D7).
|
||||
7. **Rate limits.** The PR keys per account (30 casts per 10 minutes); the contract now says per account too, and neither exists on the member API yet (above).
|
||||
8. **Wire format.** The PR's errors are JSON; the contract's vectors are MessagePack. Nothing settles the media type.
|
||||
9. **The PR's seven open decisions** (consent for `votes.cast`, re-authentication, no refresh token, grant revocation, a Ratatoskr brake, rate numbers, MAS rollout order) assume nothing the contract contradicts. D2 (re-authentication at cast) is answered yes for sealed commit in this contract: `reauthentication_required`, with `highStakes` fixed at open. D5 (a brake) is `held`.
|
||||
|
||||
## Open choices for Heimdall
|
||||
|
||||
1. Standard motions in v0 or a v1 (the PR already serves them).
|
||||
2. Pagination and filters on the list.
|
||||
3. One `cast` route or the two existing ones; `verb` redundancy.
|
||||
4. Wire format: MessagePack documents with a JSON export, or JSON only (the PR is JSON).
|
||||
5. Refuse untrimmed `candidate` and `reason` (this contract) or keep trimming server-side.
|
||||
6. Whether a receipt should carry an id or a server signature so a voter can prove it to a third party.
|
||||
7. The Mind path: keep the signed envelope on its own route, or take Mind keys as a bearer credential.
|
||||
8. `reason` length in UTF-16 code units (as Bifrost) or code points.
|
||||
9. Whether the result document (`GET .../result`) joins this contract.
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/vote-api.schema.json",
|
||||
"title": "CultNet vote API v0 (sealed-ballot motions)",
|
||||
"description": "Typed documents for listing, reading and casting on sealed-ballot motions, and the receipt a cast returns. Map-mode: every key is written, nil where absent. Spec: vote-api.md. Vectors: vote-api-vectors.json.",
|
||||
"$defs": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"instant": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
|
||||
},
|
||||
"hex64": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"trimmedLine": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
])[^\\n\\r\\u0085
]+(?<![\\t-\\r \\u0085 -
])$"
|
||||
},
|
||||
"ballot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"motionId",
|
||||
"round",
|
||||
"candidate",
|
||||
"reason",
|
||||
"salt",
|
||||
"persona"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "gamecult.sealed_ballot.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"candidate": {
|
||||
"$ref": "#/$defs/trimmedLine"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
])[^\\n\\r\\u0085
]+(?<![\\t-\\r \\u0085 -
])$"
|
||||
},
|
||||
"salt": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32,128}$"
|
||||
},
|
||||
"persona": {
|
||||
"type": "string",
|
||||
"pattern": "^[!-~]{0,64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionListRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"phase"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_list_request.v0"
|
||||
},
|
||||
"phase": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionListItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"round",
|
||||
"phase",
|
||||
"commitClosesAtUtc",
|
||||
"closesAtUtc",
|
||||
"highStakes"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
"commitClosesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"closesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"highStakes": {
|
||||
"type": "boolean",
|
||||
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionList": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motions"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_list.v0"
|
||||
},
|
||||
"motions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/motionListItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionReadRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_read_request.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"receipt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"voterUserAccountId",
|
||||
"commitment",
|
||||
"revision",
|
||||
"committedAtUtc",
|
||||
"revealed"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.receipt.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"voterUserAccountId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"commitment": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"committedAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"revealed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"title",
|
||||
"summary",
|
||||
"round",
|
||||
"priorMotionId",
|
||||
"ballotSchema",
|
||||
"phase",
|
||||
"candidates",
|
||||
"commitClosesAtUtc",
|
||||
"closesAtUtc",
|
||||
"highStakes",
|
||||
"yourVoterId",
|
||||
"yourCommitment",
|
||||
"announcement"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion.v0"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"priorMotionId": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ballotSchema": {
|
||||
"const": "gamecult.sealed_ballot.v0"
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/trimmedLine"
|
||||
}
|
||||
},
|
||||
"commitClosesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"closesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"highStakes": {
|
||||
"type": "boolean",
|
||||
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
|
||||
},
|
||||
"yourVoterId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"yourCommitment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/receipt"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"announcement": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"commitCast": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"verb",
|
||||
"commitment",
|
||||
"ballot"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.cast.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"verb": {
|
||||
"const": "commit"
|
||||
},
|
||||
"commitment": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"ballot": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"revealCast": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"verb",
|
||||
"commitment",
|
||||
"ballot"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.cast.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"verb": {
|
||||
"const": "reveal"
|
||||
},
|
||||
"commitment": {
|
||||
"type": "null"
|
||||
},
|
||||
"ballot": {
|
||||
"$ref": "#/$defs/ballot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/commitCast"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/revealCast"
|
||||
}
|
||||
]
|
||||
},
|
||||
"refusal": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error",
|
||||
"retryAfterSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.refusal.v0"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"unauthenticated",
|
||||
"forbidden",
|
||||
"reauthentication_required",
|
||||
"not_found",
|
||||
"wrong_phase",
|
||||
"contended",
|
||||
"invalid",
|
||||
"commitment_mismatch",
|
||||
"payload_too_large",
|
||||
"rate_limited",
|
||||
"held"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
{
|
||||
"vectors": [
|
||||
{
|
||||
"name": "snapshot_request_v1_defaults",
|
||||
"schemaVersion": "cultnet.snapshot_request.v1",
|
||||
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWShbalzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywKdzaGFyZElkwKpzaGFyZEVwb2NowA==",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.snapshot_request.v1",
|
||||
"messageId": "m",
|
||||
"selection": {
|
||||
"schemas": null,
|
||||
"keys": null,
|
||||
"fields": null,
|
||||
"cites": null,
|
||||
"cited": null,
|
||||
"projection": "header",
|
||||
"descending": false,
|
||||
"limit": null,
|
||||
"cursor": null
|
||||
},
|
||||
"shardId": null,
|
||||
"shardEpoch": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "snapshot_request_v1_full_selection_and_shard",
|
||||
"schemaVersion": "cultnet.snapshot_request.v1",
|
||||
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWSmbS1mdWxsqXNlbGVjdGlvbomnc2NoZW1hc5KpbGVhZl9hLnY52UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqRrZXlzkqRhLWVxqPCfmIAtcm93pmZpZWxkc5KEpWluZGV4pGtpbmSib3CmYW55X29mpnZhbHVlc5Kmd2VhcG9upHRvb2ymbnVtYmVywISlaW5kZXikbWFzc6JvcKJnZaZ2YWx1ZXPApm51bWJlcqstMjIzMzc1OS4yNaVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26lY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqhkb2N1bWVudKpkZXNjZW5kaW5nw6VsaW1pdMzIpmN1cnNvcq1vcGFxdWUtY3Vyc29yp3NoYXJkSWSnc2hhcmQtMapzaGFyZEVwb2NoBw==",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.snapshot_request.v1",
|
||||
"messageId": "m-full",
|
||||
"selection": {
|
||||
"schemas": [
|
||||
"leaf_a.v9",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
|
||||
],
|
||||
"keys": [
|
||||
"a-eq",
|
||||
"\uD83D\uDE00-row"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"index": "kind",
|
||||
"op": "any_of",
|
||||
"values": [
|
||||
"weapon",
|
||||
"tool"
|
||||
],
|
||||
"number": null
|
||||
},
|
||||
{
|
||||
"index": "mass",
|
||||
"op": "ge",
|
||||
"values": null,
|
||||
"number": "-2233759.25"
|
||||
}
|
||||
],
|
||||
"cites": {
|
||||
"target": {
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"recordKey": "a-eq"
|
||||
},
|
||||
"role": "Design"
|
||||
},
|
||||
"cited": {
|
||||
"role": "Design",
|
||||
"exists": false
|
||||
},
|
||||
"projection": "document",
|
||||
"descending": true,
|
||||
"limit": 200,
|
||||
"cursor": "opaque-cursor"
|
||||
},
|
||||
"shardId": "shard-1",
|
||||
"shardEpoch": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "database_subscribe_v1_defaults",
|
||||
"schemaVersion": "cultnet.database_subscribe.v1",
|
||||
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKFtrnN1YnNjcmlwdGlvbklko3N1YqlzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywK9pbmNsdWRlU25hcHNob3TDsWNvbnN1bWVyUnVudGltZUlkwKdib2R5SWRzwLdzdXBwb3J0ZWRCb2R5VHJhbnNwb3J0c8A=",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.database_subscribe.v1",
|
||||
"messageId": "m",
|
||||
"subscriptionId": "sub",
|
||||
"selection": {
|
||||
"schemas": null,
|
||||
"keys": null,
|
||||
"fields": null,
|
||||
"cites": null,
|
||||
"cited": null,
|
||||
"projection": "header",
|
||||
"descending": false,
|
||||
"limit": null,
|
||||
"cursor": null
|
||||
},
|
||||
"includeSnapshot": true,
|
||||
"consumerRuntimeId": null,
|
||||
"bodyIds": null,
|
||||
"supportedBodyTransports": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "database_subscribe_v1_full",
|
||||
"schemaVersion": "cultnet.database_subscribe.v1",
|
||||
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKZtLWZ1bGyuc3Vic2NyaXB0aW9uSWSoc3ViLWZ1bGypc2VsZWN0aW9uiadzY2hlbWFzkqlsZWFmX2EudjnZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmpGtleXOSpGEtZXGo8J\u002BYgC1yb3emZmllbGRzkoSlaW5kZXika2luZKJvcKZhbnlfb2amdmFsdWVzkqZ3ZWFwb26kdG9vbKZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqy0yMjMzNzU5LjI1pWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqVjaXRlZIKkcm9sZaZEZXNpZ26mZXhpc3Rzwqpwcm9qZWN0aW9uqGRvY3VtZW50qmRlc2NlbmRpbmfDpWxpbWl0zMimY3Vyc29yrW9wYXF1ZS1jdXJzb3KvaW5jbHVkZVNuYXBzaG90wrFjb25zdW1lclJ1bnRpbWVJZKhjb25zdW1lcqdib2R5SWRzkaZib2R5LWG3c3VwcG9ydGVkQm9keVRyYW5zcG9ydHOSpHJ1ZHCkcXVpYw==",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.database_subscribe.v1",
|
||||
"messageId": "m-full",
|
||||
"subscriptionId": "sub-full",
|
||||
"selection": {
|
||||
"schemas": [
|
||||
"leaf_a.v9",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
|
||||
],
|
||||
"keys": [
|
||||
"a-eq",
|
||||
"\uD83D\uDE00-row"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"index": "kind",
|
||||
"op": "any_of",
|
||||
"values": [
|
||||
"weapon",
|
||||
"tool"
|
||||
],
|
||||
"number": null
|
||||
},
|
||||
{
|
||||
"index": "mass",
|
||||
"op": "ge",
|
||||
"values": null,
|
||||
"number": "-2233759.25"
|
||||
}
|
||||
],
|
||||
"cites": {
|
||||
"target": {
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"recordKey": "a-eq"
|
||||
},
|
||||
"role": "Design"
|
||||
},
|
||||
"cited": {
|
||||
"role": "Design",
|
||||
"exists": false
|
||||
},
|
||||
"projection": "document",
|
||||
"descending": true,
|
||||
"limit": 200,
|
||||
"cursor": "opaque-cursor"
|
||||
},
|
||||
"includeSnapshot": false,
|
||||
"consumerRuntimeId": "consumer",
|
||||
"bodyIds": [
|
||||
"body-a"
|
||||
],
|
||||
"supportedBodyTransports": [
|
||||
"rudp",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "snapshot_response_raw_v1_headers",
|
||||
"schemaVersion": "cultnet.snapshot_response_raw.v1",
|
||||
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAqRhc09mBaRuZXh0q25leHQtY3Vyc29yp2hlYWRlcnOSiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1lcahzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3PAiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1sb6hzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3OSpWFscGhhpGJldGGpZG9jdW1lbnRzwKVlZGdlc8Cnc2hhcmRJZKdzaGFyZC0xqnNoYXJkRXBvY2gDsHNoYXJkTG9nU2VxdWVuY2Up",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.snapshot_response_raw.v1",
|
||||
"messageId": "m",
|
||||
"matched": 2,
|
||||
"asOf": 5,
|
||||
"next": "next-cursor",
|
||||
"headers": [
|
||||
{
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"schemaName": "leaf_a",
|
||||
"schemaVersion": "1",
|
||||
"schemaContentHash": "abc",
|
||||
"recordKey": "a-eq",
|
||||
"storedAt": "1970-01-01T00:00:00.000Z",
|
||||
"sourceRuntimeId": "rt",
|
||||
"sourceAgentId": "agent",
|
||||
"sourceRole": "role",
|
||||
"tags": null
|
||||
},
|
||||
{
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"schemaName": "leaf_a",
|
||||
"schemaVersion": "1",
|
||||
"schemaContentHash": "abc",
|
||||
"recordKey": "a-lo",
|
||||
"storedAt": "1970-01-01T00:00:00.000Z",
|
||||
"sourceRuntimeId": "rt",
|
||||
"sourceAgentId": "agent",
|
||||
"sourceRole": "role",
|
||||
"tags": [
|
||||
"alpha",
|
||||
"beta"
|
||||
]
|
||||
}
|
||||
],
|
||||
"documents": null,
|
||||
"edges": null,
|
||||
"shardId": "shard-1",
|
||||
"shardEpoch": 3,
|
||||
"shardLogSequence": 41
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "snapshot_response_raw_v1_documents_and_edges",
|
||||
"schemaVersion": "cultnet.snapshot_response_raw.v1",
|
||||
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAaRhc09mBaRuZXh0wKdoZWFkZXJzwKlkb2N1bWVudHORjKhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZcCtc2NoZW1hVmVyc2lvbsCxc2NoZW1hQ29udGVudEhhc2jAqXJlY29yZEtleaRhLWVxqHN0b3JlZEF0uDE5NzAtMDEtMDFUMDA6MDA6MDAuMDAwWq9wYXlsb2FkRW5jb2RpbmerbWVzc2FnZXBhY2uncGF5bG9hZMQEgaFrAa9zb3VyY2VSdW50aW1lSWTArXNvdXJjZUFnZW50SWTAqnNvdXJjZVJvbGXApHRhZ3PApWVkZ2VzkoWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nwKdwYXlsb2FkwIWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nq21lc3NhZ2VwYWNrp3BheWxvYWTEAwECA6dzaGFyZElkwKpzaGFyZEVwb2NowLBzaGFyZExvZ1NlcXVlbmNlwA==",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.snapshot_response_raw.v1",
|
||||
"messageId": "m",
|
||||
"matched": 1,
|
||||
"asOf": 5,
|
||||
"next": null,
|
||||
"headers": null,
|
||||
"documents": [
|
||||
{
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"schemaName": null,
|
||||
"schemaVersion": null,
|
||||
"schemaContentHash": null,
|
||||
"recordKey": "a-eq",
|
||||
"storedAt": "1970-01-01T00:00:00.000Z",
|
||||
"payloadEncoding": "messagepack",
|
||||
"payload": "gaFrAQ==",
|
||||
"sourceRuntimeId": null,
|
||||
"sourceAgentId": null,
|
||||
"sourceRole": null,
|
||||
"tags": null
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": {
|
||||
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"recordKey": "citer-1"
|
||||
},
|
||||
"role": "Design",
|
||||
"to": {
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"recordKey": "a-eq"
|
||||
},
|
||||
"payloadEncoding": null,
|
||||
"payload": null
|
||||
},
|
||||
{
|
||||
"from": {
|
||||
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"recordKey": "citer-1"
|
||||
},
|
||||
"role": "Design",
|
||||
"to": {
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"recordKey": "a-eq"
|
||||
},
|
||||
"payloadEncoding": "messagepack",
|
||||
"payload": "AQID"
|
||||
}
|
||||
],
|
||||
"shardId": null,
|
||||
"shardEpoch": null,
|
||||
"shardLogSequence": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_plain",
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KkYm9vbatyb3V0aW5nSGludMCkY29kZcCnZGV0YWlsc8A=",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "boom",
|
||||
"routingHint": null,
|
||||
"code": null,
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_selection_invalid",
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3LZOnNlbGVjdGlvbl9pbnZhbGlkOiBmaWVsZHNbMF0uaW5kZXggImtpbmQiIGlzIG5vdCBkZWNsYXJlZC6rcm91dGluZ0hpbnTApGNvZGWxc2VsZWN0aW9uX2ludmFsaWSnZGV0YWlsc4SlZmllbGSvZmllbGRzWzBdLmluZGV4pXZhbHVlpGtpbmSkYXNPZsCnY3VycmVudMA=",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "selection_invalid: fields[0].index \u0022kind\u0022 is not declared.",
|
||||
"routingHint": null,
|
||||
"code": "selection_invalid",
|
||||
"details": {
|
||||
"field": "fields[0].index",
|
||||
"value": "kind",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_cursor_stale",
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX3N0YWxlOiBzdGFsZatyb3V0aW5nSGludMCkY29kZaxjdXJzb3Jfc3RhbGWnZGV0YWlsc4SlZmllbGTApXZhbHVlwKRhc09mA6djdXJyZW50CQ==",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_stale: stale",
|
||||
"routingHint": null,
|
||||
"code": "cursor_stale",
|
||||
"details": {
|
||||
"field": null,
|
||||
"value": null,
|
||||
"asOf": 3,
|
||||
"current": 9
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_cursor_invalid_no_details",
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX2ludmFsaWQ6IGJhZKtyb3V0aW5nSGludMCkY29kZa5jdXJzb3JfaW52YWxpZKdkZXRhaWxzwA==",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_invalid: bad",
|
||||
"routingHint": null,
|
||||
"code": "cursor_invalid",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_write_not_authorized",
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3K0d3JpdGVfbm90X2F1dGhvcml6ZWSrcm91dGluZ0hpbnTApGNvZGW0d3JpdGVfbm90X2F1dGhvcml6ZWSnZGV0YWlsc4SlZmllbGSpcmVjb3JkS2V5pXZhbHVloWukYXNPZsCnY3VycmVudMA=",
|
||||
"expected": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "write_not_authorized",
|
||||
"routingHint": null,
|
||||
"code": "write_not_authorized",
|
||||
"details": {
|
||||
"field": "recordKey",
|
||||
"value": "k",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.error.v1.schema.json",
|
||||
"title": "CultNet Error Message v1",
|
||||
"description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum plus limit_reached (write-receipt.md, Remembered-write limits).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error",
|
||||
"routingHint",
|
||||
"code",
|
||||
"details",
|
||||
"messageId"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "cultnet.error.v1"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"routingHint": {
|
||||
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"shard",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"shard": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) or its server-wide memory budget is full; messageId is the write's id.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"enum": [
|
||||
null,
|
||||
"selection_invalid",
|
||||
"cursor_stale",
|
||||
"cursor_invalid",
|
||||
"reference_outside_target",
|
||||
"variant_unsupported",
|
||||
"unowned_schema",
|
||||
"write_not_authorized",
|
||||
"unsupported_schema_version",
|
||||
"message_invalid",
|
||||
"limit_reached"
|
||||
]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"field",
|
||||
"value",
|
||||
"asOf",
|
||||
"current"
|
||||
],
|
||||
"properties": {
|
||||
"field": {
|
||||
"description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read). limit_reached: no field; details stay all null. message_invalid also names \"messageId\" (over 256 bytes), \"document\", \"schemaId\" or \"channel\" (write on an unreliable channel).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"asOf": {
|
||||
"description": "cursor_stale: the cursor's own minted asOf.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
},
|
||||
"current": {
|
||||
"description": "cursor_stale: the answering server's current asOf.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"messageId": {
|
||||
"description": "The messageId of the write (or other request) this error answers, echoed byte for byte. Null only when the frame was unreadable or carried no messageId.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.write-admitted.schema.json",
|
||||
"title": "CultNet Write Admitted",
|
||||
"description": "The receipt for one admitted document put or delete (write-receipt.md). Sent only after the write's commit point, only to a connection that declared the write-receipt capability, once per messageId. Map-mode: every key is written, none is nil. A receipt never carries an error, and an error.v1 never carries admission.",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "operation", "schemaId", "recordKey"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.write_admitted.v0" },
|
||||
"messageId": { "description": "The messageId of the put or delete this admits, echoed byte for byte.", "type": "string", "minLength": 1 },
|
||||
"operation": { "enum": ["put", "delete"] },
|
||||
"schemaId": { "description": "The schemaId of the admitted write, echoed.", "type": "string", "minLength": 1 },
|
||||
"recordKey": { "description": "The recordKey of the admitted write, echoed.", "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
{
|
||||
"provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "selection_invalid_schemas_null",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "selection_invalid: must not be empty",
|
||||
"routingHint": null,
|
||||
"code": "selection_invalid",
|
||||
"details": {
|
||||
"field": "schemas",
|
||||
"value": null,
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "selection_invalid_field_value",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "selection_invalid: blank key entry",
|
||||
"routingHint": null,
|
||||
"code": "selection_invalid",
|
||||
"details": {
|
||||
"field": "keys",
|
||||
"value": " ",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor_stale",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_stale: cursor is stale",
|
||||
"routingHint": null,
|
||||
"code": "cursor_stale",
|
||||
"details": {
|
||||
"field": null,
|
||||
"value": null,
|
||||
"asOf": 1,
|
||||
"current": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor_invalid",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_invalid: cannot parse cursor",
|
||||
"routingHint": null,
|
||||
"code": "cursor_invalid",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reference_outside_target",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "reference_outside_target: outside declared target",
|
||||
"routingHint": null,
|
||||
"code": "reference_outside_target",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variant_unsupported_subscription_ended",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "variant unsupported here",
|
||||
"routingHint": null,
|
||||
"code": "variant_unsupported",
|
||||
"details": {
|
||||
"field": "subscriptionId",
|
||||
"value": "sub-1",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variant_unsupported_no_subscription",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "variant unsupported here",
|
||||
"routingHint": null,
|
||||
"code": "variant_unsupported",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unowned_schema",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.",
|
||||
"routingHint": null,
|
||||
"code": "unowned_schema",
|
||||
"details": {
|
||||
"field": "schemaId",
|
||||
"value": "some.schema.v1",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_not_authorized",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "write_not_authorized: not granted",
|
||||
"routingHint": null,
|
||||
"code": "write_not_authorized",
|
||||
"details": {
|
||||
"field": "recordKey",
|
||||
"value": "some:key",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unsupported_schema_version",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "unsupported_schema_version: cultnet.hello.v1",
|
||||
"routingHint": null,
|
||||
"code": "unsupported_schema_version",
|
||||
"details": {
|
||||
"field": "schemaVersion",
|
||||
"value": "cultnet.hello.v1",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "message_invalid",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "message_invalid: Raw put messages must contain a document map.",
|
||||
"routingHint": null,
|
||||
"code": "message_invalid",
|
||||
"details": {
|
||||
"field": "document",
|
||||
"value": null,
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,89 +0,0 @@
|
|||
# CultNet write receipt (specification)
|
||||
|
||||
Status: specification. No runtime implements it yet; the conformance follow-ups below do.
|
||||
Rules: Heimdall's ruling of 2026-11-08 (a write completes on its result frame; an ack or a return is not admission).
|
||||
Files here: `cultnet.write-admitted.schema.json`, `cultnet.error.v1.schema.json`, `write-receipt-vectors.json`. They move to `contracts/cultnet/` when the first runtime implements them.
|
||||
|
||||
## The problem
|
||||
|
||||
A put or delete today has two answers on the wire: nothing (admitted, or the server never read it) and `cultnet.error.v0` (refused, with no id saying which write). A transport ack proves delivery. Clients that completed on the ack, or on a window with no refusal, reported refused writes as done (CultLib #55 to #58 bound this with a result window; this spec replaces the window with an answer).
|
||||
|
||||
## Frames
|
||||
|
||||
**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write.
|
||||
|
||||
**A `write_admitted` `messageId` is valid only if it contains at least one Unicode letter (`\p{L}`) or number (`\p{N}`) and no Default_Ignorable_Code_Point (DerivedCoreProperties).** The compare is raw: the id is echoed byte for byte, so a decoder never trims or normalizes it (`" a "` is valid; U+2800 is not, and neither is `a` followed by U+200B). Unlike the Required fields rule, this is not a White_Space test. The letters, numbers and Default_Ignorable code points are those of **Unicode 16.0** in every runtime, taken from tables embedded in each runtime and generated by `scripts/gen-unicode-messageid-tables.py` from `DerivedGeneralCategory.txt` (Lu Ll Lt Lm Lo Nd Nl No) and `DerivedCoreProperties.txt`; no runtime asks its platform's Unicode data, so a runtime's own Unicode version cannot change a result. Bumping the version is one coordinated change: regenerate the five tables and update the vectors that pin the version in the same commit. The rule is on the decoded string, which must be well-formed Unicode: a lone surrogate refuses. A runtime whose MessagePack reader rejects invalid UTF-8 refuses the frame earlier; byte-level strictness is a property of the reader and is not required of a decoder. A frame with more than one `messageId` key is malformed: a decoder that can see the duplicate (the C# and Rust decoders) refuses it; a decoder that collapses the map (Python, TypeScript, Kotlin) judges the value it ends up with, the last key, and refuses if that value is invalid, so no decoder echoes an invalid id (vectors `admittedMessageIdDuplicates` declare each policy and every runner asserts its own). A decoder refuses an invalid id the way it refuses an empty or ill-typed one; the rule is for `write_admitted` only and `error.v1` is unchanged.
|
||||
|
||||
**Where a received `messageId` decides, and what that relies on.** Beyond echoing it, a received `messageId` is used in exactly two places. On a server, the remembered-write key `(actor, messageId)` with the content hash, the per-session limit and the replay of rule 5 are taken from the id of the write frame the server itself parsed. On a client, the write tracker attributes a `write_admitted` or `error.v1` to a write by equality with the id of a write that client itself sent (a frame naming an id not in flight settles nothing), and attributes an id-less error by the count of unanswered writes, not by any id. Both rely on the receiver being the only parser of that frame: a decoder's `messageId` on a frame that carries more than one `messageId` key is not trusted for any authorization, dedupe, replay or ordering decision, and the map-collapsing runtimes (Python, TypeScript, Kotlin, and C#'s typed deserializer on write frames) cannot see the duplicate. If a frame is ever parsed by two runtimes in a chain (a relay, proxy or forwarder that re-reads it), or a consumer needs `messageId` for authorization, dedupe, replay or ordering beyond echo and equality with a self-sent id, all runtimes must first refuse a duplicate `messageId` key (a spec change plus byte-level duplicate detection in the runtimes that decode to a map) before that use lands. Today Rust refuses a repeated key on write frames and on `write_admitted` (serde's `duplicate field messageId`, and `refuse_duplicate_keys` for the raw-list frames such as `document_put_raw`), though its `readable_message_id` names the first key when it names an unreadable frame; C# does not on write frames (a duplicate-key write is deduped on its last id, while the id it names in a refusal for an unreadable frame is read from the first key), and the Python server (`cultmesh_py/server.py`) only echoes the id while the TypeScript and Kotlin code matches it by equality with a request it sent.
|
||||
|
||||
**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id.
|
||||
|
||||
Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders.
|
||||
|
||||
## Capability: opting in
|
||||
|
||||
A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. A server advertises the two frames in its hello only if it implements the receipt rules end to end.
|
||||
|
||||
**The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in.
|
||||
|
||||
## Server rules
|
||||
|
||||
1. **One answer.** For every put or delete on a receipt-capable connection the server sends exactly one of `write_admitted` or `error.v1`, carrying that write's `messageId`. Never both, never two different answers.
|
||||
2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission.
|
||||
3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read.
|
||||
4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer.
|
||||
5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (see Remembered-write limits; a server never forgets an id it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.**
|
||||
- Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice.
|
||||
- Same key, same content hash, answer was a refusal: the same refusal again.
|
||||
- **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged.
|
||||
- **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically.
|
||||
- **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests.
|
||||
- **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table.
|
||||
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
|
||||
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case.
|
||||
- **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`.
|
||||
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1).
|
||||
- Past the session (or a server's longer retention, if it keeps one) a resend is a new write.
|
||||
6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission.
|
||||
7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither.
|
||||
|
||||
## Client rules: three outcomes
|
||||
|
||||
A write settles as exactly one of:
|
||||
|
||||
- **admitted**: a `write_admitted` whose `messageId` is the write's.
|
||||
- **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error.
|
||||
- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered; a write registered after the session ended settles this way when it is sent) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other.
|
||||
|
||||
**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. It ends when the write's age reaches it: an answer arriving at exactly that instant is late. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window.
|
||||
|
||||
**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. A resend on the same session with the same id and content is harmless on a receipt-capable server (rule 5); across a reconnect the caller follows the idempotence rule above.
|
||||
|
||||
**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing.
|
||||
|
||||
**Batches can commit partially.** A batch is N independent writes with no atomicity: some members may be admitted while others are refused or unanswered, and nothing is rolled back. The client reports each member's outcome and a batch result is only their set: admitted if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). The caller owns retry: it resends exactly the members that are not admitted, with their original ids.
|
||||
|
||||
## Changes to what #55 to #58 shipped
|
||||
|
||||
Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`. The Idunn presence publisher is send-only by its own function (`sendPresenceDocument`, not the `sendOnly` option): the same `sent` result, plus a rejection if a refusal naming the put (or naming nothing) arrives by the transport ack. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission.
|
||||
|
||||
## Vectors
|
||||
|
||||
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `admittedMessageId` (the `write_admitted` id rule above: code-point lists, each marked refuse or accept; a lone surrogate, and a frame that repeats the key, are carried as whole frames in `wireHex`, because a lone surrogate is not valid UTF-8 and only some runtimes' strings can hold it; `write-admitted-message-id-ranges.json` lists every range of the embedded tables, and each runtime probes the first and last point of each range and the points either side), `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
|
||||
|
||||
## Conformance status
|
||||
|
||||
| Server class | Advertises both frames | Server-wide memory budget (MAY, Remembered-write limits) |
|
||||
|---|---|---|
|
||||
| `CultNetDatabaseServer` | yes | not implemented: only the per-session limit of 1024 applies |
|
||||
| Shard write forwarder | yes | not implemented: only the per-session limit of 1024 applies |
|
||||
| Rust RUDP document server | yes | implemented (32 MiB default, `max_remembered_write_bytes`) |
|
||||
| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | n/a |
|
||||
|
||||
Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts.
|
||||
|
||||
Cross-runtime check: `packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts` (`npm run test:interop` in `packages/cultmesh-ts`) runs the TypeScript client (`WriteTracker`, the decoder, the one-shot writer) against the real Rust RUDP document server and the real `CultNetDatabaseServer` over loopback, one scenario set against both. Known gap it records as a todo: the Rust RUDP document server refuses every delete (`write_not_authorized: the server does not take deletes`), where this spec has an admitted delete receipted like a put.
|
||||
|
||||
## Conformance follow-ups (not in this PR)
|
||||
|
||||
C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog.
|
||||
|
|
@ -34,7 +34,7 @@ ran under the session scratchpad (`imagination/`), not in the repo.
|
|||
| F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source |
|
||||
| F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep |
|
||||
| F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source |
|
||||
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggered on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` when this was written; it now triggers on the three `-py-v*` patterns only, because npm publishing is deliberately off until ruled on (a tag run uses the workflow at the tagged commit, so `cultcache-ts-v0.15.0` on `7d292085`, before that change, still ran the old one). Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returned 404 anonymously on 2026-11-08 for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
|
||||
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` only. Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
|
||||
| F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 |
|
||||
| F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` |
|
||||
| F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim |
|
||||
|
|
@ -276,10 +276,11 @@ remains:
|
|||
history (Keep a Changelog headings).
|
||||
- A Soul pass over the release diff, then four tags: `cultcache-ts-v…`,
|
||||
`cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`.
|
||||
These go in **two pushes** (at most three tags per push, SKILL.md). No
|
||||
`-ts-v*` tag triggers `publish-packages.yml` on main (F16), so these tags
|
||||
publish nothing;
|
||||
follow-up `npm-publish-ts-packages` owns npm publishing.
|
||||
These go in **two pushes** (at most three tags per push, SKILL.md):
|
||||
`cultcache-ts` first, because its tag triggers `publish-packages.yml`
|
||||
(F16), and the three plane tags second. Whether the `cultcache-ts` tag's
|
||||
npm publish job still runs, and to where, is checked before that push;
|
||||
follow-up `npm-publish-ts-packages` owns the rest.
|
||||
- The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the
|
||||
tagged commit) is StreamPixels' B-cuts, not this campaign.
|
||||
- Size: about 60 lines of version and CHANGELOG text; a short Hands pass.
|
||||
|
|
|
|||
|
|
@ -441,8 +441,8 @@ consumer that waits wants delivered, which is now just Acknowledged.
|
|||
| `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` |
|
||||
| `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) |
|
||||
| `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it |
|
||||
| `cultmesh-ts/src/index.ts:6211-6303` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
|
||||
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:97-136` `sendPresenceDocument` (formerly `publishDocument`), behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
|
||||
| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
|
||||
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
|
||||
| `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published |
|
||||
| `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) |
|
||||
| C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None |
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ caught before it ships instead of written up afterward.
|
|||
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
|
||||
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml`, `workflow_dispatch` only; npm publishing is off (see "Coverage gaps") |
|
||||
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` |
|
||||
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
|
|
@ -198,8 +198,8 @@ or a missing declaration refuses), and:
|
|||
proof: with no tag of its own prefix it is a first release. The suite test
|
||||
that pins every declared prefix guards it: each prefix is declared once, each
|
||||
workflow tag trigger must be a declared prefix, and each such package's prefix
|
||||
must be a workflow trigger or, for a package the workflow does not tag-trigger
|
||||
(the Caching Unity package, `cultcache-ts`), the prefix of tags that hold its own manifest at the tag's own
|
||||
must be a workflow trigger or, for the Caching Unity package the workflow does
|
||||
not build, the prefix of tags that hold its own manifest at the tag's own
|
||||
version, so a typo or a copied prefix fails the suite. Nothing releases
|
||||
without the suite passing; see below for how each release path runs it.
|
||||
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
|
||||
|
|
@ -352,40 +352,10 @@ own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal
|
|||
at unchanged source and CRLF checkout means this SDK or host differs from the
|
||||
one that built the tracked DLL.
|
||||
|
||||
### Before pushing a tag
|
||||
|
||||
A tag run uses the workflow files at the tagged commit, not the ones on main, and
|
||||
the forge's GitHub mirror forwards the tag, so both CI systems run it. Before
|
||||
pushing any tag, resolve the commit it will point at and read the tag-triggering
|
||||
workflows as they are at that commit:
|
||||
|
||||
```
|
||||
sha=$(git rev-parse <commit-the-tag-will-point-at>)
|
||||
git ls-tree -r --name-only "$sha" .github/workflows
|
||||
git show "$sha":.github/workflows/publish-packages.yml | sed -n '/^on:/,/^jobs:/p'
|
||||
```
|
||||
|
||||
Confirm that the tag's prefix is a trigger there if you mean it to publish, and
|
||||
is not if you do not. Do the same for every other workflow listed that has a
|
||||
`tags:` trigger. A guard merged after that commit does not protect a tag that
|
||||
points before it.
|
||||
|
||||
`cultcache-ts-v0.15.0` was pushed on `7d292085`, which predates the guard, and
|
||||
its push started a publish run on both systems (GitHub run 42392334869, forge run
|
||||
454). The forge job log shows how it ended: the Publish step ran
|
||||
`release-after-suite.mjs`, whose `npm test` failed (11 cross-runtime tests,
|
||||
`cargo: not found` on the runner), so it printed "@gamecult/cultcache-ts's own
|
||||
tests failed; not releasing." and never reached `npm publish`. Neither
|
||||
`cultcache-ts` nor `@gamecult/cultcache-ts` exists on the npm registry
|
||||
(both return 404, checked 2026-11-08). The GitHub run's own log needs a login
|
||||
to read, so its failure cause is inferred from the identical workflow and sha.
|
||||
|
||||
### Coverage gaps
|
||||
|
||||
`publish-packages.yml` only triggers on `cultcache-py-v*`, `cultnet-py-v*`, and
|
||||
`cultmesh-py-v*` tag pushes (`cultcache-ts-v*` is deliberately not a trigger: npm
|
||||
publishing is off until ruled on, and the cultcache-ts job runs from
|
||||
`workflow_dispatch` only, publishing nothing) — it does not build or gate
|
||||
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`,
|
||||
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate
|
||||
the Unity UPM packages at all; those are consumed directly by git tag
|
||||
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
|
||||
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ this page.
|
|||
|
||||
| | CultCache | CultMath | Geometry | CultNet | CultMesh |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
|
||||
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
|
||||
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
|
||||
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet |
|
||||
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet |
|
||||
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet |
|
||||
|
||||
For the whole
|
||||
picture of what each piece is for, see the
|
||||
|
|
@ -222,244 +222,6 @@ def first_call() -> Item:
|
|||
return reopened.get_required(item_doc, "item:potion")
|
||||
```
|
||||
|
||||
## CultNet first calls
|
||||
|
||||
CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same
|
||||
shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack
|
||||
payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each
|
||||
block is verbatim from the test that runs it; a second test in the same file fails if the block stops
|
||||
matching.
|
||||
|
||||
### C# CultNet first call
|
||||
|
||||
Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs),
|
||||
run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`.
|
||||
|
||||
```csharp
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
|
||||
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class Note
|
||||
{
|
||||
[Key(0)] [CultName] public string Title = string.Empty;
|
||||
[Key(1)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
public static class CultNetFirstCall
|
||||
{
|
||||
public static async Task<Note?> Run()
|
||||
{
|
||||
var inbox = new CultCache(); // the receiver's cache
|
||||
var documents = new CultNetDocumentRegistry();
|
||||
var received = new TaskCompletionSource<bool>();
|
||||
|
||||
// Receive: a loopback listener that applies each raw document put to the inbox.
|
||||
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
|
||||
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
|
||||
{
|
||||
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
|
||||
received.TrySetResult(true);
|
||||
});
|
||||
|
||||
// Send: a typed document as a cultnet.document_put_raw.v0 message.
|
||||
using var client = new TcpFramedCultNetSchemaClient();
|
||||
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
|
||||
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
|
||||
client.SendCultNet(documents.CreateRawDocumentPutMessage(
|
||||
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
|
||||
|
||||
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
return inbox.Get<Note>(handle.Key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
|
||||
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
|
||||
from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`.
|
||||
|
||||
```ts
|
||||
import { createServer, connect } from "node:net";
|
||||
import { z } from "zod";
|
||||
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
createTcpFramedCultNetPeer,
|
||||
defineCultNetDocumentBinding,
|
||||
} from "@gamecult/cultnet-ts";
|
||||
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schemaId: "gamecult.first_call_note.v1",
|
||||
schemaName: "gamecult.first_call_note",
|
||||
schemaVersion: "gamecult.first_call_note.v1",
|
||||
schema: z.object({ title: z.string(), body: z.string() }),
|
||||
name: "title",
|
||||
});
|
||||
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
|
||||
|
||||
async function firstCall() {
|
||||
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
|
||||
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
|
||||
|
||||
// Receiver: a loopback listener whose peer applies each document put to the inbox.
|
||||
const received = new Promise<void>((resolve, reject) => {
|
||||
const server = createServer((socket) => {
|
||||
const peer = createTcpFramedCultNetPeer(socket, options);
|
||||
peer.on("invalidMessage", reject);
|
||||
peer.on("message", async (message) => {
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
|
||||
await documents.applyRawDocumentPutMessage(inbox, message);
|
||||
peer.close();
|
||||
server.close();
|
||||
resolve();
|
||||
});
|
||||
}).listen(0, "127.0.0.1", () => {
|
||||
// Sender: connect, build a typed put message, send it.
|
||||
const { port } = server.address() as { port: number };
|
||||
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
|
||||
sender.send(
|
||||
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
|
||||
title: "Hello",
|
||||
body: "sent over CultNet",
|
||||
}),
|
||||
);
|
||||
received.finally(() => sender.close());
|
||||
});
|
||||
});
|
||||
await received;
|
||||
return inbox.get(noteDocument, "note:hello");
|
||||
}
|
||||
```
|
||||
|
||||
### Rust CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
|
||||
`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
|
||||
|
||||
```rust
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
use cultnet_rs::{
|
||||
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
|
||||
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
|
||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "note")]
|
||||
struct Note {
|
||||
#[cultcache(key = 0)]
|
||||
title: String,
|
||||
#[cultcache(key = 1)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn first_call() -> anyhow::Result<Note> {
|
||||
let mut documents = CultNetDocumentRegistry::new();
|
||||
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
))?;
|
||||
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
|
||||
|
||||
// Receiver: a loopback listener that applies the document put it receives to its own cache.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let receiver = thread::spawn({
|
||||
let (documents, profile) = (documents.clone(), profile("receiver"));
|
||||
move || -> anyhow::Result<Note> {
|
||||
let (stream, _) = listener.accept()?;
|
||||
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
|
||||
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let mut inbox = CultCache::new();
|
||||
inbox.register_entry_type::<Note>()?;
|
||||
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
|
||||
}
|
||||
});
|
||||
|
||||
// Sender: build a typed put message, encode it for the wire, send one frame.
|
||||
let put = documents.create_raw_document_put_message(
|
||||
"put-1",
|
||||
"note:hello",
|
||||
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
|
||||
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
|
||||
.send("schema", &bytes)?;
|
||||
|
||||
receiver.join().expect("receiver thread")
|
||||
}
|
||||
```
|
||||
|
||||
### Python CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by
|
||||
`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on
|
||||
`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`.
|
||||
|
||||
```python
|
||||
import socket
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from cultcache_py import CultCache, define_document_type
|
||||
from cultnet_py import (
|
||||
apply_raw_document_record,
|
||||
document_put_raw,
|
||||
encode_frame,
|
||||
parse_message,
|
||||
read_frame,
|
||||
schema_document_map,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
note_doc = define_document_type(
|
||||
"note",
|
||||
encode=lambda note: asdict(note),
|
||||
decode=lambda raw: Note(**raw),
|
||||
name="title",
|
||||
)
|
||||
|
||||
|
||||
def first_call() -> Note:
|
||||
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
|
||||
|
||||
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
|
||||
put = document_put_raw(
|
||||
message_id="put-1",
|
||||
key="note:hello",
|
||||
schema_id=note_doc.catalog_entry().schema_id,
|
||||
stored_at="2026-01-01T00:00:00Z",
|
||||
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
|
||||
)
|
||||
sender.sendall(encode_frame(put.to_bytes()))
|
||||
|
||||
# Receive: read the frame, parse the message, apply its record to a second cache.
|
||||
message = parse_message(read_frame(receiver.makefile("rb")))
|
||||
assert message.schema_version == "cultnet.document_put_raw.v0"
|
||||
inbox = CultCache.builder().register_document_type(note_doc).build()
|
||||
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
|
||||
sender.close()
|
||||
receiver.close()
|
||||
return inbox.get_required(note_doc, "note:hello")
|
||||
```
|
||||
|
||||
## Where next
|
||||
|
||||
- Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.
|
||||
|
|
|
|||
|
|
@ -1,196 +0,0 @@
|
|||
# Recipe: a two-client .io game over CultCache + CultMesh (TypeScript)
|
||||
|
||||
A tiny arena. One process is the authority: it holds a single `io.arena_state` document in a CultCache
|
||||
and serves it over CultMesh. Each client discovers the authority through the Odin catalog, leases the
|
||||
document, and invokes `join`, `move` and `leave` operations. Two clients see each other join, move and
|
||||
leave because both lease the same document.
|
||||
|
||||
## What ran, what did not
|
||||
|
||||
| Piece | Status |
|
||||
| --- | --- |
|
||||
| Two clients, real WebSockets, real `@gamecult/cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) |
|
||||
| Edge clamp, and the source below matching the files that ran | **tested** |
|
||||
| Browser bundle builds and the page is served | **tested** (HTTP fetch only) |
|
||||
| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe |
|
||||
| Run by CI / `npm run test:ts` | **not yet**: the recipe has its own `package.json` (for `ws`) outside the workspaces; run it by hand as below |
|
||||
| Authority state on disk (`.cc` file) | **not yet**: the arena cache is in memory |
|
||||
| Remote play: Odin roots and a signed route | **not yet**: the client uses `local-development` trust, loopback only |
|
||||
| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority |
|
||||
| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported |
|
||||
|
||||
No `@gamecult/cultmesh-ts` or `@gamecult/cultnet-ts` TypeScript authority server exists. The server below is the CultNet
|
||||
message handling a provider owes, written in this recipe with the `ws` package.
|
||||
|
||||
## Run it
|
||||
|
||||
```
|
||||
npm ci --ignore-scripts # repo root
|
||||
(cd packages/cultnet-ts && npm run build) # then cultcache-ts, cultmesh-browser, same command
|
||||
cd docs/start/io-game && npm install
|
||||
npm test # node --test arena.test.ts, Node 24
|
||||
node serve.ts # http://127.0.0.1:8080, open it twice
|
||||
```
|
||||
|
||||
Tested on Node v24.14, which runs `.ts` files directly (no type check; `tsc` is not part of this recipe).
|
||||
|
||||
## schema.ts
|
||||
|
||||
```ts
|
||||
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
|
||||
|
||||
export interface Player { x: number; y: number }
|
||||
export interface ArenaState { players: Record<string, Player> }
|
||||
|
||||
// The one shared document: every player's position. The server owns it; clients lease it.
|
||||
export const arenaDocument = defineDocumentType({
|
||||
type: "io.arena_state",
|
||||
schemaId: "io.arena_state.v1",
|
||||
schema: { parse: (value: unknown) => value as ArenaState },
|
||||
});
|
||||
|
||||
export const arenaKey = "arena:main";
|
||||
export const verseId = "io.arena";
|
||||
export const authorityRuntimeId = "io.arena-daemon";
|
||||
export const arenaSize = 100;
|
||||
```
|
||||
|
||||
## server.ts
|
||||
|
||||
```ts
|
||||
import { createServer, type RequestListener, type Server } from "node:http";
|
||||
import { CultCache } from "@gamecult/cultcache-ts";
|
||||
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
|
||||
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
|
||||
|
||||
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
|
||||
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
|
||||
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
|
||||
await cache.put(arenaDocument, arenaKey, { players: {} });
|
||||
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
|
||||
await new Promise<void>(resolve => http.listen(port, host, resolve));
|
||||
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
|
||||
|
||||
const subscribers = new Map<WebSocket, string[]>();
|
||||
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
|
||||
const record = () => ({
|
||||
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
|
||||
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
|
||||
payloadEncoding: "messagepack" as const,
|
||||
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
|
||||
sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
const publish = async (next: ArenaState) => {
|
||||
await cache.put(arenaDocument, arenaKey, next);
|
||||
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
|
||||
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
|
||||
}
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
const operations: Record<string, (p: any) => Promise<object>> = {
|
||||
join: async () => {
|
||||
const id = `p${++nextId}`;
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
|
||||
return { id };
|
||||
},
|
||||
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
const me = state.players[id];
|
||||
if (!me) return { ok: false };
|
||||
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
|
||||
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
|
||||
return { ok: true };
|
||||
},
|
||||
leave: async ({ id }: { id: string }) => {
|
||||
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
|
||||
await publish({ players: rest });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const wss = new WebSocketServer({ server: http });
|
||||
wss.on("connection", (ws, req) => {
|
||||
const odin = req.url === "/odin";
|
||||
ws.on("close", () => subscribers.delete(ws));
|
||||
ws.on("message", async raw => {
|
||||
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
|
||||
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
|
||||
send(ws, {
|
||||
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
|
||||
verses: [{
|
||||
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
|
||||
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
|
||||
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
|
||||
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
|
||||
}],
|
||||
});
|
||||
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
|
||||
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
|
||||
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
|
||||
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
|
||||
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
|
||||
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
|
||||
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
|
||||
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
|
||||
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
|
||||
const handler = operations[m.operation];
|
||||
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
|
||||
send(ws, {
|
||||
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
|
||||
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
|
||||
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
odinUrl: `${endpoint}/odin`,
|
||||
state: () => cache.getRequired(arenaDocument, arenaKey),
|
||||
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## client.ts
|
||||
|
||||
```ts
|
||||
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
|
||||
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
||||
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
|
||||
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
verseId, authorityRuntimeId, runtimeId, rendezvous,
|
||||
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
|
||||
});
|
||||
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
|
||||
const call = async (operation: string, payload: object) =>
|
||||
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
|
||||
|
||||
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
|
||||
const { id } = await call("join", {});
|
||||
lease.watch(record => {
|
||||
if (!record) return;
|
||||
latest = decodeCultNetPayload(record, arenaDocument);
|
||||
onState(latest, id!);
|
||||
});
|
||||
return {
|
||||
id: id!,
|
||||
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
|
||||
state: () => latest,
|
||||
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`browser.ts`, `index.html` and `serve.ts` in [`io-game/`](io-game/) draw the canvas and bundle the client with esbuild.
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { joinArena } from "./client.ts";
|
||||
import { startArena } from "./server.ts";
|
||||
import { arenaSize, type ArenaState } from "./schema.ts";
|
||||
|
||||
const until = async (cond: () => boolean) => {
|
||||
for (let i = 0; i < 200 && !cond(); i++) await new Promise(r => setTimeout(r, 10));
|
||||
assert.ok(cond(), "condition not reached in 2s");
|
||||
};
|
||||
|
||||
test("two clients join one arena, see each other, and see each other move", async () => {
|
||||
const arena = await startArena();
|
||||
const seenByA: ArenaState[] = [];
|
||||
const seenByB: ArenaState[] = [];
|
||||
const a = await joinArena(arena.odinUrl, "client-a", s => seenByA.push(structuredClone(s)));
|
||||
const b = await joinArena(arena.odinUrl, "client-b", s => seenByB.push(structuredClone(s)));
|
||||
try {
|
||||
assert.notEqual(a.id, b.id);
|
||||
await until(() => Object.keys(a.state()?.players ?? {}).length === 2);
|
||||
await until(() => Object.keys(b.state()?.players ?? {}).length === 2);
|
||||
|
||||
const before = structuredClone(b.state()!.players[a.id]!);
|
||||
// Moves clamp at the arena edge, so pick a direction that always has room to move.
|
||||
const dx = before.x > 0 ? -1 : 1;
|
||||
await a.move(dx, 0);
|
||||
await until(() => b.state()!.players[a.id]!.x !== before.x);
|
||||
assert.equal(b.state()!.players[a.id]!.x, before.x + dx);
|
||||
assert.deepEqual(a.state(), arena.state());
|
||||
assert.deepEqual(b.state(), arena.state());
|
||||
|
||||
await b.leave();
|
||||
await until(() => !(a.id !== b.id && b.id in a.state()!.players));
|
||||
assert.deepEqual(Object.keys(a.state()!.players), [a.id]);
|
||||
} finally {
|
||||
await a.leave().catch(() => undefined);
|
||||
await arena.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a player cannot walk out of the arena", async () => {
|
||||
const arena = await startArena();
|
||||
const a = await joinArena(arena.odinUrl, "client-a", () => undefined);
|
||||
try {
|
||||
for (let i = 0; i <= arenaSize; i++) await a.move(-1, 0);
|
||||
assert.equal(arena.state().players[a.id]!.x, 0);
|
||||
} finally {
|
||||
await a.leave();
|
||||
await arena.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("serve.ts bundles the browser client and serves the page", async () => {
|
||||
const { serveArena } = await import("./serve.ts");
|
||||
const site = await serveArena(0);
|
||||
try {
|
||||
const html = await (await fetch(site.url)).text();
|
||||
assert.match(html, /<canvas/);
|
||||
const js = await (await fetch(`${site.url}/browser.js`)).text();
|
||||
assert.match(js, /CultMeshBrowserClient/);
|
||||
} finally {
|
||||
await site.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the recipe page shows the source files verbatim", () => {
|
||||
const page = readFileSync(new URL("../io-game.md", import.meta.url), "utf8").replace(/\r\n/g, "\n");
|
||||
for (const file of ["schema.ts", "server.ts", "client.ts"]) {
|
||||
const text = readFileSync(new URL(`./${file}`, import.meta.url), "utf8").replace(/\r\n/g, "\n");
|
||||
assert.ok(page.includes(text), `docs/start/io-game.md no longer shows ${file} verbatim`);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { joinArena } from "./client.ts";
|
||||
import { arenaSize, type ArenaState } from "./schema.ts";
|
||||
|
||||
const canvas = document.querySelector("canvas")!;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const scale = canvas.width / arenaSize;
|
||||
|
||||
const game = await joinArena(`ws://${location.host}/odin`, `browser-${crypto.randomUUID()}`, (state: ArenaState, me: string) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const [id, p] of Object.entries(state.players)) {
|
||||
ctx.fillStyle = id === me ? "#f5c542" : "#6aa9ff";
|
||||
ctx.fillRect(p.x * scale, p.y * scale, scale * 2, scale * 2);
|
||||
}
|
||||
});
|
||||
const keys: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
addEventListener("keydown", e => { const d = keys[e.key]; if (d) void game.move(...d); });
|
||||
addEventListener("pagehide", () => void game.leave());
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
|
||||
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
||||
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
|
||||
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
verseId, authorityRuntimeId, runtimeId, rendezvous,
|
||||
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
|
||||
});
|
||||
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
|
||||
const call = async (operation: string, payload: object) =>
|
||||
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
|
||||
|
||||
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
|
||||
const { id } = await call("join", {});
|
||||
lease.watch(record => {
|
||||
if (!record) return;
|
||||
latest = decodeCultNetPayload(record, arenaDocument);
|
||||
onState(latest, id!);
|
||||
});
|
||||
return {
|
||||
id: id!,
|
||||
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
|
||||
state: () => latest,
|
||||
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>arena</title>
|
||||
<canvas width="500" height="500" style="border:1px solid #888"></canvas>
|
||||
<p>Arrow keys. Open this page in a second window.</p>
|
||||
<script type="module" src="/browser.js"></script>
|
||||
64
docs/start/io-game/package-lock.json
generated
64
docs/start/io-game/package-lock.json
generated
|
|
@ -1,64 +0,0 @@
|
|||
{
|
||||
"name": "cultlib-start-io-game",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cultlib-start-io-game",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
|
||||
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"name": "cultlib-start-io-game",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "docs/start recipe: a tiny two-client browser game over CultCache + CultMesh (TypeScript).",
|
||||
"scripts": { "test": "node --test arena.test.ts" },
|
||||
"dependencies": { "ws": "^8.18.0" },
|
||||
"devDependencies": { "@types/ws": "^8.5.0" }
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
|
||||
|
||||
export interface Player { x: number; y: number }
|
||||
export interface ArenaState { players: Record<string, Player> }
|
||||
|
||||
// The one shared document: every player's position. The server owns it; clients lease it.
|
||||
export const arenaDocument = defineDocumentType({
|
||||
type: "io.arena_state",
|
||||
schemaId: "io.arena_state.v1",
|
||||
schema: { parse: (value: unknown) => value as ArenaState },
|
||||
});
|
||||
|
||||
export const arenaKey = "arena:main";
|
||||
export const verseId = "io.arena";
|
||||
export const authorityRuntimeId = "io.arena-daemon";
|
||||
export const arenaSize = 100;
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { build } from "esbuild";
|
||||
import { startArena } from "./server.ts";
|
||||
|
||||
// Serves the page, the bundled browser client, and the arena's WebSocket on one port.
|
||||
export async function serveArena(port = 8080, host = "127.0.0.1") {
|
||||
const bundle = await build({ entryPoints: [new URL("./browser.ts", import.meta.url).pathname], bundle: true, format: "esm", target: "es2022", write: false });
|
||||
const html = readFileSync(new URL("./index.html", import.meta.url));
|
||||
const arena = await startArena(port, host, (req, res) => {
|
||||
if (req.url === "/") res.writeHead(200, { "content-type": "text/html" }).end(html);
|
||||
else if (req.url === "/browser.js") res.writeHead(200, { "content-type": "text/javascript" }).end(bundle.outputFiles[0]!.contents);
|
||||
else res.writeHead(404).end();
|
||||
});
|
||||
return { url: arena.odinUrl.replace("ws:", "http:").replace("/odin", ""), close: arena.close };
|
||||
}
|
||||
|
||||
if (import.meta.main) console.log(`arena at ${(await serveArena()).url}`);
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { createServer, type RequestListener, type Server } from "node:http";
|
||||
import { CultCache } from "@gamecult/cultcache-ts";
|
||||
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
|
||||
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
|
||||
|
||||
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
|
||||
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
|
||||
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
|
||||
await cache.put(arenaDocument, arenaKey, { players: {} });
|
||||
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
|
||||
await new Promise<void>(resolve => http.listen(port, host, resolve));
|
||||
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
|
||||
|
||||
const subscribers = new Map<WebSocket, string[]>();
|
||||
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
|
||||
const record = () => ({
|
||||
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
|
||||
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
|
||||
payloadEncoding: "messagepack" as const,
|
||||
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
|
||||
sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
const publish = async (next: ArenaState) => {
|
||||
await cache.put(arenaDocument, arenaKey, next);
|
||||
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
|
||||
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
|
||||
}
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
const operations: Record<string, (p: any) => Promise<object>> = {
|
||||
join: async () => {
|
||||
const id = `p${++nextId}`;
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
|
||||
return { id };
|
||||
},
|
||||
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
const me = state.players[id];
|
||||
if (!me) return { ok: false };
|
||||
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
|
||||
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
|
||||
return { ok: true };
|
||||
},
|
||||
leave: async ({ id }: { id: string }) => {
|
||||
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
|
||||
await publish({ players: rest });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const wss = new WebSocketServer({ server: http });
|
||||
wss.on("connection", (ws, req) => {
|
||||
const odin = req.url === "/odin";
|
||||
ws.on("close", () => subscribers.delete(ws));
|
||||
ws.on("message", async raw => {
|
||||
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
|
||||
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
|
||||
send(ws, {
|
||||
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
|
||||
verses: [{
|
||||
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
|
||||
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
|
||||
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
|
||||
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
|
||||
}],
|
||||
});
|
||||
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
|
||||
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
|
||||
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
|
||||
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
|
||||
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
|
||||
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
|
||||
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
|
||||
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
|
||||
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
|
||||
const handler = operations[m.operation];
|
||||
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
|
||||
send(ws, {
|
||||
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
|
||||
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
|
||||
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
odinUrl: `${endpoint}/odin`,
|
||||
state: () => cache.getRequired(arenaDocument, arenaKey),
|
||||
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
|
||||
};
|
||||
}
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -482,18 +482,6 @@
|
|||
"resolved": "packages/cultcache-ts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gamecult/cultmesh-browser": {
|
||||
"resolved": "packages/cultmesh-browser",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gamecult/cultmesh-ts": {
|
||||
"resolved": "packages/cultmesh-ts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gamecult/cultnet-ts": {
|
||||
"resolved": "packages/cultnet-ts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
|
|
@ -877,6 +865,18 @@
|
|||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/cultmesh-browser": {
|
||||
"resolved": "packages/cultmesh-browser",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cultmesh-ts": {
|
||||
"resolved": "packages/cultmesh-ts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cultnet-ts": {
|
||||
"resolved": "packages/cultnet-ts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
|
|
@ -1184,7 +1184,7 @@
|
|||
},
|
||||
"packages/cultcache-ts": {
|
||||
"name": "@gamecult/cultcache-ts",
|
||||
"version": "0.15.0",
|
||||
"version": "0.14.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
|
|
@ -1200,16 +1200,15 @@
|
|||
}
|
||||
},
|
||||
"packages/cultmesh-browser": {
|
||||
"name": "@gamecult/cultmesh-browser",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.15.0",
|
||||
"@gamecult/cultnet-ts": "^0.3.0"
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"cultnet-ts": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gamecult/cultmesh-ts": "^0.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"cultmesh-ts": "^0.2.0",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1217,12 +1216,11 @@
|
|||
}
|
||||
},
|
||||
"packages/cultmesh-ts": {
|
||||
"name": "@gamecult/cultmesh-ts",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.15.0",
|
||||
"@gamecult/cultnet-ts": "^0.3.0",
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"cultnet-ts": "^0.3.0",
|
||||
"koffi": "3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1235,11 +1233,10 @@
|
|||
}
|
||||
},
|
||||
"packages/cultnet-ts": {
|
||||
"name": "@gamecult/cultnet-ts",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.15.0",
|
||||
"@gamecult/cultcache-ts": "^0.14.1",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,11 +2,6 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import.
|
||||
- The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `<path>.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `<path>.tmp-<pid>-<uuid>`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `<path>.tmp` is no longer used.
|
||||
|
||||
### Breaking
|
||||
|
||||
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ from collections.abc import Mapping, Sequence, Set
|
|||
from itertools import chain
|
||||
from typing import Any, Callable, Generic, Iterable, TypeVar
|
||||
|
||||
import msgpack
|
||||
from msgpack.exceptions import StackError as _MsgpackStackError
|
||||
|
||||
from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
|
||||
from .leaves import Float32, SlotCodec
|
||||
from .leaves import any as _any_leaf
|
||||
|
|
@ -27,7 +24,12 @@ or skipped, the outermost one is depth 1; depth 256 is accepted and 257 refused,
|
|||
MAX_SLOT_KEY = 65535
|
||||
"""The largest slot key a record may define: the largest MessagePack array16 length."""
|
||||
|
||||
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
|
||||
try:
|
||||
from msgpack.exceptions import StackError as _MsgpackStackError
|
||||
|
||||
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
|
||||
except ModuleNotFoundError:
|
||||
_STACK_FAULTS = ()
|
||||
|
||||
|
||||
_CONTAINERS = (Mapping, Sequence, Set)
|
||||
|
|
@ -83,10 +85,22 @@ def _json_payload_decoder(payload: bytes) -> Any:
|
|||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
|
||||
def _msgpack() -> Any:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"MessagePack payload encoding requires the optional 'msgpack' dependency. "
|
||||
"Install with: python -m pip install cultcache-py[msgpack]"
|
||||
) from exc
|
||||
return msgpack
|
||||
|
||||
|
||||
def _pack_tree(tree: Any) -> bytes:
|
||||
"""The bytes of the tree the leaves produced. `packb` writes everything but a `Float32`, which it refuses with a
|
||||
TypeError; only then does an iterative walk pack each array and map header and each value, a `Float32` through a
|
||||
single-float packer."""
|
||||
msgpack = _msgpack()
|
||||
try:
|
||||
return msgpack.packb(tree, use_bin_type=True) # type: ignore[no-any-return]
|
||||
except TypeError:
|
||||
|
|
@ -111,6 +125,13 @@ def _pack_tree(tree: Any) -> bytes:
|
|||
|
||||
|
||||
def _messagepack_payload_decoder(payload: bytes) -> Any:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"MessagePack payload decoding requires the optional 'msgpack' dependency. "
|
||||
"Install with: python -m pip install cultcache-py[msgpack]"
|
||||
) from exc
|
||||
return msgpack.unpackb(payload, raw=False)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ from collections.abc import Mapping
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, NoReturn
|
||||
|
||||
from msgpack import ExtType as _ExtType
|
||||
try:
|
||||
from msgpack import ExtType as _ExtType
|
||||
except ModuleNotFoundError:
|
||||
_ExtType = () # type: ignore[assignment,misc]
|
||||
|
||||
_bool, _bytes, _list, _int, _float, _str = bool, bytes, list, int, float, str
|
||||
_BYTE_LIKE = (_bytes, bytearray, memoryview)
|
||||
|
|
|
|||
|
|
@ -3,15 +3,9 @@
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, TypeVar
|
||||
|
||||
import msgpack
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from .backing_store import (
|
||||
CultCacheEnvelope,
|
||||
|
|
@ -27,8 +21,6 @@ STORE_FORMAT_ELEMENT_IDS = "cultcache.store.v3"
|
|||
_READABLE_STORE_FORMATS = (STORE_FORMAT_VERSION, STORE_FORMAT_ELEMENT_IDS)
|
||||
_STORE_FORMAT_PREFIX = "cultcache.store."
|
||||
_PERSISTED_RECORD_SLOTS = 4
|
||||
_LOCK_POLL_SECONDS = 0.01
|
||||
LOCK_TIMEOUT_SECONDS = 30.0
|
||||
_Contents = TypeVar("_Contents")
|
||||
|
||||
|
||||
|
|
@ -122,14 +114,14 @@ class JsonLinesBackingStore:
|
|||
|
||||
|
||||
class SingleFileMessagePackBackingStore:
|
||||
def __init__(self, path: str | os.PathLike[str], lock_timeout: float = LOCK_TIMEOUT_SECONDS) -> None:
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
self.lock_timeout = lock_timeout
|
||||
self._lock = threading.RLock()
|
||||
# The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker.
|
||||
self._format = STORE_FORMAT_VERSION
|
||||
|
||||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
msgpack = self._msgpack()
|
||||
with self._lock:
|
||||
# An empty file is not a store (no writer leaves one), so it is decoded and refused.
|
||||
# The disk decides the header: a file that is gone is not marked.
|
||||
|
|
@ -143,34 +135,39 @@ class SingleFileMessagePackBackingStore:
|
|||
if snapshot is not None:
|
||||
self._format, envelopes = snapshot
|
||||
return envelopes
|
||||
return _decode_legacy_envelopes(decoded)
|
||||
return _decode_legacy_envelopes(decoded, msgpack)
|
||||
except Exception as exc:
|
||||
raise StoreUnreadableError(self.path, exc) from exc
|
||||
|
||||
def push(self, envelope: CultCacheEnvelope) -> None:
|
||||
with self._write_transaction():
|
||||
with self._lock:
|
||||
self._replace_all([envelope], self.pull_all())
|
||||
|
||||
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None:
|
||||
with self._write_transaction():
|
||||
with self._lock:
|
||||
self._replace_all(list(envelopes), self.pull_all())
|
||||
|
||||
def delete(self, type: str, key: str) -> None:
|
||||
with self._write_transaction():
|
||||
with self._lock:
|
||||
self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)])
|
||||
|
||||
@contextmanager
|
||||
def _write_transaction(self) -> Iterator[None]:
|
||||
"""The thread lock, then the cross-process lock on `<path>.lock`, held across the read and the replace so two
|
||||
processes never rewrite from the same stale read."""
|
||||
with self._lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with store_write_lock(self.path, self.lock_timeout):
|
||||
yield
|
||||
|
||||
def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None:
|
||||
commit_store_file(self.path, msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True))
|
||||
msgpack = self._msgpack()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temp.write_bytes(msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True))
|
||||
temp.replace(self.path)
|
||||
|
||||
@staticmethod
|
||||
def _msgpack() -> Any:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"SingleFileMessagePackBackingStore requires the optional 'msgpack' dependency. "
|
||||
"Install with: python -m pip install cultcache-py[msgpack]"
|
||||
) from exc
|
||||
return msgpack
|
||||
|
||||
|
||||
def canonical(candidates: Any) -> list[CultCacheSchemaCatalogEntry]:
|
||||
|
|
@ -264,85 +261,6 @@ def _encode_snapshot(
|
|||
]
|
||||
return [format_version, catalog, records]
|
||||
|
||||
def commit_store_file(path: Path, data: bytes) -> None:
|
||||
"""Writes `data` to a unique staged file beside the store, fsyncs it, replaces the store with it and, off Windows,
|
||||
fsyncs the directory so the replace is durable. A failure before the replace removes the staged file and leaves the
|
||||
store untouched. The caller holds the store lock."""
|
||||
staged = path.with_name(f"{path.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}")
|
||||
try:
|
||||
fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
view = view[os.write(fd, view):]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.replace(staged, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
if sys.platform != "win32":
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
|
||||
"""Holds the exclusive store lock on `<path>.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix,
|
||||
LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is
|
||||
created if absent and never written or deleted."""
|
||||
lock_path = path.with_name(path.name + ".lock")
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
release = _try_lock(lock_path)
|
||||
if release is not None:
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.")
|
||||
time.sleep(_LOCK_POLL_SECONDS)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
release()
|
||||
|
||||
|
||||
def _try_lock(lock_path: Path) -> Callable[[], None] | None:
|
||||
if sys.platform == "win32":
|
||||
return _try_lock_windows(lock_path)
|
||||
import fcntl
|
||||
|
||||
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o666)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
return None
|
||||
return lambda: os.close(fd) # closing drops the flock
|
||||
|
||||
|
||||
def _try_lock_windows(lock_path: Path) -> Callable[[], None] | None:
|
||||
import ctypes
|
||||
import msvcrt
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p]
|
||||
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_BINARY, 0o666)
|
||||
handle = msvcrt.get_osfhandle(fd)
|
||||
overlapped = ctypes.create_string_buffer(32)
|
||||
# LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY over the whole range, as the TypeScript runtime takes it.
|
||||
if not kernel.LockFileEx(handle, 0x3, 0, 0xFFFFFFFF, 0xFFFFFFFF, overlapped):
|
||||
os.close(fd)
|
||||
return None
|
||||
return lambda: os.close(fd) # closing drops the lock
|
||||
|
||||
|
||||
def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None:
|
||||
"""Reads a store file, or returns None when nothing is at its path.
|
||||
|
|
@ -433,7 +351,7 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
|
|||
return decoded[0], envelopes
|
||||
|
||||
|
||||
def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]:
|
||||
def _decode_legacy_envelopes(decoded: Any, msgpack: Any) -> list[CultCacheEnvelope]:
|
||||
if not isinstance(decoded, list):
|
||||
raise ValueError("CultCache MessagePack store is not a recognized snapshot")
|
||||
envelopes: list[CultCacheEnvelope] = []
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import msgpack
|
||||
|
||||
from cultcache_py import database_entry_field, define_database_entry_type, define_record_codec, leaves as t
|
||||
|
||||
# tests/vectors/slot-nil is written only by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a
|
||||
# str in a byte[] slot, and a record short of its last slots. This runtime accepts what the reference accepts and rewrites
|
||||
# the same bytes.
|
||||
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "slot-nil"
|
||||
|
||||
|
||||
def _probe():
|
||||
inner = define_record_codec("SlotNilInner", [database_entry_field("x", 0, default="", codec=t.string)])
|
||||
return define_database_entry_type(
|
||||
"tests.slot-nil",
|
||||
[
|
||||
database_entry_field("s", 0, default="", codec=t.nullable(t.string)),
|
||||
database_entry_field("i", 1, default=0, codec=t.int32),
|
||||
database_entry_field("b", 2, default=b"", codec=t.nullable(t.bytes)),
|
||||
database_entry_field("r", 3, default=None, codec=t.nullable(inner)),
|
||||
database_entry_field("l", 4, default=[], codec=t.nullable(t.list(t.string))),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class SlotNilVectorTests(unittest.TestCase):
|
||||
def test_slots_read_and_rewrite_exactly_what_the_reference_does_for_nil_str_in_bytes_and_short_records(self) -> None:
|
||||
rows = msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)
|
||||
probe = _probe()
|
||||
self.assertEqual(17, len(rows))
|
||||
accepted = 0
|
||||
for name, payload, was_accepted, rewritten in rows:
|
||||
with self.subTest(name):
|
||||
if was_accepted:
|
||||
self.assertEqual(rewritten.hex(), probe.encode_payload(probe.decode_payload(payload)).hex())
|
||||
accepted += 1
|
||||
else:
|
||||
with self.assertRaises(Exception):
|
||||
probe.decode_payload(payload)
|
||||
self.assertEqual(15, accepted)
|
||||
|
||||
def test_a_character_outside_the_bmp_is_four_utf8_bytes(self) -> None:
|
||||
rows = {name: (payload, rewritten) for name, payload, _, rewritten in msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)}
|
||||
probe = _probe()
|
||||
self.assertEqual("\U0001F600", probe.decode_payload(rows["string-astral"][0])["s"])
|
||||
self.assertEqual(b"\xf0\x9f\x98\x80", probe.decode_payload(rows["bytes-str-astral"][0])["b"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from cultcache_py import SingleFileMessagePackBackingStore, stores
|
||||
from cultcache_py.backing_store import CultCacheEnvelope
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js"
|
||||
HOLD_SECONDS = 1.5
|
||||
|
||||
# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package.
|
||||
PY_HOLDER = """
|
||||
import fcntl, os, sys, time
|
||||
fd = os.open(sys.argv[1] + '.lock', os.O_RDWR | os.O_CREAT, 0o666)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
print('HELD', flush=True)
|
||||
time.sleep(float(sys.argv[2]))
|
||||
"""
|
||||
# A holder that is the TypeScript runtime's own withStoreWriteLock, built from packages/cultcache-ts.
|
||||
TS_HOLDER = """
|
||||
const { withStoreWriteLock } = require(process.argv[1]);
|
||||
withStoreWriteLock(process.argv[2], async () => {
|
||||
console.log('HELD');
|
||||
await new Promise((r) => setTimeout(r, Number(process.argv[3]) * 1000));
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
def envelope(key: str) -> CultCacheEnvelope:
|
||||
return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
|
||||
|
||||
|
||||
def start_holder(argv: list[str]) -> subprocess.Popen[str]:
|
||||
holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
|
||||
assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD"
|
||||
holder.held_at = time.monotonic() # type: ignore[attr-defined]
|
||||
return holder
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "the foreign holders here use flock; Windows is covered by the TS holder")
|
||||
class StoreLockTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.path = self.dir / "state.cc"
|
||||
|
||||
def assert_write_waits_for(self, holder: subprocess.Popen[str]) -> None:
|
||||
self.addCleanup(holder.wait)
|
||||
self.addCleanup(holder.stdout.close) # type: ignore[union-attr]
|
||||
refused = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
|
||||
with self.assertRaises(TimeoutError):
|
||||
refused.push(envelope("blocked"))
|
||||
self.assertFalse(self.path.exists(), "a write that could not take the lock must not touch the store")
|
||||
|
||||
SingleFileMessagePackBackingStore(self.path, lock_timeout=10).push(envelope("after"))
|
||||
self.assertGreaterEqual(time.monotonic() - holder.held_at, HOLD_SECONDS - 0.1, "the write finished while the holder still held the lock") # type: ignore[attr-defined]
|
||||
self.assertEqual(["after"], [e.key for e in SingleFileMessagePackBackingStore(self.path).pull_all()])
|
||||
|
||||
def test_write_waits_while_a_foreign_process_holds_the_lock(self) -> None:
|
||||
self.assert_write_waits_for(start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]))
|
||||
|
||||
@unittest.skipUnless(shutil.which("node") and TS_STORE_FILE.exists(), "needs node and a built packages/cultcache-ts (npm run build)")
|
||||
def test_write_waits_while_the_typescript_runtime_holds_the_lock(self) -> None:
|
||||
holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)])
|
||||
self.assert_write_waits_for(holder)
|
||||
|
||||
def test_the_lock_is_released_after_each_write(self) -> None:
|
||||
store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
|
||||
for key in "abc":
|
||||
store.push(envelope(key))
|
||||
self.assertEqual(["a", "b", "c"], sorted(e.key for e in store.pull_all()))
|
||||
|
||||
def test_concurrent_processes_lose_no_writes(self) -> None:
|
||||
script = (
|
||||
"import sys\n"
|
||||
"from cultcache_py import SingleFileMessagePackBackingStore as S\n"
|
||||
"from cultcache_py.backing_store import CultCacheEnvelope as E\n"
|
||||
"s = S(sys.argv[1])\n"
|
||||
"for i in range(25):\n"
|
||||
" s.push(E(key=f'{sys.argv[2]}{i}', type='t', payload=b'\\x01', stored_at='x'))\n"
|
||||
)
|
||||
env = {**os.environ, "PYTHONPATH": str(Path(stores.__file__).parents[1])}
|
||||
procs = [subprocess.Popen([sys.executable, "-c", script, str(self.path), tag], env=env) for tag in "pqr"]
|
||||
for proc in procs:
|
||||
self.assertEqual(0, proc.wait())
|
||||
self.assertEqual(75, len(SingleFileMessagePackBackingStore(self.path).pull_all()))
|
||||
|
||||
|
||||
class StagedWriteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.path = self.dir / "state.cc"
|
||||
|
||||
def test_the_staged_file_is_fsynced_before_the_replace(self) -> None:
|
||||
order: list[str] = []
|
||||
real_fsync, real_replace = os.fsync, os.replace
|
||||
with mock.patch("os.fsync", side_effect=lambda fd: (order.append("fsync"), real_fsync(fd))[1]), mock.patch(
|
||||
"os.replace", side_effect=lambda a, b: (order.append("replace"), real_replace(a, b))[1]
|
||||
):
|
||||
SingleFileMessagePackBackingStore(self.path).push(envelope("a"))
|
||||
self.assertEqual("fsync", order[0])
|
||||
self.assertLess(order.index("fsync"), order.index("replace"))
|
||||
|
||||
def test_the_staged_name_is_unique_per_write(self) -> None:
|
||||
names: list[str] = []
|
||||
real_replace = os.replace
|
||||
with mock.patch("os.replace", side_effect=lambda a, b: (names.append(os.fspath(a)), real_replace(a, b))[1]):
|
||||
store = SingleFileMessagePackBackingStore(self.path)
|
||||
store.push(envelope("a"))
|
||||
store.push(envelope("b"))
|
||||
self.assertEqual(2, len(set(names)))
|
||||
self.assertNotIn(os.fspath(self.path) + ".tmp", names)
|
||||
|
||||
def test_a_failed_replace_leaves_the_store_and_no_staged_file(self) -> None:
|
||||
store = SingleFileMessagePackBackingStore(self.path)
|
||||
store.push(envelope("a"))
|
||||
before = self.path.read_bytes()
|
||||
with mock.patch("os.replace", side_effect=OSError("boom")), self.assertRaises(OSError):
|
||||
store.push(envelope("b"))
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
self.assertEqual([], [p.name for p in self.dir.iterdir() if ".tmp" in p.name])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -13,7 +13,6 @@ only in git history.
|
|||
|
||||
### Added
|
||||
|
||||
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name or value find the one with the smallest key. A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
|
||||
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`.
|
||||
|
||||
## [0.3.1]
|
||||
|
|
|
|||
|
|
@ -174,9 +174,6 @@ without pretending Rust has runtime reflection hiding under the floorboards.
|
|||
- `get_required_envelope::<T>`
|
||||
- `get_all::<T>`
|
||||
- `get_all_with_keys::<T>`
|
||||
- `get_by_name::<T>`
|
||||
- `get_by_index::<T>`
|
||||
- `get_global::<T>`
|
||||
- `soa::<T>`
|
||||
- `put::<T>`
|
||||
- `put_envelope::<T>`
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
let ident = input.ident.clone();
|
||||
let mut entry_type: Option<LitStr> = None;
|
||||
let mut schema_name: Option<LitStr> = None;
|
||||
let mut global = false;
|
||||
|
||||
for attribute in input
|
||||
.attrs
|
||||
|
|
@ -38,12 +37,6 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
once(&schema_name, &meta, "schema")?;
|
||||
schema_name = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("global") {
|
||||
if global {
|
||||
return Err(meta.error("repeated `global`: #[cultcache(global)] is given once"));
|
||||
}
|
||||
global = true;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported cultcache attribute"))
|
||||
}
|
||||
|
|
@ -61,40 +54,11 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
|
||||
let slots = named_slots(input.data, &ident, "DatabaseEntry")?;
|
||||
let codec = slot_codec(&ident, &slots);
|
||||
let name_member = slots.iter().find(|slot| slot.name).map(|slot| &slot.ident);
|
||||
let name_fn = name_member.map(|member| {
|
||||
quote! {
|
||||
fn cult_name(&self) -> ::std::option::Option<::std::string::String> {
|
||||
::cultcache_rs::CultLookupValue::lookup_value(&self.#member)
|
||||
}
|
||||
}
|
||||
});
|
||||
let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident)));
|
||||
let index_pushes = index_members.map(|(alias, member)| {
|
||||
quote! {
|
||||
if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) {
|
||||
held.push((#alias, value));
|
||||
}
|
||||
}
|
||||
});
|
||||
let index_pushes: Vec<_> = index_pushes.collect();
|
||||
let indexes_fn = (!index_pushes.is_empty()).then(|| {
|
||||
quote! {
|
||||
fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> {
|
||||
let mut held = ::std::vec::Vec::new();
|
||||
#(#index_pushes)*
|
||||
held
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(quote! {
|
||||
impl ::cultcache_rs::DatabaseEntry for #ident {
|
||||
const TYPE: &'static str = #entry_type;
|
||||
const SCHEMA_NAME: &'static str = #schema_name;
|
||||
const GLOBAL: bool = #global;
|
||||
#name_fn
|
||||
#indexes_fn
|
||||
}
|
||||
|
||||
#codec
|
||||
|
|
@ -169,8 +133,6 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
|
|||
let mut key: Option<usize> = None;
|
||||
let mut default = false;
|
||||
let mut bytes = false;
|
||||
let mut name = false;
|
||||
let mut index: Option<String> = None;
|
||||
for attribute in field
|
||||
.attrs
|
||||
.iter()
|
||||
|
|
@ -195,22 +157,6 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
|
|||
} else if meta.path.is_ident("bytes") {
|
||||
bytes = true;
|
||||
Ok(())
|
||||
} else if owner == "DatabaseEntry" && meta.path.is_ident("name") {
|
||||
if name {
|
||||
return Err(meta.error("repeated `name`: #[cultcache(name)] is given once"));
|
||||
}
|
||||
name = true;
|
||||
Ok(())
|
||||
} else if owner == "DatabaseEntry" && meta.path.is_ident("index") {
|
||||
once(&index, &meta, "index")?;
|
||||
// A bare `index`, or an alias that is blank, names the field itself.
|
||||
let alias = if meta.input.peek(syn::Token![=]) {
|
||||
meta.value()?.parse::<LitStr>()?.value()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
index = Some(if alias.trim().is_empty() { field_ident.to_string() } else { alias });
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported field cultcache attribute"))
|
||||
}
|
||||
|
|
@ -234,28 +180,8 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
|
|||
ty: field.ty,
|
||||
default,
|
||||
bytes,
|
||||
name,
|
||||
index,
|
||||
});
|
||||
}
|
||||
if let Some(second) = slots.iter().filter(|slot| slot.name).nth(1) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&second.ident,
|
||||
format!("{owner} declares #[cultcache(name)] on more than one member; a record has one name"),
|
||||
));
|
||||
}
|
||||
for (position, slot) in slots.iter().enumerate() {
|
||||
let Some(alias) = &slot.index else { continue };
|
||||
if let Some(first) = slots[..position].iter().find(|earlier| earlier.index.as_ref() == Some(alias)) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&slot.ident,
|
||||
format!(
|
||||
"{owner} members {} and {} both declare index alias \"{alias}\"; an index alias must name one member",
|
||||
first.ident, slot.ident
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
slots.sort_by_key(|slot| slot.key);
|
||||
Ok(slots)
|
||||
}
|
||||
|
|
@ -441,10 +367,6 @@ struct CultCacheSlot {
|
|||
ty: Type,
|
||||
default: bool,
|
||||
bytes: bool,
|
||||
/// `#[cultcache(name)]`: the member `get_by_name` finds the record by.
|
||||
name: bool,
|
||||
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: the alias `get_by_index` finds the record by.
|
||||
index: Option<String>,
|
||||
}
|
||||
|
||||
fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
||||
|
|
@ -920,89 +842,4 @@ mod tests {
|
|||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted");
|
||||
}
|
||||
fn entry_error(source: &str) -> String {
|
||||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
database_entry_codec(input).expect_err("the fixture is refused").to_string()
|
||||
}
|
||||
|
||||
fn entry_tokens(source: &str) -> String {
|
||||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
database_entry_codec(input).expect("the fixture is accepted").to_string().replace(' ', "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_index_and_global_generate_the_lookup_surface() {
|
||||
let tokens = entry_tokens(
|
||||
r#"#[cultcache(type = "t", global)] struct S {
|
||||
#[cultcache(key = 0, name)] label: String,
|
||||
#[cultcache(key = 1, index)] code: String,
|
||||
#[cultcache(key = 2, index = "sku")] stock: Option<String>,
|
||||
}"#,
|
||||
);
|
||||
assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}");
|
||||
assert!(tokens.contains("fncult_name"), "{tokens}");
|
||||
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
|
||||
assert!(tokens.contains("(\"sku\",value)"), "{tokens}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_without_lookups_generates_none_of_them() {
|
||||
let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#);
|
||||
assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}");
|
||||
assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}");
|
||||
}
|
||||
|
||||
// ResolveIndexAlias: a blank alias is the member's name.
|
||||
#[test]
|
||||
fn a_blank_index_alias_is_the_member_name() {
|
||||
let tokens = entry_tokens(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#,
|
||||
);
|
||||
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_names_are_refused_for_that_reason() {
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name)] a: String, #[cultcache(key = 1, name)] b: String }"#,
|
||||
);
|
||||
assert!(message.contains("more than one member"), "{message}");
|
||||
}
|
||||
|
||||
// D10: two members that declare one alias; a bare index is the member name, so it collides with an explicit alias of it.
|
||||
#[test]
|
||||
fn two_members_with_one_index_alias_are_refused_for_that_reason() {
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = "k")] a: String, #[cultcache(key = 1, index = "k")] b: String }"#,
|
||||
);
|
||||
assert!(message.contains("both declare index alias \"k\""), "{message}");
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index)] a: String, #[cultcache(key = 1, index = "a")] b: String }"#,
|
||||
);
|
||||
assert!(message.contains("both declare index alias \"a\""), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_lookup_attribute_is_refused() {
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name, name)] a: String }"#,
|
||||
);
|
||||
assert!(message.contains("repeated `name`"), "{message}");
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index, index = "x")] a: String }"#,
|
||||
);
|
||||
assert!(message.contains("repeated `index`"), "{message}");
|
||||
let message = entry_error(r#"#[cultcache(type = "t", global, global)] struct S { #[cultcache(key = 0)] a: String }"#);
|
||||
assert!(message.contains("repeated `global`"), "{message}");
|
||||
}
|
||||
|
||||
// Only a DatabaseEntry is looked up: a nested slot record carrying the attributes is refused as before.
|
||||
#[test]
|
||||
fn lookup_attributes_are_refused_on_a_slots_record() {
|
||||
let message = slots_error(r#"struct S { #[cultcache(key = 0, name)] a: String }"#);
|
||||
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
|
||||
let message = slots_error(r#"struct S { #[cultcache(key = 0, index)] a: String }"#);
|
||||
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,68 +162,8 @@ pub use cultcache_rs_derive::DatabaseEntry;
|
|||
pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
|
||||
const TYPE: &'static str;
|
||||
const SCHEMA_NAME: &'static str = "DatabaseEntry";
|
||||
/// `#[cultcache(global)]`: the cache holds at most one record of this type, found by `get_global`.
|
||||
const GLOBAL: bool = false;
|
||||
/// The `#[cultcache(name)]` member's value, which `get_by_name` finds the record by. `None` is no name.
|
||||
fn cult_name(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by.
|
||||
fn cult_indexes(&self) -> Vec<(&'static str, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member
|
||||
/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none.
|
||||
pub trait CultLookupValue {
|
||||
fn lookup_value(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl CultLookupValue for String {
|
||||
fn lookup_value(&self) -> Option<String> {
|
||||
Some(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: CultLookupValue> CultLookupValue for Option<T> {
|
||||
fn lookup_value(&self) -> Option<String> {
|
||||
self.as_ref().and_then(CultLookupValue::lookup_value)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! lookup_value_by_display {
|
||||
($($ty:ty),* $(,)?) => {$(
|
||||
impl CultLookupValue for $ty {
|
||||
fn lookup_value(&self) -> Option<String> {
|
||||
Some(self.to_string())
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
|
||||
|
||||
/// A global type already holds a record under another key; a second would leave `get_global` with two answers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CultCacheSecondGlobal {
|
||||
pub schema_name: String,
|
||||
pub existing_key: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CultCacheSecondGlobal {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"{} is a global and already has record {}; {} would be a second.",
|
||||
self.schema_name, self.existing_key, self.key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CultCacheSecondGlobal {}
|
||||
|
||||
/// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry`
|
||||
/// derives implement, and what a `CultUnion` arm must carry.
|
||||
#[diagnostic::on_unimplemented(
|
||||
|
|
@ -2715,8 +2655,6 @@ struct CultCacheStoreRegistration {
|
|||
|
||||
pub struct CultCache {
|
||||
definitions: BTreeMap<String, &'static str>,
|
||||
/// The registered types declared `#[cultcache(global)]`.
|
||||
globals: BTreeSet<String>,
|
||||
schema_name_definitions: BTreeMap<String, String>,
|
||||
entries: BTreeMap<(String, String), CultCacheEnvelope>,
|
||||
stores: Vec<CultCacheStoreRegistration>,
|
||||
|
|
@ -2726,7 +2664,6 @@ impl CultCache {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
definitions: BTreeMap::new(),
|
||||
globals: BTreeSet::new(),
|
||||
schema_name_definitions: BTreeMap::new(),
|
||||
entries: BTreeMap::new(),
|
||||
stores: Vec::new(),
|
||||
|
|
@ -2757,11 +2694,6 @@ impl CultCache {
|
|||
));
|
||||
}
|
||||
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
|
||||
if T::GLOBAL {
|
||||
self.globals.insert(T::TYPE.to_string());
|
||||
} else {
|
||||
self.globals.remove(T::TYPE);
|
||||
}
|
||||
self.schema_name_definitions
|
||||
.insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string());
|
||||
Ok(())
|
||||
|
|
@ -2868,7 +2800,6 @@ impl CultCache {
|
|||
loaded.insert(entry_id(&entry), entry);
|
||||
}
|
||||
}
|
||||
self.refuse_second_globals(&loaded)?;
|
||||
self.entries = loaded;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2952,65 +2883,6 @@ impl CultCache {
|
|||
Ok(values)
|
||||
}
|
||||
|
||||
/// The one record of a `#[cultcache(global)]` type, or `None` before it is written (and for a type that is
|
||||
/// not a global).
|
||||
pub fn get_global<T: DatabaseEntry>(&self) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if !T::GLOBAL {
|
||||
return Ok(None);
|
||||
}
|
||||
self.find_record::<T>(|_| true)
|
||||
}
|
||||
|
||||
/// The record whose `#[cultcache(name)]` member is `name`. An empty name is no name, so it finds nothing. Records
|
||||
/// of one type sharing a name find the one with the smallest key (ordinal), whatever order they were written or
|
||||
/// loaded in; removing it finds the next.
|
||||
pub fn get_by_name<T: DatabaseEntry>(&self, name: &str) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.find_record::<T>(|document| document.cult_name().as_deref() == Some(name))
|
||||
}
|
||||
|
||||
/// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds
|
||||
/// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they
|
||||
/// were written or loaded in; removing it finds the next.
|
||||
pub fn get_by_index<T: DatabaseEntry>(&self, alias: &str, value: &str) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if value.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.find_record::<T>(|document| {
|
||||
document
|
||||
.cult_indexes()
|
||||
.iter()
|
||||
.any(|(held_alias, held_value)| *held_alias == alias && held_value == value)
|
||||
})
|
||||
}
|
||||
|
||||
/// The first record of `T`, in key order, that `matches`.
|
||||
fn find_record<T: DatabaseEntry>(&self, matches: impl Fn(&T) -> bool) -> Result<Option<T>> {
|
||||
let held = self
|
||||
.entries
|
||||
.range((T::TYPE.to_string(), String::new())..)
|
||||
.take_while(|((held_type, _), _)| held_type == T::TYPE);
|
||||
for (_, entry) in held {
|
||||
let document: T = decode_payload(&entry.payload).with_context(|| {
|
||||
format!(
|
||||
"failed to decode CultCache entry {:?} at key {:?} as {}",
|
||||
T::TYPE,
|
||||
entry.key,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?;
|
||||
if matches(&document) {
|
||||
return Ok(Some(document));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn soa<T: SoaDocument>(&self) -> Result<CultSoaTable<T>> {
|
||||
let keyed_rows = self.get_all_with_keys::<T>()?;
|
||||
let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip();
|
||||
|
|
@ -3028,7 +2900,6 @@ impl CultCache {
|
|||
|
||||
pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> {
|
||||
let (entry, parsed) = self.prepare_entry(key, value)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
if let Some(home) = self.home_index(T::TYPE)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
|
|
@ -3140,7 +3011,6 @@ impl CultCache {
|
|||
for entry in &staged {
|
||||
next.insert(entry_id(entry), entry.clone());
|
||||
}
|
||||
self.refuse_second_globals(&next)?;
|
||||
if let Some(store_index) = store_index {
|
||||
// The home store receives its own records only: those it already
|
||||
// holds in this cache plus the staged ones.
|
||||
|
|
@ -3159,7 +3029,6 @@ impl CultCache {
|
|||
|
||||
pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> {
|
||||
let parsed = self.validate_envelope::<T>(&entry)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
if let Some(home) = self.home_index(T::TYPE)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
|
|
@ -3192,7 +3061,6 @@ impl CultCache {
|
|||
));
|
||||
}
|
||||
require_payload_depth(&entry)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
if let Some(home) = self.home_index(&entry.r#type)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
|
|
@ -3278,43 +3146,10 @@ impl CultCache {
|
|||
self.home_index(&entry.r#type)?;
|
||||
loaded.insert(entry_id(&entry), entry);
|
||||
}
|
||||
self.refuse_second_globals(&loaded)?;
|
||||
self.entries = loaded;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refuse_second_global(
|
||||
&self,
|
||||
entries: &BTreeMap<(String, String), CultCacheEnvelope>,
|
||||
type_id: &str,
|
||||
key: &str,
|
||||
) -> Result<()> {
|
||||
if !self.globals.contains(type_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let held_other = entries
|
||||
.range((type_id.to_string(), String::new())..)
|
||||
.take_while(|((held_type, _), _)| held_type == type_id)
|
||||
.map(|((_, held_key), _)| held_key)
|
||||
.find(|held_key| held_key.as_str() != key);
|
||||
match held_other {
|
||||
Some(existing_key) => Err(CultCacheSecondGlobal {
|
||||
schema_name: self.definitions.get(type_id).copied().unwrap_or(type_id).to_string(),
|
||||
existing_key: existing_key.clone(),
|
||||
key: key.to_string(),
|
||||
}
|
||||
.into()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> {
|
||||
for (type_id, key) in entries.keys() {
|
||||
self.refuse_second_global(entries, type_id, key)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_entry_type<T: DatabaseEntry>(&self) -> Result<()> {
|
||||
match self.definitions.get(T::TYPE) {
|
||||
Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()),
|
||||
|
|
|
|||
|
|
@ -1,352 +0,0 @@
|
|||
//! Lookups parity with the C# reference: `GetByName`, `GetByIndex` and `GetGlobal`
|
||||
//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces.
|
||||
|
||||
use cultcache_rs::{
|
||||
CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry,
|
||||
SingleFileMessagePackBackingStore,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "item")]
|
||||
struct Item {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
category: String,
|
||||
#[cultcache(key = 2, index = "sku")]
|
||||
code: Option<String>,
|
||||
#[cultcache(key = 3, default)]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
fn item(name: &str, category: &str, code: Option<&str>) -> Item {
|
||||
Item { name: name.into(), category: category.into(), code: code.map(Into::into), value: 0 }
|
||||
}
|
||||
|
||||
/// Same names and values as `Item`, a different type: lookups never cross types.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "gadget")]
|
||||
struct Gadget {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
category: String,
|
||||
}
|
||||
|
||||
/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "zed")]
|
||||
struct Zed {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
category: String,
|
||||
#[cultcache(key = 2, index = "sku")]
|
||||
code: Option<String>,
|
||||
#[cultcache(key = 3, default)]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "settings", global)]
|
||||
struct Settings {
|
||||
#[cultcache(key = 0)]
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn settings(value: &str) -> Settings {
|
||||
Settings { value: value.into() }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "plain")]
|
||||
struct Plain {
|
||||
#[cultcache(key = 0)]
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "numbered")]
|
||||
struct Numbered {
|
||||
#[cultcache(key = 0, name)]
|
||||
id: u32,
|
||||
#[cultcache(key = 1, index)]
|
||||
owner: u64,
|
||||
}
|
||||
|
||||
fn cache() -> CultCache {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Item>().unwrap();
|
||||
cache.register_entry_type::<Gadget>().unwrap();
|
||||
cache.register_entry_type::<Zed>().unwrap();
|
||||
cache.register_entry_type::<Settings>().unwrap();
|
||||
cache.register_entry_type::<Plain>().unwrap();
|
||||
cache.register_entry_type::<Numbered>().unwrap();
|
||||
cache
|
||||
}
|
||||
|
||||
fn reopened(path: &std::path::Path) -> CultCache {
|
||||
let mut reopened = cache();
|
||||
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
reopened.pull_all_backing_stores().unwrap();
|
||||
reopened
|
||||
}
|
||||
|
||||
fn stored_cache(path: &std::path::Path) -> CultCache {
|
||||
let mut stored = cache();
|
||||
stored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
stored.pull_all_backing_stores().unwrap();
|
||||
stored
|
||||
}
|
||||
|
||||
// The C# FirstCall snippet: a record written to a .cc file is found by its [CultName] through a second cache.
|
||||
#[test]
|
||||
fn a_record_is_found_by_name_and_by_index_after_a_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("lookups.cc");
|
||||
let mut written = stored_cache(&path);
|
||||
written.put("item:potion", &Item { value: 50, ..item("Potion", "Consumable", Some("P-1")) }).unwrap();
|
||||
|
||||
let reopened = reopened(&path);
|
||||
let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap();
|
||||
assert_eq!(by_name.value, 50);
|
||||
assert_eq!(reopened.get_by_index::<Item>("category", "Consumable").unwrap(), Some(by_name.clone()));
|
||||
assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("A", "cat", Some("a-1"))).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None);
|
||||
assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally");
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "other").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "cat").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name");
|
||||
|
||||
let bare = CultCache::new();
|
||||
assert!(bare.get_by_name::<Item>("A").is_err());
|
||||
assert!(bare.get_by_index::<Item>("category", "cat").is_err());
|
||||
assert!(bare.get_global::<Settings>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_without_an_alias_is_named_by_its_member() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("A", "cat", None)).unwrap();
|
||||
assert!(cache.get_by_index::<Item>("category", "cat").unwrap().is_some());
|
||||
}
|
||||
|
||||
// A blank name is no name; a blank index value is not indexed; a nil member holds no value.
|
||||
#[test]
|
||||
fn blank_and_nil_values_are_not_found() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:blank", &item("", " ", None)).unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", " ").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
|
||||
|
||||
// Only emptiness voids a name: whitespace is a name, as in C# (Length > 0).
|
||||
cache.put("item:space", &item(" ", "x", None)).unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>(" ").unwrap().unwrap().category, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookups_never_cross_types() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("same", "same", None)).unwrap();
|
||||
cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap();
|
||||
cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same");
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "same").unwrap(), None);
|
||||
assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Gadget>("category", "same").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("old", "c1", None)).unwrap();
|
||||
cache.put("item:a", &item("new", "c2", None)).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found");
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "c1").unwrap(), None, "a stale value is not found");
|
||||
assert!(cache.get_by_name::<Item>("new").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Item>("category", "c2").unwrap().is_some());
|
||||
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "c2").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_string_members_are_found_by_their_text() {
|
||||
let mut cache = cache();
|
||||
let owner = 161_u64;
|
||||
cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap();
|
||||
assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Numbered>("owner", "161").unwrap().is_some());
|
||||
}
|
||||
|
||||
// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with
|
||||
// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other.
|
||||
#[test]
|
||||
fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("shared.cc");
|
||||
{
|
||||
let mut cache = stored_cache(&path);
|
||||
cache.put("item:p2", &item("p2", "x", None)).unwrap();
|
||||
cache.put("item:p1", &item("p1", "x", None)).unwrap();
|
||||
let found = |cache: &CultCache| cache.get_by_index::<Item>("category", "x").unwrap().map(|found| found.name);
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second");
|
||||
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap().unwrap().name, "p1");
|
||||
|
||||
cache.delete::<Item>("item:p1").unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one");
|
||||
|
||||
cache.put("item:p1", &item("p1", "x", None)).unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"));
|
||||
cache.delete::<Item>("item:p2").unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing");
|
||||
cache.delete::<Item>("item:p1").unwrap();
|
||||
assert_eq!(found(&cache), None);
|
||||
}
|
||||
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_sharing_a_name_find_the_smallest_key() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:b", &Item { value: 2, ..item("twin", "c", None) }).unwrap();
|
||||
cache.put("item:a", &Item { value: 1, ..item("twin", "c", None) }).unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 1);
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 2);
|
||||
}
|
||||
|
||||
// BackingStoreTests.HydratesPersistedGlobal: opening a store invents no global; a written one survives a reopen.
|
||||
#[test]
|
||||
fn a_global_is_none_until_written_and_survives_a_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("global.cc");
|
||||
{
|
||||
let mut seed = stored_cache(&path);
|
||||
assert_eq!(seed.get_global::<Settings>().unwrap(), None, "opening a store invented a global");
|
||||
seed.put("global:settings", &settings("durable authority")).unwrap();
|
||||
assert_eq!(seed.get_global::<Settings>().unwrap(), Some(settings("durable authority")));
|
||||
}
|
||||
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_that_is_not_a_global_has_no_global() {
|
||||
let mut cache = cache();
|
||||
cache.put("plain:one", &Plain { value: "v".into() }).unwrap();
|
||||
assert_eq!(cache.get_global::<Plain>().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_global_under_another_key_is_refused_and_writes_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("second.cc");
|
||||
let mut cache = stored_cache(&path);
|
||||
cache.put("g1", &settings("one")).unwrap();
|
||||
|
||||
let refusal = cache.put("g2", &settings("two")).unwrap_err();
|
||||
let second = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
|
||||
assert_eq!((second.existing_key.as_str(), second.key.as_str()), ("g1", "g2"));
|
||||
assert!(refusal.to_string().contains("is a global and already has record g1; g2 would be a second."));
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
|
||||
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("one")), "nothing reached the store");
|
||||
|
||||
// The same key is the same record, not a second.
|
||||
cache.put("g1", &settings("one again")).unwrap();
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one again")));
|
||||
}
|
||||
|
||||
// RetypeEvictionTests.EvictingAGlobalFreesItsSlot: removing the global frees the slot for another key.
|
||||
#[test]
|
||||
fn removing_a_global_frees_its_slot() {
|
||||
let mut cache = cache();
|
||||
cache.put("g1", &settings("one")).unwrap();
|
||||
assert!(cache.put("g2", &settings("two")).is_err());
|
||||
cache.delete::<Settings>("g1").unwrap();
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
|
||||
cache.put("g2", &settings("two")).unwrap();
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("two")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_write_path_refuses_a_second_global() {
|
||||
let mut cache = cache();
|
||||
cache.put("g1", &settings("one")).unwrap();
|
||||
let (second, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
|
||||
|
||||
assert!(cache.put_envelope::<Settings>(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert!(cache.put_raw_envelope(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
let (plain, _) = cache.prepare_entry("plain:1", &Plain { value: "v".into() }).unwrap();
|
||||
assert!(cache.put_prepared_batch(vec![plain, second]).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert_eq!(cache.get::<Plain>("plain:1").unwrap(), None, "a refused batch lands none of it");
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_that_stages_two_globals_is_refused_whole() {
|
||||
let mut cache = cache();
|
||||
let (one, _) = cache.prepare_entry("g1", &settings("one")).unwrap();
|
||||
let (two, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
|
||||
assert!(cache.put_prepared_batch(vec![one, two]).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
|
||||
}
|
||||
|
||||
// StoreRoutingTests: a store holding two globals is refused on load rather than answering one of them.
|
||||
#[test]
|
||||
fn loading_a_store_that_holds_two_globals_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("two.cc");
|
||||
let source = cache();
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
for (key, value) in [("g1", "one"), ("g2", "two")] {
|
||||
let (entry, _) = source.prepare_entry(key, &settings(value)).unwrap();
|
||||
store.push(&entry).unwrap();
|
||||
}
|
||||
|
||||
let mut loading = cache();
|
||||
loading.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||
assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing");
|
||||
}
|
||||
|
||||
// load_soa installs a whole table at once, so the one-global rule has to hold there too: a table holding two
|
||||
// records of a global type is refused whole and the prior view survives.
|
||||
#[test]
|
||||
fn loading_an_soa_table_that_holds_two_globals_is_refused() {
|
||||
let source = cache();
|
||||
let (first, _) = source.prepare_entry("g1", &settings("one")).unwrap();
|
||||
let (second, _) = source.prepare_entry("g2", &settings("two")).unwrap();
|
||||
|
||||
let mut loading = cache();
|
||||
loading.put("g0", &settings("prior")).unwrap();
|
||||
loading.put("plain:1", &Plain { value: "prior".into() }).unwrap();
|
||||
|
||||
let refusal = loading
|
||||
.load_soa(CultCacheSoaTable::from_envelopes(vec![first.clone(), second]))
|
||||
.unwrap_err();
|
||||
let second_global = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
|
||||
let mut named = [second_global.existing_key.as_str(), second_global.key.as_str()];
|
||||
named.sort();
|
||||
assert_eq!(named, ["g1", "g2"], "the refusal names both records");
|
||||
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("prior")), "a refused load keeps the prior view");
|
||||
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), Some(Plain { value: "prior".into() }));
|
||||
|
||||
// One global in the table is the rule's own case, not a second.
|
||||
loading.load_soa(CultCacheSoaTable::from_envelopes(vec![first])).unwrap();
|
||||
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("one")));
|
||||
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), None, "a load replaces the view");
|
||||
}
|
||||
|
|
@ -3,26 +3,27 @@
|
|||
All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.15.0]
|
||||
|
||||
0.14.1 and 0.14.2 were never tagged or published; this entry carries them with the Unreleased work, and `cultcache-ts-v0.14.0` is the previous release.
|
||||
## Unreleased
|
||||
|
||||
### Breaking
|
||||
|
||||
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
||||
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
|
||||
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. A full `pushAll` over a file with no readable stamp does not replace it: the reader refuses it. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
|
||||
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again.
|
||||
- This reverses the 0.14.2 `pushAll` fix in two ways. A full `pushAll` over a file with no readable stamp no longer replaces it (healing it): the reader refuses it, and the file is left as it is. And a `cultcache.store.v3` stamp is read, not refused. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
|
||||
- A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store.
|
||||
|
||||
### Added
|
||||
|
||||
- `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did.
|
||||
- `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader.
|
||||
- `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity.
|
||||
- The document payload codec, exported from `@gamecult/cultcache-ts/document`: `decodeDocumentPayload`, `encodeDocumentPayload`, and the two named byte escapes `documentPayloadFromBytes` and `documentPayloadBytes`.
|
||||
- `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`).
|
||||
- `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store.
|
||||
- A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses.
|
||||
|
||||
## [0.14.2]
|
||||
|
||||
### Added
|
||||
- `SingleFileMessagePackBackingStore` takes a cross-process write lock on `<file>.lock`,
|
||||
interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows).
|
||||
`push` and `delete` read the current file, merge and replace it under the lock, so concurrent
|
||||
|
|
@ -32,18 +33,27 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
|||
is, but C# writers can still overwrite records a concurrent TypeScript writer added.
|
||||
- The staged file is fsynced before the replace, and on Unix the directory after it. A
|
||||
directory sync failure after the replace is reported as replaced but not durable.
|
||||
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
|
||||
(`cultnet-ts/content`) bound payload nesting with the same check the cache
|
||||
applies on decode.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
|
||||
hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms
|
||||
apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock.
|
||||
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken
|
||||
under the lock.
|
||||
|
||||
### Dependencies
|
||||
### Fixed
|
||||
- A full `pushAll` reads the store's header under the lock first and refuses a store format
|
||||
this runtime does not read (the same error a read gives), leaving the file as it was. A
|
||||
file with no readable stamp is still replaced, so a whole write heals it. Soft `pushAll`
|
||||
is unchanged. The stamp is decoded by the decoder a read uses, so a stamp followed by bytes
|
||||
that are not UTF-8 is refused by a whole write as a read refuses it.
|
||||
|
||||
### Dependencies
|
||||
- `koffi` 3.3.0, loaded only on the first write.
|
||||
|
||||
## [0.14.1]
|
||||
|
||||
### Added
|
||||
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
|
||||
(`cultnet-ts/content`) bound payload nesting with the same check the cache
|
||||
applies on decode.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const note = decodeDocumentPayload(noteDocument, envelope.payload); // parsed No
|
|||
const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload
|
||||
```
|
||||
|
||||
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`.
|
||||
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins.
|
||||
|
||||
## `.cc` Inspection
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,15 @@
|
|||
{
|
||||
"name": "@gamecult/cultcache-ts",
|
||||
"version": "0.15.0",
|
||||
"version": "0.14.2",
|
||||
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"browser": "./dist/browser.js",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./dist/browser.d.ts",
|
||||
"require": "./dist/browser.js",
|
||||
"default": "./dist/browser.js"
|
||||
},
|
||||
"./document": {
|
||||
"types": "./dist/document.d.ts",
|
||||
"require": "./dist/document.js",
|
||||
|
|
@ -31,9 +25,6 @@
|
|||
"*": {
|
||||
"document": [
|
||||
"dist/document.d.ts"
|
||||
],
|
||||
"browser": [
|
||||
"dist/browser.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
// Browser-safe surface: everything the package root exports except the node-only single-file store
|
||||
// (node:fs, koffi). Bring your own CacheBackingStore, or none.
|
||||
export * as codecs from "./codec";
|
||||
export type { Codec, SlotsCodec } from "./codec";
|
||||
export { CultCache, CultCacheBuilder } from "./cult-cache";
|
||||
export { inspectCultCacheBytes } from "./cult-cache-inspector";
|
||||
export {
|
||||
decodeDocumentPayload,
|
||||
defineDocumentRegistry,
|
||||
defineDocumentType,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
encodeDocumentPayload,
|
||||
schemaIdentityOf,
|
||||
type CultCacheSchemaIdentity,
|
||||
} from "./document";
|
||||
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
|
||||
export type { DocumentPayload } from "./document";
|
||||
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
|
||||
export type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
CultCacheDocumentAccessor,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentFieldName,
|
||||
CultCacheDocumentFormatter,
|
||||
CultCacheDocumentIndexDefinition,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheSchemaCatalogEntry,
|
||||
CultCacheSchemaCatalogMember,
|
||||
CultCacheSchema,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheEnvelope,
|
||||
CultCacheIndexScalar,
|
||||
InferCultCacheSchemaValue,
|
||||
CultCacheStoreRegistration,
|
||||
PushAllOptions,
|
||||
} from "./types";
|
||||
export type {
|
||||
CultCacheInspection,
|
||||
InspectedCatalogEntry,
|
||||
InspectedCatalogMember,
|
||||
InspectedRecord,
|
||||
} from "./cult-cache-inspector";
|
||||
export {
|
||||
defineSwarmDocuments,
|
||||
type DefineDocumentType,
|
||||
type SwarmDocumentCatalog,
|
||||
} from "./swarm-documents";
|
||||
|
|
@ -1,2 +1,48 @@
|
|||
export * from "./browser";
|
||||
export * as codecs from "./codec";
|
||||
export type { Codec, SlotsCodec } from "./codec";
|
||||
export { CultCache, CultCacheBuilder } from "./cult-cache";
|
||||
export { inspectCultCacheBytes } from "./cult-cache-inspector";
|
||||
export {
|
||||
decodeDocumentPayload,
|
||||
defineDocumentRegistry,
|
||||
defineDocumentType,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
encodeDocumentPayload,
|
||||
schemaIdentityOf,
|
||||
type CultCacheSchemaIdentity,
|
||||
} from "./document";
|
||||
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
|
||||
export type { DocumentPayload } from "./document";
|
||||
export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store";
|
||||
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
|
||||
export type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
CultCacheDocumentAccessor,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentFieldName,
|
||||
CultCacheDocumentFormatter,
|
||||
CultCacheDocumentIndexDefinition,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheSchemaCatalogEntry,
|
||||
CultCacheSchemaCatalogMember,
|
||||
CultCacheSchema,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheEnvelope,
|
||||
CultCacheIndexScalar,
|
||||
InferCultCacheSchemaValue,
|
||||
CultCacheStoreRegistration,
|
||||
PushAllOptions,
|
||||
} from "./types";
|
||||
export type {
|
||||
CultCacheInspection,
|
||||
InspectedCatalogEntry,
|
||||
InspectedCatalogMember,
|
||||
InspectedRecord,
|
||||
} from "./cult-cache-inspector";
|
||||
export {
|
||||
defineSwarmDocuments,
|
||||
type DefineDocumentType,
|
||||
type SwarmDocumentCatalog,
|
||||
} from "./swarm-documents";
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { build } from "esbuild";
|
||||
|
||||
// dist-test/test/ -> dist-test/src/
|
||||
const srcDir = join(__dirname, "..", "src");
|
||||
|
||||
async function bundleForBrowser(entry: string): Promise<string> {
|
||||
const result = await build({
|
||||
entryPoints: [join(srcDir, entry)],
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
globalName: "cultcache",
|
||||
write: false,
|
||||
logLevel: "silent",
|
||||
});
|
||||
return result.outputFiles[0].text;
|
||||
}
|
||||
|
||||
test("the browser entry bundles for platform=browser and runs without node", async () => {
|
||||
const code = await bundleForBrowser("browser.js");
|
||||
assert.ok(!/node:fs|koffi/.test(code), "no node-only store in the browser bundle");
|
||||
// A context with no require/process/Buffer: only what a browser page has.
|
||||
const sandbox: Record<string, unknown> = { TextEncoder, TextDecoder };
|
||||
runInNewContext(code + "\nglobalThis.__api = cultcache;", sandbox);
|
||||
const api = sandbox.__api as Record<string, unknown>;
|
||||
assert.equal(typeof api.CultCache, "function");
|
||||
assert.equal(typeof api.defineDocumentType, "function");
|
||||
assert.equal(api.SingleFileMessagePackBackingStore, undefined);
|
||||
});
|
||||
|
||||
test("the node entry still exports the single-file store and everything the browser entry has", async () => {
|
||||
const node = await import(join(srcDir, "index.js"));
|
||||
const browser = await import(join(srcDir, "browser.js"));
|
||||
assert.equal(typeof node.SingleFileMessagePackBackingStore, "function");
|
||||
assert.equal(typeof node.readSingleFileStore, "function");
|
||||
for (const name of Object.keys(browser)) assert.ok(name in node, `node entry lost ${name}`);
|
||||
});
|
||||
|
||||
test("the package root keeps the node-only store out of the browser build", async () => {
|
||||
await assert.rejects(bundleForBrowser("index.js"), /node:fs|node:path|koffi/);
|
||||
});
|
||||
|
|
@ -1,14 +1,8 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to `@gamecult/cultmesh-browser` are recorded here, following
|
||||
All notable changes to `cultmesh-browser` are recorded here, following
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking
|
||||
|
||||
- The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`.
|
||||
|
||||
## [0.4.0]
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# CultMesh Browser
|
||||
|
||||
`@gamecult/cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
|
||||
`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
|
||||
WebSocket session for a stable Verse/authority-runtime target, retains explicit raw
|
||||
document leases across route replacement, and correlates typed CultNet
|
||||
operations with provider receipts.
|
||||
|
|
@ -95,7 +95,7 @@ WebCrypto `crypto.subtle`; without WebCrypto it fails with
|
|||
endpoint CultMesh resolved is the only host the reader trusts.
|
||||
|
||||
```ts
|
||||
import { fetchVerifiedContent } from "@gamecult/cultmesh-browser";
|
||||
import { fetchVerifiedContent } from "cultmesh-browser";
|
||||
|
||||
const bytes = await fetchVerifiedContent(endpoint, manifest, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@gamecult/cultmesh-browser",
|
||||
"name": "cultmesh-browser",
|
||||
"version": "0.4.0",
|
||||
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
|
||||
"type": "module",
|
||||
|
|
@ -41,12 +41,12 @@
|
|||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.15.0",
|
||||
"@gamecult/cultnet-ts": "^0.3.0"
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"cultnet-ts": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@gamecult/cultmesh-ts": "^0.2.0",
|
||||
"cultmesh-ts": "^0.2.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence).
|
||||
// Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here.
|
||||
|
||||
import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
|
||||
import { isLoopbackEndpoint } from "@gamecult/cultnet-ts/authority";
|
||||
import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "cultnet-ts/content";
|
||||
import { isLoopbackEndpoint } from "cultnet-ts/authority";
|
||||
|
||||
export type CultMeshContentErrorCode =
|
||||
| "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document";
|
||||
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts";
|
||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||
import {
|
||||
encodeCultNetMessageForWire,
|
||||
parseCultNetMessage,
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
type CultMeshVerseCatalogResponseMessage,
|
||||
type CultMeshSessionAcceptedMessage,
|
||||
type CultMeshSessionOpenMessage,
|
||||
} from "@gamecult/cultnet-ts/contracts";
|
||||
} from "cultnet-ts/contracts";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
|
|
@ -31,7 +31,7 @@ import {
|
|||
type CultMeshAuthorityTrustMode,
|
||||
type CultMeshAuthorityTrustPolicy,
|
||||
type CultMeshP256PublicKey,
|
||||
} from "@gamecult/cultnet-ts/authority";
|
||||
} from "cultnet-ts/authority";
|
||||
export * from "./content.js";
|
||||
|
||||
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { gzipSync } from "node:zlib";
|
|||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
|
||||
import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "cultnet-ts/content";
|
||||
|
||||
import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||
import test from "node:test";
|
||||
|
||||
import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||
import {
|
||||
encodeCultNetMessageForWire,
|
||||
parseCultNetMessage,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
type CultNetRawDocumentRecord,
|
||||
type CultMeshVerseCatalogResponseMessage,
|
||||
type CultMeshSessionOpenMessage,
|
||||
} from "@gamecult/cultnet-ts/contracts";
|
||||
} from "cultnet-ts/contracts";
|
||||
|
||||
import {
|
||||
CultMeshBrowserClient,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { readFileSync } from "node:fs";
|
|||
import { createServer } from "node:https";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createInterface } from "node:readline";
|
||||
import { chunkRecordKey } from "@gamecult/cultnet-ts/content";
|
||||
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "@gamecult/cultmesh-ts/content";
|
||||
import { chunkRecordKey } from "cultnet-ts/content";
|
||||
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "cultmesh-ts/content";
|
||||
|
||||
const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string];
|
||||
const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); };
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { decodeManifest } from "@gamecult/cultnet-ts/content";
|
||||
import { decodeManifest } from "cultnet-ts/content";
|
||||
import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js";
|
||||
|
||||
const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined];
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
|
|||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { EMPTY_SHA256, encodeManifest, packContent } from "@gamecult/cultnet-ts/content";
|
||||
import { EMPTY_SHA256, encodeManifest, packContent } from "cultnet-ts/content";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import assert from "node:assert/strict";
|
|||
import test from "node:test";
|
||||
|
||||
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
|
||||
import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts/contracts";
|
||||
import { decodeWireValue } from "cultnet-ts/wire";
|
||||
import type { CultNetRawDocumentRecord } from "cultnet-ts/contracts";
|
||||
|
||||
import { decodeCultNetPayload } from "../src/index.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -474,14 +474,3 @@ powershell -ExecutionPolicy Bypass -File .\packages\cultmesh-kotlin\build.ps1
|
|||
The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`.
|
||||
It also runs the built-in RUDP packet fixture and localhost UDP socket
|
||||
self-test.
|
||||
|
||||
## Write Receipt
|
||||
|
||||
`WriteReceipt.kt` decodes `cultnet.write_admitted.v0` and `cultnet.error.v1` (and `error.v0`) strictly against their
|
||||
closed schemas (`decodeWriteAdmitted`, `decodeErrorFrame`), computes the dedupe `writeContentHash`, tests a hello with
|
||||
`helloAdvertisesWriteReceipts`, and settles sent writes as admitted, refused or unconfirmed in `WriteTracker`
|
||||
(pure state machine, abstract clock; the caller owns transport and timers). Kotlin has no write client yet, so nothing
|
||||
sends a put and waits on this. `WriteReceiptVectors.kt` replays
|
||||
`contracts/cultnet/interop/write-receipt-vectors.json` (constants, contentHash, wire, schemaInvalid, scenarios; the
|
||||
server-side `dedupe` and `blankFields` sections are not run); `build.ps1` runs it after the self-test, or run
|
||||
`CultMeshKt write-receipt-vectors <path>`.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,4 @@ $sources = Get-ChildItem $src -Recurse -Filter *.kt | Select-Object -ExpandPrope
|
|||
if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" }
|
||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt
|
||||
if ($LASTEXITCODE -ne 0) { throw "cultmesh-kotlin self-test failed with exit code $LASTEXITCODE" }
|
||||
$vectors = Join-Path $repoRoot "contracts\cultnet\interop\write-receipt-vectors.json"
|
||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt write-receipt-vectors $vectors
|
||||
if ($LASTEXITCODE -ne 0) { throw "write-receipt vector replay failed with exit code $LASTEXITCODE" }
|
||||
Write-Host "Built $jarPath"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import java.net.Socket
|
|||
import java.net.SocketTimeoutException
|
||||
import java.net.URI
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.SecureRandom
|
||||
import java.security.MessageDigest
|
||||
|
|
@ -809,7 +808,6 @@ interface CultNetSchemaMessageTransport : AutoCloseable {
|
|||
val profile: CultNetTransportProfile
|
||||
val stats: CultNetTransportStats
|
||||
|
||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
||||
fun sendSchemaMessage(message: CultNetMessage)
|
||||
|
||||
fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage?
|
||||
|
|
@ -1101,9 +1099,9 @@ data class CultNetRawDocumentRecord(
|
|||
"payloadEncoding" to payloadEncoding,
|
||||
"payload" to payload.copyOf(),
|
||||
)
|
||||
if (!isBlankText(sourceRuntimeId)) wire["sourceRuntimeId"] = sourceRuntimeId
|
||||
if (!isBlankText(sourceAgentId)) wire["sourceAgentId"] = sourceAgentId
|
||||
if (!isBlankText(sourceRole)) wire["sourceRole"] = sourceRole
|
||||
if (!sourceRuntimeId.isNullOrBlank()) wire["sourceRuntimeId"] = sourceRuntimeId
|
||||
if (!sourceAgentId.isNullOrBlank()) wire["sourceAgentId"] = sourceAgentId
|
||||
if (!sourceRole.isNullOrBlank()) wire["sourceRole"] = sourceRole
|
||||
if (tags.isNotEmpty()) wire["tags"] = tags
|
||||
return wire
|
||||
}
|
||||
|
|
@ -2365,9 +2363,6 @@ fun parseCultNetMessage(payload: ByteArray): CultNetMessage {
|
|||
if (schemaVersion !is String || schemaVersion.isBlank()) {
|
||||
throw IOException("CultNet schema-v0 messages must declare schemaVersion")
|
||||
}
|
||||
// A write_admitted id is echoed byte for byte, so that frame alone is read again with invalid UTF-8 refused; document payload
|
||||
// strings elsewhere keep decoding as before.
|
||||
if (schemaVersion == WRITE_ADMITTED_VERSION) MessagePackReader(payload, strictUtf8 = true).readAny()
|
||||
val body = linkedMapOf<String, Any?>()
|
||||
for ((key, value) in decoded) {
|
||||
if (key !is String) throw IOException("CultNet schema-v0 message keys must be strings")
|
||||
|
|
@ -3172,16 +3167,13 @@ class CultNetRudpSocketTransportConnection(
|
|||
return connected
|
||||
}
|
||||
|
||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
||||
fun send(channelId: String, payload: ByteArray) {
|
||||
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
|
||||
framesSent += 1
|
||||
}
|
||||
|
||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
||||
fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8))
|
||||
|
||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
||||
fun sendSchema(payload: ByteArray) = send("schema", payload)
|
||||
|
||||
fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8))
|
||||
|
|
@ -3417,7 +3409,6 @@ fun main(args: Array<String>) {
|
|||
rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
|
||||
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
|
||||
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
|
||||
rawDocumentRecordDropsOnlyWhiteSpaceSourceIds()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -3429,7 +3420,6 @@ fun main(args: Array<String>) {
|
|||
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
|
||||
"serve" -> interopServe(options)
|
||||
"dial" -> interopDial(options)
|
||||
"write-receipt-vectors" -> if (!replayWriteReceiptVectors(args.getOrNull(1) ?: error("usage: write-receipt-vectors <vectors.json>"))) kotlin.system.exitProcess(1)
|
||||
else -> error("Unknown mode ${args[0]}")
|
||||
}
|
||||
}
|
||||
|
|
@ -6155,8 +6145,7 @@ private fun requireSchemaResponse(message: CultNetMessage?, expectedSchemaVersio
|
|||
return response
|
||||
}
|
||||
|
||||
/** [strictUtf8] makes a str whose bytes are not UTF-8 throw instead of becoming U+FFFD; only the write_admitted frame asks for it. */
|
||||
class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = false) {
|
||||
class MessagePackReader(payload: ByteArray) {
|
||||
private val input = DataInputStream(ByteArrayInputStream(payload))
|
||||
private var pushed = -1
|
||||
|
||||
|
|
@ -6311,10 +6300,7 @@ class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = fa
|
|||
code == 0xdb -> input.readInt()
|
||||
else -> throw IOException("expected string")
|
||||
}
|
||||
val bytes = readPayload(length)
|
||||
if (!strictUtf8) return String(bytes, StandardCharsets.UTF_8)
|
||||
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
return String(readPayload(length), StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun readBinaryAfterCode(code: Int): ByteArray {
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
||||
|
||||
package org.gamecult.cultmesh
|
||||
|
||||
// (low, high) pairs, flattened and sorted.
|
||||
internal val LETTER_OR_NUMBER: IntArray = intArrayOf(
|
||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
|
||||
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
|
||||
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
|
||||
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
|
||||
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
|
||||
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
|
||||
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
|
||||
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
|
||||
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
|
||||
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
|
||||
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
|
||||
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
|
||||
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
|
||||
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
|
||||
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
|
||||
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
|
||||
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
|
||||
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
|
||||
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
|
||||
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
|
||||
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A,
|
||||
0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081,
|
||||
0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248,
|
||||
0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0,
|
||||
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315,
|
||||
0x1318, 0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F,
|
||||
0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
|
||||
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819,
|
||||
0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D,
|
||||
0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89,
|
||||
0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5,
|
||||
0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC,
|
||||
0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
|
||||
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
|
||||
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC,
|
||||
0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102,
|
||||
0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128,
|
||||
0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B,
|
||||
0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25,
|
||||
0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
|
||||
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F,
|
||||
0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA,
|
||||
0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229,
|
||||
0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD,
|
||||
0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788,
|
||||
0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A,
|
||||
0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB,
|
||||
0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4,
|
||||
0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A,
|
||||
0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD,
|
||||
0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E,
|
||||
0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB,
|
||||
0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
|
||||
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F,
|
||||
0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A,
|
||||
0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D,
|
||||
0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107,
|
||||
0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300,
|
||||
0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1,
|
||||
0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530,
|
||||
0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3,
|
||||
0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760,
|
||||
0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A,
|
||||
0x10835, 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7,
|
||||
0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
|
||||
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60,
|
||||
0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40,
|
||||
0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0,
|
||||
0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80,
|
||||
0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70,
|
||||
0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075,
|
||||
0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144,
|
||||
0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0,
|
||||
0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280,
|
||||
0x11286, 0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0,
|
||||
0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
|
||||
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E,
|
||||
0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447,
|
||||
0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0,
|
||||
0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680,
|
||||
0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740,
|
||||
0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915,
|
||||
0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA,
|
||||
0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50,
|
||||
0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00,
|
||||
0x11C08, 0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08,
|
||||
0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
|
||||
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12,
|
||||
0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480,
|
||||
0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100,
|
||||
0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0,
|
||||
0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63,
|
||||
0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50,
|
||||
0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF,
|
||||
0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150,
|
||||
0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80,
|
||||
0x1BC88, 0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400,
|
||||
0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
|
||||
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516,
|
||||
0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552,
|
||||
0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736,
|
||||
0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE,
|
||||
0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140,
|
||||
0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0,
|
||||
0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0,
|
||||
0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71,
|
||||
0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05,
|
||||
0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39,
|
||||
0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
|
||||
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D,
|
||||
0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74,
|
||||
0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5,
|
||||
0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740,
|
||||
0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350,
|
||||
0x323AF,
|
||||
)
|
||||
|
||||
internal val DEFAULT_IGNORABLE: IntArray = intArrayOf(
|
||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
|
||||
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
|
||||
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
||||
)
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
package org.gamecult.cultmesh
|
||||
|
||||
// The write receipt (contracts/cultnet/interop/write-receipt.md): the two frames, their strict decode, the dedupe
|
||||
// content hash, the hello capability test and the client settle rules. Mirrors packages/cultnet-ts (contracts.ts,
|
||||
// write-tracker.ts) and packages/cultnet-rs (write_content_hash). The replay of write-receipt-vectors.json lives in
|
||||
// WriteReceiptVectors.kt. Server rules (dedupe, limits, blank fields) are not a decoder's business and are not here.
|
||||
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
|
||||
const val WRITE_ADMITTED_VERSION = "cultnet.write_admitted.v0"
|
||||
const val ERROR_V1_VERSION = "cultnet.error.v1"
|
||||
const val ERROR_V0_VERSION = "cultnet.error.v0"
|
||||
|
||||
/** A connection is receipt-capable only when its hello lists both frames. */
|
||||
val CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS: List<String> = listOf(WRITE_ADMITTED_VERSION, ERROR_V1_VERSION)
|
||||
|
||||
const val RECEIPT_RESULT_WINDOW_MS = 5000L
|
||||
const val LEGACY_RESULT_WINDOW_MS = 500L
|
||||
|
||||
fun resultWindowMs(serverAdvertisesReceipts: Boolean): Long =
|
||||
if (serverAdvertisesReceipts) RECEIPT_RESULT_WINDOW_MS else LEGACY_RESULT_WINDOW_MS
|
||||
|
||||
/** The closed refusal-code set of error.v0; error.v1 adds `limit_reached`. */
|
||||
val CULTNET_ERROR_V0_CODES: Set<String> = setOf(
|
||||
"selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported",
|
||||
"unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid",
|
||||
)
|
||||
val CULTNET_ERROR_V1_CODES: Set<String> = CULTNET_ERROR_V0_CODES + "limit_reached"
|
||||
|
||||
fun helloAdvertisesWriteReceipts(hello: CultNetMessage): Boolean {
|
||||
val versions = hello.body["supportedMessageVersions"] as? List<*> ?: return false
|
||||
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.all { it in versions }
|
||||
}
|
||||
|
||||
data class CultNetWriteAdmitted(val messageId: String, val operation: String, val schemaId: String, val recordKey: String) {
|
||||
fun toMessage() = CultNetMessage(
|
||||
WRITE_ADMITTED_VERSION,
|
||||
linkedMapOf("messageId" to messageId, "operation" to operation, "schemaId" to schemaId, "recordKey" to recordKey),
|
||||
)
|
||||
}
|
||||
|
||||
data class CultNetErrorDetails(val field: String?, val value: String?, val asOf: Long?, val current: Long?)
|
||||
|
||||
/** error.v0 (`messageId` always null, `version` 0) or error.v1 (`version` 1). */
|
||||
data class CultNetErrorFrame(
|
||||
val version: Int,
|
||||
val error: String,
|
||||
val routingHint: Map<String, Any?>?,
|
||||
val code: String?,
|
||||
val details: CultNetErrorDetails?,
|
||||
val messageId: String?,
|
||||
) {
|
||||
fun toMessage(): CultNetMessage {
|
||||
val body = linkedMapOf<String, Any?>(
|
||||
"error" to error,
|
||||
"routingHint" to routingHint,
|
||||
"code" to code,
|
||||
"details" to details?.let {
|
||||
linkedMapOf("field" to it.field, "value" to it.value, "asOf" to it.asOf, "current" to it.current)
|
||||
},
|
||||
)
|
||||
if (version == 1) body["messageId"] = messageId
|
||||
return CultNetMessage(if (version == 1) ERROR_V1_VERSION else ERROR_V0_VERSION, body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict decode of a write_admitted.v0 message against its closed schema: exactly five keys, a non-empty string each,
|
||||
* operation put|delete.
|
||||
*/
|
||||
fun decodeWriteAdmitted(message: CultNetMessage): CultNetWriteAdmitted {
|
||||
if (message.schemaVersion != WRITE_ADMITTED_VERSION) throw IOException("not a $WRITE_ADMITTED_VERSION message")
|
||||
closedKeys(message, setOf("messageId", "operation", "schemaId", "recordKey"))
|
||||
val operation = nonEmpty(message, "operation")
|
||||
if (operation != "put" && operation != "delete") throw IOException("operation must be put or delete")
|
||||
return CultNetWriteAdmitted(admittedMessageId(message), operation, nonEmpty(message, "schemaId"), nonEmpty(message, "recordKey"))
|
||||
}
|
||||
|
||||
// write-receipt.md: a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no Default_Ignorable code point anywhere, as of
|
||||
// Unicode 16.0, compared raw (never trimmed). The tables are embedded (UnicodeMessageIdTables.kt, generated by
|
||||
// scripts/gen-unicode-messageid-tables.py), not the JDK's, so the running JVM's Unicode version changes no result; the other
|
||||
// four runtimes embed the same generated tables.
|
||||
private fun inRanges(ranges: IntArray, point: Int): Boolean {
|
||||
var low = 0
|
||||
var high = ranges.size / 2 - 1
|
||||
while (low <= high) {
|
||||
val mid = (low + high) ushr 1
|
||||
when {
|
||||
point < ranges[2 * mid] -> high = mid - 1
|
||||
point > ranges[2 * mid + 1] -> low = mid + 1
|
||||
else -> return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun admittedMessageId(message: CultNetMessage): String {
|
||||
val id = nonEmpty(message, "messageId")
|
||||
var hasLetterOrNumber = false
|
||||
id.codePoints().forEach { cp ->
|
||||
if (inRanges(DEFAULT_IGNORABLE, cp)) throw IOException("messageId holds a Default_Ignorable code point")
|
||||
if (inRanges(LETTER_OR_NUMBER, cp)) hasLetterOrNumber = true
|
||||
}
|
||||
if (!hasLetterOrNumber) throw IOException("messageId must contain a letter or number")
|
||||
return id
|
||||
}
|
||||
|
||||
/** Strict decode of error.v0 (no messageId, no limit_reached) or error.v1 (messageId required, string or nil, never empty). */
|
||||
fun decodeErrorFrame(message: CultNetMessage): CultNetErrorFrame {
|
||||
val version = when (message.schemaVersion) {
|
||||
ERROR_V1_VERSION -> 1
|
||||
ERROR_V0_VERSION -> 0
|
||||
else -> throw IOException("not an error message")
|
||||
}
|
||||
closedKeys(message, setOf("error", "routingHint", "code", "details") + if (version == 1) setOf("messageId") else emptySet())
|
||||
val error = nonEmpty(message, "error")
|
||||
val codeValue = message.body["code"]
|
||||
if (codeValue != null && (codeValue !is String || codeValue !in (if (version == 1) CULTNET_ERROR_V1_CODES else CULTNET_ERROR_V0_CODES))) {
|
||||
throw IOException("unknown error code")
|
||||
}
|
||||
var messageId: String? = null
|
||||
if (version == 1) {
|
||||
val id = message.body["messageId"]
|
||||
if (id != null && (id !is String || id.isEmpty())) throw IOException("messageId must be a non-empty string or nil")
|
||||
messageId = id as String?
|
||||
}
|
||||
return CultNetErrorFrame(version, error, routingHintOf(message.body["routingHint"]), codeValue as String?, detailsOf(message.body["details"]), messageId)
|
||||
}
|
||||
|
||||
private fun closedKeys(message: CultNetMessage, required: Set<String>) {
|
||||
if (!message.body.keys.containsAll(required)) throw IOException("${message.schemaVersion} is missing ${required - message.body.keys}")
|
||||
val unknown = message.body.keys - required
|
||||
if (unknown.isNotEmpty()) throw IOException("${message.schemaVersion} carries unknown keys $unknown")
|
||||
}
|
||||
|
||||
private fun nonEmpty(message: CultNetMessage, key: String): String {
|
||||
val value = message.body[key]
|
||||
if (value !is String || value.isEmpty()) throw IOException("$key must be a non-empty string")
|
||||
return value
|
||||
}
|
||||
|
||||
private fun routingHintOf(value: Any?): Map<String, Any?>? {
|
||||
if (value == null) return null
|
||||
if (value !is Map<*, *> || value.keys != setOf("shard", "reason")) throw IOException("routingHint must be nil or {shard, reason}")
|
||||
if (value["shard"] != null && value["shard"] !is Map<*, *>) throw IOException("routingHint.shard must be an object or nil")
|
||||
if (value["reason"] !is String) throw IOException("routingHint.reason must be a string")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return value as Map<String, Any?>
|
||||
}
|
||||
|
||||
private fun detailsOf(value: Any?): CultNetErrorDetails? {
|
||||
if (value == null) return null
|
||||
if (value !is Map<*, *> || value.keys != setOf("field", "value", "asOf", "current")) {
|
||||
throw IOException("details must be nil or {field, value, asOf, current}")
|
||||
}
|
||||
fun text(key: String): String? = value[key].let { if (it == null || it is String) it as String? else throw IOException("details.$key must be a string or nil") }
|
||||
fun cursor(key: String): Long? = value[key].let {
|
||||
if (it == null) null else if (it is Long && it >= 0) it else throw IOException("details.$key must be a non-negative integer or nil")
|
||||
}
|
||||
return CultNetErrorDetails(text("field"), text("value"), cursor("asOf"), cursor("current"))
|
||||
}
|
||||
|
||||
/** sha256 of the MessagePack array [operation, schemaId, recordKey, payload]: payload a bin for a put, nil for a delete. Lowercase hex. */
|
||||
fun writeContentHash(operation: String, schemaId: String, recordKey: String, payload: ByteArray?): String {
|
||||
val writer = MessagePackWriter().array(4).string(operation).string(schemaId).string(recordKey)
|
||||
if (payload == null) writer.nil() else writer.binary(payload)
|
||||
return MessageDigest.getInstance("SHA-256").digest(writer.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** Why nothing settled a write. */
|
||||
enum class WriteUnconfirmedReason(val wire: String) { WINDOW_ELAPSED("window_elapsed"), SESSION_ENDED("session_ended"), LOCAL_SEND_FAILED("local_send_failed") }
|
||||
|
||||
/** What a late answer is recorded as on a write that already settled unconfirmed. */
|
||||
enum class WriteRecorded(val wire: String) { ADMITTED_LATE("admitted_late"), REFUSED_LATE("refused_late") }
|
||||
|
||||
/** What the caller was told. Settled once; never changed by a later frame. */
|
||||
sealed class WriteSettlement {
|
||||
object Admitted : WriteSettlement()
|
||||
data class Refused(val code: String?, val error: String, val response: CultNetErrorFrame) : WriteSettlement()
|
||||
data class Unconfirmed(val reason: WriteUnconfirmedReason) : WriteSettlement()
|
||||
}
|
||||
|
||||
/**
|
||||
* The client half of the receipt rules: a pure state machine with an abstract clock, one decision, how each sent write
|
||||
* settles. Transport, timers and decoding belong to the caller; `frame` takes a message and decodes it strictly.
|
||||
*/
|
||||
class WriteTracker(private val windowMs: Long, private val onSettle: (String, WriteSettlement) -> Unit = { _, _ -> }) {
|
||||
private class Entry(val sentAtMs: Long) {
|
||||
var settlement: WriteSettlement? = null
|
||||
var recorded: WriteRecorded? = null
|
||||
}
|
||||
|
||||
private val writes = linkedMapOf<String, Entry>()
|
||||
private var ended = false
|
||||
|
||||
/** Registers a write sent at [atMs]. Re-sending a known id is the same write and does not restart its window. */
|
||||
fun send(messageId: String, atMs: Long) {
|
||||
tick(atMs)
|
||||
if (messageId in writes) return
|
||||
val entry = Entry(atMs)
|
||||
writes[messageId] = entry
|
||||
// On a session that already ended nothing can answer this write.
|
||||
if (ended) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
|
||||
}
|
||||
|
||||
/** Feeds one received message. Only write_admitted, error.v1 and error.v0 answer; anything else is ignored. */
|
||||
fun frame(message: CultNetMessage, atMs: Long) {
|
||||
tick(atMs)
|
||||
when (message.schemaVersion) {
|
||||
WRITE_ADMITTED_VERSION -> {
|
||||
answer(decodeWriteAdmitted(message).messageId, WriteSettlement.Admitted, WriteRecorded.ADMITTED_LATE)
|
||||
return
|
||||
}
|
||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> {
|
||||
val error = decodeErrorFrame(message)
|
||||
val refusal = WriteSettlement.Refused(error.code, error.error, error)
|
||||
if (error.messageId != null) {
|
||||
answer(error.messageId, refusal, WriteRecorded.REFUSED_LATE)
|
||||
return
|
||||
}
|
||||
// An error that names no write is attributable only when exactly one write is unanswered.
|
||||
val unanswered = writes.filterValues { it.settlement == null }
|
||||
if (unanswered.size == 1) settle(unanswered.keys.first(), unanswered.values.first(), refusal)
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
/** Every unanswered write whose window, counted from its own send, has run settles unconfirmed. */
|
||||
fun tick(atMs: Long) {
|
||||
for ((id, entry) in writes) {
|
||||
if (entry.settlement == null && atMs - entry.sentAtMs >= windowMs) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.WINDOW_ELAPSED))
|
||||
}
|
||||
}
|
||||
|
||||
/** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */
|
||||
fun sessionEnd(atMs: Long) {
|
||||
tick(atMs)
|
||||
ended = true
|
||||
for ((id, entry) in writes) if (entry.settlement == null) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
|
||||
}
|
||||
|
||||
/** This client's own send of [messageId] failed: that write settles unconfirmed; the others are untouched. */
|
||||
fun sendFailed(messageId: String, atMs: Long) {
|
||||
val entry = writes[messageId]
|
||||
if (entry != null && entry.settlement == null) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.LOCAL_SEND_FAILED))
|
||||
tick(atMs)
|
||||
}
|
||||
|
||||
/** The settlement told to the caller, or null while unanswered or unknown. */
|
||||
fun settlement(messageId: String): WriteSettlement? = writes[messageId]?.settlement
|
||||
|
||||
/** The late answer recorded on an unconfirmed write. */
|
||||
fun recorded(messageId: String): WriteRecorded? = writes[messageId]?.recorded
|
||||
|
||||
private fun answer(messageId: String, settlement: WriteSettlement, late: WriteRecorded) {
|
||||
val entry = writes[messageId] ?: return // names a write that is not in flight: settles nothing
|
||||
val current = entry.settlement
|
||||
if (current == null) settle(messageId, entry, settlement)
|
||||
else if (current is WriteSettlement.Unconfirmed && entry.recorded == null) entry.recorded = late
|
||||
}
|
||||
|
||||
private fun settle(messageId: String, entry: Entry, settlement: WriteSettlement) {
|
||||
entry.settlement = settlement
|
||||
onSettle(messageId, settlement)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unicode White_Space (PropList), the one definition of "blank" in write-receipt.md rule 5; Kotlin's own isBlank takes U+001C to U+001F. */
|
||||
private val WHITE_SPACE_CODE_POINTS: Set<Int> = (0x0009..0x000D).toSet() + 0x0020 + 0x0085 + 0x00A0 + 0x1680 +
|
||||
(0x2000..0x200A).toSet() + 0x2028 + 0x2029 + 0x202F + 0x205F + 0x3000
|
||||
|
||||
/** True for null, empty, or only White_Space code points. */
|
||||
fun isBlankText(text: String?): Boolean = text == null || text.codePoints().allMatch { it in WHITE_SPACE_CODE_POINTS }
|
||||
|
||||
/** Self-test: blank is White_Space, not Kotlin's isBlank (rule 5): U+001C to U+001F name something, U+00A0 and U+0085 do not. */
|
||||
fun rawDocumentRecordDropsOnlyWhiteSpaceSourceIds() {
|
||||
fun wireKeys(id: String) = CultNetRawDocumentRecord("note.v0", "note:1", "2026-11-08T00:00:00Z", payload = byteArrayOf(1), sourceRuntimeId = id, sourceAgentId = id, sourceRole = id).toWireMap().keys
|
||||
for (blank in listOf("", "\u0085", "\u00a0\u2003", "\u3000", "\t\r\n ")) {
|
||||
check(isBlankText(blank) && "sourceRuntimeId" !in wireKeys(blank)) { "blank ${blank.map { it.code }} must be dropped" }
|
||||
}
|
||||
for (named in listOf("\u001c", "\u001f", "\u200b", "\ufeff", "\u180e", "a")) {
|
||||
check(!isBlankText(named) && "sourceRuntimeId" in wireKeys(named) && "sourceRole" in wireKeys(named)) { "${named.map { it.code }} names something and must be kept" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
package org.gamecult.cultmesh
|
||||
|
||||
// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: constants, contentHash, every
|
||||
// `wire` frame (decode to the vector's fields, re-encode to its bytes), every `schemaInvalid` frame (must be refused),
|
||||
// and every `scenarios` event replay through WriteTracker. `dedupe` and `blankFields` are server inputs and are not
|
||||
// run here. Mode: `java -cp ... org.gamecult.cultmesh.CultMeshKt write-receipt-vectors <vectors.json>`.
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
// The event actions scenarios.semantics names; anything else is refused, not skipped.
|
||||
private val EVENT_KEYS = listOf("send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck")
|
||||
|
||||
fun replayWriteReceiptVectors(path: String): Boolean {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val vectors = JsonReader(File(path).readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
|
||||
val results = linkedMapOf<String, List<String>>()
|
||||
fun check(name: String, run: () -> List<String>) {
|
||||
results[name] = try { run() } catch (e: Throwable) { listOf("threw: $e") }
|
||||
}
|
||||
|
||||
val constants = obj(vectors["constants"])
|
||||
check("constants") {
|
||||
listOfNotNull(
|
||||
"RECEIPT window".takeIf { constants["RECEIPT_RESULT_WINDOW_MS"] != RECEIPT_RESULT_WINDOW_MS },
|
||||
"LEGACY window".takeIf { constants["LEGACY_RESULT_WINDOW_MS"] != LEGACY_RESULT_WINDOW_MS },
|
||||
)
|
||||
}
|
||||
|
||||
for (case in list(obj(vectors["contentHash"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("contentHash/${c["name"]}") {
|
||||
val got = writeContentHash(c["operation"] as String, c["schemaId"] as String, c["recordKey"] as String, (c["payloadHex"] as String?)?.let(::unhex))
|
||||
if (got == c["sha256"]) emptyList() else listOf("$got != ${c["sha256"]}")
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(vectors["wire"])) {
|
||||
val c = obj(case)
|
||||
check("wire/${c["name"]}") {
|
||||
val bytes = unhex(c["messagePackHex"] as String)
|
||||
val problems = mutableListOf<String>()
|
||||
val message = decodeReceiptFrame(parseCultNetMessage(bytes))
|
||||
if (message.toWireMap() != c["message"]) problems += "decoded ${message.toWireMap()} != ${c["message"]}"
|
||||
val again = message.toBytes().joinToString("") { "%02X".format(it) }
|
||||
if (again != (c["messagePackHex"] as String).uppercase()) problems += "re-encoded bytes $again != captured ${c["messagePackHex"]}"
|
||||
problems
|
||||
}
|
||||
}
|
||||
|
||||
// admittedMessageId: a write_admitted messageId needs a letter or number and no Default_Ignorable code point, compared
|
||||
// raw. The frame is built with this runtime's own encoder; a case with `wireHex` (the lone surrogate, invalid UTF-8)
|
||||
// supplies the whole frame.
|
||||
for (case in list(obj(vectors["admittedMessageId"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("admittedMessageId/${c["name"]}") {
|
||||
val id = list(c["codePoints"]).joinToString("") { String(Character.toChars((it as String).toInt(16))) }.repeat(((c["repeat"] as Long?) ?: 1L).toInt())
|
||||
val bytes = (c["wireHex"] as String?)?.let(::unhex) ?: MessagePackWriter().value(linkedMapOf(
|
||||
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
|
||||
)).toByteArray()
|
||||
val decoded = try { decodeReceiptFrame(parseCultNetMessage(bytes)) } catch (_: IOException) { null }
|
||||
when {
|
||||
c["expect"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted an id that must be refused (${c["why"]})")
|
||||
decoded == null -> listOf("refused an id that must be accepted (${c["why"]})")
|
||||
decoded.toWireMap()["messageId"] != id -> listOf("id not echoed raw: ${decoded.toWireMap()["messageId"]}")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every
|
||||
// range, so deleting or narrowing any range fails here.
|
||||
check("unicodeTableRanges") {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val ranges = JsonReader(File(File(path).parentFile, "write-admitted-message-id-ranges.json").readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
|
||||
fun pairs(key: String) = list(ranges[key]).map { list(it).map { n -> (n as Long).toInt() } }
|
||||
val letters = pairs("letterOrNumber")
|
||||
val ignorable = pairs("defaultIgnorable")
|
||||
val problems = mutableListOf<String>()
|
||||
if (letters.size <= 700 || ignorable.size != 17) problems += "ranges file holds ${letters.size} and ${ignorable.size} ranges"
|
||||
fun accepted(id: String): Boolean = try {
|
||||
decodeReceiptFrame(parseCultNetMessage(MessagePackWriter().value(linkedMapOf(
|
||||
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
|
||||
)).toByteArray()))
|
||||
true
|
||||
} catch (_: IOException) { false }
|
||||
for ((low, high) in letters) for ((point, want) in listOf(low to true, high to true, low - 1 to false, high + 1 to false)) {
|
||||
if (accepted(String(Character.toChars(point))) != want) problems += "U+${point.toString(16)} alone should be ${if (want) "accepted" else "refused"}"
|
||||
}
|
||||
for ((low, high) in ignorable) for ((point, want) in listOf(low to false, high to false, low - 1 to true, high + 1 to true)) {
|
||||
if (accepted("a" + String(Character.toChars(point))) != want) problems += "a + U+${point.toString(16)} should be ${if (want) "accepted" else "refused"}"
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
// A document payload string with a lone surrogate (ED A0 80) is not a write_admitted id: the shared MessagePack reader keeps
|
||||
// reading it. Strictness is scoped to the write_admitted frame, never to the reader.
|
||||
check("documentPayloadStringWithLoneSurrogateStillDecodes") {
|
||||
val text = MessagePackReader(unhex("A3EDA080")).readString()
|
||||
if (text.isNotEmpty()) emptyList() else listOf("decoded to an empty string")
|
||||
}
|
||||
|
||||
// admittedMessageIdDuplicates: Kotlin decodes the frame to a map, so it cannot see a repeated key: it judges the last one.
|
||||
for (case in list(obj(vectors["admittedMessageIdDuplicates"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("admittedMessageIdDuplicates/${c["name"]}") {
|
||||
val decoded = try { decodeReceiptFrame(parseCultNetMessage(unhex(c["wireHex"] as String))) } catch (_: IOException) { null }
|
||||
when {
|
||||
c["lastKeyWins"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted a frame whose last messageId key is invalid")
|
||||
decoded == null -> listOf("refused a frame whose last messageId key is valid")
|
||||
decoded.toWireMap()["messageId"] != c["lastKeyWinsEcho"] -> listOf("echoed ${decoded.toWireMap()["messageId"]}, wanted ${c["lastKeyWinsEcho"]}")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(vectors["schemaInvalid"])) {
|
||||
val c = obj(case)
|
||||
check("schemaInvalid/${c["name"]}") {
|
||||
try {
|
||||
decodeReceiptFrame(parseCultNetMessage(unhex(c["messagePackHex"] as String)))
|
||||
listOf("accepted a frame that must be refused (${c["why"]})")
|
||||
} catch (_: IOException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a vector: `limit_reached` is in error.v1's enum only (write-receipt.md), as the TS and Rust suites also pin.
|
||||
check("error_v0_refuses_the_v1_only_limit_reached_code") {
|
||||
val body = linkedMapOf<String, Any?>("error" to "x", "routingHint" to null, "code" to "limit_reached", "details" to null)
|
||||
val v1 = decodeErrorFrame(CultNetMessage(ERROR_V1_VERSION, body + ("messageId" to null))).code
|
||||
try {
|
||||
decodeErrorFrame(CultNetMessage(ERROR_V0_VERSION, body))
|
||||
listOf("error.v0 accepted limit_reached")
|
||||
} catch (_: IOException) {
|
||||
if (v1 == "limit_reached") emptyList() else listOf("error.v1 lost limit_reached")
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(obj(vectors["scenarios"])["cases"])) {
|
||||
val scenario = obj(case)
|
||||
check("scenario/${scenario["name"]}") {
|
||||
val problems = mutableListOf<String>()
|
||||
// The fake server states its capability the only way a real one does: in its hello.
|
||||
val hello = cultNetHello(
|
||||
"fake-server", "fake",
|
||||
supportedMessageVersions = if (scenario["server"] == "receipt") CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS else emptyList(),
|
||||
)
|
||||
val windowMs = resultWindowMs(helloAdvertisesWriteReceipts(hello))
|
||||
if (windowMs != constants[scenario["window"] as String]) problems += "window $windowMs != ${scenario["window"]}"
|
||||
val told = linkedMapOf<String, WriteSettlement>()
|
||||
val tracker = WriteTracker(windowMs) { id, settlement ->
|
||||
if (id in told) problems += "$id settled twice"
|
||||
told[id] = settlement
|
||||
}
|
||||
for (event in list(scenario["events"]).map(::obj).sortedBy { it["atMs"] as Long }) {
|
||||
val at = event["atMs"] as Long
|
||||
val actions = event.keys - "atMs"
|
||||
if (actions.size != 1 || actions.first() !in EVENT_KEYS) { problems += "event at $at has keys $actions, wanted atMs and exactly one of $EVENT_KEYS"; continue }
|
||||
when {
|
||||
"send" in event -> tracker.send(event["send"] as String, at)
|
||||
// A frame reaches the client as bytes: the fake server encodes it, the client decodes it.
|
||||
"frame" in event -> tracker.frame(parseCultNetMessage(MessagePackWriter().value(event["frame"]).toByteArray()), at)
|
||||
"tick" in event -> tracker.tick(at)
|
||||
"sessionEnd" in event -> tracker.sessionEnd(at)
|
||||
"sendFailed" in event -> tracker.sendFailed(event["sendFailed"] as String, at)
|
||||
// transportAck: delivery is not an answer; the tracker has no such input and must not need one.
|
||||
}
|
||||
}
|
||||
val expect = obj(scenario["expect"])
|
||||
for ((id, wantAny) in expect) {
|
||||
val want = obj(wantAny)
|
||||
val got = told[id]
|
||||
if (got == null) { problems += "$id: never settled, wanted ${want["outcome"]}"; continue }
|
||||
val (outcome, code, reason) = when (got) {
|
||||
is WriteSettlement.Admitted -> Triple("admitted", null, null)
|
||||
is WriteSettlement.Refused -> Triple("refused", got.code, null)
|
||||
is WriteSettlement.Unconfirmed -> Triple("unconfirmed", null, got.reason.wire)
|
||||
}
|
||||
if (outcome != want["outcome"]) problems += "$id: told $outcome, wanted ${want["outcome"]}"
|
||||
if ("code" in want && code != want["code"]) problems += "$id: code $code, wanted ${want["code"]}"
|
||||
if ("reason" in want && reason != want["reason"]) problems += "$id: reason $reason, wanted ${want["reason"]}"
|
||||
if (tracker.recorded(id)?.wire != want["recorded"]) problems += "$id: recorded ${tracker.recorded(id)?.wire}, wanted ${want["recorded"]}"
|
||||
}
|
||||
for (id in told.keys) if (id !in expect) problems += "$id: settled but the scenario expects nothing for it"
|
||||
problems
|
||||
}
|
||||
}
|
||||
|
||||
// A runner that read fewer vectors than the file declares has silently skipped some.
|
||||
check("counts") {
|
||||
val declared = obj(vectors["counts"])
|
||||
fun ran(prefix: String) = results.keys.count { it.startsWith(prefix) }
|
||||
val loaded = mapOf(
|
||||
"wire" to list(vectors["wire"]).size, "schemaInvalid" to list(vectors["schemaInvalid"]).size,
|
||||
"contentHashCases" to list(obj(vectors["contentHash"])["cases"]).size, "scenarioCases" to list(obj(vectors["scenarios"])["cases"]).size,
|
||||
"admittedMessageIdCases" to list(obj(vectors["admittedMessageId"])["cases"]).size,
|
||||
)
|
||||
val executed = mapOf("wire" to ran("wire/"), "schemaInvalid" to ran("schemaInvalid/"), "contentHashCases" to ran("contentHash/"), "scenarioCases" to ran("scenario/"), "admittedMessageIdCases" to ran("admittedMessageId/"))
|
||||
loaded.keys.filter { loaded[it] != (declared[it] as Long).toInt() || executed[it] != loaded[it] }
|
||||
.map { "$it: declared ${declared[it]}, loaded ${loaded[it]}, ran ${executed[it]}" }
|
||||
}
|
||||
|
||||
for ((name, problems) in results) {
|
||||
println(if (problems.isEmpty()) "PASS $name" else "FAIL $name")
|
||||
problems.forEach { println(" $it") }
|
||||
}
|
||||
val failed = results.values.count { it.isNotEmpty() }
|
||||
println("${results.size - failed}/${results.size} passed")
|
||||
return failed == 0
|
||||
}
|
||||
|
||||
/** Strict decode of whichever receipt-family frame this is, returned as the message its typed form encodes; anything else is refused. */
|
||||
private fun decodeReceiptFrame(message: CultNetMessage): CultNetMessage = when (message.schemaVersion) {
|
||||
WRITE_ADMITTED_VERSION -> decodeWriteAdmitted(message).toMessage()
|
||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> decodeErrorFrame(message).toMessage()
|
||||
else -> throw IOException("not a receipt frame: ${message.schemaVersion}")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun obj(value: Any?): Map<String, Any?> = value as Map<String, Any?>
|
||||
private fun list(value: Any?): List<Any?> = value as List<Any?>
|
||||
|
||||
private fun unhex(text: String): ByteArray = ByteArray(text.length / 2) { text.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
|
||||
/** A small JSON reader for the vector file: objects, arrays, strings, integers, true/false/null. */
|
||||
private class JsonReader(private val text: String) {
|
||||
private var at = 0
|
||||
|
||||
fun readDocument(): Any? = readValue().also { skipSpace(); if (at != text.length) throw IOException("trailing JSON") }
|
||||
|
||||
private fun skipSpace() { while (at < text.length && text[at] in " \t\r\n") at++ }
|
||||
|
||||
private fun readValue(): Any? {
|
||||
skipSpace()
|
||||
return when (val c = text[at]) {
|
||||
'{' -> {
|
||||
at++
|
||||
val map = linkedMapOf<String, Any?>()
|
||||
skipSpace()
|
||||
if (text[at] == '}') { at++; return map }
|
||||
do {
|
||||
skipSpace()
|
||||
val key = readString()
|
||||
skipSpace(); expect(':')
|
||||
map[key] = readValue()
|
||||
skipSpace()
|
||||
} while (closes('}'))
|
||||
map
|
||||
}
|
||||
'[' -> {
|
||||
at++
|
||||
val items = mutableListOf<Any?>()
|
||||
skipSpace()
|
||||
if (text[at] == ']') { at++; return items }
|
||||
do {
|
||||
items += readValue()
|
||||
skipSpace()
|
||||
} while (closes(']'))
|
||||
items
|
||||
}
|
||||
'"' -> readString()
|
||||
't' -> literal("true", true)
|
||||
'f' -> literal("false", false)
|
||||
'n' -> literal("null", null)
|
||||
else -> {
|
||||
if (c != '-' && !c.isDigit()) throw IOException("unexpected '$c' at $at")
|
||||
val start = at++
|
||||
while (at < text.length && text[at].isDigit()) at++
|
||||
text.substring(start, at).toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** After an element: false when [close] ends the container, true after a comma. */
|
||||
private fun closes(close: Char): Boolean {
|
||||
val c = text[at++]
|
||||
if (c == close) return false
|
||||
if (c != ',') throw IOException("expected , or $close at ${at - 1}")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun literal(word: String, value: Any?): Any? {
|
||||
if (!text.startsWith(word, at)) throw IOException("bad literal at $at")
|
||||
at += word.length
|
||||
return value
|
||||
}
|
||||
|
||||
private fun expect(c: Char) { if (text[at++] != c) throw IOException("expected $c at ${at - 1}") }
|
||||
|
||||
private fun readString(): String {
|
||||
expect('"')
|
||||
val out = StringBuilder()
|
||||
while (true) {
|
||||
val c = text[at++]
|
||||
when (c) {
|
||||
'"' -> return out.toString()
|
||||
'\\' -> when (val e = text[at++]) {
|
||||
'n' -> out.append('\n'); 't' -> out.append('\t'); 'r' -> out.append('\r')
|
||||
'b' -> out.append('\b'); 'f' -> out.append('\u000c')
|
||||
'u' -> { out.append(text.substring(at, at + 4).toInt(16).toChar()); at += 4 }
|
||||
'"', '\\', '/' -> out.append(e)
|
||||
else -> throw IOException("bad escape \\$e")
|
||||
}
|
||||
else -> out.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ game sessions with prediction reconciliation, committed simulation facts, the
|
|||
local TCP/RUDP server, and a package daemon.
|
||||
|
||||
It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and
|
||||
`@gamecult/cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream
|
||||
`cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream
|
||||
negotiation, and simulation fact contracts are defined in
|
||||
`cultnet_py.cultmesh_contracts` and re-exported here.
|
||||
|
||||
|
|
@ -122,33 +122,6 @@ server = CultMesh.serve_node(
|
|||
)
|
||||
```
|
||||
|
||||
## Peer writes
|
||||
|
||||
**Breaking (0.x):** a served node refuses every peer `document_put_raw` and `document_delete` unless a write
|
||||
authority grants it, mirroring C# `ICultNetRemoteWriteAuthority`. Before this, any connected peer could overwrite
|
||||
or delete the node's documents. The refusal is a typed `cultnet.error.v0` with code `write_not_authorized` and the
|
||||
record key in `details`; an authority that raises, or returns no decision, refuses too.
|
||||
|
||||
Known residual: ungranted peers can still distinguish registered from unregistered schemas by timing (the node resolves the
|
||||
schema before the gate so the authority sees the canonical id); the refusal itself is identical.
|
||||
|
||||
```python
|
||||
from cultmesh_py import CultMeshGrantAllWrites, CultMeshRemoteWriteDecision, CultMeshRemoteWriteRequest
|
||||
|
||||
class OwnKeysOnly:
|
||||
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
|
||||
if request.record_key.startswith("input:python:"):
|
||||
return CultMeshRemoteWriteDecision.grant()
|
||||
return CultMeshRemoteWriteDecision.refuse("not your key")
|
||||
|
||||
server = CultMesh.serve_node(node, write_authority=OwnKeysOnly())
|
||||
```
|
||||
|
||||
The request carries the peer (`endpoint`, `transport`), the operation, the canonical schema id the node
|
||||
resolved (never the alias the peer sent), the record key and the shard id and epoch as sent. `session.serve(...)`
|
||||
takes the same `write_authority`. A host that already authenticates its peers, or a test rig, opts in to
|
||||
`CultMeshGrantAllWrites()`; the daemon's equivalent is `--allow-peer-writes`. Without it the daemon is read-only to peers.
|
||||
|
||||
The same local server can be launched as a package daemon when another runtime
|
||||
or operator process needs a CultNet endpoint without embedding Python glue:
|
||||
|
||||
|
|
@ -255,17 +228,6 @@ prediction = session.predict(note_doc, "input:python:move", {"body": "predicted
|
|||
unsubscribe()
|
||||
```
|
||||
|
||||
## Authority route verification
|
||||
|
||||
`cultmesh_py.authority` ports the C# reference
|
||||
(`src/GameCult.Mesh/CultMeshAuthorityProof.cs`): `AuthorityTrustPolicy.validate`
|
||||
judges an Odin-signed ECDSA P-256 route (channel protection, trusted root,
|
||||
validity window, IEEE P1363 signature over the canonical bytes) and
|
||||
`verify_session_proof` checks the provider nonce proof. Refusals raise
|
||||
`AuthorityRouteRefusal` with the C# message text. It needs `cryptography`.
|
||||
`tests/test_authority_route_vectors.py` judges
|
||||
`contracts/cultmesh/authority-route-vectors*.json`.
|
||||
|
||||
## Verify
|
||||
|
||||
`cultmesh_py.verify` checks the public exports and `py.typed` markers of all
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"]
|
||||
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"]
|
||||
[project.scripts]
|
||||
cultmesh-py-daemon = "cultmesh_py.daemon:main"
|
||||
cultmesh-py-verify = "cultmesh_py.verify:main"
|
||||
|
|
|
|||
|
|
@ -39,14 +39,6 @@ from .simulation import (
|
|||
CultMeshSimulationFactCommitter,
|
||||
simulation_fact_document,
|
||||
)
|
||||
from .write_authority import (
|
||||
CultMeshGrantAllWrites,
|
||||
CultMeshRemoteWriteAuthority,
|
||||
CultMeshRemoteWriteDecision,
|
||||
CultMeshRemoteWriteOperation,
|
||||
CultMeshRemoteWriteRequest,
|
||||
CultMeshServerPeer,
|
||||
)
|
||||
from cultnet_py.cultmesh_contracts import (
|
||||
PEER_EXCHANGE_REQUEST,
|
||||
PEER_EXCHANGE_RESPONSE,
|
||||
|
|
@ -97,12 +89,6 @@ __all__ = [
|
|||
"CultMeshSimulationFact",
|
||||
"CultMeshSimulationFactCommit",
|
||||
"CultMeshSimulationFactCommitter",
|
||||
"CultMeshGrantAllWrites",
|
||||
"CultMeshRemoteWriteAuthority",
|
||||
"CultMeshRemoteWriteDecision",
|
||||
"CultMeshRemoteWriteOperation",
|
||||
"CultMeshRemoteWriteRequest",
|
||||
"CultMeshServerPeer",
|
||||
"CultMeshAuthorityLease",
|
||||
"CultMeshAuthorityLeaseCatalog",
|
||||
"CultMeshHmacAuthorityLeaseVerifier",
|
||||
|
|
|
|||
|
|
@ -1,338 +0,0 @@
|
|||
"""CultMesh authority route verification.
|
||||
|
||||
Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of the
|
||||
route constructors in `CultMeshVerse.cs`. Rule for rule, in the same order, the
|
||||
same input yields the same refusal text as `CultMeshAuthorityTrustPolicy.Verify`:
|
||||
unsigned, channel protection, root lookup, validity window, signature.
|
||||
|
||||
Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||
|
||||
ROUTE_DOMAIN = "gamecult.cultmesh.route-certificate.v1"
|
||||
SESSION_DOMAIN = "gamecult.cultmesh.session-proof.v1"
|
||||
|
||||
AUTHENTICATED_REMOTE = "authenticated-remote"
|
||||
LOCAL_DEVELOPMENT = "local-development"
|
||||
|
||||
# .NET `char.IsWhiteSpace`: Unicode Zs, Zl, Zp, U+0009-U+000D and U+0085.
|
||||
# `str.strip()` is a different set (it also strips U+001C-U+001F).
|
||||
_CSHARP_WHITESPACE = (
|
||||
"\u0009\u000a\u000b\u000c\u000d \u0085 "
|
||||
" "
|
||||
"
"
|
||||
)
|
||||
|
||||
|
||||
def trim_csharp(value: str) -> str:
|
||||
return value.strip(_CSHARP_WHITESPACE)
|
||||
|
||||
|
||||
def is_blank_csharp(value: str | None) -> bool:
|
||||
return value is None or trim_csharp(value) == ""
|
||||
|
||||
|
||||
def _require(value: str | None, name: str) -> str:
|
||||
if is_blank_csharp(value):
|
||||
raise ValueError(f"Value must be non-empty. (Parameter '{name}')")
|
||||
return trim_csharp(value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class AuthorityRouteRefusal(Exception):
|
||||
"""A route or proof the consumer's trust policy refuses.
|
||||
|
||||
Mirrors `CultMeshSessionException` carrying a `CultMeshSessionFailure` with
|
||||
reason `Authentication`: `reason` is always "authentication".
|
||||
"""
|
||||
|
||||
reason = "authentication"
|
||||
|
||||
def __init__(self, message: str, endpoint: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.endpoint = endpoint
|
||||
|
||||
|
||||
def _b64_strict(value: str) -> bytes:
|
||||
# `Convert.FromBase64String`: standard alphabet, padded, whitespace ignored.
|
||||
stripped = re.sub(r"[ \t\r\n]", "", value)
|
||||
return base64.b64decode(stripped, validate=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P256PublicKey:
|
||||
key_id: str
|
||||
x: str
|
||||
y: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "key_id", _require(self.key_id, "keyId"))
|
||||
for name in ("x", "y"):
|
||||
value = _require(getattr(self, name), name)
|
||||
try:
|
||||
_b64_strict(value)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise ValueError(f"Coordinate must be base64. (Parameter '{name}')") from error
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def verify(self, payload: bytes, signature_base64: str) -> bool:
|
||||
"""IEEE P1363 (r || s) ECDSA-SHA256 verification; False on any malformation."""
|
||||
try:
|
||||
x, y = _b64_strict(self.x), _b64_strict(self.y)
|
||||
signature = _b64_strict(signature_base64)
|
||||
if len(x) != 32 or len(y) != 32 or len(signature) != 64:
|
||||
return False
|
||||
key = ec.EllipticCurvePublicNumbers(
|
||||
int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1()
|
||||
).public_key()
|
||||
der = encode_dss_signature(
|
||||
int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
|
||||
)
|
||||
key.verify(der, payload, ec.ECDSA(hashes.SHA256()))
|
||||
return True
|
||||
except (InvalidSignature, ValueError, binascii.Error):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteCertificate:
|
||||
provider_key: P256PublicKey
|
||||
odin_key_id: str
|
||||
issued_at_unix_milliseconds: int
|
||||
expires_at_unix_milliseconds: int
|
||||
signature: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if is_blank_csharp(self.odin_key_id):
|
||||
raise ValueError("Odin key identity is required. (Parameter 'odinKeyId')")
|
||||
object.__setattr__(self, "odin_key_id", trim_csharp(self.odin_key_id))
|
||||
if self.issued_at_unix_milliseconds < 0:
|
||||
raise ValueError("issuedAtUnixMilliseconds must not be negative.")
|
||||
if self.expires_at_unix_milliseconds <= self.issued_at_unix_milliseconds:
|
||||
raise ValueError("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.")
|
||||
object.__setattr__(self, "signature", trim_csharp(self.signature or ""))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityRoute:
|
||||
"""`CultMeshAuthorityRoute`: construction cleans every field as the C# does."""
|
||||
|
||||
authority_runtime_id: str
|
||||
endpoint: str
|
||||
protocol_ids: Sequence[str] = ()
|
||||
priority: int = 0
|
||||
generation: str | None = None
|
||||
certificate: RouteCertificate | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
runtime_id = _require(self.authority_runtime_id, "authorityRuntimeId")
|
||||
endpoint = _require(self.endpoint, "endpoint")
|
||||
if self.priority < 0:
|
||||
raise ValueError("priority must not be negative.")
|
||||
object.__setattr__(self, "authority_runtime_id", runtime_id)
|
||||
object.__setattr__(self, "endpoint", endpoint)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"protocol_ids",
|
||||
tuple(
|
||||
sorted(
|
||||
{trim_csharp(v) for v in self.protocol_ids if not is_blank_csharp(v)},
|
||||
key=lambda v: v.encode("utf-16-be"), # StringComparer.Ordinal is UTF-16 code-unit order
|
||||
)
|
||||
),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"generation",
|
||||
f"{runtime_id}@{endpoint}" if is_blank_csharp(self.generation) else trim_csharp(self.generation), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionOpen:
|
||||
"""The fields of `CultMeshSessionOpenMessage` the session proof covers."""
|
||||
|
||||
client_nonce: str | None = None
|
||||
message_id: str | None = None
|
||||
source_runtime_id: str | None = None
|
||||
verse_id: str | None = None
|
||||
authority_runtime_id: str | None = None
|
||||
protocol_id: str | None = None
|
||||
route_generation: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionAccepted:
|
||||
client_nonce: str | None
|
||||
provider_key_id: str | None
|
||||
provider_signature: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedRoute:
|
||||
route: AuthorityRoute
|
||||
provider_key: P256PublicKey | None
|
||||
is_local_development: bool
|
||||
|
||||
|
||||
def canonical_fields(*values: str | None) -> bytes:
|
||||
out = bytearray()
|
||||
for value in values:
|
||||
encoded = (value or "").encode("utf-8")
|
||||
out += struct.pack(">I", len(encoded)) + encoded
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def canonical_route(verse_id: str, route: AuthorityRoute) -> bytes:
|
||||
certificate = route.certificate
|
||||
if certificate is None:
|
||||
raise ValueError("Route certificate is required.")
|
||||
return canonical_fields(
|
||||
ROUTE_DOMAIN,
|
||||
verse_id,
|
||||
route.authority_runtime_id,
|
||||
route.endpoint,
|
||||
"\u001f".join(route.protocol_ids), # already ordinally sorted; Python sorts by code point
|
||||
str(route.priority),
|
||||
route.generation,
|
||||
certificate.provider_key.key_id,
|
||||
certificate.provider_key.x,
|
||||
certificate.provider_key.y,
|
||||
certificate.odin_key_id,
|
||||
str(certificate.issued_at_unix_milliseconds),
|
||||
str(certificate.expires_at_unix_milliseconds),
|
||||
)
|
||||
|
||||
|
||||
def canonical_session(request: SessionOpen, endpoint: str) -> bytes:
|
||||
return canonical_fields(
|
||||
SESSION_DOMAIN,
|
||||
request.client_nonce,
|
||||
request.message_id,
|
||||
request.source_runtime_id,
|
||||
request.verse_id,
|
||||
request.authority_runtime_id,
|
||||
request.protocol_id,
|
||||
endpoint,
|
||||
request.route_generation,
|
||||
)
|
||||
|
||||
|
||||
# `System.Uri` refuses spellings WHATWG parsers repair; a lenient parse could call
|
||||
# a refused endpoint loopback or protected. Same shape rule as the TypeScript port.
|
||||
_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+\-.]*://([^/?#]+)")
|
||||
|
||||
|
||||
def _split_endpoint(value: str) -> tuple[str, str] | None:
|
||||
"""Return (scheme, lowercase host) or None where System.Uri would refuse."""
|
||||
raw = re.sub(r"^[\x00-\x20]+|[\x00-\x20]+$", "", value)
|
||||
if "\\" in raw:
|
||||
return None
|
||||
shape = _AUTHORITY.match(raw)
|
||||
if not shape:
|
||||
return None
|
||||
authority = shape.group(1)
|
||||
after_user = authority[authority.rfind("@") + 1 :]
|
||||
if after_user.startswith("["):
|
||||
host = after_user[: after_user.find("]") + 1]
|
||||
else:
|
||||
host = after_user.split(":")[0]
|
||||
if not host or re.search(r"[^!-~]", host) or host.endswith("."):
|
||||
return None
|
||||
return raw.split("://", 1)[0].lower(), host.strip("[]").lower()
|
||||
|
||||
|
||||
def is_loopback_endpoint(endpoint: str) -> bool:
|
||||
parts = _split_endpoint(endpoint)
|
||||
if parts is None:
|
||||
return False
|
||||
host = parts[1]
|
||||
return host in ("localhost", "loopback", "::1", "::ffff:127.0.0.1") or bool(
|
||||
re.fullmatch(r"127\.\d{1,3}\.\d{1,3}\.\d{1,3}", host)
|
||||
)
|
||||
|
||||
|
||||
def is_protected_endpoint(endpoint: str) -> bool:
|
||||
parts = _split_endpoint(endpoint)
|
||||
return parts is not None and (parts[0] in ("wss", "https") or "quic" in parts[0])
|
||||
|
||||
|
||||
class AuthorityTrustPolicy:
|
||||
"""Consumer-owned policy. Fails closed unless a route is valid under it."""
|
||||
|
||||
def __init__(self, mode: str, odin_roots: Iterable[P256PublicKey] = ()) -> None:
|
||||
if mode not in (AUTHENTICATED_REMOTE, LOCAL_DEVELOPMENT):
|
||||
raise ValueError(f"Unknown trust mode '{mode}'.")
|
||||
self.mode = mode
|
||||
self._roots: dict[str, P256PublicKey] = {}
|
||||
for root in odin_roots:
|
||||
if root.key_id in self._roots:
|
||||
raise ValueError(f"Odin root key id '{root.key_id}' is listed more than once in the trust policy.")
|
||||
self._roots[root.key_id] = root
|
||||
|
||||
def validate(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> None:
|
||||
self.verify(verse_id, route, now_unix_milliseconds)
|
||||
|
||||
def verify(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> VerifiedRoute:
|
||||
local = self.mode == LOCAL_DEVELOPMENT and is_loopback_endpoint(route.endpoint)
|
||||
|
||||
def refuse(message: str) -> AuthorityRouteRefusal:
|
||||
return AuthorityRouteRefusal(message, route.endpoint)
|
||||
|
||||
certificate = route.certificate
|
||||
if certificate is None or is_blank_csharp(certificate.signature):
|
||||
if local:
|
||||
return VerifiedRoute(route, None, True)
|
||||
raise refuse("Remote CultMesh routes require an Odin-signed authority certificate.")
|
||||
if not is_protected_endpoint(route.endpoint) and not local:
|
||||
raise refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.")
|
||||
root = self._roots.get(certificate.odin_key_id)
|
||||
if root is None:
|
||||
raise refuse(f"Odin key '{certificate.odin_key_id}' is not trusted by this consumer.")
|
||||
if not (certificate.issued_at_unix_milliseconds <= now_unix_milliseconds < certificate.expires_at_unix_milliseconds):
|
||||
raise refuse("The Odin route certificate is not currently valid.")
|
||||
try:
|
||||
signature = _b64_strict(certificate.signature)
|
||||
except (binascii.Error, ValueError):
|
||||
raise refuse("The Odin route signature is not base64.") from None
|
||||
if len(signature) != 64:
|
||||
raise refuse("The Odin route signature is not IEEE P1363 P-256.")
|
||||
if not root.verify(canonical_route(verse_id, route), certificate.signature):
|
||||
raise refuse("The Odin route certificate signature is invalid.")
|
||||
return VerifiedRoute(route, certificate.provider_key, False)
|
||||
|
||||
|
||||
def verify_session_proof(
|
||||
request: SessionOpen,
|
||||
response: SessionAccepted,
|
||||
verse_id: str,
|
||||
route: AuthorityRoute,
|
||||
trust: AuthorityTrustPolicy,
|
||||
now_unix_milliseconds: int,
|
||||
) -> bool:
|
||||
"""Verifies the Odin route certificate (raising) and the provider nonce proof."""
|
||||
verified = trust.verify(verse_id, route, now_unix_milliseconds)
|
||||
if verified.is_local_development:
|
||||
return True
|
||||
key = verified.provider_key
|
||||
if (
|
||||
key is None
|
||||
or response.provider_key_id != key.key_id
|
||||
or response.client_nonce != request.client_nonce
|
||||
or is_blank_csharp(response.provider_signature)
|
||||
):
|
||||
return False
|
||||
return key.verify(canonical_session(request, route.endpoint), response.provider_signature) # type: ignore[arg-type]
|
||||
|
|
@ -13,7 +13,6 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser
|
|||
|
||||
from .facade import CultMesh
|
||||
from .server import CultMeshLocalServer
|
||||
from .write_authority import CultMeshGrantAllWrites
|
||||
from cultnet_py.cultmesh_contracts import (
|
||||
CultMeshPeerCard,
|
||||
CultMeshPeerCatalog,
|
||||
|
|
@ -54,7 +53,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||
parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5)
|
||||
parser.add_argument("--verse-id")
|
||||
parser.add_argument("--verse-display-name")
|
||||
parser.add_argument("--allow-peer-writes", action="store_true", help="grant every connected peer put and delete; without it peer writes are refused")
|
||||
parser.add_argument("--role", action="append", dest="roles")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
|
|
@ -119,7 +117,6 @@ def start_server(args: argparse.Namespace, parser: argparse.ArgumentParser) -> C
|
|||
display_name=args.display_name,
|
||||
max_snapshot_documents=args.max_snapshot_documents,
|
||||
max_snapshot_bytes=args.max_snapshot_bytes,
|
||||
write_authority=CultMeshGrantAllWrites() if args.allow_peer_writes else None,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The server owns its option rules; a limit it refuses is a usage error, not a crash.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ from .node import (
|
|||
from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer
|
||||
from .session import CultMeshGameSession, CultMeshGameSessionOptions
|
||||
from .simulation import CultMeshSimulationFactCommitter
|
||||
from .write_authority import CultMeshRemoteWriteAuthority
|
||||
from cultnet_py.cultmesh_contracts import (
|
||||
AuthorityLeaseVerifier,
|
||||
CultMeshAuthorityLeaseCatalog,
|
||||
|
|
@ -401,7 +400,6 @@ class CultMesh:
|
|||
verse_catalog: CultMeshVerseCatalog | None = None,
|
||||
peer_catalog: CultMeshPeerCatalog | None = None,
|
||||
observation_hub: CultNetSimulationObservationHub | None = None,
|
||||
write_authority: CultMeshRemoteWriteAuthority | None = None,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
display_name: str | None = None,
|
||||
|
|
@ -418,7 +416,6 @@ class CultMesh:
|
|||
verse_catalog=verse_catalog or CultMeshVerseCatalog(),
|
||||
peer_catalog=peer_catalog or CultMeshPeerCatalog(),
|
||||
observation_hub=observation_hub,
|
||||
write_authority=write_authority,
|
||||
host=host,
|
||||
port=port,
|
||||
display_name=display_name,
|
||||
|
|
|
|||
|
|
@ -650,29 +650,6 @@ class CultMeshDatabase:
|
|||
self._append_shard_log_delete(message)
|
||||
return message
|
||||
|
||||
def raw_put_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
|
||||
"""The canonical schema id and record key a raw put would write, or None when it would not apply."""
|
||||
document_record = message.get("document")
|
||||
# Only a document of the wire shape is resolved, so an ungranted peer's payload is never
|
||||
# converted or allocated here: a malformed one is the store's refusal, after the authority.
|
||||
if (
|
||||
not isinstance(document_record, dict)
|
||||
or not isinstance(document_record.get("recordKey"), str)
|
||||
or not isinstance(document_record.get("payload"), (bytes, bytearray, memoryview))
|
||||
):
|
||||
return None
|
||||
try:
|
||||
prepared = self._raw_put_envelope(message)
|
||||
except Exception: # noqa: BLE001 - any other malformed shape
|
||||
return None
|
||||
return None if prepared is None else (prepared[0].catalog_entry().schema_id, prepared[1].key)
|
||||
|
||||
def raw_delete_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
|
||||
"""The canonical schema id and record key a raw delete names, or None when its schema is not registered."""
|
||||
document = self._document_for_schema(str(message.get("schemaId") or ""))
|
||||
record_key = str(message.get("recordKey") or "")
|
||||
return None if document is None or not record_key else (document.catalog_entry().schema_id, record_key)
|
||||
|
||||
def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None:
|
||||
prepared = self._raw_put_envelope(message)
|
||||
if prepared is None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
|
|
@ -11,9 +10,6 @@ from typing import Any
|
|||
import msgpack
|
||||
|
||||
from cultnet_py import (
|
||||
error_message,
|
||||
message_invalid_error,
|
||||
unsupported_schema_version_error,
|
||||
CultNetRawSnapshotResponse,
|
||||
CultNetSimulationObservationHub,
|
||||
CultNetRudpPacket,
|
||||
|
|
@ -31,12 +27,6 @@ from cultnet_py import (
|
|||
)
|
||||
|
||||
from .node import CultMeshNode
|
||||
from .write_authority import (
|
||||
CultMeshRemoteWriteAuthority,
|
||||
CultMeshRemoteWriteOperation,
|
||||
CultMeshRemoteWriteRequest,
|
||||
CultMeshServerPeer,
|
||||
)
|
||||
|
||||
# The shortest message id this server answers a snapshot request under. It takes a
|
||||
# request's messageId as given, an empty one included, so no response it sends is smaller.
|
||||
|
|
@ -60,17 +50,6 @@ from cultnet_py.cultmesh_contracts import (
|
|||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How many consecutive refused frames of one kind a RUDP session may send before it is ended: undecodable
|
||||
# frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any decoded
|
||||
# frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
|
||||
# frame that reaches this number is answered too, then the session ends. The same number is the cap in every
|
||||
# runtime.
|
||||
MAX_CONSECUTIVE_REFUSED_FRAMES = 8
|
||||
|
||||
# How long a session whose capped frame was answered waits for the peer to acknowledge the answer before it ends anyway.
|
||||
_RUDP_CAPPED_ANSWER_GRACE_MS = 2000
|
||||
_EPHEMERAL_PAIR_ATTEMPTS = 64
|
||||
|
||||
|
||||
class _ReplyUnqueueable(Exception):
|
||||
"""A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is
|
||||
|
|
@ -100,10 +79,6 @@ class _DatabaseSubscription:
|
|||
class _RudpPeerConnection:
|
||||
session: CultNetRudpSession
|
||||
subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict)
|
||||
undecodable_frames_in_a_row: int = 0
|
||||
refused_deletes_in_a_row: int = 0
|
||||
# Set once the peer's capped frame has been answered: the session ends when the answers are acknowledged.
|
||||
ending_deadline_ms: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -112,9 +87,6 @@ class CultMeshLocalServer:
|
|||
verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog)
|
||||
peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog)
|
||||
observation_hub: CultNetSimulationObservationHub | None = None
|
||||
# Asked once per peer put and delete. None refuses every one of them (write_not_authorized); a node's own
|
||||
# writes never come through here.
|
||||
write_authority: CultMeshRemoteWriteAuthority | None = None
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 0
|
||||
display_name: str | None = None
|
||||
|
|
@ -185,49 +157,26 @@ class CultMeshLocalServer:
|
|||
def start(self) -> "CultMeshLocalServer":
|
||||
if self._socket is not None:
|
||||
return self
|
||||
server, rudp_socket = self._bind_sockets()
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((self.host, self.port))
|
||||
server.listen()
|
||||
server.settimeout(0.2)
|
||||
self._socket = server
|
||||
self.port = int(server.getsockname()[1])
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if rudp_socket is not None:
|
||||
if self.enable_rudp:
|
||||
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
rudp_socket.bind((self.host, self.port))
|
||||
rudp_socket.settimeout(0.02)
|
||||
self._rudp_socket = rudp_socket
|
||||
self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True)
|
||||
self._rudp_thread.start()
|
||||
return self
|
||||
|
||||
def _bind_sockets(self) -> tuple[socket.socket, socket.socket | None]:
|
||||
"""The listening TCP socket and, when RUDP is on, the UDP socket on the same port number.
|
||||
|
||||
An explicit port is bound as asked and a failure is the caller's. Port 0 asks for any free port, and the
|
||||
kernel picks the TCP one without knowing whether another process holds that number for UDP, so a
|
||||
UDP collision retries with a fresh TCP port instead of failing the start. Both sockets are bound
|
||||
before any thread starts, so a failed start leaves nothing running or open.
|
||||
"""
|
||||
attempts = _EPHEMERAL_PAIR_ATTEMPTS if self.port == 0 and self.enable_rudp else 1
|
||||
for attempt in range(attempts):
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
rudp_socket = None
|
||||
try:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((self.host, self.port))
|
||||
server.listen()
|
||||
if self.enable_rudp:
|
||||
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
rudp_socket.bind((self.host, int(server.getsockname()[1])))
|
||||
return server, rudp_socket
|
||||
except OSError as error:
|
||||
server.close()
|
||||
if rudp_socket is not None:
|
||||
rudp_socket.close()
|
||||
if error.errno != errno.EADDRINUSE or attempt == attempts - 1:
|
||||
raise
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._rudp_socket is not None:
|
||||
|
|
@ -280,7 +229,12 @@ class CultMeshLocalServer:
|
|||
if schema_version == "cultnet.simulation_observation.v0":
|
||||
candidates = self._candidate_responses(message)
|
||||
return candidates[0] if candidates else None
|
||||
return unsupported_schema_version_error(schema_version).to_wire()
|
||||
return self._error_response(
|
||||
f"Unsupported CultNet message schema: {schema_version!r}.",
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
code="unsupported_schema_version",
|
||||
details={"schemaVersion": schema_version},
|
||||
)
|
||||
|
||||
def _accept_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
|
|
@ -288,15 +242,14 @@ class CultMeshLocalServer:
|
|||
if server is None:
|
||||
return
|
||||
try:
|
||||
client, address = server.accept()
|
||||
client, _ = server.accept()
|
||||
except TimeoutError:
|
||||
continue
|
||||
except OSError:
|
||||
return
|
||||
peer = CultMeshServerPeer(f"{address[0]}:{address[1]}", "tcp")
|
||||
threading.Thread(target=self._handle_connection, args=(client, peer), daemon=True).start()
|
||||
threading.Thread(target=self._handle_connection, args=(client,), daemon=True).start()
|
||||
|
||||
def _handle_connection(self, client: socket.socket, peer: CultMeshServerPeer) -> None:
|
||||
def _handle_connection(self, client: socket.socket) -> None:
|
||||
with client:
|
||||
stream = client.makefile("rwb")
|
||||
transport = TcpFramedTransportConnection(
|
||||
|
|
@ -318,7 +271,7 @@ class CultMeshLocalServer:
|
|||
if not isinstance(message, dict):
|
||||
continue
|
||||
try:
|
||||
responses = self._handle_connection_message(message, subscriptions, peer=peer)
|
||||
responses = self._handle_connection_message(message, subscriptions)
|
||||
except Exception as error: # noqa: BLE001 - one message never ends the connection
|
||||
# Whatever a peer's message makes the handling raise is that peer's failure:
|
||||
# it is refused with fixed text and the connection serves on. The log names
|
||||
|
|
@ -360,7 +313,6 @@ class CultMeshLocalServer:
|
|||
if packet.connection_id != self.rudp_connection_id:
|
||||
continue
|
||||
|
||||
self._end_answered_peers(rudp_socket, peers)
|
||||
now_ms = _now_ms()
|
||||
peer = peers.get(remote_addr)
|
||||
if packet.packet_type == CultNetRudpPacketType.CONNECT:
|
||||
|
|
@ -409,31 +361,13 @@ class CultMeshLocalServer:
|
|||
for delivered in result.delivered:
|
||||
if delivered.channel_id != "schema":
|
||||
continue
|
||||
if peer.ending_deadline_ms is not None:
|
||||
# The capped frame was answered; nothing after it is served while the answer is delivered.
|
||||
break
|
||||
try:
|
||||
message = msgpack.unpackb(delivered.payload, raw=False)
|
||||
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
|
||||
message = None
|
||||
if isinstance(message, dict):
|
||||
peer.undecodable_frames_in_a_row = 0
|
||||
responses = self._handle_connection_message(
|
||||
message,
|
||||
peer.subscriptions,
|
||||
over_rudp=True,
|
||||
peer=CultMeshServerPeer(f"{remote_addr[0]}:{remote_addr[1]}", "rudp"),
|
||||
)
|
||||
refused_delete = message.get("schemaVersion") == "cultnet.document_delete.v0" and any(
|
||||
response.get("code") == "write_not_authorized" for response in responses
|
||||
)
|
||||
peer.refused_deletes_in_a_row = peer.refused_deletes_in_a_row + 1 if refused_delete else 0
|
||||
refused, run = refused_delete, peer.refused_deletes_in_a_row
|
||||
else:
|
||||
# A frame that is no CultNet message is answered, not dropped, and the session kept.
|
||||
peer.undecodable_frames_in_a_row += 1
|
||||
refused, run = True, peer.undecodable_frames_in_a_row
|
||||
responses = [self._message_invalid({}, "message", "CultMesh RUDP server could not read the message.")]
|
||||
continue
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
responses = self._handle_connection_message(message, peer.subscriptions, over_rudp=True)
|
||||
for response in responses:
|
||||
if unsendable is None:
|
||||
unsendable = self._send_rudp_schema_frame(
|
||||
|
|
@ -443,11 +377,6 @@ class CultMeshLocalServer:
|
|||
msgpack.packb(response, use_bin_type=True),
|
||||
message_id=str(response.get("messageId") or ""),
|
||||
)
|
||||
if refused and run >= MAX_CONSECUTIVE_REFUSED_FRAMES:
|
||||
# The capped frame is answered like any other, reliably. The session ends once the peer
|
||||
# has acknowledged every answer (or the grace runs out): see _end_answered_peers.
|
||||
peer.ending_deadline_ms = now_ms + _RUDP_CAPPED_ANSWER_GRACE_MS
|
||||
break
|
||||
except Exception as error: # noqa: BLE001 - one peer's message never ends the thread
|
||||
# Whatever a peer's message makes the handling raise is that peer's failure.
|
||||
# The session recorded the frame's sequence, so it cannot be kept: a retransmit
|
||||
|
|
@ -478,26 +407,11 @@ class CultMeshLocalServer:
|
|||
if unsendable is not None:
|
||||
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
|
||||
|
||||
def _end_answered_peers(
|
||||
self,
|
||||
rudp_socket: socket.socket,
|
||||
peers: dict[tuple[str, int], _RudpPeerConnection],
|
||||
) -> None:
|
||||
"""Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
|
||||
answer is resent first), or when the grace has run out."""
|
||||
now_ms = _now_ms()
|
||||
for remote_addr, peer in list(peers.items()):
|
||||
if peer.ending_deadline_ms is None:
|
||||
continue
|
||||
if peer.session.outstanding_reliable_packet_count == 0 or now_ms >= peer.ending_deadline_ms:
|
||||
self._end_refused_peer(rudp_socket, peers, remote_addr)
|
||||
|
||||
def _poll_rudp_resends(
|
||||
self,
|
||||
rudp_socket: socket.socket,
|
||||
peers: dict[tuple[str, int], _RudpPeerConnection],
|
||||
) -> None:
|
||||
self._end_answered_peers(rudp_socket, peers)
|
||||
now_ms = _now_ms()
|
||||
for remote_addr, peer in list(peers.items()):
|
||||
for packet in peer.session.due_resends(now_ms):
|
||||
|
|
@ -609,7 +523,6 @@ class CultMeshLocalServer:
|
|||
subscriptions: dict[str, _DatabaseSubscription],
|
||||
*,
|
||||
over_rudp: bool = False,
|
||||
peer: CultMeshServerPeer | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
schema_version = message.get("schemaVersion")
|
||||
if schema_version == "cultnet.database_subscribe.v0":
|
||||
|
|
@ -633,9 +546,9 @@ class CultMeshLocalServer:
|
|||
subscriptions.pop(str(message.get("subscriptionId") or ""), None)
|
||||
return []
|
||||
if schema_version == "cultnet.document_put_raw.v0":
|
||||
return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp, peer=peer)
|
||||
return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp)
|
||||
if schema_version == "cultnet.document_delete.v0":
|
||||
return self._handle_raw_delete(message, subscriptions, peer=peer)
|
||||
return self._handle_raw_delete(message, subscriptions)
|
||||
if schema_version == "cultnet.simulation_observation.v0":
|
||||
return self._candidate_responses(message)
|
||||
response = self.handle_message(message)
|
||||
|
|
@ -647,27 +560,14 @@ class CultMeshLocalServer:
|
|||
subscriptions: dict[str, _DatabaseSubscription],
|
||||
*,
|
||||
over_rudp: bool = False,
|
||||
peer: CultMeshServerPeer | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
document_record = message.get("document")
|
||||
if not isinstance(document_record, dict):
|
||||
return [self._message_invalid(message, "document", "Raw put messages must contain a document map.")]
|
||||
malformed_shard = self._malformed_shard_fields(message)
|
||||
if malformed_shard is not None:
|
||||
return [malformed_shard]
|
||||
# Refusal precedes every other judgment of the put. The node resolves the schema first, because the
|
||||
# authority must be asked about the canonical schema id (a payload-inferred alias included), as C#
|
||||
# does; only a payload of the wire shape is resolved, so nothing of an ungranted peer's is
|
||||
# allocated. A put the node cannot resolve is asked about under the ids as sent. Residual: the
|
||||
# refusal is the same either way, but an ungranted peer can still tell registered from unregistered
|
||||
# schemas by timing.
|
||||
target = self.node.database.raw_put_target(message) or (
|
||||
str(document_record.get("schemaId") or ""),
|
||||
str(document_record.get("recordKey") or ""),
|
||||
)
|
||||
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.PUT, target, message)
|
||||
if refusal is not None:
|
||||
return [refusal]
|
||||
return [self._error_response(
|
||||
"Raw put messages must contain a document map.",
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
code="malformed_document_put",
|
||||
)]
|
||||
unservable = self._unservable_put(message, over_rudp=over_rudp)
|
||||
if unservable is not None:
|
||||
return [unservable]
|
||||
|
|
@ -679,7 +579,11 @@ class CultMeshLocalServer:
|
|||
# raises. It is refused to the peer that sent it; no thread and no other peer is
|
||||
# affected. Neither the refusal nor the log quotes the error, which can quote the put.
|
||||
_LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__)
|
||||
return [self._message_invalid(message, "document", "Raw put document could not be stored.")]
|
||||
return [self._error_response(
|
||||
"Raw put document could not be stored.",
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
code="malformed_document_put",
|
||||
)]
|
||||
if change is None:
|
||||
return [self._error_response(
|
||||
"Raw put message did not apply to a registered document.",
|
||||
|
|
@ -698,22 +602,15 @@ class CultMeshLocalServer:
|
|||
self,
|
||||
message: dict[str, Any],
|
||||
subscriptions: dict[str, _DatabaseSubscription],
|
||||
*,
|
||||
peer: CultMeshServerPeer | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
schema_id = str(message.get("schemaId") or "")
|
||||
record_key = str(message.get("recordKey") or "")
|
||||
if not schema_id or not record_key:
|
||||
return [self._message_invalid(
|
||||
message, "schemaId" if not schema_id else "recordKey", "Raw delete messages must contain schemaId and recordKey.",
|
||||
return [self._error_response(
|
||||
"Raw delete messages must contain schemaId and recordKey.",
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
code="malformed_document_delete",
|
||||
)]
|
||||
malformed_shard = self._malformed_shard_fields(message)
|
||||
if malformed_shard is not None:
|
||||
return [malformed_shard]
|
||||
target = self.node.database.raw_delete_target(message) or (schema_id, record_key)
|
||||
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.DELETE, target, message)
|
||||
if refusal is not None:
|
||||
return [refusal]
|
||||
change = self.node.database.apply_raw_delete_message(message)
|
||||
if change is None:
|
||||
return [self._error_response(
|
||||
|
|
@ -723,70 +620,6 @@ class CultMeshLocalServer:
|
|||
)]
|
||||
return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id)
|
||||
|
||||
def _message_invalid(self, message: dict[str, Any], field: str, reason: str) -> dict[str, Any]:
|
||||
"""The typed `message_invalid` refusal naming the request field at fault, under the request's messageId."""
|
||||
response = message_invalid_error(field, reason).to_wire()
|
||||
response["messageId"] = str(message.get("messageId") or "")
|
||||
return response
|
||||
|
||||
def _malformed_shard_fields(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""The protocol error for a shardId that is not a string or a shardEpoch that is not an int (a bool is
|
||||
not), else None. It precedes the gate, so the authority only ever judges a write whose shard it can
|
||||
read, and the store never files a write under a shard the authority did not see."""
|
||||
shard_id = message.get("shardId")
|
||||
shard_epoch = message.get("shardEpoch")
|
||||
if (shard_id is None or isinstance(shard_id, str)) and (
|
||||
shard_epoch is None or (isinstance(shard_epoch, int) and not isinstance(shard_epoch, bool))
|
||||
):
|
||||
return None
|
||||
return self._message_invalid(
|
||||
message,
|
||||
"shardId" if not (shard_id is None or isinstance(shard_id, str)) else "shardEpoch",
|
||||
"shardId must be a string and shardEpoch an integer.",
|
||||
)
|
||||
|
||||
def _refuse_unauthorized_write(
|
||||
self,
|
||||
peer: CultMeshServerPeer | None,
|
||||
operation: CultMeshRemoteWriteOperation,
|
||||
target: tuple[str, str],
|
||||
message: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""None when the write may proceed, else the typed refusal for the sending peer.
|
||||
|
||||
No authority configured refuses; an authority that raises refuses too (fail closed) and is logged.
|
||||
"""
|
||||
schema_id, record_key = target
|
||||
reason: str | None
|
||||
authority = self.write_authority
|
||||
if authority is None:
|
||||
reason = "no remote write authority is configured"
|
||||
else:
|
||||
request = CultMeshRemoteWriteRequest(
|
||||
peer=peer or CultMeshServerPeer("", "unknown"),
|
||||
operation=operation,
|
||||
schema_id=schema_id,
|
||||
record_key=record_key,
|
||||
shard_id=message.get("shardId"),
|
||||
shard_epoch=message.get("shardEpoch"),
|
||||
)
|
||||
try:
|
||||
decision = authority.decide(request)
|
||||
except Exception as error: # noqa: BLE001 - a failing authority refuses
|
||||
_LOGGER.error("CultMesh remote write authority failed (%s); the write was refused.", type(error).__name__)
|
||||
decision = None
|
||||
reason = "the write authority failed"
|
||||
else:
|
||||
reason = getattr(decision, "reason", None)
|
||||
if decision is not None and getattr(decision, "is_granted", False) is True:
|
||||
return None
|
||||
_LOGGER.warning("CultMesh %s of %s refused: write not authorized.", operation.value, schema_id)
|
||||
return error_message(
|
||||
f"write_not_authorized: {reason}" if reason and reason.strip() else "write_not_authorized",
|
||||
code="write_not_authorized",
|
||||
details={"field": "recordKey", "value": record_key},
|
||||
).to_wire()
|
||||
|
||||
def _database_change_notifications(
|
||||
self,
|
||||
message: dict[str, Any],
|
||||
|
|
@ -974,7 +807,11 @@ class CultMeshLocalServer:
|
|||
and isinstance(document_record.get("recordKey"), str)
|
||||
and isinstance(document_record.get("payload"), (bytes, bytearray))
|
||||
):
|
||||
return self._message_invalid(message, "document", "Raw put documents must carry a string recordKey and a binary payload.")
|
||||
return self._error_response(
|
||||
"Raw put documents must carry a string recordKey and a binary payload.",
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
code="malformed_document_put",
|
||||
)
|
||||
if self.max_snapshot_bytes is None and not over_rudp:
|
||||
return None
|
||||
alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMes
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from .server import CultMeshLocalServer
|
||||
from .write_authority import CultMeshRemoteWriteAuthority
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -84,7 +83,6 @@ class CultMeshGameSession:
|
|||
def serve(
|
||||
self,
|
||||
*,
|
||||
write_authority: CultMeshRemoteWriteAuthority | None = None,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
display_name: str | None = None,
|
||||
|
|
@ -98,7 +96,6 @@ class CultMeshGameSession:
|
|||
verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(),
|
||||
peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(),
|
||||
observation_hub=self.observation_hub if self._serve_simulation_observations else None,
|
||||
write_authority=write_authority,
|
||||
host=host,
|
||||
port=port,
|
||||
display_name=display_name,
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
"""The one question a CultMesh server asks before it applies a peer's write.
|
||||
|
||||
Mirrors C# ``ICultNetRemoteWriteAuthority``: a server with no authority refuses every peer put and
|
||||
delete, so serving a node never lets a connected peer overwrite its documents by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class CultMeshRemoteWriteOperation(Enum):
|
||||
PUT = "put"
|
||||
DELETE = "delete"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CultMeshServerPeer:
|
||||
"""The connection a write arrived on: its endpoint and transport, nothing the peer claimed."""
|
||||
|
||||
endpoint: str
|
||||
transport: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CultMeshRemoteWriteRequest:
|
||||
"""One peer write as the authority sees it.
|
||||
|
||||
``schema_id`` is the canonical id the node resolved for the write, never an alias the peer sent.
|
||||
``shard_id`` and ``shard_epoch`` are as the peer sent them.
|
||||
"""
|
||||
|
||||
peer: CultMeshServerPeer
|
||||
operation: CultMeshRemoteWriteOperation
|
||||
schema_id: str
|
||||
record_key: str
|
||||
shard_id: str | None = None
|
||||
shard_epoch: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CultMeshRemoteWriteDecision:
|
||||
is_granted: bool
|
||||
reason: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def grant() -> CultMeshRemoteWriteDecision:
|
||||
return CultMeshRemoteWriteDecision(True)
|
||||
|
||||
@staticmethod
|
||||
def refuse(reason: str | None = None) -> CultMeshRemoteWriteDecision:
|
||||
return CultMeshRemoteWriteDecision(False, reason)
|
||||
|
||||
|
||||
class CultMeshRemoteWriteAuthority(Protocol):
|
||||
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision: ...
|
||||
|
||||
|
||||
class CultMeshGrantAllWrites:
|
||||
"""Grants every peer write. For a host that has already authenticated its peers (or a test rig); it is
|
||||
an explicit choice because serving without an authority refuses writes."""
|
||||
|
||||
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
|
||||
return CultMeshRemoteWriteDecision.grant()
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
"""Judges `contracts/cultmesh/authority-route-vectors*.json` in Python.
|
||||
|
||||
`authority-route-vectors.json` is written by the C# reference (signatures are
|
||||
captured C# bytes); the `.ts-signed.json` file is signed by TypeScript over awkward
|
||||
inputs. Each negative case fails when the matching check is removed from
|
||||
`cultmesh_py.authority`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
from cultmesh_py.authority import (
|
||||
AUTHENTICATED_REMOTE,
|
||||
LOCAL_DEVELOPMENT,
|
||||
AuthorityRoute,
|
||||
AuthorityRouteRefusal,
|
||||
AuthorityTrustPolicy,
|
||||
P256PublicKey,
|
||||
RouteCertificate,
|
||||
SessionAccepted,
|
||||
SessionOpen,
|
||||
canonical_route,
|
||||
verify_session_proof,
|
||||
)
|
||||
|
||||
CONTRACTS = Path(__file__).resolve().parents[3] / "contracts" / "cultmesh"
|
||||
|
||||
|
||||
def _key(element: dict) -> P256PublicKey:
|
||||
return P256PublicKey(element["keyId"], element["x"], element["y"])
|
||||
|
||||
|
||||
class Vector:
|
||||
def __init__(self, name: str) -> None:
|
||||
doc = json.loads((CONTRACTS / name).read_text(encoding="utf-8"))
|
||||
self.now = doc["nowUnixMilliseconds"]
|
||||
self.root = _key(doc["odinRoot"])
|
||||
r = doc["route"]
|
||||
self.verse_id = r["verseId"]
|
||||
c = r["certificate"]
|
||||
self.certificate = RouteCertificate(
|
||||
_key(c["providerKey"]), c["odinKeyId"], c["issuedAtUnixMilliseconds"],
|
||||
c["expiresAtUnixMilliseconds"], c["signature"],
|
||||
)
|
||||
self.route = AuthorityRoute(
|
||||
r["authorityRuntimeId"], r["endpoint"], r["protocolIds"], r["priority"],
|
||||
r["generation"], self.certificate,
|
||||
)
|
||||
p = doc["sessionProof"]
|
||||
q = p["request"]
|
||||
self.request = SessionOpen(
|
||||
q["clientNonce"], q["messageId"], q["sourceRuntimeId"], q["verseId"],
|
||||
q["authorityRuntimeId"], q["protocolId"], q["routeGeneration"],
|
||||
)
|
||||
self.accepted = SessionAccepted(q["clientNonce"], p["providerKeyId"], p["providerSignature"])
|
||||
self.trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [self.root])
|
||||
|
||||
def with_certificate(self, **changes) -> AuthorityRoute:
|
||||
return replace(self.route, certificate=replace(self.certificate, **changes))
|
||||
|
||||
|
||||
def refusal(test: unittest.TestCase, fn) -> str:
|
||||
with test.assertRaises(AuthorityRouteRefusal) as raised:
|
||||
fn()
|
||||
test.assertEqual("authentication", raised.exception.reason)
|
||||
return raised.exception.message
|
||||
|
||||
|
||||
class AuthorityRouteVectorTests(unittest.TestCase):
|
||||
def test_cs_written_vector_verifies_route_and_session_proof(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
v.trust.validate(v.verse_id, v.route, v.now)
|
||||
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
|
||||
def test_ts_signed_vector_verifies_and_tamper_is_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.ts-signed.json")
|
||||
v.trust.validate(v.verse_id, v.route, v.now)
|
||||
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
tampered = replace(v.route, priority=v.route.priority + 1)
|
||||
self.assertEqual(
|
||||
"The Odin route certificate signature is invalid.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, tampered, v.now)),
|
||||
)
|
||||
|
||||
def test_canonical_route_is_length_prefixed_and_sorted(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
data = canonical_route(v.verse_id, v.route)
|
||||
self.assertTrue(data.startswith(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"))
|
||||
self.assertIn(b"cultmesh.content.v1\x1fcultmesh.documents.v1", data)
|
||||
|
||||
def test_tampered_payload_fields_are_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
invalid = "The Odin route certificate signature is invalid."
|
||||
for tampered, verse in (
|
||||
(replace(v.route, priority=8), v.verse_id),
|
||||
(replace(v.route, endpoint="wss://evil.example/mesh"), v.verse_id),
|
||||
(replace(v.route, protocol_ids=("cultmesh.documents.v1",)), v.verse_id),
|
||||
(replace(v.route, generation="other"), v.verse_id),
|
||||
(v.with_certificate(expires_at_unix_milliseconds=v.certificate.expires_at_unix_milliseconds + 1), v.verse_id),
|
||||
(v.route, "other-verse"),
|
||||
):
|
||||
self.assertEqual(invalid, refusal(self, lambda: v.trust.validate(verse, tampered, v.now)))
|
||||
|
||||
def test_wrong_root_key_is_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
numbers = ec.generate_private_key(ec.SECP256R1()).public_key().public_numbers()
|
||||
impostor = P256PublicKey(
|
||||
v.root.key_id,
|
||||
base64.b64encode(numbers.x.to_bytes(32, "big")).decode(),
|
||||
base64.b64encode(numbers.y.to_bytes(32, "big")).decode(),
|
||||
)
|
||||
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [impostor])
|
||||
self.assertEqual(
|
||||
"The Odin route certificate signature is invalid.",
|
||||
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
|
||||
)
|
||||
|
||||
def test_untrusted_odin_key_id_is_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [replace(v.root, key_id="someone-else")])
|
||||
self.assertEqual(
|
||||
"Odin key 'odin-vector' is not trusted by this consumer.",
|
||||
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
|
||||
)
|
||||
|
||||
def test_expired_and_not_yet_valid_are_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
window = "The Odin route certificate is not currently valid."
|
||||
expires = v.certificate.expires_at_unix_milliseconds
|
||||
issued = v.certificate.issued_at_unix_milliseconds
|
||||
v.trust.validate(v.verse_id, v.route, expires - 1)
|
||||
v.trust.validate(v.verse_id, v.route, issued)
|
||||
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, expires)))
|
||||
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, issued - 1)))
|
||||
|
||||
def test_unsigned_and_unprotected_routes_are_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
unsigned = replace(v.route, certificate=None)
|
||||
self.assertEqual(
|
||||
"Remote CultMesh routes require an Odin-signed authority certificate.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, unsigned, v.now)),
|
||||
)
|
||||
blank = v.with_certificate(signature=" ")
|
||||
self.assertEqual(
|
||||
"Remote CultMesh routes require an Odin-signed authority certificate.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, blank, v.now)),
|
||||
)
|
||||
plain = replace(v.route, endpoint="ws://provider.example/mesh")
|
||||
self.assertEqual(
|
||||
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, plain, v.now)),
|
||||
)
|
||||
|
||||
def test_local_development_admits_only_unsigned_loopback(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
dev = AuthorityTrustPolicy(LOCAL_DEVELOPMENT, [])
|
||||
loop = AuthorityRoute("a", "ws://127.0.0.1:9/mesh")
|
||||
dev.validate(v.verse_id, loop, v.now)
|
||||
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, loop, dev, v.now))
|
||||
for endpoint in ("ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"):
|
||||
refusal(self, lambda e=endpoint: dev.validate(v.verse_id, AuthorityRoute("a", e), v.now))
|
||||
authenticated = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [])
|
||||
refusal(self, lambda: authenticated.validate(v.verse_id, loop, v.now))
|
||||
|
||||
def test_malformed_signatures_get_their_own_refusals(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
not_base64 = v.with_certificate(signature="!!not base64!!")
|
||||
self.assertEqual(
|
||||
"The Odin route signature is not base64.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, not_base64, v.now)),
|
||||
)
|
||||
short = v.with_certificate(signature=base64.b64encode(b"x" * 63).decode())
|
||||
self.assertEqual(
|
||||
"The Odin route signature is not IEEE P1363 P-256.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, short, v.now)),
|
||||
)
|
||||
|
||||
def test_high_s_signature_verifies_as_it_does_in_dotnet(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
|
||||
raw = base64.b64decode(v.certificate.signature)
|
||||
s = int.from_bytes(raw[32:], "big")
|
||||
flipped = raw[:32] + (order - s).to_bytes(32, "big")
|
||||
high = v.with_certificate(signature=base64.b64encode(flipped).decode())
|
||||
v.trust.validate(v.verse_id, high, v.now)
|
||||
|
||||
def test_session_proof_rejects_replay_wrong_key_and_forgery(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
replayed = replace(v.request, client_nonce="replayed")
|
||||
accepted = replace(v.accepted, client_nonce="replayed")
|
||||
self.assertFalse(verify_session_proof(replayed, accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
self.assertFalse(verify_session_proof(
|
||||
v.request, replace(v.accepted, provider_key_id="other"), v.verse_id, v.route, v.trust, v.now))
|
||||
self.assertFalse(verify_session_proof(
|
||||
v.request, replace(v.accepted, client_nonce="other"), v.verse_id, v.route, v.trust, v.now))
|
||||
self.assertFalse(verify_session_proof(
|
||||
v.request, replace(v.accepted, provider_signature=""), v.verse_id, v.route, v.trust, v.now))
|
||||
raw = bytearray(base64.b64decode(v.accepted.provider_signature))
|
||||
raw[0] ^= 1
|
||||
forged = replace(v.accepted, provider_signature=base64.b64encode(bytes(raw)).decode())
|
||||
self.assertFalse(verify_session_proof(v.request, forged, v.verse_id, v.route, v.trust, v.now))
|
||||
moved = replace(v.request, protocol_id="cultmesh.content.v1")
|
||||
self.assertFalse(verify_session_proof(moved, v.accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
# An expired route fails closed even with a good proof.
|
||||
refusal(self, lambda: verify_session_proof(
|
||||
v.request, v.accepted, v.verse_id, v.route, v.trust, v.certificate.expires_at_unix_milliseconds))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"""Heimdall's invariant: no client treats a reliable-transport ack as write admission. A put completes only on
|
||||
its result frame (write_not_authorized, or the success reply).
|
||||
|
||||
The Python write path is a subscription over RUDP: `send` queues the put, `read_next` returns the result frame
|
||||
and raises the peer's refusal. The server here refuses the put; its reliable ack of the put arrives (the test
|
||||
waits for it) and the client still reports the refusal, never success. The transport's `receive` yields data
|
||||
frames only, so the order of the ack against the refusal cannot change what `read_next` reports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from cultcache_py import define_database_entry_type
|
||||
from cultmesh_py import CultMesh, create_node
|
||||
from cultmesh_py.server import CultMeshLocalServer
|
||||
from cultnet_py import CultNetDatabaseSubscription, CultNetPeerError, create_rudp_schema_transport
|
||||
|
||||
from test_remote_write_authority import KEY_A, NOTE, SCHEMA_ID, put_of
|
||||
|
||||
|
||||
class ClientWriteCompletionTests(unittest.TestCase):
|
||||
def test_a_put_the_server_refused_is_reported_refused_after_its_ack_arrived(self) -> None:
|
||||
node = create_node(runtime_id="completion-server")
|
||||
node.database.register_document(NOTE)
|
||||
server = CultMeshLocalServer(node=node, port=0, write_authority=None)
|
||||
server.start()
|
||||
try:
|
||||
subscription = CultNetDatabaseSubscription(
|
||||
host="127.0.0.1",
|
||||
port=server.port,
|
||||
timeout_seconds=4.0,
|
||||
subscription_id="completion",
|
||||
schema_ids=[SCHEMA_ID],
|
||||
include_snapshot=False,
|
||||
create_transport=lambda: create_rudp_schema_transport(
|
||||
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
|
||||
),
|
||||
)
|
||||
with subscription as sub:
|
||||
sub.send(put_of(KEY_A, "peer"))
|
||||
sub._transport.flush_reliable(2.0) # the put's reliable ack has arrived
|
||||
with self.assertRaises(CultNetPeerError) as raised:
|
||||
sub.read_next()
|
||||
finally:
|
||||
server.stop()
|
||||
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
|
||||
self.assertIsNone(server.node.cache.get(NOTE, KEY_A))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -24,9 +24,7 @@ from cultcache_py import (
|
|||
define_document_type,
|
||||
)
|
||||
from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document
|
||||
from cultmesh_py.server import MAX_CONSECUTIVE_REFUSED_FRAMES
|
||||
from cultmesh_py.verify import verify
|
||||
from cultmesh_py.write_authority import CultMeshRemoteWriteDecision
|
||||
from cultnet_py import (
|
||||
compute_simulation_claim_hash,
|
||||
CultNetClientAuthorityScope,
|
||||
|
|
@ -70,7 +68,7 @@ from cultnet_py import (
|
|||
snapshot_request,
|
||||
)
|
||||
from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST
|
||||
from cultmesh_py import CultMeshGrantAllWrites, create_node
|
||||
from cultmesh_py import create_node
|
||||
from cultmesh_py import (
|
||||
CultMesh,
|
||||
CultMeshDatabase,
|
||||
|
|
@ -1633,7 +1631,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
)
|
||||
primary = CultMesh.create_node(runtime_id="forward-primary")
|
||||
primary.database.register_document(document)
|
||||
server = CultMesh.serve_node(primary, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(primary)
|
||||
try:
|
||||
shard = CultNetShardDescriptor(
|
||||
shard_id="forward",
|
||||
|
|
@ -3017,7 +3015,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
|
||||
target = CultMesh.create_node(runtime_id="subscription-alias-target")
|
||||
target.database.register_document(document)
|
||||
server = CultMesh.serve_node(source, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(source)
|
||||
try:
|
||||
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||
with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
|
||||
|
|
@ -3535,7 +3533,6 @@ class CultMeshTests(unittest.TestCase):
|
|||
sys.executable,
|
||||
"-m",
|
||||
"cultmesh_py.daemon",
|
||||
"--allow-peer-writes",
|
||||
"--runtime-id",
|
||||
"live-daemon-peer",
|
||||
"--port",
|
||||
|
|
@ -3726,7 +3723,6 @@ class CultMeshTests(unittest.TestCase):
|
|||
sys.executable,
|
||||
"-m",
|
||||
"cultmesh_py.daemon",
|
||||
"--allow-peer-writes",
|
||||
"--runtime-id",
|
||||
"forwarder-daemon-peer",
|
||||
"--port",
|
||||
|
|
@ -4002,7 +3998,6 @@ class CultMeshTests(unittest.TestCase):
|
|||
server = CultMesh.serve_node(
|
||||
node,
|
||||
verse_catalog=verses,
|
||||
write_authority=CultMeshGrantAllWrites(),
|
||||
peer_catalog=peers,
|
||||
observation_hub=observation_hub,
|
||||
display_name="Mesh Server",
|
||||
|
|
@ -4279,7 +4274,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
|
||||
node.database.register_document(document)
|
||||
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(node)
|
||||
try:
|
||||
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||
with raw_client.subscribe_database(
|
||||
|
|
@ -4328,7 +4323,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
shard_epoch=1,
|
||||
)
|
||||
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(node)
|
||||
try:
|
||||
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||
with raw_client.subscribe_database(
|
||||
|
|
@ -4425,7 +4420,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
def served_alone_bytes(key: str, body_length: int) -> int:
|
||||
node = CultMesh.create_node(runtime_id="mesh-served-bound-probe")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), enable_rudp=False)
|
||||
server = CultMesh.serve_node(node, enable_rudp=False)
|
||||
try:
|
||||
response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot)
|
||||
finally:
|
||||
|
|
@ -4439,7 +4434,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
|
||||
node = CultMesh.create_node(runtime_id="mesh-served-bound")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=limit, enable_rudp=False)
|
||||
server = CultMesh.serve_node(node, max_snapshot_bytes=limit, enable_rudp=False)
|
||||
try:
|
||||
refused, _ = exchange(server, put("put-over", "note:over", 101))
|
||||
stored_after_refusal = node.database.get(document, "note:over")
|
||||
|
|
@ -4503,7 +4498,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
document = self._rudp_bound_note()
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-large")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(node)
|
||||
try:
|
||||
served, payload = self._rudp_exchange(
|
||||
server,
|
||||
|
|
@ -4529,7 +4524,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
def served_alone_bytes(body_length: int) -> int:
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(node)
|
||||
try:
|
||||
_, payload = self._rudp_exchange(
|
||||
server, self._rudp_note_put(document, "probe", key, body_length),
|
||||
|
|
@ -4559,7 +4554,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-bound")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
|
||||
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
|
||||
try:
|
||||
refusals = [
|
||||
self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0]
|
||||
|
|
@ -4797,7 +4792,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
body_for = self._rudp_body_for(document, "note:w")
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-wide")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=1)
|
||||
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=1)
|
||||
try:
|
||||
transport = create_rudp_schema_transport(
|
||||
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id,
|
||||
|
|
@ -4862,7 +4857,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
document = self._rudp_bound_note()
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-malformed")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(node)
|
||||
try:
|
||||
refusals = []
|
||||
for field_name, value in (
|
||||
|
|
@ -4885,146 +4880,11 @@ class CultMeshTests(unittest.TestCase):
|
|||
for message_id, refusal in refusals:
|
||||
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
||||
self.assertEqual(refusal["messageId"], message_id)
|
||||
self.assertEqual(refusal["code"], "message_invalid", message_id)
|
||||
self.assertEqual(refusal["details"]["field"], "document", message_id)
|
||||
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
|
||||
self.assertEqual(after["messageId"], "after")
|
||||
self.assertEqual(after["documents"], [])
|
||||
self.assertTrue(alive)
|
||||
|
||||
def _rudp_open(self, server: Any) -> Any:
|
||||
return create_rudp_schema_transport(
|
||||
host="127.0.0.1",
|
||||
port=server.port,
|
||||
connection_id=server.rudp_connection_id,
|
||||
timeout_seconds=4.0,
|
||||
max_fragment_bytes=1200,
|
||||
)
|
||||
|
||||
def _rudp_answer(self, transport: Any, payload: bytes) -> dict[str, Any]:
|
||||
transport.send("schema", payload)
|
||||
return msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
||||
|
||||
def _assert_rudp_session_ended(self, transport: Any, server: Any) -> None:
|
||||
transport.send("schema", msgpack.packb(snapshot_request(message_id="after-cap").to_wire(), use_bin_type=True))
|
||||
with self.assertRaises(Exception):
|
||||
transport.receive(timeout_seconds=1.0)
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
|
||||
def test_cultmesh_local_server_answers_unreadable_frames_and_ends_the_session_at_eight_in_a_row(self) -> None:
|
||||
# An undecodable frame (not a msgpack map) is answered message_invalid and the session kept;
|
||||
# eight in a row end it (the eighth is answered too); any decoded frame resets the run.
|
||||
self.assertEqual(MAX_CONSECUTIVE_REFUSED_FRAMES, 8)
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-unreadable")
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
garbage = b"\xc1"
|
||||
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
|
||||
transport = self._rudp_open(server)
|
||||
try:
|
||||
for _ in range(2):
|
||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
||||
reply = self._rudp_answer(transport, garbage)
|
||||
self.assertEqual(reply["code"], "message_invalid")
|
||||
self.assertEqual(reply["details"]["field"], "message")
|
||||
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
|
||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
||||
self._rudp_answer(transport, garbage)
|
||||
transport.send("schema", garbage)
|
||||
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
||||
self.assertEqual(eighth["code"], "message_invalid")
|
||||
self._assert_rudp_session_ended(transport, server)
|
||||
finally:
|
||||
transport.close()
|
||||
server.stop()
|
||||
|
||||
def test_cultmesh_local_server_resends_a_lost_capped_answer_before_it_ends_the_session(self) -> None:
|
||||
# The eighth refusal is answered reliably: the datagram carrying it is lost on the wire, and the
|
||||
# peer still gets the answer (by resend) before the goodbye. An unreliable final answer, or a
|
||||
# goodbye sent at once, would leave the peer without it.
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-lost-answer")
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
send = server._send_rudp_packet
|
||||
dropped: list[Any] = []
|
||||
armed = threading.Event()
|
||||
|
||||
def lossy(rudp_socket: Any, remote_addr: Any, packet: Any) -> Any:
|
||||
if armed.is_set() and not dropped and packet.packet_type == CultNetRudpPacketType.DATA and b"message_invalid" in (packet.payload or b""):
|
||||
dropped.append(packet)
|
||||
return None
|
||||
return send(rudp_socket, remote_addr, packet)
|
||||
|
||||
server._send_rudp_packet = lossy
|
||||
transport = self._rudp_open(server)
|
||||
try:
|
||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
||||
self._rudp_answer(transport, b"\xc1")
|
||||
armed.set()
|
||||
reply = self._rudp_answer(transport, b"\xc1")
|
||||
self.assertEqual(len(dropped), 1, "fixture: the first transmission of the last answer was lost")
|
||||
self.assertEqual(reply["code"], "message_invalid")
|
||||
self._assert_rudp_session_ended(transport, server)
|
||||
finally:
|
||||
transport.close()
|
||||
server.stop()
|
||||
|
||||
def test_cultmesh_local_server_serves_nothing_after_the_capped_frame(self) -> None:
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-after-cap")
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
transport = self._rudp_open(server)
|
||||
try:
|
||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
||||
self._rudp_answer(transport, b"\xc1")
|
||||
transport.send("schema", b"\xc1")
|
||||
transport.send("schema", msgpack.packb(snapshot_request(message_id="behind-the-cap").to_wire(), use_bin_type=True))
|
||||
first = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
||||
self.assertEqual(first["code"], "message_invalid")
|
||||
with self.assertRaises(Exception):
|
||||
transport.receive(timeout_seconds=1.0)
|
||||
finally:
|
||||
transport.close()
|
||||
server.stop()
|
||||
|
||||
def test_cultmesh_local_server_ends_the_session_at_eight_refused_deletes_in_a_row(self) -> None:
|
||||
class GrantOnlyKeyB:
|
||||
def decide(self, request: Any) -> Any:
|
||||
return CultMeshRemoteWriteDecision.grant() if request.record_key == "note:b" else CultMeshRemoteWriteDecision.refuse("no")
|
||||
|
||||
document = self._rudp_bound_note()
|
||||
node = CultMesh.create_node(runtime_id="mesh-rudp-deletes")
|
||||
node.database.register_document(document)
|
||||
server = CultMesh.serve_node(node, write_authority=GrantOnlyKeyB())
|
||||
|
||||
def delete(key: str) -> bytes:
|
||||
return msgpack.packb({
|
||||
"schemaVersion": "cultnet.document_delete.v0",
|
||||
"messageId": f"del-{key}",
|
||||
"schemaId": document.schema_id,
|
||||
"recordKey": key,
|
||||
}, use_bin_type=True)
|
||||
|
||||
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
|
||||
transport = self._rudp_open(server)
|
||||
try:
|
||||
def refused_run(count: int) -> None:
|
||||
for _ in range(count):
|
||||
reply = self._rudp_answer(transport, delete("note:a"))
|
||||
self.assertEqual(reply["code"], "write_not_authorized")
|
||||
self.assertEqual(reply["details"]["value"], "note:a")
|
||||
|
||||
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
|
||||
# A granted delete resets the run (nothing is stored under it, so the store answers that).
|
||||
self.assertEqual(self._rudp_answer(transport, delete("note:b"))["code"], "unregistered_document_delete")
|
||||
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
|
||||
# So does any decoded non-delete frame.
|
||||
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
|
||||
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
|
||||
transport.send("schema", delete("note:a"))
|
||||
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
||||
self.assertEqual(eighth["code"], "write_not_authorized")
|
||||
self._assert_rudp_session_ended(transport, server)
|
||||
finally:
|
||||
transport.close()
|
||||
server.stop()
|
||||
|
||||
def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None:
|
||||
# Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a
|
||||
# payload its schema cannot decode, a global document under another key. Each is refused
|
||||
|
|
@ -5057,7 +4917,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
node = CultMesh.create_node(runtime_id="mesh-unstorable-put")
|
||||
node.database.register_document(document)
|
||||
node.database.register_document(global_document)
|
||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
||||
server = CultMesh.serve_node(node)
|
||||
try:
|
||||
rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()]
|
||||
rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire())
|
||||
|
|
@ -5079,8 +4939,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
for message_id, refusal in rudp_refusals + tcp_refusals:
|
||||
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
||||
self.assertEqual(refusal["messageId"], message_id)
|
||||
self.assertEqual(refusal["code"], "message_invalid", message_id)
|
||||
self.assertEqual(refusal["details"]["field"], "document", message_id)
|
||||
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
|
||||
self.assertNotIn("note:ok", refusal["error"], message_id)
|
||||
self.assertEqual(len(tcp_refusals), 5)
|
||||
self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0")
|
||||
|
|
@ -5189,18 +5048,17 @@ class CultMeshTests(unittest.TestCase):
|
|||
finally:
|
||||
server.stop()
|
||||
|
||||
self.assertEqual(unsupported["messageId"], "bad-schema")
|
||||
self.assertEqual(unsupported["code"], "unsupported_schema_version")
|
||||
self.assertEqual(unsupported["details"]["field"], "schemaVersion")
|
||||
self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0")
|
||||
self.assertIn("unsupported_schema_version", unsupported["error"])
|
||||
self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0")
|
||||
self.assertIn("Unsupported CultNet message schema", unsupported["error"])
|
||||
self.assertEqual(malformed_put["messageId"], "bad-put")
|
||||
self.assertEqual(malformed_put["code"], "message_invalid")
|
||||
self.assertEqual(malformed_put["details"]["field"], "document")
|
||||
self.assertEqual(malformed_put["code"], "malformed_document_put")
|
||||
self.assertEqual(malformed_delete["messageId"], "bad-delete")
|
||||
self.assertEqual(malformed_delete["code"], "message_invalid")
|
||||
self.assertEqual(malformed_delete["details"]["field"], "recordKey")
|
||||
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
|
||||
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection")
|
||||
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
|
||||
self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1")
|
||||
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1")
|
||||
|
||||
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
|
||||
peer = CultMeshPeerCard(
|
||||
|
|
|
|||
|
|
@ -1,338 +0,0 @@
|
|||
"""A peer's put or delete is applied only when the server's write authority grants it.
|
||||
|
||||
Ports the behaviours of C# RemoteWriteAuthorityTests. The C# forwarding behaviour (a refused write is never
|
||||
forwarded) has no counterpart: this server does not forward non-primary writes. The handlers are reached with
|
||||
the peer the connection loop would pass, and one test drives the same path over a real TCP connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import msgpack
|
||||
from cultcache_py import define_database_entry_type
|
||||
from cultmesh_py import (
|
||||
CultMesh,
|
||||
CultMeshGrantAllWrites,
|
||||
CultMeshRemoteWriteDecision,
|
||||
CultMeshRemoteWriteOperation,
|
||||
CultMeshRemoteWriteRequest,
|
||||
CultMeshServerPeer,
|
||||
create_node,
|
||||
)
|
||||
from cultmesh_py.server import CultMeshLocalServer
|
||||
from cultnet_py import CultNetPeerError, create_rudp_schema_transport, document_delete, document_put_raw, error_message
|
||||
|
||||
SCHEMA_ID = "sha256:authority-note"
|
||||
ALIAS = "runtime.generated.authority-note.ui.42"
|
||||
SHARD_ID = "authority-shard"
|
||||
KEY_A = "tests:authority:a"
|
||||
KEY_B = "tests:authority:b"
|
||||
PEER = CultMeshServerPeer("127.0.0.1:50000", "tcp")
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[3]
|
||||
_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"]
|
||||
|
||||
NOTE = define_database_entry_type(
|
||||
"tests.authority_note",
|
||||
[("schema_version", 0), ("text", 1)],
|
||||
schema_id=SCHEMA_ID,
|
||||
schema_name="tests.authority_note",
|
||||
schema_version="tests.authority_note.v1",
|
||||
)
|
||||
|
||||
|
||||
class Recording:
|
||||
def __init__(self, decide: Any) -> None:
|
||||
self._decide = decide
|
||||
self.requests: list[CultMeshRemoteWriteRequest] = []
|
||||
|
||||
def decide(self, request: CultMeshRemoteWriteRequest) -> Any:
|
||||
self.requests.append(request)
|
||||
return self._decide(request)
|
||||
|
||||
|
||||
def put_of(key: str, text: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
|
||||
return document_put_raw(
|
||||
message_id="wire",
|
||||
key=key,
|
||||
schema_id=schema_id,
|
||||
stored_at="2026-11-07T00:00:00Z",
|
||||
payload=NOTE.encode_payload({"schema_version": "tests.authority_note.v1", "text": text}),
|
||||
shard_id=SHARD_ID,
|
||||
shard_epoch=1,
|
||||
).to_wire()
|
||||
|
||||
|
||||
def delete_of(key: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
|
||||
return document_delete(
|
||||
message_id="wire-delete", schema_id=schema_id, record_key=key, shard_id=SHARD_ID, shard_epoch=1
|
||||
).to_wire()
|
||||
|
||||
|
||||
class RemoteWriteAuthorityTests(unittest.TestCase):
|
||||
def build(self, authority: Any = None) -> CultMeshLocalServer:
|
||||
node = create_node(runtime_id="authority-server")
|
||||
node.database.register_document(NOTE)
|
||||
return CultMeshLocalServer(node=node, port=0, write_authority=authority)
|
||||
|
||||
def text_of(self, server: CultMeshLocalServer, key: str) -> str | None:
|
||||
value = server.node.cache.get(NOTE, key)
|
||||
return None if value is None else value["text"]
|
||||
|
||||
def send(self, server: CultMeshLocalServer, message: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
subscriptions: dict[str, Any] = {}
|
||||
return server._handle_connection_message(message, subscriptions, peer=PEER)
|
||||
|
||||
def assert_refused(self, replies: list[dict[str, Any]], key: str, *, error: str | None = None) -> None:
|
||||
self.assertEqual(len(replies), 1)
|
||||
reply = replies[0]
|
||||
self.assertEqual(reply["schemaVersion"], "cultnet.error.v0")
|
||||
self.assertEqual(reply["code"], "write_not_authorized")
|
||||
self.assertTrue(reply["error"].startswith("write_not_authorized"))
|
||||
self.assertEqual(reply["details"]["field"], "recordKey")
|
||||
self.assertEqual(reply["details"]["value"], key)
|
||||
if error is not None:
|
||||
self.assertEqual(reply["error"], error)
|
||||
|
||||
def seed(self, server: CultMeshLocalServer, key: str, text: str) -> None:
|
||||
# The host writes its own record through its own door, which asks no remote authority.
|
||||
self.assertEqual(server.node.database.apply_raw_put_message(put_of(key, text)).change_kind, "added")
|
||||
|
||||
def test_with_no_authority_a_peers_put_and_delete_are_refused_and_change_nothing(self) -> None:
|
||||
server = self.build(None)
|
||||
self.seed(server, KEY_A, "server")
|
||||
|
||||
with self.assertLogs("cultmesh_py.server", level="WARNING") as logs:
|
||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
|
||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
||||
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
|
||||
|
||||
self.assertEqual(self.text_of(server, KEY_A), "server")
|
||||
self.assertIsNone(self.text_of(server, KEY_B))
|
||||
self.assertEqual(sum("write not authorized" in line for line in logs.output), 3)
|
||||
|
||||
def test_a_refusal_publishes_no_change_to_a_subscriber(self) -> None:
|
||||
server = self.build(None)
|
||||
server.start()
|
||||
try:
|
||||
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
|
||||
with self.assertRaises(CultNetPeerError) as raised:
|
||||
sub.send(put_of(KEY_A, "peer"))
|
||||
sub.read_next()
|
||||
finally:
|
||||
server.stop()
|
||||
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
|
||||
self.assertIsNone(self.text_of(server, KEY_A))
|
||||
|
||||
def test_a_granted_put_and_delete_commit_and_the_authority_saw_this_peer(self) -> None:
|
||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
||||
server = self.build(authority)
|
||||
|
||||
put_replies = self.send(server, put_of(KEY_A, "peer"))
|
||||
self.assertEqual(self.text_of(server, KEY_A), "peer")
|
||||
self.assertEqual(put_replies, [])
|
||||
self.send(server, delete_of(KEY_A))
|
||||
self.assertIsNone(self.text_of(server, KEY_A))
|
||||
|
||||
self.assertEqual(len(authority.requests), 2)
|
||||
self.assertTrue(all(request.peer is PEER for request in authority.requests))
|
||||
|
||||
def test_the_request_carries_the_resolved_write_and_scoped_grants_refuse_their_neighbours(self) -> None:
|
||||
authority = Recording(
|
||||
lambda request: CultMeshRemoteWriteDecision.grant()
|
||||
if request.operation is CultMeshRemoteWriteOperation.PUT and request.record_key == KEY_A
|
||||
else CultMeshRemoteWriteDecision.refuse("scoped")
|
||||
)
|
||||
server = self.build(authority)
|
||||
self.seed(server, KEY_A, "server")
|
||||
|
||||
self.send(server, put_of(KEY_A, "aliased", schema_id=ALIAS))
|
||||
|
||||
request = authority.requests[0]
|
||||
self.assertEqual(request.schema_id, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent")
|
||||
self.assertIs(request.operation, CultMeshRemoteWriteOperation.PUT)
|
||||
self.assertEqual(request.record_key, KEY_A)
|
||||
self.assertEqual(request.shard_id, SHARD_ID)
|
||||
self.assertEqual(request.shard_epoch, 1)
|
||||
self.assertEqual(self.text_of(server, KEY_A), "aliased")
|
||||
|
||||
self.assert_refused(self.send(server, put_of(KEY_B, "other-key")), KEY_B)
|
||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
||||
self.assertIsNone(self.text_of(server, KEY_B), "a grant scoped to key a refuses key b")
|
||||
self.assertEqual(self.text_of(server, KEY_A), "aliased", "a grant scoped to put refuses delete")
|
||||
self.assertIs(authority.requests[-1].operation, CultMeshRemoteWriteOperation.DELETE)
|
||||
self.assertEqual(authority.requests[-1].schema_id, SCHEMA_ID)
|
||||
|
||||
def test_refusal_precedes_every_other_judgment_of_the_put(self) -> None:
|
||||
refusing = self.build(None)
|
||||
unowned = put_of("unrelated:authority", "x", schema_id="sha256:nobody-owns-this")
|
||||
unowned["document"]["payload"] = msgpack.packb(["nobody.owns.this.v1", "x"], use_bin_type=True)
|
||||
self.assert_refused(self.send(refusing, unowned), "unrelated:authority")
|
||||
blank_key = put_of("", "x")
|
||||
self.assert_refused(self.send(refusing, blank_key), "")
|
||||
|
||||
granting = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.grant()))
|
||||
reply = self.send(granting, unowned)
|
||||
self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal")
|
||||
|
||||
def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None:
|
||||
def explode(_: Any) -> Any:
|
||||
raise RuntimeError("authority exploded")
|
||||
|
||||
server = self.build(Recording(explode))
|
||||
self.seed(server, KEY_A, "server")
|
||||
|
||||
with self.assertLogs("cultmesh_py.server", level="ERROR") as logs:
|
||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
|
||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
||||
|
||||
self.assertEqual(self.text_of(server, KEY_A), "server", "fail closed")
|
||||
self.assertTrue(any("RuntimeError" in line for line in logs.output))
|
||||
|
||||
def test_a_refusal_with_no_reason_still_says_why(self) -> None:
|
||||
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse()))
|
||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A, error="write_not_authorized")
|
||||
|
||||
def test_the_refusal_names_the_key_and_the_code_in_the_pinned_bytes(self) -> None:
|
||||
vector = next(v for v in _VECTORS if v["name"] == "write_not_authorized")
|
||||
message = error_message(
|
||||
"write_not_authorized: not granted",
|
||||
code="write_not_authorized",
|
||||
details={"field": "recordKey", "value": "some:key"},
|
||||
)
|
||||
self.assertEqual(message.to_bytes(), bytes.fromhex(vector["messagePackHex"]))
|
||||
|
||||
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse("not granted")))
|
||||
reply = self.send(server, put_of("some:key", "peer"))[0]
|
||||
self.assertEqual(msgpack.packb(reply, use_bin_type=True), bytes.fromhex(vector["messagePackHex"]))
|
||||
|
||||
def test_the_peer_receives_the_typed_refusal_over_tcp_and_a_grant_sends_nothing_back(self) -> None:
|
||||
authority = Recording(
|
||||
lambda request: CultMeshRemoteWriteDecision.grant()
|
||||
if request.record_key == KEY_A
|
||||
else CultMeshRemoteWriteDecision.refuse("only a")
|
||||
)
|
||||
server = self.build(authority)
|
||||
server.start()
|
||||
try:
|
||||
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
|
||||
sub.send(put_of(KEY_A, "peer"))
|
||||
self.assertEqual(sub.read_next_change().record_key, KEY_A, "the granted write was answered by its change, not an error")
|
||||
sub.send(put_of(KEY_B, "peer"))
|
||||
with self.assertRaises(CultNetPeerError) as raised:
|
||||
sub.read_next()
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
reply = raised.exception.response
|
||||
self.assertEqual(reply["error"], "write_not_authorized: only a")
|
||||
self.assertEqual(reply["details"]["value"], KEY_B)
|
||||
self.assertTrue(authority.requests[0].peer.endpoint.startswith("127.0.0.1:"))
|
||||
self.assertEqual(authority.requests[0].peer.transport, "tcp")
|
||||
self.assertEqual(self.text_of(server, KEY_A), "peer")
|
||||
self.assertIsNone(self.text_of(server, KEY_B))
|
||||
|
||||
def test_a_delete_sent_under_a_schema_alias_asks_the_authority_with_the_canonical_id(self) -> None:
|
||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
||||
server = self.build(authority)
|
||||
self.seed(server, KEY_A, "server")
|
||||
|
||||
self.send(server, delete_of(KEY_A, schema_id="tests.authority_note.v1"))
|
||||
|
||||
request = authority.requests[0]
|
||||
self.assertIs(request.operation, CultMeshRemoteWriteOperation.DELETE)
|
||||
self.assertEqual(request.schema_id, SCHEMA_ID)
|
||||
self.assertIsNone(self.text_of(server, KEY_A), "the aliased delete was granted and applied")
|
||||
|
||||
def test_a_missing_decision_refuses_put_and_delete(self) -> None:
|
||||
server = self.build(Recording(lambda _: None))
|
||||
self.seed(server, KEY_A, "server")
|
||||
|
||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
|
||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
||||
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
|
||||
|
||||
self.assertEqual(self.text_of(server, KEY_A), "server")
|
||||
self.assertIsNone(self.text_of(server, KEY_B))
|
||||
|
||||
def test_a_peer_over_rudp_is_refused_with_its_rudp_endpoint_and_the_server_serves_on(self) -> None:
|
||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.refuse("rudp no"))
|
||||
server = self.build(authority)
|
||||
server.start()
|
||||
try:
|
||||
transport = create_rudp_schema_transport(
|
||||
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
|
||||
)
|
||||
try:
|
||||
transport.send("schema", msgpack.packb(put_of(KEY_A, "peer"), use_bin_type=True))
|
||||
reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
||||
transport.send("schema", msgpack.packb(delete_of(KEY_A), use_bin_type=True))
|
||||
delete_reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
||||
finally:
|
||||
transport.close()
|
||||
finally:
|
||||
server.stop()
|
||||
self.assertEqual(reply["error"], "write_not_authorized: rudp no")
|
||||
self.assertEqual(delete_reply["code"], "write_not_authorized")
|
||||
self.assertEqual([r.peer.transport for r in authority.requests], ["rudp", "rudp"])
|
||||
self.assertTrue(all(r.peer.endpoint.startswith("127.0.0.1:") for r in authority.requests))
|
||||
self.assertIsNone(self.text_of(server, KEY_A))
|
||||
|
||||
def test_a_put_with_an_absurd_payload_is_refused_by_the_gate_not_crashed_on(self) -> None:
|
||||
server = self.build(None)
|
||||
absurd = put_of(KEY_A, "x")
|
||||
absurd["document"]["payload"] = 2**70
|
||||
self.assert_refused(self.send(server, absurd), KEY_A)
|
||||
|
||||
def test_an_ungranted_peers_huge_payload_is_refused_without_allocating_it(self) -> None:
|
||||
import tracemalloc
|
||||
|
||||
server = self.build(None)
|
||||
huge = put_of(KEY_A, "x")
|
||||
huge["document"]["payload"] = 10**9 # bytes(10**9) would allocate a gigabyte
|
||||
tracemalloc.start()
|
||||
try:
|
||||
replies = self.send(server, huge)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
self.assert_refused(replies, KEY_A)
|
||||
self.assertLess(peak, 10_000_000)
|
||||
|
||||
def test_wrong_typed_shard_fields_are_refused_before_the_gate_and_write_nothing(self) -> None:
|
||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
||||
server = self.build(authority)
|
||||
self.seed(server, KEY_B, "server")
|
||||
shards_before = server.node.database.shard_ids()
|
||||
for field, value in (("shardId", {"not": "a string"}), ("shardId", 5), ("shardEpoch", "7"), ("shardEpoch", True), ("shardEpoch", 1.5)):
|
||||
for operation in ("put", "delete"):
|
||||
with self.subTest(field=field, value=value, operation=operation):
|
||||
message = put_of(KEY_A, "x") if operation == "put" else delete_of(KEY_B)
|
||||
message[field] = value
|
||||
replies = self.send(server, message)
|
||||
self.assertEqual([(reply["code"], reply["details"]["field"]) for reply in replies], [("message_invalid", field)])
|
||||
self.assertEqual(authority.requests, [], "the authority was never asked")
|
||||
self.assertIsNone(self.text_of(server, KEY_A), "nothing was written")
|
||||
self.assertEqual(self.text_of(server, KEY_B), "server", "nothing was deleted")
|
||||
self.assertEqual(server.node.database.shard_ids(), shards_before, "no shard or log entry was filed")
|
||||
|
||||
def test_well_typed_shard_fields_reach_the_authority_as_sent(self) -> None:
|
||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
||||
server = self.build(authority)
|
||||
self.send(server, put_of(KEY_A, "x"))
|
||||
self.assertEqual((authority.requests[0].shard_id, authority.requests[0].shard_epoch), (SHARD_ID, 1))
|
||||
|
||||
def test_grant_all_is_an_explicit_choice_that_grants_every_write(self) -> None:
|
||||
server = self.build(CultMeshGrantAllWrites())
|
||||
self.send(server, put_of(KEY_A, "peer"))
|
||||
self.assertEqual(self.text_of(server, KEY_A), "peer")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|||
|
||||
import msgpack
|
||||
from cultcache_py import define_database_entry_type
|
||||
from cultmesh_py import CultMeshGrantAllWrites, create_node
|
||||
from cultmesh_py import create_node
|
||||
from cultmesh_py.server import CultMeshLocalServer
|
||||
from cultnet_py import (
|
||||
CultNetRudpSendOptions,
|
||||
|
|
@ -49,7 +49,7 @@ class RudpServerGenerationTests(unittest.TestCase):
|
|||
node = create_node(runtime_id="rudp-generation")
|
||||
node.database.register_document(NOTE)
|
||||
# Replies stay whole datagrams here, so each server packet the pump reads is one message.
|
||||
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000, write_authority=CultMeshGrantAllWrites())
|
||||
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000)
|
||||
self.server.start()
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
"""A server asked for any port finds a TCP+UDP pair; one asked for a port gets that port or an honest failure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import socket
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from cultmesh_py import create_node
|
||||
from cultmesh_py.server import CultMeshLocalServer
|
||||
|
||||
|
||||
def server(**kwargs) -> CultMeshLocalServer:
|
||||
return CultMeshLocalServer(node=create_node(runtime_id="bind-test"), host="127.0.0.1", **kwargs)
|
||||
|
||||
|
||||
class ServerBindTests(unittest.TestCase):
|
||||
def test_port_zero_retries_with_a_fresh_port_when_another_process_holds_the_udp_number(self) -> None:
|
||||
real_bind = socket.socket.bind
|
||||
refused: list[int] = []
|
||||
|
||||
def bind(sock: socket.socket, address) -> None:
|
||||
# The first two UDP binds find the number taken, as a UDP listener of another process would.
|
||||
if sock.type == socket.SOCK_DGRAM and len(refused) < 2:
|
||||
refused.append(address[1])
|
||||
raise OSError(errno.EADDRINUSE, "Address already in use")
|
||||
real_bind(sock, address)
|
||||
|
||||
with patch.object(socket.socket, "bind", bind):
|
||||
started = server().start()
|
||||
try:
|
||||
self.assertEqual(len(refused), 2)
|
||||
self.assertNotIn(started.port, refused[:1], "the retry used a fresh TCP port")
|
||||
probe = socket.create_connection(("127.0.0.1", started.port), timeout=2.0)
|
||||
probe.close()
|
||||
finally:
|
||||
started.stop()
|
||||
|
||||
def test_an_explicit_port_whose_udp_number_is_taken_fails_and_leaves_nothing_running(self) -> None:
|
||||
holder = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
holder.bind(("127.0.0.1", 0))
|
||||
port = holder.getsockname()[1]
|
||||
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
free.bind(("127.0.0.1", port)) # the TCP number is free: only the UDP one is taken
|
||||
finally:
|
||||
free.close()
|
||||
threads_before = threading.active_count()
|
||||
try:
|
||||
failing = server(port=port)
|
||||
with self.assertRaises(OSError) as raised:
|
||||
failing.start()
|
||||
self.assertEqual(raised.exception.errno, errno.EADDRINUSE)
|
||||
self.assertIsNone(failing._socket)
|
||||
self.assertEqual(threading.active_count(), threads_before, "no accept thread was left running")
|
||||
# The TCP number was released, not left listening.
|
||||
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
free.bind(("127.0.0.1", port))
|
||||
finally:
|
||||
free.close()
|
||||
finally:
|
||||
holder.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
123
packages/cultmesh-rs/Cargo.lock
generated
123
packages/cultmesh-rs/Cargo.lock
generated
|
|
@ -67,12 +67,6 @@ version = "1.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
|
@ -167,18 +161,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
|
|
@ -232,14 +214,11 @@ name = "cultmesh-rs"
|
|||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"cultcache-rs",
|
||||
"cultnet-rs",
|
||||
"p256",
|
||||
"pretty_assertions",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
|
|
@ -319,25 +298,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
|
|
@ -362,25 +326,6 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -397,16 +342,6 @@ version = "2.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
|
|
@ -461,7 +396,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -508,17 +442,6 @@ dependencies = [
|
|||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
|
|
@ -632,18 +555,6 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -691,15 +602,6 @@ dependencies = [
|
|||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
|
|
@ -787,16 +689,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
|
|
@ -855,20 +747,6 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
|
@ -962,7 +840,6 @@ version = "2.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,9 @@ path = "src/lib.rs"
|
|||
anyhow = "1"
|
||||
cultcache-rs = { path = "../cultcache-rs" }
|
||||
cultnet-rs = { path = "../cultnet-rs" }
|
||||
base64 = "0.22"
|
||||
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
rmp-serde = "1"
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
//! The real Rust RUDP document server on an ephemeral loopback port, for the cross-runtime write-receipt interop
|
||||
//! harness (packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts). It is the production server with a
|
||||
//! recording sink and nothing else: stdout carries one JSON line per fact the harness asserts on, and the server runs
|
||||
//! until stdin closes.
|
||||
//!
|
||||
//! {"event":"listening","port":N}
|
||||
//! {"event":"applied","op":"put","messageId":"...","schemaId":"...","recordKey":"..."}
|
||||
//!
|
||||
//! Record keys that start with `refuse:` are refused by the sink (authorize and commit), which is how the harness
|
||||
//! asks for a refused write.
|
||||
use anyhow::Result;
|
||||
use cultmesh_rs::{
|
||||
CultMeshRudpDocumentServer, CultMeshRudpRawDocumentReceipt, CultMeshRudpRawDocumentSink,
|
||||
CultMeshRudpSnapshotQuery, CultMeshSystemClock,
|
||||
};
|
||||
use cultnet_rs::CultNetRawDocumentRecord;
|
||||
use serde_json::json;
|
||||
use std::io::Read;
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const REFUSED_PREFIX: &str = "refuse:";
|
||||
|
||||
struct RecordingSink;
|
||||
|
||||
impl CultMeshRudpRawDocumentSink for RecordingSink {
|
||||
fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> {
|
||||
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
|
||||
anyhow::bail!("not authorized");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> {
|
||||
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
|
||||
anyhow::bail!("not authorized");
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"event": "applied",
|
||||
"op": "put",
|
||||
"messageId": receipt.message_id,
|
||||
"schemaId": receipt.document.schema_id,
|
||||
"recordKey": receipt.document.record_key,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let source = |_: &CultMeshRudpSnapshotQuery| -> Result<Vec<CultNetRawDocumentRecord>> { Ok(Vec::new()) };
|
||||
let mut server = CultMeshRudpDocumentServer::new(
|
||||
UdpSocket::bind("127.0.0.1:0")?,
|
||||
RecordingSink,
|
||||
source,
|
||||
CultMeshSystemClock::default(),
|
||||
Default::default(),
|
||||
)?;
|
||||
println!("{}", json!({ "event": "listening", "port": server.local_addr()?.port() }));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let watcher = Arc::clone(&stop);
|
||||
thread::spawn(move || {
|
||||
let mut sink = Vec::new();
|
||||
let _ = std::io::stdin().read_to_end(&mut sink);
|
||||
watcher.store(true, Ordering::SeqCst);
|
||||
});
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
server.poll_once()?;
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,424 +0,0 @@
|
|||
//! CultMesh authority route verification.
|
||||
//!
|
||||
//! Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of
|
||||
//! the route constructors in `CultMeshVerse.cs`. Rule for rule, in the same
|
||||
//! order, the same input yields the same refusal text as
|
||||
//! `CultMeshAuthorityTrustPolicy.Verify`: unsigned, channel protection, root
|
||||
//! lookup, validity window, signature.
|
||||
//!
|
||||
//! Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
|
||||
|
||||
use base64::alphabet::STANDARD as STANDARD_ALPHABET;
|
||||
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
|
||||
use base64::Engine;
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
const ROUTE_DOMAIN: &str = "gamecult.cultmesh.route-certificate.v1";
|
||||
const SESSION_DOMAIN: &str = "gamecult.cultmesh.session-proof.v1";
|
||||
|
||||
// `Convert.FromBase64String`: standard alphabet, padded, tolerant of trailing bits.
|
||||
const DOTNET_BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||
&STANDARD_ALPHABET,
|
||||
GeneralPurposeConfig::new()
|
||||
.with_decode_padding_mode(DecodePaddingMode::RequireCanonical)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
fn base64_decode(value: &str) -> Option<Vec<u8>> {
|
||||
let stripped: String = value.chars().filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n')).collect();
|
||||
DOTNET_BASE64.decode(stripped).ok()
|
||||
}
|
||||
|
||||
/// .NET `char.IsWhiteSpace` is the Unicode White_Space property, as is `char::is_whitespace`.
|
||||
fn trim(value: &str) -> &str {
|
||||
value.trim_matches(char::is_whitespace)
|
||||
}
|
||||
|
||||
fn is_blank(value: &str) -> bool {
|
||||
trim(value).is_empty()
|
||||
}
|
||||
|
||||
fn require(value: &str, name: &str) -> Result<String, String> {
|
||||
if is_blank(value) {
|
||||
Err(format!("Value must be non-empty. (Parameter '{name}')"))
|
||||
} else {
|
||||
Ok(trim(value).to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// A route or proof the consumer's trust policy refuses. Mirrors
|
||||
/// `CultMeshSessionFailure` with reason `Authentication`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorityRouteRefusal {
|
||||
pub message: String,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
impl AuthorityRouteRefusal {
|
||||
pub const REASON: &'static str = "authentication";
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthorityRouteRefusal {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthorityRouteRefusal {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct P256PublicKey {
|
||||
pub key_id: String,
|
||||
pub x: String,
|
||||
pub y: String,
|
||||
}
|
||||
|
||||
impl P256PublicKey {
|
||||
pub fn new(key_id: &str, x: &str, y: &str) -> Result<Self, String> {
|
||||
let key = Self {
|
||||
key_id: require(key_id, "keyId")?,
|
||||
x: require(x, "x")?,
|
||||
y: require(y, "y")?,
|
||||
};
|
||||
for (name, value) in [("x", &key.x), ("y", &key.y)] {
|
||||
if base64_decode(value).is_none() {
|
||||
return Err(format!("Coordinate must be base64. (Parameter '{name}')"));
|
||||
}
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// IEEE P1363 (r || s) ECDSA-SHA256 verification; false on any malformation.
|
||||
pub fn verify(&self, payload: &[u8], signature_base64: &str) -> bool {
|
||||
let (Some(x), Some(y), Some(signature)) = (
|
||||
base64_decode(&self.x),
|
||||
base64_decode(&self.y),
|
||||
base64_decode(signature_base64),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if x.len() != 32 || y.len() != 32 || signature.len() != 64 {
|
||||
return false;
|
||||
}
|
||||
let mut sec1 = Vec::with_capacity(65);
|
||||
sec1.push(4);
|
||||
sec1.extend_from_slice(&x);
|
||||
sec1.extend_from_slice(&y);
|
||||
let (Ok(key), Ok(signature)) = (
|
||||
VerifyingKey::from_sec1_bytes(&sec1),
|
||||
Signature::from_slice(&signature),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
key.verify(payload, &signature).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RouteCertificate {
|
||||
pub provider_key: P256PublicKey,
|
||||
pub odin_key_id: String,
|
||||
pub issued_at_unix_milliseconds: i64,
|
||||
pub expires_at_unix_milliseconds: i64,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
impl RouteCertificate {
|
||||
pub fn new(
|
||||
provider_key: P256PublicKey,
|
||||
odin_key_id: &str,
|
||||
issued_at_unix_milliseconds: i64,
|
||||
expires_at_unix_milliseconds: i64,
|
||||
signature: &str,
|
||||
) -> Result<Self, String> {
|
||||
if is_blank(odin_key_id) {
|
||||
return Err("Odin key identity is required. (Parameter 'odinKeyId')".into());
|
||||
}
|
||||
if issued_at_unix_milliseconds < 0 {
|
||||
return Err("issuedAtUnixMilliseconds must not be negative.".into());
|
||||
}
|
||||
if expires_at_unix_milliseconds <= issued_at_unix_milliseconds {
|
||||
return Err("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.".into());
|
||||
}
|
||||
Ok(Self {
|
||||
provider_key,
|
||||
odin_key_id: trim(odin_key_id).to_owned(),
|
||||
issued_at_unix_milliseconds,
|
||||
expires_at_unix_milliseconds,
|
||||
signature: trim(signature).to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `CultMeshAuthorityRoute`: construction cleans every field as the C# does.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorityRoute {
|
||||
pub authority_runtime_id: String,
|
||||
pub endpoint: String,
|
||||
pub protocol_ids: Vec<String>,
|
||||
pub priority: i32,
|
||||
pub generation: String,
|
||||
pub certificate: Option<RouteCertificate>,
|
||||
}
|
||||
|
||||
impl AuthorityRoute {
|
||||
pub fn new(
|
||||
authority_runtime_id: &str,
|
||||
endpoint: &str,
|
||||
protocol_ids: &[&str],
|
||||
priority: i32,
|
||||
generation: Option<&str>,
|
||||
certificate: Option<RouteCertificate>,
|
||||
) -> Result<Self, String> {
|
||||
let authority_runtime_id = require(authority_runtime_id, "authorityRuntimeId")?;
|
||||
let endpoint = require(endpoint, "endpoint")?;
|
||||
if priority < 0 {
|
||||
return Err("priority must not be negative.".into());
|
||||
}
|
||||
let mut protocols: Vec<String> = protocol_ids
|
||||
.iter()
|
||||
.filter(|value| !is_blank(value))
|
||||
.map(|value| trim(value).to_owned())
|
||||
.collect();
|
||||
// StringComparer.Ordinal is UTF-16 code-unit order.
|
||||
protocols.sort_by_key(|value| value.encode_utf16().collect::<Vec<u16>>());
|
||||
protocols.dedup();
|
||||
let generation = match generation {
|
||||
Some(value) if !is_blank(value) => trim(value).to_owned(),
|
||||
_ => format!("{authority_runtime_id}@{endpoint}"),
|
||||
};
|
||||
Ok(Self {
|
||||
authority_runtime_id,
|
||||
endpoint,
|
||||
protocol_ids: protocols,
|
||||
priority,
|
||||
generation,
|
||||
certificate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of `CultMeshSessionOpenMessage` the session proof covers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SessionOpen {
|
||||
pub client_nonce: String,
|
||||
pub message_id: String,
|
||||
pub source_runtime_id: String,
|
||||
pub verse_id: String,
|
||||
pub authority_runtime_id: String,
|
||||
pub protocol_id: String,
|
||||
pub route_generation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SessionAccepted {
|
||||
pub client_nonce: String,
|
||||
pub provider_key_id: String,
|
||||
pub provider_signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TrustMode {
|
||||
AuthenticatedRemote,
|
||||
LocalDevelopment,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VerifiedRoute {
|
||||
pub route: AuthorityRoute,
|
||||
pub provider_key: Option<P256PublicKey>,
|
||||
pub is_local_development: bool,
|
||||
}
|
||||
|
||||
pub fn canonical_fields(values: &[&str]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for value in values {
|
||||
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(value.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn canonical_route(verse_id: &str, route: &AuthorityRoute) -> Result<Vec<u8>, String> {
|
||||
let certificate = route.certificate.as_ref().ok_or("Route certificate is required.")?;
|
||||
let protocols = route.protocol_ids.join("\u{1f}");
|
||||
let priority = route.priority.to_string();
|
||||
let issued = certificate.issued_at_unix_milliseconds.to_string();
|
||||
let expires = certificate.expires_at_unix_milliseconds.to_string();
|
||||
Ok(canonical_fields(&[
|
||||
ROUTE_DOMAIN,
|
||||
verse_id,
|
||||
&route.authority_runtime_id,
|
||||
&route.endpoint,
|
||||
&protocols,
|
||||
&priority,
|
||||
&route.generation,
|
||||
&certificate.provider_key.key_id,
|
||||
&certificate.provider_key.x,
|
||||
&certificate.provider_key.y,
|
||||
&certificate.odin_key_id,
|
||||
&issued,
|
||||
&expires,
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn canonical_session(request: &SessionOpen, endpoint: &str) -> Vec<u8> {
|
||||
canonical_fields(&[
|
||||
SESSION_DOMAIN,
|
||||
&request.client_nonce,
|
||||
&request.message_id,
|
||||
&request.source_runtime_id,
|
||||
&request.verse_id,
|
||||
&request.authority_runtime_id,
|
||||
&request.protocol_id,
|
||||
endpoint,
|
||||
&request.route_generation,
|
||||
])
|
||||
}
|
||||
|
||||
/// `(scheme, lowercase host)`, or `None` where `System.Uri` refuses the spelling.
|
||||
/// A lenient parse could call a refused endpoint loopback or protected, so the
|
||||
/// shape rule is the same strict one the TypeScript port applies.
|
||||
fn split_endpoint(value: &str) -> Option<(String, String)> {
|
||||
let raw = value.trim_matches(|c: char| c <= ' ');
|
||||
if raw.contains('\\') {
|
||||
return None;
|
||||
}
|
||||
let (scheme, rest) = raw.split_once("://")?;
|
||||
let mut scheme_chars = scheme.chars();
|
||||
if !scheme_chars.next()?.is_ascii_alphabetic()
|
||||
|| !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let after_user = &authority[authority.rfind('@').map_or(0, |i| i + 1)..];
|
||||
let host = if after_user.starts_with('[') {
|
||||
&after_user[..after_user.find(']').map_or(after_user.len(), |i| i + 1)]
|
||||
} else {
|
||||
after_user.split(':').next().unwrap_or("")
|
||||
};
|
||||
if host.is_empty() || host.chars().any(|c| !('\u{21}'..='\u{7e}').contains(&c)) || host.ends_with('.') {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
scheme.to_ascii_lowercase(),
|
||||
host.trim_matches(['[', ']']).to_ascii_lowercase(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn is_loopback_endpoint(endpoint: &str) -> bool {
|
||||
let Some((_, host)) = split_endpoint(endpoint) else {
|
||||
return false;
|
||||
};
|
||||
let octets: Vec<&str> = host.split('.').collect();
|
||||
matches!(host.as_str(), "localhost" | "loopback" | "::1" | "::ffff:127.0.0.1")
|
||||
|| (octets.len() == 4
|
||||
&& octets[0] == "127"
|
||||
&& octets[1..].iter().all(|o| (1..=3).contains(&o.len()) && o.bytes().all(|b| b.is_ascii_digit())))
|
||||
}
|
||||
|
||||
pub fn is_protected_endpoint(endpoint: &str) -> bool {
|
||||
split_endpoint(endpoint)
|
||||
.is_some_and(|(scheme, _)| scheme == "wss" || scheme == "https" || scheme.contains("quic"))
|
||||
}
|
||||
|
||||
/// Consumer-owned policy. Fails closed unless a route is valid under it.
|
||||
pub struct AuthorityTrustPolicy {
|
||||
mode: TrustMode,
|
||||
roots: BTreeMap<String, P256PublicKey>,
|
||||
}
|
||||
|
||||
impl AuthorityTrustPolicy {
|
||||
pub fn new(mode: TrustMode, odin_roots: Vec<P256PublicKey>) -> Result<Self, String> {
|
||||
let mut roots = BTreeMap::new();
|
||||
for root in odin_roots {
|
||||
let key_id = root.key_id.clone();
|
||||
if roots.insert(key_id.clone(), root).is_some() {
|
||||
return Err(format!(
|
||||
"Odin root key id '{key_id}' is listed more than once in the trust policy."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self { mode, roots })
|
||||
}
|
||||
|
||||
pub fn validate(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<(), AuthorityRouteRefusal> {
|
||||
self.verify(verse_id, route, now_unix_milliseconds).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn verify(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<VerifiedRoute, AuthorityRouteRefusal> {
|
||||
let refuse = |message: &str| AuthorityRouteRefusal {
|
||||
message: message.to_owned(),
|
||||
endpoint: route.endpoint.clone(),
|
||||
};
|
||||
let local = self.mode == TrustMode::LocalDevelopment && is_loopback_endpoint(&route.endpoint);
|
||||
let certificate = match &route.certificate {
|
||||
Some(certificate) if !is_blank(&certificate.signature) => certificate,
|
||||
_ if local => {
|
||||
return Ok(VerifiedRoute { route: route.clone(), provider_key: None, is_local_development: true });
|
||||
}
|
||||
_ => return Err(refuse("Remote CultMesh routes require an Odin-signed authority certificate.")),
|
||||
};
|
||||
if !is_protected_endpoint(&route.endpoint) && !local {
|
||||
return Err(refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection."));
|
||||
}
|
||||
let Some(root) = self.roots.get(&certificate.odin_key_id) else {
|
||||
return Err(refuse(&format!(
|
||||
"Odin key '{}' is not trusted by this consumer.",
|
||||
certificate.odin_key_id
|
||||
)));
|
||||
};
|
||||
if now_unix_milliseconds < certificate.issued_at_unix_milliseconds
|
||||
|| now_unix_milliseconds >= certificate.expires_at_unix_milliseconds
|
||||
{
|
||||
return Err(refuse("The Odin route certificate is not currently valid."));
|
||||
}
|
||||
let Some(signature) = base64_decode(&certificate.signature) else {
|
||||
return Err(refuse("The Odin route signature is not base64."));
|
||||
};
|
||||
if signature.len() != 64 {
|
||||
return Err(refuse("The Odin route signature is not IEEE P1363 P-256."));
|
||||
}
|
||||
let canonical = canonical_route(verse_id, route).map_err(|message| refuse(&message))?;
|
||||
if !root.verify(&canonical, &certificate.signature) {
|
||||
return Err(refuse("The Odin route certificate signature is invalid."));
|
||||
}
|
||||
Ok(VerifiedRoute {
|
||||
route: route.clone(),
|
||||
provider_key: Some(certificate.provider_key.clone()),
|
||||
is_local_development: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the Odin route certificate (refusing) and the provider nonce proof.
|
||||
pub fn verify_session_proof(
|
||||
request: &SessionOpen,
|
||||
response: &SessionAccepted,
|
||||
verse_id: &str,
|
||||
route: &AuthorityRoute,
|
||||
trust: &AuthorityTrustPolicy,
|
||||
now_unix_milliseconds: i64,
|
||||
) -> Result<bool, AuthorityRouteRefusal> {
|
||||
let verified = trust.verify(verse_id, route, now_unix_milliseconds)?;
|
||||
if verified.is_local_development {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(key) = verified.provider_key else {
|
||||
return Ok(false);
|
||||
};
|
||||
if response.provider_key_id != key.key_id
|
||||
|| response.client_nonce != request.client_nonce
|
||||
|| is_blank(&response.provider_signature)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(key.verify(&canonical_session(request, &route.endpoint), &response.provider_signature))
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ use std::time::Duration;
|
|||
use std::time::Instant;
|
||||
|
||||
mod rudp_document_server;
|
||||
pub mod authority;
|
||||
|
||||
pub use rudp_document_server::*;
|
||||
|
||||
|
|
@ -82,10 +81,7 @@ pub struct CultMeshRudpDocumentPublishOptions {
|
|||
pub target: SocketAddr,
|
||||
pub runtime_id: String,
|
||||
pub connect_timeout: Duration,
|
||||
/// How long the acknowledgement of every put may take.
|
||||
pub flush_timeout: Duration,
|
||||
/// How long after the acknowledgement a refusal may still arrive; a success frame ends the wait early.
|
||||
pub result_window: Duration,
|
||||
pub poll_interval: Duration,
|
||||
pub resend_delay_ms: u64,
|
||||
pub source_agent_id: Option<String>,
|
||||
|
|
@ -152,7 +148,6 @@ impl Default for CultMeshRudpDocumentPublishOptions {
|
|||
runtime_id: "cultmesh-rudp-document-publisher".to_string(),
|
||||
connect_timeout: Duration::from_secs(3),
|
||||
flush_timeout: Duration::from_millis(300),
|
||||
result_window: Duration::from_millis(500),
|
||||
poll_interval: Duration::from_millis(10),
|
||||
resend_delay_ms: 50,
|
||||
source_agent_id: None,
|
||||
|
|
@ -869,7 +864,6 @@ pub fn request_raw_snapshot_from_rudp_catalog(
|
|||
/// fixed refusals a CultMesh catalog sends; any other text could carry
|
||||
/// anything, so it is left out.
|
||||
fn recognised_refusal(text: &str) -> String {
|
||||
let text = text.strip_prefix("write_not_authorized: ").unwrap_or(text);
|
||||
if REFUSAL_TEXTS.contains(&text) {
|
||||
format!(": {text}")
|
||||
} else {
|
||||
|
|
@ -886,30 +880,77 @@ fn unix_millis() -> u128 {
|
|||
|
||||
/// Publish one raw CultNet schema message to a RUDP catalog.
|
||||
///
|
||||
/// A put completes on the catalog's result frame, never on the transport
|
||||
/// acknowledgement: an ack proves delivery, not admission. A refusal (a
|
||||
/// `cultnet.error.v0` frame) returns an error in either order against the ack,
|
||||
/// naming the refusal when it is one of the fixed texts a catalog sends. A
|
||||
/// catalog that admits a put sends no success frame, so after the ack the call
|
||||
/// waits `result_window` for a refusal and reports the put as admitted when
|
||||
/// none comes (interim until the catalog sends a success frame; a success
|
||||
/// frame ends the wait early). Each call owns a fresh connection epoch.
|
||||
/// Each call owns a fresh connection epoch and returns only after the catalog
|
||||
/// acknowledges reliable application admission. Against a Rust CultMesh
|
||||
/// catalog, which never acknowledges a message it refuses, a refusal is
|
||||
/// returned as an error, naming the refusal when it is one of the fixed texts
|
||||
/// such a catalog sends. A catalog that acknowledges a message on receipt and
|
||||
/// refuses it afterwards (the TypeScript document server does) is reported as
|
||||
/// having admitted it: the acknowledgement arrives first.
|
||||
pub fn publish_cultnet_message_to_rudp_catalog(
|
||||
message: &cultnet_rs::CultNetMessage,
|
||||
options: CultMeshRudpDocumentPublishOptions,
|
||||
) -> Result<()> {
|
||||
publish_cultnet_messages_to_rudp_catalog(std::slice::from_ref(message), options)
|
||||
if options.runtime_id.trim().is_empty() {
|
||||
anyhow::bail!("runtime_id must be non-empty");
|
||||
}
|
||||
let mut client = connect_rudp_client(
|
||||
options.target,
|
||||
&options.runtime_id,
|
||||
options.connect_timeout,
|
||||
options.poll_interval,
|
||||
options.resend_delay_ms,
|
||||
"cultmesh-rudp-document-publisher",
|
||||
)?;
|
||||
|
||||
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let receipt = client.send_reliable("schema", payload)?;
|
||||
let flush_deadline = Instant::now() + options.flush_timeout;
|
||||
while Instant::now() < flush_deadline {
|
||||
if let Some(frame) = client.receive_once()? {
|
||||
if frame.channel_id == "schema" {
|
||||
if let Ok(cultnet_rs::CultNetMessage::Error { error, .. }) =
|
||||
decode_cultnet_message_from_slice(
|
||||
&frame.payload,
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)
|
||||
{
|
||||
let _ = client.disconnect(b"document-refused".to_vec());
|
||||
anyhow::bail!(
|
||||
"CultMesh RUDP catalog {} refused the put{}",
|
||||
options.target,
|
||||
recognised_refusal(&error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
match client.reliable_send_status(&receipt) {
|
||||
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {
|
||||
let _ = client.disconnect(b"document-acknowledged".to_vec());
|
||||
return Ok(());
|
||||
}
|
||||
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
|
||||
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
|
||||
}
|
||||
cultnet_rs::CultNetRudpReliableSendStatus::Pending => {}
|
||||
}
|
||||
client.poll_resends()?;
|
||||
thread::sleep(options.poll_interval);
|
||||
}
|
||||
let _ = client.disconnect(b"document-unacknowledged".to_vec());
|
||||
anyhow::bail!(
|
||||
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
|
||||
options.target
|
||||
)
|
||||
}
|
||||
|
||||
/// Publish a batch of raw CultNet schema messages to a RUDP catalog on one
|
||||
/// connection.
|
||||
/// Publish a batch of raw CultNet schema messages to a RUDP catalog.
|
||||
///
|
||||
/// Completes under the same rule as [`publish_cultnet_message_to_rudp_catalog`]:
|
||||
/// every message acknowledged, then no refusal frame within `result_window`
|
||||
/// (or one success frame per message). A refusal of any message in the batch
|
||||
/// fails the whole call; the batch cannot hide it. The catalog's answers do not
|
||||
/// name the message they answer, so a failed batch does not say which message
|
||||
/// was refused or whether the others were admitted.
|
||||
/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for
|
||||
/// per-message application-level acknowledgement, this sends every message on
|
||||
/// one connection and waits only for a transport-level `flush_reliable`. It
|
||||
/// trades per-receipt confirmation for batch throughput; callers that need a
|
||||
/// stronger delivery guarantee should use the singular publish path instead.
|
||||
pub fn publish_cultnet_messages_to_rudp_catalog(
|
||||
messages: &[cultnet_rs::CultNetMessage],
|
||||
options: CultMeshRudpDocumentPublishOptions,
|
||||
|
|
@ -926,62 +967,11 @@ pub fn publish_cultnet_messages_to_rudp_catalog(
|
|||
"cultmesh-rudp-document-publisher",
|
||||
)?;
|
||||
|
||||
let mut receipts = Vec::with_capacity(messages.len());
|
||||
for message in messages {
|
||||
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
||||
receipts.push(client.send_reliable("schema", payload)?);
|
||||
}
|
||||
let ack_deadline = Instant::now() + options.flush_timeout;
|
||||
let mut acknowledged_at: Option<Instant> = None;
|
||||
let mut successes = 0;
|
||||
loop {
|
||||
while let Some(frame) = client.receive_once()? {
|
||||
if frame.channel_id != "schema" {
|
||||
continue;
|
||||
}
|
||||
match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) {
|
||||
Ok(cultnet_rs::CultNetMessage::Error { error, .. }) => {
|
||||
let _ = client.disconnect(b"document-refused".to_vec());
|
||||
anyhow::bail!(
|
||||
"CultMesh RUDP catalog {} refused the put{}",
|
||||
options.target,
|
||||
recognised_refusal(&error)
|
||||
);
|
||||
}
|
||||
Ok(_) => successes += 1,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
if acknowledged_at.is_none() {
|
||||
let mut all_acknowledged = true;
|
||||
for receipt in &receipts {
|
||||
match client.reliable_send_status(receipt) {
|
||||
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {}
|
||||
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
|
||||
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
|
||||
}
|
||||
cultnet_rs::CultNetRudpReliableSendStatus::Pending => all_acknowledged = false,
|
||||
}
|
||||
}
|
||||
if all_acknowledged {
|
||||
acknowledged_at = Some(Instant::now());
|
||||
} else if Instant::now() >= ack_deadline {
|
||||
let _ = client.disconnect(b"document-unacknowledged".to_vec());
|
||||
anyhow::bail!(
|
||||
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
|
||||
options.target
|
||||
)
|
||||
}
|
||||
}
|
||||
if let Some(acknowledged_at) = acknowledged_at {
|
||||
if successes >= messages.len() || acknowledged_at.elapsed() >= options.result_window {
|
||||
let _ = client.disconnect(b"document-completed".to_vec());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
client.poll_resends()?;
|
||||
thread::sleep(options.poll_interval);
|
||||
client.send("schema", payload)?;
|
||||
}
|
||||
client.flush_reliable(options.flush_timeout)
|
||||
}
|
||||
|
||||
fn connect_rudp_client(
|
||||
|
|
@ -1459,141 +1449,6 @@ mod tests {
|
|||
Ok((target, stop, catalog))
|
||||
}
|
||||
|
||||
/// A catalog that acknowledges every reliable message it receives and refuses it with a typed
|
||||
/// write_not_authorized, the acknowledgement either before the refusal or after it (the second packet
|
||||
/// follows the first by 100 ms). The refusal carries no acknowledgement of its own.
|
||||
/// What the stub catalog sends for a put, and in which order against the transport ack.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum CatalogReply {
|
||||
RefusalThenAck,
|
||||
AckThenRefusal,
|
||||
AckThenSuccess,
|
||||
}
|
||||
|
||||
fn acknowledging_refusing_catalog(
|
||||
reply: CatalogReply,
|
||||
) -> Result<(
|
||||
SocketAddr,
|
||||
std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
thread::JoinHandle<Result<()>>,
|
||||
)> {
|
||||
use cultnet_rs::{
|
||||
CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession,
|
||||
CultNetRudpSessionOptions, decode_rudp_packet, encode_rudp_packet,
|
||||
};
|
||||
let socket = UdpSocket::bind("127.0.0.1:0")?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(5)))?;
|
||||
let target = socket.local_addr()?;
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let catalog_stop = stop.clone();
|
||||
let catalog = thread::spawn(move || -> Result<()> {
|
||||
let mut session: Option<CultNetRudpSession> = None;
|
||||
let mut wire = vec![0_u8; 65_535];
|
||||
while !catalog_stop.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
let Ok((bytes, peer)) = socket.recv_from(&mut wire) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(packet) = decode_rudp_packet(&wire[..bytes]) else {
|
||||
continue;
|
||||
};
|
||||
if packet.packet_type == CultNetRudpPacketType::Connect {
|
||||
let mut accepted = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: packet.connection_id,
|
||||
initial_sequence: None,
|
||||
resend_delay_ms: 10_000,
|
||||
max_pending_reliable_packets: Some(64),
|
||||
});
|
||||
let accept = accepted.accept_connect(&packet, 0, Vec::new())?;
|
||||
socket.send_to(&encode_rudp_packet(&accept)?, peer)?;
|
||||
session = Some(accepted);
|
||||
continue;
|
||||
}
|
||||
let Some(session) = session.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(received) = session.receive(&packet, 1) else {
|
||||
continue;
|
||||
};
|
||||
if !packet.reliable || received.delivered.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ack = session.create_ack_for_received(packet.sequence);
|
||||
let refusal = encode_cultnet_message_to_vec(
|
||||
&if reply == CatalogReply::AckThenSuccess {
|
||||
cultnet_rs::CultNetMessage::SnapshotResponseRaw {
|
||||
message_id: "completion".into(),
|
||||
documents: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
cultnet_rs::CultNetMessage::Error {
|
||||
error: "write_not_authorized: the catalog refused the document".into(),
|
||||
code: Some(cultnet_rs::CultNetErrorCode::WriteNotAuthorized),
|
||||
details: None,
|
||||
}
|
||||
},
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)?;
|
||||
let options = CultNetRudpSendOptions {
|
||||
reliable: false,
|
||||
ordered: false,
|
||||
sequenced: false,
|
||||
now_ms: 2,
|
||||
reliable_expire_after_ms: None,
|
||||
};
|
||||
let mut refusals = session.send_many("schema", refusal, options, Some(1200))?;
|
||||
for refusal in &mut refusals {
|
||||
refusal.ack = 0;
|
||||
refusal.ack_mask = 0;
|
||||
}
|
||||
let send_refusals = |socket: &UdpSocket| -> Result<()> {
|
||||
for refusal in &refusals {
|
||||
socket.send_to(&encode_rudp_packet(refusal)?, peer)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if reply == CatalogReply::RefusalThenAck {
|
||||
send_refusals(&socket)?;
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
|
||||
} else {
|
||||
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
send_refusals(&socket)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
Ok((target, stop, catalog))
|
||||
}
|
||||
|
||||
fn publish_options(target: SocketAddr) -> CultMeshRudpDocumentPublishOptions {
|
||||
CultMeshRudpDocumentPublishOptions {
|
||||
target,
|
||||
connect_timeout: Duration::from_secs(2),
|
||||
flush_timeout: Duration::from_secs(3),
|
||||
poll_interval: Duration::from_millis(2),
|
||||
resend_delay_ms: 5,
|
||||
..CultMeshRudpDocumentPublishOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn refused_note() -> cultnet_rs::CultNetMessage {
|
||||
cultnet_rs::CultNetMessage::DocumentPutRaw {
|
||||
message_id: "completion".into(),
|
||||
document: cultnet_rs::CultNetRawDocumentRecord {
|
||||
schema_id: "cultmesh.test.raw.v0".into(),
|
||||
record_key: "tests:completion:a".into(),
|
||||
stored_at: "2026-11-08T00:00:00Z".into(),
|
||||
payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![0xc0],
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A catalog's refusal text is quoted only when it is one of the fixed
|
||||
/// refusals a CultMesh catalog sends. Any other text, which could carry
|
||||
/// anything, stays out of the publisher's error.
|
||||
|
|
@ -2044,63 +1899,4 @@ mod tests {
|
|||
assert_eq!(lease.bytes(), &[1, 2, 3]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Heimdall's invariant: no client treats a reliable-transport ack as write admission. The catalog here
|
||||
// acknowledges the put and refuses it, the refusal arriving before or after the ack.
|
||||
#[test]
|
||||
fn a_publish_whose_refusal_arrives_before_the_ack_is_refused() -> Result<()> {
|
||||
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::RefusalThenAck)?;
|
||||
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
catalog.join().expect("catalog thread should not panic")?;
|
||||
let error = result.expect_err("a refused put is never reported as admitted");
|
||||
assert!(error.to_string().contains("refused the put"), "{error:#}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_publish_whose_ack_arrives_before_the_refusal_is_refused() -> Result<()> {
|
||||
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenRefusal)?;
|
||||
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
catalog.join().expect("catalog thread should not panic")?;
|
||||
let error = result.expect_err("a refused put is never reported as admitted");
|
||||
assert!(error.to_string().contains("refused the put"), "{error:#}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_publish_is_not_complete_on_the_ack_of_refused_puts() -> Result<()> {
|
||||
for reply in [CatalogReply::AckThenRefusal, CatalogReply::RefusalThenAck] {
|
||||
let (target, stop, catalog) = acknowledging_refusing_catalog(reply)?;
|
||||
let result = publish_cultnet_messages_to_rudp_catalog(&[refused_note()], publish_options(target));
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
catalog.join().expect("catalog thread should not panic")?;
|
||||
assert!(result.is_err(), "a refused put was reported as published");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_success_frame_completes_the_publish_without_waiting_out_the_result_window() -> Result<()> {
|
||||
for batch in [false, true] {
|
||||
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenSuccess)?;
|
||||
let options = CultMeshRudpDocumentPublishOptions {
|
||||
result_window: Duration::from_secs(30),
|
||||
..publish_options(target)
|
||||
};
|
||||
let started = Instant::now();
|
||||
let result = if batch {
|
||||
publish_cultnet_messages_to_rudp_catalog(&[refused_note()], options)
|
||||
} else {
|
||||
publish_cultnet_message_to_rudp_catalog(&refused_note(), options)
|
||||
};
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
catalog.join().expect("catalog thread should not panic")?;
|
||||
result?;
|
||||
assert!(started.elapsed() < Duration::from_secs(10), "batch={batch}: waited out the window");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,261 +0,0 @@
|
|||
//! Judges `contracts/cultmesh/authority-route-vectors*.json` in Rust.
|
||||
//!
|
||||
//! `authority-route-vectors.json` is written by the C# reference (its signatures
|
||||
//! are captured C# bytes); the `.ts-signed.json` file is signed by TypeScript
|
||||
//! over awkward inputs. Each negative case fails when the matching check is
|
||||
//! removed from `cultmesh_rs::authority`.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use cultmesh_rs::authority::*;
|
||||
use p256::ecdsa::Signature;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct Vector {
|
||||
now: i64,
|
||||
root: P256PublicKey,
|
||||
verse_id: String,
|
||||
route: AuthorityRoute,
|
||||
request: SessionOpen,
|
||||
accepted: SessionAccepted,
|
||||
trust: AuthorityTrustPolicy,
|
||||
}
|
||||
|
||||
fn s<'a>(value: &'a Value, key: &str) -> &'a str {
|
||||
value[key].as_str().unwrap_or_else(|| panic!("missing string {key}"))
|
||||
}
|
||||
|
||||
fn key(value: &Value) -> P256PublicKey {
|
||||
P256PublicKey::new(s(value, "keyId"), s(value, "x"), s(value, "y")).unwrap()
|
||||
}
|
||||
|
||||
fn load(name: &str) -> Vector {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../contracts/cultmesh")
|
||||
.join(name);
|
||||
let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||
let r = &doc["route"];
|
||||
let c = &r["certificate"];
|
||||
let certificate = RouteCertificate::new(
|
||||
key(&c["providerKey"]),
|
||||
s(c, "odinKeyId"),
|
||||
c["issuedAtUnixMilliseconds"].as_i64().unwrap(),
|
||||
c["expiresAtUnixMilliseconds"].as_i64().unwrap(),
|
||||
s(c, "signature"),
|
||||
)
|
||||
.unwrap();
|
||||
let protocols: Vec<&str> = r["protocolIds"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
let route = AuthorityRoute::new(
|
||||
s(r, "authorityRuntimeId"),
|
||||
s(r, "endpoint"),
|
||||
&protocols,
|
||||
r["priority"].as_i64().unwrap() as i32,
|
||||
Some(s(r, "generation")),
|
||||
Some(certificate),
|
||||
)
|
||||
.unwrap();
|
||||
let p = &doc["sessionProof"];
|
||||
let q = &p["request"];
|
||||
let root = key(&doc["odinRoot"]);
|
||||
Vector {
|
||||
now: doc["nowUnixMilliseconds"].as_i64().unwrap(),
|
||||
verse_id: s(r, "verseId").to_owned(),
|
||||
route,
|
||||
request: SessionOpen {
|
||||
client_nonce: s(q, "clientNonce").into(),
|
||||
message_id: s(q, "messageId").into(),
|
||||
source_runtime_id: s(q, "sourceRuntimeId").into(),
|
||||
verse_id: s(q, "verseId").into(),
|
||||
authority_runtime_id: s(q, "authorityRuntimeId").into(),
|
||||
protocol_id: s(q, "protocolId").into(),
|
||||
route_generation: s(q, "routeGeneration").into(),
|
||||
},
|
||||
accepted: SessionAccepted {
|
||||
client_nonce: s(q, "clientNonce").into(),
|
||||
provider_key_id: s(p, "providerKeyId").into(),
|
||||
provider_signature: s(p, "providerSignature").into(),
|
||||
},
|
||||
trust: AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![root.clone()]).unwrap(),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
impl Vector {
|
||||
fn certificate(&self) -> &RouteCertificate {
|
||||
self.route.certificate.as_ref().unwrap()
|
||||
}
|
||||
fn with_certificate(&self, edit: impl FnOnce(&mut RouteCertificate)) -> AuthorityRoute {
|
||||
let mut route = self.route.clone();
|
||||
edit(route.certificate.as_mut().unwrap());
|
||||
route
|
||||
}
|
||||
fn refusal(&self, route: &AuthorityRoute, now: i64) -> String {
|
||||
let error = self.trust.validate(&self.verse_id, route, now).expect_err("route must be refused");
|
||||
assert_eq!(AuthorityRouteRefusal::REASON, "authentication");
|
||||
assert_eq!(route.endpoint, error.endpoint);
|
||||
error.message
|
||||
}
|
||||
fn proof(&self, request: &SessionOpen, accepted: &SessionAccepted) -> bool {
|
||||
verify_session_proof(request, accepted, &self.verse_id, &self.route, &self.trust, self.now).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
const INVALID: &str = "The Odin route certificate signature is invalid.";
|
||||
|
||||
#[test]
|
||||
fn cs_written_vector_verifies_route_and_session_proof() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
|
||||
assert!(v.proof(&v.request, &v.accepted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_signed_vector_verifies_and_tamper_is_refused() {
|
||||
let v = load("authority-route-vectors.ts-signed.json");
|
||||
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
|
||||
assert!(v.proof(&v.request, &v.accepted));
|
||||
let mut tampered = v.route.clone();
|
||||
tampered.priority += 1;
|
||||
assert_eq!(INVALID, v.refusal(&tampered, v.now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_route_is_length_prefixed_and_sorted() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let bytes = canonical_route(&v.verse_id, &v.route).unwrap();
|
||||
assert!(bytes.starts_with(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"));
|
||||
let needle = b"cultmesh.content.v1\x1fcultmesh.documents.v1";
|
||||
assert!(bytes.windows(needle.len()).any(|w| w == needle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_payload_fields_are_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let edits: Vec<Box<dyn Fn(&mut AuthorityRoute)>> = vec![
|
||||
Box::new(|r| r.priority = 8),
|
||||
Box::new(|r| r.endpoint = "wss://evil.example/mesh".into()),
|
||||
Box::new(|r| r.protocol_ids = vec!["cultmesh.documents.v1".into()]),
|
||||
Box::new(|r| r.generation = "other".into()),
|
||||
Box::new(|r| r.certificate.as_mut().unwrap().expires_at_unix_milliseconds += 1),
|
||||
];
|
||||
for edit in edits {
|
||||
let mut route = v.route.clone();
|
||||
edit(&mut route);
|
||||
assert_eq!(INVALID, v.refusal(&route, v.now));
|
||||
}
|
||||
let error = v.trust.validate("other-verse", &v.route, v.now).unwrap_err();
|
||||
assert_eq!(INVALID, error.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_root_key_is_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
// The provider's key is a real P-256 key that did not sign this route.
|
||||
let provider = &v.certificate().provider_key;
|
||||
let impostor = P256PublicKey::new(&v.root.key_id, &provider.x, &provider.y).unwrap();
|
||||
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![impostor]).unwrap();
|
||||
assert_eq!(INVALID, trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_odin_key_id_is_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let other = P256PublicKey::new("someone-else", &v.root.x, &v.root.y).unwrap();
|
||||
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![other]).unwrap();
|
||||
assert_eq!(
|
||||
"Odin key 'odin-vector' is not trusted by this consumer.",
|
||||
trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_and_not_yet_valid_are_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let window = "The Odin route certificate is not currently valid.";
|
||||
let (issued, expires) = (v.certificate().issued_at_unix_milliseconds, v.certificate().expires_at_unix_milliseconds);
|
||||
v.trust.validate(&v.verse_id, &v.route, expires - 1).unwrap();
|
||||
v.trust.validate(&v.verse_id, &v.route, issued).unwrap();
|
||||
assert_eq!(window, v.refusal(&v.route, expires));
|
||||
assert_eq!(window, v.refusal(&v.route, issued - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_and_unprotected_routes_are_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let unsigned_message = "Remote CultMesh routes require an Odin-signed authority certificate.";
|
||||
let mut unsigned = v.route.clone();
|
||||
unsigned.certificate = None;
|
||||
assert_eq!(unsigned_message, v.refusal(&unsigned, v.now));
|
||||
let blank = v.with_certificate(|c| c.signature = " ".into());
|
||||
assert_eq!(unsigned_message, v.refusal(&blank, v.now));
|
||||
let mut plain = v.route.clone();
|
||||
plain.endpoint = "ws://provider.example/mesh".into();
|
||||
assert_eq!(
|
||||
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
|
||||
v.refusal(&plain, v.now)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_development_admits_only_unsigned_loopback() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let dev = AuthorityTrustPolicy::new(TrustMode::LocalDevelopment, vec![]).unwrap();
|
||||
let route = |endpoint: &str| AuthorityRoute::new("a", endpoint, &[], 0, None, None).unwrap();
|
||||
let loopback = route("ws://127.0.0.1:9/mesh");
|
||||
dev.validate(&v.verse_id, &loopback, v.now).unwrap();
|
||||
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &loopback, &dev, v.now).unwrap());
|
||||
for endpoint in ["ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"] {
|
||||
assert!(dev.validate(&v.verse_id, &route(endpoint), v.now).is_err(), "{endpoint}");
|
||||
}
|
||||
let remote = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![]).unwrap();
|
||||
assert!(remote.validate(&v.verse_id, &loopback, v.now).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_signatures_get_their_own_refusals() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let not_base64 = v.with_certificate(|c| c.signature = "!!not base64!!".into());
|
||||
assert_eq!("The Odin route signature is not base64.", v.refusal(¬_base64, v.now));
|
||||
let short = v.with_certificate(|c| c.signature = STANDARD.encode([b'x'; 63]));
|
||||
assert_eq!("The Odin route signature is not IEEE P1363 P-256.", v.refusal(&short, v.now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_s_signature_verifies_as_it_does_in_dotnet() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let raw = STANDARD.decode(&v.certificate().signature).unwrap();
|
||||
let signature = Signature::from_slice(&raw).unwrap();
|
||||
let flipped = Signature::from_scalars(*signature.r().as_ref(), -*signature.s().as_ref()).unwrap();
|
||||
let high = v.with_certificate(|c| c.signature = STANDARD.encode(flipped.to_bytes()));
|
||||
v.trust.validate(&v.verse_id, &high, v.now).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_proof_rejects_replay_wrong_key_and_forgery() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let mut replayed = v.request.clone();
|
||||
replayed.client_nonce = "replayed".into();
|
||||
let mut accepted = v.accepted.clone();
|
||||
accepted.client_nonce = "replayed".into();
|
||||
assert!(!v.proof(&replayed, &accepted));
|
||||
let mut wrong_id = v.accepted.clone();
|
||||
wrong_id.provider_key_id = "other".into();
|
||||
assert!(!v.proof(&v.request, &wrong_id));
|
||||
let mut wrong_nonce = v.accepted.clone();
|
||||
wrong_nonce.client_nonce = "other".into();
|
||||
assert!(!v.proof(&v.request, &wrong_nonce));
|
||||
let mut empty = v.accepted.clone();
|
||||
empty.provider_signature = String::new();
|
||||
assert!(!v.proof(&v.request, &empty));
|
||||
let mut raw = STANDARD.decode(&v.accepted.provider_signature).unwrap();
|
||||
raw[0] ^= 1;
|
||||
let mut forged = v.accepted.clone();
|
||||
forged.provider_signature = STANDARD.encode(raw);
|
||||
assert!(!v.proof(&v.request, &forged));
|
||||
let mut moved = v.request.clone();
|
||||
moved.protocol_id = "cultmesh.content.v1".into();
|
||||
assert!(!v.proof(&moved, &v.accepted));
|
||||
let expires = v.certificate().expires_at_unix_milliseconds;
|
||||
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &v.route, &v.trust, expires).is_err());
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +1,18 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
|
||||
All notable changes to `cultmesh-ts` are recorded here, following
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). A legacy server's echo of the put's id is not admission: only `write_admitted` is. The flush that sends the put is bounded by the result window, and a send not acknowledged by then is `local_send_failed`. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed.
|
||||
|
||||
### Breaking
|
||||
|
||||
- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default.
|
||||
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
|
||||
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
|
||||
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
|
||||
- `createRudpDocumentServer` takes `writeAuthorityTimeoutMs` (default `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`, 5000; a value outside 1..2147483647 throws `RangeError`): a write authority whose `decide()` has not settled by then refuses the write like one that throws (`write_not_authorized`, and `onError` hears of it), so a hung authority no longer holds up the session's later frames.
|
||||
- `createRudpDocumentServer` refuses every peer put and delete unless it is given a `writeAuthority`. A refused write is answered with the typed `write_not_authorized` naming the record key (the bytes pinned in `error-vectors.json`), and `onDocumentPutRaw` is never called; pass `writeAuthority: new CultMeshGrantAllWrites()` to keep the old accept-everything behaviour. The authority is asked before the put is sized or decoded and before any handler runs, with the canonical schema id and the peer's address (the schema id is resolved to its canonical form first, which is the timing residual below); one that throws, rejects or answers anything but `granted: true` refuses. A frame the server cannot read is judged as the write it looks like (a put when it does not decode at all), so an ungranted peer gets `write_not_authorized` for it too; a granted peer gets `onError` and a typed `message_invalid` refusal (`details.field` is `message`). A put carrying a wrong-typed `shardId` or `shardEpoch` is judged with null shard fields and, once granted, refused with `message_invalid` naming that field. A refusal's record key and reason are clipped (64 and 96 UTF-8 bytes) so the refusal is at most 309 bytes; a `maxPayloadBytes` below that cannot carry it, and the peer then hears nothing (the failure goes to `onError`). Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. A peer `document_delete` now reaches the new `onDocumentDeleteRaw` once granted (without it, a text refusal) instead of the unsupported-request error.
|
||||
- Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`.
|
||||
|
||||
### Added
|
||||
|
||||
- `createRudpDocumentServer` ends a session after `CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES` (8) consecutive undecodable frames or consecutive deletes the write authority refuses; fewer are answered and the session is kept. Any decoded frame resets the undecodable run, any decoded non-refused-delete frame resets the delete run. The same cap holds in every runtime.
|
||||
- `CultMeshCatalogValue<TDocument, TSchema>`, exported.
|
||||
- `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# CultMesh TS
|
||||
|
||||
`@gamecult/cultmesh-ts` is the TypeScript projection of CultMesh contracts for local
|
||||
`cultmesh-ts` is the TypeScript projection of CultMesh contracts for local
|
||||
tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the
|
||||
native CultMesh stream implementation used by Mimir and Fensalir; this package
|
||||
mirrors the typed declaration and negotiation shapes without owning the hot
|
||||
|
|
@ -21,7 +21,7 @@ WASM route. Runtime code can ask for a whole XY rect, then adapt it to the
|
|||
current wire schema at the edge:
|
||||
|
||||
```ts
|
||||
import { CultMesh } from "@gamecult/cultmesh-ts";
|
||||
import { CultMesh } from "cultmesh-ts";
|
||||
|
||||
const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300);
|
||||
const request = CultMesh.viewportRequest(viewport, [3, 8]);
|
||||
|
|
@ -215,7 +215,7 @@ const stopFeed = viewportFeed.watch(request, "browser-starfire", render);
|
|||
```
|
||||
|
||||
```ts
|
||||
import { CultMesh } from "@gamecult/cultmesh-ts";
|
||||
import { CultMesh } from "cultmesh-ts";
|
||||
|
||||
const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>();
|
||||
const stopWatching = verses.watch((verse) => {
|
||||
|
|
@ -274,7 +274,7 @@ const noteShards = shards.list({
|
|||
});
|
||||
```
|
||||
|
||||
Schema and shard catalog factories delegate to `@gamecult/cultnet-ts`; the branded
|
||||
Schema and shard catalog factories delegate to `cultnet-ts`; the branded
|
||||
`CultMesh` entrypoint does not create a second owner for schema discovery or
|
||||
topology truth.
|
||||
|
||||
|
|
@ -290,7 +290,7 @@ command transactions; the injected transport owns CultNet wire details.
|
|||
import {
|
||||
CultMeshProviderRudpTransport,
|
||||
CultMeshProviderSession,
|
||||
} from "@gamecult/cultmesh-ts";
|
||||
} from "cultmesh-ts";
|
||||
|
||||
const odinTransport = new CultMeshProviderRudpTransport({
|
||||
endpoint: "rudp://127.0.0.1:17871",
|
||||
|
|
@ -345,11 +345,9 @@ evidence for the requested provider identity.
|
|||
|
||||
## RUDP Helpers
|
||||
|
||||
`createRudpDocumentServer` refuses every peer put and delete unless given a `writeAuthority` (`new CultMeshGrantAllWrites()` accepts all). The authority is asked before a write's payload is sized or decoded and before any handler runs, and a refusal is the typed `write_not_authorized`. Residual: ungranted peers can still distinguish registered from unregistered schemas by timing.
|
||||
|
||||
Node runtimes can build the shared CultNet reliable-UDP transport from the
|
||||
branded CultMesh entrypoint while the transport semantics stay owned by
|
||||
`@gamecult/cultnet-ts`:
|
||||
`cultnet-ts`:
|
||||
|
||||
```ts
|
||||
const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040);
|
||||
|
|
@ -474,7 +472,7 @@ The split is deliberate:
|
|||
The mirrored API shape is `CultMeshStreamCatalog`:
|
||||
|
||||
```ts
|
||||
import { CultMesh } from "@gamecult/cultmesh-ts";
|
||||
import { CultMesh } from "cultmesh-ts";
|
||||
|
||||
const streams = CultMesh.createStreamCatalog();
|
||||
const stopWatchingStreams = streams.watch((stream) => {
|
||||
|
|
@ -535,7 +533,7 @@ nonlocal fallback.
|
|||
|
||||
## Content Chunks over HTTP(S)
|
||||
|
||||
`@gamecult/cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD`
|
||||
`cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD`
|
||||
`<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the
|
||||
handler reads only the query. Every chunk is hashed against its name before a
|
||||
byte is sent, so a corrupt or swapped file answers 500 and never leaves the
|
||||
|
|
@ -552,7 +550,7 @@ host store may reuse its buffer). `onServerFault` reports every 500 (`hash-misma
|
|||
```ts
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:https";
|
||||
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "@gamecult/cultmesh-ts/content";
|
||||
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "cultmesh-ts/content";
|
||||
|
||||
const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), {
|
||||
onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@gamecult/cultmesh-ts",
|
||||
"name": "cultmesh-ts",
|
||||
"version": "0.2.0",
|
||||
"description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.",
|
||||
"main": "dist/index.js",
|
||||
|
|
@ -24,8 +24,7 @@
|
|||
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js",
|
||||
"test:interop": "npm --prefix ../.. run build --workspace @gamecult/cultcache-ts --workspace @gamecult/cultnet-ts && node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test --test-force-exit dist-test/test/interop/write-receipt-interop.test.js"
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"cultmesh",
|
||||
|
|
@ -49,8 +48,8 @@
|
|||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.15.0",
|
||||
"@gamecult/cultnet-ts": "^0.3.0",
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"cultnet-ts": "^0.3.0",
|
||||
"koffi": "3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { constants } from "node:fs";
|
|||
import { open } from "node:fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { join } from "node:path";
|
||||
import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "@gamecult/cultnet-ts/content";
|
||||
import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "cultnet-ts/content";
|
||||
|
||||
/** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */
|
||||
export interface ContentChunkRequest { method: string; url: string; }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createSocket, type Socket } from "node:dgram";
|
||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||
|
||||
import {
|
||||
CultCache,
|
||||
|
|
@ -20,13 +20,6 @@ import {
|
|||
CultNetRudpSocketTransportConnection,
|
||||
rudpClientBindHost,
|
||||
CultNetSchemaCatalog,
|
||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
||||
WriteTracker,
|
||||
helloAdvertisesWriteReceipts,
|
||||
resultWindowMs,
|
||||
cultNetMessageInvalidError,
|
||||
cultNetTextError,
|
||||
cultNetWriteNotAuthorizedError,
|
||||
CultNetShardCatalog,
|
||||
cultNetBuiltinSchemaRegistry,
|
||||
decodeRudpPacket,
|
||||
|
|
@ -38,7 +31,6 @@ import {
|
|||
sendRudpDatagram,
|
||||
type CultNetErrorMessage,
|
||||
type CultNetDocumentBinding,
|
||||
type CultNetDocumentDeleteMessage,
|
||||
type CultNetMessage,
|
||||
type CultNetOperationRequestMessage,
|
||||
type CultNetOperationResponseMessage,
|
||||
|
|
@ -48,9 +40,7 @@ import {
|
|||
type CultNetSchemaCatalogOptions,
|
||||
type CultNetSnapshotResponseRawMessage,
|
||||
type CultNetWireContract,
|
||||
type WriteSettlement,
|
||||
type WriteUnconfirmedReason,
|
||||
} from "@gamecult/cultnet-ts";
|
||||
} from "cultnet-ts";
|
||||
|
||||
import {
|
||||
CultMeshQuicRealtimeConnector,
|
||||
|
|
@ -4001,63 +3991,6 @@ export type CultMeshRudpDocumentPut = {
|
|||
| { readonly decoded: false; readonly payload: DocumentPayload }
|
||||
);
|
||||
|
||||
/** The peer a write arrived from: the datagram's source, nothing the peer claimed. */
|
||||
export interface CultMeshRemoteWritePeer {
|
||||
address: string;
|
||||
family: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export type CultMeshRemoteWriteOperation = "put" | "delete";
|
||||
|
||||
/**
|
||||
* One peer write as the authority sees it. `schemaId` is the canonical id the registry resolved for the write (the id
|
||||
* as sent when no binding takes it), never an alias the peer used when a binding resolves it. `shardId` and
|
||||
* `shardEpoch` are as a put carried them, `null` when it sent none (always `null` for a delete, which has none); a
|
||||
* put carrying either with the wrong type is refused before any authority is asked.
|
||||
*/
|
||||
export interface CultMeshRemoteWriteRequest {
|
||||
peer: CultMeshRemoteWritePeer;
|
||||
operation: CultMeshRemoteWriteOperation;
|
||||
schemaId: string;
|
||||
recordKey: string;
|
||||
shardId: string | null;
|
||||
shardEpoch: number | null;
|
||||
}
|
||||
|
||||
export interface CultMeshRemoteWriteDecision {
|
||||
granted: boolean;
|
||||
/** Told to the refused peer after `write_not_authorized:`; keep it free of anything secret. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one question a document server asks before it applies a peer's put or delete (C# `ICultNetRemoteWriteAuthority`).
|
||||
* A server with no authority refuses every peer write; one whose authority throws, rejects or answers anything but
|
||||
* `granted: true` refuses too.
|
||||
*/
|
||||
export interface CultMeshRemoteWriteAuthority {
|
||||
decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants every peer write. For a host that has already authenticated its peers, or a test rig: it is an explicit
|
||||
* choice because serving without an authority refuses writes.
|
||||
*/
|
||||
export class CultMeshGrantAllWrites implements CultMeshRemoteWriteAuthority {
|
||||
decide(): CultMeshRemoteWriteDecision {
|
||||
return { granted: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** A delete as `onDocumentDeleteRaw` receives it, after the authority granted it. */
|
||||
export interface CultMeshRudpDocumentDelete {
|
||||
messageId: string;
|
||||
schemaId: string;
|
||||
recordKey: string;
|
||||
remote: CultMeshRemoteWritePeer;
|
||||
}
|
||||
|
||||
export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> {
|
||||
binding: CultNetDocumentBinding<TDefinition>;
|
||||
recordKey: string;
|
||||
|
|
@ -4097,24 +4030,10 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
|
|||
documents: CultNetDocumentRegistry;
|
||||
getCache?: () => Promise<CultCache> | CultCache;
|
||||
onError?: (error: Error) => void;
|
||||
/**
|
||||
* Who may put and delete. Without it every peer put and delete is refused with a typed `write_not_authorized`
|
||||
* (and `onDocumentPutRaw` is never called); pass `new CultMeshGrantAllWrites()` to accept them all. The authority is
|
||||
* asked before the document is sized, resolved or decoded.
|
||||
*/
|
||||
writeAuthority?: CultMeshRemoteWriteAuthority;
|
||||
/**
|
||||
* How long the write authority has to answer before the write is refused (default
|
||||
* `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`). A `decide()` that never settles is a refusal like any
|
||||
* other, so it cannot hold up the frames that follow it on the session.
|
||||
*/
|
||||
writeAuthorityTimeoutMs?: number;
|
||||
onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) =>
|
||||
| CultMeshRudpDocumentReceipt
|
||||
| void
|
||||
| Promise<CultMeshRudpDocumentReceipt | void>;
|
||||
/** Applies a delete the authority granted. Without it a granted delete is answered with a text refusal. */
|
||||
onDocumentDeleteRaw?: (request: CultMeshRudpDocumentDelete) => void | Promise<void>;
|
||||
onOperationRequest?: (
|
||||
request: CultNetOperationRequestMessage,
|
||||
session: CultMeshRudpServerSession,
|
||||
|
|
@ -4124,19 +4043,6 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
|
|||
sessionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** A write authority that has not answered in this long has refused the write. */
|
||||
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* How many consecutive refused frames of one kind a `createRudpDocumentServer` session may send before it is
|
||||
* ended: undecodable frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any
|
||||
* decoded frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
|
||||
* frame that reaches this number is answered too, then the session ends. The same number is the cap in every runtime.
|
||||
*/
|
||||
export const CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES = 8;
|
||||
/** How long a session whose capped frame was answered waits for the peer to acknowledge the answers before it ends anyway. */
|
||||
const CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS = 2000;
|
||||
|
||||
/** Replies larger than one fragment are split into fragments of at most this many bytes. */
|
||||
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200;
|
||||
|
||||
|
|
@ -4189,64 +4095,7 @@ export interface CultMeshRudpDocumentServer {
|
|||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A write the peer refused: the typed `cultnet.error.v0` it answered with (`write_not_authorized`,
|
||||
* `message_invalid`, ...), whichever of the transport ack and the result frame came first.
|
||||
*/
|
||||
export class CultMeshRudpWriteRefusedError extends Error {
|
||||
readonly code: string | undefined;
|
||||
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>;
|
||||
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>) {
|
||||
super(response.error);
|
||||
this.name = "CultMeshRudpWriteRefusedError";
|
||||
this.code = response.code ?? undefined;
|
||||
this.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, the
|
||||
* session ended first, or the client's own send failed. Not success and not refusal; a caller that needs admission treats it as not admitted.
|
||||
*/
|
||||
export class CultMeshRudpWriteUnconfirmedError extends Error {
|
||||
readonly reason: WriteUnconfirmedReason;
|
||||
readonly messageId: string;
|
||||
constructor(messageId: string, reason: WriteUnconfirmedReason) {
|
||||
super(`Write ${messageId} was not confirmed: ${reason}.`);
|
||||
this.name = "CultMeshRudpWriteUnconfirmedError";
|
||||
this.reason = reason;
|
||||
this.messageId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
|
||||
/**
|
||||
* How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not
|
||||
* admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the
|
||||
* write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never
|
||||
* published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also
|
||||
* bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is
|
||||
* unconfirmed as `local_send_failed`.
|
||||
*/
|
||||
resultWindowMs?: number;
|
||||
/**
|
||||
* Declare the write-receipt capability: send a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1`
|
||||
* before the put. A server that lists both back is receipt-capable: the put then resolves only on its
|
||||
* `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` on its `error.v1`, and rejects with
|
||||
* `CultMeshRudpWriteUnconfirmedError` when nothing settles it. Unset, no hello is sent and the server answers
|
||||
* as it always did.
|
||||
*/
|
||||
writeReceipts?: boolean;
|
||||
/** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */
|
||||
helloWaitMs?: number;
|
||||
/**
|
||||
* Send the put and report only that it was sent: no hello, no wait for a result frame. Resolves `"sent"` once the
|
||||
* transport has acknowledged the put (`flushTimeoutMs`), rejects `CultMeshRudpWriteUnconfirmedError`
|
||||
* (`local_send_failed`) if it cannot be sent, and never resolves `"admitted"`: whether the server applied or
|
||||
* refused it is unknown. For callers whose server cannot answer a write yet. Opt-in per call; not combinable with
|
||||
* `writeReceipts`.
|
||||
*/
|
||||
sendOnly?: boolean;
|
||||
messageId?: string;
|
||||
sourceRuntimeId?: string;
|
||||
sourceAgentId?: string;
|
||||
|
|
@ -5610,11 +5459,6 @@ export class CultMesh {
|
|||
closed: boolean;
|
||||
/** A packet it owed could never be sent, so the session ended. */
|
||||
unsendable: boolean;
|
||||
/** Consecutive undecodable frames, and consecutive deletes the authority refused. */
|
||||
unreadableInARow: number;
|
||||
refusedDeletesInARow: number;
|
||||
/** Set once the peer's capped frame is answered: the session ends when the answers are acknowledged, or at this time. */
|
||||
endingDeadlineMs?: number;
|
||||
work: Promise<void>;
|
||||
};
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
|
|
@ -5622,11 +5466,6 @@ export class CultMesh {
|
|||
let sendFailures = 0;
|
||||
const resendPollMs = Math.max(10, options.resendPollMs ?? 25);
|
||||
const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000);
|
||||
const decideTimeoutMs = options.writeAuthorityTimeoutMs ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS;
|
||||
// A timer past 2^31-1 ms (or NaN) fires after 1 ms in Node, which would refuse every write that is not instant.
|
||||
if (!(decideTimeoutMs > 0 && decideTimeoutMs <= 0x7fffffff)) {
|
||||
throw new RangeError("CultMesh RUDP document server writeAuthorityTimeoutMs must be greater than zero and at most 2147483647.");
|
||||
}
|
||||
const wireContract = options.wireContract ?? "cultnet.schema.v0";
|
||||
const maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES;
|
||||
if (!(maxFragmentBytes > 0)) {
|
||||
|
|
@ -5669,8 +5508,6 @@ export class CultMesh {
|
|||
connectPayload,
|
||||
closed: false,
|
||||
unsendable: false,
|
||||
unreadableInARow: 0,
|
||||
refusedDeletesInARow: 0,
|
||||
session: new CultNetRudpSession({
|
||||
connectionId,
|
||||
initialSequence: options.initialSequence,
|
||||
|
|
@ -5735,36 +5572,15 @@ export class CultMesh {
|
|||
record: SessionRecord,
|
||||
payload: Uint8Array,
|
||||
): Promise<void> {
|
||||
// Nothing after the capped frame is served while its answer is delivered.
|
||||
if (record.closed || record.endingDeadlineMs !== undefined) return;
|
||||
let decoded: unknown;
|
||||
let message: CultNetMessage;
|
||||
try {
|
||||
decoded = decodeWireValue(payload);
|
||||
message = parseCultNetMessage(decoded, wireContract);
|
||||
} catch (error) {
|
||||
// A frame the server cannot read is judged as the write it looks like (a put when it does not even decode), so
|
||||
// an ungranted peer hears the same refusal for it as for any other write, not a parse error.
|
||||
const write = readableWrite(decoded);
|
||||
record.unreadableInARow += 1;
|
||||
if (decoded !== undefined && !write) {
|
||||
// It decodes but is no message the parser takes (an unknown schemaVersion, a non-map) and is no write the
|
||||
// authority could judge: answered and counted like any other unreadable frame.
|
||||
reportError(error);
|
||||
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the message."));
|
||||
} else if (!await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) {
|
||||
reportError(error);
|
||||
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the write."));
|
||||
}
|
||||
if (record.unreadableInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
|
||||
return;
|
||||
}
|
||||
record.unreadableInARow = 0;
|
||||
if (message.schemaVersion !== "cultnet.document_delete.v0") record.refusedDeletesInARow = 0;
|
||||
if (record.closed) return;
|
||||
const message = parseCultNetMessage(decodeWireValue(payload), wireContract);
|
||||
switch (message.schemaVersion) {
|
||||
case "cultnet.snapshot_request.v0": {
|
||||
if (!options.getCache) {
|
||||
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no cache for snapshot requests."));
|
||||
sendSchemaMessage(record, {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: "CultMesh RUDP document server has no cache for snapshot requests.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
let reply: CultNetMessage;
|
||||
|
|
@ -5793,32 +5609,11 @@ export class CultMesh {
|
|||
sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message));
|
||||
return;
|
||||
case "cultnet.document_put_raw.v0":
|
||||
{
|
||||
// The parser passes shard fields of any type through a put. The authority is asked first, with the shard
|
||||
// fields it can trust (null when wrong-typed), so an ungranted peer hears only the refusal; a granted peer
|
||||
// whose shard fields are wrong-typed is then refused as malformed.
|
||||
const { shardId, shardEpoch } = message as { shardId?: unknown; shardEpoch?: unknown };
|
||||
const malformedShard =
|
||||
(shardId !== undefined && typeof shardId !== "string") ||
|
||||
(shardEpoch !== undefined && !Number.isSafeInteger(shardEpoch));
|
||||
if (await writeRefused(
|
||||
record, "put", message.document.schemaId, message.document.recordKey,
|
||||
malformedShard ? null : (shardId as string | undefined) ?? null,
|
||||
malformedShard ? null : (shardEpoch as number | undefined) ?? null,
|
||||
)) return;
|
||||
if (malformedShard) {
|
||||
sendSchemaMessage(record, cultNetMessageInvalidError(
|
||||
typeof shardId !== "undefined" && typeof shardId !== "string" ? "shardId" : "shardEpoch",
|
||||
"Raw put messages must carry a string shardId and an integer shardEpoch.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (options.onDocumentPutRaw) {
|
||||
const unservable = unservablePut(message);
|
||||
if (unservable) {
|
||||
reportError(unservable);
|
||||
sendSchemaMessage(record, cultNetTextError(unservable.message));
|
||||
sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", error: unservable.message });
|
||||
return;
|
||||
}
|
||||
const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents));
|
||||
|
|
@ -5828,119 +5623,32 @@ export class CultMesh {
|
|||
} catch (error) {
|
||||
// The put is stored: the peer must hear that its receipt is not coming.
|
||||
reportError(error);
|
||||
sendSchemaMessage(record, cultNetTextError(`CultMesh RUDP document server stored a put but could not send its receipt.`));
|
||||
sendSchemaMessage(record, {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: `CultMesh RUDP document server stored a put but could not send its receipt.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
case "cultnet.document_delete.v0": {
|
||||
if (await writeRefused(record, "delete", message.schemaId, message.recordKey, null, null)) {
|
||||
record.refusedDeletesInARow += 1;
|
||||
if (record.refusedDeletesInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
|
||||
return;
|
||||
}
|
||||
record.refusedDeletesInARow = 0;
|
||||
if (!options.onDocumentDeleteRaw) {
|
||||
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no delete handler."));
|
||||
return;
|
||||
}
|
||||
await options.onDocumentDeleteRaw({
|
||||
messageId: message.messageId,
|
||||
schemaId: message.schemaId,
|
||||
recordKey: message.recordKey,
|
||||
remote: { ...record.remote },
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "cultnet.operation_request.v0":
|
||||
if (!options.onOperationRequest) {
|
||||
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no operation handler."));
|
||||
sendSchemaMessage(record, {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: "CultMesh RUDP document server has no operation handler.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record)));
|
||||
return;
|
||||
default:
|
||||
sendSchemaMessage(record, cultNetTextError(`Unsupported CultMesh RUDP document request ${message.schemaVersion}.`));
|
||||
sendSchemaMessage(record, {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: `Unsupported CultMesh RUDP document request ${message.schemaVersion}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clipUtf8(text: string, maxBytes: number): string {
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
return bytes.byteLength <= maxBytes ? text : new TextDecoder().decode(bytes.subarray(0, maxBytes)).replace(/\uFFFD+$/, "");
|
||||
}
|
||||
|
||||
/** The put or delete a decoded frame claims to be, with the ids it carries when they are strings. */
|
||||
function readableWrite(value: unknown): { operation: CultMeshRemoteWriteOperation; schemaId: string; recordKey: string } | undefined {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
const frame = value as { schemaVersion?: unknown; schemaId?: unknown; recordKey?: unknown; document?: unknown };
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
if (frame.schemaVersion === "cultnet.document_delete.v0") {
|
||||
return { operation: "delete", schemaId: str(frame.schemaId), recordKey: str(frame.recordKey) };
|
||||
}
|
||||
if (frame.schemaVersion === "cultnet.document_put_raw.v0") {
|
||||
const document = (typeof frame.document === "object" && frame.document !== null ? frame.document : {}) as { schemaId?: unknown; recordKey?: unknown };
|
||||
return { operation: "put", schemaId: str(document.schemaId), recordKey: str(document.recordKey) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the write authority about a put or delete, before anything of the write is sized, resolved or decoded, and
|
||||
* answers a refusal with the typed `write_not_authorized` naming the record. True when the write is refused.
|
||||
* No authority refuses; an authority that throws, rejects or does not say `granted: true` refuses too (fail
|
||||
* closed), and a failure is reported without quoting it.
|
||||
*/
|
||||
async function writeRefused(
|
||||
record: SessionRecord,
|
||||
operation: CultMeshRemoteWriteOperation,
|
||||
schemaId: string,
|
||||
recordKey: string,
|
||||
shardId: string | null,
|
||||
shardEpoch: number | null,
|
||||
): Promise<boolean> {
|
||||
let reason: string | undefined;
|
||||
const authority = options.writeAuthority;
|
||||
if (!authority) {
|
||||
reason = "no remote write authority is configured";
|
||||
} else {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const timedOut = Symbol("decide timeout");
|
||||
const decision = await Promise.race([
|
||||
Promise.resolve().then(() => authority.decide({
|
||||
peer: { ...record.remote },
|
||||
operation,
|
||||
schemaId: options.documents.canonicalSchemaId(schemaId) ?? schemaId,
|
||||
recordKey,
|
||||
shardId,
|
||||
shardEpoch,
|
||||
})),
|
||||
new Promise<typeof timedOut>((resolve) => { timer = setTimeout(() => resolve(timedOut), decideTimeoutMs); }),
|
||||
]);
|
||||
if (decision === timedOut) {
|
||||
reportError(new Error(`CultMesh remote write authority did not answer within ${decideTimeoutMs} ms; the write was refused.`));
|
||||
reason = "the write authority timed out";
|
||||
} else {
|
||||
if (decision?.granted === true) return false;
|
||||
reason = typeof decision?.reason === "string" ? decision.reason : undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
reportError(new Error(`CultMesh remote write authority failed (${error instanceof Error ? error.name : typeof error}); the write was refused.`));
|
||||
reason = "the write authority failed";
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// The key is the peer's and the reason the authority's: both are clipped so the refusal fits `maxPayloadBytes`
|
||||
// whatever they are (the peer is told the clipped key; a `maxPayloadBytes` under about 512 can still not carry it).
|
||||
sendSchemaMessage(record, cultNetWriteNotAuthorizedError(clipUtf8(recordKey, 64), reason === undefined ? undefined : clipUtf8(reason, 96)));
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function publicSession(record: SessionRecord): CultMeshRudpServerSession {
|
||||
return {
|
||||
sessionId: record.sessionId,
|
||||
|
|
@ -5990,17 +5698,6 @@ export class CultMesh {
|
|||
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* A session that reached the cap of consecutive refused frames: its last refusal went out like any reply
|
||||
* (reliable, so a lost datagram is resent), and the session ends once the peer has acknowledged every reply or
|
||||
* the grace runs out. The resend timer ends it.
|
||||
*/
|
||||
function endCappedSession(record: SessionRecord): void {
|
||||
const key = `${record.remote.address}:${record.remote.port}`;
|
||||
if (sessions.get(key) !== record) return;
|
||||
record.endingDeadlineMs ??= Date.now() + CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS;
|
||||
}
|
||||
|
||||
/** The session owes its peer a packet that can never be sent as built. */
|
||||
function endUnsendableSession(record: SessionRecord, error: Error): void {
|
||||
if (record.unsendable) return;
|
||||
|
|
@ -6044,7 +5741,7 @@ export class CultMesh {
|
|||
*/
|
||||
function refuse(record: SessionRecord, text: string): void {
|
||||
if (record.closed) return;
|
||||
const payload = encodeSchemaMessage(cultNetTextError(text));
|
||||
const payload = encodeSchemaMessage({ schemaVersion: "cultnet.error.v0", error: text });
|
||||
for (const packet of record.session.sendMany("schema", payload, {
|
||||
reliable: false,
|
||||
ordered: false,
|
||||
|
|
@ -6134,11 +5831,6 @@ export class CultMesh {
|
|||
closeSession(record);
|
||||
continue;
|
||||
}
|
||||
if (record.endingDeadlineMs !== undefined
|
||||
&& (record.session.outstandingReliablePacketCount === 0 || nowMs >= record.endingDeadlineMs)) {
|
||||
endRefusedSession(key, record);
|
||||
continue;
|
||||
}
|
||||
for (const packet of record.session.dueResends(nowMs)) {
|
||||
sendPacket(record, packet);
|
||||
}
|
||||
|
|
@ -6216,88 +5908,25 @@ export class CultMesh {
|
|||
recordKey: string,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
options: CultMeshRudpDocumentPublishOptions = {},
|
||||
): Promise<"admitted" | "sent"> {
|
||||
): Promise<void> {
|
||||
requireNonEmpty(runtimeId, "runtimeId");
|
||||
requireNonEmpty(recordKey, "recordKey");
|
||||
if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined.");
|
||||
const registry = new CultNetDocumentRegistry([binding]);
|
||||
const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`;
|
||||
// Built before any connection and outside every try: a put that cannot be built is the caller's error, with its cause.
|
||||
const put = registry.createRawDocumentPutMessage(
|
||||
binding,
|
||||
messageId,
|
||||
recordKey,
|
||||
value,
|
||||
{
|
||||
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
|
||||
sourceAgentId: options.sourceAgentId,
|
||||
sourceRole: options.sourceRole,
|
||||
tags: options.tags,
|
||||
},
|
||||
);
|
||||
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
|
||||
try {
|
||||
if (options.sendOnly) {
|
||||
try {
|
||||
peer.send(put);
|
||||
await peer.flush(options.flushTimeoutMs ?? 30_000);
|
||||
} catch {
|
||||
throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed");
|
||||
}
|
||||
return "sent";
|
||||
}
|
||||
// A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides
|
||||
// how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw.
|
||||
let receiptCapable = false;
|
||||
let tracker: WriteTracker | undefined;
|
||||
let settlement: WriteSettlement | undefined;
|
||||
let wake: (() => void) | undefined;
|
||||
let serverHello: (() => void) | undefined;
|
||||
peer.on("message", (message: CultNetMessage) => {
|
||||
if (message.schemaVersion === "cultnet.hello.v0") {
|
||||
receiptCapable = helloAdvertisesWriteReceipts(message);
|
||||
serverHello?.();
|
||||
} else {
|
||||
tracker?.frame(message, Date.now());
|
||||
}
|
||||
});
|
||||
peer.on("close", () => tracker?.sessionEnd(Date.now()));
|
||||
if (options.writeReceipts) {
|
||||
const answered = new Promise<void>((resolve) => { serverHello = resolve; });
|
||||
peer.sendHello({
|
||||
schemaVersion: "cultnet.hello.v0", runtimeId, runtimeKind: "cultmesh-ts",
|
||||
supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS],
|
||||
});
|
||||
let helloTimer: NodeJS.Timeout | undefined;
|
||||
await Promise.race([answered, new Promise<void>((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]);
|
||||
clearTimeout(helloTimer);
|
||||
}
|
||||
const writeTracker = new WriteTracker({
|
||||
windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable),
|
||||
onSettle: (_id, result) => { settlement = result; wake?.(); },
|
||||
});
|
||||
tracker = writeTracker;
|
||||
writeTracker.send(messageId, Date.now());
|
||||
try {
|
||||
peer.send(put);
|
||||
// The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure.
|
||||
const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
|
||||
await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1)));
|
||||
} catch {
|
||||
// Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal.
|
||||
writeTracker.sendFailed(messageId, Date.now());
|
||||
}
|
||||
while (!settlement) {
|
||||
const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, remaining);
|
||||
wake = () => { clearTimeout(timer); resolve(); };
|
||||
});
|
||||
writeTracker.tick(Date.now());
|
||||
}
|
||||
if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response);
|
||||
if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason);
|
||||
return "admitted";
|
||||
peer.send(registry.createRawDocumentPutMessage(
|
||||
binding,
|
||||
options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`,
|
||||
recordKey,
|
||||
value,
|
||||
{
|
||||
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
|
||||
sourceAgentId: options.sourceAgentId,
|
||||
sourceRole: options.sourceRole,
|
||||
tags: options.tags,
|
||||
},
|
||||
));
|
||||
await peer.flush(options.flushTimeoutMs ?? 30_000);
|
||||
} finally {
|
||||
peer.close();
|
||||
}
|
||||
|
|
@ -6316,7 +5945,6 @@ export class CultMesh {
|
|||
const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000);
|
||||
const onMessage = (message: CultNetMessage): void => {
|
||||
if (message.schemaVersion === "cultnet.error.v0") { cleanup(); reject(new CultMeshRudpWriteRefusedError(message)); return; }
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return;
|
||||
try {
|
||||
const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { createSocket, type Socket } from "node:dgram";
|
||||
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import { encodeWireValue } from "cultnet-ts/wire";
|
||||
import {
|
||||
CultNetPeer,
|
||||
CultNetRudpSocketTransportConnection,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
type CultNetOperationRequestMessage,
|
||||
type CultNetOperationResponseMessage,
|
||||
type CultNetRawDocumentRecord,
|
||||
} from "@gamecult/cultnet-ts";
|
||||
} from "cultnet-ts";
|
||||
|
||||
import type {
|
||||
CultMeshProviderCommand,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue