diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d36a197..e635903 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: push: - branches: [main] + branches: [main, feature/atlas-cached-exploration] pull_request: permissions: contents: read @@ -14,6 +14,7 @@ jobs: timeout-minutes: 15 env: MOONSHOT_BUDGET_USD: "0" + MOONSHOT_OFFLINE_FIXTURE: "1" MOONSHOT_BASE_PATH: /github-factory/ steps: - uses: actions/checkout@v4 @@ -28,7 +29,7 @@ jobs: - name: Verify prefixed production app in Chromium shell: bash run: | - HOST=127.0.0.1 PORT=4177 bun run start > /tmp/github-factory.log 2>&1 & + HOST=127.0.0.1 PORT=4177 bun --no-env-file --preload ./test/fixtures/offline-runtime.ts .output/server/index.mjs > /tmp/github-factory.log 2>&1 & app_pid=$! trap 'kill "$app_pid" 2>/dev/null || true' EXIT for attempt in {1..30}; do diff --git a/deploy/d1-storage.ts b/deploy/d1-storage.ts index c3ac332..866895b 100644 --- a/deploy/d1-storage.ts +++ b/deploy/d1-storage.ts @@ -17,7 +17,7 @@ export interface StorageEnvironment { WORLDS_DB?: WorldD1Binding; FACTORY_STORAGE_SECRET?: string; FACTORY_STORAGE_DATABASE_ID?: string; FACTORY_MIGRATION_EPOCH?: string; FACTORY_SOURCE_SHA?: string; FACTORY_WORKER_BUNDLE_SHA256?: string; - FACTORY_ADMISSION_FENCE?: string; FACTORY_STORAGE_MODE?: string; + FACTORY_ADMISSION_FENCE?: string; FACTORY_STORAGE_MODE?: string; FACTORY_STORAGE_BINDING_NONCE?: string; } const reply = (value: unknown, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store", "x-content-type-options": "nosniff" } }); const record = (x: unknown): x is Record => Boolean(x) && typeof x === "object" && !Array.isArray(x); @@ -33,26 +33,33 @@ async function equalSecret(actual: string, expected: string) { for (let i = 0; i < a.length; i++) different |= a[i]! ^ b[i]!; return different === 0; } -async function boundedBody(request: Request) { - if (Number(request.headers.get("content-length")) > 2 * 1024 * 1024) throw new Error("invalid input"); +async function boundedBody(request: Request, proofOnly = false) { + const maxBytes = proofOnly ? 4096 : 2 * 1024 * 1024; + if (Number(request.headers.get("content-length")) > maxBytes) throw new Error("invalid input"); const reader = request.body?.getReader(); if (!reader) throw new Error("invalid input"); + let timer: ReturnType | undefined; + const timeout = proofOnly ? new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("invalid input")), 5000); }) : null; const chunks: Uint8Array[] = []; let length = 0; - try { for (;;) { const { value, done } = await reader.read(); if (done) break; length += value.byteLength; if (length > 2 * 1024 * 1024) throw new Error("invalid input"); chunks.push(value); } } - finally { await reader.cancel().catch(() => {}); } + try { for (;;) { const { value, done } = await (timeout ? Promise.race([reader.read(), timeout]) : reader.read()); if (done) break; length += value.byteLength; if (length > maxBytes) { clearTimeout(timer); throw new Error("invalid input"); } chunks.push(value); } } + finally { clearTimeout(timer); if (proofOnly) void reader.cancel().catch(() => {}); else await reader.cancel().catch(() => {}); } const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } - const body = JSON.parse(new TextDecoder().decode(bytes)); if (!record(body)) throw new Error("invalid input"); return body; + const body = JSON.parse(new TextDecoder("utf-8", { fatal: proofOnly }).decode(bytes)); if (!record(body)) throw new Error("invalid input"); return body; } /** Private fixed-operation RPC. It accepts neither caller SQL nor URLs/prompts, * and this Worker has no model key, management token or paid ledger binding. */ -export async function worldStorageRpc(request: Request, env: StorageEnvironment): Promise { +export async function worldStorageRpc(request: Request, env: StorageEnvironment, profile: "default" | "rollback-proof" = "default"): Promise { if (!(await equalSecret(request.headers.get("x-factory-storage-key") || "", env.FACTORY_STORAGE_SECRET || ""))) return reply({ error: "Storage authorization required" }, 403); + if (profile !== "default" && profile !== "rollback-proof") return reply({ error: "Storage profile rejected" }, 403); + const proofOnly = profile === "rollback-proof"; + if (proofOnly && (env.FACTORY_STORAGE_MODE !== "probe-only" || env.FACTORY_STORAGE_DATABASE_ID !== "b2210c4a-4ed3-49ba-b044-2f6c24958fac" || env.FACTORY_ADMISSION_FENCE !== "" || !/^[a-f0-9]{32}$/.test(env.FACTORY_STORAGE_BINDING_NONCE || ""))) return reply({ error: "Proof configuration unavailable" }, 503); if (request.method !== "POST" || request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return reply({ error: "Storage operation rejected" }, 400); if (!env.WORLDS_DB) return reply({ error: "Authoritative storage unavailable" }, 503); let body: Record; - try { body = await boundedBody(request); } catch { return reply({ error: "Storage operation rejected" }, 400); } + try { body = await boundedBody(request, proofOnly); } catch { return reply({ error: "Storage operation rejected" }, 400); } + if (proofOnly && !(body.op === "status" && fields(body, ["op"]) || body.op === "probe" && fields(body, ["op", "action", "nonce"]) && body.action === "rollback" && typeof body.nonce === "string" && /^[a-f0-9]{32}$/.test(body.nonce))) return reply({ error: "Only status and rollback proof permitted" }, 403); const db = env.WORLDS_DB.withSession?.("first-primary") ?? env.WORLDS_DB; let identity: StorageIdentity; const ok = (result: unknown) => reply({ ok: true, backend: "d1", op: body.op, identity, result }); @@ -60,7 +67,7 @@ export async function worldStorageRpc(request: Request, env: StorageEnvironment) if (!["probe-only", "worlds"].includes(env.FACTORY_STORAGE_MODE || "")) throw new Error("Storage mode unavailable"); if (env.FACTORY_STORAGE_MODE === "probe-only" && body.op !== "status" && body.op !== "probe") return reply({ error: "This bridge permits synthetic probes only" }, 403); const stored = await db.prepare("SELECT database_uuid,binding_nonce FROM storage_identity WHERE singleton=1").first<{ database_uuid: string; binding_nonce: string }>(); - if (!stored || stored.database_uuid !== env.FACTORY_STORAGE_DATABASE_ID || !/^[a-f0-9]{32}$/.test(stored.binding_nonce) || + if (!stored || proofOnly && stored.binding_nonce !== env.FACTORY_STORAGE_BINDING_NONCE || stored.database_uuid !== env.FACTORY_STORAGE_DATABASE_ID || !/^[a-f0-9]{32}$/.test(stored.binding_nonce) || !/^[a-f0-9]{32}$/.test(env.FACTORY_MIGRATION_EPOCH || "") || !/^[a-f0-9]{40}$/.test(env.FACTORY_SOURCE_SHA || "") || !/^[a-f0-9]{64}$/.test(env.FACTORY_WORKER_BUNDLE_SHA256 || "")) throw new Error("D1 identity unavailable"); identity = { databaseId: stored.database_uuid, bindingNonce: stored.binding_nonce, epoch: env.FACTORY_MIGRATION_EPOCH!, sourceSha: env.FACTORY_SOURCE_SHA!, workerBundleSha256: env.FACTORY_WORKER_BUNDLE_SHA256!, admissionFenced: env.FACTORY_ADMISSION_FENCE === env.FACTORY_MIGRATION_EPOCH }; if (body.op === "migrationSnapshot") { @@ -145,6 +152,7 @@ export async function worldStorageRpc(request: Request, env: StorageEnvironment) return ok({ backend: "d1", authoritative: true, databaseId: identity.databaseId, schemaVersion: version.version, worlds: worlds.count, aliases: aliases.count }); } if (body.op === "probe") { + if (proofOnly && (await db.prepare("SELECT MAX(version) version FROM schema_migrations").first<{version:number}>())?.version !== 2) throw new Error("D1 schema unavailable"); if (!fields(body, ["op", "action", "nonce"]) || typeof body.nonce !== "string" || !/^[a-f0-9]{32}$/.test(body.nonce)) throw new TypeError(); const id = `storage-rpc-synthetic-${body.nonce}`; if (body.action === "rollback") { diff --git a/deploy/d1/0003-history.sql b/deploy/d1/0003-history.sql new file mode 100644 index 0000000..a9ad572 --- /dev/null +++ b/deploy/d1/0003-history.sql @@ -0,0 +1,56 @@ +-- UNAPPLIED. Offline history core only; runtime schema/version acceptance and +-- migration authorization require separate review. Existing schema is untouched. +CREATE TABLE history_repositories ( + repo TEXT PRIMARY KEY, + revision INTEGER NOT NULL CHECK(revision >= 0), + normalized_bytes INTEGER NOT NULL CHECK(normalized_bytes >= 0 AND normalized_bytes <= 12582912) +); +CREATE TABLE history_commits ( + repo TEXT NOT NULL REFERENCES history_repositories(repo), + sha TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY(repo,sha) +); +CREATE TABLE history_anchors ( + repo TEXT NOT NULL REFERENCES history_repositories(repo), + anchor_key TEXT NOT NULL, + generation TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY(repo,anchor_key), + UNIQUE(repo,generation) +); +CREATE TABLE history_frames ( + repo TEXT NOT NULL REFERENCES history_repositories(repo), + frame_key TEXT NOT NULL, + commit_sha TEXT NOT NULL, + -- UTF-8-safe boundaries may shorten a chunk by three bytes; a maximum-size + -- 512KiB frame can therefore need a ninth chunk. + chunk_count INTEGER NOT NULL CHECK(chunk_count BETWEEN 1 AND 9), + payload_bytes INTEGER NOT NULL CHECK(payload_bytes BETWEEN 1 AND 524288), + payload_hash TEXT NOT NULL, + PRIMARY KEY(repo,frame_key) +); +CREATE TABLE history_frame_chunks ( + repo TEXT NOT NULL, + frame_key TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 8), + payload TEXT NOT NULL, + PRIMARY KEY(repo,frame_key,ordinal), + FOREIGN KEY(repo,frame_key) REFERENCES history_frames(repo,frame_key) +); +CREATE TABLE history_batches ( + repo TEXT NOT NULL REFERENCES history_repositories(repo), + batch_id TEXT NOT NULL, + action_id TEXT NOT NULL, + anchor_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY(repo,batch_id), + UNIQUE(repo,anchor_key,action_id) +); +-- A failed comparison MUST throw inside D1Database.batch, not report changes=0 +-- after unrelated facts/cursor writes have already committed. +CREATE TABLE history_append_guards ( + repo TEXT PRIMARY KEY, + accepted INTEGER NOT NULL CHECK(accepted = 1) +); diff --git a/deploy/d1/0004-world-public-captures.sql b/deploy/d1/0004-world-public-captures.sql new file mode 100644 index 0000000..5dba407 --- /dev/null +++ b/deploy/d1/0004-world-public-captures.sql @@ -0,0 +1,14 @@ +-- UNAPPLIED. Standalone D1 public-capture authority core only. NO RPC/runtime +-- adoption or schema_migrations marker is enabled by this file. +-- A separate authorized provisioning transaction must insert ONE authority row +-- with exact existing database UUID/binding nonce, explicit epoch/key_tag, and a +-- checksum of the original metadata view (including all existing worlds/aliases). +-- Core constructors/read paths NEVER initialize/reset/repair missing authority. +-- No company records, paid ledger or upstream/client allowance belongs here. +CREATE TABLE world_capture_authority (singleton INTEGER PRIMARY KEY CHECK(singleton=1), protocol INTEGER NOT NULL CHECK(protocol=1), database_uuid TEXT NOT NULL CHECK(length(database_uuid)=36), binding_nonce TEXT NOT NULL CHECK(length(binding_nonce)=32 AND binding_nonce NOT GLOB '*[^0-9a-f]*'), authority_epoch TEXT NOT NULL CHECK(length(authority_epoch)=32 AND authority_epoch NOT GLOB '*[^0-9a-f]*'), key_tag TEXT NOT NULL CHECK(length(key_tag)=64 AND key_tag NOT GLOB '*[^0-9a-f]*'), sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence BETWEEN 0 AND 9007199254740991), revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision BETWEEN 0 AND 9007199254740991), checksum TEXT NOT NULL CHECK(length(checksum)=64 AND checksum NOT GLOB '*[^0-9a-f]*')); +CREATE TABLE world_capture_barriers (repo TEXT PRIMARY KEY CHECK(length(repo) BETWEEN 3 AND 140 AND repo=lower(repo) AND repo NOT GLOB '*[^a-z0-9_./-]*'), sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence BETWEEN 1 AND 9007199254740991), denied INTEGER NOT NULL CHECK(typeof(denied)='integer' AND denied IN (0,1))); +CREATE TABLE world_capture_generations (handle_hash TEXT PRIMARY KEY CHECK(length(handle_hash)=64 AND handle_hash NOT GLOB '*[^0-9a-f]*'), repo TEXT NOT NULL, start_canonical TEXT NOT NULL, start_sequence INTEGER NOT NULL CHECK(typeof(start_sequence)='integer' AND start_sequence BETWEEN 0 AND 9007199254740991), explicit INTEGER NOT NULL CHECK(typeof(explicit)='integer' AND explicit IN (0,1)), status TEXT NOT NULL CHECK(status IN ('active','committed','deny-pending','denied')), operation_hash TEXT, roots TEXT, targets TEXT, accepted_sequence INTEGER, ordinal INTEGER NOT NULL CHECK(typeof(ordinal)='integer' AND ordinal BETWEEN 1 AND 9007199254740991), CHECK((status='active' AND operation_hash IS NULL AND roots IS NULL AND targets IS NULL AND accepted_sequence IS NULL) OR (status<>'active' AND operation_hash IS NOT NULL AND length(operation_hash)=64 AND operation_hash NOT GLOB '*[^0-9a-f]*' AND roots IS NOT NULL AND json_valid(roots) AND json_type(roots)='array' AND json_array_length(roots) BETWEEN 1 AND 2 AND targets IS NOT NULL AND json_valid(targets) AND json_type(targets)='array' AND json_array_length(targets)<=204 AND typeof(accepted_sequence)='integer' AND accepted_sequence BETWEEN 0 AND 9007199254740991))); +-- A persistent constant row lets each CAS assertion throw via CHECK INSIDE the +-- D1 batch without an unsafe changes=0 result or a dangling guard-cleanup step. +CREATE TABLE world_capture_guard (singleton INTEGER PRIMARY KEY CHECK(singleton=1), accepted INTEGER NOT NULL CHECK(accepted=1)); +INSERT INTO world_capture_guard VALUES(1,1); diff --git a/deploy/gateway.ts b/deploy/gateway.ts index 76c0c07..a4b66c3 100644 --- a/deploy/gateway.ts +++ b/deploy/gateway.ts @@ -1,8 +1,54 @@ import { worldStorageRpc, type StorageEnvironment } from "./d1-storage"; +import { historyStorageRpc } from "./history-rpc"; +import { worldCaptureRpc } from "./world-capture-rpc"; +import type { HistoryD1Binding } from "./history-storage"; + +export interface GatewayEnvironment extends Omit { + WORLDS_DB?: StorageEnvironment["WORLDS_DB"] & HistoryD1Binding; + FACTORY_ORIGIN: string; FACTORY_GATEWAY_SECRET?: string; + COMPANY_SITE?: { fetch(request: Request): Promise }; + /** Unset/"" keeps the legacy storage RPC. "4" mounts ONLY the schema4 world + * capture RPC at the same path; any other value or invalid key fails closed. */ + FACTORY_WORLD_CAPTURE_SCHEMA_VERSION?: string; FACTORY_WORLD_CAPTURE_KEY_HEX?: string; + /** Exactly "true" mounts the history RPC; otherwise its path is an edge 404. */ + FACTORY_HISTORY_RPC_ENABLED?: string; +} + +const STORAGE_PATH = "/github-factory/_storage"; +const noStore = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }; +const unavailable = () => new Response(JSON.stringify({ ok: false, code: "WORLD_STORAGE_UNAVAILABLE", error: "The fixed world storage operation is unavailable or rejected.", writeOutcome: "not-started" }), { status: 503, headers: noStore }); +// One non-extractable HMAC key per isolate, re-imported only if the hex changes. +let captureKey: { hex: string; key: Promise } | undefined; +function importCaptureKey(hex: string) { + if (captureKey?.hex !== hex) { + const raw = Uint8Array.from(hex.match(/../g)!, v => parseInt(v, 16)); + const key = crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); + key.catch(() => { if (captureKey?.key === key) captureKey = undefined; }); + captureKey = { hex, key }; + } + return captureKey.key; +} + +/** Private storage paths never reach the Bun origin. Profile selection is by + * explicit Worker configuration only, never by request content. */ +async function storageRoute(request: Request, pathname: string, env: GatewayEnvironment): Promise { + if (pathname === `${STORAGE_PATH}/history`) + return env.FACTORY_HISTORY_RPC_ENABLED === "true" ? historyStorageRpc(request, env) : new Response("Not found", { status: 404 }); + if (pathname !== STORAGE_PATH) return new Response("Not found", { status: 404 }); + const version = env.FACTORY_WORLD_CAPTURE_SCHEMA_VERSION; + if (version === undefined || version === "") return worldStorageRpc(request, env); + const hex = env.FACTORY_WORLD_CAPTURE_KEY_HEX ?? ""; + if (version !== "4" || !/^[a-f0-9]{64}$/.test(hex)) return unavailable(); + let key: CryptoKey; + try { key = await importCaptureKey(hex); } catch { return unavailable(); } + const identity = { databaseId: env.FACTORY_STORAGE_DATABASE_ID ?? "", bindingNonce: env.FACTORY_STORAGE_BINDING_NONCE ?? "", epoch: env.FACTORY_MIGRATION_EPOCH ?? "" }; + return worldCaptureRpc(request, env, { identity, key }); +} + /** Independent path Worker. A prefix route also catches bare-path query strings. * No company-site bundle, credentials, SQLite or model code is included. */ export default { - async fetch(request: Request, env: StorageEnvironment & { FACTORY_ORIGIN: string; FACTORY_GATEWAY_SECRET?: string; COMPANY_SITE?: { fetch(request: Request): Promise } }): Promise { + async fetch(request: Request, env: GatewayEnvironment): Promise { const publicUrl = new URL(request.url); if (publicUrl.hostname !== "chaorders.com") return new Response("Not found", { status: 404 }); @@ -11,7 +57,7 @@ export default { // transparent service binding for adjacent company paths, never recursion. if (!(publicUrl.pathname === "/github-factory" || publicUrl.pathname.startsWith("/github-factory/"))) return env.COMPANY_SITE?.fetch(request) ?? new Response("Not found", { status: 404 }); - if (publicUrl.pathname === "/github-factory/_storage") return worldStorageRpc(request, env); + if (publicUrl.pathname === STORAGE_PATH || publicUrl.pathname.startsWith(`${STORAGE_PATH}/`)) return storageRoute(request, publicUrl.pathname, env); // Before retiring a legacy Bun process, close every admission path that can // collect or generate. Cached gallery/preview reads remain available until // the dedicated connector is stopped to close already accepted slow bodies. diff --git a/deploy/history-rpc.ts b/deploy/history-rpc.ts new file mode 100644 index 0000000..5d0adf5 --- /dev/null +++ b/deploy/history-rpc.ts @@ -0,0 +1,117 @@ +import { D1HistoryStore, type HistoryD1Binding } from "./history-storage"; +import type { StorageIdentity } from "./migration-contract"; +import { validateImmutableCommit } from "../src/history/topology"; +import type { ImmutableCommit } from "../src/history/topology-contracts"; +import { HISTORY_RPC_URL, HISTORY_SCHEMA3_SQL_SHA256, HistoryRpcInputError, parseHistoryWireJson as strictJson, parseHistoryRpcOperation as operation, historyWireInteger as integer } from "../src/server/history-rpc-contract"; + +export { HISTORY_RPC_URL, HISTORY_SCHEMA3_SQL_SHA256 } from "../src/server/history-rpc-contract"; +export interface HistoryRpcEnvironment { + WORLDS_DB?: HistoryD1Binding; + FACTORY_STORAGE_SECRET?: string; FACTORY_STORAGE_MODE?: string; + FACTORY_STORAGE_DATABASE_ID?: string; FACTORY_STORAGE_BINDING_NONCE?: string; + FACTORY_MIGRATION_EPOCH?: string; FACTORY_SOURCE_SHA?: string; + FACTORY_WORKER_BUNDLE_SHA256?: string; FACTORY_ADMISSION_FENCE?: string; +} +// Full canonical DDL fingerprints compiled from reviewed 0001/0002 control DDL +// and ALL seven 0003 tables, including column/PK/UNIQUE/FK/CHECK contracts. +// No runtime filesystem, caller DDL or claimed marker alone can supply these. +const ddlPins: Record = { + schema_migrations: "f3b91bee1ea996a819b3ee260497938220abbcc35f03b0ae7a8e929eabfc10ef", + storage_identity: "21840d2d14fd95a53f1eda02367e239dd9fa852c3d80f496070cf72db9d3efc3", + history_repositories: "d9d6c2625769dda7f4ef6f54008d93f94a50f77ffba81f8ce4f01b1a00bf8523", + history_commits: "06c05ef9f0d332911e5f3e116e336d7eb8372ec248bc8ab767acdc53a358b781", + history_anchors: "341d0dd107f8b8617a505cd7f2418990945d516c303e962b0feb667116a27f75", + history_frames: "05b527573d19d4dca633ce90e53690c254b40d620ec25ae2af74e39257adcab1", + history_frame_chunks: "10d3f101fe1fb6fdb603502094f84238b5bc0f258758149f1ca1c9e93a1e13b5", + history_batches: "a1f6c5ac8e810cf33e80ba234c984b4cb62941c7003f9f94f0339a398b052622", + history_append_guards: "2daa7c8fc1dd739515575e04ef2ae6cbb2c9d722ed7094b08df5ac598f14a439", +}; +const autoIndexes = new Set(["history_repositories_1", "history_commits_1", "history_anchors_1", "history_anchors_2", "history_frames_1", "history_frame_chunks_1", "history_batches_1", "history_batches_2", "history_append_guards_1"].map(n => `sqlite_autoindex_${n}`)); +const schemaSql = "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE tbl_name IN ('schema_migrations','storage_identity','history_repositories','history_commits','history_anchors','history_frames','history_frame_chunks','history_batches','history_append_guards') ORDER BY type,name LIMIT 32"; +const encode = new TextEncoder(), hash = async (s: string) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", encode.encode(s))), n => n.toString(16).padStart(2, "0")).join(""); +class Rejection extends Error { constructor(readonly code: string, readonly status: number) { super(code); } } +const reject = (code = "HISTORY_RPC_INVALID_REQUEST", status = 400): never => { throw new Rejection(code, status); }; +async function authenticated(actual: string, expected: string) { + if (typeof actual !== "string" || typeof expected !== "string") return false; + if (actual.length < 32 || actual.length > 256 || expected.length < 32 || expected.length > 256 || encode.encode(actual).length > 256 || encode.encode(expected).length > 256) return false; + const [a, b] = await Promise.all([hash(actual), hash(expected)]); let difference = 0; + for (let i = 0; i < a.length; i++) difference |= a.charCodeAt(i) ^ b.charCodeAt(i); + return difference === 0; +} +async function readBody(request: Request, timeoutMs: number) { + const reader = request.body?.getReader(); if (!reader) return reject(); + let timer: ReturnType | undefined, length = 0; const end = performance.now() + timeoutMs; + const timeout = new Promise((_, fail) => { timer = setTimeout(() => fail(new Rejection("HISTORY_RPC_BODY_TIMEOUT", 408)), timeoutMs); }); + try { + const advertised = request.headers.get("content-length"); + if (advertised !== null && (!/^(0|[1-9][0-9]*)$/.test(advertised) || Number(advertised) > 2 * 1024 * 1024)) return reject("HISTORY_RPC_BODY_TOO_LARGE", 413); + const chunks: Uint8Array[] = []; + for (;;) { const part = await Promise.race([reader.read(), timeout]); if (performance.now() >= end) return reject("HISTORY_RPC_BODY_TIMEOUT", 408); if (part.done) break; if (!(part.value instanceof Uint8Array)) return reject(); length += part.value.byteLength; if (length > 2 * 1024 * 1024) return reject("HISTORY_RPC_BODY_TOO_LARGE", 413); chunks.push(part.value); } + const bytes = new Uint8Array(length); let offset = 0; for (const c of chunks) { bytes.set(c, offset); offset += c.length; } + let value: unknown; try { value = strictJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { return reject(); } + if (performance.now() >= end) return reject("HISTORY_RPC_BODY_TIMEOUT", 408); + return value; + } finally { clearTimeout(timer); try { void reader.cancel().catch(() => {}); } catch { /* no pending cancellation promise */ } } +} +async function schema(db: HistoryD1Binding) { + const marker = await db.prepare("SELECT version,sql_sha256 FROM schema_migrations ORDER BY version DESC LIMIT 1").first<{ version: number; sql_sha256: string }>(); + if (marker?.version !== 3 || marker.sql_sha256 !== HISTORY_SCHEMA3_SQL_SHA256) return reject("HISTORY_STORAGE_SCHEMA_UNAVAILABLE", 503); + const keys = await db.prepare("PRAGMA foreign_keys").first<{ foreign_keys: number }>(); if (keys?.foreign_keys !== 1) return reject("HISTORY_STORAGE_SCHEMA_UNAVAILABLE", 503); + const rows = await db.prepare(schemaSql).all<{ type: string; name: string; tbl_name: string; sql: string | null }>(); + if (!rows.success || rows.results.length !== Object.keys(ddlPins).length + autoIndexes.size) return reject("HISTORY_STORAGE_SCHEMA_UNAVAILABLE", 503); + const seen = new Set(); + for (const row of rows.results) { + if (seen.has(row.name)) return reject("HISTORY_STORAGE_SCHEMA_UNAVAILABLE", 503); seen.add(row.name); + if (row.type === "index" && autoIndexes.has(row.name) && row.sql === null && row.name.startsWith(`sqlite_autoindex_${row.tbl_name}_`)) continue; + // Preserve SQL token boundaries. Deleting whitespace would confuse the + // valid declaration "payload TEXT NOT NULL" with a renamed column + // "payloadTEXTNOTNULL" and could falsely certify a broken table contract. + if (row.type !== "table" || row.name !== row.tbl_name || !ddlPins[row.name] || typeof row.sql !== "string" || await hash((row.sql.replace(/--[^\n]*/g, "").replace(/;\s*$/, "").toLowerCase().match(/[a-z_][a-z0-9_]*|[0-9]+|[^\s]/g) ?? []).join(" ")) !== ddlPins[row.name]) return reject("HISTORY_STORAGE_SCHEMA_UNAVAILABLE", 503); + } + if (Object.keys(ddlPins).some(n => !seen.has(n)) || [...autoIndexes].some(n => !seen.has(n))) return reject("HISTORY_STORAGE_SCHEMA_UNAVAILABLE", 503); +} +const known = new Set("invalid-anchor invalid-history-batch invalid-history-unit invalid-history-page invalid-frame-key normalized-frame-required duplicate-batch-commit duplicate-batch-frame repo-mismatch non-spine-commit parent-cycle frame-membership-unknown immutable-commit-conflict immutable-frame-conflict immutable-tree-conflict partial-observations-unresolved normalized-duplicate-path non-directory-parent frame-commit-binding-mismatch invalid-frame-coverage invalid-normalized-frame invalid-provenance invalid-tree-entry path-outside-scope commit-byte-cap frame-byte-cap history-revision-conflict action-already-used batch-payload-conflict anchor-generation-conflict history-commit-cap history-frame-cap history-anchor-cap history-receipt-cap history-byte-cap".split(" ")); +function reply(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" } }); } +/** Standalone SERVER dispatcher only. No gateway/Worker import, client, route, + * network request, secret lookup or schema adoption. Logical UUID/nonce is not + * proof of the physical Cloudflare binding or active Worker version. */ +export async function historyStorageRpc(request: Request, env: HistoryRpcEnvironment, options: { bodyTimeoutMs?: number } = {}): Promise { + let batchStarted = false; + let verified: { op: string; identity: StorageIdentity; schemaVersion: 3 } | undefined; + try { + if (!await authenticated(request.headers.get("x-factory-storage-key") ?? "", env.FACTORY_STORAGE_SECRET ?? "")) return reject("HISTORY_STORAGE_AUTH_REQUIRED", 403); + if (env.FACTORY_STORAGE_MODE !== "worlds") return reject("HISTORY_STORAGE_MODE_REJECTED", 503); + if (request.url !== HISTORY_RPC_URL || request.method !== "POST" || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers.get("content-type") ?? "")) return reject(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(env.FACTORY_STORAGE_DATABASE_ID ?? "") || !/^[a-f0-9]{32}$/.test(env.FACTORY_STORAGE_BINDING_NONCE ?? "") || !/^[a-f0-9]{32}$/.test(env.FACTORY_MIGRATION_EPOCH ?? "") || !/^[a-f0-9]{40}$/.test(env.FACTORY_SOURCE_SHA ?? "") || !/^[a-f0-9]{64}$/.test(env.FACTORY_WORKER_BUNDLE_SHA256 ?? "") || !(env.FACTORY_ADMISSION_FENCE === "" || env.FACTORY_ADMISSION_FENCE === env.FACTORY_MIGRATION_EPOCH)) return reject("HISTORY_STORAGE_IDENTITY_UNAVAILABLE", 503); + const duration = options.bodyTimeoutMs ?? 5000; if (!integer(duration, 5000) || duration < 1) return reject("HISTORY_STORAGE_UNAVAILABLE", 503); + const op = operation(await readBody(request, duration)); + if (op.op === "historyAppend" && env.FACTORY_ADMISSION_FENCE !== "") return reject("STORAGE_MIGRATION_HOLD", 503); + if (!env.WORLDS_DB) return reject("HISTORY_STORAGE_UNAVAILABLE", 503); + const db = env.WORLDS_DB.withSession?.("first-primary") ?? env.WORLDS_DB; + const identityRows = await db.prepare("SELECT singleton,database_uuid,binding_nonce FROM storage_identity ORDER BY singleton LIMIT 2").all<{ singleton: number; database_uuid: string; binding_nonce: string }>(); + if (!identityRows.success || identityRows.results.length !== 1 || identityRows.results[0]?.singleton !== 1 || identityRows.results[0]?.database_uuid !== env.FACTORY_STORAGE_DATABASE_ID || identityRows.results[0]?.binding_nonce !== env.FACTORY_STORAGE_BINDING_NONCE) return reject("HISTORY_STORAGE_IDENTITY_UNAVAILABLE", 503); + await schema(db); + const identity: StorageIdentity = { databaseId: env.FACTORY_STORAGE_DATABASE_ID!, bindingNonce: env.FACTORY_STORAGE_BINDING_NONCE!, epoch: env.FACTORY_MIGRATION_EPOCH!, sourceSha: env.FACTORY_SOURCE_SHA!, workerBundleSha256: env.FACTORY_WORKER_BUNDLE_SHA256!, admissionFenced: env.FACTORY_ADMISSION_FENCE !== "" }; + verified = { op: op.op, identity, schemaVersion: 3 }; + // Observe initiation only; an exception after this point is not a rollback + // receipt. This wraps the existing store, never reimplements its persistence. + const store = new D1HistoryStore({ prepare: sql => db.prepare(sql), batch: statements => { batchStarted = true; return db.batch(statements); } }); + let result: unknown, proofCommit: ImmutableCommit | undefined; + if (op.op === "historyFrame") { + const frame = await store.frame(op.anchor, op.frameKey); result = frame; + if (frame.status === "available") { + const row = await db.prepare("SELECT payload FROM history_commits WHERE repo=? AND sha=?").bind(op.anchor.repoKey, frame.frame.commitSha).first<{ payload: string }>(); + if (!row || new TextEncoder().encode(row.payload).length > 2048) return reject("HISTORY_STORAGE_UNAVAILABLE", 503); + const parsed = validateImmutableCommit(JSON.parse(row.payload)); + if (!parsed.ok || JSON.stringify(parsed.value) !== row.payload || parsed.value.repoKey !== op.anchor.repoKey || parsed.value.sha !== frame.frame.commitSha || parsed.value.treeSha !== frame.frame.treeSha) return reject("HISTORY_STORAGE_UNAVAILABLE", 503); + proofCommit = parsed.value; + } + } else result = op.op === "historyPage" ? await store.page(op.anchor, op.offset, op.limit) : await store.append(op.batch); + return reply({ ok: true, backend: "d1", ...verified, result, ...(proofCommit ? { proofCommit } : {}) }); + } catch (error) { + if (batchStarted) return reply({ ok: false, backend: "d1", ...verified, code: "HISTORY_WRITE_OUTCOME_UNKNOWN", error: "The write outcome is unknown. Read authoritative history before another explicit action; no retry was sent.", writeOutcome: "unknown" }, 503); + const code = error instanceof Rejection || error instanceof HistoryRpcInputError ? error.code : known.has((error as { code?: string })?.code ?? "") ? (error as { code: string }).code : "HISTORY_STORAGE_UNAVAILABLE"; + const status = error instanceof Rejection || error instanceof HistoryRpcInputError ? error.status : code.endsWith("-cap") ? 429 : /conflict|already-used|non-spine|membership-unknown|unresolved|cycle/.test(code) ? 409 : known.has(code) ? 400 : 503; + return reply({ ok: false, backend: "d1", ...verified, code, error: status < 500 ? "The fixed history storage request was rejected." : "Authoritative history storage is unavailable.", writeOutcome: "not-started" }, status); + } +} diff --git a/deploy/history-storage.ts b/deploy/history-storage.ts new file mode 100644 index 0000000..fac13c5 --- /dev/null +++ b/deploy/history-storage.ts @@ -0,0 +1,257 @@ +import type { ImmutableCommit, ImmutableTreeFrame, TopologyAnchor } from "../src/history/topology-contracts"; +import { coalesceImmutableCommit, coalesceImmutableFrame, compareTopologyFrames, firstParentSpine, topologyBytes, topologyFrameKey, validateImmutableCommit, validateImmutableTreeFrame } from "../src/history/topology"; +import { codePointCompare, digest } from "../src/evidence/contracts"; +import { HISTORY_STORAGE_LIMITS as L, HistoryStorageError, historyAnchor, historyCursor, historyReject, validHistoryCursor, type HistoryAppendBatch, type HistoryAppendResult, type HistoryCursor, type HistoryFrameResult, type HistoryPage, type HistoryRetention, type HistoryStore } from "../src/server/history-storage"; + +/** Minimal injectable D1 shape. No HTTP/RPC surface and no production fallback. + * batch MUST have D1's all-statements-atomic/rollback-on-error semantics. */ +export interface HistoryStatement { + bind(...values: unknown[]): HistoryStatement; + all(): Promise<{ success: boolean; results: T[] }>; + first(): Promise; +} +export interface HistoryD1Binding { + prepare(sql: string): HistoryStatement; + batch(statements: HistoryStatement[]): Promise<{ success: boolean }[]>; + withSession?(constraint: "first-primary"): HistoryD1Binding; +} +interface AnchorRow { anchor: TopologyAnchor; cursor: HistoryCursor; members: string[] } +interface Manifest { frame_key: string; commit_sha: string; chunk_count: number; payload_bytes: number; payload_hash: string } +interface Receipt { batchId: string; actionId: string; anchorKey: string; fingerprint: string; acceptedCursor: HistoryCursor } +interface State { + revision: number; normalizedBytes: number; commits: Map; anchors: Map; + manifests: Map; receipts: number; receiptBytes: number; +} +const key = (a: TopologyAnchor) => JSON.stringify([a.sha, a.generation]); +const json = JSON.stringify; +const bytes = (s: string) => new TextEncoder().encode(s).length; +const record = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); +const fields = (v: Record, names: string[]) => Object.keys(v).length === names.length && names.every(n => Object.hasOwn(v, n)); +const id = (v: unknown): v is string => typeof v === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(v); +const cursorValue = (c: HistoryCursor): HistoryCursor => ({ revision: c.revision, retainedCommits: c.retainedCommits, nextParentSha: c.nextParentSha, reachedRoot: c.reachedRoot, termination: c.termination }); +function unwrap(r: { ok: true; value: T } | { ok: false; code: string }): T { return r.ok ? r.value : historyReject(r.code); } +function corrupt(): never { return historyReject("history-storage-unavailable"); } +const emptyRetention = (): HistoryRetention => ({ commits: 0, memberships: 0, frames: 0, anchors: 0, receipts: 0, normalizedBytes: 0, physicalBytes: null }); + +/** Strings remain valid UTF-8 independently, including characters crossing the + * byte boundary. Each D1 bound parameter remains below its 100KB limit. */ +function chunks(payload: string): string[] { + const encoded = new TextEncoder().encode(payload), result: string[] = []; + for (let at = 0; at < encoded.length;) { + let end = Math.min(at + L.chunkBytes, encoded.length); + while (end < encoded.length && (encoded[end]! & 0xc0) === 0x80) end--; + result.push(new TextDecoder("utf-8", { fatal: true }).decode(encoded.subarray(at, end))); at = end; + } + return result; +} + +/** Every limit can only be reduced for isolated deployments/tests. No eviction. */ +export class D1HistoryStore implements HistoryStore { + private readonly limits: typeof L; + constructor(private readonly binding: HistoryD1Binding, limits: Partial> = {}) { + for (const [name, value] of Object.entries(limits)) if (!["commits", "frames", "bytes", "anchors", "receipts"].includes(name) || !Number.isSafeInteger(value) || value < 1 || value > L[name as keyof typeof L]) historyReject("invalid-storage-limit"); + this.limits = { ...L, ...limits } as typeof L; + } + private async safe(action: (db: HistoryD1Binding) => Promise): Promise { + try { return await action(this.binding.withSession?.("first-primary") ?? this.binding); } + catch (error) { if (error instanceof HistoryStorageError) throw error; return corrupt(); } + } + private async all(db: HistoryD1Binding, sql: string, values: unknown[], max: number): Promise { + const r = await db.prepare(sql).bind(...values).all(); + if (!r.success || !Array.isArray(r.results) || r.results.length > max) return corrupt(); + return r.results; + } + private async revision(db: HistoryD1Binding, repo: string): Promise { + const row = await db.prepare("SELECT revision FROM history_repositories WHERE repo=?").bind(repo).first<{ revision: number }>(); + if (row && (!Number.isSafeInteger(row.revision) || row.revision < 0)) return corrupt(); + return row?.revision ?? -1; + } + private async state(db: HistoryD1Binding, repo: string): Promise { + const row = await db.prepare("SELECT revision,normalized_bytes FROM history_repositories WHERE repo=?").bind(repo).first<{ revision: number; normalized_bytes: number }>(); + const state: State = { revision: row?.revision ?? -1, normalizedBytes: row?.normalized_bytes ?? 0, commits: new Map(), anchors: new Map(), manifests: new Map(), receipts: 0, receiptBytes: 0 }; + if (!row) return state; + if (!Number.isSafeInteger(row.revision) || row.revision < 0 || !Number.isSafeInteger(row.normalized_bytes) || row.normalized_bytes < 0 || row.normalized_bytes > L.bytes) return corrupt(); + // Bounded keyset pages: never materialize an unbounded history table. + let after = ""; + for (;;) { + const rows = await this.all<{ sha: string; payload: string }>(db, "SELECT sha,payload FROM history_commits WHERE repo=? AND sha>? ORDER BY sha LIMIT 50", [repo, after], L.page); + for (const r of rows) { + if (bytes(r.payload) > 2048) return corrupt(); + const c = unwrap(validateImmutableCommit(JSON.parse(r.payload))); + if (c.repoKey !== repo || c.sha !== r.sha || json(c) !== r.payload || state.commits.has(c.sha)) return corrupt(); + state.commits.set(c.sha, c); + if (state.commits.size > this.limits.commits) return corrupt(); + after = r.sha; + } + if (rows.length < L.page) break; + } + const anchors = await this.all<{ anchor_key: string; generation: string; payload: string }>(db, "SELECT anchor_key,generation,payload FROM history_anchors WHERE repo=? ORDER BY anchor_key LIMIT 9", [repo], this.limits.anchors); + for (const r of anchors) { + if (bytes(r.payload) > 32 * 1024) return corrupt(); + const a = JSON.parse(r.payload) as AnchorRow; + if (!record(a) || !fields(a as unknown as Record, ["anchor", "cursor", "members"]) || !validHistoryCursor(a.cursor) || !Array.isArray(a.members) || a.members.length > this.limits.commits || new Set(a.members).size !== a.members.length || a.members.some(s => !state.commits.has(s))) return corrupt(); + const anchor = historyAnchor(a.anchor); + if (anchor.repoKey !== repo || key(anchor) !== r.anchor_key || anchor.generation !== r.generation || a.cursor.retainedCommits !== a.members.length) return corrupt(); + const facts = a.members.map(s => state.commits.get(s)!), spine = firstParentSpine(anchor, facts); + if (!spine.ok || json(spine.value.commits.map(c => c.sha)) !== json(a.members) || spine.value.nonSpineShas.length) return corrupt(); + // Shape and byte counts cannot establish cursor truth: a same-size wrong + // parent hash (or false root/coverage state) must never be published. + if (json(cursorValue(a.cursor)) !== json(historyCursor(anchor, facts, a.cursor.revision))) return corrupt(); + state.anchors.set(r.anchor_key, a); + } + const manifests = await this.all(db, "SELECT frame_key,commit_sha,chunk_count,payload_bytes,payload_hash FROM history_frames WHERE repo=? ORDER BY frame_key LIMIT 9", [repo], this.limits.frames); + for (const m of manifests) { + if (!state.commits.has(m.commit_sha) || !Number.isSafeInteger(m.chunk_count) || m.chunk_count < 1 || m.chunk_count > 9 || !Number.isSafeInteger(m.payload_bytes) || m.payload_bytes < 1 || m.payload_bytes > 512 * 1024 || !/^[a-f0-9]{64}$/.test(m.payload_hash) || m.frame_key.length > 2048) return corrupt(); + state.manifests.set(m.frame_key, m); + } + const counts = await db.prepare("SELECT COUNT(*) AS count,COALESCE(SUM(length(CAST(payload AS BLOB))),0) AS bytes FROM history_batches WHERE repo=?").bind(repo).first<{ count: number; bytes: number }>(); + if (!counts || !Number.isSafeInteger(counts.count) || counts.count < 0 || counts.count > this.limits.receipts || !Number.isSafeInteger(counts.bytes) || counts.bytes < 0 || counts.bytes > L.bytes) return corrupt(); + state.receipts = counts.count; state.receiptBytes = counts.bytes; + if (this.logicalBytes(repo, state, state.revision) !== state.normalizedBytes || await this.revision(db, repo) !== state.revision) return historyReject("history-revision-conflict"); + return state; + } + private logicalBytes(repo: string, state: State, revision: number): number { + return topologyBytes({ repo, revision }) + [...state.commits.values()].reduce((n, c) => n + topologyBytes(c), 0) + + [...state.anchors.values()].reduce((n, a) => n + topologyBytes(a), 0) + + [...state.manifests.values()].reduce((n, m) => n + topologyBytes(m) + m.payload_bytes, 0) + state.receiptBytes; + } + private retention(s: State): HistoryRetention { + return { commits: s.commits.size, memberships: [...s.anchors.values()].reduce((n, a) => n + a.members.length, 0), frames: s.manifests.size, anchors: s.anchors.size, receipts: s.receipts, normalizedBytes: s.normalizedBytes, physicalBytes: null }; + } + private async decode(db: HistoryD1Binding, repo: string, manifest: Manifest, commit: ImmutableCommit): Promise { + const rows = await this.all<{ ordinal: number; payload: string }>(db, "SELECT ordinal,payload FROM history_frame_chunks WHERE repo=? AND frame_key=? ORDER BY ordinal LIMIT 10", [repo, manifest.frame_key], 9); + if (rows.length !== manifest.chunk_count || rows.some((r, i) => r.ordinal !== i || bytes(r.payload) > L.chunkBytes)) return corrupt(); + const payload = rows.map(r => r.payload).join(""); + if (bytes(payload) !== manifest.payload_bytes || await digest(JSON.parse(payload)) !== manifest.payload_hash) return corrupt(); + const frame = unwrap(validateImmutableTreeFrame(JSON.parse(payload), commit)); + if (json(frame) !== payload || topologyFrameKey(frame) !== manifest.frame_key) return corrupt(); + return frame; + } + async page(anchorInput: TopologyAnchor, offset = 0, limit: number = L.page): Promise { + const anchor = historyAnchor(anchorInput); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > L.commits || !Number.isSafeInteger(limit) || limit < 1 || limit > L.page) return historyReject("invalid-history-page"); + return this.safe(async db => { + const s = await this.state(db, anchor.repoKey), a = s.anchors.get(key(anchor)); + return { status: a ? "available" : "missing-anchor", anchor, cursor: a?.cursor ?? historyCursor(anchor, [], 0), commits: a ? a.members.slice(offset, offset + limit).map(sha => s.commits.get(sha)!) : [], nextOffset: a && offset + limit < a.members.length ? offset + limit : null, completeHistory: false, total: null, retention: s.revision === -1 ? emptyRetention() : this.retention(s) }; + }); + } + async frame(anchorInput: TopologyAnchor, frameKey: string): Promise { + const anchor = historyAnchor(anchorInput); + if (typeof frameKey !== "string" || frameKey.length > 2048) return historyReject("invalid-frame-key"); + return this.safe(async db => { + const s = await this.state(db, anchor.repoKey), a = s.anchors.get(key(anchor)); + if (!a) return { status: "missing-anchor" }; + let parts: unknown; try { parts = JSON.parse(frameKey); } catch { return historyReject("invalid-frame-key"); } + if (!Array.isArray(parts) || parts.length !== 4 || parts[0] !== anchor.repoKey || !a.members.includes(parts[1])) return { status: "not-member" }; + const m = s.manifests.get(frameKey); if (!m) return { status: "missing-frame" }; + const frame = await this.decode(db, anchor.repoKey, m, s.commits.get(m.commit_sha)!); + if (await this.revision(db, anchor.repoKey) !== s.revision) return historyReject("history-revision-conflict"); + return { status: "available", frame }; + }); + } + async append(input: HistoryAppendBatch): Promise { + // Runtime validation is intentional even though internal callers have types. + if (!record(input) || !fields(input, ["anchor", "batchId", "actionId", "expectedRevision", "expectedCursor", "unit"]) || !id(input.batchId) || !id(input.actionId) || !Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0 || !validHistoryCursor(input.expectedCursor) || input.expectedCursor.revision !== input.expectedRevision || !record(input.unit) || input.unit.complete !== true) return historyReject("invalid-history-batch"); + const anchor = historyAnchor(input.anchor), repo = anchor.repoKey, anchorKey = key(anchor), unit = input.unit; + if (unit.kind === "commits" ? !fields(unit, ["kind", "complete", "commits"]) || !Array.isArray(unit.commits) || unit.commits.length < 1 || unit.commits.length > 4 : unit.kind === "frames" ? !fields(unit, ["kind", "complete", "frames"]) || !Array.isArray(unit.frames) || unit.frames.length < 1 || unit.frames.length > 2 : true) return historyReject("invalid-history-unit"); + return this.safe(async db => { + const s = await this.state(db, repo), prior = s.anchors.get(anchorKey); + if ([...s.anchors.values()].some(a => a.anchor.generation === anchor.generation && a.anchor.sha !== anchor.sha)) return historyReject("anchor-generation-conflict"); + const current = prior?.cursor ?? historyCursor(anchor, [], 0), normalizedCommits: ImmutableCommit[] = [], normalizedFrames: ImmutableTreeFrame[] = []; + const changedCommits = new Map(); + if (unit.kind === "commits") { + for (const raw of unit.commits) { + const c = unwrap(validateImmutableCommit(raw)); if (c.repoKey !== repo) return historyReject("repo-mismatch"); + if (normalizedCommits.some(x => x.sha === c.sha)) return historyReject("duplicate-batch-commit"); + normalizedCommits.push(c); const old = s.commits.get(c.sha); changedCommits.set(c.sha, old ? unwrap(coalesceImmutableCommit(old, c)) : c); + } + } else { + for (const raw of unit.frames) { + if (!record(raw) || !Object.hasOwn(raw, "sourceCompleteness")) return historyReject("normalized-frame-required"); + const c = s.commits.get(raw.commitSha as string); if (!c || !prior?.members.includes(c.sha)) return historyReject("frame-membership-unknown"); + const f = unwrap(validateImmutableTreeFrame(raw, c)); if (normalizedFrames.some(x => topologyFrameKey(x) === topologyFrameKey(f))) return historyReject("duplicate-batch-frame"); + normalizedFrames.push(f); + } + } + normalizedCommits.sort((a, b) => codePointCompare(a.sha, b.sha)); normalizedFrames.sort((a, b) => codePointCompare(topologyFrameKey(a), topologyFrameKey(b))); + const fingerprint = await digest({ anchor, batchId: input.batchId, actionId: input.actionId, expectedRevision: input.expectedRevision, expectedCursor: cursorValue(input.expectedCursor), unit: unit.kind === "commits" ? { kind: "commits", complete: true, commits: normalizedCommits } : { kind: "frames", complete: true, frames: normalizedFrames } }); + const previous = await db.prepare("SELECT payload FROM history_batches WHERE repo=? AND batch_id=?").bind(repo, input.batchId).first<{ payload: string }>(); + if (previous) { + if (bytes(previous.payload) > 2048) return corrupt(); + const receipt = JSON.parse(previous.payload) as Receipt; + if (receipt.fingerprint !== fingerprint || receipt.anchorKey !== anchorKey || receipt.batchId !== input.batchId || receipt.actionId !== input.actionId || !validHistoryCursor(receipt.acceptedCursor)) return historyReject("batch-payload-conflict"); + if (await this.revision(db, repo) !== s.revision) return historyReject("history-revision-conflict"); + return { idempotent: true, acceptedCursor: receipt.acceptedCursor, currentCursor: current, retention: this.retention(s) }; + } + if (json(cursorValue(input.expectedCursor)) !== json(current)) return historyReject("history-revision-conflict"); + if (await db.prepare("SELECT batch_id FROM history_batches WHERE repo=? AND anchor_key=? AND action_id=?").bind(repo, anchorKey, input.actionId).first()) return historyReject("action-already-used"); + const oldRepoRevision = s.revision, nextRepoRevision = Math.max(0, s.revision) + 1, oldAnchorPayload = prior ? json(prior) : null; + let acceptedCursor: HistoryCursor, members = prior?.members ?? []; + if (unit.kind === "commits") { + const memberFacts = new Map(members.map(sha => [sha, s.commits.get(sha)!])); + for (const [sha, commit] of changedCommits) memberFacts.set(sha, commit); + if (memberFacts.size > this.limits.commits) return historyReject("history-commit-cap"); + const spine = unwrap(firstParentSpine(anchor, [...memberFacts.values()])); + if (spine.nonSpineShas.length || spine.commits.length < members.length || members.some((sha, i) => spine.commits[i]?.sha !== sha)) return historyReject("non-spine-commit"); + members = spine.commits.map(c => c.sha); acceptedCursor = historyCursor(anchor, spine.commits, current.revision + 1); + } else acceptedCursor = { ...current, revision: current.revision + 1 }; + for (const [sha, c] of changedCommits) s.commits.set(sha, c); + s.anchors.set(anchorKey, { anchor, cursor: acceptedCursor, members }); + const changedAnchors = new Map([[anchorKey, s.anchors.get(anchorKey)!]]); + // Facts are shared, membership is not. A null->observed parent refinement + // changes the cursor of every retained window that ends at that fact. + // Refresh only changed semantics, atomically with the facts; never extend + // another generation's membership or rewrite historical batch receipts. + for (const [otherKey, other] of s.anchors) { + if (otherKey === anchorKey || !other.members.some(sha => changedCommits.has(sha))) continue; + const facts = other.members.map(sha => s.commits.get(sha)!); + const derived = historyCursor(other.anchor, facts, other.cursor.revision); + if (json(derived) === json(cursorValue(other.cursor))) continue; + const refreshed = { ...other, cursor: { ...derived, revision: other.cursor.revision + 1 } }; + s.anchors.set(otherKey, refreshed); changedAnchors.set(otherKey, refreshed); + } + const changedFrames = new Map(); + for (const f of normalizedFrames) { + const frameKey = topologyFrameKey(f), c = s.commits.get(f.commitSha)!, old = s.manifests.get(frameKey); + const merged = old ? unwrap(coalesceImmutableFrame(await this.decode(db, repo, old, c), f, c)) : f; + // Decode one old frame at a time; never retain eight decoded frames. + for (const [otherKey, m] of s.manifests) { + if (otherKey === frameKey) continue; + const other = changedFrames.get(otherKey)?.frame ?? await this.decode(db, repo, m, s.commits.get(m.commit_sha)!); + if (other.treeSha === merged.treeSha) unwrap(compareTopologyFrames(other, merged, s.commits.get(other.commitSha)!, c)); + } + const payload = json(merged), pieces = chunks(payload), manifest: Manifest = { frame_key: frameKey, commit_sha: merged.commitSha, chunk_count: pieces.length, payload_bytes: bytes(payload), payload_hash: await digest(merged) }; + changedFrames.set(frameKey, { frame: merged, pieces, manifest }); s.manifests.set(frameKey, manifest); + } + const receipt: Receipt = { batchId: input.batchId, actionId: input.actionId, anchorKey, fingerprint, acceptedCursor }; + s.receipts++; s.receiptBytes += topologyBytes(receipt); s.normalizedBytes = this.logicalBytes(repo, s, nextRepoRevision); + if (s.commits.size > this.limits.commits || members.length > this.limits.commits) return historyReject("history-commit-cap"); + if (s.anchors.size > this.limits.anchors) return historyReject("history-anchor-cap"); + if (s.manifests.size > this.limits.frames) return historyReject("history-frame-cap"); + if (s.receipts > this.limits.receipts) return historyReject("history-receipt-cap"); + if (s.normalizedBytes > this.limits.bytes) return historyReject("history-byte-cap"); + const statements: HistoryStatement[] = []; + const add = (sql: string, ...values: unknown[]) => statements.push(db.prepare(sql).bind(...values)); + add("INSERT INTO history_repositories(repo,revision,normalized_bytes) VALUES(?,0,0) ON CONFLICT(repo) DO NOTHING", repo); + // The CHECK failure rolls back even the preceding repository creation. + // Both repo and anchor observations are compared inside this transaction. + add(`INSERT INTO history_append_guards(repo,accepted) SELECT ?,CASE WHEN + (SELECT revision FROM history_repositories WHERE repo=?)=? AND + COALESCE((SELECT payload FROM history_anchors WHERE repo=? AND anchor_key=?),'')=? + THEN 1 ELSE 0 END`, repo, repo, Math.max(0, oldRepoRevision), repo, anchorKey, oldAnchorPayload ?? ""); + for (const [sha, c] of changedCommits) add("INSERT INTO history_commits(repo,sha,payload) VALUES(?,?,?) ON CONFLICT(repo,sha) DO UPDATE SET payload=excluded.payload", repo, sha, json(c)); + for (const [changedKey, changed] of changedAnchors) add("INSERT INTO history_anchors(repo,anchor_key,generation,payload) VALUES(?,?,?,?) ON CONFLICT(repo,anchor_key) DO UPDATE SET payload=excluded.payload", repo, changedKey, changed.anchor.generation, json(changed)); + for (const [frameKey, f] of changedFrames) { + add("DELETE FROM history_frame_chunks WHERE repo=? AND frame_key=?", repo, frameKey); + add("INSERT INTO history_frames(repo,frame_key,commit_sha,chunk_count,payload_bytes,payload_hash) VALUES(?,?,?,?,?,?) ON CONFLICT(repo,frame_key) DO UPDATE SET chunk_count=excluded.chunk_count,payload_bytes=excluded.payload_bytes,payload_hash=excluded.payload_hash", repo, frameKey, f.manifest.commit_sha, f.manifest.chunk_count, f.manifest.payload_bytes, f.manifest.payload_hash); + f.pieces.forEach((piece, i) => add("INSERT INTO history_frame_chunks(repo,frame_key,ordinal,payload) VALUES(?,?,?,?)", repo, frameKey, i, piece)); + } + add("INSERT INTO history_batches(repo,batch_id,action_id,anchor_key,fingerprint,payload) VALUES(?,?,?,?,?,?)", repo, input.batchId, input.actionId, anchorKey, fingerprint, json(receipt)); + add("UPDATE history_repositories SET revision=?,normalized_bytes=? WHERE repo=?", nextRepoRevision, s.normalizedBytes, repo); + add("DELETE FROM history_append_guards WHERE repo=?", repo); + const results = await db.batch(statements); + if (results.length !== statements.length || results.some(r => !r.success)) return corrupt(); + return { idempotent: false, acceptedCursor, currentCursor: acceptedCursor, retention: this.retention(s) }; + }); + } +} diff --git a/deploy/storage-probe.ts b/deploy/storage-probe.ts new file mode 100644 index 0000000..fd4eff2 --- /dev/null +++ b/deploy/storage-probe.ts @@ -0,0 +1,13 @@ +import { worldStorageRpc, type StorageEnvironment } from "./d1-storage"; +export const STORAGE_PROBE_HOST = "moon-factory-storage-probe-9ee81fb8.1031luy.workers.dev"; +export const STORAGE_PROBE_PATH = "/github-factory/_storage-probe"; +export const STORAGE_PROBE_DATABASE = "b2210c4a-4ed3-49ba-b044-2f6c24958fac"; +/** Isolated rollback-proof endpoint. Never forwards company/product traffic, + * arbitrary SQL/URLs, business operations, or a caller-selected RPC profile. */ +export default { + fetch(request: Request, env: StorageEnvironment): Promise | Response { + const url = new URL(request.url); + if (url.protocol !== "https:" || url.host !== STORAGE_PROBE_HOST || url.username || url.password || url.pathname !== STORAGE_PROBE_PATH || url.search || url.hash || request.method !== "POST") return new Response("Not found", { status: 404 }); + return worldStorageRpc(request, env, "rollback-proof"); + }, +}; diff --git a/deploy/storage-schema4.ts b/deploy/storage-schema4.ts new file mode 100644 index 0000000..5ffc66e --- /dev/null +++ b/deploy/storage-schema4.ts @@ -0,0 +1,213 @@ +import type { HistoryD1Binding } from "./history-storage"; +import { captureHash } from "../src/server/world-public-capture-contract"; +import type { WorldCaptureIdentity } from "../src/server/world-public-capture-contract"; + +/** Preserve quoted literals and token boundaries; skip only actual SQL + * comments outside strings. Marker hashes alone do not certify a schema. */ +export function schema4SqlTokens(sql: string): string { + const tokens: string[] = []; + for (let i = 0; i < sql.length;) { + if (/\s/.test(sql[i]!)) { i++; continue; } + if (sql.startsWith("--", i)) { const end = sql.indexOf("\n", i); i = end < 0 ? sql.length : end + 1; continue; } + if (sql.startsWith("/*", i)) { const end = sql.indexOf("*/", i + 2); if (end < 0) throw Error(); i = end + 2; continue; } + if (sql[i] === "'") { const at = i++; for (;;) { if (i >= sql.length) throw Error(); if (sql[i++] === "'") { if (sql[i] === "'") { i++; continue; } break; } } tokens.push(sql.slice(at, i)); continue; } + const word = sql.slice(i).match(/^[A-Za-z0-9_]+/); if (word) { tokens.push(word[0].toLowerCase()); i += word[0].length; continue; } + if (/[(),=<>.*-]/.test(sql[i]!)) { tokens.push(sql[i++]!); continue; } + if (sql[i] === ";" && !sql.slice(i + 1).trim()) break; + throw Error(); + } + return JSON.stringify(tokens); +} + +// Compiled reviewed migration/table/index pins follow below. No filesystem, +// caller-supplied schema, initializer, repair or SQL application at runtime. +export const WORLD_SCHEMA4_MIGRATIONS = [[1,"c3bdf6ab52a9466d4f6cd89d18c52d13f93c538c4f97a1df4b8e19e55a5229eb"],[2,"5005bea0d822f08c87455cd7b7938067d5973ad5719115c67b79c6970c26ba56"],[3,"6b0f837993b07fee15e7ebf45244b55fa7d9303dfb1ed6a0a885ae0979892994"],[4,"c011a5c579cb0c4555dce94ed14d95c7b7e5849741d3aa6a67058056ac93d965"]] as const; +const pins: Record = { + "history_anchors": [ + "table", + "history_anchors", + "9d7025a49665d3247bc21b18b15fd8765349f3392206de0b4c0ac4c007c0f1e5" + ], + "history_append_guards": [ + "table", + "history_append_guards", + "3c96e3830b347484f00d166976ba5d8e8f079b0a45c3a4b8c781e6a5e1ba6b2e" + ], + "history_batches": [ + "table", + "history_batches", + "244658ba1466c585ca5022044556e090f9530be3c89c7c5fbc2be0e757e00f38" + ], + "history_commits": [ + "table", + "history_commits", + "10a6352bfbfa84b48653160ea00660ccddd15f09e237ff61eb44fa188c202e28" + ], + "history_frame_chunks": [ + "table", + "history_frame_chunks", + "ad4fc34c526339330414dc212305dca8014b101d579ba16006c91eb9b7f58c96" + ], + "history_frames": [ + "table", + "history_frames", + "975d117d8eb9cb057f06e3f7f1c9981ea783314ec7634d7c715d3dc2eac0dd2c" + ], + "history_repositories": [ + "table", + "history_repositories", + "4c57d50079b69ab93a8750fdbf69adb65a3d53f0ee77f6bfcd5e178905245c8b" + ], + "migration_guards": [ + "table", + "migration_guards", + "9f364985618adbe5784b8a1f52400a65d668ba811e4cb633b796ed76f5ed896b" + ], + "migration_sentinels": [ + "table", + "migration_sentinels", + "1c95f93d172e7e397b4093b2d2eae571be566deed3ba10afff6d532d5bfb9838" + ], + "schema_migrations": [ + "table", + "schema_migrations", + "d9795f3591d9ec60f051e5d9e549cec476c4af1f6c9398ab849e7185bbfd866f" + ], + "sqlite_autoindex_history_anchors_1": [ + "index", + "history_anchors", + null + ], + "sqlite_autoindex_history_anchors_2": [ + "index", + "history_anchors", + null + ], + "sqlite_autoindex_history_append_guards_1": [ + "index", + "history_append_guards", + null + ], + "sqlite_autoindex_history_batches_1": [ + "index", + "history_batches", + null + ], + "sqlite_autoindex_history_batches_2": [ + "index", + "history_batches", + null + ], + "sqlite_autoindex_history_commits_1": [ + "index", + "history_commits", + null + ], + "sqlite_autoindex_history_frame_chunks_1": [ + "index", + "history_frame_chunks", + null + ], + "sqlite_autoindex_history_frames_1": [ + "index", + "history_frames", + null + ], + "sqlite_autoindex_history_repositories_1": [ + "index", + "history_repositories", + null + ], + "sqlite_autoindex_migration_guards_1": [ + "index", + "migration_guards", + null + ], + "sqlite_autoindex_migration_sentinels_1": [ + "index", + "migration_sentinels", + null + ], + "sqlite_autoindex_storage_provenance_1": [ + "index", + "storage_provenance", + null + ], + "sqlite_autoindex_world_aliases_1": [ + "index", + "world_aliases", + null + ], + "sqlite_autoindex_world_capture_barriers_1": [ + "index", + "world_capture_barriers", + null + ], + "sqlite_autoindex_world_capture_generations_1": [ + "index", + "world_capture_generations", + null + ], + "sqlite_autoindex_worlds_1": [ + "index", + "worlds", + null + ], + "storage_identity": [ + "table", + "storage_identity", + "66e9d0e3b20ee7907d80d7a7d92c6d7653b0c837a01a68689ab203c09dd2629e" + ], + "storage_provenance": [ + "table", + "storage_provenance", + "bb799e063b398e29c455e9369f61ae2e9a223905274d6893fdc97a9f022bf481" + ], + "world_aliases": [ + "table", + "world_aliases", + "a58ada65481ba6fac4e6886c5bd2fa74414575abea2634b1e9bc8fd5644fd932" + ], + "world_capture_authority": [ + "table", + "world_capture_authority", + "26d4494c0c1ca5dce32958487c66edc108e8b589db2aba8f613db314ff0449fc" + ], + "world_capture_barriers": [ + "table", + "world_capture_barriers", + "7c192089920270e90fb29c1aa24792d90b825e62fa6579ba1f55f5fa73065bf6" + ], + "world_capture_generations": [ + "table", + "world_capture_generations", + "33ed083ca3324fc59266dff4afb76ceaf12dd2c45d198b820beac37607450b25" + ], + "world_capture_guard": [ + "table", + "world_capture_guard", + "8e6b5534797a362267306fbb4125e83fcf094ce3e269534320a3d0a18115fe4e" + ], + "worlds": [ + "table", + "worlds", + "ae042307311f880b93139a52b224f558040f17f6e5374a9d131025ebf80cfb08" + ] +}; +export class Schema4Unavailable extends Error { constructor() { super("Compatible world storage schema is unavailable."); } } +export async function assertStorageSchema4(db: HistoryD1Binding, identity: WorldCaptureIdentity) { + try { + const markers = await db.prepare("SELECT version,applied_at,sql_sha256 FROM schema_migrations ORDER BY version LIMIT 5").all<{ version: number; applied_at: string; sql_sha256: string }>(); + if (!markers.success || !Array.isArray(markers.results) || markers.results.length !== 4 || markers.results.some((m, i) => m.version !== WORLD_SCHEMA4_MIGRATIONS[i]![0] || m.sql_sha256 !== WORLD_SCHEMA4_MIGRATIONS[i]![1] || typeof m.applied_at !== "string" || m.applied_at.length > 64 || !Number.isFinite(Date.parse(m.applied_at)))) throw Error(); + const foreign = await db.prepare("PRAGMA foreign_keys").first<{ foreign_keys: number }>(); if (foreign?.foreign_keys !== 1) throw Error(); + // Inventory every object, including pinned SQLite auto-indexes. A name + // prefix (especially LIKE's wildcard underscore) is not an exclusion grant. + const schema = await db.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY name LIMIT 35").all<{ type: string; name: string; tbl_name: string; sql: string | null }>(); + if (!schema.success || !Array.isArray(schema.results) || schema.results.length !== Object.keys(pins).length) throw Error(); + const seen = new Set(); + for (const row of schema.results) { const pin = pins[row.name]; if (!pin || seen.has(row.name) || row.type !== pin[0] || row.tbl_name !== pin[1] || (pin[2] === null ? row.sql !== null : typeof row.sql !== "string" || await captureHash(schema4SqlTokens(row.sql)) !== pin[2])) throw Error(); seen.add(row.name); } + if (Object.keys(pins).some(n => !seen.has(n))) throw Error(); + const stored = await db.prepare("SELECT singleton,database_uuid,binding_nonce FROM storage_identity ORDER BY singleton LIMIT 2").all<{ singleton: number; database_uuid: string; binding_nonce: string }>(); + if (!stored.success || !Array.isArray(stored.results) || stored.results.length !== 1 || stored.results[0]?.singleton !== 1 || stored.results[0]?.database_uuid !== identity.databaseId || stored.results[0]?.binding_nonce !== identity.bindingNonce) throw Error(); + } catch { throw new Schema4Unavailable(); } +} diff --git a/deploy/world-capture-rpc.ts b/deploy/world-capture-rpc.ts new file mode 100644 index 0000000..a4fd226 --- /dev/null +++ b/deploy/world-capture-rpc.ts @@ -0,0 +1,105 @@ +import type { HistoryD1Binding } from "./history-storage"; +import { D1PublicCaptureCore } from "./world-public-captures"; +import { assertStorageSchema4 } from "./storage-schema4"; +import { WORLD_CAPTURE_RPC_LIMITS as L, WORLD_CAPTURE_RPC_URL, WorldCaptureRpcInputError, parseWorldCaptureJson, parseWorldCaptureOperation, worldWireInteger } from "../src/server/world-capture-rpc-contract"; +import { WorldCaptureError, captureBytes, captureCryptoKey, captureHash, captureIdentity, type WorldCaptureIdentity, type RemoteWorldGeneration } from "../src/server/world-public-capture-contract"; +import { isRenderableWorld } from "../src/server/world-readiness"; +import type { WorldSnapshot } from "../src/contracts"; + +export interface WorldCaptureRpcEnvironment { + WORLDS_DB?: HistoryD1Binding; FACTORY_STORAGE_SECRET?: string; FACTORY_STORAGE_MODE?: string; + FACTORY_STORAGE_DATABASE_ID?: string; FACTORY_STORAGE_BINDING_NONCE?: string; + FACTORY_MIGRATION_EPOCH?: string; FACTORY_ADMISSION_FENCE?: string; +} +export interface WorldCaptureRpcOptions { identity: WorldCaptureIdentity; key: CryptoKey; bodyTimeoutMs?: number; timeoutMs?: number } +class Rejection extends Error { constructor(readonly code: string, readonly status: number) { super("Fixed world storage operation rejected."); } } +const reject = (code = "WORLD_RPC_INVALID_REQUEST", status = 400): never => { throw new Rejection(code, status); }; +async function authenticated(actual: string, expected: unknown) { + if (typeof expected !== "string" || actual.length < 32 || actual.length > 256 || expected.length < 32 || expected.length > 256 || captureBytes(actual) > 256 || captureBytes(expected) > 256) return false; + const [a, b] = await Promise.all([captureHash(actual), captureHash(expected)]); let difference = 0; for (let i = 0; i < 64; i++) difference |= a.charCodeAt(i) ^ b.charCodeAt(i); return difference === 0; +} +async function body(request: Request, duration: number) { + const advertised = request.headers.get("content-length"); + if (advertised !== null && (!/^(0|[1-9][0-9]*)$/.test(advertised) || !worldWireInteger(Number(advertised), L.bodyBytes))) return reject("WORLD_RPC_BODY_TOO_LARGE", 413); + const reader = request.body?.getReader(); if (!reader) return reject(); + let timer: ReturnType | undefined, size = 0; + const deadline = performance.now() + duration, timeout = new Promise((_, fail) => { timer = setTimeout(() => fail(new Rejection("WORLD_RPC_BODY_TIMEOUT", 408)), duration); }); + try { + const parts: Uint8Array[] = []; + for (;;) { const part = await Promise.race([reader.read(), timeout]); if (performance.now() >= deadline) return reject("WORLD_RPC_BODY_TIMEOUT", 408); if (part.done) break; if (!(part.value instanceof Uint8Array)) return reject(); size += part.value.byteLength; if (size > L.bodyBytes) return reject("WORLD_RPC_BODY_TOO_LARGE", 413); parts.push(part.value); } + const bytes = new Uint8Array(size); let offset = 0; for (const p of parts) { bytes.set(p, offset); offset += p.byteLength; } + let value: unknown; try { value = parseWorldCaptureJson(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes)); } catch { return reject(); } + if (performance.now() >= deadline) return reject("WORLD_RPC_BODY_TIMEOUT", 408); return value; + } finally { clearTimeout(timer); try { void reader.cancel().catch(() => {}); } catch { /* no awaited hung cancellation */ } } +} +const newWork = new Set(["captureBegin", "put", "alias", "evict"]); +const coreCodes = new Set(["WORLD_CAPTURE_CONFLICT", "WORLD_CAPTURE_CAPACITY", "WORLD_CAPTURE_INVALID_REQUEST"]); +function reply(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" } }); } + +/** Independently callable SERVER dispatcher. NOT mounted, registered or + * imported by any Worker/gateway/runtime. No env/file/key lookup or defaults. + * Logical identity is not physical binding/retirement proof. */ +export async function worldCaptureRpc(request: Request, env: WorldCaptureRpcEnvironment, options: WorldCaptureRpcOptions): Promise { + let dispatched = false, expired = false, timer: ReturnType | undefined, verified: { op: string; identity: WorldCaptureIdentity; schemaVersion: 4; protocol: 1 } | undefined; + let core: D1PublicCaptureCore | undefined, generation: RemoteWorldGeneration | undefined; + const duration = options?.timeoutMs ?? L.totalMs, bodyDuration = options?.bodyTimeoutMs ?? L.bodyMs; + const end = performance.now() + (worldWireInteger(duration, L.totalMs) && duration > 0 ? duration : 0); + const checkTime = () => { if (expired || performance.now() >= end) { expired = true; return reject("WORLD_RPC_TIMEOUT", 503); } }; + const fence = () => { if (!(env.FACTORY_ADMISSION_FENCE === "" || env.FACTORY_ADMISSION_FENCE === options.identity.epoch)) return reject("WORLD_STORAGE_IDENTITY_UNAVAILABLE", 503); return env.FACTORY_ADMISSION_FENCE !== ""; }; + async function execute() { + if (!await authenticated(request.headers.get("x-factory-storage-key") ?? "", env.FACTORY_STORAGE_SECRET)) return reject("WORLD_STORAGE_AUTH_REQUIRED", 403); + checkTime(); + if (!worldWireInteger(duration, L.totalMs) || duration < 1 || !worldWireInteger(bodyDuration, L.bodyMs) || bodyDuration < 1) return reject("WORLD_STORAGE_UNAVAILABLE", 503); + if (env.FACTORY_STORAGE_MODE !== "worlds") return reject("WORLD_STORAGE_MODE_REJECTED", 503); + if (request.url !== WORLD_CAPTURE_RPC_URL || request.method !== "POST" || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers.get("content-type") ?? "") || request.headers.has("content-encoding")) return reject(); + const identity = captureIdentity(options.identity), key = captureCryptoKey(options.key); + if (env.FACTORY_STORAGE_DATABASE_ID !== identity.databaseId || env.FACTORY_STORAGE_BINDING_NONCE !== identity.bindingNonce || env.FACTORY_MIGRATION_EPOCH !== identity.epoch) return reject("WORLD_STORAGE_IDENTITY_UNAVAILABLE", 503); + fence(); const op = parseWorldCaptureOperation(await body(request, Math.min(bodyDuration, Math.max(1, end - performance.now())))); checkTime(); + if (newWork.has(op.op) && fence()) return reject("STORAGE_MIGRATION_HOLD", 503); + if (!env.WORLDS_DB) return reject("WORLD_STORAGE_UNAVAILABLE", 503); + const source = env.WORLDS_DB.withSession?.("first-primary") ?? env.WORLDS_DB; + const db: HistoryD1Binding = { + prepare(sql) { checkTime(); return source.prepare(sql); }, + async batch(statements) { + checkTime(); await assertStorageSchema4(db, identity); checkTime(); + if (newWork.has(op.op) && fence()) return reject("STORAGE_MIGRATION_HOLD", 503); fence(); + // This observes actual dispatch, never an exported exception class. + dispatched = true; return source.batch(statements); + }, + }; + await assertStorageSchema4(db, identity); checkTime(); core = new D1PublicCaptureCore(db, identity, key); + const status = await core.status(); checkTime(); verified = { op: op.op, identity: status.identity, schemaVersion: 4, protocol: status.protocol }; + if ("generation" in op) generation = op.generation; + let result: unknown; + if (op.op === "status") result = { ...status, admissionFenced: fence(), retirementQuiet: "unknown" }; + else if (op.op === "getMany" || op.op === "summaries") { + const rows: unknown[] = []; + for (const key of op.keys) { checkTime(); const row = await core.get(key); if (row) { let w: WorldSnapshot; try { w = parseWorldCaptureJson(row.snapshot) as WorldSnapshot; } catch { return reject("WORLD_STORAGE_UNAVAILABLE", 503); } rows.push(op.op === "getMany" ? { key, ...row } : { key, repo: w.repo, collectedAt: w.collectedAt, expiresAt: row.expiresAt, ready: isRenderableWorld(w) }); } } + if ((await core.status()).revision !== status.revision) return reject("WORLD_STORAGE_READ_CONFLICT", 503); result = rows; + } else if (op.op === "captureBegin") result = await core.begin(op.repo, { explicitRevalidation: op.explicitRevalidation }); + else if (op.op === "captureAssert") result = await core.assert(op.generation); + else if (op.op === "captureInspect") result = await core.inspect(op.generation); + else if (op.op === "captureCommit") result = await core.commit(op.generation, op.write); + else if (op.op === "captureRevoke") result = await core.revoke(op.generation, op.claim); + else if (op.op === "put") { await core.putLegacy(op.write); result = { saved: true }; } + else if (op.op === "alias") { await core.aliasLegacy(op.alias, op.canonical); result = { accepted: true }; } + else if (op.op === "evict") { await core.evict(op.repo); result = { evicted: true }; } + checkTime(); + const payload = { ok: true, backend: "d1", ...verified, result }; if (captureBytes(JSON.stringify(payload)) > L.responseBytes) return reject("WORLD_RPC_RESPONSE_TOO_LARGE", 503); + return reply(payload); + } + const timeout = new Promise((_, fail) => { timer = setTimeout(() => { expired = true; fail(new Rejection("WORLD_RPC_TIMEOUT", 503)); }, Math.max(1, Math.min(L.totalMs, duration || 1))); }); + try { return await Promise.race([execute(), timeout]); } + catch (error) { + if (dispatched) { + let privacy: { barrier: string; cleanup: string } | undefined; + // Narrow evidence comes from the ORIGINAL authenticated receipt/barrier + // state, never arbitrary properties of a transport exception. Read only. + if (!expired && verified?.op === "captureRevoke" && core && generation) { try { privacy = await Promise.race([core.privacyStatus(generation), timeout]); } catch { /* outcome remains unknown */ } } + return reply({ ok: false, backend: "d1", ...verified, code: "WORLD_WRITE_OUTCOME_UNKNOWN", error: "World storage outcome is unknown. No automatic retry or rollback was claimed.", writeOutcome: "unknown", ...(privacy ? { privacy } : {}) }, 503); + } + const code = error instanceof Rejection ? error.code : error instanceof WorldCaptureRpcInputError ? "WORLD_RPC_INVALID_REQUEST" : core && error instanceof WorldCaptureError && coreCodes.has(error.code) ? error.code : "WORLD_STORAGE_UNAVAILABLE"; + const status = error instanceof Rejection ? error.status : code === "WORLD_RPC_INVALID_REQUEST" || code === "WORLD_CAPTURE_INVALID_REQUEST" ? 400 : code === "WORLD_CAPTURE_CONFLICT" ? 409 : 503; + return reply({ ok: false, backend: "d1", ...verified, code, error: "The fixed world storage operation is unavailable or rejected.", writeOutcome: "not-started" }, status); + } finally { clearTimeout(timer); } +} diff --git a/deploy/world-public-captures.ts b/deploy/world-public-captures.ts new file mode 100644 index 0000000..aa50191 --- /dev/null +++ b/deploy/world-public-captures.ts @@ -0,0 +1,266 @@ +import type { HistoryD1Binding, HistoryStatement } from "./history-storage"; +import { WORLD_CAPTURE_LIMITS as L, WORLD_CAPTURE_TABLE_SQL, WorldCaptureError, captureBytes, captureChunks, captureCryptoKey, captureHash, captureIdentity, captureKey, captureKeyTag, captureReject, captureSqlTokens, issueWorldGeneration, verifyWorldGeneration, verifyWorldPrivateObservation, worldGenerationShape, type RemoteWorldGeneration, type WorldCaptureIdentity, type WorldCaptureWrite } from "../src/server/world-public-capture-contract"; + +type Authority = [number, number, string, string, string, string, number, number]; +type Barrier = [string, number, number]; +type Generation = [string, string, string, number, number, string, string | null, string | null, string | null, number | null, number]; +type Manifest = [string, number, number, string, string, number]; +type Alias = [string, string, number]; +type View = [Authority[], [number, string, string][], Barrier[], Generation[], Manifest[], Alias[], [number, number][], [string, string, string, string][]]; +interface State { view: View; checksum: string; raw: string; snapshot: string | null } +const json = JSON.stringify; +const safe = (n: unknown): n is number => typeof n === "number" && Number.isSafeInteger(n) && n >= 0; +const hex = (s: unknown): s is string => typeof s === "string" && /^[a-f0-9]{64}$/.test(s); +const sorted = (rows: T[]) => rows.sort((a, b) => a[0]! < b[0]! ? -1 : a[0]! > b[0]! ? 1 : 0); +const unique = (keys: string[]) => [...new Set(keys)].sort(); +const unavailable = (): never => { throw new WorldCaptureError(); }; +const conflict = (): never => captureReject("WORLD_CAPTURE_CONFLICT", 409); +const increment = (n: number) => safe(n + 1) ? n + 1 : unavailable(); +function list(value: string | null, cap: number) { + if (typeof value !== "string") return unavailable(); + const keys: unknown = JSON.parse(value); + if (!Array.isArray(keys) || !keys.length || keys.length > cap || keys.some(k => captureKey(k) !== k) || json(unique(keys)) !== value) return unavailable(); + return keys as string[]; +} + +/** A single SQLite statement observes control, aliases, manifests, schema and + * requested raw bytes in one read snapshot. Capped subqueries expose overflow + * without materializing unbounded tables. No snapshot payload enters metadata. */ +export const WORLD_CAPTURE_VIEW_SQL = `SELECT json_array( + (SELECT json_group_array(json_array(singleton,protocol,database_uuid,binding_nonce,authority_epoch,key_tag,sequence,revision)) FROM (SELECT * FROM world_capture_authority ORDER BY singleton LIMIT 2)), + (SELECT json_group_array(json_array(singleton,database_uuid,binding_nonce)) FROM (SELECT * FROM storage_identity ORDER BY singleton LIMIT 2)), + (SELECT json_group_array(json_array(repo,sequence,denied)) FROM (SELECT * FROM world_capture_barriers ORDER BY repo LIMIT ${L.barriers + 1})), + (SELECT json_group_array(json_array(handle_hash,repo,start_canonical,start_sequence,explicit,status,operation_hash,roots,targets,accepted_sequence,ordinal)) FROM (SELECT * FROM world_capture_generations ORDER BY handle_hash LIMIT ${L.generations + 1})), + (SELECT json_group_array(json_array(repo,expires_at,saved_at,snapshot_sha256,origin,length(CAST(snapshot AS BLOB)))) FROM (SELECT * FROM worlds ORDER BY repo LIMIT ${L.worlds + 1})), + (SELECT json_group_array(json_array(alias,canonical,rowid)) FROM (SELECT alias,canonical,rowid FROM world_aliases ORDER BY alias LIMIT ${L.aliases + 1})), + (SELECT json_group_array(json_array(singleton,accepted)) FROM (SELECT * FROM world_capture_guard ORDER BY singleton LIMIT 2)), + (SELECT json_group_array(json_array(type,name,tbl_name,sql)) FROM (SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name IN ('world_capture_authority','world_capture_barriers','world_capture_generations','world_capture_guard') OR (type='trigger' AND tbl_name LIKE 'world_capture_%') ORDER BY name LIMIT 9)) +) AS view`; +const viewExpression = `(${WORLD_CAPTURE_VIEW_SQL})`; + +/** Standalone, server-only D1 authority. There is intentionally no initializer, + * RPC, runtime registration, env fallback, key default or ledger access here. + * The binding MUST provide D1 all-statements-atomic batch semantics. Logical + * identity/HMAC cannot fence an old binary, DB owner, or cloned physical DB. + * All 256 generation receipts are retained; capacity exhaustion fails closed. + * A lost begin ACK can orphan a slot. Retirement/provisioning needs a separate + * reviewed protocol, never implicit eviction or an automatic retry/reset. */ +export class D1PublicCaptureCore { + private readonly identity: WorldCaptureIdentity; + private readonly key: CryptoKey; + private readonly transient = new Set(); + constructor(private readonly binding: HistoryD1Binding, identity: WorldCaptureIdentity, key: CryptoKey) { this.identity = captureIdentity(identity); this.key = captureCryptoKey(key); } + private async run(action: (db: HistoryD1Binding) => Promise): Promise { + try { return await action(this.binding.withSession?.("first-primary") ?? this.binding); } + catch (error) { if (error instanceof WorldCaptureError) throw error; return unavailable(); } + } + private async state(db: HistoryD1Binding, repo = ""): Promise { + try { return await this.readState(db, repo); } catch { return unavailable(); } + } + private async readState(db: HistoryD1Binding, repo: string) { + const row = await db.prepare(`${WORLD_CAPTURE_VIEW_SQL}, (SELECT checksum FROM world_capture_authority WHERE singleton=1) AS checksum, + (SELECT snapshot FROM worlds WHERE repo=COALESCE((SELECT canonical FROM world_aliases WHERE alias=?),?)) AS snapshot`).bind(repo, repo).first<{ view: string; checksum: string; snapshot: string | null }>(); + if (!row || typeof row.view !== "string" || captureBytes(row.view) > L.metadataBytes || !hex(row.checksum)) return unavailable(); + const v: View = JSON.parse(row.view); + if (!Array.isArray(v) || v.length !== 8 || v.some(x => !Array.isArray(x)) || v[0].length !== 1 || v[1].length !== 1 || v[2].length > L.barriers || v[3].length > L.generations || v[4].length > L.worlds || v[5].length > L.aliases || json(v[6]) !== "[[1,1]]" || v[7].length !== 4) return unavailable(); + const a = v[0][0]!, i = v[1][0]!; + if (a.length !== 8 || a[0] !== 1 || a[1] !== 1 || a[2] !== this.identity.databaseId || a[3] !== this.identity.bindingNonce || a[4] !== this.identity.epoch || a[5] !== await captureKeyTag(this.identity, this.key) || !safe(a[6]) || !safe(a[7]) || a[6] > a[7] || json(i) !== json([1, a[2], a[3]])) return unavailable(); + for (const [name, ddl] of Object.entries(WORLD_CAPTURE_TABLE_SQL)) { + const schema = v[7].find(r => r[1] === name); + if (!schema || schema.length !== 4 || schema[0] !== "table" || schema[2] !== name || typeof schema[3] !== "string" || captureSqlTokens(schema[3]) !== captureSqlTokens(ddl)) return unavailable(); + } + for (const b of v[2]) if (b.length !== 3 || captureKey(b[0]) !== b[0] || !safe(b[1]) || b[1] < 1 || b[1] > a[6] || ![0, 1].includes(b[2])) return unavailable(); + const ordinals = new Set(); + for (const g of v[3]) { + if (g.length !== 11 || !hex(g[0]) || captureKey(g[1]) !== g[1] || captureKey(g[2]) !== g[2] || !safe(g[3]) || g[3] > a[6] || ![0, 1].includes(g[4]) || !["active", "committed", "deny-pending", "denied"].includes(g[5]) || !safe(g[10]) || g[10] < 1 || g[10] > a[7] || ordinals.has(g[10])) return unavailable(); + ordinals.add(g[10]); + if (g[5] === "active") { if (g.slice(6, 10).some(x => x !== null)) return unavailable(); } + else { + if (!hex(g[6]) || !safe(g[9]) || g[9] < g[3] || g[9] > a[6]) return unavailable(); + const roots = list(g[7], 2), targets = list(g[8], 204); + if (!roots.includes(g[1]) || roots.some(k => !targets.includes(k)) || (g[5] === "committed" ? g[7] !== g[8] : g[9] <= g[3] || g[6] !== await captureHash(json([roots, targets])))) return unavailable(); + } + } + for (const w of v[4]) if (w.length !== 6 || captureKey(w[0]) !== w[0] || !safe(w[1]) || !safe(w[2]) || w[1] <= w[2] || !hex(w[3]) || typeof w[4] !== "string" || !/^[a-zA-Z0-9_.:-]{1,128}$/.test(w[4]) || !safe(w[5]) || w[5] < 1 || w[5] > L.snapshotBytes) return unavailable(); + const rowids = new Set(); + for (const alias of v[5]) { + if (alias.length !== 3 || captureKey(alias[0]) !== alias[0] || captureKey(alias[1]) !== alias[1] || alias[0] === alias[1] || !safe(alias[2]) || alias[2] < 1 || rowids.has(alias[2]) || v[5].some(other => other[0] === alias[1]) || v[4].some(w => w[0] === alias[0])) return unavailable(); + rowids.add(alias[2]); + } + if (Math.max(0, ...v[2].map(b => b[1])) !== a[6] || await captureHash(row.view) !== row.checksum) return unavailable(); + return { view: v, raw: row.view, checksum: row.checksum, snapshot: row.snapshot } satisfies State; + } + private resolve(s: State, repo: string) { return s.view[5].find(a => a[0] === repo)?.[1] ?? repo; } + private admitted(s: State, keys: string[], g?: Generation) { + for (const key of keys) { + const b = s.view[2].find(b => b[0] === key); + if (this.transient.has(key) || (g ? b && (b[1] > g[3] || b[2] === 1 && g[4] !== 1) : b)) return conflict(); + } + } + private next(s: State): State { const n: State = { ...s, view: structuredClone(s.view), snapshot: null }; n.view[0][0]![7] = increment(n.view[0][0]![7]); return n; } + private statement(db: HistoryD1Binding, sql: string, ...values: unknown[]) { return db.prepare(sql).bind(...values); } + private guard(db: HistoryD1Binding, view: string, checksum: string, extra = "1", values: unknown[] = []) { + const pieces = captureChunks(view); + return this.statement(db, `INSERT INTO world_capture_guard(singleton,accepted) VALUES(1,CASE WHEN ${viewExpression}=(${pieces.map(() => "?").join("||")}) AND (SELECT checksum FROM world_capture_authority WHERE singleton=1)=? AND (${extra}) THEN 1 ELSE 0 END) ON CONFLICT(singleton) DO UPDATE SET accepted=excluded.accepted`, ...pieces, checksum, ...values); + } + private async save(db: HistoryD1Binding, old: State, next: State, changes: HistoryStatement[] = [], verify?: { repo: string; snapshot: string }) { + sorted(next.view[2]); sorted(next.view[3]); sorted(next.view[4]); sorted(next.view[5]); + const raw = json(next.view); if (captureBytes(raw) > L.metadataBytes || next.view[2].length > L.barriers || next.view[3].length > L.generations) return unavailable(); + const checksum = await captureHash(raw), statements: HistoryStatement[] = [this.guard(db, old.raw, old.checksum), ...changes]; + // Rewrite only changed control rows. This also bounds a batch well below + // D1's statement/parameter limits, with no active receipt eviction. + const add = (sql: string, ...args: unknown[]) => statements.push(this.statement(db, sql, ...args)); + for (const b of next.view[2]) if (json(old.view[2].find(x => x[0] === b[0])) !== json(b)) add("INSERT INTO world_capture_barriers VALUES(?,?,?) ON CONFLICT(repo) DO UPDATE SET sequence=excluded.sequence,denied=excluded.denied", ...b); + for (const g of next.view[3]) if (json(old.view[3].find(x => x[0] === g[0])) !== json(g)) add("INSERT INTO world_capture_generations VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(handle_hash) DO UPDATE SET status=excluded.status,operation_hash=excluded.operation_hash,roots=excluded.roots,targets=excluded.targets,accepted_sequence=excluded.accepted_sequence", ...g); + add("UPDATE world_capture_authority SET sequence=?,revision=?,checksum=? WHERE singleton=1", next.view[0][0]![6], next.view[0][0]![7], checksum); + const pieces = verify ? captureChunks(verify.snapshot) : []; + statements.push(this.guard(db, raw, checksum, verify ? `(SELECT snapshot FROM worlds WHERE repo=?)=(${pieces.map(() => "?").join("||")})` : "1", verify ? [verify.repo, ...pieces] : [])); + // Once dispatched, no exported exception or result shape proves rollback. + // No automatic retries, including CAS conflicts or lost acknowledgement. + try { + const result = await db.batch(statements); if (!Array.isArray(result) || result.length !== statements.length || result.some(r => !r || r.success !== true)) throw Error(); + // A successful-shaped ACK alone is insufficient. This independent read + // does not retry a write or infer rollback. A concurrent newer revision + // conservatively yields UNKNOWN even if this batch actually committed. + const observed = await this.state(db, verify?.repo); + if (observed.raw !== raw || observed.checksum !== checksum || verify && observed.snapshot !== verify.snapshot) throw Error(); + } + catch { throw new WorldCaptureError("WORLD_CAPTURE_UNAVAILABLE", 503, "unknown"); } + } + private async authenticate(s: State, generation: RemoteWorldGeneration) { + worldGenerationShape(generation); const hash = await captureHash(generation.handle), g = s.view[3].find(g => g[0] === hash); + if (!g || g[1] !== generation.repo) return conflict(); + await verifyWorldGeneration(generation, this.identity, this.key, { canonical: g[2], sequence: g[3], explicit: g[4] === 1 }); return g; + } + async begin(input: string, options: { explicitRevalidation: boolean } = { explicitRevalidation: false }): Promise { + const repo = captureKey(input); if (!options || Object.keys(options).join() !== "explicitRevalidation" || typeof options.explicitRevalidation !== "boolean") return captureReject(); + const explicit = options.explicitRevalidation; + return this.run(async db => { + const s = await this.state(db), canonical = this.resolve(s, repo); + this.admitted(s, [repo, canonical], ["", repo, canonical, s.view[0][0]![6], +explicit, "active", null, null, null, null, 1]); + if (s.view[3].length >= L.generations) return captureReject("WORLD_CAPTURE_CAPACITY", 503); + const token = await issueWorldGeneration(this.identity, this.key, repo, canonical, s.view[0][0]![6], explicit), n = this.next(s); + n.view[3].push([await captureHash(token.handle), repo, canonical, token.sequence, +explicit, "active", null, null, null, null, n.view[0][0]![7]]); + await this.save(db, s, n); return token; + }); + } + async inspect(generation: RemoteWorldGeneration) { return this.run(async db => { const g = await this.authenticate(await this.state(db), generation); return { status: g[5], operationHash: g[6], acceptedSequence: g[9] }; }); } + /** Read-only RPC preflight/identity and drain assertions. These never mint, + * consume, repair or return keys/private target lists. */ + async status() { return this.run(async db => { const s = await this.state(db), a = s.view[0][0]!; return { identity: { databaseId: a[2], bindingNonce: a[3], epoch: a[4] }, protocol: 1 as const, revision: a[7], sequence: a[6], worlds: s.view[4].length, aliases: s.view[5].length, generations: s.view[3].length }; }); } + async assert(generation: RemoteWorldGeneration) { return this.run(async db => { const s = await this.state(db), g = await this.authenticate(s, generation); if (g[5] !== "active") return conflict(); this.admitted(s, [g[1], g[2], this.resolve(s, g[1])], g); return { admitted: true as const }; }); } + async privacyStatus(generation: RemoteWorldGeneration) { return this.run(async db => { const s = await this.state(db), g = await this.authenticate(s, generation); if (!["deny-pending", "denied"].includes(g[5]) || list(g[8], 204).some(k => !s.view[2].some(b => b[0] === k && b[1] === g[9] && b[2] === 1))) return { barrier: "unknown" as const, cleanup: "unconfirmed" as const }; return { barrier: "confirmed" as const, cleanup: g[5] === "denied" ? "confirmed" as const : "unconfirmed" as const }; }); } + async get(input: string) { + const repo = captureKey(input); + return this.run(async db => { + const s = await this.state(db, repo), canonical = this.resolve(s, repo), keys = [repo, canonical]; + if (keys.some(k => this.transient.has(k))) return unavailable(); + if (keys.some(k => s.view[2].some(b => b[0] === k && b[2] === 1))) return null; + const w = s.view[4].find(w => w[0] === canonical); if (!w) return null; + if (typeof s.snapshot !== "string" || await captureHash(s.snapshot) !== w[3]) return unavailable(); + this.snapshot({ snapshot: s.snapshot, savedAt: w[2], expiresAt: w[1] }, canonical); + return { repo: canonical, snapshot: s.snapshot, expiresAt: w[1], savedAt: w[2], hash: w[3], origin: w[4] }; + }); + } + private snapshot(write: WorldCaptureWrite, expected?: string) { + if (!write || Object.keys(write).sort().join() !== "expiresAt,savedAt,snapshot" || typeof write.snapshot !== "string" || !captureBytes(write.snapshot) || captureBytes(write.snapshot) > L.snapshotBytes || !safe(write.savedAt) || !safe(write.expiresAt) || write.expiresAt <= write.savedAt) return captureReject(); + let w: any; try { w = JSON.parse(write.snapshot); } catch { return captureReject(); } + const canonical = captureKey(w?.repo?.fullName); + if (expected && canonical !== expected || w?.schemaVersion !== 1 || w.source !== "github" || w.repo.url !== `https://github.com/${w.repo.fullName}` || typeof w.headSha !== "string" || !/^[a-f0-9]{40}$/i.test(w.headSha) || typeof w.collectedAt !== "string" || !Number.isFinite(Date.parse(w.collectedAt)) || !Array.isArray(w.nodes) || !Array.isArray(w.edges) || !w.blueprint) return captureReject(); + return canonical; + } + private async put(db: HistoryD1Binding, old: State, n: State, repo: string, write: WorldCaptureWrite, changes: HistoryStatement[]) { + if (old.view[4].some(w => w[0] === repo && w[2] > write.savedAt)) return conflict(); + const manifest: Manifest = [repo, write.expiresAt, write.savedAt, await captureHash(write.snapshot), "runtime", captureBytes(write.snapshot)], pieces = captureChunks(write.snapshot); + changes.push(this.statement(db, `INSERT INTO worlds(repo,snapshot,expires_at,saved_at,snapshot_sha256,origin) VALUES(?,(${pieces.map(() => "?").join("||")}),?,?,?,'runtime') ON CONFLICT(repo) DO UPDATE SET snapshot=excluded.snapshot,expires_at=excluded.expires_at,saved_at=excluded.saved_at,snapshot_sha256=excluded.snapshot_sha256,origin=excluded.origin`, repo, ...pieces, write.expiresAt, write.savedAt, manifest[3])); + n.view[4] = [...n.view[4].filter(w => w[0] !== repo), manifest]; + const retained = [...n.view[4]].sort((a, b) => b[2] - a[2] || (a[0] < b[0] ? -1 : 1)).slice(0, L.worlds), keep = new Set(retained.map(w => w[0])); + if (!keep.has(repo)) return conflict(); + for (const w of n.view[4]) if (!keep.has(w[0])) changes.push(this.statement(db, "DELETE FROM worlds WHERE repo=?", w[0])); + n.view[4] = retained; + // A newly observed real canonical replaces an old alias at that name. + // Retention also removes aliases whose target was physically evicted, + // matching the original store without touching any privacy authority. + changes.push(this.statement(db, "DELETE FROM world_aliases WHERE alias=? OR canonical NOT IN (SELECT repo FROM worlds)", repo)); + n.view[5] = n.view[5].filter(a => a[0] !== repo && keep.has(a[1])); + } + private alias(db: HistoryD1Binding, n: State, input: string, canonical: string, changes: HistoryStatement[]) { + // Never delete a valid source to install an alias to an absent target. + if (input === canonical || !n.view[4].some(w => w[0] === canonical)) return; + const previous = n.view[5].find(a => a[0] === input); + changes.push(this.statement(db, "DELETE FROM worlds WHERE repo=?", input)); n.view[4] = n.view[4].filter(w => w[0] !== input); + // A canonical can move again. Its former incoming aliases are removed, + // rather than chained to input -> canonical. Unaffected rowids survive. + changes.push(this.statement(db, "DELETE FROM world_aliases WHERE canonical=?", input)); n.view[5] = n.view[5].filter(a => a[1] !== input); + const rowid = previous?.[2] ?? increment(Math.max(0, ...n.view[5].map(a => a[2]))); + changes.push(this.statement(db, "INSERT INTO world_aliases(rowid,alias,canonical) VALUES(?,?,?) ON CONFLICT(alias) DO UPDATE SET canonical=excluded.canonical", rowid, input, canonical)); + n.view[5] = [...n.view[5].filter(a => a[0] !== input), [input, canonical, rowid]]; + if (n.view[5].length > L.aliases) { + const removed = [...n.view[5]].sort((a, b) => a[2] - b[2])[0]!; + changes.push(this.statement(db, "DELETE FROM world_aliases WHERE alias=?", removed[0])); n.view[5] = n.view[5].filter(a => a[0] !== removed[0]); + } + } + private barriers(n: State, targets: string[], denied: number) { + const sequence = n.view[0][0]![6] = increment(n.view[0][0]![6]); + n.view[2] = [...n.view[2].filter(b => !targets.includes(b[0])), ...targets.map(k => [k, sequence, denied] as Barrier)]; return sequence; + } + async commit(generation: RemoteWorldGeneration, write: WorldCaptureWrite) { + const canonical = this.snapshot(write); write = { ...write }; + return this.run(async db => { + const s = await this.state(db), g = await this.authenticate(s, generation); if (g[5] !== "active") return conflict(); + const keys = unique([g[1], g[2], this.resolve(s, g[1]), canonical]); this.admitted(s, keys, g); + const n = this.next(s), ng = n.view[3].find(x => x[0] === g[0])!, roots = unique([g[1], canonical]), changes: HistoryStatement[] = []; + if (keys.some(k => s.view[2].some(b => b[0] === k && b[2] === 1))) this.barriers(n, roots, 0); + await this.put(db, s, n, canonical, write, changes); this.alias(db, n, g[1], canonical, changes); + ng[5] = "committed"; ng[6] = await captureHash(json([canonical, write])); ng[7] = ng[8] = json(roots); ng[9] = n.view[0][0]![6]; + await this.save(db, s, n, changes, { repo: canonical, snapshot: write.snapshot }); return { saved: true as const }; + }); + } + async putLegacy(write: WorldCaptureWrite) { + const repo = this.snapshot(write); write = { ...write }; + return this.run(async db => { const s = await this.state(db); this.admitted(s, [repo, this.resolve(s, repo)]); const n = this.next(s), changes: HistoryStatement[] = []; await this.put(db, s, n, repo, write, changes); await this.save(db, s, n, changes, { repo, snapshot: write.snapshot }); }); + } + async aliasLegacy(input: string, target: string) { + const repo = captureKey(input), canonical = captureKey(target); + return this.run(async db => { const s = await this.state(db); this.admitted(s, [repo, canonical, this.resolve(s, repo), this.resolve(s, canonical)]); if (repo === canonical || !s.view[4].some(w => w[0] === canonical)) return; const n = this.next(s), changes: HistoryStatement[] = []; this.alias(db, n, repo, canonical, changes); await this.save(db, s, n, changes); }); + } + async evict(input: string) { + const repo = captureKey(input); + return this.run(async db => { const s = await this.state(db), canonical = this.resolve(s, repo), n = this.next(s), changes: HistoryStatement[] = [this.statement(db, "DELETE FROM worlds WHERE repo=?", canonical), this.statement(db, "DELETE FROM world_aliases WHERE alias=? OR canonical=?", canonical, canonical)]; n.view[4] = n.view[4].filter(w => w[0] !== canonical); n.view[5] = n.view[5].filter(a => a[0] !== canonical && a[1] !== canonical); await this.save(db, s, n, changes); }); + } + async revoke(generation: RemoteWorldGeneration, claim?: string): Promise<{ barrier: "confirmed"; cleanup: "confirmed"; idempotent: boolean }> { + return this.run(async db => { + const s = await this.state(db), g = await this.authenticate(s, generation), canonical = claim === undefined ? undefined : await verifyWorldPrivateObservation(claim, generation, this.identity, this.key), roots = unique([g[1], ...(canonical ? [canonical] : [])]); + const expanded = unique([...roots, ...roots.map(k => this.resolve(s, k))]), targets = unique([...expanded, ...s.view[5].filter(a => expanded.includes(a[1])).map(a => a[0])]); + if (targets.length > 204) return captureReject(); + if (g[5] !== "active") { + if (!["denied", "deny-pending"].includes(g[5]) || g[7] !== json(roots) || g[6] !== await captureHash(json([roots, list(g[8], 204)]))) return conflict(); + const previous = list(g[8], 204); + if (previous.some(k => !s.view[2].some(b => b[0] === k && b[1] === g[9] && b[2] === 1))) return conflict(); + // Removed aliases after successful cleanup must not change receipt + // identity. New aliases into the group disallow idempotent acceptance. + if (targets.some(k => !previous.includes(k))) return conflict(); + if (g[5] === "deny-pending") throw new WorldCaptureError("WORLD_CAPTURE_UNAVAILABLE", 503, "unknown", "confirmed", "unconfirmed"); + return { barrier: "confirmed", cleanup: "confirmed", idempotent: true }; + } + // A later barrier (including a reopen) fences a delayed private result. + if (unique([g[1], g[2], ...targets]).some(k => s.view[2].some(b => b[0] === k && b[1] > g[3]))) return conflict(); + for (const key of targets) this.transient.add(key); + const n = this.next(s), ng = n.view[3].find(x => x[0] === g[0])!, sequence = this.barriers(n, targets, 1); + ng[5] = "deny-pending"; ng[6] = await captureHash(json([roots, targets])); ng[7] = json(roots); ng[8] = json(targets); ng[9] = sequence; + try { await this.save(db, s, n); } + catch { throw new WorldCaptureError("WORLD_CAPTURE_UNAVAILABLE", 503, "unknown", "unknown", "not-started"); } + for (const key of targets) this.transient.delete(key); + // Independent second batch. Never remove a concurrent public reopen. + try { + const current = await this.state(db), cg = await this.authenticate(current, generation); + if (cg[5] !== "deny-pending" || targets.some(k => !current.view[2].some(b => b[0] === k && b[1] === sequence && b[2] === 1))) return conflict(); + const clean = this.next(current), changes: HistoryStatement[] = []; + for (const key of targets) { changes.push(this.statement(db, "DELETE FROM worlds WHERE repo=?", key)); changes.push(this.statement(db, "DELETE FROM world_aliases WHERE alias=? OR canonical=?", key, key)); } + clean.view[4] = clean.view[4].filter(w => !targets.includes(w[0])); clean.view[5] = clean.view[5].filter(a => !targets.includes(a[0]) && !targets.includes(a[1])); clean.view[3].find(x => x[0] === g[0])![5] = "denied"; + await this.save(db, current, clean, changes); + } catch { throw new WorldCaptureError("WORLD_CAPTURE_UNAVAILABLE", 503, "unknown", "confirmed", "unconfirmed"); } + return { barrier: "confirmed", cleanup: "confirmed", idempotent: false }; + }); + } +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index a7b04bf..8778f09 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -79,7 +79,7 @@ The async adapter coalesces up to32concurrent world keys; gallery summaries use The first restart needs a retirement fence because the legacy9b app has no HOLD/activity counters. Deploy the reviewed edge admission fence before stopping anything: world GET/HEAD, create/refresh/AI and unknown API admissions return503; cheap GET/HEAD gallery/preview/health/budget can continue initially. Before any connector interruption, independently read Cloudflare management evidence for the physical database binding, one 100% active deployment/version, the actual deployed module bytes, and all fence/origin/company/source/epoch settings. The private drain config must pin `workerAuthority.deploymentId`, `versionId` and `moduleSha256`; authenticated RPC status alone cannot authorize interruption. Then stop **only the dedicated product connector**, after checking its exact unit/token/tunnel identity and single-product ingress. Verify that Cloudflare reports no tunnel connections and the retiring Bun PID has no accepted input sockets. This closes previously accepted slow request bodies and prevents old origin requests from reentering after the fence. Keep the retiring Bun alive. -The reviewed `scripts/drain-retiring-runtime.ts` waits at least240seconds after input closure (60second source collection + bounded model fetch/body up to90seconds + settlement/transport margin). It checks the same PID/start time/boot ID, original ledger device/inode and protected old PID, then requires30continuous seconds with no active/unknown process sockets and no public `ai_public_hourly` reservations or new public admission. IPv4/IPv6 TCP/UDP/raw and UNIX sockets are classified; only the exact inherited journald UNIX stream can be ignored. A sampling gap above5seconds resets the quiet window. Legacy reservations are recorded separately and never cancelled/settled. Unknown or remaining work fails closed. The ledger authority comes from a separately pinned original handoff receipt, never from the current environment: absolute path, realpath, device and inode must all agree in retirement, selection, opening and rollback gates. A current environment pointing to a copied ledger cannot establish its own authority. Repeat the independent management checks throughout draining, at the final retirement boundary and immediately before restart. Each observation brackets the actual module read with current deployment/settings readbacks; unknown or changed traffic, bindings, version or module aborts. A final fresh receipt check immediately precedes the restart. No new-process zero counter or instant zero reservation is accepted as retirement proof. +The reviewed `scripts/drain-retiring-runtime.ts` waits at least240seconds after input closure (60second source collection + bounded model fetch/body up to90seconds + settlement/transport margin). It checks the same PID/start time/boot ID, original ledger device/inode and protected old PID, then requires30continuous seconds with no active/unknown process sockets and no public `ai_public_hourly` reservations or new public admission. IPv4/IPv6 TCP/UDP/raw and UNIX sockets are classified; only the exact inherited journald UNIX stream can be ignored. A sampling gap above5seconds resets the quiet window. Legacy reservations are recorded separately and never cancelled/settled. Unknown or remaining work fails closed. The ledger authority comes from a separately pinned original handoff receipt, never from the current environment: absolute path, realpath, device and inode must all agree in retirement, selection, opening and rollback gates. A current environment pointing to a copied ledger cannot establish its own authority. Repeat the independent management checks throughout draining, at the final retirement boundary and immediately before restart. Deployment chronology is parsed as explicit-offset RFC3339 instants with nanosecond precision, never sorted as strings. Missing/invalid dates, unknown `-00:00` offsets, conflicting deployment IDs and equal instants (including different precision or equivalent offsets) reject the observation. The accepted operational range is 2000 onward through host time plus five minutes; fractions above nine digits, leap seconds and offsets beyond ±14:00 are unsupported and fail closed. Each observation brackets the actual module read with current deployment/settings readbacks; unknown or changed traffic, bindings, version or module aborts. A final fresh receipt check immediately precedes the restart. No new-process zero counter or instant zero reservation is accepted as retirement proof. Install the same tested artifact on the **new product service only**, initially using SQLite with HOLD already set. The connector can reopen under the still-active edge fence after exact candidate/held health is verified. The protected old service/port4176 and all existing ledger/admission files remain unchanged. This procedure deliberately has a maintenance window; record its actual start/end, response gaps and available read scope. @@ -91,3 +91,17 @@ Rollback is explicit; there is no automatic fallback. Retain the previous releas Bridge-only permissions are one-attempt permissions. They bind an expiring grant, unique attempt ID, initial and deployed Worker versions, frozen helpers/artifacts, legacy SQLite runtime and unchanged financial authority. An execution is consumed before mutation; rollback is permitted only for that exact still-active probe-only version with no business migration. A completed rollback permanently revokes it. Starting a full cutover transfers authority and revokes every old bridge rollback path; an old bridge grant cannot bypass HOLD, drain or D1 delta reconciliation after runtime writes. Recovery from an unknown state requires a separately reviewed recovery decision. + +### Gateway storage routing profiles + +`deploy/gateway.ts` answers every `/github-factory/_storage` path at the edge; none reaches the Bun origin. Profile selection uses explicit Worker configuration only, never request content: + +| Worker setting | `/_storage` | `/_storage/history` | +| --- | --- | --- | +| `FACTORY_WORLD_CAPTURE_SCHEMA_VERSION` unset or empty (default) | legacy schema2 storage RPC, unchanged | — | +| `FACTORY_WORLD_CAPTURE_SCHEMA_VERSION=4` plus Secret `FACTORY_WORLD_CAPTURE_KEY_HEX` (64 lowercase hex) | schema4 world capture RPC only; identity comes from `FACTORY_STORAGE_DATABASE_ID`, `FACTORY_STORAGE_BINDING_NONCE` and `FACTORY_MIGRATION_EPOCH`, and is then checked against the stored D1 authority/key tag | — | +| any other version value or a malformed key | `503 WORLD_STORAGE_UNAVAILABLE` before any SQL; no legacy fallback | — | +| `FACTORY_HISTORY_RPC_ENABLED=true` (exact) | — | history RPC | +| otherwise | — | `404` | + +Other `/_storage/*` subpaths return `404`. The capture key must equal the server-only `MOONSHOT_WORLD_CAPTURE_KEY_HEX`. It must be a Worker Secret, never a Variable. Enabling schema4 requires schema4 migrations and a provisioned `world_capture_authority` row. Profile changes are deployment changes: they follow the physical-binding, rollback and maintenance-window gates above. Synthetic routing tests do not prove a live D1 binding. diff --git a/docs/QA.md b/docs/QA.md index d428ba1..d957b14 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -91,3 +91,26 @@ The integrated suite now also exercises a real zoom/pan followed by a synthetic A separate real production smoke exposed Bun's default 10-second idle timeout on a large repository survey. The isolated Nitro plugin now extends API requests to 180 seconds, covering the bounded 60-second GitHub survey and one bounded model call. The source network limits remain unchanged. Regression evidence is the failed production request plus a successful real production import lasting over 10 seconds; fixture tests alone do not prove this behavior. The real refresh smoke also exposed CI run URLs inside the architecture cache identity. A new run could evict the same-head field guide even though paths and topology had not changed. The focused regression now replaces a node's observed run URL and proves cache reuse with exactly one total generation attempt; the distinct recovered-topology test still rejects reuse after an expanded survey. + +## Local captured-source exploration candidate + +The feature branch adds an optional local source challenge to cache-only Atlas journeys. A visitor chooses a captured repository, explicitly enters its world, matches an existing pinned directory source (or recorded commit with an author timestamp), and classifies the evidence limits. Discovery, entry and source-link clicks award nothing. A correct explicit submission may create one Exploration star per canonical repository on this browser; it does not verify reading, learning, contribution, current runtime health or GitHub stars. + +Receipts retain the original completed head and capture independently of the current world. IndexedDB transactions serialize the permanent reward slot and reset generation across tabs. Capacity is bounded to 100 repositories, 2048 serialized UTF-8 bytes per record and 256 KiB of gameplay payload; earlier progress is never silently evicted. Unavailable, corrupt, unsupported or failed browser storage falls back visibly to session practice with unknown durable totals. Clear exploration progress confirms deletion of gameplay data only. No player data is added to public worlds, server storage, paid ledgers or a new API. + +Offline browser acceptance uses labeled synthetic GitHub-shaped captures, real browser IndexedDB and denied upstream access. It tests the two-repository Atlas round trip, incorrect answers, replay and changed-capture idempotency, independent connection completion races, reset epochs, multi-tab reconciliation, retained corruption/capacity failures, aborted commits, unsupported schemas, commit fallback, narrow-screen keyboard access and shared pause control. These are synthetic acceptance boundaries. The feature is not deployed; real public gameplay, independent comprehension benefits, Safari/Firefox and physical devices remain unverified. + +Review regressions retain request admission for cache-only full-world GETs without taking collection/AI allowance. Catalog, preview and direct cache reads preserve malformed rows and aliases; the same positive shape gate determines renderability. Rejected session updates leave prior payloads unchanged. Confirmed Clear can erase corrupt known-v1 gameplay rows while atomically advancing the reset generation; unsupported schemas, unknown metadata or invalid generation remain untouched. Saved Exploration stars and unsaved session stars are counted separately, and discovery/visit wording follows the persistence of the individual flag. + +## Captured commit replay candidate + +Cache-only explorer journeys can open Replay captured commits. The finite rail uses retained commit sources and UTC author timestamps, with deterministic date/SHA presentation order, explicit duplicate/conflict/rejection coverage, Play/Pause, seek, Previous/Next and four playback speeds. It starts paused and stops at the captured window's end. Hidden/offscreen panels pause their owned clock; reopening does not catch up elapsed hidden time. Style changes preserve the selected marker. Playback makes no network or progress writes. + +The map remains explicitly Captured structure at its original head. Historical topology, ancestry, complete history and issue/PR lifecycles remain unavailable; a message containing “Merge” establishes no merge event. Marker pacing is presentation timing and gives no Exploration star. Empty eligible windows remain unknown. This increment does not implement first-parent tree frames, building births/deletions or runtime telemetry. At the replay checkpoint, WorldCanvas pause only froze motion parameters; that checkpoint did not verify zero idle/hidden RAF. Real public replay acceptance awaits a separately authorized release. + + +## WorldCanvas demand scheduling candidate + +The current canvas cancels its pending RAF when hidden, outside the viewport or unmounted. Paused and reduced-motion scenes repaint on actual layout/source/style/selection/hover/camera/resize invalidation and schedule no continuous idle RAF. Visible normal motion retains bounded 30fps painting with one pending handle. Visibility restoration preserves the animation clock without hidden-time catch-up, and only an eligible real first paint can mark a cached visit. + +Synthetic browser probes observe native RAF requests/cancellation/execution and real Canvas painting, including a hidden initial mount, dynamic media changes, pause/resume, offscreen restoration and input/source/camera regressions. These local measurements do not establish physical-device power savings, cross-browser behavior or public runtime adoption. Earlier replay/Atlas receipts retain their original limitations. diff --git a/e2e/atlas.spec.ts b/e2e/atlas.spec.ts new file mode 100644 index 0000000..8e3b57c --- /dev/null +++ b/e2e/atlas.spec.ts @@ -0,0 +1,192 @@ +import { basePath, test, expect } from "./fixtures"; +import { syntheticWorld } from "../test/fixtures/browser-world"; +const atlasPath = basePath + "/atlas", keys = ["synthetic-atlas/alpha", "synthetic-atlas/beta", "synthetic-atlas/missing"]; +function captured(key: string, head: string) { let w = syntheticWorld(); const old = w.repo.url, oldHead = w.headSha; w = JSON.parse(JSON.stringify(w).replaceAll(old, `https://github.com/${key}`).replaceAll(oldHead, head)); const [owner, name] = key.split("/"); w.source = "github"; w.repo = { ...w.repo, owner, name, fullName: key, url: `https://github.com/${key}`, description: "SYNTHETIC 原始源码说明 · captured evidence only", license: "NOASSERTION" }; w.nodes[0].label = "SYNTHETIC 源码"; w.nodes[0].path = "packages/源码"; w.nodes[0].evidence = [`${w.repo.url}/tree/${head}/packages/${encodeURIComponent("源码")}`]; return w; } +async function fixture(page: any, api: any) { + const worlds = [captured(keys[0], "a".repeat(40)), captured(keys[1], "b".repeat(40))]; + api.respond("GET", "/api/gallery", () => ({ body: { projects: keys.map((key, i) => ({ fullName: key, name: `SYNTHETIC ${key.split("/")[1]}`, description: "SYNTHETIC catalog", category: "Editorial region", suggestedStyle: i === 1 ? "factorio" : "moonshot", ready: true, stars: null, language: null, collectedAt: null })) } })); + for (const [i, key] of keys.entries()) + api.respond("GET", "/api/world/" + key, (route: any) => { expect(new URL(route.request().url()).search).toBe("?mode=cached"); return i < 2 ? { body: worlds[i] } : { status: 404, body: { code: "CACHE_MISS", error: "Not collected. No collection requested." } }; }); + for(const key of keys) api.respond("GET","/api/gallery-preview/"+key,()=>({body:{state:"unknown",reason:"cache-miss",fullName:key,headSha:null,collectedAt:null,refreshAfter:null,github:"unknown",tree:"unavailable",nodes:[],edges:[],totalNodes:0,totalEdges:0,limited:false}})); + await page.addInitScript(() => { + const p = CanvasRenderingContext2D.prototype as any, fill = p.fillRect, text = p.fillText; + p.fillRect = function (...args: any[]) { if (this.canvas.parentElement?.classList.contains("atlas-map") && args[0] === 0 && args[1] === 0 && args[2] >= this.canvas.clientWidth - 1 && args[3] >= this.canvas.clientHeight - 1) { + this.canvas.__frames = (this.canvas.__frames ?? 0) + 1; + this.canvas.__labels = []; + } return fill.apply(this, args); }; + p.fillText = function (...args: any[]) { if (this.canvas.parentElement?.classList.contains("atlas-map")) + (this.canvas.__labels ??= []).push({ text: args[0], x: args[1], y: args[2] }); return text.apply(this, args); }; + }); + return worlds; +} +async function showList(page: any) { const button = page.getByRole("button", { name: "Repository list", exact: true }); if (await button.isVisible()) + await button.click(); } +function row(page: any, key: string) { return page.locator(".atlas-list-rows").getByRole("button", { name: new RegExp(key) }); } +test("SYNTHETIC Atlas two-repository cached round trips preserve identity/source/styles/view/focus without stars or POST", async ({ page, api }) => { + const worlds = await fixture(page, api); + await page.goto(atlasPath); + await expect(row(page, keys[0])).toContainText("Cached status not checked"); + expect(api.requests.filter((r: any) => r.path.includes("/api/world/"))).toEqual([]); + for (const [i, key] of keys.slice(0, 2).entries()) { + await row(page, key).click(); + const card = page.locator(".atlas-card"); + await expect(card.getByText("Discovered on this device · No Exploration star awarded.", { exact: true })).toBeVisible(); + await expect(card.getByRole("link", { name: "GitHub source" })).toHaveAttribute("href", worlds[i].repo.url); + await expect(card).toContainText("Stale capture · current state unknown"); + await expect(card).toContainText("Historical trees unavailable"); + const cv = page.locator(".atlas-map > canvas"); + await cv.focus(); + await cv.press("="); + await cv.press("ArrowDown"); + const before = await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!)); + await card.getByRole("link", { name: "Enter cached world" }).click(); + await expect(page).toHaveURL(new RegExp(`/world/${key}\\?`)); + await expect(page.locator(".explorer-visit-status")).toContainText("Visited on this device"); + await expect(page.locator(".explorer-visit-status")).toContainText("No Exploration star"); + await page.locator(".district-list").getByRole("button", { name: /SYNTHETIC 源码/ }).click(); + const inspector = page.locator(".inspector"); + await expect(inspector.locator(".path-label")).toHaveText("packages/源码"); + await expect(inspector.getByRole("link", { name: "Inspect on GitHub" })).toHaveAttribute("href", `${worlds[i].repo.url}/tree/${worlds[i].headSha}/packages/${encodeURIComponent("源码")}`); + for (const name of ["Moon Factory", "Factorio", "Red Alert", "Red Alert 2", "Civilization"]) { + await page.getByRole("group", { name: "World perspective" }).getByRole("button", { name: new RegExp(name + "$") }).click(); + await expect(inspector.locator(".path-label")).toHaveText("packages/源码"); + } + await page.getByRole("link", { name: "Back to Atlas", exact: true }).click(); + await expect(page.locator(".atlas-card")).toContainText("Visited on this device"); + await expect(row(page, key)).toBeFocused(); + const after = await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!)); + expect(after.centerX).toBeCloseTo(before.centerX, 5); + expect(after.centerY).toBeCloseTo(before.centerY, 5); + expect(after.zoomRatio).toBeCloseTo(before.zoomRatio, 6); + } + expect(api.requests.filter((r: any) => r.method === "POST" || r.path.includes("blueprint") || r.path.includes("budget"))).toEqual([]); +}); +test("SYNTHETIC unchecked, real cache miss and 503 stay separate, with no implicit collection", async ({ page, api }) => { + await fixture(page, api); + api.respond("GET", "/api/world/" + keys[1], () => ({ status: 503, body: { code: "WORLD_STORAGE_UNAVAILABLE", error: "SYNTHETIC authoritative store unavailable" } })); + await page.goto(atlasPath); + await expect(row(page, keys[2])).toContainText("Cached status not checked"); + await row(page, keys[2]).click(); + await expect(page.locator(".atlas-card .atlas-status")).toHaveText("Not collected"); + await expect(page.getByRole("link", { name: "Enter cached world" })).toHaveCount(0); + await row(page, keys[1]).click(); + await expect(page.locator(".atlas-card .atlas-status")).toHaveText("Cached facts unavailable"); + await expect(page.locator(".atlas-card")).not.toContainText("Not collected"); + expect(api.requests.every((r: any) => r.method === "GET")).toBe(true); +}); +test("SYNTHETIC actual painted map label selects the same repository as its visible list and stays idle", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await fixture(page, api); + await page.goto(atlasPath); + await row(page, keys[0]).click(); + await expect(page.getByRole("link", { name: "Enter cached world" })).toBeVisible(); + await page.getByRole("button", { name: "Close selected landmark" }).click(); + const cv = page.locator(".atlas-map > canvas"); + await expect.poll(() => cv.evaluate((c: any, key: string) => c.__labels?.some((p: any) => p.text === key), keys[0])).toBe(true); + const label = await cv.evaluate((c: any, key: string) => c.__labels.find((p: any) => p.text === key), keys[0]), box = (await cv.boundingBox())!; + await page.mouse.click(box.x + label.x, box.y + label.y); + await expect(row(page, keys[0])).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(".atlas-card")).toContainText("a".repeat(40)); + await page.waitForTimeout(150); + const before = await cv.evaluate((c: any) => c.__frames); + await page.waitForTimeout(200); + expect(await cv.evaluate((c: any) => c.__frames)).toBe(before); + const dimensions = await cv.evaluate((c: any) => ({ width: c.width, height: c.height, css: c.getBoundingClientRect().width })); + expect(dimensions.width / dimensions.css).toBeLessThanOrEqual(1.501); + expect(dimensions.width * dimensions.height).toBeLessThanOrEqual(4000000); +}); +for (const width of [320, 390, 768]) + test(`SYNTHETIC Atlas ${width}px cached identity/source/keyboard remain readable without horizontal overflow`, async ({ page, api }) => { + await page.setViewportSize({ width, height: 1000 }); + await fixture(page, api); + await page.goto(atlasPath); + await showList(page); + await row(page, keys[0]).focus(); + await page.keyboard.press("ArrowDown"); + await expect(row(page, keys[1])).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.locator(".atlas-card")).toContainText("b".repeat(40)); + await expect(page.locator(".atlas-card").getByRole("link", { name: "GitHub source" })).toHaveAttribute("href", "https://github.com/" + keys[1]); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + expect(await page.locator(".atlas-card").evaluate(e => e.scrollWidth <= e.clientWidth + 1)).toBe(true); + await page.getByRole("link", { name: "Enter cached world" }).click(); + await expect(page.locator(".explorer-visit-status")).toContainText("Visited on this device"); + await page.getByRole("link", { name: "Back to Atlas", exact: true }).click(); + await expect(row(page, keys[1])).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }); +test("SYNTHETIC hidden Atlas map stops drawing, resumes one state-correct frame and never polls", async ({ page, api }) => { + await page.setViewportSize({ width: 390, height: 1000 }); + await fixture(page, api); + await page.goto(atlasPath); + const cv = page.locator(".atlas-map > canvas"); + await expect.poll(() => cv.evaluate((c: any) => c.__frames ?? 0)).toBeGreaterThan(0); + await showList(page); + await page.waitForTimeout(150); + const frames = await cv.evaluate((c: any) => c.__frames), requests = api.requests.length; + await page.waitForTimeout(250); + expect(await cv.evaluate((c: any) => c.__frames)).toBe(frames); + expect(api.requests.length).toBe(requests); + await page.getByRole("button", { name: "Map", exact: true }).click(); + await expect.poll(() => cv.evaluate((c: any) => c.__frames)).toBeGreaterThan(frames); +}); +test("SYNTHETIC real two-touch pinch and cancellation never become repository entry or collection", async ({ page, api, context }) => { + await page.setViewportSize({ width: 390, height: 1000 }); + await fixture(page, api); + await page.goto(atlasPath); + const cv = page.locator(".atlas-map > canvas"); + await expect.poll(() => cv.evaluate((c: any) => c.__frames ?? 0)).toBeGreaterThan(0); + const b = (await cv.boundingBox())!, cx = b.x + b.width / 2, cy = b.y + b.height / 2, cdp = await context.newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx - 30, y: cy, id: 1 }, { x: cx + 30, y: cy, id: 2 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 70, y: cy, id: 1 }, { x: cx + 70, y: cy, id: 2 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + expect(await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!).zoomRatio)).toBeGreaterThan(1); + expect(api.requests.filter((r: any) => r.path.includes("/api/world/"))).toEqual([]); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx + 35, y: cy + 35, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [] }); + await cv.focus(); + await cv.press("0"); + await showList(page); + await row(page, keys[0]).click(); + await expect(page.getByRole("link", { name: "Enter cached world" })).toBeVisible(); + expect(api.requests.every((r: any) => r.method === "GET")).toBe(true); +}); + +test("SYNTHETIC Atlas light/dark chrome computes readable contrast and mobile entry stays visible", async ({ page, api }, testInfo) => { + await fixture(page, api); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(basePath + "/"); + await expect(page.getByRole("link", { name: "Explore Atlas", exact: true }).filter({ visible: true })).toBeVisible(); + await page.getByRole("link", { name: "Explore Atlas", exact: true }).filter({ visible: true }).click(); + await showList(page); + await row(page, keys[0]).click(); + await expect(page.getByRole("link", { name: "Enter cached world" })).toBeVisible(); + const audit = await page.evaluate(() => { + const rgb = (value: string) => { const a = value.match(/[\d.]+/g)?.map(Number) ?? []; return [a[0] ?? 0, a[1] ?? 0, a[2] ?? 0, a[3] ?? 1]; }; + const over = (a: number[], b: number[]) => { const alpha = a[3] + b[3] * (1 - a[3]); return [0, 1, 2].map(i => (a[i] * a[3] + b[i] * b[3] * (1 - a[3])) / (alpha || 1)).concat(alpha); }; + const lum = (a: number[]) => a.slice(0, 3).map(n => { n /= 255; return n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4; }).reduce((n, v, i) => n + v * [.2126, .7152, .0722][i], 0); + const failures: unknown[] = [], samples: unknown[] = []; + for (const el of document.querySelectorAll(".atlas-shell *")) { + const c = getComputedStyle(el), r = el.getBoundingClientRect(); + if (!r.width || !r.height || c.visibility === "hidden" || el.closest(".sr-only")) continue; + const text = Array.from(el.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent).join("").trim(); + if (!text) continue; + const ancestors: Element[] = []; let p: Element | null = el; + while (p) { ancestors.unshift(p); p = p.parentElement; } + let bg = [255, 255, 255, 1], opacity = 1; + for (const ancestor of ancestors) { const style = getComputedStyle(ancestor); bg = over(rgb(style.backgroundColor), bg); opacity *= Number(style.opacity); } + const foreground = rgb(c.color); foreground[3] *= opacity; + const a = lum(over(foreground, bg)), b = lum(bg), ratio = (Math.max(a, b) + .05) / (Math.min(a, b) + .05); + const sample = { tag: el.tagName, text: text.slice(0, 100), ratio, size: c.fontSize }; + samples.push(sample); + if (ratio < (parseFloat(c.fontSize) >= 24 ? 3 : 4.5) || parseFloat(c.fontSize) < 12) failures.push(sample); + } + return { samples, failures, width: innerWidth, documentWidth: document.documentElement.scrollWidth }; + }); + expect(audit.failures).toEqual([]); + expect(audit.samples.length).toBeGreaterThan(15); + expect(audit.documentWidth).toBe(audit.width); + await testInfo.attach("computed-contrast-synthetic.json", { body: JSON.stringify(audit, null, 2), contentType: "application/json" }); + await page.screenshot({ path: testInfo.outputPath("atlas-390-synthetic.png"), fullPage: true }); +}); diff --git a/e2e/cached-exploration.spec.ts b/e2e/cached-exploration.spec.ts new file mode 100644 index 0000000..ee7c02e --- /dev/null +++ b/e2e/cached-exploration.spec.ts @@ -0,0 +1,21 @@ +import { basePath, test, expect, worldApi, worldPath } from "./fixtures"; + +test("SYNTHETIC cached entry, retry, reload and sixty-second idle never collect or request AI",async({page,api})=>{ + api.world.source="github";let ready=false;const queries:string[]=[]; + api.respond("GET",worldApi,route=>{const u=new URL(route.request().url());queries.push(u.search);return ready?{body:api.world}:{status:404,body:{error:"Not collected. No collection was requested.",code:"CACHE_MISS"}}}); + await page.clock.install();await page.goto(worldPath+"?mode=cached");await expect(page.getByRole("alert")).toContainText("Not collected"); + ready=true;await page.getByRole("button",{name:"Try again",exact:true}).click();await expect(page.locator(".stage-heading h1")).toContainText("browser-fixture"); + const before=api.requests.length;await page.clock.fastForward(61000);expect(api.requests.length).toBe(before); + await page.reload();await expect(page.locator(".stage-heading h1")).toContainText("browser-fixture"); + expect(queries).toEqual(["?mode=cached","?mode=cached","?mode=cached"]);expect(api.requests.some(r=>r.method==="POST"||r.path.includes("blueprint")||r.path.includes("budget"))).toBe(false); +}); +test("SYNTHETIC unknown exploration mode does not silently enter legacy collection",async({page,api})=>{ + await page.goto(worldPath+"?mode=other");await expect(page.getByRole("alert")).toContainText("Invalid exploration mode");expect(api.requests).toEqual([]); +}); +test("SYNTHETIC actual local empty-store route rejects malformed intent and returns cache miss without collection",async({request})=>{ + test.skip(process.env.MOONSHOT_OFFLINE_FIXTURE !== "1", "Native empty-store test requires the explicit outbound-blocked fixture runtime."); + // APIRequestContext bypasses browser fixtures: this uses the owned keyless local + // runtime's empty synthetic store, never a public origin or real repository. + for(const query of ["mode=other","mode=cached&mode=cached","mode=cached&refresh=true"]){const response=await request.get(`${basePath}/api/world/synthetic-cache-test/not-collected?${query}`);expect(response.status()).toBe(400);expect((await response.json()).code).toBe("INVALID_REQUEST")} + const miss=await request.get(`${basePath}/api/world/synthetic-cache-test/not-collected?mode=cached`);expect(miss.status()).toBe(404);expect((await miss.json()).code).toBe("CACHE_MISS"); +}); diff --git a/e2e/canvas-scheduling.spec.ts b/e2e/canvas-scheduling.spec.ts new file mode 100644 index 0000000..5f10276 --- /dev/null +++ b/e2e/canvas-scheduling.spec.ts @@ -0,0 +1,35 @@ +import {test,expect,worldPath,worldApi} from "./fixtures"; +async function observe(page:any){await page.addInitScript(()=>{ + const nativeRequest=window.requestAnimationFrame.bind(window),nativeCancel=window.cancelAnimationFrame.bind(window),callbacks=new WeakMap(),pending=new Map(),canvases=new WeakMap();let active:any=null; + (window as any).__canvasScheduling={canvases,worldOwners:[]}; + window.requestAnimationFrame=(callback)=>{let record=callbacks.get(callback);if(!record){record={requests:0,runs:0,cancels:0,pending:0,paints:0};callbacks.set(callback,record);}record.requests++;record.pending++;const id=nativeRequest(time=>{pending.delete(id);record.pending--;record.runs++;const before=active;active={record,painted:new Set()};try{callback.call(window,time);}finally{active=before;}});pending.set(id,record);return id;}; + window.cancelAnimationFrame=(id)=>{const record=pending.get(id);if(record){record.pending--;record.cancels++;pending.delete(id);}nativeCancel(id);}; + const fill=CanvasRenderingContext2D.prototype.fillRect;CanvasRenderingContext2D.prototype.fillRect=function(...args:any[]){if(active&&this.canvas.dataset.testid==="world-canvas"&&!active.painted.has(this.canvas)){active.painted.add(this.canvas);active.record.paints++;canvases.set(this.canvas,active.record);if(!(window as any).__canvasScheduling.worldOwners.includes(active.record))(window as any).__canvasScheduling.worldOwners.push(active.record);}return fill.apply(this,args as any);}; +});} +const stats=(canvas:any)=>canvas.evaluate((c:HTMLCanvasElement)=>{const r=(window as any).__canvasScheduling.canvases.get(c);return r?{...r}:{requests:0,runs:0,cancels:0,pending:0,paints:0};}); +async function settle(canvas:any){await expect.poll(async()=>(await stats(canvas)).pending).toBe(0);await canvas.page().waitForTimeout(100);} +async function noIdleWork(canvas:any,duration=350){await settle(canvas);const before=await stats(canvas);await canvas.page().waitForTimeout(duration);expect(await stats(canvas)).toEqual(before);} +async function cached(page:any,api:any){api.world.source="github";api.world.nodes[0].evidence=[`${api.world.repo.url}/tree/${api.world.headSha}/${api.world.nodes[0].path}`];api.respond("GET",worldApi,()=>({body:api.world}));await page.goto(worldPath+"?mode=cached");} + +test("SYNTHETIC real WorldCanvas reduced motion has zero idle RAF and still repaints every actual input/style/source",async({page,api})=>{ + await observe(page);await cached(page,api);const cv=page.getByTestId("world-canvas");await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(0);await noIdleWork(cv,1200); + for(const name of ["Moon Factory","Factorio","Red Alert","Red Alert 2","Civilization"]){const before=(await stats(cv)).paints;await page.getByRole("group",{name:"World perspective"}).getByRole("button",{name:new RegExp(name+"$")}).click();if(name!=="Moon Factory")await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(before);await noIdleWork(cv);} + for(const key of ["ArrowRight","+","0"]){const before=(await stats(cv)).paints;await cv.focus();await cv.press(key);await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(before);await noIdleWork(cv);} + let before=(await stats(cv)).paints;await page.getByRole("button",{name:"Zoom in",exact:true}).click();await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(before);await noIdleWork(cv); + before=(await stats(cv)).paints;await page.locator(".district-row").filter({hasText:"SYNTHETIC Web"}).click();await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(before);await noIdleWork(cv); + before=(await stats(cv)).paints;await page.setViewportSize({width:1250,height:930});await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(before);await noIdleWork(cv); + api.world={...api.world,headSha:"d".repeat(40),collectedAt:"2026-11-04T12:00:01Z"};before=(await stats(cv)).paints;await page.getByRole("button",{name:"Refresh GitHub data",exact:true}).click();await expect(page.locator(".world-source-stamp code")).toHaveText("ddddddd");await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(before);await noIdleWork(cv);expect(api.requests.every((r:any)=>r.method==="GET")).toBe(true); +}); + +test("SYNTHETIC real WorldCanvas visible motion stays bounded at 60fps, pause/media/hidden/offscreen cancel pending RAF and recover",async({page,api})=>{ + await page.emulateMedia({reducedMotion:"no-preference"});await observe(page);await cached(page,api);const cv=page.getByTestId("world-canvas");await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(1);const before=await stats(cv);await page.waitForTimeout(1000);const animated=await stats(cv);expect(animated.paints-before.paints).toBeGreaterThan(15);expect(animated.paints-before.paints).toBeLessThanOrEqual(66);expect(animated.pending).toBeLessThanOrEqual(1); + await page.getByRole("button",{name:"Pause motion",exact:true}).click();await noIdleWork(cv,1200);let frame=(await stats(cv)).paints;await cv.focus();await cv.press("ArrowDown");await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(frame);await noIdleWork(cv); + await page.getByRole("button",{name:"Resume motion",exact:true}).click();frame=(await stats(cv)).paints;await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(frame+1);await page.emulateMedia({reducedMotion:"reduce"});await noIdleWork(cv);await page.emulateMedia({reducedMotion:"no-preference"});frame=(await stats(cv)).paints;await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(frame+1); + await page.evaluate(()=>{Object.defineProperty(document,"hidden",{configurable:true,get:()=>true});document.dispatchEvent(new Event("visibilitychange"));});await noIdleWork(cv,600);await page.evaluate(()=>{Object.defineProperty(document,"hidden",{configurable:true,get:()=>false});document.dispatchEvent(new Event("visibilitychange"));});frame=(await stats(cv)).paints;await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(frame+1); + await page.locator(".world-canvas-wrap").evaluate((el:HTMLElement)=>el.style.transform="translateY(-400vh)");await noIdleWork(cv,600);await page.locator(".world-canvas-wrap").evaluate((el:HTMLElement)=>el.style.transform="");frame=(await stats(cv)).paints;await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(frame+1); + await page.getByRole("button",{name:"Activity",exact:true}).click();await expect(cv).toHaveCount(0);const closed=await page.evaluate(()=>(window as any).__canvasScheduling.worldOwners.map((r:any)=>({...r})));expect(closed.every((r:any)=>r.pending===0)).toBe(true);await page.waitForTimeout(500);expect(await page.evaluate(()=>(window as any).__canvasScheduling.worldOwners.map((r:any)=>({...r})))).toEqual(closed); +}); + +test("SYNTHETIC hidden before first paint never marks visited; visibility paints once before awarding visit",async({page,api})=>{ + await observe(page);await page.addInitScript(()=>Object.defineProperty(document,"hidden",{configurable:true,get:()=>true}));await cached(page,api);const cv=page.getByTestId("world-canvas");await expect(cv).toBeVisible();await expect(page.locator(".explorer-visit-status")).toContainText("Rendering cached world");await page.waitForTimeout(500);expect((await stats(cv)).paints).toBe(0);await expect(page.locator(".explorer-visit-status")).not.toContainText("Visited");await page.evaluate(()=>{Object.defineProperty(document,"hidden",{configurable:true,get:()=>false});document.dispatchEvent(new Event("visibilitychange"));});await expect.poll(async()=>(await stats(cv)).paints).toBeGreaterThan(0);await expect(page.locator(".explorer-visit-status")).toContainText("Visited on this device");await noIdleWork(cv); +}); diff --git a/e2e/historic-viewer.spec.ts b/e2e/historic-viewer.spec.ts new file mode 100644 index 0000000..349b4b8 --- /dev/null +++ b/e2e/historic-viewer.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { historicSha, historicRepo, oldHistoricPath } from "../test/fixtures/historic-viewer"; + +// Bundled real component, served solely by intercepted loopback browser routes. +// No production test route, API, store or fixture fallback is introduced. +let javascript = "", css = "", errors: string[] = [], unexpected: string[] = []; +test.beforeAll(() => { + const dir=join(process.cwd(),"artifacts/historic-component-harness"); + execFileSync("bun",["--no-env-file","build","test/fixtures/historic-viewer-harness.tsx","--target=browser","--outdir",dir],{cwd:process.cwd(),env:{PATH:process.env.PATH},stdio:"pipe"}); + javascript=readFileSync(join(dir,"historic-viewer-harness.js"),"utf8");css=readFileSync(join(dir,"historic-viewer-harness.css"),"utf8"); +}); +test.beforeEach(async({page,baseURL})=>{ + errors=[];unexpected=[];page.on("pageerror",error=>errors.push(error.message));const origin=new URL(baseURL!).origin; + await page.addInitScript(()=>{const native=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window),pending=new Set();(window as any).__historicRaf={requests:0,runs:0,pending};window.requestAnimationFrame=callback=>{const record=(window as any).__historicRaf;record.requests++;const id=native(time=>{pending.delete(id);record.runs++;callback(time);});pending.add(id);return id;};window.cancelAnimationFrame=id=>{pending.delete(id);cancel(id);};}); + await page.route("**/*",async route=>{const url=new URL(route.request().url());if(url.origin!==origin){unexpected.push(url.href);return route.abort();}if(url.pathname==="/__synthetic_historic__/")return route.fulfill({contentType:"text/html",body:'
'});if(url.pathname==="/__synthetic_historic__/entry.js")return route.fulfill({contentType:"text/javascript",body:javascript});if(url.pathname==="/__synthetic_historic__/entry.css")return route.fulfill({contentType:"text/css",body:css});if(url.pathname==="/favicon.ico")return route.fulfill({status:204,body:""});unexpected.push(url.href);return route.abort();}); + await page.goto(origin+"/__synthetic_historic__/");await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(1));await expect.poll(()=>page.getByTestId("historic-scene-canvas").getAttribute("data-paint-count")).not.toBeNull(); +}); +test.afterEach(()=>{expect(errors,"Real component runtime errors").toEqual([]);expect(unexpected,"Harness must make no upstream/API requests").toEqual([]);}); +const painted=(page:any)=>page.getByTestId("historic-scene-canvas").evaluate((c:HTMLCanvasElement)=>({ids:JSON.parse(c.dataset.paintedIds!),camera:JSON.parse(c.dataset.camera!),paints:Number(c.dataset.paintCount)})); +const idle=async(page:any,duration=400)=>{await expect.poll(()=>page.evaluate(()=>(window as any).__historicRaf.pending.size)).toBe(0);const before=await page.evaluate(()=>({requests:(window as any).__historicRaf.requests,runs:(window as any).__historicRaf.runs}));await page.waitForTimeout(duration);expect(await page.evaluate(()=>({requests:(window as any).__historicRaf.requests,runs:(window as any).__historicRaf.runs}))).toEqual(before);}; + +test("SYNTHETIC actual historic Canvas changes supplied tree, exact delta, pinned source, tombstone, camera and all five art styles",async({page})=>{ + const cv=page.getByTestId("historic-scene-canvas");expect((await painted(page)).ids).toContain("path:"+encodeURIComponent(oldHistoricPath));expect((await painted(page)).ids).not.toContain("path:src%2Fnew.ts");await expect(page.getByRole("link",{name:"View pinned commit",exact:true})).toHaveAttribute("href",`https://github.com/${historicRepo}/commit/${historicSha(1)}`); + await cv.focus();await cv.press("ArrowRight");await idle(page);const camera=(await painted(page)).camera,box=(await cv.boundingBox())!;await page.mouse.click(box.x+camera.x-90*camera.k,box.y+camera.y-72.5*camera.k);await expect(page.getByRole("complementary",{name:"Historical selection"})).toContainText("shared.ts"); + await page.getByRole("list",{name:"Captured paths"}).getByRole("button",{name:oldHistoricPath,exact:true}).click();const priorSource=await page.getByRole("link",{name:"View selected path at this commit",exact:true}).getAttribute("href");await page.getByRole("button",{name:"Next checkpoint",exact:true}).click();await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(2));await expect(page.getByTestId("historic-transition")).toContainText("1 added entries, 1 removed entries");await expect(page.getByRole("complementary",{name:"Historical selection"})).toContainText("Not present in this complete captured scope");await expect(page.getByRole("link",{name:"View prior selected path source"})).toHaveAttribute("href",priorSource!); + const images=new Set();for(const name of ["Moon Factory","Factorio","Red Alert","Red Alert 2","Civilization"]){await page.getByRole("group",{name:"Historical art style"}).getByRole("button",{name,exact:true}).click();await idle(page,80);images.add(await cv.evaluate((c:HTMLCanvasElement)=>c.toDataURL()));expect((await painted(page)).camera).toEqual(camera);expect((await painted(page)).ids).not.toContain("path:"+encodeURIComponent(oldHistoricPath));expect((await painted(page)).ids).toContain("path:src%2Fnew.ts");await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(2));}expect(images.size).toBe(5);await page.screenshot({path:"artifacts/historic-viewer-offline/desktop.png",fullPage:true}); +}); + +test("SYNTHETIC finite checkpoint playback pauses, ends and keeps reduced-motion/hidden/offscreen idle without catch-up",async({page})=>{ + await idle(page,500);await page.getByRole("button",{name:"Play checkpoints",exact:true}).click();await page.getByRole("button",{name:"Pause checkpoints",exact:true}).click();await page.waitForTimeout(1350);await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(1));await idle(page); + await page.getByRole("button",{name:"Play checkpoints",exact:true}).click();await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(2));await expect(page.getByRole("button",{name:"Play checkpoints",exact:true})).toBeVisible();await idle(page,500);await page.getByRole("button",{name:"Previous checkpoint",exact:true}).click();await page.getByRole("button",{name:"Play checkpoints",exact:true}).click(); + await page.evaluate(()=>{Object.defineProperty(document,"hidden",{configurable:true,get:()=>true});document.dispatchEvent(new Event("visibilitychange"));});await idle(page,1350);await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(1));await page.evaluate(()=>{Object.defineProperty(document,"hidden",{configurable:true,get:()=>false});document.dispatchEvent(new Event("visibilitychange"));});await idle(page,1350);await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(1)); + await page.getByRole("button",{name:"Play checkpoints",exact:true}).click();await page.locator(".historic-scene-wrap").evaluate((el:HTMLElement)=>el.style.transform="translateY(-400vh)");await idle(page,1350);await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(1));await page.locator(".historic-scene-wrap").evaluate((el:HTMLElement)=>el.style.transform="");await idle(page,1350);await page.emulateMedia({reducedMotion:"no-preference"});await idle(page,500); +}); + +test("SYNTHETIC partial and nonadjacent checkpoints show gaps, never guessed deletion; backward seek is also a gap",async({page})=>{ + for(const mode of ["partial","gap"]as const){await page.evaluate(mode=>(window as any).__historicHarness.replace(mode),mode);await page.getByRole("button",{name:"Next checkpoint",exact:true}).click();await expect(page.getByTestId("historic-transition")).toContainText("Checkpoint gap");await expect(page.getByTestId("historic-transition")).not.toContainText("removed entries");if(mode==="partial")await expect(page.getByTestId("historic-coverage")).toContainText("absence unknown");await page.getByRole("button",{name:"Previous checkpoint",exact:true}).click();await expect(page.getByTestId("historic-transition")).toContainText("Checkpoint gap");} + const seek=page.getByRole("slider",{name:"Seek loaded checkpoint"});await seek.focus();await seek.press("End");await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(3));await seek.press("Home");await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(1)); +}); + +test("SYNTHETIC invalid/missing input is unavailable; generation replacement cancels old playback and resets source cursor",async({page})=>{ + await page.getByRole("button",{name:"Play checkpoints",exact:true}).click();await page.evaluate(()=>(window as any).__historicHarness.replace("full","SYNTHETIC_generation_2"));await page.waitForTimeout(1350);await expect(page.getByTestId("historic-commit")).toHaveText(historicSha(1));await expect(page.getByRole("button",{name:"Play checkpoints",exact:true})).toBeVisible(); + for(const mode of ["invalid","missing"]){await page.evaluate(mode=>(window as any).__historicHarness.replace(mode),mode);await expect(page.getByTestId("historic-scene-canvas")).toHaveCount(0);await expect(page.getByRole("status")).toContainText("Historical checkpoints unavailable");await idle(page);} +}); +for(const width of [320,390])test(`SYNTHETIC ${width}px long CJK paths/full SHA/source controls fit without overflow`,async({page})=>{ + await page.setViewportSize({width,height:900});await page.getByText("Fixed source anchor",{exact:true}).click();await page.getByRole("list",{name:"Captured paths"}).getByRole("button",{name:oldHistoricPath,exact:true}).click();await expect(page.getByRole("complementary",{name:"Historical selection"})).toContainText(oldHistoricPath);expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);await expect(page.getByRole("link",{name:"View selected path at this commit"})).toHaveAttribute("href",new RegExp(historicSha(1)));await page.screenshot({path:`artifacts/historic-viewer-offline/mobile-${width}.png`,fullPage:true}); +}); + +test("SYNTHETIC actual checkpoint input repaint stays bounded30fps and has zero work after interaction",async({page})=>{ + const cv=page.getByTestId("historic-scene-canvas");await cv.focus();await idle(page);const before=(await painted(page)).paints;await cv.evaluate(async(c:HTMLCanvasElement)=>{await new Promise(resolve=>{let n=0;const timer=setInterval(()=>{c.dispatchEvent(new KeyboardEvent("keydown",{key:"ArrowRight",bubbles:true}));if(++n===100){clearInterval(timer);resolve();}},10);});});await idle(page);const delta=(await painted(page)).paints-before;expect(delta).toBeGreaterThan(5);expect(delta).toBeLessThanOrEqual(35); +}); + +test("SYNTHETIC blob/symlink/directory tombstones retain the exact selected pinned source URL across cursor and style changes",async({page})=>{ + for(const [index,path,kind]of [[0,oldHistoricPath,"blob"],[1,"链接-快捷方式","blob"],[2,"已移除目录","tree"]]as const){ + await page.evaluate(generation=>(window as any).__historicHarness.replace("sources",generation),`SYNTHETIC_source_${index}`); + const original=await page.getByRole("link",{name:`Source for ${path}`,exact:true}).getAttribute("href"),expected=`https://github.com/${historicRepo}/${kind}/${historicSha(1)}/${path.split("/").map(encodeURIComponent).join("/")}`;expect(original).toBe(expected); + await page.getByRole("list",{name:"Captured paths"}).getByRole("button",{name:path,exact:true}).click();await expect(page.getByRole("link",{name:"View selected path at this commit",exact:true})).toHaveAttribute("href",original!);await page.getByRole("button",{name:"Next checkpoint",exact:true}).click();await expect(page.getByRole("link",{name:"View prior selected path source",exact:true})).toHaveAttribute("href",original!); + await page.getByRole("group",{name:"Historical art style"}).getByRole("button",{name:"Factorio",exact:true}).click();await expect(page.getByRole("link",{name:"View prior selected path source",exact:true})).toHaveAttribute("href",original!);await page.getByRole("button",{name:"Previous checkpoint",exact:true}).click();await expect(page.getByRole("link",{name:"View selected path at this commit",exact:true})).toHaveAttribute("href",original!);await page.getByRole("button",{name:"Next checkpoint",exact:true}).click();await expect(page.getByRole("link",{name:"View prior selected path source",exact:true})).toHaveAttribute("href",original!); + } +}); diff --git a/e2e/history-replay.spec.ts b/e2e/history-replay.spec.ts new file mode 100644 index 0000000..7dbc209 --- /dev/null +++ b/e2e/history-replay.spec.ts @@ -0,0 +1,32 @@ +import {test,expect,basePath} from "./fixtures"; +import {syntheticWorld} from "../test/fixtures/browser-world"; +const key="synthetic-history/replay"; +function capture(){let w=syntheticWorld();const old=w.repo.url,oldHead=w.headSha;w=JSON.parse(JSON.stringify(w).replaceAll(old,`https://github.com/${key}`).replaceAll(oldHead,"f".repeat(40)));w.source="github";w.repo={...w.repo,owner:"synthetic-history",name:"replay",fullName:key,url:`https://github.com/${key}`};w.nodes[0].evidence=[`${w.repo.url}/tree/${w.headSha}/${w.nodes[0].path}`];w.commits=[{id:"c".repeat(40),message:"SYNTHETIC latest marker",author:"SYNTHETIC",date:"2025-07-03T00:00:00Z",url:`${w.repo.url}/commit/${"c".repeat(40)}`},{id:"a".repeat(40),message:"原始源文本 / "+"SYNTHETIC_SOURCE_".repeat(20),author:"SYNTHETIC",date:"2025-07-01T00:00:00Z",url:`${w.repo.url}/commit/${"a".repeat(40)}`},{id:"b".repeat(40),message:"Merge pull request #123 (source text; no merge evidence)",author:"SYNTHETIC",date:"2025-07-02T00:00:00Z",url:`${w.repo.url}/commit/${"b".repeat(40)}`}];return w;} +async function open(page:any,api:any,w=capture()){api.respond("GET","/api/world/"+key,(r:any)=>{expect(new URL(r.request().url()).search).toBe("?mode=cached");return{body:w};});await page.goto(`${basePath}/world/${key}?mode=cached`);await expect(page.locator(".explorer-visit-status")).toContainText("Visited on this device");await page.getByRole("button",{name:"Replay captured commits",exact:true}).click();await expect(page.getByRole("region",{name:"Captured commit replay"})).toContainText("3 eligible / 3 retained");return w;} +const panel=(page:any)=>page.getByRole("region",{name:"Captured commit replay",exact:true}); +async function progress(page:any){return page.evaluate(async()=>{const db=await new Promise(resolve=>{const r=indexedDB.open("moon-factory-exploration",1);r.onsuccess=()=>resolve(r.result);});return new Promise(resolve=>{const t=db.transaction(["repoProgress","completions","metadata"]),a=t.objectStore("completions").getAll(),b=t.objectStore("repoProgress").getAll();t.oncomplete=()=>{db.close();resolve(JSON.stringify([a.result,b.result]));};});});} + +test("SYNTHETIC cached replay advances actual SHA cards to finite end; preserves map/source/styles and makes no request or award",async({page,api})=>{ + const w=await open(page,api),p=panel(page),requests=api.requests.length,before=await progress(page);await expect(p).toContainText("Historical topology unavailable");await expect(p.locator(".history-structure")).toContainText(w.headSha);await expect(p.locator(".history-marker code")).toHaveText("a".repeat(40));await expect(p).toContainText("2025-07-01T00:00:00.000Z"); + await p.getByRole("button",{name:"Play commit replay",exact:true}).click();await expect(p.locator(".history-marker code")).toHaveText("b".repeat(40));await p.getByRole("button",{name:"Pause commit replay",exact:true}).click(); + for(const name of ["Moon Factory","Factorio","Red Alert","Red Alert 2","Civilization"]){await page.getByRole("group",{name:"World perspective"}).getByRole("button",{name:new RegExp(name+"$")}).click();await expect(p.locator(".history-marker code")).toHaveText("b".repeat(40));await expect(p.locator(".history-structure")).toContainText("f".repeat(40));} + for(const speed of ["0.5","1","2","4"])await p.getByLabel("Playback speed").selectOption(speed);await p.getByRole("button",{name:"Play commit replay",exact:true}).click();await expect(p.locator(".history-marker")).toContainText("End of captured window");await expect(p.getByRole("button",{name:"Play commit replay",exact:true})).toBeDisabled();await expect(p.getByRole("link",{name:/^Commit source/})).toHaveAttribute("href",`${w.repo.url}/commit/${"c".repeat(40)}`); + expect(api.requests.length).toBe(requests);expect(await progress(page)).toBe(before);await page.getByRole("button",{name:"Close commit replay",exact:true}).click();await expect(p).toHaveCount(0); +}); + +test("SYNTHETIC hidden/offscreen replay pauses owned clock, no catch-up; keyboard seek and close remain finite",async({page,api})=>{ + await open(page,api);const p=panel(page);await p.getByRole("button",{name:"Play commit replay",exact:true}).click();await page.evaluate(()=>{Object.defineProperty(document,"hidden",{configurable:true,get:()=>true});document.dispatchEvent(new Event("visibilitychange"));});await expect(p).toHaveAttribute("data-playback","paused");await page.waitForTimeout(1200);await expect(p.locator(".history-marker code")).toHaveText("a".repeat(40));await page.evaluate(()=>{Object.defineProperty(document,"hidden",{configurable:true,get:()=>false});document.dispatchEvent(new Event("visibilitychange"));});await expect(p).toHaveAttribute("data-playback","paused"); + await p.getByLabel("Seek captured commit").focus();await page.keyboard.press("ArrowRight");await expect(p.locator(".history-marker code")).toHaveText("b".repeat(40));await p.getByRole("button",{name:"Play commit replay",exact:true}).click();await p.evaluate((e:HTMLElement)=>e.style.transform="translateY(-300vh)");await expect(p).toHaveAttribute("data-playback","paused");await page.waitForTimeout(1200);await p.evaluate((e:HTMLElement)=>e.style.transform="");await expect(p.locator(".history-marker code")).toHaveText("b".repeat(40));await p.getByRole("button",{name:"Previous captured commit"}).click();await expect(p.locator(".history-marker code")).toHaveText("a".repeat(40));await page.getByRole("button",{name:"Close commit replay",exact:true}).click(); +}); +for(const width of [320,390])test(`SYNTHETIC ${width}px reduced-motion/CJK replay keeps source, controls and current structure readable`,async({page,api})=>{ + await page.setViewportSize({width,height:1000});await page.emulateMedia({reducedMotion:"reduce"});await open(page,api);const p=panel(page);await expect(p).toContainText("原始源文本");expect(await p.evaluate((e:HTMLElement)=>e.scrollWidth<=e.clientWidth+1)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await p.getByRole("button",{name:"Next captured commit"}).click();await expect(p.locator(".history-marker code")).toHaveText("b".repeat(40));await expect(page.getByRole("link",{name:"Back to Atlas",exact:true})).toBeVisible();await page.getByRole("button",{name:"Close commit replay",exact:true}).click(); +}); + +test("SYNTHETIC empty captured window stays unknown and playback never changes a completed local star",async({page,api})=>{ + const w=capture();api.respond("GET","/api/world/"+key,()=>({body:w}));await page.goto(`${basePath}/world/${key}?mode=cached`);const challenge=page.getByRole("region",{name:"Source challenge",exact:true});await challenge.getByRole("button",{name:"Start source challenge"}).click();await challenge.getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().check();await challenge.getByRole("radio",{name:/^The map records this directory source/}).check();await challenge.getByRole("button",{name:"Submit source answers"}).click();await expect(challenge.getByRole("status")).toContainText("Saved on this device");const before=await progress(page);await page.getByRole("button",{name:"Replay captured commits",exact:true}).click();const p=panel(page);await p.getByLabel("Playback speed").selectOption("4");await p.getByRole("button",{name:"Play commit replay",exact:true}).click();await expect(p.locator(".history-marker")).toContainText("End of captured window");expect(await progress(page)).toBe(before); + w.commits=[];await page.reload();await page.getByRole("button",{name:"Replay captured commits",exact:true}).click();await expect(panel(page)).toContainText("No captured commit markers; history not loaded");await expect(panel(page).getByRole("button",{name:"Play commit replay",exact:true})).toHaveCount(0);await expect(challenge.locator(".exploration-receipt")).toContainText("1 Exploration star"); +}); + +test("SYNTHETIC explicit cached reload replaces replay generation and ignores the old playing clock",async({page,api})=>{ + const w=await open(page,api),p=panel(page),requests=api.requests.length;await p.getByRole("button",{name:"Play commit replay",exact:true}).click();w.headSha="d".repeat(40);w.collectedAt="2026-11-04T12:00:01Z";w.nodes[0].evidence=[`${w.repo.url}/tree/${w.headSha}/${w.nodes[0].path}`];await page.getByRole("button",{name:"Refresh GitHub data",exact:true}).click();await expect(p.locator(".history-structure")).toContainText(w.headSha);await expect(p).toHaveAttribute("data-playback","paused");await page.waitForTimeout(1200);await expect(p.locator(".history-marker code")).toHaveText("a".repeat(40));expect(api.requests.length).toBe(requests+1);expect(api.requests.every((r:any)=>r.method==="GET")).toBe(true); +}); diff --git a/e2e/history-runtime.spec.ts b/e2e/history-runtime.spec.ts new file mode 100644 index 0000000..9132380 --- /dev/null +++ b/e2e/history-runtime.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; + +// SYNTHETIC route-registration evidence against an independently owned, +// unconfigured loopback build. This is not enabled/live history acceptance. +test("registered history GET and POST stay unavailable with no runtime configuration", async ({ request, baseURL }) => { + const route = new URL("api/history/synthetic-history/registered-route", baseURL).href; + for (const method of ["GET", "POST"] as const) { + const response = await request.fetch(route, { method, + ...(method === "POST" ? { data: { action: "walk", cursor: "synthetic.invalid" } } : {}), + }); + expect(response.status()).toBe(503); + expect(response.headers()["content-type"]).toContain("application/json"); + expect(response.headers()["cache-control"]).toBe("no-store"); + expect(response.headers()["x-content-type-options"]).toBe("nosniff"); + expect(await response.json()).toEqual({ code: "HISTORY_HTTP_UNAVAILABLE", error: "History is temporarily unavailable. No automatic retry or fallback was attempted." }); + } +}); + +test("browser fetch reaches the registered history route under the compiled base path", async ({ page, baseURL }) => { + const origin = new URL(baseURL!).origin; + await page.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); + // The favicon is a real same-artifact static document; avoid other API flows. + await page.goto(new URL("favicon.svg", baseURL).href); + const result = await page.evaluate(async url => { + const response = await fetch(url); + return { status: response.status, payload: await response.json() }; + }, new URL("api/history/synthetic-history/registered-route", baseURL).href); + expect(result.status).toBe(503); expect(result.payload.code).toBe("HISTORY_HTTP_UNAVAILABLE"); +}); diff --git a/e2e/home-motion.spec.ts b/e2e/home-motion.spec.ts new file mode 100644 index 0000000..095ac94 --- /dev/null +++ b/e2e/home-motion.spec.ts @@ -0,0 +1,103 @@ +import { basePath, test, expect } from "./fixtures"; +import type { Page } from "@playwright/test"; + +// The hero is an original procedural illustration (no repository data). Frames are +// counted by instrumenting drawImage on the hero canvas, which every frame composites. +async function countHeroFrames(page: Page) { + await page.addInitScript(() => { + const proto = CanvasRenderingContext2D.prototype; + const original = proto.drawImage; + proto.drawImage = function (...args: [CanvasImageSource, number, number, ...number[]]) { + if (this.canvas.dataset.testid === "hero-canvas") this.canvas.dataset.frames = String(Number(this.canvas.dataset.frames || 0) + 1); + return Reflect.apply(original, this, args); + }; + }); +} +const frames = async (page: Page) => Number(await page.getByTestId("hero-canvas").getAttribute("data-frames")); +const pixels = (page: Page) => page.getByTestId("hero-canvas").evaluate((c: HTMLCanvasElement) => c.toDataURL()); +async function idle(page: Page, ms = 500) { + await page.waitForTimeout(120); + const before = await frames(page); + await page.waitForTimeout(ms); + expect(await frames(page)).toBe(before); +} + +test("hero world animates continuously while visible and stops offscreen and when hidden", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await countHeroFrames(page); + await page.goto(`${basePath}/`); + const canvas = page.getByTestId("hero-canvas"); + await expect(canvas).toBeVisible(); + await expect.poll(() => frames(page)).toBeGreaterThan(5); + const a = await pixels(page); + await page.waitForTimeout(300); + expect(await pixels(page)).not.toBe(a); + const before = await frames(page); + await page.waitForTimeout(500); + expect(await frames(page) - before).toBeGreaterThan(10); + // Construction intro completes and the HUD reports every district. + await expect(page.getByTestId("hero-built")).toHaveText(/^(\d+)\/\1$/, { timeout: 8000 }); + + await page.locator(".site-footer").scrollIntoViewIfNeeded(); + await idle(page); + await page.evaluate(() => window.scrollTo(0, 0)); + const resumed = await frames(page); + await expect.poll(() => frames(page)).toBeGreaterThan(resumed + 2); + + await page.evaluate(() => { Object.defineProperty(document, "hidden", { configurable: true, get: () => true }); document.dispatchEvent(new Event("visibilitychange")); }); + await idle(page); + await page.evaluate(() => { Object.defineProperty(document, "hidden", { configurable: true, get: () => false }); document.dispatchEvent(new Event("visibilitychange")); }); + const shown = await frames(page); + await expect.poll(() => frames(page)).toBeGreaterThan(shown + 2); +}); + +test("reduced motion paints one finished still frame and five visibly distinct hero styles", async ({ page }) => { + await countHeroFrames(page); + await page.goto(`${basePath}/`); + await expect.poll(() => frames(page)).toBeGreaterThan(0); + await idle(page); + const still = await pixels(page); + await page.waitForTimeout(300); + expect(await pixels(page)).toBe(still); + await expect(page.getByTestId("hero-built")).toHaveText(/^(\d+)\/\1$/); + const chips = page.getByRole("group", { name: "Hero world style" }); + const seen = new Set(); + for (const name of ["Moon Factory", "Factorio", "Red Alert", "Red Alert 2", "Civilization"]) { + const chip = chips.getByRole("button", { name, exact: true }); + await chip.click(); + await expect(chip).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(".hero-scene-top")).toContainText(name.toUpperCase()); + seen.add(await pixels(page)); + await idle(page, 250); + } + expect(seen.size).toBe(5); +}); + +test("homepage motion layer keeps 320px layout without horizontal overflow", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize({ width: 320, height: 760 }); + await page.goto(`${basePath}/`); + await page.getByTestId("hero-canvas").scrollIntoViewIfNeeded(); + await expect(page.getByRole("group", { name: "Hero world style" }).getByRole("button")).toHaveCount(5); + for (const selector of [".worlds-section", ".perspectives-section", ".closing-line"]) { + await page.locator(selector).scrollIntoViewIfNeeded(); + await page.waitForTimeout(150); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); + // Revealed sections end fully visible. + await expect(page.locator(".closing-line")).toHaveClass(/is-revealed/); + await expect.poll(() => page.locator(".closing-line").evaluate((el) => getComputedStyle(el).opacity)).toBe("1"); +}); + +test("gallery card tilts toward the pointer and settles back on leave", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto(`${basePath}/`); + const shell = page.locator(".project-card-shell").filter({ has: page.getByRole("heading", { name: "SYNTHETIC Atlas", exact: true }) }); + await shell.scrollIntoViewIfNeeded(); + const box = (await shell.boundingBox())!; + await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5); + await page.mouse.move(box.x + box.width * 0.9, box.y + box.height * 0.1, { steps: 4 }); + await expect.poll(() => shell.evaluate((el) => parseFloat(el.style.getPropertyValue("--tilt-y")))).toBeGreaterThan(3); + await page.mouse.move(2, 2); + await expect.poll(() => shell.evaluate((el) => parseFloat(el.style.getPropertyValue("--tilt-y")))).toBe(0); +}); diff --git a/e2e/interaction.spec.ts b/e2e/interaction.spec.ts index c77b8fa..4302e5e 100644 --- a/e2e/interaction.spec.ts +++ b/e2e/interaction.spec.ts @@ -32,7 +32,7 @@ async function camera(canvas: Locator): Promise { }); } -async function nextDraw(canvas: Locator) { +async function nextDraw(canvas: Locator, action: () => Promise) { const frame = () => canvas.evaluate( (element) => @@ -41,6 +41,7 @@ async function nextDraw(canvas: Locator) { )?.frame ?? 0, ); const previous = await frame(); + await action(); await expect.poll(frame).toBeGreaterThan(previous); } @@ -118,12 +119,12 @@ test("SYNTHETIC pointer pan, wheel zoom, pointer cancel and lost capture recover await page.mouse.down(); await page.mouse.move(start.x + 25, start.y + 10, { steps: 2 }); const pointerId = Number(await canvas.getAttribute("data-test-pointer-id")); - await canvas.dispatchEvent(eventName, { pointerId, pointerType: "mouse", bubbles: true }); - await page.mouse.up(); - await nextDraw(canvas); + await nextDraw(canvas, async () => { + await canvas.dispatchEvent(eventName, { pointerId, pointerType: "mouse", bubbles: true }); + await page.mouse.up(); + }); const cancelled = await camera(canvas); - await page.mouse.move(start.x + 90, start.y + 40, { steps: 4 }); - await nextDraw(canvas); + await nextDraw(canvas, () => page.mouse.move(start.x + 90, start.y + 40, { steps: 4 })); expect(await camera(canvas)).toEqual(cancelled); await page.mouse.down(); await page.mouse.move(start.x + 130, start.y + 40, { steps: 4 }); @@ -183,9 +184,8 @@ test("SYNTHETIC facts refresh preserves the user's camera and selected district" refreshed.collectedAt = new Date(Date.now()).toISOString(); api.respond("POST", "/api/world/synthetic-moonshot/browser-fixture", () => ({ body: refreshed })); await openWorld(page); - await page.locator(".district-row").filter({ hasText: "SYNTHETIC Web" }).click(); const canvas = page.getByTestId("world-canvas"); - await nextDraw(canvas); + await nextDraw(canvas, () => page.locator(".district-row").filter({ hasText: "SYNTHETIC Web" }).click()); await canvas.focus(); const initial = await camera(canvas); await canvas.press("ArrowRight"); @@ -194,9 +194,8 @@ test("SYNTHETIC facts refresh preserves the user's camera and selected district" await canvas.press("+"); await expect.poll(async () => (await camera(canvas)).scale).toBeGreaterThan(panned.scale); const viewpoint = await camera(canvas); - await page.getByRole("button", { name: "Refresh GitHub data" }).click(); + await nextDraw(canvas, () => page.getByRole("button", { name: "Refresh GitHub data" }).click()); await expect(page.locator(".world-source-stamp code")).toHaveText("ddddddd"); - await nextDraw(canvas); expect(await camera(canvas)).toEqual(viewpoint); await expect(page.locator(".inspector").getByRole("heading", { name: "SYNTHETIC Web", exact: true })).toBeVisible(); expect(api.requests.filter((request) => request.path.startsWith("/api/blueprint/"))).toEqual([]); diff --git a/e2e/progression-harness.ts b/e2e/progression-harness.ts new file mode 100644 index 0000000..d6dfbe0 --- /dev/null +++ b/e2e/progression-harness.ts @@ -0,0 +1,4 @@ +// Bundled only by offline Playwright; never imported by the application. +import { ProgressStore } from "../src/progression/progress-store"; +import { deriveChallenge, startAttempt, conclusionAnswers } from "../src/progression/rules"; +(globalThis as any).__progressHarness = { ProgressStore, deriveChallenge, startAttempt, conclusionAnswers }; diff --git a/e2e/progression.spec.ts b/e2e/progression.spec.ts new file mode 100644 index 0000000..481af21 --- /dev/null +++ b/e2e/progression.spec.ts @@ -0,0 +1,114 @@ +import { basePath, test, expect } from "./fixtures"; +import { syntheticWorld } from "../test/fixtures/browser-world"; +const keyA="synthetic-progress/alpha",keyB="synthetic-progress/beta"; +function capture(key=keyA,head="a".repeat(40)) {let w=syntheticWorld();const old=w.repo.url,oldHead=w.headSha;w=JSON.parse(JSON.stringify(w).replaceAll(old,`https://github.com/${key}`).replaceAll(oldHead,head));const [owner,name]=key.split("/");w.source="github";w.repo={...w.repo,owner,name,fullName:key,url:`https://github.com/${key}`,description:"SYNTHETIC captured fixture"};w.nodes[0].evidence=[`${w.repo.url}/tree/${head}/${w.nodes[0].path}`];return w;} +async function fixtures(api:any,worlds=[capture(),capture(keyB,"b".repeat(40))]) {api.respond("GET","/api/gallery",()=>({body:{projects:worlds.map(w=>({fullName:w.repo.fullName,name:w.repo.name,description:"SYNTHETIC",category:"SYNTHETIC",suggestedStyle:"moonshot",ready:true}))}}));for(const w of worlds)api.respond("GET","/api/world/"+w.repo.fullName,(r:any)=>{expect(new URL(r.request().url()).search).toBe("?mode=cached");return {body:w};});} +const challenge=(page:any)=>page.getByRole("region",{name:"Source challenge",exact:true}); +async function answer(page:any,wrong=false){const section=challenge(page);await section.getByRole("button",{name:/^(Start|Practice) source challenge$/}).click();await section.getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().check();await section.getByRole("radio",{name:wrong?"This proves the service is healthy in production.":"The map records this directory source; its role is inferred and runtime behavior is unknown.",exact:true}).check();await section.getByRole("button",{name:"Submit source answers",exact:true}).click();} +async function receipts(page:any){return page.evaluate(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open("moon-factory-exploration",1);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});return new Promise((resolve,reject)=>{const t=db.transaction("completions"),r=t.objectStore("completions").getAll();r.onsuccess=()=>{db.close();resolve(r.result);};r.onerror=()=>reject(r.error);});});} +async function harness(page:any){const result=await Bun.build({entrypoints:["./e2e/progression-harness.ts"],target:"browser",format:"iife"});if(!result.success)throw Error("Offline harness build failed");await page.addScriptTag({content:await result.outputs[0].text()});} + +test("SYNTHETIC two-repo source challenge loop: wrong answer, Saved receipt, Atlas lamp/back/focus, reload and repeat retain one slot",async({page,api})=>{ + await fixtures(api);await page.goto(basePath+"/atlas"); + for(const key of [keyA,keyB]){ + const row=page.locator(".atlas-list-rows").getByRole("button",{name:new RegExp(key)});await row.click();await expect(page.locator(".atlas-card")).toContainText("Discovered on this device");await expect(page.locator(".atlas-lantern")).toHaveCount(key===keyA?0:0); + await page.getByRole("link",{name:"Enter cached world",exact:true}).click();await expect(page.locator(".explorer-visit-status")).toContainText("No Exploration star awarded for entry"); + await answer(page,true);await expect(challenge(page).getByRole("status")).toContainText("Try again");expect((await receipts(page)).filter((r:any)=>r.completedIdentity.repoKey===key)).toHaveLength(0); + for (const name of ["Moon Factory","Factorio","Red Alert","Red Alert 2","Civilization"]) { await page.getByRole("group",{name:"World perspective"}).getByRole("button",{name:new RegExp(name+"$")}).click();await expect(challenge(page).getByRole("radio",{name:"This proves the service is healthy in production.",exact:true})).toBeChecked(); } + await challenge(page).getByRole("radio",{name:"The map records this directory source; its role is inferred and runtime behavior is unknown.",exact:true}).check();await challenge(page).getByRole("button",{name:"Submit source answers"}).click();await expect(challenge(page).getByRole("status")).toHaveText("Exploration star earned. Saved on this device."); + const first=(await receipts(page)).find((r:any)=>r.completedIdentity.repoKey===key);await page.reload();await expect(challenge(page).locator(".exploration-receipt")).toContainText("Completed on this device");await answer(page);await expect(challenge(page).getByRole("status")).toContainText("No additional Exploration star");expect((await receipts(page)).find((r:any)=>r.completedIdentity.repoKey===key)).toEqual(first); + await page.getByRole("link",{name:"Back to Atlas",exact:true}).click();await expect(page.locator(".atlas-lantern")).toContainText("Lantern lit · 1 Exploration star");await expect(page.locator(".atlas-list-rows").getByRole("button",{name:new RegExp(key)})).toBeFocused(); + } + expect(await receipts(page)).toHaveLength(2);expect(api.requests.some((r:any)=>r.method!=="GET"||r.path.includes("budget")||r.path.includes("blueprint"))).toBe(false); +}); + +test("SYNTHETIC real IndexedDB two-connection atomic completion, reset generation and changed head/rules",async({page,api})=>{ + await fixtures(api);await page.goto(basePath+"/atlas");await harness(page); + const result=await page.evaluate(async(w:any)=>{ + const {ProgressStore,deriveChallenge,startAttempt,conclusionAnswers}=(window as any).__progressHarness; + const a=new ProgressStore(indexedDB),b=new ProgressStore(indexedDB),c=await deriveChallenge(w); + const correct=(ch:any,g=0)=>({...startAttempt(ch,g),selectedEvidenceId:ch.evidence[0].id,selectedSubjectId:ch.target.subjectId,answerCodes:conclusionAnswers(ch.variant,ch.choices.conclusion[0].code)}); + const frozen=JSON.stringify(w),r=await Promise.all([a.complete(c,correct(c),c.identity),b.complete(c,correct(c),c.identity)]);const before=await a.read();let lateRejected=false;try{await a.complete(c,correct(c),c.identity,()=>false);}catch(e){lateRejected=(e as Error).message.includes("Snapshot changed");} + const other=structuredClone(w);other.headSha="d".repeat(40);other.nodes[0].evidence=[`${other.repo.url}/tree/${other.headSha}/${other.nodes[0].path}`];const v2=await deriveChallenge(other,Date.now(),2),again=await b.complete(v2,correct(v2),v2.identity); + await b.reset();let resetRejected=false;try{await a.complete(c,correct(c),c.identity);}catch(e){resetRejected=(e as Error).message.includes("reset");} + const after=await a.read();await a.complete(c,correct(c,after.generation),c.identity);const saved=await a.read();a.close();b.close(); + return {existing:r.map((x:any)=>x.existing).sort(),count:before.completions.length,first:r[0].receipt,again:again.receipt,resetRejected,empty:after.completions.length,generation:after.generation,resaved:saved.completions.length,lateRejected,unchanged:frozen===JSON.stringify(w)}; + },capture()); + expect(result.existing).toEqual([false,true]);expect(result.count).toBe(1);expect(result.first).toEqual(result.again);expect(result.resetRejected).toBe(true);expect(result.empty).toBe(0);expect(result.generation).toBe(1);expect(result.resaved).toBe(1);expect(result.unchanged).toBe(true);expect(result.lateRejected).toBe(true); +}); + +test("SYNTHETIC two tabs reconcile completion and reset without resurrecting a pending attempt",async({page,context,api})=>{ + await fixtures(api);await page.goto(`${basePath}/world/${keyA}?mode=cached`);const second=await context.newPage();await second.goto(`${basePath}/world/${keyA}?mode=cached`); + await challenge(page).getByRole("button",{name:"Start source challenge"}).click();await challenge(page).getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().check();await challenge(page).getByRole("radio",{name:/^The map records this directory source/}).check(); + await answer(second);await expect(challenge(second).getByRole("status")).toContainText("Saved on this device");await expect(challenge(page).locator(".exploration-receipt")).toContainText("1 Exploration star"); + await second.getByRole("link",{name:"Back to Atlas",exact:true}).click();await second.getByRole("button",{name:"Clear exploration progress",exact:true}).click();await second.getByRole("button",{name:"Confirm clear exploration progress",exact:true}).click(); + await expect(challenge(page).getByRole("status")).toHaveText("Progress was reset. Start again.");await expect(challenge(page).getByRole("button",{name:"Submit source answers"})).toHaveCount(0);expect(await receipts(page)).toHaveLength(0);await second.close(); +}); + +test("SYNTHETIC source unavailability/commit fallback and blocked storage remain explicit session-only",async({page,api})=>{ + const w=capture();for(const n of w.nodes)n.evidence=[];const commit=w.commits[0];commit.id="e".repeat(40);commit.url=`${w.repo.url}/commit/${commit.id}`;commit.message="Merge #123 原始信息";await fixtures(api,[w]); + await page.addInitScript(()=>Object.defineProperty(window,"indexedDB",{get:()=>undefined}));await page.goto(`${basePath}/world/${keyA}?mode=cached`); + const panel=challenge(page);await panel.getByRole("button",{name:"Start source challenge"}).click();await expect(panel).toContainText("Author timestamp");await expect(panel).toContainText("Merge #123 原始信息");await panel.getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().check();await panel.getByRole("radio",{name:"This is an author timestamp; a merged pull request is not established.",exact:true}).check();await panel.getByRole("button",{name:"Submit source answers"}).click();await expect(panel.getByRole("status")).toContainText("Session progress — not saved");await expect(panel).toContainText("Durable progress count is unknown"); + w.commits=[];await page.reload();await expect(challenge(page)).toContainText("Source challenge unavailable");await expect(challenge(page).getByRole("button",{name:"Start source challenge"})).toHaveCount(0); +}); + +test("SYNTHETIC narrow-screen keyboard challenge controls remain reachable, pause preserves source facts and local reward",async({page,api})=>{ + await page.setViewportSize({width:360,height:1000});await page.emulateMedia({reducedMotion:"no-preference"});await fixtures(api);await page.goto(`${basePath}/world/${keyA}?mode=cached`);await page.getByRole("button",{name:"Pause motion",exact:true}).click();await expect(page.getByRole("button",{name:"Resume motion"})).toHaveAttribute("aria-pressed","true");await page.waitForTimeout(150);const pixels=await page.getByTestId("world-canvas").evaluate((c:HTMLCanvasElement)=>c.toDataURL());await page.waitForTimeout(200);expect(await page.getByTestId("world-canvas").evaluate((c:HTMLCanvasElement)=>c.toDataURL())).toBe(pixels); + await page.getByRole("button",{name:"Show source challenge",exact:true}).click();const panel=challenge(page);const start=panel.getByRole("button",{name:"Start source challenge"});await start.focus();await page.keyboard.press("Enter");await panel.getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().focus();await page.keyboard.press("Space");await panel.getByRole("radio",{name:/^The map records this directory source/}).focus();await page.keyboard.press("Space");await panel.getByRole("button",{name:"Submit source answers"}).focus();await page.keyboard.press("Enter");await expect(panel.getByRole("status")).toContainText("Saved on this device"); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);expect(await panel.evaluate((e: HTMLElement)=>e.scrollWidth<=e.clientWidth+1)).toBe(true);await expect(page.getByRole("link",{name:"Back to Atlas",exact:true})).toBeVisible(); +}); + +test("SYNTHETIC real IDB caps, corrupt records, aborted save and newer schema retain data with honest session fallback",async({page,api})=>{ + await fixtures(api);await page.goto(basePath+"/atlas");await harness(page); + const result=await page.evaluate(async(w:any)=>{ + const {ProgressStore,deriveChallenge,startAttempt,conclusionAnswers}=(window as any).__progressHarness; + const c=await deriveChallenge(w),s=new ProgressStore(indexedDB),a={...startAttempt(c,0),selectedEvidenceId:c.evidence[0].id,selectedSubjectId:c.target.subjectId,answerCodes:conclusionAnswers(c.variant,c.choices.conclusion[0].code)}; + const db=await new Promise((resolve,reject)=>{const r=indexedDB.open("moon-factory-exploration",1);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);}); + const write=(run:(tx:IDBTransaction)=>void)=>new Promise((resolve,reject)=>{const t=db.transaction(["repoProgress","completions","metadata"],"readwrite");t.oncomplete=()=>resolve();t.onerror=()=>reject(t.error);run(t);}); + await write(t=>{for(let i=0;i<100;i++)t.objectStore("repoProgress").put({schemaVersion:1,repoKey:`synthetic-limit/repo${i}`,discovered:true,visited:false});}); + const cap=await s.complete(c,a,c.identity); + const count=(name:string)=>new Promise((resolve)=>{const r=db.transaction(name).objectStore(name).count();r.onsuccess=()=>resolve(r.result);});const capRepos=await count("repoProgress"),capCompletions=await count("completions"); + await write(t=>{t.objectStore("repoProgress").clear();t.objectStore("repoProgress").put({schemaVersion:1,repoKey:"synthetic-corrupt/retained",discovered:true,visited:false,extra:"x".repeat(2049)});}); + const corrupt=await s.read();let retained=0;await new Promise((resolve)=>{const r=db.transaction("repoProgress").objectStore("repoProgress").count();r.onsuccess=()=>{retained=r.result;resolve();};}); + await write(t=>t.objectStore("repoProgress").clear()); + const original=IDBObjectStore.prototype.add;IDBObjectStore.prototype.add=function(){throw new DOMException("SYNTHETIC quota failure","QuotaExceededError");}; + const aborted=await s.complete(c,a,c.identity);IDBObjectStore.prototype.add=original;const durableCount=await count("completions");const recovered=await s.complete(c,a,c.identity),recoveredCount=await count("completions"); + s.close();db.close();const higher=await new Promise((resolve,reject)=>{const r=indexedDB.open("moon-factory-exploration",2);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});higher.close(); + const unsupported=await new ProgressStore(indexedDB).read(); + return {cap:cap.receipt.persistence,capRepos,capCompletions,corrupt:corrupt.persistence,retained,aborted:aborted.receipt.persistence,durableCount,recovered:recovered.receipt.persistence,recoveredCount,recoveredOriginal:recovered.receipt.completedAt===cap.receipt.completedAt,unsupported:unsupported.persistence,unknown:unsupported.durableCountKnown}; + },capture()); + expect(result).toEqual({cap:"session",capRepos:100,capCompletions:0,corrupt:"session",retained:1,aborted:"session",durableCount:0,recovered:"device",recoveredCount:1,recoveredOriginal:true,unsupported:"session",unknown:false}); +}); + +test("F4 SYNTHETIC confirmed clear removes corrupt v1 rows atomically but rejects unknown metadata/generation",async({page,api})=>{ + await fixtures(api);await page.goto(basePath+"/atlas");await harness(page); + const r=await page.evaluate(async(w:any)=>{ + const {ProgressStore,deriveChallenge,startAttempt,conclusionAnswers}=(window as any).__progressHarness,s=new ProgressStore(indexedDB),c=await deriveChallenge(w),a={...startAttempt(c,0),selectedEvidenceId:c.evidence[0].id,selectedSubjectId:c.target.subjectId,answerCodes:conclusionAnswers(c.variant,c.choices.conclusion[0].code)}; + await s.read();const db=await new Promise((resolve,reject)=>{const r=indexedDB.open("moon-factory-exploration",1);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);}); + const write=(f:(t:IDBTransaction)=>void)=>new Promise((resolve,reject)=>{const t=db.transaction(["repoProgress","metadata"],"readwrite");t.oncomplete=()=>resolve();t.onerror=()=>reject(t.error);f(t);}); + await write(t=>t.objectStore("repoProgress").put({schemaVersion:99,repoKey:"synthetic/corrupt",discovered:true,visited:false}));const bad=await s.read(),cleared=await s.reset();let stale=false;try{await s.complete(c,a,c.identity);}catch(e){stale=(e as Error).message.includes("reset");} + await write(t=>{t.objectStore("repoProgress").put({schemaVersion:99,repoKey:"synthetic/retained"});t.objectStore("metadata").put("foreign","unknown");});let metadataRejected=false;try{await s.reset();}catch{metadataRejected=true;} + await write(t=>{t.objectStore("metadata").delete("unknown");t.objectStore("metadata").put(null,"generation");});let generationRejected=false;try{await s.reset();}catch{generationRejected=true;} + const retained=await new Promise(resolve=>{const r=db.transaction("repoProgress").objectStore("repoProgress").count();r.onsuccess=()=>resolve(r.result);});db.close();s.close();return {warning:bad.warning,empty:cleared.repos.length,generation:cleared.generation,stale,metadataRejected,generationRejected,retained}; + },capture()); + expect(r.warning).toContain("invalid or unsupported");expect(r.warning).not.toContain("capacity");expect(r).toMatchObject({empty:0,generation:1,stale:true,metadataRejected:true,generationRejected:true,retained:1}); + // The explicit UI recovery follows the same reset protocol; no database deletion. + await page.evaluate(async()=>{const db=await new Promise(resolve=>{const r=indexedDB.open("moon-factory-exploration",1);r.onsuccess=()=>resolve(r.result);});await new Promise(resolve=>{const t=db.transaction("metadata","readwrite");t.objectStore("metadata").put(1,"generation");t.oncomplete=()=>resolve();});db.close();}); + await page.getByRole("button",{name:"Reload local progress"}).click();await page.getByRole("button",{name:"Clear exploration progress",exact:true}).click();await page.getByRole("button",{name:"Confirm clear exploration progress",exact:true}).click();await expect(page.getByRole("alertdialog")).toHaveCount(0);await expect(page.locator(".atlas-progress-controls")).toContainText("0 saved Exploration stars"); +}); + +test("F6 SYNTHETIC unsaved receipt stays session-only after Back/read, recovery saves original once and reload drops only unsaved",async({page,api})=>{ + await fixtures(api);await page.addInitScript(()=>{const original=IDBObjectStore.prototype.add;let fail=true;IDBObjectStore.prototype.add=function(...args:any[]){if(this.name==="completions"&&fail){fail=false;throw new DOMException("SYNTHETIC save failure","QuotaExceededError");}return original.apply(this,args as any);};}); + await page.goto(basePath+"/atlas");await page.locator(".atlas-list-rows").getByRole("button",{name:new RegExp(keyA)}).click();await page.getByRole("link",{name:"Enter cached world"}).click();await answer(page);await expect(challenge(page).getByRole("status")).toContainText("Session progress — not saved");expect(await receipts(page)).toHaveLength(0); + await page.getByRole("link",{name:"Back to Atlas",exact:true}).click();await expect(page.locator(".atlas-progress-controls")).toContainText("0 saved Exploration stars · 1 session Exploration stars — not saved");await page.getByRole("button",{name:"Reload local progress"}).click();await expect(page.locator(".atlas-progress-controls")).toContainText("0 saved Exploration stars · 1 session"); + await page.getByRole("link",{name:"Enter cached world"}).click();await answer(page);await expect(challenge(page).getByRole("status")).toContainText("Saved on this device");const saved=await receipts(page);expect(saved).toHaveLength(1);await answer(page);expect(await receipts(page)).toEqual(saved); + await page.getByRole("link",{name:"Back to Atlas",exact:true}).click();await expect(page.locator(".atlas-progress-controls")).toContainText("1 saved Exploration stars · 0 session");await page.reload();await expect(page.locator(".atlas-progress-controls")).toContainText("1 saved Exploration stars · 0 session"); +}); + +test("F6 SYNTHETIC unsaved discovery/visit are labelled session-only and disappear after reload",async({page,api})=>{ + await fixtures(api);await page.addInitScript(()=>{const original=IDBObjectStore.prototype.put;IDBObjectStore.prototype.put=function(...args:any[]){if(this.name==="repoProgress")throw new DOMException("SYNTHETIC flag save failure","QuotaExceededError");return original.apply(this,args as any);};});await page.goto(basePath+"/atlas");await page.locator(".atlas-list-rows").getByRole("button",{name:new RegExp(keyA)}).click();await expect(page.locator(".atlas-observation")).toContainText("Discovered this session");await page.getByRole("link",{name:"Enter cached world"}).click();await expect(page.locator(".explorer-visit-status")).toContainText("Visited this session — not saved");await answer(page);await expect(challenge(page).getByRole("status")).toContainText("Session progress — not saved");await page.getByRole("link",{name:"Back to Atlas",exact:true}).click();await expect(page.locator(".atlas-progress-controls")).toContainText("0 saved Exploration stars · 1 session");await expect(page.locator(".atlas-observation")).toContainText("Visited this session");await page.reload();await expect(page.locator(".atlas-progress-controls")).toContainText("Saved Exploration star count unknown · 0 session");expect(await receipts(page)).toHaveLength(0); +}); + +test("F5 SYNTHETIC malformed cached graph is unavailable in Atlas and Explorer without an error boundary",async({page,api})=>{ + const bad=capture();(bad.nodes as any[])[1]=null;await fixtures(api,[bad]);await page.goto(basePath+"/atlas");await page.locator(".atlas-list-rows").getByRole("button",{name:new RegExp(keyA)}).click();await expect(page.locator(".atlas-status")).toHaveText("Cached facts unavailable");await expect(page.getByRole("link",{name:"Enter cached world"})).toHaveCount(0);await page.goto(`${basePath}/world/${keyA}?mode=cached`);await expect(page.getByRole("alert")).toContainText("cannot be verified");await expect(page.getByText("Signal interrupted.",{exact:true})).toHaveCount(0);await expect(page.getByTestId("world-canvas")).toHaveCount(0); +}); diff --git a/e2e/world-motion.spec.ts b/e2e/world-motion.spec.ts new file mode 100644 index 0000000..2d67c10 --- /dev/null +++ b/e2e/world-motion.spec.ts @@ -0,0 +1,174 @@ +import type { Locator, Page } from "@playwright/test"; +import type { NodeKind, WorldSnapshot } from "../src/contracts"; +import { test, expect, worldApi, worldPath } from "./fixtures"; + +const STYLES = ["moonshot", "factorio", "red-alert", "red-alert-2", "civilization"] as const; +const SHOTS = process.env.MOONSHOT_WORLD_SHOTS; + +/** SYNTHETIC larger city so every effect has something to act on. Invented data only. */ +function city(base: WorldSnapshot): WorldSnapshot { + const kinds: NodeKind[] = ["app", "service", "package", "data", "infra", "docs", "test", "tool", "external"]; + const status = ["unknown", "unknown", "active", "unknown", "healthy", "unknown", "attention", "unknown", "unknown"] as const; + const nodes = Array.from({ length: 16 }, (_, i) => ({ + ...base.nodes[0], + id: `n${i}`, + label: `SYNTHETIC ${kinds[i % kinds.length]} ${i}`, + kind: kinds[i % kinds.length], + path: `synthetic/${i}`, + status: status[i % status.length], + confidence: i % 4 === 3 ? ("inferred" as const) : ("observed" as const), + })); + const edges = nodes.slice(1).map((n, i) => ({ + id: `e${i}`, + source: nodes[Math.floor(i / 2)].id, + target: n.id, + label: "SYNTHETIC relationship", + kind: "dependency" as const, + confidence: i % 5 === 4 ? ("inferred" as const) : ("observed" as const), + })); + const commits = Array.from({ length: 10 }, (_, i) => ({ ...base.commits[0], id: `c${i}`, date: base.collectedAt })); + return { ...base, nodes, edges, commits, blueprint: { ...base.blueprint, districts: [] } }; +} +async function pixels(canvas: Locator) { + return canvas.evaluate((c: HTMLCanvasElement) => { + const g = document.createElement("canvas"); + g.width = 96; + g.height = 64; + const x = g.getContext("2d")!; + x.drawImage(c, 0, 0, 96, 64); + return Array.from(x.getImageData(0, 0, 96, 64).data); + }); +} +const difference = (a: number[], b: number[]) => a.reduce((sum, v, i) => sum + Math.abs(v - b[i]), 0) / a.length; +async function countFrames(page: Page) { + await page.addInitScript(() => { + const native = window.requestAnimationFrame.bind(window); + (window as unknown as { __rafRuns: number }).__rafRuns = 0; + window.requestAnimationFrame = (cb) => + native((t) => { + (window as unknown as { __rafRuns: number }).__rafRuns++; + cb(t); + }); + }); +} +const runs = (page: Page) => page.evaluate(() => (window as unknown as { __rafRuns: number }).__rafRuns); + +test("SYNTHETIC visible world animates: pixels change over time, all five styles render, frame cost is bounded", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + api.world = city(api.world); + api.respond("GET", worldApi, () => ({ body: api.world })); + for (const style of STYLES) { + await page.goto(`${worldPath}?style=${style}`); + const canvas = page.getByTestId("world-canvas"); + await expect(page.locator(`.world-canvas-wrap.style-${style}`)).toBeVisible(); + await expect.poll(() => canvas.getAttribute("data-frame-ms")).not.toBeNull(); + const first = await pixels(canvas); + await page.waitForTimeout(700); + const later = await pixels(canvas); + expect(difference(first, later), `${style} world should move while visible`).toBeGreaterThan(0.05); + await page.waitForTimeout(600); + const cost = Number(await canvas.getAttribute("data-frame-ms")); + expect(cost, `${style} average draw cost (ms)`).toBeLessThan(40); + test.info().annotations.push({ type: "frame-ms", description: `${style}: ${cost.toFixed(2)}ms` }); + } +}); + +test("SYNTHETIC reduced motion paints one still lit frame and schedules zero idle RAF", async ({ page, api }) => { + await countFrames(page); + api.world = city(api.world); + api.respond("GET", worldApi, () => ({ body: api.world })); + await page.goto(`${worldPath}?style=factorio`); + const canvas = page.getByTestId("world-canvas"); + await expect.poll(() => canvas.getAttribute("data-frame-ms")).not.toBeNull(); + await page.waitForTimeout(300); + const still = await pixels(canvas), + before = await runs(page); + await page.waitForTimeout(800); + expect(await runs(page)).toBe(before); + expect(difference(still, await pixels(canvas))).toBe(0); +}); + +test("SYNTHETIC hidden and offscreen worlds run zero RAF callbacks and resume without a jump", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await countFrames(page); + api.world = city(api.world); + api.respond("GET", worldApi, () => ({ body: api.world })); + await page.goto(`${worldPath}?style=red-alert-2`); + const canvas = page.getByTestId("world-canvas"); + await expect.poll(() => canvas.getAttribute("data-frame-ms")).not.toBeNull(); + await page.evaluate(() => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => true }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await page.waitForTimeout(100); + let before = await runs(page); + const frozen = await pixels(canvas); + await page.waitForTimeout(700); + expect(await runs(page)).toBe(before); + await page.evaluate(() => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => false }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await expect.poll(() => runs(page)).toBeGreaterThan(before + 2); + // No catch-up: the first resumed frames stay close to the frozen frame (700ms of hidden time skipped). + expect(difference(frozen, await pixels(canvas))).toBeLessThan(6); + await page.locator(".world-canvas-wrap").evaluate((el: HTMLElement) => (el.style.transform = "translateY(-400vh)")); + await page.waitForTimeout(150); + before = await runs(page); + await page.waitForTimeout(600); + expect(await runs(page)).toBe(before); + await page.locator(".world-canvas-wrap").evaluate((el: HTMLElement) => (el.style.transform = "")); + await expect.poll(() => runs(page)).toBeGreaterThan(before + 2); +}); + +test("SYNTHETIC animated camera glides to a selected district and still settles exactly on reset", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + api.world = city(api.world); + api.respond("GET", worldApi, () => ({ body: api.world })); + await page.goto(`${worldPath}?style=moonshot`); + const canvas = page.getByTestId("world-canvas"); + await expect.poll(() => canvas.getAttribute("data-frame-ms")).not.toBeNull(); + await page.locator(".district-row").filter({ hasText: "SYNTHETIC data 3" }).click(); + await expect(page.locator(".inspector").getByRole("heading", { name: "SYNTHETIC data 3", exact: true })).toBeVisible(); + await canvas.focus(); + await canvas.press("Escape"); + await expect(page.getByRole("button", { name: "Close district" })).toHaveCount(0); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.getByRole("button", { name: "Reset camera", exact: true }).click(); + await page.waitForTimeout(1500); + const box = (await canvas.boundingBox())!; + // Clicking a building center still selects it after camera motion (picking uses the drawn view). + await page.locator(".district-row").filter({ hasText: "SYNTHETIC app 0" }).click(); + await expect(page.locator(".inspector").getByRole("heading", { name: "SYNTHETIC app 0", exact: true })).toBeVisible(); + expect(box.width).toBeGreaterThan(0); +}); + +test("SYNTHETIC world motion screenshots (only with MOONSHOT_WORLD_SHOTS)", async ({ page, api }) => { + test.skip(!SHOTS, "Set MOONSHOT_WORLD_SHOTS= to capture review screenshots."); + test.setTimeout(240_000); + api.world = city(api.world); + api.respond("GET", worldApi, () => ({ body: api.world })); + for (const [label, viewport] of [["desktop", { width: 1440, height: 900 }], ["mobile", { width: 390, height: 844 }]] as const) { + await page.setViewportSize(viewport); + for (const style of STYLES) { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(`${worldPath}?style=${style}`); + const canvas = page.getByTestId("world-canvas"); + await expect.poll(() => canvas.getAttribute("data-frame-ms")).not.toBeNull(); + await page.waitForTimeout(250); + await page.screenshot({ path: `${SHOTS}/${label}-${style}-still.png` }); + if (label === "mobile") continue; + await page.clock.install(); + await page.emulateMedia({ reducedMotion: "no-preference" }); + for (const [name, at] of [["day", 1500], ["dusk", 6000], ["night", 10000]] as const) { + await page.clock.runFor(at); + await page.waitForTimeout(80); + await canvas.screenshot({ path: `${SHOTS}/${label}-${style}-${name}.png` }); + } + for (let i = 0; i < 4; i++) { + await page.clock.runFor(250); + await canvas.screenshot({ path: `${SHOTS}/${label}-${style}-seq${i}.png` }); + } + } + } +}); diff --git a/scripts/deployment-chronology.ts b/scripts/deployment-chronology.ts new file mode 100644 index 0000000..16e5821 --- /dev/null +++ b/scripts/deployment-chronology.ts @@ -0,0 +1,33 @@ +/** Cloudflare deployment chronology, shared semantic contract with the private + * Python operations helper. RFC3339, explicit known offset, <=9 fraction digits; + * retain nanoseconds rather than truncating precision through Date.parse. + * Operational range: 2000-01-01 onward, at most five minutes ahead of this host. + * Unsupported leap seconds/unknown -00:00 offsets fail closed. */ +export function deploymentInstant(value: unknown, nowMs = Date.now()): bigint { + if (typeof value !== "string" || !Number.isSafeInteger(nowMs)) throw new Error("Invalid deployment chronology"); + const match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,9}))?(Z|[+-][0-9]{2}:[0-9]{2})$/.exec(value); + if (!match || match[0] !== value || match[8] === "-00:00") throw new Error("Invalid deployment chronology"); + const [year, month, day, hour, minute, second] = match.slice(1,7).map(Number); + const local = Date.UTC(year,month-1,day,hour,minute,second), date = new Date(local); + if (year < 2000 || date.getUTCFullYear() !== year || date.getUTCMonth() !== month-1 || date.getUTCDate() !== day || date.getUTCHours() !== hour || date.getUTCMinutes() !== minute || date.getUTCSeconds() !== second) throw new Error("Invalid deployment chronology"); + const zone = match[8], offsetHours = zone === "Z" ? 0 : Number(zone.slice(1,3)), offsetMinutes = zone === "Z" ? 0 : Number(zone.slice(4,6)); + if (offsetHours > 14 || offsetMinutes > 59 || offsetHours === 14 && offsetMinutes !== 0) throw new Error("Invalid deployment timezone"); + const offset = (offsetHours * 60 + offsetMinutes) * (zone[0] === "-" ? -1 : 1); + const instant = BigInt(local - offset * 60_000) * 1_000_000n + BigInt((match[7] || "").padEnd(9,"0")); + if (instant < 946684800000000000n || instant > BigInt(Math.trunc(nowMs) + 300_000) * 1_000_000n) throw new Error("Unreasonable deployment chronology"); + return instant; +} +const uuid = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value); +export function selectCurrentWorkerDeployment(value: any, nowMs = Date.now()) { + if (!value || !Array.isArray(value.deployments) || !value.deployments.length || value.deployments.length > 1000) throw new Error("Current Worker deployment unavailable"); + const instants = new Set(), ids = new Set(); + const rows = value.deployments.map((row: any) => { + const instant = deploymentInstant(row?.created_on,nowMs); + if (!uuid(row?.id) || ids.has(row.id) || instants.has(instant)) throw new Error("Ambiguous or conflicting deployment chronology"); + ids.add(row.id); instants.add(instant); return { row, instant }; + }); + rows.sort((a: any,b: any) => a.instant > b.instant ? -1 : 1); + const active = rows[0].row; + if (!Array.isArray(active.versions) || active.versions.length !== 1 || active.versions[0]?.percentage !== 100 || !uuid(active.versions[0].version_id)) throw new Error("Worker must have a single known 100% version"); + return { deploymentId: active.id as string, versionId: active.versions[0].version_id as string }; +} diff --git a/scripts/live-worker-authority.ts b/scripts/live-worker-authority.ts index 4044934..77ea367 100644 --- a/scripts/live-worker-authority.ts +++ b/scripts/live-worker-authority.ts @@ -1,18 +1,9 @@ import { assertIdentity, stableJson, type StorageIdentity } from "../deploy/migration-contract"; import { requireWorkerBindingAuthority } from "./worker-binding-authority"; +import { selectCurrentWorkerDeployment } from "./deployment-chronology"; export interface WorkerDeploymentIdentity { deploymentId: string; versionId: string; moduleSha256: string } export interface WorkerManagementConfig { cloudflareAccountId: string; workerAuthority: WorkerDeploymentIdentity; storageIdentity: StorageIdentity } const uuid = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value); -function currentDeployment(value: any) { - if (!value || !Array.isArray(value.deployments) || !value.deployments.length) throw new Error("Current Worker deployment unavailable"); - const rows = [...value.deployments]; - if (rows.some((row) => !Number.isFinite(Date.parse(row?.created_on)))) throw new Error("Worker deployment chronology unknown"); - rows.sort((a, b) => Date.parse(b.created_on) - Date.parse(a.created_on)); - if (rows.length > 1 && rows[0].created_on === rows[1].created_on && rows[0].id !== rows[1].id) throw new Error("Ambiguous current Worker deployment"); - const active = rows[0]; - if (!uuid(active.id) || !Array.isArray(active.versions) || active.versions.length !== 1 || active.versions[0]?.percentage !== 100 || !uuid(active.versions[0].version_id)) throw new Error("Worker must have a single known 100% version"); - return { deploymentId: active.id, versionId: active.versions[0].version_id }; -} async function moduleHash(response: Response) { const type = response.headers.get("content-type") || "", body = Buffer.from(await response.arrayBuffer()); if (/^(application|text)\/javascript(?:\+module)?(?:\s*;|$)/i.test(type)) return new Bun.CryptoHasher("sha256").update(body).digest("hex"); @@ -48,12 +39,12 @@ export async function verifyLiveWorkerAuthority(config: WorkerManagementConfig, return response; }; const json = async (suffix: string) => { const body = await (await read(suffix)).json() as any; if (body?.success !== true) throw new Error("Worker management evidence rejected"); return body.result; }; - const first = currentDeployment(await json("/deployments")); + const first = selectCurrentWorkerDeployment(await json("/deployments")); if (first.deploymentId !== config.workerAuthority.deploymentId || first.versionId !== config.workerAuthority.versionId) throw new Error("Worker deployment/version changed"); const bindings = requireWorkerBindingAuthority(await json("/settings"), config.storageIdentity); const moduleSha256 = await moduleHash(await read("")); if (moduleSha256 !== config.workerAuthority.moduleSha256) throw new Error("Actual Worker module changed"); - const last = currentDeployment(await json("/deployments")); + const last = selectCurrentWorkerDeployment(await json("/deployments")); const lastBindings = requireWorkerBindingAuthority(await json("/settings"), config.storageIdentity); if (stableJson(first) !== stableJson(last) || stableJson(bindings) !== stableJson(lastBindings)) throw new Error("Worker changed during authority observation"); return { ...first, moduleSha256, bindings }; diff --git a/src/atlas/AtlasCanvas.tsx b/src/atlas/AtlasCanvas.tsx new file mode 100644 index 0000000..42a4108 --- /dev/null +++ b/src/atlas/AtlasCanvas.tsx @@ -0,0 +1,243 @@ +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { constrain, fit, pan, resizeCamera, wheelFactor, zoomAt, type Camera, type Point, type Size } from "./camera"; +import { homeBounds, type CacheStatus, type Landmark } from "./model"; +import { contains, paintAtlas, type HitTarget } from "./paint"; +interface Props { + landmarks: Landmark[]; + statuses: Map; + selected: string | null; + lit?: ReadonlySet; + initialView?: { + centerX: number; + centerY: number; + zoomRatio: number; + }; + locateRequest?: { + key: string; + revision: number; + } | null; + onChoose: (keys: string[], origin: "canvas") => void; + onClear: () => void; + onView: (camera: Camera, homeZoom: number, commit: boolean) => void; +} +export function AtlasCanvas(props: Props) { + const canvas = useRef(null), wrap = useRef(null), data = useRef(props); + data.current = props; + const view = useRef<{ + camera: Camera; + size: Size; + initialized: boolean; + }>({ camera: { centerX: 1400, centerY: 1000, zoom: .2 }, size: { width: 1, height: 1 }, initialized: false }); + const targets = useRef([]), invalidate = useRef(() => { }), commitView = useRef<(c: Camera, commit?: boolean) => void>(() => { }), cancelGesture = useRef<(silent?: boolean) => void>(() => { }); + const pointers = useRef(new Map()), gesture = useRef<{ + mode: "drag" | "pinch"; + camera: Camera; + point: Point; + distance: number; + moved: boolean; + at: number; + touch: boolean; + } | null>(null), suppressTap = useRef(false); + const [unavailable, setUnavailable] = useState(false), [tooltip, setTooltip] = useState<{ + text: string; + x: number; + y: number; + key: string; + } | null>(null); + const hoverTimer = useRef>(), hoverKey = useRef(null); + const clearHover = () => { if (hoverTimer.current) + clearTimeout(hoverTimer.current); hoverTimer.current = undefined; hoverKey.current = null; setTooltip(null); invalidate.current(); }; + const change = (c: Camera, commit = false) => commitView.current(c, commit); + const home = () => change(fit(homeBounds(data.current.landmarks), view.current.size), true); + const locate = (key: string | null) => { const p = data.current.landmarks.find(x => x.repoKey === key); if (p) + change({ ...view.current.camera, centerX: p.x, centerY: p.y }, true); }; + useEffect(() => { + const cv = canvas.current, el = wrap.current; + if (!cv || !el) + return; + let raf = 0, visible = true, disposed = false, lastPaint = -Infinity; + const context = cv.getContext("2d"); + if (!context) { + setUnavailable(true); + return; + } + const eligible = () => !disposed && visible && !document.hidden && el.clientWidth > 0 && el.clientHeight > 0; + const request = () => { if (!raf && eligible()) + raf = requestAnimationFrame(draw); }; + const draw = (now: number) => { + raf = 0; + if (!eligible()) + return; + if (now - lastPaint < 1000 / 30) { + request(); + return; + } + lastPaint = now; + const { size, camera } = view.current, ratio = cv.width / size.width; + context.setTransform(ratio, 0, 0, ratio, 0, 0); + targets.current = paintAtlas(context, data.current.landmarks, camera, size, data.current.statuses, data.current.selected, hoverKey.current, data.current.lit); + }; + invalidate.current = request; + commitView.current = (camera, commit = false) => { view.current.camera = constrain(camera, view.current.size); data.current.onView(view.current.camera, fit(homeBounds(data.current.landmarks), view.current.size).zoom, commit); request(); }; + const resize = () => { + const rect = el.getBoundingClientRect(), size = { width: rect.width, height: rect.height }; + if (size.width < 1 || size.height < 1) + return; + const old = view.current, home = homeBounds(data.current.landmarks), homeCamera = fit(home, size), saved = data.current.initialView; + const camera = old.initialized ? resizeCamera(old.camera, old.size, size, home) : saved ? constrain({ centerX: saved.centerX, centerY: saved.centerY, zoom: homeCamera.zoom * saved.zoomRatio }, size) : constrain(homeCamera, size); + const pixels = size.width < 600 ? 1500000 : 4000000, ratio = Math.min(1.5, window.devicePixelRatio || 1, Math.sqrt(pixels / (size.width * size.height))); + cv.width = Math.max(1, Math.floor(size.width * ratio)); + cv.height = Math.max(1, Math.floor(size.height * ratio)); + view.current = { camera, size, initialized: true }; + data.current.onView(camera, homeCamera.zoom, false); + request(); + }; + const observer = new ResizeObserver(resize); + observer.observe(el); + resize(); + const stop = () => { + if (raf) cancelAnimationFrame(raf); + raf = 0; + cancelGesture.current(disposed); + }; + const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; if (visible) + request(); + else + stop(); }); + intersection.observe(el); + const visibility = () => { if (document.hidden) + stop(); + else + request(); }; + document.addEventListener("visibilitychange", visibility); + const blur = () => cancelGesture.current(); + window.addEventListener("blur", blur); + const wheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) + return; e.preventDefault(); clearHover(); const r = cv.getBoundingClientRect(); change(zoomAt(view.current.camera, wheelFactor(e.deltaY, e.deltaMode, r.height), { x: e.clientX - r.left, y: e.clientY - r.top }, view.current.size), true); }; + cv.addEventListener("wheel", wheel, { passive: false }); + return () => { disposed = true; stop(); observer.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", visibility); window.removeEventListener("blur", blur); cv.removeEventListener("wheel", wheel); if (hoverTimer.current) + clearTimeout(hoverTimer.current); cv.width = 0; cv.height = 0; invalidate.current = () => { }; commitView.current = () => { }; }; + }, []); + useEffect(() => { invalidate.current(); }, [props.landmarks, props.statuses, props.selected, props.lit]); + useEffect(() => { if (props.locateRequest) + locate(props.locateRequest.key); }, [props.locateRequest, props.landmarks]); + const point = (e: ReactPointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; + const hits = (p: Point) => targets.current.filter(t => t.rects.some(r => contains(r, p.x, p.y))); + const begin = (e: ReactPointerEvent) => { + if (e.button !== 0 || pointers.current.size >= 2) + return; + clearHover(); + const p = point(e); + pointers.current.set(e.pointerId, p); + e.currentTarget.setPointerCapture(e.pointerId); + const all = [...pointers.current.values()]; + if (all.length === 2) { + suppressTap.current = true; + gesture.current = { mode: "pinch", camera: { ...view.current.camera }, point: { x: (all[0].x + all[1].x) / 2, y: (all[0].y + all[1].y) / 2 }, distance: Math.max(1, Math.hypot(all[0].x - all[1].x, all[0].y - all[1].y)), moved: true, at: performance.now(), touch: true }; + } + else + gesture.current = { mode: "drag", camera: { ...view.current.camera }, point: p, distance: 1, moved: false, at: performance.now(), touch: e.pointerType !== "mouse" }; + }; + const move = (e: ReactPointerEvent) => { + const p = point(e), g = gesture.current; + if (!pointers.current.has(e.pointerId) || !g) { + if (e.pointerType === "mouse" && !props.selected) { + if (hoverTimer.current) + clearTimeout(hoverTimer.current); + const hit = hits(p); + if (hit.length === 1 && hit[0].keys.length === 1) { + const key = hit[0].keys[0], landmark = props.landmarks.find(x => x.repoKey === key); + hoverTimer.current = setTimeout(() => { hoverKey.current = key; setTooltip({ key, text: landmark?.fullName ?? key, x: Math.max(8, Math.min(p.x + 12, view.current.size.width - 288)), y: Math.max(8, Math.min(p.y - 48, view.current.size.height - 60)) }); invalidate.current(); }, 120); + } + else + clearHover(); + } + return; + } + pointers.current.set(e.pointerId, p); + if (g.mode === "pinch" && pointers.current.size === 2) { + const [a, b] = [...pointers.current.values()], mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const zoomed = zoomAt(g.camera, Math.hypot(a.x - b.x, a.y - b.y) / g.distance, g.point, view.current.size); + change(pan(zoomed, mid.x - g.point.x, mid.y - g.point.y, view.current.size)); + } + else { + const dx = p.x - g.point.x, dy = p.y - g.point.y; + if (Math.hypot(dx, dy) > (g.touch ? 10 : 6)) + g.moved = true; + if (g.moved) + change(pan(g.camera, dx, dy, view.current.size)); + } + }; + const end = (e: ReactPointerEvent) => { + if (!pointers.current.has(e.pointerId)) + return; + const g = gesture.current; + if (g?.mode === "drag" && !g.moved && !suppressTap.current && performance.now() - g.at <= 500) { + const matched = hits(point(e)); + const keys = [...new Set(matched.flatMap(t => t.keys))]; + if (keys.length) + props.onChoose(keys, "canvas"); + } + pointers.current.delete(e.pointerId); + if (e.currentTarget.hasPointerCapture(e.pointerId)) + e.currentTarget.releasePointerCapture(e.pointerId); + const remaining = [...pointers.current.values()]; + gesture.current = remaining.length ? { mode: "drag", camera: { ...view.current.camera }, point: remaining[0], distance: 1, moved: false, at: performance.now(), touch: true } : null; + if (!remaining.length) + suppressTap.current = false; + change(view.current.camera, true); + }; + cancelGesture.current = (silent = false) => { + const cv = canvas.current, ids = [...pointers.current.keys()]; + pointers.current.clear(); + gesture.current = null; + suppressTap.current = false; + for (const id of ids) if (cv?.hasPointerCapture(id)) cv.releasePointerCapture(id); + if (hoverTimer.current) clearTimeout(hoverTimer.current); + hoverKey.current = null; + if (!silent) setTooltip(null); + }; + return
+ {unavailable ?

Map unavailable. Use the repository list and source card.

: cancelGesture.current()} onLostPointerCapture={e => { if (pointers.current.has(e.pointerId)) + cancelGesture.current(); }} onPointerLeave={() => { if (!pointers.current.size) + clearHover(); }} onKeyDown={e => { + if (e.ctrlKey || e.metaKey || e.altKey) + return; + const c = view.current.camera, s = view.current.size, step = e.shiftKey ? 128 : 64; + const deltas: Record = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; + if (deltas[e.key]) { + e.preventDefault(); + change(pan(c, ...deltas[e.key], s), true); + } + else if (["+", "=", "-"].includes(e.key)) { + e.preventDefault(); + change(zoomAt(c, e.key === "-" ? 1 / 1.2 : 1.2, { x: s.width / 2, y: s.height / 2 }, s), true); + } + else if (e.key === "0") { + e.preventDefault(); + home(); + } + else if (e.key.toLowerCase() === "f") { + e.preventDefault(); + locate(props.selected); + } + else if (e.key === "Escape") { + if (tooltip) + clearHover(); + else + props.onClear(); + } + }}/>} + {tooltip && !props.selected &&
{tooltip.text}
} +
+ + + + +
+

Scenic routes, not dependency links · Static scenery

+
; +} diff --git a/src/atlas/AtlasPage.tsx b/src/atlas/AtlasPage.tsx new file mode 100644 index 0000000..2506773 --- /dev/null +++ b/src/atlas/AtlasPage.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import type { FeaturedProject } from "../contracts"; +import { getCachedWorld, getGallery } from "../client/api"; +import { Brand, External } from "../components/shared"; +import { factsFromWorld, layoutLandmarks, statusText, type CacheStatus } from "./model"; +import { AtlasCanvas } from "./AtlasCanvas"; +import { readAtlasSession, saveAtlasSession, type AtlasSession } from "./session"; +import { selectionLoader } from "./loader"; +import type { Camera } from "./camera"; +import { explorationFlags, noteExploration, subscribeExploration, useProgress, clearExploration, explorationCompletion, reconcileProgress } from "../progression/session"; +import { flagSaved, progressCounts } from "../progression/progress-store"; +import "./atlas.css"; +export function AtlasPage() { + const progress = useProgress(), [resetConfirm,setResetConfirm] = useState(false), [resetError,setResetError] = useState(""); + const counts = progressCounts(progress); + const lit = useMemo(() => new Set(progress.completions.map(r=>r.completedIdentity.repoKey)),[progress]); + const [catalog, setCatalog] = useState(null), [catalogError, setCatalogError] = useState(null), [selected, setSelected] = useState(null), [statuses, setStatuses] = useState(new Map()), [imports, setImports] = useState([]), [query, setQuery] = useState(""), [page, setPage] = useState(0), [pane, setPane] = useState<"map" | "list">("map"), [chooser, setChooser] = useState([]), [locateRequest, setLocateRequest] = useState<{ + key: string; + revision: number; + } | null>(null), [positionSaved, setPositionSaved] = useState(true), [initial, setInitial] = useState(null), [progressRevision, setProgressRevision] = useState(0), [focusedKey, setFocusedKey] = useState(null), [inspectInput, setInspectInput] = useState(""), [inspectError, setInspectError] = useState(null); + const session = useRef(null), view = useRef({ centerX: 1400, centerY: 1000, zoomRatio: 1 }), selectedRef = useRef(null), origin = useRef<"list" | "canvas">("list"), importRef = useRef([]), statusRef = useRef(statuses), list = useRef(null), pendingRestore = useRef(false), mounted = useRef(true); + const loader = useMemo(() => selectionLoader((key: string) => { const [owner, repo] = key.split("/"); return getCachedWorld(owner, repo); }), []); + const updateStatus = useCallback((key: string, value: CacheStatus) => { const next = new Map(statusRef.current); next.set(key, value); statusRef.current = next; setStatuses(next); }, []); + const save = useCallback((token?: string) => { + const record: AtlasSession = { layoutVersion: 1, ...view.current, selectedRepoKey: selectedRef.current, focusOrigin: origin.current, listScroll: list.current?.scrollTop ?? session.current?.listScroll ?? 0, importedRepoKeys: importRef.current, returnToken: token ?? session.current?.returnToken ?? null }; + session.current = record; + setPositionSaved(saveAtlasSession(record)); + }, []); + const loadFacts = useCallback((key: string) => { + const old = statusRef.current.get(key), lastReceived = old?.state === "cached" ? old.facts : old?.lastReceived; + updateStatus(key, { state: "loading", lastReceived }); + loader.select(key, w => { + const facts = factsFromWorld(w, key); + if (!facts) { + updateStatus(key, { state: "unavailable", lastReceived }); + return; + } + updateStatus(key, { state: "cached", facts }); + }, error => updateStatus(key, (error as { + code?: string; + })?.code === "CACHE_MISS" ? { state: "miss", lastReceived } : { state: "unavailable", lastReceived })); + }, [loader, updateStatus]); + const cancelRead = useCallback(() => { + loader.cancel(); + const key = selectedRef.current, state = key ? statusRef.current.get(key) : undefined; + if (key && state?.state === "loading") updateStatus(key, { state: "unchecked", lastReceived: state.lastReceived }); + }, [loader, updateStatus]); + const choose = useCallback((keys: string[], via: "list" | "canvas") => { + if (keys.length !== 1) { + cancelRead(); + setChooser(keys); + return; + } + const key = keys[0]; + const previous = selectedRef.current, prior = previous ? statusRef.current.get(previous) : undefined; + if (previous && previous !== key && prior?.state === "loading") + updateStatus(previous, { state: "unchecked", lastReceived: prior.lastReceived }); + selectedRef.current = key; + origin.current = via; + setSelected(key); + setChooser([]); + setInspectError(null); + if (via === "list") + setLocateRequest(x => ({ key, revision: (x?.revision ?? 0) + 1 })); + if (statusRef.current.get(key)?.state !== "cached") + loadFacts(key); + else + loader.cancel(); + }, [loader, loadFacts, updateStatus, cancelRead]); + useEffect(() => { + mounted.current = true; + const saved = readAtlasSession(); + setPositionSaved(saved.saved); + session.current = saved.value; + const params = new URLSearchParams(window.location.search), returnToken = params.get("atlasReturn"), canRestore = saved.value && params.get("home") !== "1" && (!params.has("atlasReturn") || params.getAll("atlasReturn").length === 1 && returnToken === saved.value.returnToken); + const restorable = canRestore ? saved.value : null; + if (saved.value) { + importRef.current = saved.value.importedRepoKeys; + setImports(importRef.current); + } + if (restorable) { + view.current = { centerX: restorable.centerX, centerY: restorable.centerY, zoomRatio: restorable.zoomRatio }; + selectedRef.current = restorable.selectedRepoKey; + origin.current = restorable.focusOrigin; + setInitial(restorable); + setSelected(restorable.selectedRepoKey); + if (window.innerWidth < 600 && restorable.focusOrigin === "list") + setPane("list"); + pendingRestore.current = true; + } + void getGallery().then(({ projects }) => { if (mounted.current) + setCatalog(projects.slice(0, 2000)); }).catch(() => { if (mounted.current) { + setCatalog([]); + setCatalogError("Catalog unavailable. Cached status remains unknown."); + } }); + const onPageHide = () => save(); + window.addEventListener("pagehide", onPageHide); + const unsubscribe = subscribeExploration(() => setProgressRevision(x => x + 1)); + return () => { mounted.current = false; loader.cancel(); window.removeEventListener("pagehide", onPageHide); unsubscribe(); }; + }, [loader, save]); + const landmarks = useMemo(() => layoutLandmarks(catalog ?? [], imports), [catalog, imports]); + useEffect(() => { if (catalog && pendingRestore.current) { + pendingRestore.current = false; + if (selectedRef.current) { + loadFacts(selectedRef.current); + const index = landmarks.findIndex(x => x.repoKey === selectedRef.current); + if (index >= 0) + setPage(Math.floor(index / 50)); + } + requestAnimationFrame(() => { if (list.current && session.current) + list.current.scrollTop = session.current.listScroll; const key = selectedRef.current; if (key) { + const target = window.innerWidth < 600 && origin.current === "canvas" ? document.querySelector(".atlas-map canvas") : document.getElementById("atlas-repo-" + key); + (target ?? document.getElementById("atlas-filter"))?.focus({ preventScroll: true }); + } }); + } }, [catalog, landmarks, loadFacts]); + const current = selected ? statuses.get(selected) : undefined, facts = current?.state === "cached" ? current.facts : undefined, lastReceived = current && current.state !== "cached" ? current.lastReceived : undefined; + useEffect(() => { if (chooser.length === 0 && facts && selected === facts.identity.repoKey) + noteExploration(facts.identity, "discovered"); }, [facts, selected, chooser.length]); + const selectedLandmark = landmarks.find(x => x.repoKey === selected), filtered = landmarks.filter(x => (x.fullName + " " + x.title + " " + x.category).toLowerCase().includes(query.toLowerCase())), rows = filtered.slice(page * 50, page * 50 + 50), enterToken = useMemo(() => selected ? crypto.randomUUID() : null, [selected]); + const completion = selected ? explorationCompletion(selected) : null; + const flags = selected ? explorationFlags(selected) : { discovered: false, visited: false }; + void progressRevision; + const inspect = () => { + try { + const url = new URL(inspectInput.trim()); + const parts = url.pathname.replace(/\/$/, "").split("/").slice(1); + if (url.protocol !== "https:" || url.hostname !== "github.com" || url.port || url.username || url.password || url.search || url.hash || parts.length !== 2 || !parts.every(p => /^[a-z0-9_.-]+$/i.test(p))) + throw Error(); + const key = parts.join("/").toLowerCase(); + if (!landmarks.some(p => p.repoKey === key) && imports.length >= 100) { + setInspectError("The 100-repository survey capacity is full. Existing positions are retained."); + return; + } + choose([key], "list"); + } + catch { + setInspectError("Enter a public HTTPS GitHub repository URL. This action only checks existing cached facts."); + } + }; + useEffect(() => { if (facts && selected && !landmarks.some(p => p.repoKey === selected) && importRef.current.length < 100) { + importRef.current = [...importRef.current, selected]; + setImports(importRef.current); + save(); + } }, [facts, selected, landmarks, save]); + return
+

Repository Atlas

Mission Control
+
{progress.durableCountKnown ? `${counts.saved} saved Exploration stars` : "Saved Exploration star count unknown"} · {counts.session} session Exploration stars — not saved{resetConfirm &&

Clear only this browser’s exploration flags and stars? Atlas positions, public source caches and server budgets are preserved.

}{resetError &&

{resetError}

}{progress.warning &&

{progress.warning}

}
+

Choose a landmark, inspect its captured identity, then enter its world. This atlas shows its catalog and your cached survey entries.

{catalog === null ? "Loading catalog" : `${landmarks.length} listed landmarks · ${[...statuses.values()].filter(s => s.state === "cached").length} captures checked this visit`} · No full GitHub map or complete history is claimed.

+ {catalogError &&

{catalogError}

} + {!positionSaved &&

View position is temporary. Browser session storage is unavailable; a reload may lose it.

} +
+
+
{ setQuery(e.target.value); setPage(0); }} placeholder="Repository or editorial region"/> +
{ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) + return; const buttons = Array.from(e.currentTarget.querySelectorAll("button")), index = buttons.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) + return; e.preventDefault(); const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, index + (e.key === "ArrowDown" ? 1 : -1))); buttons[next]?.focus(); }}> + {rows.map((p, i) => )} + {catalog !== null && rows.length === 0 &&

No matching landmarks. Unlisted public repositories may already have cached worlds.

} +
+ {filtered.length > 50 &&
{page + 1} / {Math.ceil(filtered.length / 50)}
} +
{ e.preventDefault(); inspect(); }}> setInspectInput(e.target.value)} placeholder="https://github.com/owner/repo" required/>No collection, GitHub or model request.{inspectError &&

{inspectError}

}
+
+ {catalog !== null && !(initial?.selectedRepoKey === selected && selected && (!current || current.state === "loading")) ? { cancelRead(); setChooser([]); selectedRef.current = null; setSelected(null); save(); }} onView={(camera: Camera, homeZoom: number, commit: boolean) => { view.current = { centerX: camera.centerX, centerY: camera.centerY, zoomRatio: camera.zoom / homeZoom }; if (commit) + save(); }}/> :

Loading the bounded catalog…

} + +
+
Public cached source · Read only · Original scenery · Local exploration is separate from GitHub stars and contributions
+
; +} diff --git a/src/atlas/atlas.css b/src/atlas/atlas.css new file mode 100644 index 0000000..25b2244 --- /dev/null +++ b/src/atlas/atlas.css @@ -0,0 +1,71 @@ +.atlas-shell { height:100dvh; min-height:600px; display:flex; flex-direction:column; color:var(--geist-fg); background:var(--geist-bg); font-size:14px; line-height:1.6; } +.atlas-header { display:flex; flex-wrap:wrap; gap:16px; align-items:center; min-height:64px; padding:10px 24px; background:var(--geist-surface); border-bottom:1px solid var(--geist-border); } +.atlas-header .brand { min-height:44px; } +.atlas-header h1 { font:500 24px/1.3 var(--heading); } +.atlas-header > a:last-child { margin-left:auto; color:var(--geist-interactive); text-decoration:underline; text-underline-offset:3px; min-height:44px; display:flex; align-items:center; } +.atlas-intro { display:flex; flex-wrap:wrap; justify-content:space-between; gap:4px 24px; padding:10px 24px; } +.atlas-intro p { color:var(--geist-secondary); max-width:750px; } +.atlas-intro p+p { font-size:12px; } +.atlas-warning { padding:8px 24px; color:var(--geist-warning-fg); background:var(--geist-warning-bg); overflow-wrap:anywhere; } +.atlas-body { display:grid; grid-template-columns:260px minmax(0,1fr) 320px; flex:1; min-height:0; } +.atlas-list { min-width:0; min-height:0; display:flex; flex-direction:column; background:var(--geist-surface); padding:16px; border-right:1px solid var(--geist-border); gap:10px; } +.atlas-list > label { font-size:12px; color:var(--geist-secondary); } +.atlas-shell input { width:100%; min-width:0; border:1px solid var(--geist-control-border); border-radius:4px; padding:10px; background:var(--geist-surface); color:var(--geist-fg); font-size:14px; } +.atlas-shell input::placeholder { color:var(--geist-muted); opacity:1; } +.atlas-list-rows { flex:1; overflow:auto; min-height:80px; scrollbar-width:thin; } +.atlas-list-rows > button { display:block; text-align:left; width:100%; min-height:70px; padding:12px 10px; margin-bottom:8px; border:1px solid var(--geist-control-border); border-radius:4px; } +.atlas-list-rows > button:hover { background:var(--geist-hover); } +.atlas-list-rows > button[aria-pressed=true] { background:var(--geist-selected); box-shadow:inset 3px 0 var(--geist-fg); } +.atlas-list-rows strong { display:block; font-size:13px; overflow-wrap:anywhere; } +.atlas-list-rows span,.atlas-list-rows small { display:block; color:var(--geist-secondary); font-size:12px; line-height:1.5; overflow-wrap:anywhere; } +.atlas-list-rows small { margin-top:4px; color:var(--geist-muted); } +.atlas-inspect { border-top:1px solid var(--geist-border); padding-top:12px; display:flex; flex-direction:column; gap:8px; } +.atlas-inspect label,.atlas-inspect small { font-size:12px; color:var(--geist-secondary); } +.atlas-inspect input { font-size:12px; } +.atlas-inspect button,.atlas-pagination button,.atlas-tabs button,.atlas-card > button { min-height:44px; padding:8px 12px; border:1px solid var(--geist-control-border); border-radius:4px; background:var(--geist-surface); } +.atlas-inspect button:hover,.atlas-card > button:hover { background:var(--geist-hover); } +.atlas-inspect [role=alert] { color:var(--geist-danger-fg); background:var(--geist-danger-bg); padding:8px; overflow-wrap:anywhere; } +.atlas-pagination { display:flex; gap:8px; align-items:center; justify-content:space-between; font-size:12px; } +.atlas-map { min-width:0; min-height:180px; position:relative; overflow:hidden; background:var(--geist-map-bg); } +.atlas-map > canvas { display:block; position:absolute; inset:0; width:100%; height:100%; touch-action:none; outline-offset:-4px; } +.atlas-map > canvas:focus-visible { outline:2px solid var(--geist-map-focus); } +.atlas-map-unavailable { color:var(--geist-map-fg); background:var(--geist-map-surface); position:absolute; top:16px; left:16px; right:80px; padding:12px; } +.atlas-camera { position:absolute; right:12px; top:12px; display:flex; flex-direction:column; gap:8px; } +.atlas-camera button { width:44px; height:44px; border:1px solid var(--geist-map-border); color:var(--geist-map-fg); background:var(--geist-map-surface); border-radius:4px; font-size:22px; } +.atlas-camera button:hover { background:var(--geist-primary-hover); } +.atlas-camera button:focus-visible { outline:2px solid var(--geist-map-focus); outline-offset:-3px; } +.atlas-camera button:disabled { background:var(--geist-map-surface); color:var(--geist-map-muted); opacity:1; } +.atlas-map-note { position:absolute; bottom:10px; left:12px; right:12px; width:max-content; max-width:calc(100% - 24px); color:var(--geist-map-fg); background:var(--geist-map-surface); font-size:12px; line-height:1.5; padding:4px 8px; pointer-events:none; } +.atlas-tooltip { position:absolute; max-width:280px; padding:8px 12px; font-size:13px; line-height:1.5; color:var(--geist-map-fg); background:var(--geist-map-surface); border:1px solid var(--geist-map-border); border-radius:4px; overflow-wrap:anywhere; pointer-events:none; } +.atlas-card { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; background:var(--geist-surface); padding:20px; border-left:1px solid var(--geist-border); } +.atlas-card-content { min-height:0; flex:1; overflow:auto; scrollbar-width:thin; } +.atlas-actions { display:flex; gap:8px; flex-shrink:0; padding-top:12px; border-top:1px solid var(--geist-border); } +.atlas-actions > * { flex:1; } +.atlas-actions button { min-height:44px; padding:8px; border:1px solid var(--geist-control-border); border-radius:4px; background:var(--geist-surface); } +.atlas-card h2 { font:500 22px/1.3 var(--heading); overflow-wrap:anywhere; margin:8px 0 16px; } +.atlas-card h2 > span { display:block; } +.atlas-card p { color:var(--geist-secondary); margin-bottom:14px; overflow-wrap:anywhere; } +.atlas-card-top { display:flex; align-items:center; justify-content:space-between; gap:8px; font:12px/1.5 var(--mono); color:var(--geist-secondary); } +.atlas-card-top button { min-width:44px; min-height:44px; border:1px solid var(--geist-control-border); font-size:24px; border-radius:4px; } +.atlas-card .atlas-status { color:var(--geist-info-fg); background:var(--geist-info-bg); padding:8px; font-size:12px; } +.atlas-card .atlas-status[data-state="miss"],.atlas-card .atlas-status[data-state="unchecked"] { color:var(--geist-unknown-fg); background:var(--geist-unknown-bg); } +.atlas-card .atlas-status[data-state="unavailable"] { color:var(--geist-danger-fg); background:var(--geist-danger-bg); } +.atlas-card .atlas-status[data-state="cached"][data-freshness="stale"] { color:var(--geist-warning-fg); background:var(--geist-warning-bg); } +.atlas-last { font-size:12px; color:var(--geist-secondary); overflow-wrap:anywhere; } +.atlas-card dl { border-block:1px solid var(--geist-border); padding-block:12px; margin-bottom:16px; font-size:12px; } +.atlas-card dt { color:var(--geist-muted); margin-top:8px; } +.atlas-card dd { margin:0 0 12px; overflow-wrap:anywhere; color:var(--geist-fg); } +.atlas-card a:not(.atlas-enter) { display:inline-flex; align-items:center; min-height:44px; } +.atlas-card a { color:var(--geist-interactive); text-decoration:underline; text-underline-offset:3px; overflow-wrap:anywhere; } +.atlas-card .atlas-enter { display:flex; justify-content:center; align-items:center; gap:8px; color:var(--geist-primary-fg); background:var(--geist-primary-bg); min-height:44px; padding:12px; border-radius:4px; text-decoration:none; margin-top:0; } +.atlas-card .atlas-enter:hover { background:var(--geist-primary-hover); } +.atlas-card .atlas-observation { font-size:12px; margin-top:16px; } +.atlas-card > button { overflow-wrap:anywhere; max-width:100%; } +.atlas-card .atlas-retry { display:block; width:auto; margin-top:0; } +.atlas-footer { padding:8px 24px; color:var(--geist-secondary); border-top:1px solid var(--geist-border); font-size:12px; } +.atlas-tabs { display:none; } +.explorer-atlas-back { color:var(--geist-interactive); font-size:13px; text-decoration:underline; text-underline-offset:3px; min-height:44px; display:flex; align-items:center; } +.explorer-visit-status { font-size:12px; color:var(--geist-secondary); padding:8px 24px; background:var(--geist-bg); border-bottom:1px solid var(--geist-border); } +@media(max-width:1180px) and (min-width:1024px) { .atlas-body { grid-template-columns:220px minmax(0,1fr) 280px; } .atlas-list { padding:12px; } .atlas-card { padding:16px; } } +@media(max-width:1023px) { .atlas-body { grid-template-columns:220px minmax(0,1fr); grid-template-rows:minmax(180px,1fr) auto; } .atlas-list { grid-row:1/3; } .atlas-card { grid-column:2; max-height:35dvh; border-top:1px solid var(--geist-border); } .atlas-header { padding-inline:16px; } .atlas-intro { padding-inline:16px; } .atlas-header h1 { font-size:20px; } } +@media(max-width:600px) { .atlas-shell { min-height:680px; } .atlas-header { gap:8px 16px; } .atlas-header .brand strong { font-size:18px; } .atlas-header h1 { font-size:20px; } .atlas-header > a:last-child { margin-left:0; font-size:13px; } .atlas-intro { padding:8px 12px; } .atlas-intro p { font-size:13px; } .atlas-intro p+p { font-size:12px; } .atlas-tabs { display:flex; gap:8px; padding:8px 12px; } .atlas-tabs button { flex:1; } .atlas-tabs button[aria-pressed=true] { box-shadow:inset 0 -3px var(--geist-fg); background:var(--geist-selected); } .atlas-body { display:flex; flex-direction:column; min-height:360px; } .atlas-map { flex:1; min-height:180px; } .atlas-list { flex:1; min-height:180px; border:0; max-height:55dvh; } .atlas-list-rows { min-height:100px; } .atlas-pane-map .atlas-list { display:none; } .atlas-pane-list .atlas-map { display:none; } .atlas-card { flex:0 1 auto; max-height:35dvh; padding:14px 16px; border-left:0; } .atlas-card h2 { font-size:20px; } .atlas-card > p { font-size:14px; } .atlas-shell input,.atlas-inspect input { font-size:16px; } .atlas-footer { padding-inline:12px; } .explorer-visit-status { padding-inline:12px; } } diff --git a/src/atlas/camera.ts b/src/atlas/camera.ts new file mode 100644 index 0000000..a6a8bf7 --- /dev/null +++ b/src/atlas/camera.ts @@ -0,0 +1,39 @@ +export interface Point { + x: number; + y: number; +} +export interface Size { + width: number; + height: number; +} +export interface Bounds { + left: number; + top: number; + right: number; + bottom: number; +} +export interface Camera { + centerX: number; + centerY: number; + zoom: number; +} +export const SCENE: Bounds = { left: 0, top: 0, right: 8192, bottom: 6144 }; +const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n)); +export function fit(bounds: Bounds, size: Size, padding = 24) { + const zoom = Math.min((Math.max(1, size.width) - Math.min(padding * 2, size.width / 2)) / Math.max(1, bounds.right - bounds.left), (Math.max(1, size.height) - Math.min(padding * 2, size.height / 2)) / Math.max(1, bounds.bottom - bounds.top)) * .88; + return { centerX: (bounds.left + bounds.right) / 2, centerY: (bounds.top + bounds.bottom) / 2, zoom: clamp(zoom, .025, 2.5) }; +} +export function constrain(camera: Camera, size: Size, bounds = SCENE): Camera { + const minimum = Math.max(.025, fit(bounds, size).zoom * .85), zoom = clamp(Number.isFinite(camera.zoom) ? camera.zoom : minimum, minimum, 2.5); + const axis = (n: number, start: number, end: number, span: number) => { const a = start + span / (2 * zoom), b = end - span / (2 * zoom); return a > b ? (start + end) / 2 : clamp(Number.isFinite(n) ? n : (start + end) / 2, a, b); }; + return { centerX: axis(camera.centerX, bounds.left, bounds.right, size.width), centerY: axis(camera.centerY, bounds.top, bounds.bottom, size.height), zoom }; +} +export const project = (p: Point, c: Camera, s: Size): Point => ({ x: s.width / 2 + (p.x - c.centerX) * c.zoom, y: s.height / 2 + (p.y - c.centerY) * c.zoom }); +export const unproject = (p: Point, c: Camera, s: Size): Point => ({ x: c.centerX + (p.x - s.width / 2) / c.zoom, y: c.centerY + (p.y - s.height / 2) / c.zoom }); +export function pan(c: Camera, dx: number, dy: number, s: Size) { return constrain({ ...c, centerX: c.centerX - dx / c.zoom, centerY: c.centerY - dy / c.zoom }, s); } +export function zoomAt(c: Camera, factor: number, point: Point, s: Size) { + const before = unproject(point, c, s), bounded = constrain({ ...c, zoom: c.zoom * factor }, s), after = unproject(point, bounded, s); + return constrain({ ...bounded, centerX: bounded.centerX + before.x - after.x, centerY: bounded.centerY + before.y - after.y }, s); +} +export function resizeCamera(c: Camera, old: Size, next: Size, home: Bounds) { return constrain({ ...c, zoom: fit(home, next).zoom * c.zoom / fit(home, old).zoom }, next); } +export const wheelFactor = (delta: number, mode: number, height: number) => Math.exp(-clamp(delta * (mode === 1 ? 16 : mode === 2 ? height : 1), -240, 240) * .0015); diff --git a/src/atlas/loader.ts b/src/atlas/loader.ts new file mode 100644 index 0000000..db28a20 --- /dev/null +++ b/src/atlas/loader.ts @@ -0,0 +1,22 @@ +/** At most two bounded cache reads and one replaceable latest selection. No + * polling, retry clock or request from hover/paint. Late results cannot win. */ +export function selectionLoader(read: (key: string) => Promise) { + let generation = 0, active = 0, pending: { + key: string; + generation: number; + accept: (value: T) => void; + reject: (error: unknown) => void; + } | null = null; + const start = (task: NonNullable) => { active++; void read(task.key).then(value => { if (task.generation === generation) + task.accept(value); }, error => { if (task.generation === generation) + task.reject(error); }).finally(() => { active--; if (pending) { + const next = pending; + pending = null; + if (next.generation === generation) + start(next); + } }); }; + return { select(key: string, accept: (value: T) => void, reject: (error: unknown) => void) { const task = { key, generation: ++generation, accept, reject }; if (active < 2) + start(task); + else + pending = task; }, cancel() { generation++; pending = null; } }; +} diff --git a/src/atlas/model.ts b/src/atlas/model.ts new file mode 100644 index 0000000..b64845f --- /dev/null +++ b/src/atlas/model.ts @@ -0,0 +1,99 @@ +import { CURATED_PROJECTS } from "../catalog"; +import type { FeaturedProject, StyleId, WorldSnapshot } from "../contracts"; +import { cachedWorldReady, worldIdentity } from "../evidence/captured"; +import { codePointCompare, type WorldIdentity } from "../evidence/contracts"; +import { project, type Bounds, type Camera, type Size } from "./camera"; +export interface Landmark { + repoKey: string; + fullName: string; + title: string; + category: string; + style: StyleId; + x: number; + y: number; +} +export interface CachedFacts { + identity: WorldIdentity; + fullName: string; + description: string; + sourceUrl: string; + tree: string; + github: string; + nodes: number; + edges: number; + refreshAfter: number; + license: string | null; +} +export type CacheStatus = { + state: "unchecked" | "loading" | "miss" | "unavailable"; + lastReceived?: CachedFacts; +} | { + state: "cached"; + facts: CachedFacts; +}; +export function factsFromWorld(w: WorldSnapshot, expectedKey?: string, now = Date.now()): CachedFacts | null { + const identity = worldIdentity(w, now); + if (!identity || !cachedWorldReady(w, now) || expectedKey && identity.repoKey !== expectedKey) + return null; + return { identity, fullName: w.repo.fullName, description: w.repo.description, sourceUrl: w.repo.url, tree: w.coverage.tree, github: w.health.github, nodes: w.nodes.length, edges: w.edges.length, refreshAfter: w.refreshAfter, license: w.repo.license }; +} +export function statusText(status?: CacheStatus) { return !status || status.state === "unchecked" ? "Cached status not checked" : status.state === "loading" ? "Loading cached facts" : status.state === "miss" ? "Not collected" : status.state === "unavailable" ? "Cached facts unavailable" : "Cached capture"; } +export function hash(value: string) { let h = 2166136261; for (const ch of value) + h = Math.imul(h ^ ch.codePointAt(0)!, 16777619); return h >>> 0; } +// Immutable v1 editorial slots. Response order, captures and GitHub stars never +// determine placement. A catalog slot change requires a new layout version. +const slots = new Map(CURATED_PROJECTS.map((p, i) => [p.fullName.toLowerCase(), i])); +const home = [[650, 650], [1400, 750], [950, 1400], [1850, 1400], [2150, 500]]; +export function layoutLandmarks(projects: readonly FeaturedProject[], imports: readonly string[]): Landmark[] { + const byKey = new Map(); + for (const p of projects) + if (/^[a-z0-9-]+\/[a-z0-9_.-]+$/i.test(p.fullName)) + byKey.set(p.fullName.toLowerCase(), p); + for (const key of imports.slice(0, 100)) + if (!byKey.has(key)) + byKey.set(key, { fullName: key, name: key.split("/")[1], description: "", category: "Survey region", suggestedStyle: "moonshot", language: null, stars: null, ready: false, collectedAt: null }); + return [...byKey].map(([key, p]) => { + const slot = slots.get(key), importSlot = imports.indexOf(key), h = hash(key); + const point = slot !== undefined ? slot < 5 ? home[slot] : [3200 + (slot - 5) % 5 * 850, 650 + Math.floor((slot - 5) / 5) * 1350] : importSlot >= 0 ? [500 + (importSlot % 10) * 760, 4700 + Math.floor(importSlot / 10) * 115] : [500 + h % 7200, 500 + (hash(key + ":y") % 5000)]; + return { repoKey: key, fullName: p.fullName, title: p.name, category: p.category, style: p.suggestedStyle, x: point[0], y: point[1] }; + }).sort((a, b) => a.y - b.y || codePointCompare(a.repoKey, b.repoKey)); +} +export function homeBounds(points: Landmark[]): Bounds { + const known = points.filter(p => (slots.get(p.repoKey) ?? 1000) < 5); + const group = known.length ? known : points.slice(0, 5); + return group.length ? { left: Math.min(...group.map(p => p.x)) - 160, top: Math.min(...group.map(p => p.y)) - 160, right: Math.max(...group.map(p => p.x)) + 160, bottom: Math.max(...group.map(p => p.y)) + 160 } : { left: 500, top: 400, right: 2200, bottom: 1800 }; +} +export interface ProjectedLandmark { + x: number; + y: number; + keys: string[]; + landmark?: Landmark; +} +export function visibleGroups(points: Landmark[], camera: Camera, size: Size, selected: string | null): ProjectedLandmark[] { + const visible = points.map(p => ({ ...project(p, camera, size), landmark: p })).filter(p => p.x >= -128 && p.x <= size.width + 128 && p.y >= -128 && p.y <= size.height + 128); + const cap = size.width < 600 ? 40 : 80, clusterCap = size.width < 600 ? 16 : 32; + let cell = 2 ** Math.ceil(Math.log2((size.width < 600 ? 72 : 64) / camera.zoom)); + for (let level = 0; level < 12; level++, cell *= 2) { + const bins = new Map(); + const singles: ProjectedLandmark[] = []; + for (const p of visible) { + if (p.landmark.repoKey === selected) { + singles.push({ ...p, keys: [selected] }); + continue; + } + const key = `${Math.floor(p.landmark.x / cell)}:${Math.floor(p.landmark.y / cell)}`; + const bin = bins.get(key) ?? []; + bin.push(p); + bins.set(key, bin); + } + const clusters: ProjectedLandmark[] = []; + for (const bin of bins.values()) + if (bin.length >= 3) + clusters.push({ x: bin.reduce((n, p) => n + p.x, 0) / bin.length, y: bin.reduce((n, p) => n + p.y, 0) / bin.length, keys: bin.map(p => p.landmark.repoKey).sort(codePointCompare) }); + else + singles.push(...bin.map(p => ({ ...p, keys: [p.landmark.repoKey] }))); + if (singles.length <= cap && clusters.length <= clusterCap || level === 11) + return [...clusters, ...singles].sort((a, b) => a.y - b.y || codePointCompare(a.keys[0], b.keys[0])); + } + return []; +} diff --git a/src/atlas/paint.ts b/src/atlas/paint.ts new file mode 100644 index 0000000..03d64be --- /dev/null +++ b/src/atlas/paint.ts @@ -0,0 +1,190 @@ +import { MAP_CONTRAST as C } from "../world/paint"; +import { hash, visibleGroups, type CacheStatus, type Landmark } from "./model"; +import type { Camera, Size } from "./camera"; +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} +export interface HitTarget { + keys: string[]; + rects: Rect[]; + x: number; + y: number; +} +export const contains = (r: Rect, x: number, y: number) => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height; +const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width + 8 && a.x + a.width + 8 > b.x && a.y < b.y + b.height + 8 && a.y + a.height + 8 > b.y; +const polygon = (g: CanvasRenderingContext2D, points: number[][], fill: string, stroke?: string) => { g.beginPath(); points.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); g.closePath(); g.fillStyle = fill; g.fill(); if (stroke) { + g.strokeStyle = stroke; + g.lineWidth = 1.5; + g.stroke(); +} }; +function sculpture(g: CanvasRenderingContext2D, x: number, y: number, size: number, variant: number) { + g.save(); + g.translate(x, y); + g.scale(size / 64, size / 64); + g.fillStyle = C.surface; + g.beginPath(); + g.ellipse(0, 14, 40, 16, 0, 0, Math.PI * 2); + g.fill(); + polygon(g, [[-35, 3], [0, -8], [35, 3], [35, 14], [0, 25], [-35, 14]], C.border, C.edge); + polygon(g, [[-35, 3], [0, -8], [35, 3], [0, 14]], C.muted, C.edge); + if (variant === 0) { + polygon(g, [[-23, 0], [0, -9], [23, 0], [23, -13], [0, -22], [-23, -13]], C.surface, C.edge); + g.beginPath(); + g.ellipse(0, -17, 17, 14, 0, Math.PI, 0); + g.lineTo(17, -13); + g.lineTo(-17, -13); + g.closePath(); + g.fillStyle = C.muted; + g.fill(); + g.strokeStyle = C.edge; + g.stroke(); + } + else if (variant === 1) { + for (const [xx, hh] of [[-18, 34], [15, 45]]) { + polygon(g, [[xx - 7, 6], [xx + 7, 1], [xx + 7, -hh], [xx - 7, -hh + 5]], C.surface, C.edge); + polygon(g, [[xx - 10, -hh + 4], [xx, -hh - 2], [xx + 10, -hh - 6], [xx + 2, -hh]], C.muted, C.edge); + } + g.strokeStyle = C.edge; + g.lineWidth = 5; + g.beginPath(); + g.arc(-1, -10, 17, Math.PI, 0); + g.stroke(); + } + else + for (let i = 0; i < 3; i++) { + g.beginPath(); + g.ellipse(0, -i * 9, 28 - i * 6, 11 - i * 2, 0, 0, Math.PI * 2); + g.fillStyle = i % 2 ? C.surface : C.muted; + g.fill(); + g.strokeStyle = C.edge; + g.stroke(); + } + g.restore(); +} +/** Original vector scenery. No textures, tile cache, physics or cross-repo graph. */ +export function paintAtlas(g: CanvasRenderingContext2D, points: Landmark[], camera: Camera, size: Size, statuses: Map, selected: string | null, hover: string | null, lit: ReadonlySet = new Set()) { + g.fillStyle = C.bg; + g.fillRect(0, 0, size.width, size.height); + g.save(); + g.translate(size.width / 2 - camera.centerX * camera.zoom, size.height / 2 - camera.centerY * camera.zoom); + g.scale(camera.zoom, camera.zoom); + for (let i = 0; i < 14; i++) { + const x = 170 + (i % 5) * 1720, y = 340 + Math.floor(i / 5) * 1830, skew = (i % 3) * 140; + polygon(g, [[x, y], [x + 1100, y - 180 - skew], [x + 1510, y + 340], [x + 1050, y + 640], [x - 120, y + 470]], C.surface); + g.beginPath(); + g.moveTo(x - 120, y + 470); + g.bezierCurveTo(x + 350, y + 680, x + 1100, y + 570, x + 1510, y + 340); + g.strokeStyle = C.border; + g.globalAlpha = .35; + g.lineWidth = 2 / camera.zoom; + g.stroke(); + g.globalAlpha = 1; + } + g.beginPath(); + g.moveTo(550, 1200); + g.bezierCurveTo(2000, 2200, 2350, 400, 4100, 1200); + g.bezierCurveTo(6000, 2300, 3700, 3700, 6700, 4900); + g.setLineDash([9 / camera.zoom, 12 / camera.zoom]); + g.strokeStyle = C.border; + g.lineWidth = 1.5 / camera.zoom; + g.stroke(); + g.setLineDash([]); + g.restore(); + const groups = visibleGroups(points, camera, size, selected), targets: HitTarget[] = [], labels: { + group: typeof groups[number]; + rect: Rect; + text: string; + }[] = [], accepted: Rect[] = []; + const maxLabels = size.width < 600 ? 10 : 24; + const byPriority = [...groups].sort((a, b) => Number(b.keys.includes(selected ?? "")) - Number(a.keys.includes(selected ?? "")) || Number(b.keys.includes(hover ?? "")) - Number(a.keys.includes(hover ?? ""))); + for (const group of groups) { + const active = group.keys.length === 1 && group.keys[0] === selected, cached = group.landmark && statuses.get(group.landmark.repoKey)?.state === "cached", extent = camera.zoom < .12 ? 36 : camera.zoom > .6 ? 72 : 54; + const { x, y } = group; + if (group.keys.length > 1) { + g.fillStyle = C.surface; + g.strokeStyle = C.border; + g.lineWidth = 2; + g.beginPath(); + g.roundRect(x - 27, y - 26, 54, 52, 8); + g.fill(); + g.stroke(); + g.fillStyle = C.text; + g.font = '600 14px ui-monospace,monospace'; + g.textAlign = "center"; + g.textBaseline = "middle"; + g.fillText(String(group.keys.length), x, y); + } + else if (cached) + sculpture(g, x, y, extent, hash(group.keys[0]) % 3); + else { + g.strokeStyle = C.border; + g.lineWidth = 2; + g.setLineDash([5, 4]); + g.beginPath(); + g.ellipse(x, y, extent * .5, extent * .32, 0, 0, Math.PI * 2); + g.stroke(); + g.setLineDash([]); + g.fillStyle = C.surface; + g.fillRect(x - 10, y - 14, 20, 25); + g.fillStyle = C.text; + g.font = '500 18px ui-monospace,monospace'; + g.textBaseline = "middle"; + g.textAlign = "center"; + g.fillText("?", x, y + 5); + } + if (active || group.keys.includes(hover ?? "")) { + g.beginPath(); + g.ellipse(x, y + 8, extent * .67, extent * .42, 0, 0, Math.PI * 2); + g.strokeStyle = C.surface; + g.lineWidth = 6; + g.stroke(); + g.strokeStyle = active ? C.focus : C.text; + g.lineWidth = 2; + g.stroke(); + } + if (group.keys.some(key => lit.has(key))) { + g.strokeStyle = C.focus; g.fillStyle = C.surface; g.lineWidth = 2; + g.beginPath(); g.arc(x + extent * .42, y - extent * .5, 12, 0, Math.PI * 2); g.fill(); g.stroke(); + g.fillStyle = C.text; g.font = '600 16px ui-monospace,monospace'; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText("✧", x + extent * .42, y - extent * .5); + } + targets.push({ keys: group.keys, x, y, rects: [{ x: x - Math.max(22, extent * .65), y: y - Math.max(22, extent * .8), width: Math.max(44, extent * 1.3), height: Math.max(44, extent * 1.15) }] }); + } + for (const group of byPriority) { + if (labels.length >= maxLabels && !group.keys.includes(selected ?? "")) + continue; + let text = group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`; + g.font = `${group.keys.includes(selected ?? "") ? 600 : 500} ${group.keys.includes(selected ?? "") ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`; + const maxWidth = Math.min(group.keys.includes(selected ?? "") ? 260 : 180, size.width - 24); + while (text.length > 1 && g.measureText(text + "…").width > maxWidth) + text = text.slice(0, -1); + if (text !== (group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`)) + text += "…"; + const width = g.measureText(text).width + 16, height = 30; + const candidates = [{ x: group.x - width / 2, y: group.y + 42, width, height }, { x: group.x - width / 2, y: group.y - 76, width, height }, { x: group.x + 48, y: group.y - 16, width, height }, { x: group.x - width - 48, y: group.y - 16, width, height }]; + const rect = candidates.find(r => r.x >= 8 && r.x + r.width <= size.width - 8 && r.y >= 8 && r.y + r.height <= size.height - 64 && !accepted.some(a => overlaps(r, a)) && !(r.x + r.width > size.width - 64 && r.y < 230)); + if (rect) { + accepted.push(rect); + labels.push({ group, rect, text }); + targets.find(t => t.keys === group.keys)?.rects.push(rect); + } + } + for (const { group, rect, text } of labels) { + const active = group.keys.includes(selected ?? ""); + g.font = `${active ? 600 : 500} ${active ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`; + g.fillStyle = active ? C.labelSelectedPlate : C.labelPlate; + g.strokeStyle = active ? C.labelSelectedBorder : C.border; + g.lineWidth = active ? 2 : 1; + g.beginPath(); + g.roundRect(rect.x, rect.y, rect.width, rect.height, 3); + g.fill(); + g.stroke(); + g.fillStyle = active ? C.labelSelectedText : C.labelText; + g.textAlign = "center"; + g.textBaseline = "middle"; + g.fillText(text, rect.x + rect.width / 2, rect.y + 15); + } + return targets; +} diff --git a/src/atlas/session.ts b/src/atlas/session.ts new file mode 100644 index 0000000..05d5ff0 --- /dev/null +++ b/src/atlas/session.ts @@ -0,0 +1,48 @@ +import { appPath } from "../base-path"; +export const LAYOUT_VERSION = 1; +export interface AtlasSession { + layoutVersion: 1; + centerX: number; + centerY: number; + zoomRatio: number; + selectedRepoKey: string | null; + focusOrigin: "list" | "canvas"; + listScroll: number; + importedRepoKeys: string[]; + returnToken: string | null; +} +const KEY = "moonfactory.atlas.session.v1", validKey = (x: unknown): x is string => typeof x === "string" && /^[a-z0-9-]+\/[a-z0-9_.-]+$/.test(x) && x.length <= 140; +let memory: AtlasSession | null = null; +export function validateSession(x: any): x is AtlasSession { return !!x && x.layoutVersion === LAYOUT_VERSION && [x.centerX, x.centerY, x.zoomRatio, x.listScroll].every(Number.isFinite) && x.zoomRatio > 0 && x.zoomRatio <= 100 && x.listScroll >= 0 && (x.selectedRepoKey === null || validKey(x.selectedRepoKey)) && ["list", "canvas"].includes(x.focusOrigin) && Array.isArray(x.importedRepoKeys) && x.importedRepoKeys.length <= 100 && x.importedRepoKeys.every(validKey) && new Set(x.importedRepoKeys).size === x.importedRepoKeys.length && (x.returnToken === null || typeof x.returnToken === "string" && /^[a-f0-9-]{36}$/.test(x.returnToken)); } +export function readAtlasSession(storage?: Pick) { + try { + const raw = (storage ?? window.sessionStorage).getItem(KEY); + if (raw && new TextEncoder().encode(raw).length > 32768) + throw Error(); + const value = raw ? JSON.parse(raw) : null; + if (value && !validateSession(value)) + throw Error(); + memory = value; + return { value: value as AtlasSession | null, saved: true }; + } + catch { + return { value: memory, saved: false }; + } +} +export function saveAtlasSession(value: AtlasSession, storage?: Pick) { + if (!validateSession(value)) + return false; + memory = structuredClone(value); + const text = JSON.stringify(value); + if (new TextEncoder().encode(text).length > 32768) + return false; + try { + (storage ?? window.sessionStorage).setItem(KEY, text); + return true; + } + catch { + return false; + } +} +export function validatedReturnToken(search: string, session: AtlasSession | null) { const q = new URLSearchParams(search); const token = q.get("atlasReturn"); return q.getAll("mode").length === 1 && q.get("mode") === "cached" && q.getAll("from").length === 1 && q.get("from") === "atlas" && q.getAll("atlasReturn").length === 1 && token === session?.returnToken ? token : null; } +export function atlasBackHref(search: string, session: AtlasSession | null) { const token = validatedReturnToken(search, session); return appPath(token ? `/atlas?atlasReturn=${encodeURIComponent(token)}` : "/atlas?home=1"); } diff --git a/src/client/api.ts b/src/client/api.ts index 703afff..daf90a4 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -8,18 +8,18 @@ export async function request(url: string, options?: RequestInit): Promise } catch { throw new Error("The server returned an unreadable response. Please try again."); } - if (!r.ok) throw new Error((result as { error?: string })?.error || `Request failed (${r.status}).`); + if (!r.ok) throw Object.assign(new Error((result as { error?: string })?.error || `Request failed (${r.status}).`), { status:r.status, code:(result as {code?:string})?.code }); return result as T; } export const getGallery = () => request<{ projects: FeaturedProject[] }>("/api/gallery"); export const createWorld = (repo: string) => request("/api/worlds", { method: "POST", body: JSON.stringify({ repo, enrich: false }) }); const pendingWorlds = new Map>(); -export function getWorld(owner: string, repo: string): Promise { - const url = `/api/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; +function worldRequest(owner: string, repo: string, cached: boolean): Promise { + const url = `/api/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${cached ? "?mode=cached" : ""}`; const pending = pendingWorlds.get(url); if (pending) return pending; - const requestWorld = request(url); + const requestWorld = request(url,cached ? {signal:AbortSignal.timeout(8000)} : undefined); pendingWorlds.set(url, requestWorld); const clear = () => { if (pendingWorlds.get(url) === requestWorld) pendingWorlds.delete(url); @@ -27,6 +27,8 @@ export function getWorld(owner: string, repo: string): Promise { void requestWorld.then(clear, clear); return requestWorld; } +export const getWorld = (owner:string,repo:string) => worldRequest(owner,repo,false); +export const getCachedWorld = (owner:string,repo:string) => worldRequest(owner,repo,true); export const refreshWorld = (owner: string, repo: string) => request(`/api/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { method: "POST", diff --git a/src/components/ExplorerPage.tsx b/src/components/ExplorerPage.tsx index 52ed2e5..7240949 100644 --- a/src/components/ExplorerPage.tsx +++ b/src/components/ExplorerPage.tsx @@ -1,5 +1,13 @@ +import { Link } from "@tanstack/react-router"; +import { cachedWorldReady, worldIdentity } from "../evidence/captured"; +import { readAtlasSession, validatedReturnToken } from "../atlas/session"; +import { noteExploration, subscribeExploration, explorationFlags, useProgress } from "../progression/session"; +import "../atlas/atlas.css"; +import { flagSaved } from "../progression/progress-store"; +import { HistoryReplay } from "./HistoryReplay"; +import { SourceChallenge } from "./SourceChallenge"; import { appPath } from "../base-path"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Activity, ArrowLeft, @@ -35,7 +43,7 @@ import { import type { BudgetStatus, StyleId, WorldNode, WorldSnapshot } from "../contracts"; import { WorldCanvas } from "../world/WorldCanvas"; import { STYLES } from "../client/demo"; -import { clock, enrichWorld, getBudget, getWorld, refreshWorld, shortNumber } from "../client/api"; +import { clock, enrichWorld, getBudget, getCachedWorld, getWorld, refreshWorld, shortNumber } from "../client/api"; import { Brand, External } from "./shared"; /** Keep source text intact while offering natural identifier wrapping points. */ function identifierBreaks(value: string) { @@ -62,10 +70,19 @@ function ciSummary(w: WorldSnapshot) { export function ExplorerPage({ owner, repo, + readMode = "legacy", }: { owner: string; repo: string; + readMode?: "legacy" | "cached" | "invalid"; }) { + const progress = useProgress(); + const [atlasToken,setAtlasToken] = useState(null), [,setProgressRevision] = useState(0); + const loadGeneration = useRef(0); + const cachedOnly = readMode === "cached"; + const [sourceVerified,setSourceVerified] = useState(false); + const [showReplay,setShowReplay] = useState(false); + const [motionPaused,setMotionPaused] = useState(false), [challengeOpen,setChallengeOpen] = useState(false); const [world, setWorld] = useState(null), [error, setError] = useState(null), [refreshing, setRefreshing] = useState(false), @@ -83,41 +100,47 @@ export function ExplorerPage({ }, []); const load = useCallback( async (refresh = false) => { + const generation = ++loadGeneration.current; if (refresh) setRefreshing(true); try { - const next = await (refresh ? refreshWorld(owner, repo) : getWorld(owner, repo)); + if (readMode === "invalid") throw new Error("Invalid exploration mode. Return to Atlas to choose a cached world."); + const next = await (cachedOnly ? getCachedWorld(owner, repo) : refresh ? refreshWorld(owner, repo) : getWorld(owner, repo)); + if (generation !== loadGeneration.current) return; + if (cachedOnly && !cachedWorldReady(next)) throw new Error("This cached capture cannot be verified for exploration."); setWorld(next); + setSourceVerified(true); setError(null); } catch (e) { - setError(e instanceof Error ? e.message : "The repository could not be loaded."); + if (generation === loadGeneration.current) { setSourceVerified(false); setError(e instanceof Error ? e.message : "The repository could not be loaded."); } } finally { - setRefreshing(false); + if (generation === loadGeneration.current) setRefreshing(false); } }, - [owner, repo], + [owner, repo, readMode, cachedOnly], ); useEffect(() => { let active = true; - getWorld(owner, repo) - .then((w) => { - if (active) setWorld(w); - }) - .catch((e) => { - if (active) setError(e.message); - }); - getBudget() + setWorld(null); setSelected(null); setSourceVerified(false); setShowReplay(false); + void load(); + if (!cachedOnly && readMode !== "invalid") getBudget() .then((b) => { if (active) setBudget(b); }) .catch(() => {}); - const timer = setInterval(() => { + const timer = readMode === "legacy" ? setInterval(() => { if (active && document.visibilityState === "visible") void load(); - }, 60000); + }, 60000) : undefined; return () => { active = false; - clearInterval(timer); + ++loadGeneration.current; + if (timer) clearInterval(timer); }; - }, [owner, repo, load]); + }, [owner, repo, load, cachedOnly, readMode]); + useEffect(() => { + if (!cachedOnly) return; + setAtlasToken(validatedReturnToken(window.location.search,readAtlasSession().value)); + return subscribeExploration(()=>setProgressRevision(x=>x+1)); + },[cachedOnly]); const changeStyle = (s: StyleId) => { setStyle(s); const url = new URL(window.location.href); @@ -149,7 +172,9 @@ export function ExplorerPage({ stale = world ? Date.now() > world.refreshAfter : false; const share = async () => { try { - await navigator.clipboard.writeText(window.location.href); + const shareUrl = new URL(window.location.href); + shareUrl.searchParams.delete("atlasReturn"); shareUrl.searchParams.delete("from"); + await navigator.clipboard.writeText(shareUrl.href); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { @@ -161,6 +186,8 @@ export function ExplorerPage({
+ {cachedOnly && } + {cachedOnly && Back to Atlas} / @@ -178,6 +205,7 @@ export function ExplorerPage({
+ {cachedOnly && world &&
{explorationFlags(world.repo.fullName.toLowerCase()).visited ? (flagSaved(progress, world.repo.fullName.toLowerCase(), "visited") ? "Visited on this device" : "Visited this session — not saved") : "Rendering cached world"} · No Exploration star awarded for entry.
} {!world ? (
{error ? ( @@ -320,7 +348,8 @@ export function ExplorerPage({
{tab === "world" ? ( <> - + { const identity = worldIdentity(world); if (cachedOnly && identity) noteExploration(identity,"visited"); }} /> + {showReplay && cachedOnly && }
{styleInfo.name.toUpperCase()} / REPOSITORY WORLD

@@ -530,7 +559,8 @@ export function ExplorerPage({ ))}

-