From ee4f65bedf7dac147866d5532218f1f923bf3445 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 16:29:28 +0200 Subject: [PATCH] Build authoritative Realm Chat V1 --- CHANGELOG.md | 6 + docs/README.md | 2 + docs/design/realm-chat-v1-implementation.md | 168 ++++ docs/security/threat-model.md | 15 + docs/technical-architecture.md | 7 + package.json | 1 + scripts/publish-spacetime-dev.mjs | 17 +- .../spacetime-additive-migration-proof.mjs | 12 +- ...rify-access-request-additive-migration.mjs | 35 +- .../verify-spacetime-additive-migration.mjs | 327 ++++++-- spacetimedb/README.md | 28 +- .../additive-v15-schema/package.json | 13 + .../additive-v15-schema/src/index.ts | 750 ++++++++++++++++++ .../additive-v15-schema/tsconfig.json | 13 + spacetimedb/pnpm-lock.yaml | 10 + spacetimedb/src/index.ts | 13 + spacetimedb/src/realmChatPolicy.ts | 262 ++++++ spacetimedb/src/reducers/realmChat.ts | 719 +++++++++++++++++ spacetimedb/src/schema.ts | 121 +++ .../accessRequestMigrationTooling.test.ts | 42 +- .../tests/accessRequestReducers.test.ts | 7 + .../tests/castleWorkerAuthority.test.ts | 5 +- spacetimedb/tests/dailyMarksReducers.test.ts | 2 +- .../tests/playerIdentityPrivacy.test.ts | 7 + spacetimedb/tests/realmChatAuthority.test.ts | 188 +++++ spacetimedb/tests/resourceReducers.test.ts | 7 + .../tests/waterRevisionAuthority.test.ts | 2 +- .../waterRevisionMigrationTooling.test.ts | 10 +- src/components/WarpkeepExperience.tsx | 4 + src/components/realm/RealmChatDock.css | 562 +++++++++++++ src/components/realm/RealmChatDock.tsx | 614 ++++++++++++++ src/components/realm/RealmMapScreen.tsx | 82 ++ src/spacetime/WarpkeepSpacetimeProvider.tsx | 230 +++++- .../admin_activate_realm_chat_v_1_reducer.ts | 15 + .../admin_disable_realm_chat_v_1_reducer.ts | 13 + ...realm_chat_report_context_v_1_procedure.ts | 20 + ...min_get_realm_chat_status_v_1_procedure.ts | 19 + ...n_list_realm_chat_reports_v_1_procedure.ts | 21 + ...n_resolve_realm_chat_report_v_1_reducer.ts | 16 + .../admin_stage_realm_chat_v_1_reducer.ts | 13 + ...ombstone_realm_chat_message_v_1_reducer.ts | 16 + .../get_realm_chat_history_v_1_procedure.ts | 21 + src/spacetime/module_bindings/index.ts | 59 ++ .../realm_chat_recent_v_1_table.ts | 21 + .../realm_chat_status_v_1_table.ts | 21 + .../report_realm_chat_message_v_1_reducer.ts | 17 + .../send_realm_chat_message_v_1_reducer.ts | 16 + src/spacetime/module_bindings/types.ts | 157 ++++ .../module_bindings/types/procedures.ts | 12 + .../module_bindings/types/reducers.ts | 14 + src/spacetime/playerModuleBindings.ts | 40 +- src/spacetime/realmChatPresentation.ts | 194 +++++ src/spacetime/warpkeepConnection.ts | 138 ++++ tests/RealmChatDock.test.tsx | 169 ++++ tests/activationToolingSecurity.test.ts | 12 + tests/playerModuleBindings.test.ts | 8 + tests/realmChatPolicy.test.ts | 100 +++ tests/realmChatPresentation.test.ts | 84 ++ tests/realmChatSendIdempotency.test.ts | 38 + 59 files changed, 5447 insertions(+), 88 deletions(-) create mode 100644 docs/design/realm-chat-v1-implementation.md create mode 100644 spacetimedb/migration-fixtures/additive-v15-schema/package.json create mode 100644 spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts create mode 100644 spacetimedb/migration-fixtures/additive-v15-schema/tsconfig.json create mode 100644 spacetimedb/src/realmChatPolicy.ts create mode 100644 spacetimedb/src/reducers/realmChat.ts create mode 100644 spacetimedb/tests/realmChatAuthority.test.ts create mode 100644 src/components/realm/RealmChatDock.css create mode 100644 src/components/realm/RealmChatDock.tsx create mode 100644 src/spacetime/module_bindings/admin_activate_realm_chat_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_disable_realm_chat_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_get_realm_chat_report_context_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_get_realm_chat_status_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_list_realm_chat_reports_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_resolve_realm_chat_report_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_stage_realm_chat_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_tombstone_realm_chat_message_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/get_realm_chat_history_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/realm_chat_recent_v_1_table.ts create mode 100644 src/spacetime/module_bindings/realm_chat_status_v_1_table.ts create mode 100644 src/spacetime/module_bindings/report_realm_chat_message_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/send_realm_chat_message_v_1_reducer.ts create mode 100644 src/spacetime/realmChatPresentation.ts create mode 100644 tests/RealmChatDock.test.tsx create mode 100644 tests/realmChatPolicy.test.ts create mode 100644 tests/realmChatPresentation.test.ts create mode 100644 tests/realmChatSendIdempotency.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7758c92..a0aa6b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ full engineering record. ## [Unreleased] +- Built the review-only Realm Chat V1 foundation as an isolated SpacetimeDB + protocol: server-authored identity/order/time, a private archive, bounded live + projection and history, exactly-once sends, rolling abuse limits, private + context-preserving reports, audited tombstones, and responsive desktop/Mini + App chat surfaces. The feature remains unseeded, unpublishable, and hidden + behind independent server and client gates pending legal and activation review. - Drafted the next Alpha Terms, Hegemony Social Contract, and Privacy Notice for a future persistent Realm Chat, including explicit conduct, reporting, moderation, history, and privacy boundaries. Chat remains disabled pending diff --git a/docs/README.md b/docs/README.md index ebf50ee5..8a03db34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,8 @@ contributors. This page routes deeper work without duplicating it. - [Product direction](design/warpkeep-direction.md) — the game's premise and design principles - [Roadmap](design/roadmap.md) — what is live, under development, and later +- [Realm Chat V1 implementation](design/realm-chat-v1-implementation.md) — + review-only research, SpacetimeDB authority, abuse controls, and rollout gates - [Technical architecture](technical-architecture.md) — browser, identity bridge, SpacetimeDB, rendering, and delivery - [Lowlands renderer](design/hegemony-lowlands-terrain.md) — terrain, diff --git a/docs/design/realm-chat-v1-implementation.md b/docs/design/realm-chat-v1-implementation.md new file mode 100644 index 00000000..b11f9c82 --- /dev/null +++ b/docs/design/realm-chat-v1-implementation.md @@ -0,0 +1,168 @@ +# Realm Chat V1 implementation + +Status: **review-only; client, server activation, and production publication disabled** + +Policy version: `2026-08-03-realm-chat-policy-v1` + +Realm channel: `realm:genesis-001` + +This document records the product research, technical design, security model, +and release boundary for Warpkeep's first persistent in-game chat. The legal +and product contract remains the controlling source for whether the feature may +ever be activated: [Realm Chat V1 contract](realm-chat-v1-contract.md). + +## Research translated into Warpkeep + +The implementation follows recurring patterns from established live games, +while avoiding features that would create false expectations in an early Alpha. + +| Established pattern | Warpkeep V1 decision | +| --- | --- | +| Final Fantasy XIV lets players create chat tabs and choose which message categories appear in each tab. | V1 has one clearly named Realm channel, but its channel key, status row, and isolated subscription leave room for later user-defined views without changing message authority. | +| Fortnite distinguishes game/party text chat, exposes privacy settings, and places reporting close to the relevant conversation. | The Realm dock makes audience scope explicit, keeps mute local to the browser session, and attaches reporting to one exact message rather than to an unstructured player form. | +| Minecraft's reporting flow includes surrounding chat context and allows players to preview what will be submitted. | Warpkeep records a bounded context range at report time, discloses that behavior before submission, prevents later messages from entering the report, and keeps the evidence private for authorized review. | +| Modern game chat preserves play space: a bounded desktop surface and a dedicated compact/mobile destination are more usable than a full-screen overlay everywhere. | Desktop uses a lower-left dock; compact web and Farcaster Mini Apps use the Realm's existing full-screen destination and single Back-navigation owner. | + +Primary product references: + +- [Final Fantasy XIV: creating a chat log tab](https://na.finalfantasyxiv.com/uiguide/communication/communication-chat/chat_owntab.html) +- [Fortnite: managing text chat options](https://www.epicgames.com/help/c-1/a202300000011592?lang=en-US) +- [Fortnite: reporting bad player behavior](https://www.epicgames.com/help/c-5719350646299/a202300000017678?lang=en-US) +- [Minecraft: addressing player chat reporting](https://www.minecraft.net/en-us/article/addressing-player-chat-reporting-tool) +- [Minecraft Java 1.19.1 report-context notes](https://feedback.minecraft.net/hc/en-us/articles/34593554333197-Minecraft-Java-Edition-1-19-1) +- [Minecraft accessibility settings](https://help.minecraft.net/hc/en-us/articles/43045760611469) + +## Authority model + +The browser expresses intent. SpacetimeDB decides identity, admission, +agreement eligibility, channel, message ID, order, time, visibility, rate +limits, report linkage, and moderation state. + +```text +admitted player + -> send/report reducer (private intent, caller-derived FID) + -> private archive / receipt / rate / report tables + -> bounded public status + newest 128-message projection + -> isolated browser subscription + -> desktop dock or compact Realm destination +``` + +The implementation uses the official SpacetimeDB model deliberately: + +- reducers are the only message/report mutation boundary; +- public tables contain only channel status and the bounded recent projection; +- private tables contain the permanent archive, channel sequence, rate events, + idempotency receipts, and reports; +- older history is a caller-gated procedure with an exclusive indexed cursor, + at most 50 sequence lookups, and no full-table scan; +- moderator procedures are admin-only, bounded, and read-only unless the named + reducer records an audited state change; and +- generated browser bindings expose only two public tables, two self-service + reducers, and one caller-safe history procedure. + +Relevant platform references: + +- [SpacetimeDB TypeScript client and subscriptions](https://spacetimedb.com/docs/clients/typescript/) +- [SpacetimeDB table model](https://spacetimedb.com/docs/tables/) +- [SpacetimeDB table access permissions](https://spacetimedb.com/docs/tables/access-permissions/) +- [SpacetimeDB reducers](https://spacetimedb.com/docs/functions/reducers/) +- [SpacetimeDB views and caller-scoped reads](https://spacetimedb.com/docs/functions/views/) + +Chat is not joined to Warpkeep's large Realm snapshot. Its two-table +subscription has its own observer and failure boundary, so chat reconnects or +malformed chat rows cannot invalidate terrain, keeps, resources, or Workers. + +## Persistence and migration + +Protocol V15 appends exactly seven tables after the frozen V14 schema: + +| Table | Visibility | Purpose | +| --- | --- | --- | +| `realm_chat_status_v1` | Public | Policy, mode, and projection limits | +| `realm_chat_channel_v1` | Private | Canonical sequence and channel state | +| `realm_chat_message_v1` | Private | Authoritative message archive and moderation evidence | +| `realm_chat_recent_v1` | Public | Exact newest window, capped at 128 rows | +| `realm_chat_rate_event_v1` | Private | Bounded rolling anti-spam evidence | +| `realm_chat_send_receipt_v1` | Private | Exactly-once retry receipts | +| `realm_chat_report_v1` | Private | One caller/message report and frozen context range | + +The V14 fixture stays frozen. A separate V15 fixture, static schema checks, and +a disposable populated-database proof establish additive preservation, +idempotent republish, all-seven-table row retention, and refusal of destructive +V15-to-V14 rollback. The canonical production publisher intentionally has no +V15 publication lane in this branch. + +## Message and abuse policy + +The server normalizes CRLF and Unicode NFC before validation. Candidate V1 +limits are 500 Unicode scalars, 2,048 UTF-8 bytes, and eight lines. Controls +that can forge or visually reorder moderation evidence are rejected while +ordinary right-to-left language remains supported. + +Accepted messages are limited to one every two seconds, ten in a rolling +minute, sixty in a rolling hour, and no duplicate normalized body from the same +sender within sixty seconds. Rejected attempts consume no quota. State is +bounded per FID and corrupt or oversized ledgers fail closed. + +Each send uses a canonical UUID request key. A client retries an ambiguous +timeout with the same key and body for a bounded window; the server either +returns the original success or rejects a conflicting replay. Message IDs are +server-generated UUIDv7 values and sequence/time are server-authored. + +Reports are message-local, caller-bound, private, and idempotent. Self-reporting +and duplicate report mutation are rejected. The recorded context ends at the +last sequence that existed when the report was created, so later conversation +cannot silently alter evidence. Public moderation replaces the body with a +tombstone while the private admin evidence procedure retains the original text. +Reporting never automatically hides a message or punishes a player. + +## Player experience + +- The launcher shows unread count without stealing focus. +- Opening at the bottom marks the current live window read. New messages do not + force-scroll a player who is reading earlier history. +- The composer preserves a failed draft, sends on Enter, inserts a line break + with Shift+Enter, and does not send during IME composition. +- Sender portraits open a small keeper card with keep location, session mute, + exact-message report, and safe plain-text copy controls. +- Messages are rendered as text only. V1 has no HTML, automatic links, embeds, + attachments, or rich previews. +- Compact chat participates in the Realm's single Farcaster Back boundary: + Back closes a report first, then chat, then resumes normal Realm navigation. +- The message log, status announcements, report dialog, focus containment, + expanded relationships, reduced motion, safe areas, and focus restoration + are keyboard and assistive-technology aware. + +## Deliberate V1 non-goals + +V1 does not claim typing indicators, online presence, delivery/read receipts, +direct messages, guild chat, proximity chat, voice, translation, reactions, +editing, deletion by players, link previews, attachments, or push +notifications. In particular, it does not infer presence from a socket or +invent typing state that SpacetimeDB does not authoritatively persist. + +These can be evaluated later as separate privacy, retention, moderation, and +authority changes. Channel extensibility is preserved without exposing those +features prematurely. + +## Activation checklist + +Merging this implementation must not activate or publish chat. Activation +requires a separate reviewed change that records all of the following: + +1. owner and qualified legal approval, including the unresolved age/minor + policy and an explicit retention/erasure schedule; +2. exact approved legal, policy, client, server, schema, and generated-binding + versions; +3. a V15 production predecessor, additive migration receipt, protected admin + inspection, and exact post-publication checkpoint; +4. staged channel health, bounded projection/archive parity, moderator access, + kill-switch, and canary evidence; +5. desktop, compact web, and Farcaster Mini App accessibility/abuse QA; +6. named rollback owner and evidence-preserving incident procedure; and +7. a separate commit changing both the server activation compile gate and the + client entry gate only after the active channel is verified. + +Until then, the client flag is `false`, server activation is not compiled, the +channel is unseeded, the production publisher rejects V15 mutation, and no chat +data is collected. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 7121cdbd..7acb5dec 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -59,6 +59,7 @@ operation. Anonymous visitors do not connect to the game database. | World and castle state | Transactional integrity and server-enforced ownership. | | Deployment authority | Least privilege, reviewed changes, protected branches, and reproducible artifacts. | | Player privacy | Minimum collection, bounded presentation fields, redacted diagnostics, and private operational records. | +| Realm Chat and moderation evidence | Server-authored identity/order/time, bounded public history, private archive/reports/rate state, audited moderation, and no activation before approved retention and minor-participation policy. | ## Trust boundaries @@ -143,6 +144,10 @@ operation. Anonymous visitors do not connect to the game database. - Public Farcaster presentation is sanitized and optional. A tab cache may hold only public display fields, may merge only after a successful same-FID refresh, and never restores authority. +- Review-only Realm Chat keeps its permanent archive, rate events, idempotency + receipts, reports, and moderator evidence private. Only one status row and an + exact newest 128-message projection can become public after separate + activation. The ordinary Realm snapshot never absorbs chat history. ### Input, transport, and abuse controls @@ -162,6 +167,10 @@ operation. Anonymous visitors do not connect to the game database. re-enter the same reviewed-host, byte, decode, and static-format checks. - Public error messages and logs omit proof material, tokens, cookies, QR payloads, relay secrets, identities, private rows, and credentialed URLs. +- Realm Chat normalizes Unicode and line endings, rejects evidence-spoofing + controls, applies exact rolling per-FID limits, and records reports against a + frozen context range. Report submission does not automatically hide content + or punish another player. ### Operations and software supply chain @@ -199,6 +208,8 @@ operation. Anonymous visitors do not connect to the game database. | Dependency or CI compromise | Lockfiles, audits, checksum and action pinning, job separation, and branch protection reduce exposure; provider compromise remains possible. | | Operator compromise | Destination allowlists, short-lived tokens, private secret storage, and approvals reduce blast radius; a compromised operator account or workstation remains a critical incident. | | Misleading Marks or Alpha expectations | Product copy states that Marks are non-transferable and have no cash value or guaranteed reward; formal legal and privacy review remains necessary as use expands. | +| Chat spam, harassment, or forged moderation context | Server-derived identity/order/time, bounded rate ledgers, exact-message reporting, frozen surrounding context, local mute, private review, and audited tombstones reduce abuse; human moderation quality and response time remain operational risks. | +| Chat archive or report exposure | Private tables, narrow generated player bindings, bounded procedures, body-free public tombstones, and isolated subscriptions reduce exposure; operator compromise and approved retention/erasure handling remain residual risks. | ## Residual risks and current limitations @@ -212,6 +223,10 @@ operation. Anonymous visitors do not connect to the game database. - Public Realm projections are observable to admitted clients by design. Privacy classification and retention must be revisited before adding new player-linked data. +- Realm Chat is disabled and unpublishable in the review branch. Activation is + blocked until qualified review approves retention/erasure, data-subject + handling, age/minor participation, moderation access, incident response, and + exact production migration checkpoints. - Hosting-layer security headers, including HSTS, require ongoing deployment verification. The production CSP keeps exact source and egress allowlists, but SpacetimeDB 2.6.1 requires a narrowly scoped `script-src 'unsafe-eval'` diff --git a/docs/technical-architecture.md b/docs/technical-architecture.md index a9031417..872a4377 100644 --- a/docs/technical-architecture.md +++ b/docs/technical-architecture.md @@ -46,6 +46,13 @@ browser cannot choose an FID, castle owner, balance, timer, or outcome through request fields. Schema changes are additive because deployed tables and generated client bindings must remain compatible. +A review-only Realm Chat V1 implementation is isolated from the large Realm +snapshot. SpacetimeDB owns sender identity, order, time, anti-abuse state, +history, reporting, and moderation evidence; the browser may subscribe only to +one status row and a bounded recent projection. Independent client and server +gates plus a blocked production publication lane keep it unavailable pending a +separate legal and operational activation review. + The module guide, local commands, and schema notes live in [`spacetimedb/README.md`](../spacetimedb/README.md). diff --git a/package.json b/package.json index e2426e1c..54b0a58b 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "stdb:generate": "node scripts/generate-spacetime-bindings.mjs", "stdb:verify-bindings": "node scripts/verify-spacetime-bindings.mjs", "stdb:build-v14-migration-fixture": "spacetime build --module-path spacetimedb/migration-fixtures/additive-v14-schema", + "stdb:build-v15-migration-fixture": "spacetime build --module-path spacetimedb/migration-fixtures/additive-v15-schema", "stdb:verify-additive-migration": "node scripts/verify-spacetime-additive-migration.mjs", "stdb:verify-worker-migration": "node scripts/verify-castle-worker-additive-migration.mjs", "stdb:verify-access-request-migration": "node scripts/verify-access-request-additive-migration.mjs", diff --git a/scripts/publish-spacetime-dev.mjs b/scripts/publish-spacetime-dev.mjs index 815ef43f..f191eec1 100644 --- a/scripts/publish-spacetime-dev.mjs +++ b/scripts/publish-spacetime-dev.mjs @@ -2682,12 +2682,13 @@ function validateMigrationArtifactReceiptShape(receipt) { receipt === null || typeof receipt !== 'object' || Object.keys(receipt).sort().join(',') - !== 'artifactDigest,artifactPath,v11TableSchemaDigest,v12TableSchemaDigest,v13TableSchemaDigest,v14TableSchemaDigest' + !== 'artifactDigest,artifactPath,v11TableSchemaDigest,v12TableSchemaDigest,v13TableSchemaDigest,v14TableSchemaDigest,v15TableSchemaDigest' || receipt.artifactPath !== PROVEN_ARTIFACT_PATH || !SHA256_DIGEST.test(receipt.v11TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.v12TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.v13TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.v14TableSchemaDigest ?? '') + || !SHA256_DIGEST.test(receipt.v15TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.artifactDigest ?? '') ) { fail('The additive migration proof artifact receipt was invalid.'); @@ -2698,6 +2699,7 @@ function validateMigrationArtifactReceiptShape(receipt) { v12TableSchemaDigest: receipt.v12TableSchemaDigest, v13TableSchemaDigest: receipt.v13TableSchemaDigest, v14TableSchemaDigest: receipt.v14TableSchemaDigest, + v15TableSchemaDigest: receipt.v15TableSchemaDigest, artifactDigest: receipt.artifactDigest, }); } @@ -2724,6 +2726,7 @@ export function parseMigrationProofReceipt(output) { v12TableSchemaDigest: proofReceipt.v12TableSchemaDigest, v13TableSchemaDigest: proofReceipt.v13TableSchemaDigest, v14TableSchemaDigest: proofReceipt.v14TableSchemaDigest, + v15TableSchemaDigest: proofReceipt.v15TableSchemaDigest, artifactDigest: proofReceipt.artifactDigest, }); } @@ -3840,7 +3843,7 @@ export async function publishModule( if (targetDatabase !== CANONICAL_DATABASE_IDENTITY) { fail('The production publish target was not the pinned canonical database identity.'); } - const artifact = validateMigrationArtifactReceiptShape(artifactReceipt); + const artifact = verifyMigrationArtifactReceipt(artifactReceipt); const artifactSnapshot = createPrivatePublishSnapshot( artifact.artifactPath, artifact.artifactDigest, @@ -3929,6 +3932,15 @@ export async function publishModule( } } +/** + * Keep the additive v15 artifact testable without granting it a production + * publication lane. Activation requires a later evidence-backed change with + * exact predecessor and post-publication checkpoints. + */ +export function requireRealmChatV15ProductionPublishReady() { + fail('Realm Chat protocol v15 is review-only and cannot be published by this build.'); +} + async function main() { const { dryRun, @@ -3971,6 +3983,7 @@ async function main() { console.log(`Dry run: verified the pinned CLI, current additive migration, founded-state expectation contract, explicit ${resourceRolloutStage} resource stage, explicit ${genesisWorldRolloutStage} Genesis world stage, explicit ${workerRolloutStage} Worker stage, explicit ${workerModulePredecessor} module predecessor, explicit ${workerForwardRepair} Worker forward-repair selection, and ${issuer}; would update the canonical existing database without deleting data.`); return; } + requireRealmChatV15ProductionPublishReady(); await validateIssuerDeployment(issuer); attestCanonicalDatabase(executable); if ( diff --git a/scripts/spacetime-additive-migration-proof.mjs b/scripts/spacetime-additive-migration-proof.mjs index 5577f923..71295f57 100644 --- a/scripts/spacetime-additive-migration-proof.mjs +++ b/scripts/spacetime-additive-migration-proof.mjs @@ -3,18 +3,20 @@ const V11_TABLE_SCHEMA_RECEIPT_FIELD = 'v11_table_schema_sha256'; const V12_TABLE_SCHEMA_RECEIPT_FIELD = 'v12_table_schema_sha256'; const V13_TABLE_SCHEMA_RECEIPT_FIELD = 'v13_table_schema_sha256'; const V14_TABLE_SCHEMA_RECEIPT_FIELD = 'v14_table_schema_sha256'; +const V15_TABLE_SCHEMA_RECEIPT_FIELD = 'v15_table_schema_sha256'; const ARTIFACT_RECEIPT_FIELD = 'artifact_sha256'; const RECEIPT_FIELDS = Object.freeze([ V11_TABLE_SCHEMA_RECEIPT_FIELD, V12_TABLE_SCHEMA_RECEIPT_FIELD, V13_TABLE_SCHEMA_RECEIPT_FIELD, V14_TABLE_SCHEMA_RECEIPT_FIELD, + V15_TABLE_SCHEMA_RECEIPT_FIELD, ARTIFACT_RECEIPT_FIELD, ]); const INVALID_RECEIPT_MESSAGE = 'The current additive migration proof did not produce its exact success receipt.'; -export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 14; +export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15; export const ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION = '2.6.1'; // The compiled lifecycle lane includes a nine-minute route and one complete // gathering minute. Keep a bounded margin for server startup and cleanup. @@ -35,6 +37,7 @@ export function formatAdditiveMigrationProofReceipt({ v12TableSchemaDigest, v13TableSchemaDigest, v14TableSchemaDigest, + v15TableSchemaDigest, artifactDigest, }) { if ( @@ -51,6 +54,8 @@ export function formatAdditiveMigrationProofReceipt({ || !SHA256_DIGEST.test(v13TableSchemaDigest) || typeof v14TableSchemaDigest !== 'string' || !SHA256_DIGEST.test(v14TableSchemaDigest) + || typeof v15TableSchemaDigest !== 'string' + || !SHA256_DIGEST.test(v15TableSchemaDigest) || typeof artifactDigest !== 'string' || !SHA256_DIGEST.test(artifactDigest) ) { @@ -61,6 +66,7 @@ export function formatAdditiveMigrationProofReceipt({ + `${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest} ` + `${V13_TABLE_SCHEMA_RECEIPT_FIELD}=${v13TableSchemaDigest} ` + `${V14_TABLE_SCHEMA_RECEIPT_FIELD}=${v14TableSchemaDigest} ` + + `${V15_TABLE_SCHEMA_RECEIPT_FIELD}=${v15TableSchemaDigest} ` + `${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`; } @@ -84,11 +90,13 @@ export function parseAdditiveMigrationProofReceipt(output) { const v12TableSchemaDigest = digestFields[V12_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; const v13TableSchemaDigest = digestFields[V13_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; const v14TableSchemaDigest = digestFields[V14_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; + const v15TableSchemaDigest = digestFields[V15_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; const artifactDigest = digestFields[ARTIFACT_RECEIPT_FIELD][0][1]; const receiptSuffix = ` ${V11_TABLE_SCHEMA_RECEIPT_FIELD}=${v11TableSchemaDigest}` + ` ${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest}` + ` ${V13_TABLE_SCHEMA_RECEIPT_FIELD}=${v13TableSchemaDigest}` + ` ${V14_TABLE_SCHEMA_RECEIPT_FIELD}=${v14TableSchemaDigest}` + + ` ${V15_TABLE_SCHEMA_RECEIPT_FIELD}=${v15TableSchemaDigest}` + ` ${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`; if ( !proofLine.startsWith(`${SUCCESS_PREFIX} `) @@ -96,6 +104,7 @@ export function parseAdditiveMigrationProofReceipt(output) { || !SHA256_DIGEST.test(v12TableSchemaDigest) || !SHA256_DIGEST.test(v13TableSchemaDigest) || !SHA256_DIGEST.test(v14TableSchemaDigest) + || !SHA256_DIGEST.test(v15TableSchemaDigest) || !SHA256_DIGEST.test(artifactDigest) || !proofLine.endsWith(receiptSuffix) || proofLine.slice(SUCCESS_PREFIX.length + 1, -receiptSuffix.length).length === 0 @@ -108,6 +117,7 @@ export function parseAdditiveMigrationProofReceipt(output) { v12TableSchemaDigest, v13TableSchemaDigest, v14TableSchemaDigest, + v15TableSchemaDigest, artifactDigest, }); } diff --git a/scripts/verify-access-request-additive-migration.mjs b/scripts/verify-access-request-additive-migration.mjs index 249b3531..85184b84 100644 --- a/scripts/verify-access-request-additive-migration.mjs +++ b/scripts/verify-access-request-additive-migration.mjs @@ -13,6 +13,14 @@ const v13FixturePath = resolve( repositoryRoot, 'spacetimedb/migration-fixtures/additive-v13-schema/src/index.ts', ); +const v14FixturePath = resolve( + repositoryRoot, + 'spacetimedb/migration-fixtures/additive-v14-schema/src/index.ts', +); +const v15FixturePath = resolve( + repositoryRoot, + 'spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts', +); const proofPath = resolve( repositoryRoot, 'scripts/verify-spacetime-additive-migration.mjs', @@ -42,26 +50,41 @@ function tableDefinition(source, name) { return source.slice(start, end); } -const [schema, v12Fixture, v13Fixture, proof, receipt] = await Promise.all([ +const [schema, v12Fixture, v13Fixture, v14Fixture, v15Fixture, proof, receipt] = await Promise.all([ readFile(schemaPath, 'utf8'), readFile(v12FixturePath, 'utf8'), readFile(v13FixturePath, 'utf8'), + readFile(v14FixturePath, 'utf8'), + readFile(v15FixturePath, 'utf8'), readFile(proofPath, 'utf8'), readFile(receiptPath, 'utf8'), ]); const v12Registrations = registrations(v12Fixture, 'const db = schema({'); const v13Registrations = registrations(v13Fixture, 'const db = schema({'); +const v14Registrations = registrations(v14Fixture, 'const db = schema({'); +const v15Registrations = registrations(v15Fixture, 'const db = schema({'); const candidateRegistrations = registrations(schema, 'const warpkeep = schema({'); assert.equal(v12Registrations.length, 53, 'v12 fixture must end at ref 52'); assert.deepEqual(v13Registrations.slice(0, 53), v12Registrations); assert.deepEqual(candidateRegistrations.slice(0, 53), v12Registrations); assert.deepEqual(v13Registrations.slice(53), ['accessRequestV1']); -assert.deepEqual(candidateRegistrations.slice(0, 54), v13Registrations); -assert.deepEqual(candidateRegistrations.slice(54), [ +assert.deepEqual(v14Registrations.slice(0, 54), v13Registrations); +assert.deepEqual(v14Registrations.slice(54), [ 'dailyMarkGrantV1', 'dailyMarkScheduleV1', ]); +assert.deepEqual(v15Registrations.slice(0, 56), v14Registrations); +assert.deepEqual(v15Registrations.slice(56), [ + 'realmChatStatusV1', + 'realmChatChannelV1', + 'realmChatMessageV1', + 'realmChatRecentV1', + 'realmChatRateEventV1', + 'realmChatSendReceiptV1', + 'realmChatReportV1', +]); +assert.deepEqual(candidateRegistrations, v15Registrations); const v13TailStart = v13Fixture.indexOf( '/** v13 private, append-only expression of interest in manual admission. */', @@ -126,16 +149,18 @@ assert.match(proof, /arguments_\.filter\(value => value === '--delete-data=never assert.match(proof, /arguments_\.some\(value => value\.startsWith\('--delete-data='/); assert.doesNotMatch(proof, /--delete-data=(?:always|on-conflict|if-required)/); -assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 14/); +assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15/); assert.match(receipt, /v13_table_schema_sha256/); assert.match(receipt, /v13TableSchemaDigest/); assert.match(receipt, /v14_table_schema_sha256/); assert.match(receipt, /v14TableSchemaDigest/); +assert.match(receipt, /v15_table_schema_sha256/); +assert.match(receipt, /v15TableSchemaDigest/); console.log( 'access-request additive migration proof passed: exact v12 refs 0–52 preserved, ' + 'private access_request_v1 remains the exact v13 ref 53 boundary, ' - + 'the reviewed v14 daily Marks suffix is the only allowed extension, ' + + 'the reviewed v14 daily Marks and review-only v15 Realm Chat suffixes are the only allowed extensions, ' + 'the loopback proof exercises 2/10/50 same-cycle calls and two FIDs, ' + 'and every rehearsal remains deletion-disabled', ); diff --git a/scripts/verify-spacetime-additive-migration.mjs b/scripts/verify-spacetime-additive-migration.mjs index 3816d3c2..97e4cabc 100644 --- a/scripts/verify-spacetime-additive-migration.mjs +++ b/scripts/verify-spacetime-additive-migration.mjs @@ -83,6 +83,10 @@ const additiveV14SchemaFixture = resolve( repositoryRoot, 'spacetimedb/migration-fixtures/additive-v14-schema', ); +const additiveV15SchemaFixture = resolve( + repositoryRoot, + 'spacetimedb/migration-fixtures/additive-v15-schema', +); const additiveModule = resolve(repositoryRoot, 'spacetimedb'); const command = process.env.SPACETIME_BIN || 'spacetime'; const expectedCliVersion = ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION; @@ -97,6 +101,7 @@ const worldExpansionDatabase = 'warpkeep-migration-world-expansion'; const waterLifecycleDatabase = 'warpkeep-migration-water-lifecycle'; const populatedWaterStoneMigrationDatabase = 'warpkeep-migration-populated-water-stone'; const dailyMarksMigrationDatabase = 'warpkeep-migration-daily-marks-v14'; +const realmChatMigrationDatabase = 'warpkeep-migration-realm-chat-v15'; const maximumOutputBytes = 1_000_000; const commandTimeoutMilliseconds = 120_000; const procedureTimeoutMilliseconds = 5_000; @@ -289,6 +294,15 @@ const additiveV14Tables = Object.freeze([ 'daily_mark_grant_v1', 'daily_mark_schedule_v_1', ]); +const additiveV15Tables = Object.freeze([ + 'realm_chat_status_v1', + 'realm_chat_channel_v1', + 'realm_chat_message_v1', + 'realm_chat_recent_v1', + 'realm_chat_rate_event_v1', + 'realm_chat_send_receipt_v1', + 'realm_chat_report_v1', +]); const deployedV3Tables = Object.freeze([ ...existingTables, ...additiveV3Tables, @@ -337,6 +351,10 @@ const deployedV14Tables = Object.freeze([ ...deployedV13Tables, ...additiveV14Tables, ]); +const deployedV15Tables = Object.freeze([ + ...deployedV14Tables, + ...additiveV15Tables, +]); const expectedProductTypeRefs = Object.freeze({ allowed_fid: 0, world_tile: 1, @@ -394,6 +412,13 @@ const expectedProductTypeRefs = Object.freeze({ access_request_v1: 53, daily_mark_grant_v1: 54, daily_mark_schedule_v_1: 55, + realm_chat_status_v1: 56, + realm_chat_channel_v1: 57, + realm_chat_message_v1: 58, + realm_chat_recent_v1: 59, + realm_chat_rate_event_v1: 60, + realm_chat_send_receipt_v1: 61, + realm_chat_report_v1: 62, }); const childEnvironmentKeys = Object.freeze([ 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP', @@ -1440,6 +1465,60 @@ function assertAdditiveV14Schema(before, after) { } } +function assertAdditiveV15Schema(before, after) { + for (const name of deployedV14Tables) { + assert.deepEqual(tableSignature(after, name), tableSignature(before, name)); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } + const beforeNames = new Set(before.tables.map(table => table.name)); + const added = after.tables + .map(table => table.name) + .filter(name => !beforeNames.has(name)) + .sort(); + assert.deepEqual(added, [...additiveV15Tables].sort()); + const contracts = { + realm_chat_status_v1: { + access: 'Public', + fields: ['channel_key', 'realm_id', 'policy_version', 'mode', 'recent_limit', 'history_page_limit', 'updated_at'], + }, + realm_chat_channel_v1: { + access: 'Private', + fields: ['channel_key', 'realm_id', 'policy_version', 'mode', 'next_sequence', 'updated_at'], + }, + realm_chat_message_v1: { + access: 'Private', + fields: ['message_id', 'sequence', 'channel_key', 'sender_fid', 'body', 'sent_at', 'visibility', 'moderated_at', 'moderation_code'], + }, + realm_chat_recent_v1: { + access: 'Public', + fields: ['sequence', 'message_id', 'channel_key', 'sender_fid', 'body', 'sent_at', 'visibility'], + }, + realm_chat_rate_event_v1: { + access: 'Private', + fields: ['event_id', 'fid', 'accepted_at_micros', 'body_digest'], + }, + realm_chat_send_receipt_v1: { + access: 'Private', + fields: ['operation_key', 'fid', 'request_key', 'body_digest', 'message_id', 'sequence', 'created_at'], + }, + realm_chat_report_v1: { + access: 'Private', + fields: ['report_ordinal', 'report_key', 'report_id', 'reporter_fid', 'message_id', 'reported_sender_fid', 'message_sequence', 'category', 'details', 'context_first_sequence', 'context_last_sequence', 'created_at', 'status', 'reviewed_at', 'resolution_code'], + }, + }; + for (const [name, contract] of Object.entries(contracts)) { + assert.deepEqual(fieldNames(after, name), contract.fields); + assert.equal(access(after, name), contract.access); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } +} + async function freeLoopbackPort() { return new Promise((resolvePromise, rejectPromise) => { const server = createServer(); @@ -2921,9 +3000,9 @@ async function verifyActualModuleResourceLifecycle(server, database, privateKey, let stage = 'seed'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - // Keep inspection on the complete v14 candidate schema. Reverting to a - // predecessor fixture after Stone is appended would be destructive. - const inspectionArtifactPath = join(additiveV14SchemaFixture, 'dist', 'bundle.js'); + // Keep inspection on the complete v15 candidate schema. Reverting to a + // predecessor fixture after Realm Chat is appended would be destructive. + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -3848,9 +3927,9 @@ async function verifyActualModuleExpeditionLifecycles( let stage = 'seed-world'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - // Reusing the candidate fixture preserves the complete v14 suffix during + // Reusing the candidate fixture preserves the complete v15 suffix during // SQL inspection; publishing any predecessor would request a downgrade. - const inspectionArtifactPath = join(additiveV14SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -5267,7 +5346,7 @@ async function verifyActualModuleWaterLifecycle(server, database, privateKey, ow let stage = 'publish'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV14SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); const useActualModule = async () => { if (activeModule === 'actual') return; @@ -5738,7 +5817,7 @@ async function verifyGenesisWorldExpansionLifecycle( // Wood append. Reverting to an earlier protocol after publishing the // candidate would correctly be rejected as a destructive schema downgrade. const fixtureArtifactPath = join(additiveV8SchemaFixture, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV14SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); await publishBuiltArtifact(server, ownerToken, fixtureArtifactPath, database); @@ -6982,22 +7061,101 @@ async function main() { ); } - // Advance every database to the real v14 candidate so the implementation - // is exercised against the exact v14 table contract without production. + // Freeze the independent v15 Realm Chat suffix before the real candidate. + // All seven tables must append after the exact v14 boundary and start empty. + await publish(server, owner.token, additiveV15SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, actualModuleDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, resourceLifecycleDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, populatedWaterStoneMigrationDatabase); + const emptyV15 = await describe(server, owner.token, emptyDatabase); + const nonemptyV15 = await describe(server, owner.token, nonemptyDatabase); + const actualModuleV15 = await describe(server, owner.token, actualModuleDatabase); + const populatedWaterStoneV15 = await describe( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + ); + assertAdditiveV15Schema(emptyV14, emptyV15); + assertAdditiveV15Schema(nonemptyV14, nonemptyV15); + assertAdditiveV15Schema(actualModuleV14, actualModuleV15); + assertAdditiveV15Schema(populatedWaterStoneV14, populatedWaterStoneV15); + const fixtureV15TableSchemaDigest = canonicalTableSchemaBoundaryDigest( + emptyV15, + deployedV15Tables, + ); + for (const description of [nonemptyV15, actualModuleV15, populatedWaterStoneV15]) { + assert.equal( + canonicalTableSchemaBoundaryDigest(description, deployedV15Tables), + fixtureV15TableSchemaDigest, + ); + } + for (const table of additiveV15Tables) { + assert.equal(await count(server, owner.token, populatedWaterStoneMigrationDatabase, table), 0n); + } + + // A dedicated database proves every v15 table populated, candidate-safe, + // and protected from a v15-to-v14 rollback. + await publish(server, owner.token, additiveV14SchemaFixture, realmChatMigrationDatabase); + const realmChatV14 = await describe(server, owner.token, realmChatMigrationDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, realmChatMigrationDatabase); + const realmChatV15 = await describe(server, owner.token, realmChatMigrationDatabase); + assertAdditiveV15Schema(realmChatV14, realmChatV15); + for (const table of additiveV15Tables) { + assert.equal(await count(server, owner.token, realmChatMigrationDatabase, table), 0n); + } + await callLoopbackReducer( + server, + realmChatMigrationDatabase, + 'fixture_seed_realm_chat_sentinel_v15', + owner.token, + '[]', + 200, + ); + const populatedRealmChatRows = await tableRowDigests( + server, + owner.token, + realmChatMigrationDatabase, + additiveV15Tables, + ); + for (const table of additiveV15Tables) { + assert.equal(await count(server, owner.token, realmChatMigrationDatabase, table), 1n); + } + await publish( + server, + owner.token, + additiveV14SchemaFixture, + realmChatMigrationDatabase, + false, + /break|delete|remove|migration|incompatible|data loss|table/i, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, realmChatMigrationDatabase)), + schemaDigest(realmChatV15), + ); + await publish(server, owner.token, additiveModule, realmChatMigrationDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, realmChatMigrationDatabase); + assert.deepEqual( + await tableRowDigests(server, owner.token, realmChatMigrationDatabase, additiveV15Tables), + populatedRealmChatRows, + ); + + // Advance every database to the real v15 candidate so the implementation + // is exercised against the exact Realm Chat table contract without production. await publish(server, owner.token, additiveModule, emptyDatabase); await publish(server, owner.token, additiveModule, nonemptyDatabase); await publish(server, owner.token, additiveModule, actualModuleDatabase); await publish(server, owner.token, additiveModule, resourceLifecycleDatabase); await publish(server, owner.token, additiveModule, populatedWaterStoneMigrationDatabase); - const populatedWaterStoneCandidateV14 = await describe( + const populatedWaterStoneCandidateV15 = await describe( server, owner.token, populatedWaterStoneMigrationDatabase, ); - for (const name of deployedV14Tables) { + for (const name of deployedV15Tables) { assert.deepEqual( - tableSignature(populatedWaterStoneCandidateV14, name), - tableSignature(emptyV14, name), + tableSignature(populatedWaterStoneCandidateV15, name), + tableSignature(emptyV15, name), ); } await verifyAccessRequestHttpLifecycle( @@ -7005,13 +7163,13 @@ async function main() { populatedWaterStoneMigrationDatabase, privateKey, ); - // Return to the exact auth-neutral v14 schema only for private owner SQL. + // Return to the exact auth-neutral v15 schema only for private owner SQL. // The append-only request row remains while every v12 row digest must stay // byte-for-byte identical to its pre-request baseline. await publish( server, owner.token, - additiveV14SchemaFixture, + additiveV15SchemaFixture, populatedWaterStoneMigrationDatabase, ); assert.deepEqual( @@ -7159,10 +7317,10 @@ async function main() { workerRolloutV11Database, ); assertAdditiveV13Schema(workerRolloutCandidateV12, workerRolloutCandidateV13); - await publishBuiltArtifact( + await publish( server, owner.token, - builtArtifactPath, + additiveV14SchemaFixture, workerRolloutV11Database, ); const workerRolloutCandidateV14 = await describe( @@ -7171,14 +7329,26 @@ async function main() { workerRolloutV11Database, ); assertAdditiveV14Schema(workerRolloutCandidateV13, workerRolloutCandidateV14); + await publishBuiltArtifact( + server, + owner.token, + builtArtifactPath, + workerRolloutV11Database, + ); + const workerRolloutCandidateV15 = await describe( + server, + owner.token, + workerRolloutV11Database, + ); + assertAdditiveV15Schema(workerRolloutCandidateV14, workerRolloutCandidateV15); // The real module rejects the disposable owner identity by design. Swap - // only this database to the schema-identical auth-neutral v14 fixture + // only this database to the schema-identical auth-neutral v15 fixture // before private SQL proves that every v11 row survived and all additive // suffixes remain empty. await publish( server, owner.token, - additiveV14SchemaFixture, + additiveV15SchemaFixture, workerRolloutV11Database, ); assert.deepEqual( @@ -7211,6 +7381,12 @@ async function main() { 0n, ); } + for (const table of additiveV15Tables) { + assert.equal( + await count(server, owner.token, workerRolloutV11Database, table), + 0n, + ); + } await publishBuiltArtifact( server, owner.token, @@ -7237,25 +7413,30 @@ async function main() { const builtArtifactDigest = createHash('sha256') .update(await readFile(builtArtifactPath)) .digest('hex'); - const emptyCandidateV14 = await describe(server, owner.token, emptyDatabase); - const nonemptyCandidateV14 = await describe(server, owner.token, nonemptyDatabase); - const actualCandidateV14 = await describe(server, owner.token, actualModuleDatabase); + const emptyCandidateV15 = await describe(server, owner.token, emptyDatabase); + const nonemptyCandidateV15 = await describe(server, owner.token, nonemptyDatabase); + const actualCandidateV15 = await describe(server, owner.token, actualModuleDatabase); const provenV12TableSchemaDigest = projectedTableSchemaBoundaryDigest( - emptyCandidateV14, + emptyCandidateV15, deployedV12Tables, ); assert.equal(provenV12TableSchemaDigest, fixtureV12TableSchemaDigest); const provenV13TableSchemaDigest = projectedTableSchemaBoundaryDigest( - emptyCandidateV14, + emptyCandidateV15, deployedV13Tables, ); assert.equal(provenV13TableSchemaDigest, fixtureV13TableSchemaDigest); - const provenV14TableSchemaDigest = canonicalTableSchemaBoundaryDigest( - emptyCandidateV14, + const provenV14TableSchemaDigest = projectedTableSchemaBoundaryDigest( + emptyCandidateV15, deployedV14Tables, ); assert.equal(provenV14TableSchemaDigest, fixtureV14TableSchemaDigest); - for (const description of [nonemptyCandidateV14, actualCandidateV14]) { + const provenV15TableSchemaDigest = canonicalTableSchemaBoundaryDigest( + emptyCandidateV15, + deployedV15Tables, + ); + assert.equal(provenV15TableSchemaDigest, fixtureV15TableSchemaDigest); + for (const description of [nonemptyCandidateV15, actualCandidateV15]) { assert.equal( projectedTableSchemaBoundaryDigest(description, deployedV12Tables), provenV12TableSchemaDigest, @@ -7265,28 +7446,32 @@ async function main() { provenV13TableSchemaDigest, ); assert.equal( - canonicalTableSchemaBoundaryDigest(description, deployedV14Tables), + projectedTableSchemaBoundaryDigest(description, deployedV14Tables), provenV14TableSchemaDigest, ); + assert.equal( + canonicalTableSchemaBoundaryDigest(description, deployedV15Tables), + provenV15TableSchemaDigest, + ); } for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(actualCandidateV14, name), + tableSignature(actualCandidateV15, name), tableSignature(emptyV12, name), ); assert.deepEqual( - tableSignature(nonemptyCandidateV14, name), + tableSignature(nonemptyCandidateV15, name), tableSignature(nonemptyV12, name), ); assert.deepEqual( - tableSignature(actualCandidateV14, name), + tableSignature(actualCandidateV15, name), tableSignature(actualModuleV12, name), ); } for (const description of [ - emptyCandidateV14, - nonemptyCandidateV14, - actualCandidateV14, + emptyCandidateV15, + nonemptyCandidateV15, + actualCandidateV15, ]) { assert.equal(access(description, 'access_request_v1'), 'Private'); assert.deepEqual(fieldNames(description, 'access_request_v1'), [ @@ -7311,12 +7496,12 @@ async function main() { ]); } // The candidate's on-connect policy intentionally rejects the disposable - // owner identity. Reuse the table-identical, auth-neutral v14 fixture before + // owner identity. Reuse the table-identical, auth-neutral v15 fixture before // owner SQL reads and never downgrade the schema suffix. - await publish(server, owner.token, additiveV14SchemaFixture, emptyDatabase); - await publish(server, owner.token, additiveV14SchemaFixture, nonemptyDatabase); - await publish(server, owner.token, additiveV14SchemaFixture, actualModuleDatabase); - // SQL preservation reads remain on the complete v14 candidate. No reducer + await publish(server, owner.token, additiveV15SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, actualModuleDatabase); + // SQL preservation reads remain on the complete v15 candidate. No reducer // is invoked by these owner-only queries. for (const [database, beforeRows] of [ [emptyDatabase, emptyV7Rows], @@ -7329,7 +7514,7 @@ async function main() { ); } - const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV14); + const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV15); await publishBuiltArtifact( server, owner.token, @@ -7340,10 +7525,10 @@ async function main() { schemaDigest(await describe(server, owner.token, nonemptyDatabase)), idempotentSchemaBefore, ); - await publish(server, owner.token, additiveV14SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, nonemptyDatabase); // The actual module correctly rejects the disposable local identity at its - // on-connect boundary; owner SQL still reads the unchanged v14 rows. + // on-connect boundary; owner SQL still reads the unchanged v15 rows. assert.equal(await count(server, owner.token, emptyDatabase, 'player'), 0n); assert.equal(await count(server, owner.token, emptyDatabase, 'player_v2'), 0n); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 0); @@ -7371,6 +7556,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, database, table), 0n); } @@ -7401,7 +7587,7 @@ async function main() { )), actualModuleWorldBefore); // Identity columns reject arbitrary SQL literals after the candidate's - // issuer boundary is active. The auth-neutral v14 fixture inserts the + // issuer boundary is active. The auth-neutral v15 fixture inserts the // caller's verified sender identity through a disposable reducer instead. await callLoopbackReducer( server, @@ -7426,10 +7612,11 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } - const populatedV14SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); + const populatedV15SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); await callLoopbackReducer( server, @@ -7451,7 +7638,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -7467,6 +7654,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -7480,7 +7668,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -7496,6 +7684,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -7509,7 +7698,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -7521,7 +7710,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -7533,7 +7722,21 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, + ); + // The v15 boundary must refuse its immediate predecessor before any + // Realm Chat table can be removed. + await publish( + server, + owner.token, + additiveV14SchemaFixture, + emptyDatabase, + false, + /break|delete|remove|migration|incompatible|data loss|table/i, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, emptyDatabase)), + populatedV15SchemaDigest, ); // The v14 boundary must refuse its immediate predecessor before either // private daily-Mark table can be removed. @@ -7547,7 +7750,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); // The v13 boundary also remains protected from its own predecessor. await publish( @@ -7560,7 +7763,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); // Older predecessors must also remain unable to remove Worker or Water // state. @@ -7574,7 +7777,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -7586,7 +7789,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -7598,7 +7801,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); // Older fixture rollbacks remain refused as well. await publish( @@ -7611,17 +7814,17 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish(server, owner.token, additiveModule, emptyDatabase); - assertAdditiveV14Schema( - emptyV13, + assertAdditiveV15Schema( + emptyV14, await describe(server, owner.token, emptyDatabase), ); // Reuse the table-identical auth-neutral fixture for the final bounded // identity assertion; the candidate itself deliberately rejects the // disposable owner issuer before any private identity SQL can run. - await publish(server, owner.token, additiveV14SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, emptyDatabase); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); for (const table of [ @@ -7636,6 +7839,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -7661,10 +7865,10 @@ async function main() { + 'identity-safe generic worker readiness, roster, assignment, occupation, bounded receipt, and private schedule tables appended at exact refs 47-52, ' + 'private access-request intent and authoritative request timestamp appended at exact ref 53, ' + 'private exactly-once daily Mark receipts and identity-free cadence appended at exact refs 54-55, ' - + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v14, ' + + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v15, ' + 'every v12 table was populated and retained through the real candidate, the v13 request suffix survived populated, ' + 'both v14 tables started empty, fixture republish remained idempotent, ' - + 'and the complete state was protected from v14-to-v13 and older downgrades, ' + + 'and the complete state was protected from v15-to-v14, v14-to-v13, and older downgrades, ' + 'exact resolver HTTP lifecycle enforced without mutation, ' + `atomic 1,261-to-10,000 world expansion proved in ${worldExpansionDurationMilliseconds}ms with an idempotent retry, ` + `actual Water administration exercised with ${waterLifecycleProof}, ` @@ -7677,12 +7881,13 @@ async function main() { + 'presentation-independent founder monitoring and bootstrap, ' + 'legacy first-time admission rejection and complete-graph re-enable preservation, ' + 'and guarded backfill rejection/idempotence held, ' - + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v14, ' + + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v15, ' + 'and guarded v13/v12/v11/v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', v11TableSchemaDigest: provenV11TableSchemaDigest, v12TableSchemaDigest: provenV12TableSchemaDigest, v13TableSchemaDigest: provenV13TableSchemaDigest, v14TableSchemaDigest: provenV14TableSchemaDigest, + v15TableSchemaDigest: provenV15TableSchemaDigest, artifactDigest: builtArtifactDigest, })); } finally { diff --git a/spacetimedb/README.md b/spacetimedb/README.md index 36d90650..0e9a5b70 100644 --- a/spacetimedb/README.md +++ b/spacetimedb/README.md @@ -12,11 +12,12 @@ balance, advance a timer, or decide an expedition outcome. | Browser/backend wire protocol | 3 | | Player authentication contract | 2 | | Genesis world generation | 3 | -| Append-only schema generation | 14 (daily Marks suffix) | +| Append-only schema generation | 15 (review-only Realm Chat suffix) | | Alpha 0.3.12 suffix | Water refs 37–40; Stone refs 41–45 | | Generic worker suffix | refs 47–52; active | | Access-request suffix | ref 53; active | | Daily Marks suffix | private refs 54–55; activation is separate | +| Realm Chat suffix | refs 56–62; review-only, unseeded, and unpublishable | Deployed tables retain their original declaration order and shape. Later features append new tables; they do not rename or delete existing data. The @@ -61,6 +62,8 @@ Public subscriptions contain only shared-world presentation: - active four-worker roster and generic node-lease projections; the public rows contain no FID, cargo, accrual, balance, request, or auth data; - public Community Marks projection only when its policy permits it. +- if separately activated in a future release, Realm Chat status and only its + newest bounded 128-message projection. Private tables contain admission, ownership, unclaimed-slot decisions, resource and Marks accounts, agreement evidence, daily-grant receipts, operator audit, @@ -68,6 +71,11 @@ expedition state, retry receipts, and balances. Retired compatibility tables remain private and frozen to preserve the deployed append-only schema; current authority paths do not write or interpret them. +Realm Chat's channel sequence, message archive, rate ledger, send receipts, and +reports are also private. They are absent from the player binding and ordinary +Realm snapshot; the browser receives only the bounded public pair plus +caller-gated send, report, and indexed history operations. + The pinned SDK requires scheduled expedition rows to be public. Those rows are therefore deliberately minimal: schedule/stage identifiers, site, origin castle, and an already-public lifecycle timestamp. They contain no FID, @@ -132,6 +140,24 @@ redirect a grant. Disabled admission pauses future grants without deleting the existing balance. Marks have no transfer, redemption, purchase, airdrop, or financial-reward loop and require no wallet or blockchain activity. +## Review-only Realm Chat + +Protocol V15 appends a server-authoritative persistent Realm Chat foundation. +The module derives sender FID, channel, UUIDv7 message identity, sequence, +timestamp, visibility, rate decisions, and report context. Sends are +exactly-once across ambiguous client retries; recent public state is capped at +128 rows; history reads at most 50 exact sequence keys; and private reports +preserve a context range that cannot expand after submission. + +Public moderation uses body-free tombstones while private admin evidence keeps +the original record. Admin status verifies the entire bounded public projection +against the private archive. Operational mutations are admin-only and audited. + +This code does not authorize use. Server activation is not compiled, the +client entry flag is false, and the production publisher rejects a V15 +mutation. See the [implementation and research record](../docs/design/realm-chat-v1-implementation.md) +and [controlling contract](../docs/design/realm-chat-v1-contract.md). + ## Local development From this directory: diff --git a/spacetimedb/migration-fixtures/additive-v15-schema/package.json b/spacetimedb/migration-fixtures/additive-v15-schema/package.json new file mode 100644 index 00000000..f430c4fe --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v15-schema/package.json @@ -0,0 +1,13 @@ +{ + "name": "warpkeep-additive-v15-schema-migration-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "Apache-2.0", + "dependencies": { + "spacetimedb": "2.6.1" + }, + "devDependencies": { + "typescript": "5.6.3" + } +} diff --git a/spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts new file mode 100644 index 00000000..299b93b3 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts @@ -0,0 +1,750 @@ +import { schema, table, t } from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import { + goldExpeditionErrorCode, + runGoldExpeditionSchedule, +} from '../../../src/goldExpeditionAuthority'; +import { + foodExpeditionErrorCode, + runFoodExpeditionSchedule, +} from '../../../src/foodExpeditionAuthority'; +import { + woodExpeditionErrorCode, + runWoodExpeditionSchedule, +} from '../../../src/woodExpeditionAuthority'; +import { + stoneExpeditionErrorCode, + runStoneExpeditionSchedule, +} from '../../../src/stoneExpeditionAuthority'; + +const allowedFid = table({ name: 'allowed_fid' }, { + fid: t.u64().primaryKey(), enabled: t.bool(), authEpoch: t.u32(), + invitedAt: t.timestamp(), invitedBy: t.string(), note: t.string(), +}); +const worldTile = table({ name: 'world_tile', public: true }, { + key: t.string().primaryKey(), q: t.i32(), r: t.i32(), biome: t.string(), + terrainSeed: t.u32(), occupantCastleId: t.option(t.u64()), +}); +const player = table({ name: 'player', public: true }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), username: t.option(t.string()), + displayName: t.option(t.string()), pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const castle = table({ name: 'castle', public: true }, { + castleId: t.u64().primaryKey().autoInc(), ownerFid: t.u64().unique(), tileKey: t.string().unique(), + q: t.i32(), r: t.i32(), level: t.i32(), name: t.string(), createdAt: t.timestamp(), +}); +const adminAudit = table({ name: 'admin_audit' }, { + id: t.u64().primaryKey().autoInc(), action: t.string(), targetFid: t.option(t.u64()), + actorSubject: t.string(), createdAt: t.timestamp(), note: t.string(), +}); +const playerV2 = table({ name: 'player_v2', public: true }, { + fid: t.u64().primaryKey(), username: t.option(t.string()), displayName: t.option(t.string()), + pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const playerOwnershipV2 = table({ name: 'player_ownership_v2' }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), +}); +const realmV1 = table({ name: 'realm_v1', public: true }, { + realmId: t.string().primaryKey(), publicName: t.string(), seedName: t.string(), numericSeed: t.u32(), + generationVersion: t.u32(), authoritativeRadius: t.u32(), renderRadius: t.u32(), playerCapacity: t.u32(), + active: t.bool(), createdAt: t.timestamp(), +}); +const worldTileMetaV1 = table({ + name: 'world_tile_meta_v1', public: true, + indexes: [{ accessor: 'byRealmAndRing', algorithm: 'btree', columns: ['realmId', 'ring'] as const }] as const, +}, { + tileKey: t.string().primaryKey(), realmId: t.string().index(), s: t.i32(), ring: t.u32(), sector: t.u32(), + terrainKind: t.string(), passable: t.bool(), movementCost: t.u32(), staticContentKind: t.string(), generationVersion: t.u32(), +}); +const castleSlotV1 = table({ name: 'castle_slot_v1', public: true }, { + slotId: t.u32().primaryKey(), realmId: t.string().index(), tileKey: t.string().unique(), q: t.i32(), r: t.i32(), generationVersion: t.u32(), +}); +const castleSlotClaimV1 = table({ name: 'castle_slot_claim_v1' }, { + slotId: t.u32().primaryKey(), ownerFid: t.u64().unique(), castleId: t.u64().unique(), claimedAt: t.timestamp(), generationVersion: t.u32(), +}); +const realmProfileV1 = table({ name: 'realm_profile_v1', public: true }, { + fid: t.u64().primaryKey(), canonicalUsername: t.option(t.string()), displayName: t.option(t.string()), pfpUrl: t.option(t.string()), publicBio: t.option(t.string()), + admittedAt: t.timestamp(), firstAuthenticatedAt: t.option(t.timestamp()), profileUpdatedAt: t.timestamp(), publicStatus: t.string(), communityStatsVisible: t.bool(), + totalSnapBurnedMicros: t.option(t.u128()), marksEarnedMicros: t.option(t.u128()), marksSpentMicros: t.option(t.u128()), marksBalanceMicros: t.option(t.u128()), marksPolicyVersion: t.option(t.string()), +}); +const markAccountV1 = table({ name: 'mark_account_v1' }, { + fid: t.u64().primaryKey(), totalSnapBurnedMicros: t.u128(), earnedMicros: t.u128(), spentMicros: t.u128(), balanceMicros: t.u128(), policyVersion: t.string(), updatedAt: t.timestamp(), +}); +const snapBurnCreditV1 = table({ name: 'snap_burn_credit_v1' }, { + eventKey: t.string().primaryKey(), batchId: t.string().index(), chainId: t.u32(), tokenContract: t.string(), transactionHash: t.string(), logIndex: t.u32(), burnReference: t.string().unique(), burnMethod: t.string(), senderAddress: t.string(), blockNumber: t.u64(), blockHash: t.string(), amountMicros: t.u128(), attributedFid: t.u64().index(), attributionPolicyVersion: t.string(), contractCodeHash: t.string(), creditedAt: t.timestamp(), +}); +const fidWalletAttributionV1 = table({ + name: 'fid_wallet_attribution_v1', indexes: [{ accessor: 'bySnapshotAndAddress', algorithm: 'btree', columns: ['snapshotGeneration', 'address'] as const }] as const, +}, { + snapshotAttributionKey: t.string().primaryKey(), attributionKey: t.string(), snapshotGeneration: t.u64(), fid: t.u64().index(), address: t.string(), addressType: t.string(), source: t.string(), snapshotAt: t.timestamp(), attributionPolicyVersion: t.string(), active: t.bool(), +}); +const walletAttributionSnapshotV1 = table({ name: 'wallet_attribution_snapshot_v1' }, { + snapshotKey: t.string().primaryKey(), generation: t.u64(), snapshotId: t.string(), policyVersion: t.string(), attributionCount: t.u32(), snapshotAt: t.timestamp(), +}); +const snapScanCursorV1 = table({ name: 'snap_scan_cursor_v1' }, { + cursorKey: t.string().primaryKey(), chainId: t.u32(), tokenContract: t.string(), policyVersion: t.string(), deploymentStartBlock: t.u64(), lastFinalizedBlock: t.u64(), lastFinalizedBlockHash: t.string(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), scannedAt: t.timestamp(), +}); +const snapScanBatchV1 = table({ + name: 'snap_scan_batch_v1', indexes: [{ accessor: 'byCursorAndStatus', algorithm: 'btree', columns: ['cursorKey', 'status'] as const }] as const, +}, { + batchId: t.string().primaryKey(), cursorKey: t.string(), status: t.string(), previousFinalizedBlock: t.u64(), previousFinalizedBlockHash: t.string(), throughFinalizedBlock: t.u64(), throughFinalizedBlockHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), walletAttributionCount: t.u32(), expectedCredits: t.u32(), expectedMicros: t.u128(), appliedCredits: t.u32(), appliedMicros: t.u128(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), startedAt: t.timestamp(), finalizedAt: t.option(t.timestamp()), +}); +const alphaTermsAcceptanceV1 = table({ name: 'alpha_terms_acceptance_v1' }, { + acceptanceKey: t.string().primaryKey(), fid: t.u64().index(), termsVersion: t.string(), acceptedAt: t.timestamp(), +}); +const resourceAccountV1 = table({ name: 'resource_account_v1' }, { + fid: t.u64().primaryKey(), castleId: t.u64().unique(), realmId: t.string().index(), food: t.u64(), wood: t.u64(), stone: t.u64(), gold: t.u64(), settledThroughMicros: t.u64(), revision: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); + +const goldSiteV1 = table({ name: 'gold_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const goldNodeOccupationV1 = table({ name: 'gold_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const goldExpeditionV1 = table({ name: 'gold_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedGold: t.u64(), creditedGold: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const goldExpeditionIdempotencyV1 = table({ name: 'gold_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const goldExpeditionScheduleV1 = table({ name: 'gold_expedition_schedule_v_1', public: true, scheduled: (): any => runGoldExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmForestLayoutV1 = table({ name: 'realm_forest_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), layoutDigest: t.string(), assetCatalogDigest: t.string(), instanceCount: t.u32(), seededAt: t.timestamp() }); +const realmForestInstanceV1 = table({ name: 'realm_forest_instance_v1', public: true }, { treeId: t.string().primaryKey(), realmId: t.string().index(), tileKey: t.string(), q: t.i32(), r: t.i32(), localXMicrounits: t.i64(), localZMicrounits: t.i64(), worldXMicrounits: t.i64(), worldZMicrounits: t.i64(), rotationMilliDegrees: t.u32(), scaleBasisPoints: t.u32(), speciesId: t.string(), habitat: t.string(), layoutVersion: t.u32() }); + +const foodSiteV1 = table({ name: 'food_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const foodNodeOccupationV1 = table({ name: 'food_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const foodExpeditionV1 = table({ name: 'food_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedFood: t.u64(), creditedFood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const foodExpeditionIdempotencyV1 = table({ name: 'food_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const foodExpeditionScheduleV1 = table({ name: 'food_expedition_schedule_v_1', public: true, scheduled: (): any => runFoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const woodSiteV1 = table({ name: 'wood_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const woodNodeOccupationV1 = table({ name: 'wood_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const woodExpeditionV1 = table({ name: 'wood_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedWood: t.u64(), creditedWood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const woodExpeditionIdempotencyV1 = table({ name: 'wood_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const woodExpeditionScheduleV1 = table({ name: 'wood_expedition_schedule_v_1', public: true, scheduled: (): any => runWoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterLayoutV1 = table({ name: 'realm_water_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), generationVersion: t.u32(), canonicalLandCellCount: t.u32(), oceanCellCount: t.u32(), lakeCellCount: t.u32(), lakeBodyCount: t.u32(), riverCount: t.u32(), riverCellCount: t.u32(), seaLevelMilli: t.i32(), seaLevelPolicyVersion: t.string(), fogStartDepthCells: t.u32(), fogFullDepthCells: t.u32(), hiddenBufferCells: t.u32(), layoutDigest: t.string(), sourceCommit: t.string(), activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()) }); +const realmWaterBodyV1 = table({ name: 'realm_water_body_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }] as const }, { bodyId: t.string().primaryKey(), realmId: t.string().index(), regime: t.string(), cellCount: t.u32(), sourceCellKey: t.string(), mouthCellKey: t.string(), surfaceLevelMilli: t.i32(), flowDirectionXQ15: t.i32(), flowDirectionZQ15: t.i32(), wavePreset: t.string(), ordinal: t.u32(), seed: t.u32(), generationVersion: t.u32(), layoutVersion: t.u32() }); +const realmWaterCellV1 = table({ name: 'realm_water_cell_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }, { accessor: 'byBody', algorithm: 'btree', columns: ['bodyId'] as const }] as const }, { cellKey: t.string().primaryKey(), realmId: t.string().index(), q: t.i32(), r: t.i32(), regime: t.string(), bodyId: t.string(), depthCells: t.u32(), elevationMilli: t.i32(), surfaceLevelMilli: t.i32(), ring: t.u32(), s: t.i32(), underlyingTileKey: t.option(t.string()), riverOrdinal: t.option(t.u32()), riverOrder: t.option(t.u32()), downstreamWaterCellKey: t.option(t.string()), flowAccumulation: t.u32(), depthClass: t.u32(), oceanDepth: t.u32(), bankSeed: t.u32(), generationVersion: t.u32(), fogBand: t.string(), layoutVersion: t.u32() }); +const realmEnvironmentV1 = table({ name: 'realm_environment_v1', public: true }, { realmId: t.string().primaryKey(), environmentEpoch: t.u64(), waterLayoutVersion: t.u32(), seaLevelMilli: t.i32(), sunDirectionXMicro: t.i32(), sunDirectionYMicro: t.i32(), sunDirectionZMicro: t.i32(), updatedAt: t.timestamp() }); + +const stoneSiteV1 = table({ name: 'stone_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const stoneNodeOccupationV1 = table({ name: 'stone_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const stoneExpeditionV1 = table({ name: 'stone_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedStone: t.u64(), creditedStone: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const stoneExpeditionIdempotencyV1 = table({ name: 'stone_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const stoneExpeditionScheduleV1 = table({ name: 'stone_expedition_schedule_v_1', public: true, scheduled: (): any => runStoneExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterRevisionV1 = table({ name: 'realm_water_revision_v1', public: true }, { + realmId: t.string().primaryKey(), revisionVersion: t.u32(), policyVersion: t.string(), + baseLayoutVersion: t.u32(), baseLayoutDigest: t.string(), oceanBodyCount: t.u32(), + riverBodyCount: t.u32(), enabledBodyCount: t.u32(), oceanCellCount: t.u32(), + riverCellCount: t.u32(), enabledCellCount: t.u32(), lakeBodyCount: t.u32(), + lakeCellCount: t.u32(), riverWidthCells: t.u32(), navigationFogBoundaryDepthCells: t.u32(), + hiddenBufferCells: t.u32(), revisionDigest: t.string(), sourceCommit: t.string(), + activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()), +}); + +/** v12 generic-worker suffix. Public rows contain only identity/lifecycle data. */ +const realmWorkerSystemV1 = table({ name: 'realm_worker_system_v1', public: true }, { + realmId: t.string().primaryKey(), policyVersion: t.string(), workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), expectedWorkerCount: t.u32(), rosterDigest: t.string(), + mode: t.string(), legacyDrainRequired: t.bool(), createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), +}); +const castleWorkerV1 = table({ + name: 'castle_worker_v1', public: true, + indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const, +}, { + workerId: t.string().primaryKey(), originCastleId: t.u64(), ordinal: t.u32(), status: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), arrivesAtMicros: t.option(t.u64()), gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.option(t.u64()), routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), timelineRevision: t.u32(), revision: t.u64(), +}); +const workerAssignmentV1 = table({ + name: 'worker_assignment_v1', + indexes: [ + { accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }, + { accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }, + ] as const, +}, { + assignmentId: t.string().primaryKey(), workerId: t.string().unique(), fid: t.u64(), + originCastleId: t.u64(), resourceKind: t.string(), siteId: t.string().index(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.u64(), routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), settledThroughMicros: t.u64(), accruedAmount: t.u64(), + materializedAmount: t.u64(), timelineRevision: t.u32(), policyVersion: t.string(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const workerNodeOccupationV1 = table({ + name: 'worker_node_occupation_v1', public: true, + indexes: [ + { accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, +}, { + nodeKey: t.string().primaryKey(), resourceKind: t.string(), siteId: t.string(), workerId: t.string(), + workerOrdinal: t.u32(), originCastleId: t.u64(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), timelineRevision: t.u32(), +}); +const workerCommandIdempotencyV1 = table({ + name: 'worker_command_idempotency_v1', + indexes: [{ accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }] as const, +}, { + requestKey: t.string().primaryKey(), fid: t.u64(), workerId: t.option(t.string()), commandKind: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), assignmentId: t.option(t.string()), + resultRevision: t.u64(), createdAt: t.timestamp(), +}); +const workerAssignmentScheduleV1 = table({ + name: 'worker_assignment_schedule_v_1', + indexes: [ + { accessor: 'byAssignment', algorithm: 'btree', columns: ['assignmentId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, + scheduled: (): any => runWorkerAssignmentScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), assignmentId: t.string(), + workerId: t.string(), timelineRevision: t.u32(), stage: t.string(), +}); + +/** v13 private, append-only expression of interest in manual admission. */ +const accessRequestV1 = table({ name: 'access_request_v1' }, { + fid: t.u64().primaryKey(), requestCycle: t.u64(), requestedAt: t.timestamp(), +}); + +/** v14 private, exactly-once admitted-player UTC-day Mark receipt. */ +const dailyMarkGrantV1 = table({ name: 'daily_mark_grant_v1' }, { + grantKey: t.string().primaryKey(), fid: t.u64().index(), utcDay: t.u64().index(), + amountMicros: t.u128(), policyVersion: t.string(), grantedAt: t.timestamp(), +}); + +/** v14 identity-free, private scheduler singleton. */ +const dailyMarkScheduleV1 = table({ + name: 'daily_mark_schedule_v_1', + scheduled: (): any => runDailyMarkScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), + policyVersion: t.string().unique(), +}); + +/** v15 public, identity-free Realm Chat readiness projection. */ +const realmChatStatusV1 = table({ name: 'realm_chat_status_v1', public: true }, { + channelKey: t.string().primaryKey(), realmId: t.string().index(), policyVersion: t.string(), + mode: t.string(), recentLimit: t.u32(), historyPageLimit: t.u32(), updatedAt: t.timestamp(), +}); + +/** v15 private channel authority and monotonic sequence cursor. */ +const realmChatChannelV1 = table({ name: 'realm_chat_channel_v1' }, { + channelKey: t.string().primaryKey(), realmId: t.string().unique(), policyVersion: t.string(), + mode: t.string(), nextSequence: t.u64(), updatedAt: t.timestamp(), +}); + +/** v15 private permanent message archive. */ +const realmChatMessageV1 = table({ + name: 'realm_chat_message_v1', + indexes: [{ accessor: 'byChannelAndSequence', algorithm: 'btree', columns: ['channelKey', 'sequence'] as const }] as const, +}, { + messageId: t.string().primaryKey(), sequence: t.u64().unique(), channelKey: t.string(), + senderFid: t.u64().index(), body: t.string(), sentAt: t.timestamp(), visibility: t.string(), + moderatedAt: t.option(t.timestamp()), moderationCode: t.option(t.string()), +}); + +/** v15 bounded public recent-message projection. */ +const realmChatRecentV1 = table({ name: 'realm_chat_recent_v1', public: true }, { + sequence: t.u64().primaryKey(), messageId: t.string().unique(), channelKey: t.string().index(), + senderFid: t.u64().index(), body: t.string(), sentAt: t.timestamp(), visibility: t.string(), +}); + +/** v15 private rolling rate ledger. */ +const realmChatRateEventV1 = table({ name: 'realm_chat_rate_event_v1' }, { + eventId: t.string().primaryKey(), fid: t.u64().index(), acceptedAtMicros: t.u64(), bodyDigest: t.string(), +}); + +/** v15 private exactly-once send receipts. */ +const realmChatSendReceiptV1 = table({ name: 'realm_chat_send_receipt_v1' }, { + operationKey: t.string().primaryKey(), fid: t.u64().index(), requestKey: t.string(), + bodyDigest: t.string(), messageId: t.string().unique(), sequence: t.u64().unique(), createdAt: t.timestamp(), +}); + +/** v15 private one-reporter/one-message evidence. */ +const realmChatReportV1 = table({ name: 'realm_chat_report_v1' }, { + reportOrdinal: t.u64().primaryKey().autoInc(), reportKey: t.string().unique(), + reportId: t.string().unique(), reporterFid: t.u64().index(), messageId: t.string().index(), + reportedSenderFid: t.u64(), messageSequence: t.u64(), category: t.string(), details: t.string(), + contextFirstSequence: t.u64(), contextLastSequence: t.u64(), createdAt: t.timestamp(), + status: t.string(), reviewedAt: t.option(t.timestamp()), resolutionCode: t.option(t.string()), +}); + +const db = schema({ + allowedFid, worldTile, player, castle, adminAudit, playerV2, playerOwnershipV2, + realmV1, worldTileMetaV1, castleSlotV1, castleSlotClaimV1, realmProfileV1, markAccountV1, + snapBurnCreditV1, fidWalletAttributionV1, walletAttributionSnapshotV1, snapScanCursorV1, + snapScanBatchV1, alphaTermsAcceptanceV1, resourceAccountV1, goldSiteV1, goldNodeOccupationV1, + goldExpeditionV1, goldExpeditionIdempotencyV1, goldExpeditionScheduleV1, realmForestLayoutV1, + realmForestInstanceV1, foodSiteV1, foodNodeOccupationV1, foodExpeditionV1, + foodExpeditionIdempotencyV1, foodExpeditionScheduleV1, woodSiteV1, woodNodeOccupationV1, + woodExpeditionV1, woodExpeditionIdempotencyV1, woodExpeditionScheduleV1, realmWaterLayoutV1, + realmWaterBodyV1, realmWaterCellV1, realmEnvironmentV1, stoneSiteV1, + stoneNodeOccupationV1, stoneExpeditionV1, stoneExpeditionIdempotencyV1, + stoneExpeditionScheduleV1, realmWaterRevisionV1, realmWorkerSystemV1, castleWorkerV1, + workerAssignmentV1, workerNodeOccupationV1, workerCommandIdempotencyV1, workerAssignmentScheduleV1, + accessRequestV1, dailyMarkGrantV1, dailyMarkScheduleV1, + realmChatStatusV1, realmChatChannelV1, realmChatMessageV1, realmChatRecentV1, + realmChatRateEventV1, realmChatSendReceiptV1, realmChatReportV1, +}); + +export const runWorkerAssignmentScheduleV1 = db.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + () => {}, +); + +/** Scheduler-only v14 wire; the schema fixture deliberately performs no grant. */ +export const runDailyMarkScheduleV1 = db.reducer( + { name: 'run_daily_mark_schedule_v_1' }, + { arg: dailyMarkScheduleV1.rowType }, + () => {}, +); + +/** One typed row per v15 table for populated rollback and preservation proof. */ +export const fixtureSeedRealmChatSentinelV15 = db.reducer( + { name: 'fixture_seed_realm_chat_sentinel_v15' }, + ctx => { + if ( + ctx.db.realmChatStatusV1.count() !== 0n + || ctx.db.realmChatChannelV1.count() !== 0n + || ctx.db.realmChatMessageV1.count() !== 0n + || ctx.db.realmChatRecentV1.count() !== 0n + || ctx.db.realmChatRateEventV1.count() !== 0n + || ctx.db.realmChatSendReceiptV1.count() !== 0n + || ctx.db.realmChatReportV1.count() !== 0n + ) throw new Error('FIXTURE_REALM_CHAT_NOT_EMPTY'); + const channelKey = 'realm:genesis-001'; + const messageId = '018f7b44-5f2f-7c54-8c0d-3f521d46b193'; + ctx.db.realmChatStatusV1.insert({ + channelKey, realmId: 'HEGEMONY_GENESIS_001', policyVersion: 'migration-chat-v1', + mode: 'staged', recentLimit: 128, historyPageLimit: 50, updatedAt: ctx.timestamp, + }); + ctx.db.realmChatChannelV1.insert({ + channelKey, realmId: 'HEGEMONY_GENESIS_001', policyVersion: 'migration-chat-v1', + mode: 'staged', nextSequence: 2n, updatedAt: ctx.timestamp, + }); + ctx.db.realmChatMessageV1.insert({ + messageId, sequence: 1n, channelKey, senderFid: 991_301n, body: 'migration sentinel', + sentAt: ctx.timestamp, visibility: 'visible', moderatedAt: undefined, moderationCode: undefined, + }); + ctx.db.realmChatRecentV1.insert({ + sequence: 1n, messageId, channelKey, senderFid: 991_301n, body: 'migration sentinel', + sentAt: ctx.timestamp, visibility: 'visible', + }); + ctx.db.realmChatRateEventV1.insert({ + eventId: messageId, fid: 991_301n, + acceptedAtMicros: ctx.timestamp.microsSinceUnixEpoch, bodyDigest: '0000000000000000', + }); + ctx.db.realmChatSendReceiptV1.insert({ + operationKey: '991301:018f7b44-5f2f-7c54-8c0d-3f521d46b194', fid: 991_301n, + requestKey: '018f7b44-5f2f-7c54-8c0d-3f521d46b194', bodyDigest: '0000000000000000', + messageId, sequence: 1n, createdAt: ctx.timestamp, + }); + ctx.db.realmChatReportV1.insert({ + reportOrdinal: 0n, reportKey: `991302:${messageId}`, + reportId: '018f7b44-5f2f-7c54-8c0d-3f521d46b195', reporterFid: 991_302n, + messageId, reportedSenderFid: 991_301n, messageSequence: 1n, + category: 'other', details: 'migration sentinel', contextFirstSequence: 1n, + contextLastSequence: 11n, createdAt: ctx.timestamp, status: 'pending', + reviewedAt: undefined, resolutionCode: undefined, + }); + }, +); + +/** Retain the v13 populated-suffix fixture reducer across the v14 append. */ +export const fixtureSeedAccessRequestSentinelV13 = db.reducer( + { name: 'fixture_seed_access_request_sentinel_v13' }, + ctx => { + if (ctx.db.accessRequestV1.count() !== 0n) { + throw new Error('FIXTURE_ACCESS_REQUEST_NOT_EMPTY'); + } + ctx.db.accessRequestV1.insert({ + fid: 991_201n, + requestCycle: 1n, + requestedAt: ctx.timestamp, + }); + }, +); + +export const runGoldExpeditionScheduleV1 = db.reducer( + { name: 'run_gold_expedition_schedule_v_1' }, + { arg: goldExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runGoldExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = goldExpeditionErrorCode(error); throw new SenderError(code ?? 'GOLD_SCHEDULE_ERROR'); } + }, +); +export const runFoodExpeditionScheduleV1 = db.reducer( + { name: 'run_food_expedition_schedule_v_1' }, + { arg: foodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runFoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = foodExpeditionErrorCode(error); throw new SenderError(code ?? 'FOOD_SCHEDULE_ERROR'); } + }, +); +export const runWoodExpeditionScheduleV1 = db.reducer( + { name: 'run_wood_expedition_schedule_v_1' }, + { arg: woodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runWoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = woodExpeditionErrorCode(error); throw new SenderError(code ?? 'WOOD_SCHEDULE_ERROR'); } + }, +); +export const runStoneExpeditionScheduleV1 = db.reducer( + { name: 'run_stone_expedition_schedule_v_1' }, + { arg: stoneExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runStoneExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = stoneExpeditionErrorCode(error); throw new SenderError(code ?? 'STONE_SCHEDULE_ERROR'); } + }, +); + +/** Auth-neutral identity fixture; SQL identity literals are issuer-bound. */ +export const fixtureInsertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_insert_player_ownership_v9' }, + { fid: t.u64() }, + (ctx, { fid }) => { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_EXISTS'); + ctx.db.playerOwnershipV2.insert({ fid, identity: ctx.sender }); + }, +); + +/** Bounded identity-row assertion; SQL cannot read identity columns across issuers. */ +export const fixtureAssertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_assert_player_ownership_v9' }, + { fid: t.u64(), expectedCount: t.u64() }, + (ctx, { fid, expectedCount }) => { + if (ctx.db.playerOwnershipV2.count() !== expectedCount) throw new Error('FIXTURE_OWNERSHIP_COUNT_INVALID'); + if (expectedCount === 0n) { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_UNEXPECTED'); + return; + } + if (expectedCount !== 1n || ctx.db.playerOwnershipV2.fid.find(fid) === null) { + throw new Error('FIXTURE_OWNERSHIP_ROW_INVALID'); + } + }, +); + +/** Preserve the v9 Water sentinel wire unchanged in the v10 fixture. */ +export const fixtureSeedWaterSentinelV9 = db.reducer( + { name: 'fixture_seed_water_sentinel_v9' }, + ctx => { + if ( + ctx.db.realmWaterLayoutV1.count() !== 0n + || ctx.db.realmWaterBodyV1.count() !== 0n + || ctx.db.realmWaterCellV1.count() !== 0n + || ctx.db.realmEnvironmentV1.count() !== 0n + ) throw new Error('FIXTURE_WATER_NOT_EMPTY'); + const realmId = 'MIGRATION_WATER_SENTINEL'; + const bodyId = 'migration-water-body'; + ctx.db.realmWaterLayoutV1.insert({ + realmId, + layoutVersion: 1, + policyVersion: 'migration-water-sentinel-v1', + generationVersion: 3, + canonicalLandCellCount: 10_000, + oceanCellCount: 1, + lakeCellCount: 0, + lakeBodyCount: 0, + riverCount: 0, + riverCellCount: 0, + seaLevelMilli: 0, + seaLevelPolicyVersion: 'migration-water-sentinel-v1', + fogStartDepthCells: 1, + fogFullDepthCells: 2, + hiddenBufferCells: 1, + layoutDigest: '0'.repeat(64), + sourceCommit: '0'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + ctx.db.realmWaterBodyV1.insert({ + bodyId, + realmId, + regime: 'ocean', + cellCount: 1, + sourceCellKey: 'migration-water-cell', + mouthCellKey: 'migration-water-cell', + surfaceLevelMilli: 0, + flowDirectionXQ15: 0, + flowDirectionZQ15: 0, + wavePreset: 'migration', + ordinal: 0, + seed: 0, + generationVersion: 3, + layoutVersion: 1, + }); + ctx.db.realmWaterCellV1.insert({ + cellKey: 'migration-water-cell', + realmId, + q: 0, + r: 0, + regime: 'ocean', + bodyId, + depthCells: 1, + elevationMilli: 0, + surfaceLevelMilli: 0, + ring: 0, + s: 0, + underlyingTileKey: undefined, + riverOrdinal: undefined, + riverOrder: undefined, + downstreamWaterCellKey: undefined, + flowAccumulation: 0, + depthClass: 1, + oceanDepth: 1, + bankSeed: 0, + generationVersion: 3, + fogBand: 'clear', + layoutVersion: 1, + }); + ctx.db.realmEnvironmentV1.insert({ + realmId, + environmentEpoch: 1n, + waterLayoutVersion: 1, + seaLevelMilli: 0, + sunDirectionXMicro: 0, + sunDirectionYMicro: 1_000_000, + sunDirectionZMicro: 0, + updatedAt: ctx.timestamp, + }); + }, +); + +/** One typed row per v10 Stone table for the next additive migration. */ +export const fixtureSeedStoneSentinelV10 = db.reducer( + { name: 'fixture_seed_stone_sentinel_v10' }, + ctx => { + if ( + ctx.db.stoneSiteV1.count() !== 0n + || ctx.db.stoneNodeOccupationV1.count() !== 0n + || ctx.db.stoneExpeditionV1.count() !== 0n + || ctx.db.stoneExpeditionIdempotencyV1.count() !== 0n + || ctx.db.stoneExpeditionScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_STONE_NOT_EMPTY'); + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 7n * 24n * 60n * 60n * 1_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 24n * 60n * 60n * 1_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 24n * 60n * 60n * 1_000_000n; + const siteId = 'migration-stone-site'; + const expeditionId = 'migration-stone-expedition'; + const originCastleId = 991_001n; + const fid = 991_002n; + ctx.db.stoneSiteV1.insert({ siteId, q: 1, r: -1, tier: 1, active: true }); + ctx.db.stoneNodeOccupationV1.insert({ + siteId, + originCastleId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + }); + ctx.db.stoneExpeditionV1.insert({ + expeditionId, + fid, + originCastleId, + siteId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + settledThroughMicros: startedAtMicros, + accruedStone: 0n, + creditedStone: 0n, + policyVersion: 'migration-stone-sentinel-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionIdempotencyV1.insert({ + requestKey: 'migration-stone-sentinel-request-0001', + fid, + siteId, + expeditionId, + createdAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(arrivesAtMicros), + originCastleId, + siteId, + stage: 'arrival', + }); + }, +); + +/** Typed v11 sentinel used only to prove rollback refusal and row survival. */ +export const fixtureSeedWaterRevisionSentinelV11 = db.reducer( + { name: 'fixture_seed_water_revision_sentinel_v11' }, + ctx => { + if (ctx.db.realmWaterRevisionV1.count() !== 0n) { + throw new Error('FIXTURE_WATER_REVISION_NOT_EMPTY'); + } + ctx.db.realmWaterRevisionV1.insert({ + realmId: 'MIGRATION_WATER_SENTINEL', + revisionVersion: 2, + policyVersion: 'migration-water-revision-sentinel-v1', + baseLayoutVersion: 1, + baseLayoutDigest: '0'.repeat(64), + oceanBodyCount: 1, + riverBodyCount: 0, + enabledBodyCount: 1, + oceanCellCount: 1, + riverCellCount: 0, + enabledCellCount: 1, + lakeBodyCount: 0, + lakeCellCount: 0, + riverWidthCells: 1, + navigationFogBoundaryDepthCells: 2, + hiddenBufferCells: 1, + revisionDigest: '1'.repeat(64), + sourceCommit: '1'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + }, +); + +const FIXTURE_RESOURCE_QUANTUM_MICROS = 600_000_000n; +const FIXTURE_RESOURCE_POLICY_VERSION = 'genesis-resource-yield-v1'; + +export const fixtureRewindResourceOneQuantum = db.reducer( + { name: 'fixture_rewind_resource_one_quantum' }, + { fid: t.u64() }, + (ctx, { fid }) => { + const row = ctx.db.resourceAccountV1.fid.find(fid); + if ( + row === null + || row.policyVersion !== FIXTURE_RESOURCE_POLICY_VERSION + || row.revision !== 0n + || row.food !== 0n + || row.wood !== 0n + || row.stone !== 0n + || row.gold !== 0n + || row.settledThroughMicros < FIXTURE_RESOURCE_QUANTUM_MICROS + ) throw new Error('FIXTURE_RESOURCE_STATE_INVALID'); + const rewoundMicros = row.settledThroughMicros - FIXTURE_RESOURCE_QUANTUM_MICROS; + ctx.db.resourceAccountV1.fid.update({ + ...row, + settledThroughMicros: rewoundMicros, + createdAt: new Timestamp(rewoundMicros), + updatedAt: ctx.timestamp, + }); + }, +); + +/** Populates every v12 table with bounded, auth-neutral rows for migration proof. */ +export const fixtureSeedGenericWorkerSentinelV12 = db.reducer( + { name: 'fixture_seed_generic_worker_sentinel_v12' }, + ctx => { + if ( + ctx.db.realmWorkerSystemV1.count() !== 0n + || ctx.db.castleWorkerV1.count() !== 0n + || ctx.db.workerAssignmentV1.count() !== 0n + || ctx.db.workerNodeOccupationV1.count() !== 0n + || ctx.db.workerCommandIdempotencyV1.count() !== 0n + || ctx.db.workerAssignmentScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_WORKER_NOT_EMPTY'); + const castleId = 991_101n; + const fid = 991_102n; + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 30_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 86_400_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 30_000_000n; + const assignmentId = 'migration-worker-assignment-0001'; + const workerId = 'genesis-001-castle-991101-worker-01'; + const siteId = 'migration-worker-site'; + ctx.db.realmWorkerSystemV1.insert({ + realmId: 'GENESIS_001', + policyVersion: 'genesis-001-castle-workers-v1', + workersPerCastle: 4, + expectedCastleCount: 1, + expectedWorkerCount: 4, + rosterDigest: 'migration-worker-roster-digest', + mode: 'staged', + legacyDrainRequired: true, + createdAt: ctx.timestamp, + activatedAt: undefined, + }); + for (let ordinal = 1; ordinal <= 4; ordinal += 1) { + ctx.db.castleWorkerV1.insert({ + workerId: `genesis-001-castle-991101-worker-0${ordinal}`, + originCastleId: castleId, + ordinal, + status: ordinal === 1 ? 'gathering' : 'idle', + resourceKind: ordinal === 1 ? 'stone' : undefined, + siteId: ordinal === 1 ? siteId : undefined, + startedAtMicros: ordinal === 1 ? startedAtMicros : undefined, + arrivesAtMicros: ordinal === 1 ? arrivesAtMicros : undefined, + gatheringEndsAtMicros: ordinal === 1 ? gatheringEndsAtMicros : undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: ordinal === 1 ? returnsAtMicros : undefined, + routeSteps: ordinal === 1 ? 1 : undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + }); + } + ctx.db.workerAssignmentV1.insert({ + assignmentId, + workerId, + fid, + originCastleId: castleId, + resourceKind: 'stone', + siteId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros, + routeSteps: 1, + returnStartProgressBasisPoints: 0, + settledThroughMicros: arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision: 0, + policyVersion: 'genesis-001-castle-workers-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey: 'stone:migration-worker-site', + resourceKind: 'stone', + siteId, + workerId, + workerOrdinal: 1, + originCastleId: castleId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision: 0, + }); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey: '991102:migration-worker-request-0001', + fid, + workerId, + commandKind: 'dispatch', + resourceKind: 'stone', + siteId, + assignmentId, + resultRevision: 0n, + createdAt: ctx.timestamp, + }); + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(gatheringEndsAtMicros), + assignmentId, + workerId, + timelineRevision: 0, + stage: 'gathering-expiry', + }); + }, +); + +export default db; diff --git a/spacetimedb/migration-fixtures/additive-v15-schema/tsconfig.json b/spacetimedb/migration-fixtures/additive-v15-schema/tsconfig.json new file mode 100644 index 00000000..ff6a5945 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v15-schema/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/spacetimedb/pnpm-lock.yaml b/spacetimedb/pnpm-lock.yaml index 649efdeb..c882ca64 100644 --- a/spacetimedb/pnpm-lock.yaml +++ b/spacetimedb/pnpm-lock.yaml @@ -72,6 +72,16 @@ importers: specifier: 5.6.3 version: 5.6.3 + migration-fixtures/additive-v15-schema: + dependencies: + spacetimedb: + specifier: 2.6.1 + version: 2.6.1 + devDependencies: + typescript: + specifier: 5.6.3 + version: 5.6.3 + migration-fixtures/additive-v2-schema: dependencies: spacetimedb: diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 22e9fd9e..9011daf2 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -40,6 +40,19 @@ export { adminBackfillDailyMarkAccountsV1, adminActivateDailyMarksV1, } from './reducers/dailyMarks'; +export { + sendRealmChatMessageV1, + getRealmChatHistoryV1, + reportRealmChatMessageV1, + adminGetRealmChatStatusV1, + adminStageRealmChatV1, + adminActivateRealmChatV1, + adminDisableRealmChatV1, + adminTombstoneRealmChatMessageV1, + adminListRealmChatReportsV1, + adminGetRealmChatReportContextV1, + adminResolveRealmChatReportV1, +} from './reducers/realmChat'; export { getMyResourceStateV1, collectResourcesV1, diff --git a/spacetimedb/src/realmChatPolicy.ts b/spacetimedb/src/realmChatPolicy.ts new file mode 100644 index 00000000..3395f59f --- /dev/null +++ b/spacetimedb/src/realmChatPolicy.ts @@ -0,0 +1,262 @@ +/** + * Realm Chat V1 is deliberately staged behind both a server mode and a client + * entry flag. These limits become authoritative only after the separate legal + * and activation gates are approved. + */ +export const REALM_CHAT_POLICY_VERSION = '2026-08-03-realm-chat-policy-v1'; +export const REALM_CHAT_CHANNEL_KEY = 'realm:genesis-001'; +export const REALM_CHAT_REALM_ID = 'HEGEMONY_GENESIS_001'; +export const REALM_CHAT_SERVER_ACTIVATION_ALLOWED = false; + +export const REALM_CHAT_RECENT_LIMIT = 128; +export const REALM_CHAT_HISTORY_PAGE_LIMIT = 50; +export const REALM_CHAT_MAX_SCALARS = 500; +export const REALM_CHAT_MAX_UTF8_BYTES = 2_048; +export const REALM_CHAT_MAX_LINES = 8; +export const REALM_CHAT_MIN_INTERVAL_MICROS = 2_000_000n; +export const REALM_CHAT_MINUTE_WINDOW_MICROS = 60_000_000n; +export const REALM_CHAT_HOUR_WINDOW_MICROS = 3_600_000_000n; +export const REALM_CHAT_MAX_PER_MINUTE = 10; +export const REALM_CHAT_MAX_PER_HOUR = 60; +export const REALM_CHAT_DUPLICATE_WINDOW_MICROS = 60_000_000n; +export const REALM_CHAT_RATE_EVENTS_PER_FID = REALM_CHAT_MAX_PER_HOUR; +export const REALM_CHAT_RECEIPTS_PER_FID = 64; +export const REALM_CHAT_REPORT_DETAILS_MAX_SCALARS = 500; +export const REALM_CHAT_REPORT_DETAILS_MAX_UTF8_BYTES = 2_048; +export const REALM_CHAT_REPORT_CONTEXT_RADIUS = 10n; +export const REALM_CHAT_MESSAGE_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +export const REALM_CHAT_REPORT_CATEGORIES = Object.freeze([ + 'threat_or_harm', + 'harassment_or_hate', + 'personal_information', + 'sexual_exploitation', + 'fraud_or_malware', + 'illegal_trade', + 'spam_or_disruption', + 'other', +] as const); + +export type RealmChatReportCategory = typeof REALM_CHAT_REPORT_CATEGORIES[number]; + +export class RealmChatPolicyError extends Error { + public constructor(public readonly code: string) { + super(code); + this.name = 'RealmChatPolicyError'; + } +} + +function fail(code: string): never { + throw new RealmChatPolicyError(code); +} + +function scalarCount(value: string): number { + return [...value].length; +} + +function utf8ByteCount(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function containsDisallowedControl(value: string): boolean { + for (const scalar of value) { + const code = scalar.codePointAt(0)!; + if ( + code === 0x7f + || (code >= 0 && code <= 0x08) + || (code >= 0x0b && code <= 0x0c) + || (code >= 0x0e && code <= 0x1f) + || (code >= 0x80 && code <= 0x9f) + // Directional isolates and overrides can make moderation evidence look + // different from the stored text. Ordinary RTL scripts remain valid. + || code === 0x061c + || code === 0x200e + || code === 0x200f + || (code >= 0x202a && code <= 0x202e) + || (code >= 0x2066 && code <= 0x2069) + ) return true; + } + return false; +} + +/** Normalize platform line endings and Unicode before any length/rate check. */ +export function normalizeRealmChatBody(input: string): string { + if (typeof input !== 'string' || input.length > REALM_CHAT_MAX_UTF8_BYTES) { + fail('REALM_CHAT_BODY_INVALID'); + } + const body = input + .replace(/\r\n?/g, '\n') + .normalize('NFC') + .trim(); + if (body.length === 0) fail('REALM_CHAT_BODY_EMPTY'); + if (containsDisallowedControl(body)) fail('REALM_CHAT_BODY_CONTROL'); + if (body.split('\n').length > REALM_CHAT_MAX_LINES) fail('REALM_CHAT_BODY_LINES'); + if (scalarCount(body) > REALM_CHAT_MAX_SCALARS) fail('REALM_CHAT_BODY_SCALARS'); + if (utf8ByteCount(body) > REALM_CHAT_MAX_UTF8_BYTES) fail('REALM_CHAT_BODY_BYTES'); + return body; +} + +export function normalizeRealmChatReportDetails(input: string): string { + if ( + typeof input !== 'string' + || input.length > REALM_CHAT_REPORT_DETAILS_MAX_UTF8_BYTES + ) fail('REALM_CHAT_REPORT_DETAILS_INVALID'); + const details = input.replace(/\r\n?/g, '\n').normalize('NFC').trim(); + if (containsDisallowedControl(details)) fail('REALM_CHAT_REPORT_DETAILS_CONTROL'); + if (scalarCount(details) > REALM_CHAT_REPORT_DETAILS_MAX_SCALARS) { + fail('REALM_CHAT_REPORT_DETAILS_SCALARS'); + } + if (utf8ByteCount(details) > REALM_CHAT_REPORT_DETAILS_MAX_UTF8_BYTES) { + fail('REALM_CHAT_REPORT_DETAILS_BYTES'); + } + return details; +} + +export function requireRealmChatReportCategory( + input: string, +): RealmChatReportCategory { + if (typeof input !== 'string' || input.length > 32) { + return fail('REALM_CHAT_REPORT_CATEGORY_INVALID'); + } + if ((REALM_CHAT_REPORT_CATEGORIES as readonly string[]).includes(input)) { + return input as RealmChatReportCategory; + } + return fail('REALM_CHAT_REPORT_CATEGORY_INVALID'); +} + +/** Canonical lowercase UUIDv4/v7 request IDs keep operation keys bounded. */ +export function requireRealmChatRequestKey(input: string): string { + if ( + typeof input !== 'string' + || input.length !== 36 + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(input) + ) { + fail('REALM_CHAT_REQUEST_KEY_INVALID'); + } + return input; +} + +export function requireRealmChatMessageId(input: string): string { + if ( + typeof input !== 'string' + || input.length !== 36 + || !REALM_CHAT_MESSAGE_ID_PATTERN.test(input) + ) fail('REALM_CHAT_MESSAGE_ID_INVALID'); + return input; +} + +/** Stable non-cryptographic digest used only for exact duplicate throttling. */ +export function realmChatBodyDigest(body: string): string { + let hash = 0xcbf29ce484222325n; + for (const byte of new TextEncoder().encode(body)) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + return hash.toString(16).padStart(16, '0'); +} + +export type RealmChatRateEvent = Readonly<{ + acceptedAtMicros: bigint; + bodyDigest: string; +}>; + +export type RealmChatRateDecision = Readonly<{ + retained: readonly RealmChatRateEvent[]; + retryAfterMicros: bigint; +}>; + +/** + * Evaluate exact rolling windows from the bounded accepted-event ledger. + * Rejected attempts never consume quota. + */ +export function evaluateRealmChatRateLimit( + events: Iterable, + nowMicros: bigint, + bodyDigest: string, +): RealmChatRateDecision { + if (nowMicros <= 0n) fail('REALM_CHAT_TIME_INVALID'); + const all = [...events]; + if (all.length > REALM_CHAT_RATE_EVENTS_PER_FID) { + fail('REALM_CHAT_RATE_STATE_INTEGRITY'); + } + if (all.some(event => ( + event.acceptedAtMicros <= 0n + || event.acceptedAtMicros > nowMicros + || !/^[0-9a-f]{16}$/.test(event.bodyDigest) + ))) fail('REALM_CHAT_RATE_STATE_INTEGRITY'); + const retained = all + .filter(event => ( + nowMicros - event.acceptedAtMicros < REALM_CHAT_HOUR_WINDOW_MICROS + )) + .sort((left, right) => ( + left.acceptedAtMicros < right.acceptedAtMicros ? -1 + : left.acceptedAtMicros > right.acceptedAtMicros ? 1 : 0 + )); + const latest = retained.length === 0 ? undefined : retained[retained.length - 1]; + if (latest !== undefined && nowMicros - latest.acceptedAtMicros < REALM_CHAT_MIN_INTERVAL_MICROS) { + fail('REALM_CHAT_RATE_COOLDOWN'); + } + const duplicate = retained.find(event => ( + event.bodyDigest === bodyDigest + && nowMicros - event.acceptedAtMicros < REALM_CHAT_DUPLICATE_WINDOW_MICROS + )); + if (duplicate !== undefined) fail('REALM_CHAT_RATE_DUPLICATE'); + + const minute = retained.filter(event => ( + nowMicros - event.acceptedAtMicros < REALM_CHAT_MINUTE_WINDOW_MICROS + )); + if (minute.length >= REALM_CHAT_MAX_PER_MINUTE) fail('REALM_CHAT_RATE_MINUTE'); + if (retained.length >= REALM_CHAT_MAX_PER_HOUR) fail('REALM_CHAT_RATE_HOUR'); + + const retryCandidates = [ + latest === undefined + ? 0n + : REALM_CHAT_MIN_INTERVAL_MICROS - (nowMicros - latest.acceptedAtMicros), + minute.length === 0 + ? 0n + : REALM_CHAT_MINUTE_WINDOW_MICROS - (nowMicros - minute[0].acceptedAtMicros), + retained.length === 0 + ? 0n + : REALM_CHAT_HOUR_WINDOW_MICROS - (nowMicros - retained[0].acceptedAtMicros), + ]; + return Object.freeze({ + retained: Object.freeze(retained), + retryAfterMicros: retryCandidates.reduce( + (maximum, value) => value > maximum ? value : maximum, + 0n, + ), + }); +} + +export function realmChatOperationKey(fid: bigint, requestKey: string): string { + if (fid <= 0n) fail('REALM_CHAT_FID_INVALID'); + return `${fid}:${requireRealmChatRequestKey(requestKey)}`; +} + +export function realmChatReportKey(fid: bigint, messageId: string): string { + if (fid <= 0n) { + fail('REALM_CHAT_REPORT_KEY_INVALID'); + } + return `${fid}:${requireRealmChatMessageId(messageId)}`; +} + +export function realmChatContextBounds( + sequence: bigint, + availableLastSequence: bigint, +): Readonly<{ + first: bigint; + last: bigint; +}> { + if (sequence <= 0n || availableLastSequence < sequence) { + fail('REALM_CHAT_SEQUENCE_INVALID'); + } + return Object.freeze({ + first: sequence > REALM_CHAT_REPORT_CONTEXT_RADIUS + ? sequence - REALM_CHAT_REPORT_CONTEXT_RADIUS + : 1n, + last: sequence + REALM_CHAT_REPORT_CONTEXT_RADIUS < availableLastSequence + ? sequence + REALM_CHAT_REPORT_CONTEXT_RADIUS + : availableLastSequence, + }); +} diff --git a/spacetimedb/src/reducers/realmChat.ts b/spacetimedb/src/reducers/realmChat.ts new file mode 100644 index 00000000..70f3291e --- /dev/null +++ b/spacetimedb/src/reducers/realmChat.ts @@ -0,0 +1,719 @@ +import { SenderError, t } from 'spacetimedb/server'; + +import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { + REALM_CHAT_HISTORY_PAGE_LIMIT, + REALM_CHAT_HOUR_WINDOW_MICROS, + REALM_CHAT_POLICY_VERSION, + REALM_CHAT_RECEIPTS_PER_FID, + REALM_CHAT_RECENT_LIMIT, + REALM_CHAT_SERVER_ACTIVATION_ALLOWED, + REALM_CHAT_CHANNEL_KEY, + REALM_CHAT_REALM_ID, + RealmChatPolicyError, + evaluateRealmChatRateLimit, + normalizeRealmChatBody, + normalizeRealmChatReportDetails, + realmChatBodyDigest, + realmChatContextBounds, + realmChatOperationKey, + realmChatReportKey, + requireRealmChatMessageId, + requireRealmChatReportCategory, + requireRealmChatRequestKey, +} from '../realmChatPolicy'; +import warpkeep from '../schema'; + +const U64_MAXIMUM = (1n << 64n) - 1n; +const ADMIN_REPORT_PAGE_LIMIT = 20; +const REPORT_RESOLUTION_CODES = Object.freeze(['dismissed', 'actioned', 'escalated'] as const); +const MODERATION_CODES = Object.freeze([ + 'conduct', + 'privacy', + 'legal', + 'security', + 'service_integrity', +] as const); + +const realmChatMessageProjectionV1 = t.object('RealmChatMessageProjectionV1', { + messageId: t.string(), + sequence: t.u64(), + senderFid: t.u64(), + body: t.string(), + sentAtMicros: t.u64(), + visibility: t.string(), +}); + +const realmChatHistoryPageV1 = t.object('RealmChatHistoryPageV1', { + channelKey: t.string(), + policyVersion: t.string(), + messages: t.array(realmChatMessageProjectionV1), + nextBeforeSequence: t.option(t.u64()), + hasMore: t.bool(), +}); + +const adminRealmChatStatusV1 = t.object('AdminRealmChatStatusV1', { + channelKey: t.string(), + policyVersion: t.string(), + mode: t.string(), + nextSequence: t.u64(), + archivedMessages: t.u64(), + recentMessages: t.u64(), + reports: t.u64(), + rateEvents: t.u64(), + sendReceipts: t.u64(), + graphValid: t.bool(), + activationCompiled: t.bool(), +}); + +const adminRealmChatReportEntryV1 = t.object('AdminRealmChatReportEntryV1', { + reportOrdinal: t.u64(), + reportId: t.string(), + reporterFid: t.u64(), + messageId: t.string(), + reportedSenderFid: t.u64(), + messageSequence: t.u64(), + category: t.string(), + details: t.string(), + contextFirstSequence: t.u64(), + contextLastSequence: t.u64(), + createdAtMicros: t.u64(), + status: t.string(), + reviewedAtMicros: t.option(t.u64()), + resolutionCode: t.option(t.string()), +}); + +const adminRealmChatReportPageV1 = t.object('AdminRealmChatReportPageV1', { + reports: t.array(adminRealmChatReportEntryV1), + nextBeforeOrdinal: t.option(t.u64()), + hasMore: t.bool(), + totalReports: t.u64(), +}); + +const adminRealmChatReportContextV1 = t.object('AdminRealmChatReportContextV1', { + report: adminRealmChatReportEntryV1, + messages: t.array(realmChatMessageProjectionV1), +}); + +type ChatContext = Parameters[0]; + +function senderPolicyError(error: unknown): never { + if (error instanceof RealmChatPolicyError) throw new SenderError(error.code); + if (error instanceof SenderError) throw error; + throw error; +} + +function canonicalMessageId(input: string): string { + try { + return requireRealmChatMessageId(input); + } catch (error) { + return senderPolicyError(error); + } +} + +function statusMatchesChannel(ctx: ChatContext): boolean { + const channel = ctx.db.realmChatChannelV1.channelKey.find(REALM_CHAT_CHANNEL_KEY); + const status = ctx.db.realmChatStatusV1.channelKey.find(REALM_CHAT_CHANNEL_KEY); + return channel !== null + && status !== null + && channel.realmId === REALM_CHAT_REALM_ID + && status.realmId === REALM_CHAT_REALM_ID + && channel.policyVersion === REALM_CHAT_POLICY_VERSION + && status.policyVersion === REALM_CHAT_POLICY_VERSION + && channel.mode === status.mode + && status.recentLimit === REALM_CHAT_RECENT_LIMIT + && status.historyPageLimit === REALM_CHAT_HISTORY_PAGE_LIMIT + && channel.nextSequence > 0n; +} + +function requireChannel(ctx: ChatContext, active: boolean) { + if (!statusMatchesChannel(ctx)) throw new SenderError('REALM_CHAT_STATE_INTEGRITY'); + const channel = ctx.db.realmChatChannelV1.channelKey.find(REALM_CHAT_CHANNEL_KEY)!; + if (active && channel.mode !== 'active') throw new SenderError('REALM_CHAT_UNAVAILABLE'); + return channel; +} + +function boundedFidRows( + rows: Iterable, + maximum: number, + code: string, +): Row[] { + const result: Row[] = []; + for (const row of rows) { + result.push(row); + if (result.length > maximum) throw new SenderError(code); + } + return result; +} + +function pruneExpiredRateEvents( + ctx: ChatContext, + rows: readonly { eventId: string; acceptedAtMicros: bigint }[], + nowMicros: bigint, +): void { + for (const row of rows) { + if ( + row.acceptedAtMicros > 0n + && row.acceptedAtMicros <= nowMicros + && nowMicros - row.acceptedAtMicros >= REALM_CHAT_HOUR_WINDOW_MICROS + ) ctx.db.realmChatRateEventV1.eventId.delete(row.eventId); + } +} + +function pruneSendReceipts(ctx: ChatContext, fid: bigint): void { + const receipts = boundedFidRows( + ctx.db.realmChatSendReceiptV1.fid.filter(fid), + REALM_CHAT_RECEIPTS_PER_FID, + 'REALM_CHAT_RECEIPT_STATE_INTEGRITY', + ).sort((left, right) => { + const leftMicros = left.createdAt.microsSinceUnixEpoch; + const rightMicros = right.createdAt.microsSinceUnixEpoch; + return leftMicros < rightMicros ? -1 + : leftMicros > rightMicros ? 1 + : left.operationKey.localeCompare(right.operationKey); + }); + const deleteCount = Math.max(0, receipts.length - REALM_CHAT_RECEIPTS_PER_FID + 1); + for (const receipt of receipts.slice(0, deleteCount)) { + ctx.db.realmChatSendReceiptV1.operationKey.delete(receipt.operationKey); + } +} + +function pruneRecentProjection(ctx: ChatContext): void { + const overflow = ctx.db.realmChatRecentV1.count() - BigInt(REALM_CHAT_RECENT_LIMIT); + if (overflow <= 0n) return; + if (overflow !== 1n) throw new SenderError('REALM_CHAT_RECENT_STATE_INTEGRITY'); + let oldest: bigint | undefined; + for (const row of ctx.db.realmChatRecentV1.iter()) { + if (oldest === undefined || row.sequence < oldest) oldest = row.sequence; + } + if (oldest === undefined || !ctx.db.realmChatRecentV1.sequence.delete(oldest)) { + throw new SenderError('REALM_CHAT_RECENT_STATE_INTEGRITY'); + } +} + +function projectMessage(message: Readonly<{ + messageId: string; + sequence: bigint; + senderFid: bigint; + body: string; + sentAt: Readonly<{ microsSinceUnixEpoch: bigint }>; + visibility: string; +}>) { + return { + messageId: message.messageId, + sequence: message.sequence, + senderFid: message.senderFid, + body: message.visibility === 'visible' ? message.body : '', + sentAtMicros: message.sentAt.microsSinceUnixEpoch, + visibility: message.visibility, + }; +} + +/** Private moderator evidence retains the original body after public tombstoning. */ +function projectEvidenceMessage(message: Parameters[0]) { + return { + ...projectMessage(message), + body: message.body, + }; +} + +/** + * The public table is a bounded cache, not an independent source of truth. + * Admin health checks verify that it is the exact newest archive window and + * that tombstoned bodies cannot survive in the public projection. + */ +function recentProjectionMatchesArchive( + ctx: ChatContext, + channel: Readonly<{ channelKey: string; nextSequence: bigint }>, +): boolean { + const archiveCount = ctx.db.realmChatMessageV1.count(); + const expectedCount = archiveCount < BigInt(REALM_CHAT_RECENT_LIMIT) + ? archiveCount + : BigInt(REALM_CHAT_RECENT_LIMIT); + if (ctx.db.realmChatRecentV1.count() !== expectedCount) return false; + + const firstSequence = channel.nextSequence - expectedCount; + for (let sequence = firstSequence; sequence < channel.nextSequence; sequence += 1n) { + const archive = ctx.db.realmChatMessageV1.sequence.find(sequence); + const recent = ctx.db.realmChatRecentV1.sequence.find(sequence); + if ( + archive === null + || recent === null + || archive.channelKey !== channel.channelKey + || recent.channelKey !== channel.channelKey + || recent.messageId !== archive.messageId + || recent.senderFid !== archive.senderFid + || recent.body !== (archive.visibility === 'visible' ? archive.body : '') + || recent.sentAt.microsSinceUnixEpoch !== archive.sentAt.microsSinceUnixEpoch + || recent.visibility !== archive.visibility + ) return false; + } + return true; +} + +function reportEntry(report: Readonly<{ + reportOrdinal: bigint; + reportId: string; + reporterFid: bigint; + messageId: string; + reportedSenderFid: bigint; + messageSequence: bigint; + category: string; + details: string; + contextFirstSequence: bigint; + contextLastSequence: bigint; + createdAt: Readonly<{ microsSinceUnixEpoch: bigint }>; + status: string; + reviewedAt?: Readonly<{ microsSinceUnixEpoch: bigint }>; + resolutionCode?: string; +}>) { + return { + reportOrdinal: report.reportOrdinal, + reportId: report.reportId, + reporterFid: report.reporterFid, + messageId: report.messageId, + reportedSenderFid: report.reportedSenderFid, + messageSequence: report.messageSequence, + category: report.category, + details: report.details, + contextFirstSequence: report.contextFirstSequence, + contextLastSequence: report.contextLastSequence, + createdAtMicros: report.createdAt.microsSinceUnixEpoch, + status: report.status, + reviewedAtMicros: report.reviewedAt?.microsSinceUnixEpoch, + resolutionCode: report.resolutionCode, + }; +} + +/** Exactly-once, server-authored send path. Rejected attempts write nothing. */ +export const sendRealmChatMessageV1 = warpkeep.reducer( + { name: 'send_realm_chat_message_v1' }, + { requestKey: t.string(), body: t.string() }, + (ctx, { requestKey, body }) => { + try { + const { claims } = requireGameplayPlayerV1(ctx); + const normalizedBody = normalizeRealmChatBody(body); + const normalizedRequestKey = requireRealmChatRequestKey(requestKey); + const digest = realmChatBodyDigest(normalizedBody); + const operationKey = realmChatOperationKey(claims.fid, normalizedRequestKey); + const existing = ctx.db.realmChatSendReceiptV1.operationKey.find(operationKey); + if (existing !== null) { + const message = ctx.db.realmChatMessageV1.messageId.find(existing.messageId); + if ( + existing.fid !== claims.fid + || existing.requestKey !== normalizedRequestKey + || existing.bodyDigest !== digest + || message === null + || message.sequence !== existing.sequence + || realmChatBodyDigest(message.body) !== digest + ) throw new SenderError('REALM_CHAT_IDEMPOTENCY_CONFLICT'); + return; + } + + const channel = requireChannel(ctx, true); + const nowMicros = ctx.timestamp.microsSinceUnixEpoch; + const rateRows = boundedFidRows( + ctx.db.realmChatRateEventV1.fid.filter(claims.fid), + 60, + 'REALM_CHAT_RATE_STATE_INTEGRITY', + ); + evaluateRealmChatRateLimit(rateRows, nowMicros, digest); + pruneExpiredRateEvents(ctx, rateRows, nowMicros); + pruneSendReceipts(ctx, claims.fid); + + if (channel.nextSequence <= 0n || channel.nextSequence === U64_MAXIMUM) { + throw new SenderError('REALM_CHAT_SEQUENCE_EXHAUSTED'); + } + const message = ctx.db.realmChatMessageV1.insert({ + messageId: ctx.newUuidV7().toString(), + sequence: channel.nextSequence, + channelKey: channel.channelKey, + senderFid: claims.fid, + body: normalizedBody, + sentAt: ctx.timestamp, + visibility: 'visible', + moderatedAt: undefined, + moderationCode: undefined, + }); + ctx.db.realmChatChannelV1.channelKey.update({ + ...channel, + nextSequence: channel.nextSequence + 1n, + updatedAt: ctx.timestamp, + }); + ctx.db.realmChatRecentV1.insert({ + sequence: message.sequence, + messageId: message.messageId, + channelKey: message.channelKey, + senderFid: message.senderFid, + body: message.body, + sentAt: message.sentAt, + visibility: message.visibility, + }); + ctx.db.realmChatRateEventV1.insert({ + eventId: message.messageId, + fid: claims.fid, + acceptedAtMicros: nowMicros, + bodyDigest: digest, + }); + ctx.db.realmChatSendReceiptV1.insert({ + operationKey, + fid: claims.fid, + requestKey: normalizedRequestKey, + bodyDigest: digest, + messageId: message.messageId, + sequence: message.sequence, + createdAt: ctx.timestamp, + }); + pruneRecentProjection(ctx); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +/** Caller-gated, exclusive-cursor history. It reads at most 50 sequence keys. */ +export const getRealmChatHistoryV1 = warpkeep.procedure( + { name: 'get_realm_chat_history_v1' }, + { beforeSequence: t.u64(), limit: t.u32() }, + realmChatHistoryPageV1, + (ctx, { beforeSequence, limit }) => ctx.withTx(tx => { + try { + requireGameplayPlayerV1(tx); + const channel = requireChannel(tx, true); + if (!Number.isInteger(limit) || limit < 1 || limit > REALM_CHAT_HISTORY_PAGE_LIMIT) { + throw new SenderError('REALM_CHAT_HISTORY_LIMIT'); + } + if (beforeSequence > channel.nextSequence) { + throw new SenderError('REALM_CHAT_HISTORY_CURSOR'); + } + let cursor = beforeSequence === 0n ? channel.nextSequence : beforeSequence; + const messages = []; + while (cursor > 1n && messages.length < limit) { + cursor -= 1n; + const message = tx.db.realmChatMessageV1.sequence.find(cursor); + if (message === null || message.channelKey !== channel.channelKey) { + throw new SenderError('REALM_CHAT_HISTORY_INTEGRITY'); + } + messages.push(projectMessage(message)); + } + return { + channelKey: channel.channelKey, + policyVersion: channel.policyVersion, + messages, + nextBeforeSequence: messages.length === 0 + ? undefined + : messages[messages.length - 1].sequence, + hasMore: cursor > 1n, + }; + } catch (error) { + return senderPolicyError(error); + } + }), +); + +/** One private report per caller/message; recording never changes visibility. */ +export const reportRealmChatMessageV1 = warpkeep.reducer( + { name: 'report_realm_chat_message_v1' }, + { messageId: t.string(), category: t.string(), details: t.string() }, + (ctx, { messageId, category, details }) => { + try { + const { claims } = requireGameplayPlayerV1(ctx); + const channel = requireChannel(ctx, false); + const message = ctx.db.realmChatMessageV1.messageId.find( + canonicalMessageId(messageId), + ); + if (message === null || message.channelKey !== REALM_CHAT_CHANNEL_KEY) { + throw new SenderError('REALM_CHAT_MESSAGE_NOT_FOUND'); + } + if (message.senderFid === claims.fid) throw new SenderError('REALM_CHAT_REPORT_SELF'); + const normalizedCategory = requireRealmChatReportCategory(category); + const normalizedDetails = normalizeRealmChatReportDetails(details); + const key = realmChatReportKey(claims.fid, message.messageId); + const existing = ctx.db.realmChatReportV1.reportKey.find(key); + if (existing !== null) { + if ( + existing.reporterFid !== claims.fid + || existing.messageId !== message.messageId + || existing.category !== normalizedCategory + || existing.details !== normalizedDetails + ) throw new SenderError('REALM_CHAT_REPORT_ALREADY_EXISTS'); + return; + } + const context = realmChatContextBounds(message.sequence, channel.nextSequence - 1n); + ctx.db.realmChatReportV1.insert({ + reportOrdinal: 0n, + reportKey: key, + reportId: ctx.newUuidV7().toString(), + reporterFid: claims.fid, + messageId: message.messageId, + reportedSenderFid: message.senderFid, + messageSequence: message.sequence, + category: normalizedCategory, + details: normalizedDetails, + contextFirstSequence: context.first, + contextLastSequence: context.last, + createdAt: ctx.timestamp, + status: 'pending', + reviewedAt: undefined, + resolutionCode: undefined, + }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +function inspectRealmChat(ctx: ChatContext) { + const channel = ctx.db.realmChatChannelV1.channelKey.find(REALM_CHAT_CHANNEL_KEY); + return { + channelKey: REALM_CHAT_CHANNEL_KEY, + policyVersion: REALM_CHAT_POLICY_VERSION, + mode: channel?.mode ?? 'unconfigured', + nextSequence: channel?.nextSequence ?? 0n, + archivedMessages: ctx.db.realmChatMessageV1.count(), + recentMessages: ctx.db.realmChatRecentV1.count(), + reports: ctx.db.realmChatReportV1.count(), + rateEvents: ctx.db.realmChatRateEventV1.count(), + sendReceipts: ctx.db.realmChatSendReceiptV1.count(), + graphValid: channel === null + ? ctx.db.realmChatStatusV1.count() === 0n + && ctx.db.realmChatChannelV1.count() === 0n + && ctx.db.realmChatMessageV1.count() === 0n + && ctx.db.realmChatRecentV1.count() === 0n + && ctx.db.realmChatRateEventV1.count() === 0n + && ctx.db.realmChatSendReceiptV1.count() === 0n + && ctx.db.realmChatReportV1.count() === 0n + : ctx.db.realmChatStatusV1.count() === 1n + && ctx.db.realmChatChannelV1.count() === 1n + && statusMatchesChannel(ctx) + && channel.nextSequence === ctx.db.realmChatMessageV1.count() + 1n + && recentProjectionMatchesArchive(ctx, channel), + activationCompiled: REALM_CHAT_SERVER_ACTIVATION_ALLOWED, + }; +} + +export const adminGetRealmChatStatusV1 = warpkeep.procedure( + { name: 'admin_get_realm_chat_status_v1' }, + adminRealmChatStatusV1, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + return inspectRealmChat(tx); + }), +); + +/** Append-only schema can be staged, but this build cannot activate it. */ +export const adminStageRealmChatV1 = warpkeep.reducer( + { name: 'admin_stage_realm_chat_v1' }, + ctx => { + const admin = requireAdmin(ctx); + const before = inspectRealmChat(ctx); + if (!before.graphValid) throw new SenderError('REALM_CHAT_STATE_INTEGRITY'); + const existing = ctx.db.realmChatChannelV1.channelKey.find(REALM_CHAT_CHANNEL_KEY); + if (existing !== null) { + if (existing.mode !== 'staged') throw new SenderError('REALM_CHAT_ALREADY_CONFIGURED'); + return; + } + ctx.db.realmChatChannelV1.insert({ + channelKey: REALM_CHAT_CHANNEL_KEY, + realmId: REALM_CHAT_REALM_ID, + policyVersion: REALM_CHAT_POLICY_VERSION, + mode: 'staged', + nextSequence: 1n, + updatedAt: ctx.timestamp, + }); + ctx.db.realmChatStatusV1.insert({ + channelKey: REALM_CHAT_CHANNEL_KEY, + realmId: REALM_CHAT_REALM_ID, + policyVersion: REALM_CHAT_POLICY_VERSION, + mode: 'staged', + recentLimit: REALM_CHAT_RECENT_LIMIT, + historyPageLimit: REALM_CHAT_HISTORY_PAGE_LIMIT, + updatedAt: ctx.timestamp, + }); + ctx.db.adminAudit.insert({ + id: 0n, + action: 'realm_chat_staged_v1', + targetFid: undefined, + actorSubject: admin.subject, + createdAt: ctx.timestamp, + note: `channel=${REALM_CHAT_CHANNEL_KEY};policy=${REALM_CHAT_POLICY_VERSION}`, + }); + }, +); + +export const adminActivateRealmChatV1 = warpkeep.reducer( + { name: 'admin_activate_realm_chat_v1' }, + { expectedPolicyVersion: t.string() }, + (ctx, { expectedPolicyVersion }) => { + const admin = requireAdmin(ctx); + if (!REALM_CHAT_SERVER_ACTIVATION_ALLOWED) { + throw new SenderError('REALM_CHAT_ACTIVATION_NOT_COMPILED'); + } + if (expectedPolicyVersion !== REALM_CHAT_POLICY_VERSION) { + throw new SenderError('REALM_CHAT_POLICY_MISMATCH'); + } + const channel = requireChannel(ctx, false); + if (channel.mode === 'active') return; + if (channel.mode !== 'staged') throw new SenderError('REALM_CHAT_NOT_STAGED'); + const status = ctx.db.realmChatStatusV1.channelKey.find(channel.channelKey)!; + ctx.db.realmChatChannelV1.channelKey.update({ ...channel, mode: 'active', updatedAt: ctx.timestamp }); + ctx.db.realmChatStatusV1.channelKey.update({ ...status, mode: 'active', updatedAt: ctx.timestamp }); + ctx.db.adminAudit.insert({ + id: 0n, + action: 'realm_chat_activated_v1', + targetFid: undefined, + actorSubject: admin.subject, + createdAt: ctx.timestamp, + note: `channel=${REALM_CHAT_CHANNEL_KEY};policy=${REALM_CHAT_POLICY_VERSION}`, + }); + }, +); + +export const adminDisableRealmChatV1 = warpkeep.reducer( + { name: 'admin_disable_realm_chat_v1' }, + ctx => { + const admin = requireAdmin(ctx); + const channel = requireChannel(ctx, false); + const status = ctx.db.realmChatStatusV1.channelKey.find(channel.channelKey)!; + if (channel.mode === 'disabled') return; + ctx.db.realmChatChannelV1.channelKey.update({ ...channel, mode: 'disabled', updatedAt: ctx.timestamp }); + ctx.db.realmChatStatusV1.channelKey.update({ ...status, mode: 'disabled', updatedAt: ctx.timestamp }); + ctx.db.adminAudit.insert({ + id: 0n, + action: 'realm_chat_disabled_v1', + targetFid: undefined, + actorSubject: admin.subject, + createdAt: ctx.timestamp, + note: `channel=${REALM_CHAT_CHANNEL_KEY}`, + }); + }, +); + +export const adminTombstoneRealmChatMessageV1 = warpkeep.reducer( + { name: 'admin_tombstone_realm_chat_message_v1' }, + { messageId: t.string(), moderationCode: t.string() }, + (ctx, { messageId, moderationCode }) => { + const admin = requireAdmin(ctx); + if (!(MODERATION_CODES as readonly string[]).includes(moderationCode)) { + throw new SenderError('REALM_CHAT_MODERATION_CODE_INVALID'); + } + const message = ctx.db.realmChatMessageV1.messageId.find( + canonicalMessageId(messageId), + ); + if (message === null) throw new SenderError('REALM_CHAT_MESSAGE_NOT_FOUND'); + if (message.visibility === 'tombstoned') return; + if (message.visibility !== 'visible') throw new SenderError('REALM_CHAT_VISIBILITY_INVALID'); + ctx.db.realmChatMessageV1.messageId.update({ + ...message, + visibility: 'tombstoned', + moderatedAt: ctx.timestamp, + moderationCode, + }); + const recent = ctx.db.realmChatRecentV1.sequence.find(message.sequence); + if (recent !== null) { + ctx.db.realmChatRecentV1.sequence.update({ + ...recent, + body: '', + visibility: 'tombstoned', + }); + } + ctx.db.adminAudit.insert({ + id: 0n, + action: 'realm_chat_message_tombstoned_v1', + targetFid: message.senderFid, + actorSubject: admin.subject, + createdAt: ctx.timestamp, + note: `message=${message.messageId};code=${moderationCode}`, + }); + }, +); + +export const adminListRealmChatReportsV1 = warpkeep.procedure( + { name: 'admin_list_realm_chat_reports_v1' }, + { beforeOrdinal: t.u64(), limit: t.u32() }, + adminRealmChatReportPageV1, + (ctx, { beforeOrdinal, limit }) => ctx.withTx(tx => { + requireAdmin(tx); + if (!Number.isInteger(limit) || limit < 1 || limit > ADMIN_REPORT_PAGE_LIMIT) { + throw new SenderError('REALM_CHAT_REPORT_PAGE_LIMIT'); + } + const totalReports = tx.db.realmChatReportV1.count(); + if (beforeOrdinal > totalReports + 1n) throw new SenderError('REALM_CHAT_REPORT_CURSOR'); + let cursor = beforeOrdinal === 0n ? totalReports + 1n : beforeOrdinal; + const reports = []; + while (cursor > 1n && reports.length < limit) { + cursor -= 1n; + const report = tx.db.realmChatReportV1.reportOrdinal.find(cursor); + if (report === null) throw new SenderError('REALM_CHAT_REPORT_STATE_INTEGRITY'); + reports.push(reportEntry(report)); + } + return { + reports, + nextBeforeOrdinal: reports.length === 0 + ? undefined + : reports[reports.length - 1].reportOrdinal, + hasMore: cursor > 1n, + totalReports, + }; + }), +); + +export const adminGetRealmChatReportContextV1 = warpkeep.procedure( + { name: 'admin_get_realm_chat_report_context_v1' }, + { reportId: t.string() }, + adminRealmChatReportContextV1, + (ctx, { reportId }) => ctx.withTx(tx => { + requireAdmin(tx); + const report = tx.db.realmChatReportV1.reportId.find( + canonicalMessageId(reportId), + ); + if (report === null) throw new SenderError('REALM_CHAT_REPORT_NOT_FOUND'); + const messages = []; + for ( + let sequence = report.contextFirstSequence; + sequence <= report.contextLastSequence; + sequence += 1n + ) { + const message = tx.db.realmChatMessageV1.sequence.find(sequence); + if (message !== null) messages.push(projectEvidenceMessage(message)); + if (sequence === U64_MAXIMUM) break; + } + return { report: reportEntry(report), messages }; + }), +); + +export const adminResolveRealmChatReportV1 = warpkeep.reducer( + { name: 'admin_resolve_realm_chat_report_v1' }, + { reportId: t.string(), resolutionCode: t.string() }, + (ctx, { reportId, resolutionCode }) => { + const admin = requireAdmin(ctx); + if (!(REPORT_RESOLUTION_CODES as readonly string[]).includes(resolutionCode)) { + throw new SenderError('REALM_CHAT_REPORT_RESOLUTION_INVALID'); + } + const report = ctx.db.realmChatReportV1.reportId.find( + canonicalMessageId(reportId), + ); + if (report === null) throw new SenderError('REALM_CHAT_REPORT_NOT_FOUND'); + if (report.status === 'resolved') { + if (report.resolutionCode !== resolutionCode) { + throw new SenderError('REALM_CHAT_REPORT_ALREADY_RESOLVED'); + } + return; + } + if (report.status !== 'pending') throw new SenderError('REALM_CHAT_REPORT_STATE_INTEGRITY'); + ctx.db.realmChatReportV1.reportOrdinal.update({ + ...report, + status: 'resolved', + reviewedAt: ctx.timestamp, + resolutionCode, + }); + ctx.db.adminAudit.insert({ + id: 0n, + action: 'realm_chat_report_resolved_v1', + targetFid: report.reportedSenderFid, + actorSubject: admin.subject, + createdAt: ctx.timestamp, + note: `report=${report.reportId};resolution=${resolutionCode}`, + }); + }, +); diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index bcb0f6bd..dd103f89 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -1222,6 +1222,120 @@ export const dailyMarkScheduleV1 = table( }, ); +/** Public, identity-free readiness projection for the single Realm channel. */ +export const realmChatStatusV1 = table( + { name: 'realm_chat_status_v1', public: true }, + { + channelKey: t.string().primaryKey(), + realmId: t.string().index(), + policyVersion: t.string(), + mode: t.string(), + recentLimit: t.u32(), + historyPageLimit: t.u32(), + updatedAt: t.timestamp(), + }, +); + +/** Private channel authority and monotonic sequence cursor. */ +export const realmChatChannelV1 = table( + { name: 'realm_chat_channel_v1' }, + { + channelKey: t.string().primaryKey(), + realmId: t.string().unique(), + policyVersion: t.string(), + mode: t.string(), + nextSequence: t.u64(), + updatedAt: t.timestamp(), + }, +); + +/** + * Private permanent message archive. The full body and moderation metadata + * must never be exposed by a public subscription. + */ +export const realmChatMessageV1 = table( + { + name: 'realm_chat_message_v1', + indexes: [{ + accessor: 'byChannelAndSequence', + algorithm: 'btree', + columns: ['channelKey', 'sequence'] as const, + }] as const, + }, + { + messageId: t.string().primaryKey(), + sequence: t.u64().unique(), + channelKey: t.string(), + senderFid: t.u64().index(), + body: t.string(), + sentAt: t.timestamp(), + visibility: t.string(), + moderatedAt: t.option(t.timestamp()), + moderationCode: t.option(t.string()), + }, +); + +/** Bounded public projection maintained transactionally at at most 128 rows. */ +export const realmChatRecentV1 = table( + { name: 'realm_chat_recent_v1', public: true }, + { + sequence: t.u64().primaryKey(), + messageId: t.string().unique(), + channelKey: t.string().index(), + senderFid: t.u64().index(), + body: t.string(), + sentAt: t.timestamp(), + visibility: t.string(), + }, +); + +/** Private, per-sender rolling-window ledger; authority prunes it to one hour. */ +export const realmChatRateEventV1 = table( + { name: 'realm_chat_rate_event_v1' }, + { + eventId: t.string().primaryKey(), + fid: t.u64().index(), + acceptedAtMicros: t.u64(), + bodyDigest: t.string(), + }, +); + +/** Private exactly-once receipts for retried browser send operations. */ +export const realmChatSendReceiptV1 = table( + { name: 'realm_chat_send_receipt_v1' }, + { + operationKey: t.string().primaryKey(), + fid: t.u64().index(), + requestKey: t.string(), + bodyDigest: t.string(), + messageId: t.string().unique(), + sequence: t.u64().unique(), + createdAt: t.timestamp(), + }, +); + +/** Private, one-reporter/one-message evidence record. No automatic sanction. */ +export const realmChatReportV1 = table( + { name: 'realm_chat_report_v1' }, + { + reportOrdinal: t.u64().primaryKey().autoInc(), + reportKey: t.string().unique(), + reportId: t.string().unique(), + reporterFid: t.u64().index(), + messageId: t.string().index(), + reportedSenderFid: t.u64(), + messageSequence: t.u64(), + category: t.string(), + details: t.string(), + contextFirstSequence: t.u64(), + contextLastSequence: t.u64(), + createdAt: t.timestamp(), + status: t.string(), + reviewedAt: t.option(t.timestamp()), + resolutionCode: t.option(t.string()), + }, +); + const warpkeep = schema({ // Preserve the original production schema prefix exactly. New tables are // append-only so SpacetimeDB can apply this migration without rewriting it. @@ -1281,6 +1395,13 @@ const warpkeep = schema({ accessRequestV1, dailyMarkGrantV1, dailyMarkScheduleV1, + realmChatStatusV1, + realmChatChannelV1, + realmChatMessageV1, + realmChatRecentV1, + realmChatRateEventV1, + realmChatSendReceiptV1, + realmChatReportV1, }); /** diff --git a/spacetimedb/tests/accessRequestMigrationTooling.test.ts b/spacetimedb/tests/accessRequestMigrationTooling.test.ts index 1e0e31d8..5b30743e 100644 --- a/spacetimedb/tests/accessRequestMigrationTooling.test.ts +++ b/spacetimedb/tests/accessRequestMigrationTooling.test.ts @@ -16,19 +16,34 @@ function registrations(text: string, marker: string): string[] { .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); } -test('v13 remains the exact frozen v12 prefix while v14 appends daily Marks', () => { +test('v14 remains frozen while v15 appends Realm Chat after daily Marks', () => { const v12 = source('../migration-fixtures/additive-v12-schema/src/index.ts'); const v13 = source('../migration-fixtures/additive-v13-schema/src/index.ts'); + const v14 = source('../migration-fixtures/additive-v14-schema/src/index.ts'); + const v15 = source('../migration-fixtures/additive-v15-schema/src/index.ts'); const candidate = source('../src/schema.ts'); const v12Tables = registrations(v12, 'const db = schema({'); const v13Tables = registrations(v13, 'const db = schema({'); + const v14Tables = registrations(v14, 'const db = schema({'); + const v15Tables = registrations(v15, 'const db = schema({'); const candidateTables = registrations(candidate, 'const warpkeep = schema({'); assert.equal(v12Tables.length, 53); assert.deepEqual(v13Tables.slice(0, 53), v12Tables); - assert.deepEqual(candidateTables.slice(0, 54), v13Tables); + assert.deepEqual(v14Tables.slice(0, 54), v13Tables); assert.deepEqual(v13Tables.slice(53), ['accessRequestV1']); - assert.deepEqual(candidateTables.slice(54), ['dailyMarkGrantV1', 'dailyMarkScheduleV1']); + assert.deepEqual(v14Tables.slice(54), ['dailyMarkGrantV1', 'dailyMarkScheduleV1']); + assert.deepEqual(v15Tables.slice(0, 56), v14Tables); + assert.deepEqual(candidateTables, v15Tables); + assert.deepEqual(candidateTables.slice(56), [ + 'realmChatStatusV1', + 'realmChatChannelV1', + 'realmChatMessageV1', + 'realmChatRecentV1', + 'realmChatRateEventV1', + 'realmChatSendReceiptV1', + 'realmChatReportV1', + ]); assert.match(v13, /const accessRequestV1 = table\(\{ name: 'access_request_v1' \}, \{/); assert.match( v13, @@ -40,7 +55,7 @@ test('v13 remains the exact frozen v12 prefix while v14 appends daily Marks', () ); }); -test('general rehearsal binds v14 schema and row preservation with deletion disabled', () => { +test('general rehearsal binds v15 schema and row preservation with deletion disabled', () => { const proof = source('../../scripts/verify-spacetime-additive-migration.mjs'); const receipt = source('../../scripts/spacetime-additive-migration-proof.mjs'); @@ -50,17 +65,22 @@ test('general rehearsal binds v14 schema and row preservation with deletion disa assert.match(proof, /spacetimedb\/migration-fixtures\/additive-v14-schema/); assert.match(proof, /function assertAdditiveV14Schema\(before, after\)/); assert.match(proof, /assertAdditiveV14Schema\(emptyV13, emptyV14\)/); + assert.match(proof, /spacetimedb\/migration-fixtures\/additive-v15-schema/); + assert.match(proof, /function assertAdditiveV15Schema\(before, after\)/); + assert.match(proof, /assertAdditiveV15Schema\(emptyV14, emptyV15\)/); assert.match(proof, /tableRowDigests\([\s\S]*deployedV12Tables[\s\S]*populatedWaterStoneV12Rows/); assert.match(proof, /'access_request_v1',[\s\S]*\),\s*0n/); assert.match(proof, /'--delete-data=never'/); assert.match(proof, /value\.startsWith\('--delete-data='/); assert.doesNotMatch(proof, /--delete-data=(?:always|on-conflict|if-required)/); - assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 14/); + assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15/); assert.match(receipt, /const V13_TABLE_SCHEMA_RECEIPT_FIELD = 'v13_table_schema_sha256'/); assert.match(receipt, /v13TableSchemaDigest/); assert.match(receipt, /const V14_TABLE_SCHEMA_RECEIPT_FIELD = 'v14_table_schema_sha256'/); assert.match(receipt, /v14TableSchemaDigest/); + assert.match(receipt, /const V15_TABLE_SCHEMA_RECEIPT_FIELD = 'v15_table_schema_sha256'/); + assert.match(receipt, /v15TableSchemaDigest/); }); test('connected rehearsal contains the bounded private request lifecycle', () => { @@ -116,7 +136,7 @@ test('connected rehearsal contains the bounded private request lifecycle', () => assert.match(proof, /status: 'already_admitted'[\s\S]*pendingRequests: 0n/); assert.ok(invocation >= 0); - assert.match(finalOwnerRead, /additiveV14SchemaFixture/); + assert.match(finalOwnerRead, /additiveV15SchemaFixture/); assert.match(finalOwnerRead, /tableRowDigests\([\s\S]*deployedV12Tables/); assert.match(finalOwnerRead, /access_request_v1 WHERE fid = \$\{syntheticMissingAccessRequestFid\}/); assert.match(finalOwnerRead, /access_request_v1 WHERE fid = \$\{syntheticSecondAccessRequestFid\}/); @@ -134,7 +154,7 @@ test('dedicated Worker v11-to-v12 proof remains a separate frozen boundary', () assert.doesNotMatch(verifier, /additive-v13-schema|accessRequestV1/); }); -test('workspace metadata includes frozen v13 and current v14 fixtures', () => { +test('workspace metadata includes frozen v13/v14 and current v15 fixtures', () => { assert.match( source('../migration-fixtures/additive-v13-schema/package.json'), /warpkeep-additive-v13-schema-migration-fixture/, @@ -151,4 +171,12 @@ test('workspace metadata includes frozen v13 and current v14 fixtures', () => { source('../pnpm-lock.yaml'), /migration-fixtures\/additive-v14-schema:/, ); + assert.match( + source('../migration-fixtures/additive-v15-schema/package.json'), + /warpkeep-additive-v15-schema-migration-fixture/, + ); + assert.match( + source('../pnpm-lock.yaml'), + /migration-fixtures\/additive-v15-schema:/, + ); }); diff --git a/spacetimedb/tests/accessRequestReducers.test.ts b/spacetimedb/tests/accessRequestReducers.test.ts index aeec4805..f010424d 100644 --- a/spacetimedb/tests/accessRequestReducers.test.ts +++ b/spacetimedb/tests/accessRequestReducers.test.ts @@ -44,6 +44,13 @@ test('access requests add one exact private table at the end of the deployed v12 assert.deepEqual(currentRegistrations.slice(54), [ 'dailyMarkGrantV1', 'dailyMarkScheduleV1', + 'realmChatStatusV1', + 'realmChatChannelV1', + 'realmChatMessageV1', + 'realmChatRecentV1', + 'realmChatRateEventV1', + 'realmChatSendReceiptV1', + 'realmChatReportV1', ]); const definition = section( diff --git a/spacetimedb/tests/castleWorkerAuthority.test.ts b/spacetimedb/tests/castleWorkerAuthority.test.ts index 24e76124..4e534080 100644 --- a/spacetimedb/tests/castleWorkerAuthority.test.ts +++ b/spacetimedb/tests/castleWorkerAuthority.test.ts @@ -145,7 +145,10 @@ test('worker reads use bounded indexes and public tables omit assignment correla assert.match(occupationMatch, /assignment\.phase !== 'returning'/); assert.doesNotMatch(section(schema, 'export const castleWorkerV1', 'export const workerAssignmentV1'), /assignmentId/); assert.doesNotMatch(section(schema, 'export const workerNodeOccupationV1', 'export const workerCommandIdempotencyV1'), /assignmentId/); - assert.doesNotMatch(section(schema, 'export const workerAssignmentScheduleV1', 'const warpkeep = schema'), /public: true/); + assert.doesNotMatch( + section(schema, 'export const workerAssignmentScheduleV1', 'export const realmChatStatusV1'), + /public: true/, + ); }); test('atomic worker control state uses one caller-bound transaction and one projection clock', () => { diff --git a/spacetimedb/tests/dailyMarksReducers.test.ts b/spacetimedb/tests/dailyMarksReducers.test.ts index 6cc1e6cc..b7fbe178 100644 --- a/spacetimedb/tests/dailyMarksReducers.test.ts +++ b/spacetimedb/tests/dailyMarksReducers.test.ts @@ -27,7 +27,7 @@ test('v14 appends private daily receipt and identity-free schedule tables', () = assert.match(grants, /fid: t\.u64\(\)\.index\(\)/); assert.match(grants, /utcDay: t\.u64\(\)\.index\(\)/); - const schedule = section(schema, 'export const dailyMarkScheduleV1', 'const warpkeep = schema({'); + const schedule = section(schema, 'export const dailyMarkScheduleV1', 'export const realmChatStatusV1'); assert.match(schedule, /name: 'daily_mark_schedule_v_1'/); assert.doesNotMatch(schedule, /fid|amountMicros|public:\s*true/); assert.match(schedule, /scheduled: \(\): any => runDailyMarkScheduleV1/); diff --git a/spacetimedb/tests/playerIdentityPrivacy.test.ts b/spacetimedb/tests/playerIdentityPrivacy.test.ts index f6b5d7b1..b3855c30 100644 --- a/spacetimedb/tests/playerIdentityPrivacy.test.ts +++ b/spacetimedb/tests/playerIdentityPrivacy.test.ts @@ -137,6 +137,8 @@ test('generated bindings contain the public projections and omit every private e 'gold_site_v_1_table.ts', 'player_table.ts', 'player_v_2_table.ts', + 'realm_chat_recent_v_1_table.ts', + 'realm_chat_status_v_1_table.ts', 'realm_environment_v_1_table.ts', 'realm_forest_instance_v_1_table.ts', 'realm_forest_layout_v_1_table.ts', @@ -223,6 +225,11 @@ test('generated bindings contain the public projections and omit every private e 'gold_expedition_v_1', 'mark_account_v_1', 'player_ownership_v_2', + 'realm_chat_channel_v_1', + 'realm_chat_message_v_1', + 'realm_chat_rate_event_v_1', + 'realm_chat_report_v_1', + 'realm_chat_send_receipt_v_1', 'snap_burn_credit_v_1', 'snap_scan_batch_v_1', 'snap_scan_cursor_v_1', diff --git a/spacetimedb/tests/realmChatAuthority.test.ts b/spacetimedb/tests/realmChatAuthority.test.ts new file mode 100644 index 00000000..76d81a0e --- /dev/null +++ b/spacetimedb/tests/realmChatAuthority.test.ts @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function section(text: string, start: string, end?: string): string { + const startAt = text.indexOf(start); + assert.notEqual(startAt, -1, `missing source marker: ${start}`); + if (end === undefined) return text.slice(startAt); + const endAt = text.indexOf(end, startAt + start.length); + assert.notEqual(endAt, -1, `missing source marker: ${end}`); + return text.slice(startAt, endAt); +} + +function registrations(text: string, marker: string): string[] { + const registration = section(text, marker, '\n});'); + return registration + .split(/[\n,]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +test('v15 appends exactly seven privacy-separated Realm Chat tables', () => { + const schema = source('../src/schema.ts'); + const v14 = source('../migration-fixtures/additive-v14-schema/src/index.ts'); + const v15 = source('../migration-fixtures/additive-v15-schema/src/index.ts'); + const current = registrations(schema, 'const warpkeep = schema({'); + const predecessor = registrations(v14, 'const db = schema({'); + const fixture = registrations(v15, 'const db = schema({'); + + assert.deepEqual(current, fixture); + assert.deepEqual(current.slice(0, predecessor.length), predecessor); + assert.deepEqual(current.slice(predecessor.length), [ + 'realmChatStatusV1', + 'realmChatChannelV1', + 'realmChatMessageV1', + 'realmChatRecentV1', + 'realmChatRateEventV1', + 'realmChatSendReceiptV1', + 'realmChatReportV1', + ]); + + for (const publicName of ['realmChatStatusV1', 'realmChatRecentV1']) { + assert.match(section(schema, `export const ${publicName} = table(`, '\n);'), /public: true/); + } + for (const privateName of [ + 'realmChatChannelV1', + 'realmChatMessageV1', + 'realmChatRateEventV1', + 'realmChatSendReceiptV1', + 'realmChatReportV1', + ]) { + assert.doesNotMatch( + section(schema, `export const ${privateName} = table(`, '\n);'), + /public: true/, + ); + } +}); + +test('send, history, and reporting derive identity, time, order, and context on the server', () => { + const reducer = source('../src/reducers/realmChat.ts'); + const send = section( + reducer, + 'export const sendRealmChatMessageV1', + '/** Caller-gated, exclusive-cursor history.', + ); + const history = section( + reducer, + 'export const getRealmChatHistoryV1', + '/** One private report per caller/message;', + ); + const report = section( + reducer, + 'export const reportRealmChatMessageV1', + 'function inspectRealmChat', + ); + + assert.match(send, /\{ requestKey: t\.string\(\), body: t\.string\(\) \}/); + assert.doesNotMatch(send, /senderFid: t\.|sequence: t\.|sentAt: t\.|channelKey: t\./); + assert.match(send, /requireGameplayPlayerV1\(ctx\)/); + assert.match(send, /normalizeRealmChatBody\(body\)/); + assert.match(send, /evaluateRealmChatRateLimit\(rateRows, nowMicros, digest\)/); + assert.match(send, /messageId: ctx\.newUuidV7\(\)\.toString\(\)/); + assert.match(send, /sentAt: ctx\.timestamp/); + assert.match(send, /sequence: channel\.nextSequence/); + assert.match(send, /realmChatSendReceiptV1\.operationKey\.find\(operationKey\)/); + assert.match(send, /pruneRecentProjection\(ctx\)/); + assert.match(reducer, /function recentProjectionMatchesArchive/); + assert.match(reducer, /recentProjectionMatchesArchive\(ctx, channel\)/); + + assert.match(history, /requireGameplayPlayerV1\(tx\)/); + assert.match(history, /limit < 1 \|\| limit > REALM_CHAT_HISTORY_PAGE_LIMIT/); + assert.match(history, /while \(cursor > 1n && messages\.length < limit\)/); + assert.match(history, /realmChatMessageV1\.sequence\.find\(cursor\)/); + assert.doesNotMatch(history, /realmChatMessageV1\.iter\(\)/); + + assert.match(report, /requireGameplayPlayerV1\(ctx\)/); + assert.match(report, /canonicalMessageId\(messageId\)/); + assert.match(report, /message\.senderFid === claims\.fid/); + assert.match(report, /realmChatReportV1\.reportKey\.find\(key\)/); + assert.match( + report, + /realmChatContextBounds\(message\.sequence, channel\.nextSequence - 1n\)/, + ); + assert.doesNotMatch(report, /visibility:\s*'tombstoned'|realmChatRecentV1\..*update/); +}); + +test('moderation preserves private evidence while public tombstones reveal no body', () => { + const reducer = source('../src/reducers/realmChat.ts'); + const evidence = section(reducer, 'function projectEvidenceMessage', 'function reportEntry'); + const tombstone = section( + reducer, + 'export const adminTombstoneRealmChatMessageV1', + 'export const adminListRealmChatReportsV1', + ); + const context = section( + reducer, + 'export const adminGetRealmChatReportContextV1', + 'export const adminResolveRealmChatReportV1', + ); + + assert.match(evidence, /body: message\.body/); + assert.match(tombstone, /requireAdmin\(ctx\)/); + assert.match(tombstone, /realmChatMessageV1\.messageId\.update/); + assert.match(tombstone, /realmChatRecentV1\.sequence\.update\(\{[\s\S]*body: ''/); + assert.doesNotMatch(tombstone, /realmChatMessageV1\.messageId\.delete/); + assert.match(context, /requireAdmin\(tx\)/); + assert.match(context, /messages\.push\(projectEvidenceMessage\(message\)\)/); +}); + +test('activation is doubly gated and every operational admin path is audited or read-only', () => { + const policy = source('../src/realmChatPolicy.ts'); + const legal = source('../../src/legal/realmChatPolicy.ts'); + const publisher = source('../../scripts/publish-spacetime-dev.mjs'); + const reducer = source('../src/reducers/realmChat.ts'); + const activate = section( + reducer, + 'export const adminActivateRealmChatV1', + 'export const adminDisableRealmChatV1', + ); + + assert.match(policy, /REALM_CHAT_SERVER_ACTIVATION_ALLOWED = false/); + assert.match(legal, /WARPKEEP_REALM_CHAT_CLIENT_ENTRY_ENABLED = false/); + assert.match( + publisher, + /Realm Chat protocol v15 is review-only and cannot be published by this build/, + ); + assert.match(activate, /const admin = requireAdmin\(ctx\)/); + assert.match(activate, /REALM_CHAT_ACTIVATION_NOT_COMPILED/); + assert.match(activate, /expectedPolicyVersion !== REALM_CHAT_POLICY_VERSION/); + assert.match(activate, /action: 'realm_chat_activated_v1'/); + for (const action of [ + 'realm_chat_staged_v1', + 'realm_chat_activated_v1', + 'realm_chat_disabled_v1', + 'realm_chat_message_tombstoned_v1', + 'realm_chat_report_resolved_v1', + ]) assert.match(reducer, new RegExp(`action: '${action}'`)); +}); + +test('player bindings expose only the bounded public pair and caller operations', () => { + const bindings = source('../../src/spacetime/playerModuleBindings.ts'); + for (const publicName of ['realmChatStatusV1', 'realmChatRecentV1']) { + assert.match(bindings, new RegExp(`\\b${publicName}\\b`)); + } + for (const operation of [ + 'SendRealmChatMessageV1Reducer', + 'ReportRealmChatMessageV1Reducer', + 'GetRealmChatHistoryV1Procedure', + ]) assert.match(bindings, new RegExp(`\\b${operation}\\b`)); + for (const privateName of [ + 'realmChatChannelV1', + 'realmChatMessageV1', + 'realmChatRateEventV1', + 'realmChatSendReceiptV1', + 'realmChatReportV1', + 'adminActivateRealmChatV1', + 'adminTombstoneRealmChatMessageV1', + ]) assert.doesNotMatch(bindings, new RegExp(`\\b${privateName}\\b`)); + + const root = new URL('../../src/spacetime/module_bindings/', import.meta.url); + assert.equal(existsSync(new URL('realm_chat_status_v_1_table.ts', root)), true); + assert.equal(existsSync(new URL('realm_chat_recent_v_1_table.ts', root)), true); + assert.equal(existsSync(new URL('realm_chat_message_v_1_table.ts', root)), false); +}); diff --git a/spacetimedb/tests/resourceReducers.test.ts b/spacetimedb/tests/resourceReducers.test.ts index 5a523e4c..b3e20acb 100644 --- a/spacetimedb/tests/resourceReducers.test.ts +++ b/spacetimedb/tests/resourceReducers.test.ts @@ -87,6 +87,13 @@ test('resource and Gold prefixes remain intact through later additive suffixes', 'accessRequestV1', 'dailyMarkGrantV1', 'dailyMarkScheduleV1', + 'realmChatStatusV1', + 'realmChatChannelV1', + 'realmChatMessageV1', + 'realmChatRecentV1', + 'realmChatRateEventV1', + 'realmChatSendReceiptV1', + 'realmChatReportV1', ]); const account = tableDefinition(schema, 'resourceAccountV1'); diff --git a/spacetimedb/tests/waterRevisionAuthority.test.ts b/spacetimedb/tests/waterRevisionAuthority.test.ts index 4d412baa..21f76217 100644 --- a/spacetimedb/tests/waterRevisionAuthority.test.ts +++ b/spacetimedb/tests/waterRevisionAuthority.test.ts @@ -62,5 +62,5 @@ test('the append-only public revision table stores policy without topology', () assert.match(revision, /navigationFogBoundaryDepthCells: t\.u32\(\)/); assert.match(revision, /activatedAt: t\.option\(t\.timestamp\(\)\)/); assert.doesNotMatch(revision, /\n\s*q:|\n\s*r:|cellKey:|bodyId:/); - assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\s*realmWorkerSystemV1,\n\s*castleWorkerV1,\n\s*workerAssignmentV1,\n\s*workerNodeOccupationV1,\n\s*workerCommandIdempotencyV1,\n\s*workerAssignmentScheduleV1,\n\s*accessRequestV1,\n\s*dailyMarkGrantV1,\n\s*dailyMarkScheduleV1,\n\}\);/); + assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\s*realmWorkerSystemV1,\n\s*castleWorkerV1,\n\s*workerAssignmentV1,\n\s*workerNodeOccupationV1,\n\s*workerCommandIdempotencyV1,\n\s*workerAssignmentScheduleV1,\n\s*accessRequestV1,\n\s*dailyMarkGrantV1,\n\s*dailyMarkScheduleV1,\n\s*realmChatStatusV1,\n\s*realmChatChannelV1,\n\s*realmChatMessageV1,\n\s*realmChatRecentV1,\n\s*realmChatRateEventV1,\n\s*realmChatSendReceiptV1,\n\s*realmChatReportV1,\n\}\);/); }); diff --git a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts index 2efe2a86..98125382 100644 --- a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts +++ b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts @@ -57,11 +57,11 @@ test('the auth-neutral v11 fixture extends the exact v10 table prefix at ref 46' assert.match(v11, /name: 'fixture_seed_water_revision_sentinel_v11'/); }); -test('the migration verifier retains the populated v10 to v11 proof inside protocol v14', () => { +test('the migration verifier retains the populated v10 to v11 proof inside protocol v15', () => { const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs'); const receipt = source('../../scripts/spacetime-additive-migration-proof.mjs'); - assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 14/); + assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15/); assert.match(verifier, /spacetimedb\/migration-fixtures\/additive-v11-schema/); assert.match(verifier, /const additiveV11Tables = Object\.freeze\(\[\s*'realm_water_revision_v1'/); assert.match(verifier, /realm_water_revision_v1: 46/); @@ -72,9 +72,9 @@ test('the migration verifier retains the populated v10 to v11 proof inside proto assert.match(verifier, /populatedWaterStoneV10Rows/); assert.match(verifier, /populatedWaterStoneV11Rows/); assert.match(verifier, /additiveV10SchemaFixture,[\s\S]{0,120}populatedWaterStoneMigrationDatabase,[\s\S]{0,40}false/); - assert.match(verifier, /v14 boundary must refuse its immediate predecessor/); + assert.match(verifier, /v15 boundary must refuse its immediate predecessor/); assert.match(verifier, /deployedV11Tables/); - assert.match(verifier, /populated Water\/Stone\/Water-revision fixtures remained preserved through v14/); + assert.match(verifier, /populated Water\/Stone\/Water-revision fixtures remained preserved through v15/); assert.match(verifier, /stage = 'revision-base-precondition'/); assert.match(verifier, /stage = 'revision-inert-base-rejection'/); assert.match(verifier, /stage = 'revision-admin-denial'/); @@ -90,6 +90,6 @@ test('the migration verifier retains the populated v10 to v11 proof inside proto assert.ok(inspectionFixtures.length >= 4); assert.deepEqual( new Set(inspectionFixtures), - new Set(['additiveV14SchemaFixture']), + new Set(['additiveV15SchemaFixture']), ); }); diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx index 7bf35cab..448576e7 100644 --- a/src/components/WarpkeepExperience.tsx +++ b/src/components/WarpkeepExperience.tsx @@ -1629,6 +1629,10 @@ export function WarpkeepExperience() { ? backend.returnLegacyExpedition : undefined } + realmChat={backend.realmChat} + onSendRealmChatMessage={backend.sendRealmChatMessage} + onReportRealmChatMessage={backend.reportRealmChatMessage} + onLoadEarlierRealmChat={backend.loadEarlierRealmChat} graphicsPreference={graphicsPreference} resolvedGraphicsQuality={resolvedGraphicsQuality} audioMuted={audioMuted} diff --git a/src/components/realm/RealmChatDock.css b/src/components/realm/RealmChatDock.css new file mode 100644 index 00000000..ad6cf27e --- /dev/null +++ b/src/components/realm/RealmChatDock.css @@ -0,0 +1,562 @@ +.realm-chat-dock { + position: fixed; + z-index: 24; + left: max(1rem, var(--realm-safe-left)); + bottom: max(1rem, var(--realm-safe-bottom)); + color: #f7f0df; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + pointer-events: none; +} + +.realm-chat-dock button, +.realm-chat-dock textarea, +.realm-chat-dock select { + font: inherit; +} + +.realm-chat-dock__launcher, +.realm-chat-dock__panel, +.realm-chat-report { + pointer-events: auto; +} + +.realm-chat-dock__launcher { + display: inline-flex; + gap: 0.55rem; + align-items: center; + min-height: 46px; + padding: 0.7rem 0.9rem; + border: 1px solid rgb(235 205 135 / 42%); + border-radius: 0.65rem; + background: linear-gradient(145deg, rgb(25 19 32 / 94%), rgb(9 8 13 / 96%)); + box-shadow: 0 0.8rem 2rem rgb(0 0 0 / 34%); + color: #f2d98f; + font-size: 0.68rem; + font-weight: 850; + letter-spacing: 0.12em; + cursor: pointer; + backdrop-filter: blur(16px); +} + +.realm-chat-dock__launcher:hover, +.realm-chat-dock__launcher:focus-visible { + border-color: #f3dc96; + outline: 2px solid #f3dc96; + outline-offset: 2px; +} + +.realm-chat-dock__sigil { + color: #bd86dd; + font-size: 1rem; +} + +.realm-chat-dock__launcher b { + display: grid; + min-width: 1.2rem; + height: 1.2rem; + padding-inline: 0.18rem; + place-items: center; + border-radius: 99px; + background: #8c45ad; + color: white; + font-size: 0.62rem; + letter-spacing: 0; +} + +.realm-chat-dock__panel { + display: grid; + width: min(24rem, calc(100vw - 2rem)); + height: min(35rem, calc(100vh - 7rem)); + height: min(35rem, calc(100dvh - 7rem)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid rgb(235 205 135 / 28%); + border-radius: 0.85rem; + background: + radial-gradient(circle at 12% 0%, rgb(113 58 139 / 24%), transparent 17rem), + rgb(9 8 13 / 95%); + box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 48%); + backdrop-filter: blur(22px); +} + +.realm-chat-dock__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.8rem 0.85rem 0.72rem; + border-bottom: 1px solid rgb(235 205 135 / 16%); +} + +.realm-chat-dock__header div, +.realm-chat-dock__header span, +.realm-chat-dock__header strong { + display: block; +} + +.realm-chat-dock__header span { + color: #bd9bcf; + font-size: 0.55rem; + font-weight: 850; + letter-spacing: 0.14em; +} + +.realm-chat-dock__header strong { + margin-top: 0.05rem; + color: #f0d58c; + font: 500 1.1rem/1.1 Georgia, "Times New Roman", serif; + letter-spacing: 0.035em; +} + +.realm-chat-dock__header > button { + width: 40px; + height: 40px; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: #d8cddc; + font-size: 1.25rem; + cursor: pointer; +} + +.realm-chat-dock__header > button:hover, +.realm-chat-dock__header > button:focus-visible { + background: rgb(255 255 255 / 7%); + outline: 2px solid #f0d58c; +} + +.realm-chat-dock__content { + position: relative; + display: grid; + min-height: 0; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.realm-chat-dock__messages { + min-height: 0; + padding: 0.7rem; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.realm-chat-dock__history, +.realm-chat-dock__unmute, +.realm-chat-dock__jump { + display: block; + margin: 0.2rem auto 0.7rem; + padding: 0.42rem 0.65rem; + border: 1px solid rgb(218 187 235 / 22%); + border-radius: 99px; + background: rgb(102 57 127 / 18%); + color: #d9c1e6; + font-size: 0.65rem; + font-weight: 750; + cursor: pointer; +} + +.realm-chat-dock__jump { + position: absolute; + z-index: 2; + right: 0.75rem; + bottom: 6.8rem; + margin: 0; + border-color: rgb(238 211 148 / 35%); + background: #2c2030; + color: #f1d98e; + box-shadow: 0 0.5rem 1.2rem rgb(0 0 0 / 35%); +} + +.realm-chat-dock__unmute { + width: 100%; + margin: 0; + border-width: 1px 0 0; + border-radius: 0; +} + +.realm-chat-dock__beginning, +.realm-chat-dock__error { + margin: 0.25rem 0 0.8rem; + color: #9f94a4; + font-size: 0.62rem; + text-align: center; +} + +.realm-chat-dock__error { + color: #ffc1ad; +} + +.realm-chat-dock__empty { + display: grid; + min-height: 13rem; + align-content: center; + justify-items: center; + padding: 1rem; + color: #bcb1c1; + text-align: center; +} + +.realm-chat-dock__empty strong { + color: #e7d9bd; + font: 500 1rem/1.2 Georgia, "Times New Roman", serif; +} + +.realm-chat-dock__empty span { + max-width: 17rem; + margin-top: 0.35rem; + font-size: 0.7rem; + line-height: 1.45; +} + +.realm-chat-message { + display: grid; + grid-template-columns: 2.1rem minmax(0, 1fr); + gap: 0.55rem; + padding: 0.48rem 0.35rem; + border-radius: 0.5rem; +} + +.realm-chat-message:hover { + background: rgb(255 255 255 / 2.5%); +} + +.realm-chat-message__avatar { + width: 2.05rem; + height: 2.05rem; + padding: 0; + overflow: hidden; + border: 1px solid rgb(225 194 124 / 42%); + border-radius: 50%; + background: linear-gradient(145deg, #70468a, #27182f); + color: white; + cursor: pointer; +} + +.realm-chat-message__avatar img, +.realm-chat-message__avatar span { + display: grid; + width: 100%; + height: 100%; + place-items: center; + object-fit: cover; +} + +.realm-chat-message__body { + min-width: 0; +} + +.realm-chat-message__body > header { + display: flex; + gap: 0.45rem; + align-items: baseline; +} + +.realm-chat-message__body > header button { + max-width: 13rem; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + color: #ead9b1; + font-size: 0.72rem; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.realm-chat-message__body time { + color: #817985; + font-size: 0.58rem; +} + +.realm-chat-message__body > p { + margin: 0.15rem 0 0; + overflow-wrap: anywhere; + color: #ede7ee; + font-size: 0.74rem; + line-height: 1.4; + white-space: pre-wrap; +} + +.realm-chat-message__removed { + color: #948b99 !important; + font-style: italic; +} + +.realm-chat-keeper-card { + margin-top: 0.45rem; + padding: 0.55rem; + border: 1px solid rgb(227 196 128 / 18%); + border-radius: 0.5rem; + background: rgb(255 255 255 / 3%); +} + +.realm-chat-keeper-card > strong, +.realm-chat-keeper-card > span { + display: block; +} + +.realm-chat-keeper-card > strong { + color: #f2ddaa; + font-size: 0.72rem; +} + +.realm-chat-keeper-card > span { + margin-top: 0.1rem; + color: #a89dab; + font-size: 0.64rem; +} + +.realm-chat-keeper-card > div { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.5rem; +} + +.realm-chat-keeper-card button { + min-height: 32px; + padding: 0.32rem 0.48rem; + border: 1px solid rgb(235 205 135 / 24%); + border-radius: 0.35rem; + background: rgb(21 17 26 / 80%); + color: #d8cadc; + font-size: 0.61rem; + cursor: pointer; +} + +.realm-chat-composer { + padding: 0.65rem; + border-top: 1px solid rgb(235 205 135 / 14%); + background: rgb(5 5 8 / 74%); +} + +.realm-chat-composer textarea { + display: block; + width: 100%; + min-height: 3rem; + max-height: 8rem; + box-sizing: border-box; + resize: vertical; + padding: 0.58rem 0.65rem; + border: 1px solid rgb(223 194 128 / 22%); + border-radius: 0.52rem; + outline: 0; + background: rgb(255 255 255 / 4%); + color: #fff9ef; + font-size: 0.74rem; + line-height: 1.35; +} + +.realm-chat-composer textarea:focus { + border-color: #e8cb81; + box-shadow: 0 0 0 2px rgb(232 203 129 / 14%); +} + +.realm-chat-composer__rail { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.45rem; +} + +.realm-chat-composer__rail span { + color: #938998; + font-size: 0.6rem; +} + +.realm-chat-composer__rail span[data-invalid="true"], +.realm-chat-composer > p { + color: #ffb49d; +} + +.realm-chat-composer__rail button { + min-width: 4.2rem; + min-height: 36px; + border: 1px solid #d7b968; + border-radius: 0.42rem; + background: linear-gradient(145deg, #d9bb6e, #96743c); + color: #17100b; + font-size: 0.68rem; + font-weight: 850; + cursor: pointer; +} + +.realm-chat-composer__rail button:disabled { + opacity: 0.42; + cursor: default; +} + +.realm-chat-composer > p { + margin: 0.4rem 0 0; + font-size: 0.62rem; +} + +.realm-chat-dock[data-compact="true"] { + inset: 0; + z-index: 31; +} + +.realm-chat-dock[data-compact="true"][data-open="false"] { + inset: auto auto max(0.75rem, var(--realm-safe-bottom)) max(0.75rem, var(--realm-safe-left)); +} + +.realm-chat-dock[data-compact="true"] .realm-fullscreen-surface { + pointer-events: auto; +} + +.realm-chat-dock[data-compact="true"] .realm-fullscreen-surface__body { + display: grid; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.realm-chat-dock[data-compact="true"] .realm-chat-dock__content { + min-height: 0; +} + +.realm-chat-report { + position: fixed; + z-index: 70; + inset: 0; + display: grid; + padding: 1rem; + place-items: center; + background: rgb(3 3 6 / 76%); + backdrop-filter: blur(10px); +} + +.realm-chat-report__dialog { + width: min(28rem, 100%); + max-height: calc(100dvh - 2rem); + box-sizing: border-box; + padding: 1rem; + overflow-y: auto; + border: 1px solid rgb(237 209 143 / 30%); + border-radius: 0.8rem; + background: #100d16; + box-shadow: 0 2rem 5rem rgb(0 0 0 / 55%); +} + +.realm-chat-report__dialog header p, +.realm-chat-report__dialog h2 { + margin: 0; +} + +.realm-chat-report__dialog header p { + color: #bb91d0; + font-size: 0.58rem; + font-weight: 850; + letter-spacing: 0.14em; +} + +.realm-chat-report__dialog h2 { + margin-top: 0.1rem; + color: #f0d58c; + font: 500 1.35rem/1.15 Georgia, "Times New Roman", serif; +} + +.realm-chat-report__dialog h2:focus { + outline: 0; +} + +.realm-chat-report blockquote { + margin: 0.8rem 0; + padding: 0.7rem; + border-left: 2px solid #8e52ab; + background: rgb(255 255 255 / 3%); +} + +.realm-chat-report blockquote strong, +.realm-chat-report blockquote span { + display: block; +} + +.realm-chat-report blockquote strong { + color: #e9d6a9; + font-size: 0.7rem; +} + +.realm-chat-report blockquote span { + margin-top: 0.2rem; + overflow-wrap: anywhere; + color: #e7dfe9; + font-size: 0.74rem; + white-space: pre-wrap; +} + +.realm-chat-report__context-note { + color: #aea3b2; + font-size: 0.68rem; + line-height: 1.45; +} + +.realm-chat-report form, +.realm-chat-report label { + display: grid; + gap: 0.35rem; +} + +.realm-chat-report form { + gap: 0.75rem; +} + +.realm-chat-report label { + color: #d9cbdc; + font-size: 0.67rem; + font-weight: 750; +} + +.realm-chat-report label span { + color: #8e8492; + font-weight: 500; +} + +.realm-chat-report select, +.realm-chat-report textarea { + width: 100%; + box-sizing: border-box; + padding: 0.55rem; + border: 1px solid rgb(228 201 137 / 22%); + border-radius: 0.45rem; + background: #1a1620; + color: #f5eef6; +} + +.realm-chat-report__actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.realm-chat-report__actions button { + min-height: 40px; + padding: 0.5rem 0.7rem; + border: 1px solid rgb(231 202 135 / 30%); + border-radius: 0.45rem; + background: rgb(255 255 255 / 5%); + color: #e9dfe9; + font-size: 0.68rem; + font-weight: 800; + cursor: pointer; +} + +.realm-chat-report__actions button[type="submit"] { + background: #8a4aa8; + color: white; +} + +@media (prefers-reduced-motion: no-preference) { + .realm-chat-dock__panel { + animation: realm-chat-open 180ms cubic-bezier(0.2, 0.72, 0.28, 1); + transform-origin: left bottom; + } + + @keyframes realm-chat-open { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + } +} diff --git a/src/components/realm/RealmChatDock.tsx b/src/components/realm/RealmChatDock.tsx new file mode 100644 index 00000000..19d4ef66 --- /dev/null +++ b/src/components/realm/RealmChatDock.tsx @@ -0,0 +1,614 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent +} from 'react'; + +import type { + RealmChatHistoryPagePresentation, + RealmChatMessagePresentation, + RealmChatPresentation +} from '../../spacetime/realmChatPresentation'; +import { useModalFocusBoundary } from '../menu/useModalFocusBoundary'; +import { RealmFullScreenSurface } from './RealmFullScreenSurface'; +import type { RealmChromeMode } from './realmChromePresentation'; +import './RealmChatDock.css'; + +const CHAT_CHARACTER_LIMIT = 500; +const CHAT_LINE_LIMIT = 8; +const CHAT_LOCAL_MESSAGE_LIMIT = 512; + +const REPORT_CATEGORIES = Object.freeze([ + ['threat_or_harm', 'Threats or harm'], + ['harassment_or_hate', 'Harassment or hate'], + ['personal_information', 'Personal information'], + ['sexual_exploitation', 'Sexual exploitation'], + ['fraud_or_malware', 'Fraud or malware'], + ['illegal_trade', 'Illegal trade'], + ['spam_or_disruption', 'Spam or disruption'], + ['other', 'Other'] +] as const); + +export type RealmChatSenderProfile = Readonly<{ + fid: number; + label: string; + pfpUrl?: string; + castleId?: number; + castleName?: string; +}>; + +export type RealmChatDockProps = Readonly<{ + chat: RealmChatPresentation; + chromeMode: RealmChromeMode; + identityFid: number; + senderProfiles: ReadonlyMap; + onSend: (body: string) => Promise; + onReport: (messageId: string, category: string, details: string) => Promise; + onLoadEarlier: ( + beforeSequence: bigint, + limit?: number + ) => Promise; + onLocateCastle?: (castleId: number) => void; + onCompactBackChange?: (handler: (() => void) | undefined) => void; +}>; + +function messageTime(sentAtMicros: bigint) { + const milliseconds = Number(sentAtMicros / 1_000n); + if (!Number.isSafeInteger(milliseconds)) return ''; + try { + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit' + }).format(milliseconds); + } catch { + return ''; + } +} + +function mergeMessages( + recent: readonly RealmChatMessagePresentation[], + history: readonly RealmChatMessagePresentation[] +) { + const bySequence = new Map(); + for (const message of history) bySequence.set(message.sequence, message); + for (const message of recent) bySequence.set(message.sequence, message); + return Object.freeze( + [...bySequence.values()] + .sort((left, right) => left.sequence < right.sequence ? -1 : 1) + .slice(-CHAT_LOCAL_MESSAGE_LIMIT) + ); +} + +function fallbackSender(fid: number): RealmChatSenderProfile { + return Object.freeze({ fid, label: `Keeper #${fid}` }); +} + +function senderMonogram(profile: RealmChatSenderProfile) { + const trimmed = profile.label.replace(/^@/, '').trim(); + return trimmed[0]?.toLocaleUpperCase() ?? 'W'; +} + +function RealmChatAvatar({ profile }: Readonly<{ profile: RealmChatSenderProfile }>) { + return profile.pfpUrl ? ( + + ) : ( + + ); +} + +function RealmChatReportDialog({ + message, + profile, + onCancel, + onReport +}: Readonly<{ + message: RealmChatMessagePresentation; + profile: RealmChatSenderProfile; + onCancel: () => void; + onReport: (messageId: string, category: string, details: string) => Promise; +}>) { + const dialogRef = useRef(null); + const headingRef = useRef(null); + const [category, setCategory] = useState(REPORT_CATEGORIES[0][0]); + const [details, setDetails] = useState(''); + const [pending, setPending] = useState(false); + const [failed, setFailed] = useState(false); + const detailsLength = [...details].length; + const detailsValid = detailsLength <= 500; + useModalFocusBoundary({ dialogRef, initialFocusRef: headingRef, onEscape: onCancel }); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (pending || !detailsValid) return; + setPending(true); + setFailed(false); + try { + await onReport(message.messageId, category, details); + onCancel(); + } catch { + setFailed(true); + } finally { + setPending(false); + } + }; + + return ( +
+
+
+

REALM SAFETY

+

+ Report message +

+
+
+ {profile.label} + {message.body} +
+

+ The server will preserve this message and a small surrounding context window for review. +

+
+ +