diff --git a/.env.example b/.env.example index 0b379543..f6288928 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ # bridge value are valid. No VITE_ variable may contain a secret. VITE_WARPKEEP_SHARED_ALPHA_ENABLED=false VITE_SPACETIMEDB_URI=https://maincloud.spacetimedb.com -VITE_SPACETIMEDB_DATABASE=warpkeep-89e4u +VITE_SPACETIMEDB_DATABASE=c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e VITE_WARPKEEP_AUTH_BRIDGE_URL= VITE_WARPKEEP_OIDC_ISSUER= VITE_WARPKEEP_OIDC_AUDIENCE=warpkeep-spacetimedb diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e3e995d1..0c8546d8 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -31,7 +31,7 @@ jobs: VITE_WARPKEEP_OIDC_ISSUER: ${{ vars.WARPKEEP_OIDC_ISSUER || '' }} VITE_WARPKEEP_OIDC_AUDIENCE: ${{ vars.WARPKEEP_OIDC_AUDIENCE || 'warpkeep-spacetimedb' }} VITE_SPACETIMEDB_URI: ${{ vars.WARPKEEP_SPACETIMEDB_URI || 'https://maincloud.spacetimedb.com' }} - VITE_SPACETIMEDB_DATABASE: ${{ vars.WARPKEEP_SPACETIMEDB_DATABASE || 'warpkeep-89e4u' }} + VITE_SPACETIMEDB_DATABASE: ${{ vars.WARPKEEP_SPACETIMEDB_DATABASE || 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index bb240426..32ffe093 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -147,6 +147,7 @@ jobs: run: | pnpm --dir spacetimedb run verify npm run stdb:verify-bindings + npm run stdb:verify-worker-migration npm run stdb:verify-additive-migration - name: Audit module dependencies diff --git a/.gitignore b/.gitignore index 3189f5f8..7950bd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,20 @@ dist .DS_Store coverage *.local -.cache/warpkeep-assets/ -.cache/warpkeep-tools/ + +# Machine-local caches, deployment state, and diagnostic captures. +.cache/ +.wrangler/ +.dev.vars* +!.dev.vars.example +*.har +*.log + +# Credential-bearing files must stay in Keychain or ignored local config. +.npmrc +*.key +*.pem +*.p12 +*.pfx +*.jks +*.keystore diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2db587..1b848908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ full engineering record. ## [Unreleased] +## [0.3.14] — 2026-07-22 + +- Made the Realm recover from temporary graphics interruptions while preserving + selection and camera intent, and let castles continue at compact detail when + optional richer models cannot load. +- Gave river and ocean surfaces gentle motion and selectable, read-only public + records, including source-to-mouth river navigation. Reduced-motion play + keeps the water still. +- Refined the Lowlands toward a clearer green palette and denser grass coverage + without changing authoritative terrain, ownership, or resource rules. +- Staged a server-authoritative four-worker foundation behind inactive migration + and activation gates. Workers are not live in Alpha 0.3.14; the existing + expedition flow remains in place. + ## [0.3.13] — 2026-07-19 - Let the old scattered lakes return to lowland while preserving twelve @@ -111,7 +125,8 @@ full engineering record. Lowlands, a first keep, Farcaster sign-in, and an admission-gated shared-world foundation. -[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.13...HEAD +[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.14...HEAD +[0.3.14]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.13...v0.3.14 [0.3.13]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.12...v0.3.13 [0.3.12]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.11...v0.3.12 [0.3.11]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.8...v0.3.11 diff --git a/README.md b/README.md index 486cb73a..5a4865e5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## What is this? -Genesis 001 is a persistent, invite-only 10,000-cell Lowlands realm with 100 permanent castle sites kept close to its founding district. Each founder signs in with a verified Farcaster identity, receives one durable keep, and privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone are governed by authoritative terrain yield, while dedicated expeditions can gather all four resources. Alpha 0.3.13 is live but early; founders can explore its coast, twelve rivers, clustered forests, and resource sites, follow their supply wagons, and return to a world that remembers them, while the intended core strategy loop is not playable yet. Warpkeep is a one-person experiment—not a finished MMO or financial product; there are no token rewards, no financial promises, and joining does not earn an airdrop or financial return or guarantee a reward or future value. +Genesis 001 is a persistent, invite-only 10,000-cell Lowlands realm with 100 permanent castle sites kept close to its founding district. Each founder signs in with a verified Farcaster identity, receives one durable keep, and privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone are governed by authoritative terrain yield, while dedicated expeditions can gather all four resources. Alpha 0.3.14 is live but early; founders can explore its coast, twelve rivers, clustered forests, and resource sites, follow their supply wagons, and return to a world that remembers them, while the intended core strategy loop is not playable yet. Warpkeep is a one-person experiment—not a finished MMO or financial product; there are no token rewards, no financial promises, and joining does not earn an airdrop or financial return or guarantee a reward or future value.  @@ -27,7 +27,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri | State | Today | | --- | --- | -| ✅ Live | Alpha 0.3.13 is live and invite-only. | +| ✅ Live | Alpha 0.3.14 is live and invite-only. | | ✅ World | Genesis 001 persists 10,000 cells, a coastline, twelve one-cell rivers, and 100 permanent castle sites near the founding district. Founders return to one durable keep, explore the Lowlands up to its fog, and inspect nearby founders through their public username / portrait / castle. The same authoritative world waits across sessions. | | ✅ Authority | FID is the durable identity; handles and portraits are bounded presentation metadata. Farcaster sign-in uses a browser-bound, least-privilege bridge. The browser presents. The server decides admission and ownership. It also owns resources, timers, and saved state. | | ✅ Resources | Each keep privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone come from authoritative terrain yield and can also be gathered at Wheat Farms, Logging Camps, and Stone Quarries; Gold comes from Gold Mines. The resource rail shows stored and ready amounts, and hover, focus, or tap explains current behavior. The browser never invents balances. | @@ -52,7 +52,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri - **Architecture:** The [technical architecture](docs/technical-architecture.md) explains what the browser shows and what the server decides. - **Roadmap:** The [roadmap](docs/design/roadmap.md) and [game direction](docs/design/warpkeep-direction.md) separate today's game from later plans. - **Authentication:** The [Farcaster integration](docs/farcaster-integration.md) guide covers sign-in, privacy, and public configuration. -- **Release:** The [Alpha 0.3.13 release notes](CHANGELOG.md#0313--2026-07-19) record exactly what is live. +- **Release:** The [Alpha 0.3.14 release notes](CHANGELOG.md#0314--2026-07-22) record exactly what is live. - **Licensing:** [LICENSING.md](LICENSING.md) explains release rules; [asset provenance](ASSETS-LICENSE.md) records where media came from and what permissions apply. - **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) covers checks and provenance; the [Realm Council issue forms](https://github.com/ael-dev3/Warpkeep/issues/new/choose) accept privacy-safe bugs and ideas. - **Security:** Report sensitive issues privately through [SECURITY.md](SECURITY.md), never through a public issue. diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md new file mode 100644 index 00000000..8754c939 --- /dev/null +++ b/docs/design/realm-renderer-recovery.md @@ -0,0 +1,39 @@ +# Realm renderer recovery + +The Realm keeps a real WebGL scene as the source of truth once it has become +ready. The renderer lifecycle is explicit: `probing`, `loading`, `ready`, +`recovering`, `static-unsupported`, and `failed`. + +`static-unsupported` is reserved for a device that cannot create WebGL before +the first successful scene. It is an accessible, bounded illustrated view; it +is never a post-ready error surface. A renderer construction error, failed +castle assembly, castle-count mismatch, or synchronization failure remains an +explicit loading/recovery/failed state instead of silently replacing a real +world with a full-world SVG. + +Context loss calls `preventDefault`, pauses ambient work and rendering, and +retains React selection, camera intent, and the scene attestation. Pointer, +wheel, label-click, and camera input are synchronously suspended while the +context is lost so a partially disposed scene cannot consume a gesture. The +restored event starts a bounded scene rebuild and records loss/restore counts on +the canvas for DOM diagnostics. If the browser does not restore the context in +time, the user sees an explicit retry surface. All renderer surfaces share one +cached, non-destructive WebGL2 capability probe. No capability check calls +`WEBGL_lose_context` or otherwise tears down a context; a probe only reads the +optional texture-size limit. + +Castle loading is staged: Compact is mandatory and retried once for transient +transport failures after a deterministic short yield; Balanced and High are +optional upgrades. A missing optional LOD records the active quality in +`data-realm-castle-active-lod` and continues with Compact. Pairing, integrity, +and Compact failures are reported with stable failure codes for telemetry and +QA. Each controlled load is assigned a monotonic renderer generation. Scene +callbacks carry that generation and stale callbacks from a disposed scene are +ignored by both the React boundary and the pure lifecycle reducer. The DOM +exposes the active generation and the last generation that rendered a +successful frame, making recovery assertions deterministic. A ready renderer +can never transition into static compatibility mode. + +The recovery contract is intentionally frontend-only. Durable world state, +authorization, and SpacetimeDB subscriptions remain outside the renderer and +are never mutated by recovery code. diff --git a/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json new file mode 100644 index 00000000..02ce14d8 --- /dev/null +++ b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "id": "hegemony-worker-record-art-v1", + "recordedAt": "2026-07-19", + "purpose": "Transparent decorative Worker art for the Worker inspection panel", + "projectAuthorization": { + "authorizedBy": "Warpkeep project owner", + "instructionDate": "2026-07-19", + "scope": "Use the supplied transparent Worker illustration in the Warpkeep Worker UI slice stacked on the stable Realm PR. This authorizes repository/runtime integration only after review; it does not authorize merge, deployment, production activation, worker seeding, or generic worker authority.", + "notGranted": [ + "proof of underlying copyright ownership", + "a public open-content licence", + "general third-party derivative or redistribution permission", + "trademark or canonical-identity rights", + "worker ownership, status, route, cargo, reward, or settlement authority" + ] + }, + "sourceInputs": [ + { + "role": "owner-supplied transparent Worker illustration", + "repositoryRetained": false, + "originalName": "codex-clipboard-1312042e-175b-466f-adda-9bef7b12c1f0.png", + "bytes": 478174, + "sha256": "f6ae700affb5ce981074c7952bc81f90b60e2dab947b94867c5394d3e23b4d6d", + "image": { + "format": "png", + "width": 1024, + "height": 1024, + "channels": 4, + "alpha": true + } + } + ], + "processing": { + "tool": "sharp 0.35.3", + "operation": "lossy WebP derivative with alpha preserved", + "outputEncoding": "quality 92, alphaQuality 100, effort 6, smartSubsample true", + "runtimeAsset": { + "path": "public/images/realm/hegemony-worker-record.webp", + "format": "webp", + "width": 1024, + "height": 1024, + "bytes": 86984, + "sha256": "ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e", + "decodedRgbaSha256": "2e77492f76801576adcc9cfe660fa15494123bc43fcd767e005cd5ad95d8b047", + "alpha": { + "transparentPixels": 858605, + "partiallyTransparentPixels": 39551, + "opaquePixels": 150420 + }, + "visibleBoundsAlpha16": { + "minX": 256, + "minY": 50, + "maxX": 863, + "maxY": 934 + } + } + }, + "presentationBoundary": { + "component": "src/components/realm/WorkerInspectionPanel.tsx", + "runtimeUse": "same-origin decorative hero art in a focus-safe Worker inspector", + "forbiddenClaims": [ + "private FID", + "private cargo or account balance", + "browser-invented worker ownership", + "browser-derived dispatch or recall authority", + "reward or Marks linkage" + ] + }, + "licence": { + "spdx": "LicenseRef-Warpkeep-Provenance-Required", + "policy": "The exact project instruction authorizes this Warpkeep runtime use but does not establish a public relicensing grant. See ASSETS-LICENSE.md." + }, + "visualQa": { + "transparentSourceVerified": true, + "visibleBoundsRecorded": true, + "runtimeDerivativeViewedOnTransparentViewer": true, + "externalRuntimeUrl": false + } +} diff --git a/docs/releases/versioning.md b/docs/releases/versioning.md index 50e20077..d225bb40 100644 --- a/docs/releases/versioning.md +++ b/docs/releases/versioning.md @@ -1,7 +1,7 @@ # Versioning and releases Warpkeep uses semantic versions for the product and Git commit SHAs for builds. -The current Alpha is `0.3.13`. +The current Alpha is `0.3.14`. ## Version numbers diff --git a/index.html b/index.html index 41d3e697..45280d58 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,11 @@
+ { const inspectorProfileImage = inspector?.querySelector( 'canvas[data-profile-image-state]' ); + // Water endpoint controls intentionally share some list styling. Count only + // the semantically named castle list so source/mouth buttons cannot inflate + // or invalidate the exact 100-castle accessibility gate. const exploreCastleButtons = [...document.querySelectorAll( - '.realm-cell-navigator__castles button' + '.realm-cell-navigator__castles[aria-label="Founded castles"] > li > button' )].filter(visible); const exploreAccessibleCastleButtons = exploreCastleButtons.filter((button) => ( button instanceof HTMLButtonElement @@ -2362,7 +2382,11 @@ async function waitForAcceptedRenderedDom(session, expected, state) { `overflow=${String(value.labelClusterOverflowCount)}`, `portrait=${String(value.inspectorProfileImageState)}`, `models=${String(value.presentedModelCount)}`, - `bases=${String(value.presentedLandscapeBaseCount)}` + `bases=${String(value.presentedLandscapeBaseCount)}`, + `terrainKinds=${String(value.semanticTerrainKindCount)}`, + `terrainFeatures=${String(value.semanticTerrainFeatureCount)}`, + `exploreCastles=${String(value.exploreCastleCount)}`, + `exploreAccessible=${String(value.exploreAccessibleCastleCount)}` ].join(','); try { parseRenderedWebglBrowserDom(value, expected); diff --git a/scripts/spacetime-additive-migration-proof.mjs b/scripts/spacetime-additive-migration-proof.mjs index dffdb1e6..4632b9f8 100644 --- a/scripts/spacetime-additive-migration-proof.mjs +++ b/scripts/spacetime-additive-migration-proof.mjs @@ -3,7 +3,7 @@ const RECEIPT_FIELD = 'artifact_sha256'; const INVALID_RECEIPT_MESSAGE = 'The current additive migration proof did not produce its exact success receipt.'; -export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 11; +export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 12; 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. diff --git a/scripts/validate-pages-deploy-config.mjs b/scripts/validate-pages-deploy-config.mjs index e697c132..2a543f3d 100644 --- a/scripts/validate-pages-deploy-config.mjs +++ b/scripts/validate-pages-deploy-config.mjs @@ -3,7 +3,7 @@ const EXPECTED_REPOSITORY_URL = 'https://github.com/ael-dev3/Warpkeep'; const EXPECTED_AUDIENCE = 'warpkeep-spacetimedb'; const EXPECTED_BRIDGE = 'https://auth.warpkeep.com'; const EXPECTED_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com'; -const EXPECTED_SPACETIMEDB_DATABASE = 'warpkeep-89e4u'; +const EXPECTED_SPACETIMEDB_DATABASE = 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e'; const SHA_PATTERN = /^[0-9a-f]{40}$/i; function fail(message) { diff --git a/scripts/verify-castle-worker-additive-migration.mjs b/scripts/verify-castle-worker-additive-migration.mjs new file mode 100644 index 00000000..2a7d073c --- /dev/null +++ b/scripts/verify-castle-worker-additive-migration.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = resolve(root, 'spacetimedb/src/schema.ts'); +const previousFixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v11-schema/src/index.ts'); +const fixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts'); + +function registrations(source, marker) { + const start = source.indexOf(marker); + const end = source.indexOf('\n});', start); + assert.ok(start >= 0 && end > start, `missing schema marker: ${marker}`); + return source.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +function table(source, name) { + const start = source.indexOf(`const ${name} = table(`); + const end = source.indexOf('\n);', start); + assert.ok(start >= 0 && end > start, `missing table: ${name}`); + return source.slice(start, end); +} + +const [schema, previousFixture, fixture] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(previousFixturePath, 'utf8'), + readFile(fixturePath, 'utf8'), +]); +const current = registrations(schema, 'const warpkeep = schema({'); +const previous = registrations(previousFixture, 'const db = schema({'); +const candidate = registrations(fixture, 'const db = schema({'); +assert.equal(previous.length, 47, 'v11 fixture must end at ref 46'); +assert.deepEqual(current.slice(0, 47), previous, 'current schema changed before the v12 suffix'); +assert.deepEqual(candidate.slice(0, 47), previous, 'v12 fixture changed the deployed prefix'); +assert.deepEqual(candidate.slice(47), [ + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', +]); +assert.deepEqual(current.slice(47), candidate.slice(47), 'module and fixture suffix differ'); +for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1']) { + const definition = table(schema, name); + assert.match(definition, /public: true/); + assert.doesNotMatch(definition, /\bfid\b|assignmentId|accruedAmount|materializedAmount|balance|requestKey|auth/i); +} +for (const name of ['workerAssignmentV1', 'workerCommandIdempotencyV1', 'workerAssignmentScheduleV1']) { + assert.doesNotMatch(table(schema, name), /public: true/); +} +assert.match(fixture, /fixture_seed_generic_worker_sentinel_v12/); +console.log('generic worker additive migration proof passed: refs 0–46 preserved, refs 47–52 append-only, populated fixture present, assignment correlation remains private'); diff --git a/scripts/verify-production-dist-exclusions.mjs b/scripts/verify-production-dist-exclusions.mjs index 75942ebc..862a92fa 100644 --- a/scripts/verify-production-dist-exclusions.mjs +++ b/scripts/verify-production-dist-exclusions.mjs @@ -55,6 +55,27 @@ const forbiddenContent = Object.freeze([ '/v1/qa/challenge', '/v1/qa/realm-snapshot' ]); +const requiredProductionCspFragments = Object.freeze([ + 'data-warpkeep-production-csp', + "default-src 'none'", + "base-uri 'none'", + "object-src 'none'", + "frame-src 'none'", + "form-action 'none'", + "script-src 'self' 'wasm-unsafe-eval'", + "script-src-attr 'none'", + 'https://auth.warpkeep.com', + 'https://relay.farcaster.xyz', + 'https://mainnet.optimism.io', + 'https://maincloud.spacetimedb.com', + 'wss://maincloud.spacetimedb.com', + 'https://imagedelivery.net', + 'https://wrpcd.net', + 'https://res.cloudinary.com', + 'https://i.imgur.com', + 'https://lh3.googleusercontent.com', + 'https://i.seadn.io' +]); function filesUnder(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { @@ -67,6 +88,22 @@ function filesUnder(directory) { }); } +const productionIndex = readFileSync(resolve(dist, 'index.html'), 'utf8'); +for (const fragment of requiredProductionCspFragments) { + if (!productionIndex.includes(fragment)) { + throw new Error(`Production document CSP is missing ${JSON.stringify(fragment)}.`); + } +} +if (/script-src[^;]*'unsafe-eval'/.test(productionIndex)) { + throw new Error('Production document CSP permits unrestricted unsafe-eval.'); +} +if (/(?:^|[;\s])https:(?:[;\s]|$)|(?:^|[;\s])wss?:(?:[;\s]|$)/.test(productionIndex)) { + throw new Error('Production document CSP permits an unrestricted network scheme.'); +} +if (/localhost|127\.0\.0\.1|\[::1\]/.test(productionIndex)) { + throw new Error('Production document CSP contains a loopback network exception.'); +} + for (const path of filesUnder(dist)) { const relativePath = relative(dist, path).replaceAll('\\', '/'); if (forbiddenPathFragments.some((fragment) => relativePath.includes(fragment))) { diff --git a/scripts/verify-spacetime-additive-migration.mjs b/scripts/verify-spacetime-additive-migration.mjs index 29286a25..c2d9c455 100644 --- a/scripts/verify-spacetime-additive-migration.mjs +++ b/scripts/verify-spacetime-additive-migration.mjs @@ -63,6 +63,10 @@ const additiveV11SchemaFixture = resolve( repositoryRoot, 'spacetimedb/migration-fixtures/additive-v11-schema', ); +const additiveV12SchemaFixture = resolve( + repositoryRoot, + 'spacetimedb/migration-fixtures/additive-v12-schema', +); const additiveModule = resolve(repositoryRoot, 'spacetimedb'); const command = process.env.SPACETIME_BIN || 'spacetime'; const expectedCliVersion = ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION; @@ -243,6 +247,14 @@ const additiveV10Tables = Object.freeze([ const additiveV11Tables = Object.freeze([ 'realm_water_revision_v1', ]); +const additiveV12Tables = Object.freeze([ + 'realm_worker_system_v1', + 'castle_worker_v1', + 'worker_assignment_v1', + 'worker_node_occupation_v1', + 'worker_command_idempotency_v1', + 'worker_assignment_schedule_v_1', +]); const deployedV3Tables = Object.freeze([ ...existingTables, ...additiveV3Tables, @@ -279,6 +291,10 @@ const deployedV11Tables = Object.freeze([ ...deployedV10Tables, ...additiveV11Tables, ]); +const deployedV12Tables = Object.freeze([ + ...deployedV11Tables, + ...additiveV12Tables, +]); const expectedProductTypeRefs = Object.freeze({ allowed_fid: 0, world_tile: 1, @@ -327,6 +343,12 @@ const expectedProductTypeRefs = Object.freeze({ stone_expedition_idempotency_v1: 44, stone_expedition_schedule_v_1: 45, realm_water_revision_v1: 46, + realm_worker_system_v1: 47, + castle_worker_v1: 48, + worker_assignment_v1: 49, + worker_node_occupation_v1: 50, + worker_command_idempotency_v1: 51, + worker_assignment_schedule_v_1: 52, }); const childEnvironmentKeys = Object.freeze([ 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP', @@ -1194,6 +1216,87 @@ function assertAdditiveV11Schema(before, after) { ); } +function assertDeployedV11TablesUnchanged(before, after) { + for (const name of deployedV11Tables) { + assert.deepEqual(tableSignature(after, name), tableSignature(before, name)); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } +} + +function assertAdditiveV12Schema(before, after) { + assertDeployedV11TablesUnchanged(before, after); + 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, [...additiveV12Tables].sort()); + const contracts = { + realm_worker_system_v1: { + access: 'Public', + fields: [ + 'realm_id', 'policy_version', 'workers_per_castle', 'expected_castle_count', + 'expected_worker_count', 'roster_digest', 'mode', 'legacy_drain_required', + 'created_at', 'activated_at', + ], + }, + castle_worker_v1: { + access: 'Public', + fields: [ + 'worker_id', 'origin_castle_id', 'ordinal', 'status', 'resource_kind', + 'site_id', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'timeline_revision', 'revision', 'created_at', 'updated_at', + ], + }, + worker_assignment_v1: { + access: 'Private', + fields: [ + 'assignment_id', 'worker_id', 'fid', 'origin_castle_id', 'resource_kind', + 'site_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'settled_through_micros', 'accrued_amount', 'materialized_amount', + 'timeline_revision', 'policy_version', 'created_at', 'updated_at', + ], + }, + worker_node_occupation_v1: { + access: 'Public', + fields: [ + 'node_key', 'resource_kind', 'site_id', 'worker_id', 'worker_ordinal', + 'origin_castle_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'timeline_revision', + ], + }, + worker_command_idempotency_v1: { + access: 'Private', + fields: [ + 'request_key', 'fid', 'worker_id', 'command_kind', 'resource_kind', + 'site_id', 'assignment_id', 'result_revision', 'created_at', + ], + }, + worker_assignment_schedule_v_1: { + access: 'Private', + fields: [ + 'schedule_id', 'scheduled_at', 'assignment_id', 'worker_id', + 'timeline_revision', 'stage', + ], + }, + }; + 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(); @@ -2029,9 +2132,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 v11 candidate schema. Reverting to a + // Keep inspection on the complete v12 candidate schema. Reverting to a // predecessor fixture after Stone is appended would be destructive. - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -2846,9 +2949,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 v11 suffix during + // Reusing the candidate fixture preserves the complete v12 suffix during // SQL inspection; publishing any predecessor would request a downgrade. - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -3286,7 +3389,7 @@ async function verifyActualModuleWaterLifecycle(server, database, privateKey, ow let stage = 'publish'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); const useActualModule = async () => { if (activeModule === 'actual') return; @@ -3757,7 +3860,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(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); await publishBuiltArtifact(server, ownerToken, fixtureArtifactPath, database); @@ -4548,28 +4651,130 @@ async function main() { populatedWaterStoneV11Rows, ); - // Advance every database to the real v11 candidate so the implementation - // is exercised against the exact v11 table contract without production. + // Freeze v12 independently, then prove a populated canonical v11 -> v12 + // migration before exercising the real candidate artifact. + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, resourceLifecycleDatabase); + await publish( + server, + owner.token, + additiveV12SchemaFixture, + populatedWaterStoneMigrationDatabase, + ); + const emptyV12 = await describe(server, owner.token, emptyDatabase); + const nonemptyV12 = await describe(server, owner.token, nonemptyDatabase); + const actualModuleV12 = await describe(server, owner.token, actualModuleDatabase); + const populatedWaterStoneV12 = await describe( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + ); + assertAdditiveV12Schema(emptyV11, emptyV12); + assertAdditiveV12Schema(nonemptyV11, nonemptyV12); + assertAdditiveV12Schema(actualModuleV11, actualModuleV12); + assertAdditiveV12Schema(populatedWaterStoneV11, populatedWaterStoneV12); + for (const name of deployedV12Tables) { + assert.deepEqual( + tableSignature(actualModuleV12, name), + tableSignature(emptyV12, name), + ); + } + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV11Tables, + ), + populatedWaterStoneV11Rows, + ); + for (const table of additiveV12Tables) { + assert.equal(await count( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + table, + ), 0n); + } + await callLoopbackReducer( + server, + populatedWaterStoneMigrationDatabase, + 'fixture_seed_generic_worker_sentinel_v12', + owner.token, + '[]', + 200, + ); + const expectedPopulatedV12Counts = new Map([ + ['realm_worker_system_v1', 1n], + ['castle_worker_v1', 4n], + ['worker_assignment_v1', 1n], + ['worker_node_occupation_v1', 1n], + ['worker_command_idempotency_v1', 1n], + ['worker_assignment_schedule_v_1', 1n], + ]); + for (const [table, expectedCount] of expectedPopulatedV12Counts) { + assert.equal(await count( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + table, + ), expectedCount); + } + const populatedWaterStoneV12Rows = await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV12Tables, + ); + const populatedWaterStoneV12SchemaDigest = schemaDigest( + await describe(server, owner.token, populatedWaterStoneMigrationDatabase), + ); + await publish( + server, + owner.token, + additiveV11SchemaFixture, + populatedWaterStoneMigrationDatabase, + false, + /break|delete|remove|migration|incompatible|data loss|table/i, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, populatedWaterStoneMigrationDatabase)), + populatedWaterStoneV12SchemaDigest, + ); + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV12Tables, + ), + populatedWaterStoneV12Rows, + ); + + // Advance every database to the real v12 candidate so the implementation + // is exercised against the exact v12 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 populatedWaterStoneCandidateV11 = await describe( + const populatedWaterStoneCandidateV12 = await describe( server, owner.token, populatedWaterStoneMigrationDatabase, ); - for (const name of deployedV11Tables) { + for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(populatedWaterStoneCandidateV11, name), - tableSignature(emptyV11, name), + tableSignature(populatedWaterStoneCandidateV12, name), + tableSignature(emptyV12, name), ); } await publish( server, owner.token, - additiveV11SchemaFixture, + additiveV12SchemaFixture, populatedWaterStoneMigrationDatabase, ); assert.deepEqual( @@ -4577,9 +4782,9 @@ async function main() { server, owner.token, populatedWaterStoneMigrationDatabase, - deployedV11Tables, + deployedV12Tables, ), - populatedWaterStoneV11Rows, + populatedWaterStoneV12Rows, ); await verifyResolverHttpLifecycle(server, actualModuleDatabase, privateKey); const worldExpansionDurationMilliseconds = await verifyGenesisWorldExpansionLifecycle( @@ -4616,30 +4821,30 @@ async function main() { const builtArtifactDigest = createHash('sha256') .update(await readFile(builtArtifactPath)) .digest('hex'); - const emptyCandidateV11 = await describe(server, owner.token, emptyDatabase); - const nonemptyCandidateV11 = await describe(server, owner.token, nonemptyDatabase); - const actualCandidateV11 = await describe(server, owner.token, actualModuleDatabase); - for (const name of deployedV11Tables) { + const emptyCandidateV12 = await describe(server, owner.token, emptyDatabase); + const nonemptyCandidateV12 = await describe(server, owner.token, nonemptyDatabase); + const actualCandidateV12 = await describe(server, owner.token, actualModuleDatabase); + for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(actualCandidateV11, name), - tableSignature(emptyV11, name), + tableSignature(actualCandidateV12, name), + tableSignature(emptyV12, name), ); assert.deepEqual( - tableSignature(nonemptyCandidateV11, name), - tableSignature(nonemptyV11, name), + tableSignature(nonemptyCandidateV12, name), + tableSignature(nonemptyV12, name), ); assert.deepEqual( - tableSignature(actualCandidateV11, name), - tableSignature(actualModuleV11, name), + tableSignature(actualCandidateV12, name), + tableSignature(actualModuleV12, name), ); } // The candidate's on-connect policy intentionally rejects the disposable - // owner identity. Reuse the table-identical, auth-neutral v11 fixture before + // owner identity. Reuse the table-identical, auth-neutral v12 fixture before // owner SQL reads and never downgrade the schema suffix. - await publish(server, owner.token, additiveV11SchemaFixture, emptyDatabase); - await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase); - await publish(server, owner.token, additiveV11SchemaFixture, actualModuleDatabase); - // SQL preservation reads remain on the complete v11 candidate. No reducer + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase); + // SQL preservation reads remain on the complete v12 candidate. No reducer // is invoked by these owner-only queries. for (const [database, beforeRows] of [ [emptyDatabase, emptyV7Rows], @@ -4652,7 +4857,7 @@ async function main() { ); } - const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV11); + const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV12); await publishBuiltArtifact( server, owner.token, @@ -4663,10 +4868,10 @@ async function main() { schemaDigest(await describe(server, owner.token, nonemptyDatabase)), idempotentSchemaBefore, ); - await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); // The actual module correctly rejects the disposable local identity at its - // on-connect boundary; owner SQL still reads the unchanged v11 rows. + // on-connect boundary; owner SQL still reads the unchanged v12 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); @@ -4691,6 +4896,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, database, table), 0n); } @@ -4721,7 +4927,7 @@ async function main() { )), actualModuleWorldBefore); // Identity columns reject arbitrary SQL literals after the candidate's - // issuer boundary is active. The auth-neutral v11 fixture inserts the + // issuer boundary is active. The auth-neutral v12 fixture inserts the // caller's verified sender identity through a disposable reducer instead. await callLoopbackReducer( server, @@ -4743,10 +4949,11 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } - const populatedV11SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); + const populatedV12SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); await callLoopbackReducer( server, @@ -4768,7 +4975,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -4781,6 +4988,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4794,7 +5002,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -4807,6 +5015,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4820,7 +5029,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4832,7 +5041,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4844,10 +5053,10 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); - // The immediate v11 -> v10 rollback must be refused before it can remove - // the Water revision. Older boundaries continue protecting Stone and Water. + // The v12 boundary must refuse every predecessor before any generic-worker + // or Water table can be removed. await publish( server, owner.token, @@ -4858,7 +5067,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4870,7 +5079,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4882,7 +5091,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); // Older fixture rollbacks remain refused as well. await publish( @@ -4895,17 +5104,17 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish(server, owner.token, additiveModule, emptyDatabase); - assertAdditiveV11Schema( - emptyV10, + assertAdditiveV12Schema( + emptyV11, 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, additiveV11SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, 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 [ @@ -4917,6 +5126,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4939,7 +5149,9 @@ async function main() { + 'public Tier-I Stone sites, identity-minimized occupations, and public-safe lifecycle schedule projection plus private Stone expedition and idempotency ' + 'tables appended at exact refs 41-45, ' + 'public ocean-and-river Water revision policy appended at exact ref 46, ' - + '61-tile empty, synthetic nonempty, and populated v10 Water/Stone fixtures remained preserved through v11, ' + + 'identity-safe generic worker readiness, roster, assignment, occupation, bounded receipt, and private schedule tables appended at exact refs 47-52, ' + + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v12, ' + + 'every v12 table was populated, retained through the real candidate, and protected from a v12-to-v11 downgrade, ' + '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}, ` @@ -4951,8 +5163,8 @@ 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 v11, ' - + 'and guarded v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', + + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v12, ' + + 'and guarded v11/v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', artifactDigest: builtArtifactDigest, })); } finally { diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 85d6b317..5c7a42c2 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -266,7 +266,7 @@ one verified FID being resolved. The module retains a 60-second rejection ceiling. The token has no admin role and is never returned or logged. The Worker calls the fixed documented Maincloud endpoint -`POST https://maincloud.spacetimedb.com/v1/database/warpkeep-89e4u/call/auth_resolver_get_fid_admission_v2` +`POST https://maincloud.spacetimedb.com/v1/database/c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e/call/auth_resolver_get_fid_admission_v2` with `Authorization: Bearer